mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-18 22:04:45 +08:00
refactor: add unified endpoint normalization infrastructure
Introduce endpoint.go with shared constants, NormalizeInboundEndpoint, DeriveUpstreamEndpoint, InboundEndpointMiddleware, and context helpers. This replaces the two separate normalization implementations (OpenAI and Gateway) with a single source of truth. Includes comprehensive test coverage. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
174
backend/internal/handler/endpoint.go
Normal file
174
backend/internal/handler/endpoint.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
// Canonical inbound / upstream endpoint paths.
|
||||||
|
// All normalization and derivation reference this single set
|
||||||
|
// of constants — add new paths HERE when a new API surface
|
||||||
|
// is introduced.
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const (
|
||||||
|
EndpointMessages = "/v1/messages"
|
||||||
|
EndpointChatCompletions = "/v1/chat/completions"
|
||||||
|
EndpointResponses = "/v1/responses"
|
||||||
|
EndpointGeminiModels = "/v1beta/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gin.Context keys used by the middleware and helpers below.
|
||||||
|
const (
|
||||||
|
ctxKeyInboundEndpoint = "_gateway_inbound_endpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
// Normalization functions
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// NormalizeInboundEndpoint maps a raw request path (which may carry
|
||||||
|
// prefixes like /antigravity, /openai, /sora) to its canonical form.
|
||||||
|
//
|
||||||
|
// "/antigravity/v1/messages" → "/v1/messages"
|
||||||
|
// "/v1/chat/completions" → "/v1/chat/completions"
|
||||||
|
// "/openai/v1/responses/foo" → "/v1/responses"
|
||||||
|
// "/v1beta/models/gemini:gen" → "/v1beta/models"
|
||||||
|
func NormalizeInboundEndpoint(path string) string {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(path, EndpointChatCompletions):
|
||||||
|
return EndpointChatCompletions
|
||||||
|
case strings.Contains(path, EndpointMessages):
|
||||||
|
return EndpointMessages
|
||||||
|
case strings.Contains(path, EndpointResponses):
|
||||||
|
return EndpointResponses
|
||||||
|
case strings.Contains(path, EndpointGeminiModels):
|
||||||
|
return EndpointGeminiModels
|
||||||
|
default:
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeriveUpstreamEndpoint determines the upstream endpoint from the
|
||||||
|
// account platform and the normalized inbound endpoint.
|
||||||
|
//
|
||||||
|
// Platform-specific rules:
|
||||||
|
// - OpenAI always forwards to /v1/responses (with optional subpath
|
||||||
|
// such as /v1/responses/compact preserved from the raw URL).
|
||||||
|
// - Anthropic → /v1/messages
|
||||||
|
// - Gemini → /v1beta/models
|
||||||
|
// - Sora → /v1/chat/completions
|
||||||
|
// - Antigravity routes may target either Claude or Gemini, so the
|
||||||
|
// inbound endpoint is used to distinguish.
|
||||||
|
func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string {
|
||||||
|
inbound = strings.TrimSpace(inbound)
|
||||||
|
|
||||||
|
switch platform {
|
||||||
|
case service.PlatformOpenAI:
|
||||||
|
// OpenAI forwards everything to the Responses API.
|
||||||
|
// Preserve subresource suffix (e.g. /v1/responses/compact).
|
||||||
|
if suffix := responsesSubpathSuffix(rawRequestPath); suffix != "" {
|
||||||
|
return EndpointResponses + suffix
|
||||||
|
}
|
||||||
|
return EndpointResponses
|
||||||
|
|
||||||
|
case service.PlatformAnthropic:
|
||||||
|
return EndpointMessages
|
||||||
|
|
||||||
|
case service.PlatformGemini:
|
||||||
|
return EndpointGeminiModels
|
||||||
|
|
||||||
|
case service.PlatformSora:
|
||||||
|
return EndpointChatCompletions
|
||||||
|
|
||||||
|
case service.PlatformAntigravity:
|
||||||
|
// Antigravity accounts serve both Claude and Gemini.
|
||||||
|
if inbound == EndpointGeminiModels {
|
||||||
|
return EndpointGeminiModels
|
||||||
|
}
|
||||||
|
return EndpointMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown platform — fall back to inbound.
|
||||||
|
return inbound
|
||||||
|
}
|
||||||
|
|
||||||
|
// responsesSubpathSuffix extracts the part after "/responses" in a raw
|
||||||
|
// request path, e.g. "/openai/v1/responses/compact" → "/compact".
|
||||||
|
// Returns "" when there is no meaningful suffix.
|
||||||
|
func responsesSubpathSuffix(rawPath string) string {
|
||||||
|
trimmed := strings.TrimRight(strings.TrimSpace(rawPath), "/")
|
||||||
|
idx := strings.LastIndex(trimmed, "/responses")
|
||||||
|
if idx < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
suffix := trimmed[idx+len("/responses"):]
|
||||||
|
if suffix == "" || suffix == "/" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(suffix, "/") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
// Middleware
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// InboundEndpointMiddleware normalizes the request path and stores the
|
||||||
|
// canonical inbound endpoint in gin.Context so that every handler in
|
||||||
|
// the chain can read it via GetInboundEndpoint.
|
||||||
|
//
|
||||||
|
// Apply this middleware to all gateway route groups.
|
||||||
|
func InboundEndpointMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
path := c.FullPath()
|
||||||
|
if path == "" && c.Request != nil && c.Request.URL != nil {
|
||||||
|
path = c.Request.URL.Path
|
||||||
|
}
|
||||||
|
c.Set(ctxKeyInboundEndpoint, NormalizeInboundEndpoint(path))
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
// Context helpers — used by handlers before building
|
||||||
|
// RecordUsageInput / RecordUsageLongContextInput.
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GetInboundEndpoint returns the canonical inbound endpoint stored by
|
||||||
|
// InboundEndpointMiddleware. If the middleware did not run (e.g. in
|
||||||
|
// tests), it falls back to normalizing c.FullPath() on the fly.
|
||||||
|
func GetInboundEndpoint(c *gin.Context) string {
|
||||||
|
if v, ok := c.Get(ctxKeyInboundEndpoint); ok {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: normalize on the fly.
|
||||||
|
path := ""
|
||||||
|
if c != nil {
|
||||||
|
path = c.FullPath()
|
||||||
|
if path == "" && c.Request != nil && c.Request.URL != nil {
|
||||||
|
path = c.Request.URL.Path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NormalizeInboundEndpoint(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUpstreamEndpoint derives the upstream endpoint from the context
|
||||||
|
// and the account platform. Handlers call this after scheduling an
|
||||||
|
// account, passing account.Platform.
|
||||||
|
func GetUpstreamEndpoint(c *gin.Context, platform string) string {
|
||||||
|
inbound := GetInboundEndpoint(c)
|
||||||
|
rawPath := ""
|
||||||
|
if c != nil && c.Request != nil && c.Request.URL != nil {
|
||||||
|
rawPath = c.Request.URL.Path
|
||||||
|
}
|
||||||
|
return DeriveUpstreamEndpoint(inbound, rawPath, platform)
|
||||||
|
}
|
||||||
159
backend/internal/handler/endpoint_test.go
Normal file
159
backend/internal/handler/endpoint_test.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() { gin.SetMode(gin.TestMode) }
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
// NormalizeInboundEndpoint
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestNormalizeInboundEndpoint(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
// Direct canonical paths.
|
||||||
|
{"/v1/messages", EndpointMessages},
|
||||||
|
{"/v1/chat/completions", EndpointChatCompletions},
|
||||||
|
{"/v1/responses", EndpointResponses},
|
||||||
|
{"/v1beta/models", EndpointGeminiModels},
|
||||||
|
|
||||||
|
// Prefixed paths (antigravity, openai, sora).
|
||||||
|
{"/antigravity/v1/messages", EndpointMessages},
|
||||||
|
{"/openai/v1/responses", EndpointResponses},
|
||||||
|
{"/openai/v1/responses/compact", EndpointResponses},
|
||||||
|
{"/sora/v1/chat/completions", EndpointChatCompletions},
|
||||||
|
{"/antigravity/v1beta/models/gemini:generateContent", EndpointGeminiModels},
|
||||||
|
|
||||||
|
// Gin route patterns with wildcards.
|
||||||
|
{"/v1beta/models/*modelAction", EndpointGeminiModels},
|
||||||
|
{"/v1/responses/*subpath", EndpointResponses},
|
||||||
|
|
||||||
|
// Unknown path is returned as-is.
|
||||||
|
{"/v1/embeddings", "/v1/embeddings"},
|
||||||
|
{"", ""},
|
||||||
|
{" /v1/messages ", EndpointMessages},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.path, func(t *testing.T) {
|
||||||
|
require.Equal(t, tt.want, NormalizeInboundEndpoint(tt.path))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
// DeriveUpstreamEndpoint
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestDeriveUpstreamEndpoint(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
inbound string
|
||||||
|
rawPath string
|
||||||
|
platform string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
// Anthropic.
|
||||||
|
{"anthropic messages", EndpointMessages, "/v1/messages", service.PlatformAnthropic, EndpointMessages},
|
||||||
|
|
||||||
|
// Gemini.
|
||||||
|
{"gemini models", EndpointGeminiModels, "/v1beta/models/gemini:gen", service.PlatformGemini, EndpointGeminiModels},
|
||||||
|
|
||||||
|
// Sora.
|
||||||
|
{"sora completions", EndpointChatCompletions, "/sora/v1/chat/completions", service.PlatformSora, EndpointChatCompletions},
|
||||||
|
|
||||||
|
// OpenAI — always /v1/responses.
|
||||||
|
{"openai responses root", EndpointResponses, "/v1/responses", service.PlatformOpenAI, EndpointResponses},
|
||||||
|
{"openai responses compact", EndpointResponses, "/openai/v1/responses/compact", service.PlatformOpenAI, "/v1/responses/compact"},
|
||||||
|
{"openai responses nested", EndpointResponses, "/openai/v1/responses/compact/detail", service.PlatformOpenAI, "/v1/responses/compact/detail"},
|
||||||
|
{"openai from messages", EndpointMessages, "/v1/messages", service.PlatformOpenAI, EndpointResponses},
|
||||||
|
{"openai from completions", EndpointChatCompletions, "/v1/chat/completions", service.PlatformOpenAI, EndpointResponses},
|
||||||
|
|
||||||
|
// Antigravity — uses inbound to pick Claude vs Gemini upstream.
|
||||||
|
{"antigravity claude", EndpointMessages, "/antigravity/v1/messages", service.PlatformAntigravity, EndpointMessages},
|
||||||
|
{"antigravity gemini", EndpointGeminiModels, "/antigravity/v1beta/models", service.PlatformAntigravity, EndpointGeminiModels},
|
||||||
|
|
||||||
|
// Unknown platform — passthrough.
|
||||||
|
{"unknown platform", "/v1/embeddings", "/v1/embeddings", "unknown", "/v1/embeddings"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
require.Equal(t, tt.want, DeriveUpstreamEndpoint(tt.inbound, tt.rawPath, tt.platform))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
// responsesSubpathSuffix
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestResponsesSubpathSuffix(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
raw string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"/v1/responses", ""},
|
||||||
|
{"/v1/responses/", ""},
|
||||||
|
{"/v1/responses/compact", "/compact"},
|
||||||
|
{"/openai/v1/responses/compact/detail", "/compact/detail"},
|
||||||
|
{"/v1/messages", ""},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.raw, func(t *testing.T) {
|
||||||
|
require.Equal(t, tt.want, responsesSubpathSuffix(tt.raw))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
// InboundEndpointMiddleware + context helpers
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestInboundEndpointMiddleware(t *testing.T) {
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(InboundEndpointMiddleware())
|
||||||
|
|
||||||
|
var captured string
|
||||||
|
router.POST("/v1/messages", func(c *gin.Context) {
|
||||||
|
captured = GetInboundEndpoint(c)
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
require.Equal(t, EndpointMessages, captured)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetInboundEndpoint_FallbackWithoutMiddleware(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/antigravity/v1/messages", nil)
|
||||||
|
|
||||||
|
// Middleware did not run — fallback to normalizing c.Request.URL.Path.
|
||||||
|
got := GetInboundEndpoint(c)
|
||||||
|
require.Equal(t, EndpointMessages, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUpstreamEndpoint_FullFlow(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(rec)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses/compact", nil)
|
||||||
|
|
||||||
|
// Simulate middleware.
|
||||||
|
c.Set(ctxKeyInboundEndpoint, NormalizeInboundEndpoint(c.Request.URL.Path))
|
||||||
|
|
||||||
|
got := GetUpstreamEndpoint(c, service.PlatformOpenAI)
|
||||||
|
require.Equal(t, "/v1/responses/compact", got)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user