endpoint.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. package handler
  2. import (
  3. "strings"
  4. "github.com/Wei-Shaw/sub2api/internal/service"
  5. "github.com/gin-gonic/gin"
  6. )
  7. // ──────────────────────────────────────────────────────────
  8. // Canonical inbound / upstream endpoint paths.
  9. // All normalization and derivation reference this single set
  10. // of constants — add new paths HERE when a new API surface
  11. // is introduced.
  12. // ──────────────────────────────────────────────────────────
  13. const (
  14. EndpointMessages = "/v1/messages"
  15. EndpointChatCompletions = "/v1/chat/completions"
  16. EndpointEmbeddings = "/v1/embeddings"
  17. EndpointAlphaSearch = "/v1/alpha/search"
  18. EndpointResponses = "/v1/responses"
  19. EndpointResponsesCompact = "/v1/responses/compact"
  20. EndpointResponsesInputTokens = "/v1/responses/input_tokens"
  21. EndpointImagesGenerations = "/v1/images/generations"
  22. EndpointImagesEdits = "/v1/images/edits"
  23. EndpointImageTasks = "/v1/images/tasks"
  24. EndpointVideosGenerations = "/v1/videos/generations"
  25. EndpointVideosEdits = "/v1/videos/edits"
  26. EndpointVideosExtensions = "/v1/videos/extensions"
  27. EndpointVideos = "/v1/videos"
  28. EndpointGeminiModels = "/v1beta/models"
  29. )
  30. const EndpointAntigravityGenerateContent = "/v1internal:streamGenerateContent"
  31. // gin.Context keys used by the middleware and helpers below.
  32. const (
  33. ctxKeyInboundEndpoint = "_gateway_inbound_endpoint"
  34. ctxKeyActualUpstreamEndpoint = "_gateway_actual_upstream_endpoint"
  35. )
  36. // ──────────────────────────────────────────────────────────
  37. // Normalization functions
  38. // ──────────────────────────────────────────────────────────
  39. // NormalizeInboundEndpoint maps a raw request path (which may carry
  40. // prefixes like /antigravity, /openai) to its canonical form.
  41. //
  42. // "/antigravity/v1/messages" → "/v1/messages"
  43. // "/v1/chat/completions" → "/v1/chat/completions"
  44. // "/openai/v1/responses/foo" → "/v1/responses"
  45. // "/v1beta/models/gemini:gen" → "/v1beta/models"
  46. //
  47. // The OpenAI Responses API is also exposed via a few bare/alias
  48. // routes that do not carry a "/v1/" prefix (top-level bare route and
  49. // the Codex direct route). "/responses/compact" (and "/backend-api/
  50. // codex/responses/compact") is a distinct client endpoint — the
  51. // "compact" client — and is normalized to its OWN canonical inbound
  52. // endpoint, EndpointResponsesCompact, rather than being folded into
  53. // the root Responses endpoint. Any other subpath under the bare/alias
  54. // roots (i.e. not "compact" itself or nested under it) remains a
  55. // subresource suffix of the root Responses endpoint:
  56. //
  57. // "/v1/responses/compact" → EndpointResponsesCompact
  58. // "/v1/responses/compact/detail" → EndpointResponsesCompact
  59. // "/openai/v1/responses/compact" → EndpointResponsesCompact
  60. // "/openai/v1/responses/compact/detail" → EndpointResponsesCompact
  61. // "/responses/compact" → EndpointResponsesCompact
  62. // "/responses/compact/detail" → EndpointResponsesCompact
  63. // "/backend-api/codex/responses/compact" → EndpointResponsesCompact
  64. // "/backend-api/codex/responses/compact/detail" → EndpointResponsesCompact
  65. // "/v1/responses" → EndpointResponses
  66. // "/openai/v1/responses" → EndpointResponses
  67. // "/responses" → EndpointResponses
  68. // "/backend-api/codex/responses" → EndpointResponses
  69. //
  70. // The compact check MUST be evaluated before the root Responses check,
  71. // otherwise "/v1/responses" (a prefix of "/v1/responses/compact")
  72. // would erroneously match first.
  73. func NormalizeInboundEndpoint(path string) string {
  74. path = strings.TrimSpace(path)
  75. switch {
  76. case strings.Contains(path, EndpointResponsesInputTokens) || isResponsesInputTokensAliasPath(path):
  77. return EndpointResponsesInputTokens
  78. case strings.Contains(path, EndpointEmbeddings):
  79. return EndpointEmbeddings
  80. case strings.Contains(path, EndpointAlphaSearch) || isBareOrSubpathOf(strings.TrimRight(path, "/"), "/alpha/search") || isBareOrSubpathOf(strings.TrimRight(path, "/"), "/backend-api/codex/alpha/search"):
  81. return EndpointAlphaSearch
  82. case strings.Contains(path, EndpointChatCompletions):
  83. return EndpointChatCompletions
  84. case strings.Contains(path, EndpointMessages):
  85. return EndpointMessages
  86. case strings.Contains(path, EndpointImagesGenerations) || strings.Contains(path, "/images/generations"):
  87. return EndpointImagesGenerations
  88. case strings.Contains(path, EndpointImagesEdits) || strings.Contains(path, "/images/edits"):
  89. return EndpointImagesEdits
  90. case strings.Contains(path, EndpointImageTasks) || strings.Contains(path, "/images/tasks/"):
  91. return EndpointImageTasks
  92. case strings.Contains(path, EndpointVideosGenerations) || strings.Contains(path, "/videos/generations"):
  93. return EndpointVideosGenerations
  94. case strings.Contains(path, EndpointVideosEdits) || strings.Contains(path, "/videos/edits"):
  95. return EndpointVideosEdits
  96. case strings.Contains(path, EndpointVideosExtensions) || strings.Contains(path, "/videos/extensions"):
  97. return EndpointVideosExtensions
  98. case strings.Contains(path, EndpointVideos) || strings.Contains(path, "/videos/"):
  99. return EndpointVideos
  100. case strings.Contains(path, EndpointResponsesCompact) || isResponsesCompactAliasPath(path):
  101. return EndpointResponsesCompact
  102. case strings.Contains(path, EndpointResponses) || isResponsesRootAliasPath(path):
  103. return EndpointResponses
  104. case strings.Contains(path, EndpointGeminiModels):
  105. return EndpointGeminiModels
  106. default:
  107. return path
  108. }
  109. }
  110. func isResponsesInputTokensAliasPath(path string) bool {
  111. trimmed := strings.TrimRight(strings.TrimSpace(path), "/")
  112. if trimmed == "" {
  113. return false
  114. }
  115. return isBareOrSubpathOf(trimmed, "/responses/input_tokens") ||
  116. isBareOrSubpathOf(trimmed, "/backend-api/codex/responses/input_tokens")
  117. }
  118. // isResponsesCompactAliasPath reports whether path is the bare/alias
  119. // "compact" client endpoint — i.e. it is rooted at "/responses/compact"
  120. // or "/backend-api/codex/responses/compact" (bare routes that serve
  121. // the OpenAI Responses API "compact" client without a "/v1/" prefix),
  122. // or any subpath nested under either of those roots:
  123. //
  124. // - "/responses/compact" (bare route, compact client)
  125. // - "/responses/compact/*subpath" (nested, e.g. "/responses/compact/detail")
  126. // - "/backend-api/codex/responses/compact" (Codex direct route, compact client)
  127. // - "/backend-api/codex/responses/compact/*subpath" (nested, e.g.
  128. // "/backend-api/codex/responses/compact/detail")
  129. //
  130. // This MUST be checked before isResponsesRootAliasPath, since
  131. // "/responses" is a prefix of "/responses/compact".
  132. func isResponsesCompactAliasPath(path string) bool {
  133. trimmed := strings.TrimRight(strings.TrimSpace(path), "/")
  134. if trimmed == "" {
  135. return false
  136. }
  137. return isBareOrSubpathOf(trimmed, "/responses/compact") || isBareOrSubpathOf(trimmed, "/backend-api/codex/responses/compact")
  138. }
  139. // isResponsesRootAliasPath reports whether path is one of the bare/alias
  140. // routes that serve the root OpenAI Responses API without a "/v1/"
  141. // prefix, or any non-"compact" subpath registered under them:
  142. //
  143. // - "/responses" (top-level bare route)
  144. // - "/responses/*subpath" (any subpath other than "compact",
  145. // since "compact" is its own distinct inbound endpoint)
  146. // - "/backend-api/codex/responses" (Codex direct route)
  147. // - "/backend-api/codex/responses/*subpath" (any subpath other than
  148. // "compact")
  149. //
  150. // Only the top-level bare route and the Codex direct route (and their
  151. // subpaths) are recognized here — this deliberately does NOT generalize
  152. // to any path merely ending in "/responses" (e.g. an unrelated
  153. // "/foo/responses" must not match).
  154. func isResponsesRootAliasPath(path string) bool {
  155. trimmed := strings.TrimRight(strings.TrimSpace(path), "/")
  156. if trimmed == "" {
  157. return false
  158. }
  159. return isBareOrSubpathOf(trimmed, "/responses") || isBareOrSubpathOf(trimmed, "/backend-api/codex/responses")
  160. }
  161. // isBareOrSubpathOf reports whether path is exactly root, or a subpath
  162. // rooted at root (i.e. root followed by "/"). This anchors the match
  163. // at the start of path so it cannot match paths where root appears
  164. // nested under some other unrelated prefix.
  165. func isBareOrSubpathOf(path, root string) bool {
  166. return path == root || strings.HasPrefix(path, root+"/")
  167. }
  168. // DeriveUpstreamEndpoint determines the upstream endpoint from the
  169. // account platform and the normalized inbound endpoint.
  170. //
  171. // Platform-specific rules:
  172. // - OpenAI and Grok text compatibility routes forward to /v1/responses
  173. // (with optional subpath such as /v1/responses/compact preserved from
  174. // the raw URL); native endpoints such as embeddings and alpha search
  175. // retain their paths. Grok raw Chat requests override this through the
  176. // forwarding result consumed by resolveOpenAIUpstreamEndpoint.
  177. // - Anthropic → /v1/messages
  178. // - Gemini → /v1beta/models
  179. // - Antigravity → /v1/messages (Claude) or gemini (Gemini)
  180. // - Antigravity routes may target either Claude or Gemini, so the
  181. // inbound endpoint is used to distinguish.
  182. func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string {
  183. inbound = strings.TrimSpace(inbound)
  184. switch platform {
  185. case service.PlatformOpenAI, service.PlatformGrok:
  186. if inbound == EndpointEmbeddings || inbound == EndpointAlphaSearch || inbound == EndpointResponsesInputTokens || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits || inbound == EndpointVideosGenerations || inbound == EndpointVideosEdits || inbound == EndpointVideosExtensions || inbound == EndpointVideos {
  187. return inbound
  188. }
  189. // OpenAI forwards everything to the Responses API.
  190. // Preserve subresource suffix (e.g. /v1/responses/compact,
  191. // /v1/responses/compact/detail) as derived from the raw path.
  192. if suffix := responsesSubpathSuffix(rawRequestPath); suffix != "" {
  193. return EndpointResponses + suffix
  194. }
  195. // The raw path carried no derivable suffix (e.g. it was already
  196. // normalized upstream, or the caller only has the canonical
  197. // inbound endpoint available) — fall back to the canonical
  198. // compact endpoint when that's what the inbound request was
  199. // recognized as, so it isn't silently treated as the root
  200. // Responses endpoint.
  201. if inbound == EndpointResponsesCompact {
  202. return EndpointResponsesCompact
  203. }
  204. return EndpointResponses
  205. case service.PlatformAnthropic:
  206. return EndpointMessages
  207. case service.PlatformGemini:
  208. return EndpointGeminiModels
  209. case service.PlatformAntigravity:
  210. // Antigravity accounts serve both Claude and Gemini.
  211. if inbound == EndpointGeminiModels {
  212. return EndpointGeminiModels
  213. }
  214. return EndpointMessages
  215. }
  216. // Unknown platform — fall back to inbound.
  217. return inbound
  218. }
  219. // responsesSubpathSuffix extracts the part after "/responses" in a raw
  220. // request path, e.g. "/openai/v1/responses/compact" → "/compact".
  221. // Returns "" when there is no meaningful suffix.
  222. func responsesSubpathSuffix(rawPath string) string {
  223. trimmed := strings.TrimRight(strings.TrimSpace(rawPath), "/")
  224. idx := strings.LastIndex(trimmed, "/responses")
  225. if idx < 0 {
  226. return ""
  227. }
  228. suffix := trimmed[idx+len("/responses"):]
  229. if suffix == "" || suffix == "/" {
  230. return ""
  231. }
  232. if !strings.HasPrefix(suffix, "/") {
  233. return ""
  234. }
  235. return suffix
  236. }
  237. // ──────────────────────────────────────────────────────────
  238. // Middleware
  239. // ──────────────────────────────────────────────────────────
  240. // InboundEndpointMiddleware normalizes the request path and stores the
  241. // canonical inbound endpoint in gin.Context so that every handler in
  242. // the chain can read it via GetInboundEndpoint.
  243. //
  244. // Apply this middleware to all gateway route groups.
  245. func InboundEndpointMiddleware() gin.HandlerFunc {
  246. return func(c *gin.Context) {
  247. path := ""
  248. if c.Request != nil && c.Request.URL != nil {
  249. path = c.Request.URL.Path
  250. }
  251. if path == "" {
  252. path = c.FullPath()
  253. }
  254. c.Set(ctxKeyInboundEndpoint, NormalizeInboundEndpoint(path))
  255. c.Next()
  256. }
  257. }
  258. // ──────────────────────────────────────────────────────────
  259. // Context helpers — used by handlers before building
  260. // RecordUsageInput.
  261. // ──────────────────────────────────────────────────────────
  262. // GetInboundEndpoint returns the canonical inbound endpoint stored by
  263. // InboundEndpointMiddleware. If the middleware did not run (e.g. in
  264. // tests), it falls back to normalizing c.Request.URL.Path on the fly
  265. // (preferring the raw request path over c.FullPath(), which collapses
  266. // wildcard route patterns such as "/v1/responses/*subpath" and would
  267. // otherwise mis-normalize concrete requests like "/v1/responses/compact"
  268. // to the root Responses endpoint).
  269. func GetInboundEndpoint(c *gin.Context) string {
  270. if v, ok := c.Get(ctxKeyInboundEndpoint); ok {
  271. if s, ok := v.(string); ok && s != "" {
  272. return s
  273. }
  274. }
  275. // Fallback: normalize on the fly.
  276. path := ""
  277. if c != nil {
  278. if c.Request != nil && c.Request.URL != nil {
  279. path = c.Request.URL.Path
  280. }
  281. if path == "" {
  282. path = c.FullPath()
  283. }
  284. }
  285. return NormalizeInboundEndpoint(path)
  286. }
  287. // GetUpstreamEndpoint derives the upstream endpoint from the context
  288. // and the account platform. Handlers call this after scheduling an
  289. // account, passing account.Platform.
  290. func GetUpstreamEndpoint(c *gin.Context, platform string) string {
  291. // OpenAI 转发服务维护独立的运行时端点上下文,覆盖普通入站推导。
  292. // 这对 force_chat_completions 的错误路径尤为重要:此时可能没有
  293. // ForwardResult,不能把入站 /v1/responses 误报成上游端点。
  294. if platform == service.PlatformOpenAI || platform == service.PlatformGrok || service.IsCNProvider(platform) {
  295. if endpoint := service.GetActualOpenAIUpstreamEndpoint(c); endpoint != "" {
  296. return endpoint
  297. }
  298. }
  299. if c != nil {
  300. if value, ok := c.Get(ctxKeyActualUpstreamEndpoint); ok {
  301. if endpoint, ok := value.(string); ok && endpoint != "" {
  302. return endpoint
  303. }
  304. }
  305. }
  306. inbound := GetInboundEndpoint(c)
  307. rawPath := ""
  308. if c != nil && c.Request != nil && c.Request.URL != nil {
  309. rawPath = c.Request.URL.Path
  310. }
  311. return DeriveUpstreamEndpoint(inbound, rawPath, platform)
  312. }
  313. func setActualUpstreamEndpoint(c *gin.Context, endpoint string) {
  314. if c != nil {
  315. c.Set(ctxKeyActualUpstreamEndpoint, strings.TrimSpace(endpoint))
  316. }
  317. }
  318. func shouldUseAntigravityCompat(account *service.Account) bool {
  319. return account != nil &&
  320. account.Platform == service.PlatformAntigravity &&
  321. account.Type == service.AccountTypeOAuth
  322. }