| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348 |
- 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"
- EndpointEmbeddings = "/v1/embeddings"
- EndpointAlphaSearch = "/v1/alpha/search"
- EndpointResponses = "/v1/responses"
- EndpointResponsesCompact = "/v1/responses/compact"
- EndpointResponsesInputTokens = "/v1/responses/input_tokens"
- EndpointImagesGenerations = "/v1/images/generations"
- EndpointImagesEdits = "/v1/images/edits"
- EndpointImageTasks = "/v1/images/tasks"
- EndpointVideosGenerations = "/v1/videos/generations"
- EndpointVideosEdits = "/v1/videos/edits"
- EndpointVideosExtensions = "/v1/videos/extensions"
- EndpointVideos = "/v1/videos"
- EndpointGeminiModels = "/v1beta/models"
- )
- const EndpointAntigravityGenerateContent = "/v1internal:streamGenerateContent"
- // gin.Context keys used by the middleware and helpers below.
- const (
- ctxKeyInboundEndpoint = "_gateway_inbound_endpoint"
- ctxKeyActualUpstreamEndpoint = "_gateway_actual_upstream_endpoint"
- )
- // ──────────────────────────────────────────────────────────
- // Normalization functions
- // ──────────────────────────────────────────────────────────
- // NormalizeInboundEndpoint maps a raw request path (which may carry
- // prefixes like /antigravity, /openai) 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"
- //
- // The OpenAI Responses API is also exposed via a few bare/alias
- // routes that do not carry a "/v1/" prefix (top-level bare route and
- // the Codex direct route). "/responses/compact" (and "/backend-api/
- // codex/responses/compact") is a distinct client endpoint — the
- // "compact" client — and is normalized to its OWN canonical inbound
- // endpoint, EndpointResponsesCompact, rather than being folded into
- // the root Responses endpoint. Any other subpath under the bare/alias
- // roots (i.e. not "compact" itself or nested under it) remains a
- // subresource suffix of the root Responses endpoint:
- //
- // "/v1/responses/compact" → EndpointResponsesCompact
- // "/v1/responses/compact/detail" → EndpointResponsesCompact
- // "/openai/v1/responses/compact" → EndpointResponsesCompact
- // "/openai/v1/responses/compact/detail" → EndpointResponsesCompact
- // "/responses/compact" → EndpointResponsesCompact
- // "/responses/compact/detail" → EndpointResponsesCompact
- // "/backend-api/codex/responses/compact" → EndpointResponsesCompact
- // "/backend-api/codex/responses/compact/detail" → EndpointResponsesCompact
- // "/v1/responses" → EndpointResponses
- // "/openai/v1/responses" → EndpointResponses
- // "/responses" → EndpointResponses
- // "/backend-api/codex/responses" → EndpointResponses
- //
- // The compact check MUST be evaluated before the root Responses check,
- // otherwise "/v1/responses" (a prefix of "/v1/responses/compact")
- // would erroneously match first.
- func NormalizeInboundEndpoint(path string) string {
- path = strings.TrimSpace(path)
- switch {
- case strings.Contains(path, EndpointResponsesInputTokens) || isResponsesInputTokensAliasPath(path):
- return EndpointResponsesInputTokens
- case strings.Contains(path, EndpointEmbeddings):
- return EndpointEmbeddings
- case strings.Contains(path, EndpointAlphaSearch) || isBareOrSubpathOf(strings.TrimRight(path, "/"), "/alpha/search") || isBareOrSubpathOf(strings.TrimRight(path, "/"), "/backend-api/codex/alpha/search"):
- return EndpointAlphaSearch
- case strings.Contains(path, EndpointChatCompletions):
- return EndpointChatCompletions
- case strings.Contains(path, EndpointMessages):
- return EndpointMessages
- case strings.Contains(path, EndpointImagesGenerations) || strings.Contains(path, "/images/generations"):
- return EndpointImagesGenerations
- case strings.Contains(path, EndpointImagesEdits) || strings.Contains(path, "/images/edits"):
- return EndpointImagesEdits
- case strings.Contains(path, EndpointImageTasks) || strings.Contains(path, "/images/tasks/"):
- return EndpointImageTasks
- case strings.Contains(path, EndpointVideosGenerations) || strings.Contains(path, "/videos/generations"):
- return EndpointVideosGenerations
- case strings.Contains(path, EndpointVideosEdits) || strings.Contains(path, "/videos/edits"):
- return EndpointVideosEdits
- case strings.Contains(path, EndpointVideosExtensions) || strings.Contains(path, "/videos/extensions"):
- return EndpointVideosExtensions
- case strings.Contains(path, EndpointVideos) || strings.Contains(path, "/videos/"):
- return EndpointVideos
- case strings.Contains(path, EndpointResponsesCompact) || isResponsesCompactAliasPath(path):
- return EndpointResponsesCompact
- case strings.Contains(path, EndpointResponses) || isResponsesRootAliasPath(path):
- return EndpointResponses
- case strings.Contains(path, EndpointGeminiModels):
- return EndpointGeminiModels
- default:
- return path
- }
- }
- func isResponsesInputTokensAliasPath(path string) bool {
- trimmed := strings.TrimRight(strings.TrimSpace(path), "/")
- if trimmed == "" {
- return false
- }
- return isBareOrSubpathOf(trimmed, "/responses/input_tokens") ||
- isBareOrSubpathOf(trimmed, "/backend-api/codex/responses/input_tokens")
- }
- // isResponsesCompactAliasPath reports whether path is the bare/alias
- // "compact" client endpoint — i.e. it is rooted at "/responses/compact"
- // or "/backend-api/codex/responses/compact" (bare routes that serve
- // the OpenAI Responses API "compact" client without a "/v1/" prefix),
- // or any subpath nested under either of those roots:
- //
- // - "/responses/compact" (bare route, compact client)
- // - "/responses/compact/*subpath" (nested, e.g. "/responses/compact/detail")
- // - "/backend-api/codex/responses/compact" (Codex direct route, compact client)
- // - "/backend-api/codex/responses/compact/*subpath" (nested, e.g.
- // "/backend-api/codex/responses/compact/detail")
- //
- // This MUST be checked before isResponsesRootAliasPath, since
- // "/responses" is a prefix of "/responses/compact".
- func isResponsesCompactAliasPath(path string) bool {
- trimmed := strings.TrimRight(strings.TrimSpace(path), "/")
- if trimmed == "" {
- return false
- }
- return isBareOrSubpathOf(trimmed, "/responses/compact") || isBareOrSubpathOf(trimmed, "/backend-api/codex/responses/compact")
- }
- // isResponsesRootAliasPath reports whether path is one of the bare/alias
- // routes that serve the root OpenAI Responses API without a "/v1/"
- // prefix, or any non-"compact" subpath registered under them:
- //
- // - "/responses" (top-level bare route)
- // - "/responses/*subpath" (any subpath other than "compact",
- // since "compact" is its own distinct inbound endpoint)
- // - "/backend-api/codex/responses" (Codex direct route)
- // - "/backend-api/codex/responses/*subpath" (any subpath other than
- // "compact")
- //
- // Only the top-level bare route and the Codex direct route (and their
- // subpaths) are recognized here — this deliberately does NOT generalize
- // to any path merely ending in "/responses" (e.g. an unrelated
- // "/foo/responses" must not match).
- func isResponsesRootAliasPath(path string) bool {
- trimmed := strings.TrimRight(strings.TrimSpace(path), "/")
- if trimmed == "" {
- return false
- }
- return isBareOrSubpathOf(trimmed, "/responses") || isBareOrSubpathOf(trimmed, "/backend-api/codex/responses")
- }
- // isBareOrSubpathOf reports whether path is exactly root, or a subpath
- // rooted at root (i.e. root followed by "/"). This anchors the match
- // at the start of path so it cannot match paths where root appears
- // nested under some other unrelated prefix.
- func isBareOrSubpathOf(path, root string) bool {
- return path == root || strings.HasPrefix(path, root+"/")
- }
- // DeriveUpstreamEndpoint determines the upstream endpoint from the
- // account platform and the normalized inbound endpoint.
- //
- // Platform-specific rules:
- // - OpenAI and Grok text compatibility routes forward to /v1/responses
- // (with optional subpath such as /v1/responses/compact preserved from
- // the raw URL); native endpoints such as embeddings and alpha search
- // retain their paths. Grok raw Chat requests override this through the
- // forwarding result consumed by resolveOpenAIUpstreamEndpoint.
- // - Anthropic → /v1/messages
- // - Gemini → /v1beta/models
- // - Antigravity → /v1/messages (Claude) or gemini (Gemini)
- // - 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, service.PlatformGrok:
- if inbound == EndpointEmbeddings || inbound == EndpointAlphaSearch || inbound == EndpointResponsesInputTokens || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits || inbound == EndpointVideosGenerations || inbound == EndpointVideosEdits || inbound == EndpointVideosExtensions || inbound == EndpointVideos {
- return inbound
- }
- // OpenAI forwards everything to the Responses API.
- // Preserve subresource suffix (e.g. /v1/responses/compact,
- // /v1/responses/compact/detail) as derived from the raw path.
- if suffix := responsesSubpathSuffix(rawRequestPath); suffix != "" {
- return EndpointResponses + suffix
- }
- // The raw path carried no derivable suffix (e.g. it was already
- // normalized upstream, or the caller only has the canonical
- // inbound endpoint available) — fall back to the canonical
- // compact endpoint when that's what the inbound request was
- // recognized as, so it isn't silently treated as the root
- // Responses endpoint.
- if inbound == EndpointResponsesCompact {
- return EndpointResponsesCompact
- }
- return EndpointResponses
- case service.PlatformAnthropic:
- return EndpointMessages
- case service.PlatformGemini:
- return EndpointGeminiModels
- 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 := ""
- if c.Request != nil && c.Request.URL != nil {
- path = c.Request.URL.Path
- }
- if path == "" {
- path = c.FullPath()
- }
- c.Set(ctxKeyInboundEndpoint, NormalizeInboundEndpoint(path))
- c.Next()
- }
- }
- // ──────────────────────────────────────────────────────────
- // Context helpers — used by handlers before building
- // RecordUsageInput.
- // ──────────────────────────────────────────────────────────
- // 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.Request.URL.Path on the fly
- // (preferring the raw request path over c.FullPath(), which collapses
- // wildcard route patterns such as "/v1/responses/*subpath" and would
- // otherwise mis-normalize concrete requests like "/v1/responses/compact"
- // to the root Responses endpoint).
- 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 {
- if c.Request != nil && c.Request.URL != nil {
- path = c.Request.URL.Path
- }
- if path == "" {
- path = c.FullPath()
- }
- }
- 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 {
- // OpenAI 转发服务维护独立的运行时端点上下文,覆盖普通入站推导。
- // 这对 force_chat_completions 的错误路径尤为重要:此时可能没有
- // ForwardResult,不能把入站 /v1/responses 误报成上游端点。
- if platform == service.PlatformOpenAI || platform == service.PlatformGrok || service.IsCNProvider(platform) {
- if endpoint := service.GetActualOpenAIUpstreamEndpoint(c); endpoint != "" {
- return endpoint
- }
- }
- if c != nil {
- if value, ok := c.Get(ctxKeyActualUpstreamEndpoint); ok {
- if endpoint, ok := value.(string); ok && endpoint != "" {
- return endpoint
- }
- }
- }
- inbound := GetInboundEndpoint(c)
- rawPath := ""
- if c != nil && c.Request != nil && c.Request.URL != nil {
- rawPath = c.Request.URL.Path
- }
- return DeriveUpstreamEndpoint(inbound, rawPath, platform)
- }
- func setActualUpstreamEndpoint(c *gin.Context, endpoint string) {
- if c != nil {
- c.Set(ctxKeyActualUpstreamEndpoint, strings.TrimSpace(endpoint))
- }
- }
- func shouldUseAntigravityCompat(account *service.Account) bool {
- return account != nil &&
- account.Platform == service.PlatformAntigravity &&
- account.Type == service.AccountTypeOAuth
- }
|