endpoint_test.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. package handler
  2. import (
  3. "net/http"
  4. "net/http/httptest"
  5. "testing"
  6. "github.com/Wei-Shaw/sub2api/internal/service"
  7. "github.com/gin-gonic/gin"
  8. "github.com/stretchr/testify/require"
  9. )
  10. func init() { gin.SetMode(gin.TestMode) }
  11. // ──────────────────────────────────────────────────────────
  12. // NormalizeInboundEndpoint
  13. // ──────────────────────────────────────────────────────────
  14. func TestNormalizeInboundEndpoint(t *testing.T) {
  15. tests := []struct {
  16. path string
  17. want string
  18. }{
  19. // Direct canonical paths.
  20. {"/v1/messages", EndpointMessages},
  21. {"/v1/chat/completions", EndpointChatCompletions},
  22. {"/v1/embeddings", EndpointEmbeddings},
  23. {"/v1/alpha/search", EndpointAlphaSearch},
  24. {"/v1/responses", EndpointResponses},
  25. {"/v1/responses/input_tokens", EndpointResponsesInputTokens},
  26. {"/v1/responses/compact", EndpointResponsesCompact},
  27. {"/v1/responses/compact/detail", EndpointResponsesCompact},
  28. {"/v1/images/generations", EndpointImagesGenerations},
  29. {"/v1/images/edits", EndpointImagesEdits},
  30. {"/v1/images/tasks/imgtask_123", EndpointImageTasks},
  31. {"/v1/videos/generations", EndpointVideosGenerations},
  32. {"/v1/videos/req_123", EndpointVideos},
  33. {"/v1beta/models", EndpointGeminiModels},
  34. // Prefixed paths (antigravity, openai) — root Responses.
  35. {"/antigravity/v1/messages", EndpointMessages},
  36. {"/openai/v1/responses", EndpointResponses},
  37. {"/openai/v1/images/generations", EndpointImagesGenerations},
  38. {"/openai/v1/images/edits", EndpointImagesEdits},
  39. {"/antigravity/v1beta/models/gemini:generateContent", EndpointGeminiModels},
  40. // Prefixed paths — "/responses/compact" is its OWN distinct
  41. // inbound endpoint, not folded into the root Responses endpoint.
  42. {"/openai/v1/responses/compact", EndpointResponsesCompact},
  43. {"/openai/v1/responses/compact/detail", EndpointResponsesCompact},
  44. // Bare top-level alias route "/responses" — root vs. compact.
  45. {"/responses", EndpointResponses},
  46. {"/responses/input_tokens", EndpointResponsesInputTokens},
  47. {"/responses/compact", EndpointResponsesCompact},
  48. {"/responses/compact/detail", EndpointResponsesCompact},
  49. {"/alpha/search", EndpointAlphaSearch},
  50. {"/images/tasks/imgtask_123", EndpointImageTasks},
  51. // Bare Codex direct alias route — root vs. compact.
  52. {"/backend-api/codex/responses", EndpointResponses},
  53. {"/backend-api/codex/responses/input_tokens", EndpointResponsesInputTokens},
  54. {"/backend-api/codex/responses/compact", EndpointResponsesCompact},
  55. {"/backend-api/codex/responses/compact/detail", EndpointResponsesCompact},
  56. {"/backend-api/codex/alpha/search", EndpointAlphaSearch},
  57. // Must NOT generalize to arbitrary paths merely ending in
  58. // "/responses" (or "/responses/compact") that are unrelated to
  59. // the two known bare alias roots, unless they already carry a
  60. // supported "/v1/responses..." prefix form.
  61. {"/foo/responses", "/foo/responses"},
  62. {"/foo/responses/compact", "/foo/responses/compact"},
  63. // Unknown path is returned as-is.
  64. {"/v1/embeddings", "/v1/embeddings"},
  65. {"", ""},
  66. {" /v1/messages ", EndpointMessages},
  67. }
  68. for _, tt := range tests {
  69. t.Run(tt.path, func(t *testing.T) {
  70. require.Equal(t, tt.want, NormalizeInboundEndpoint(tt.path))
  71. })
  72. }
  73. }
  74. // ──────────────────────────────────────────────────────────
  75. // DeriveUpstreamEndpoint
  76. // ──────────────────────────────────────────────────────────
  77. func TestDeriveUpstreamEndpoint(t *testing.T) {
  78. tests := []struct {
  79. name string
  80. inbound string
  81. rawPath string
  82. platform string
  83. want string
  84. }{
  85. // Anthropic.
  86. {"anthropic messages", EndpointMessages, "/v1/messages", service.PlatformAnthropic, EndpointMessages},
  87. // Gemini.
  88. {"gemini models", EndpointGeminiModels, "/v1beta/models/gemini:gen", service.PlatformGemini, EndpointGeminiModels},
  89. // OpenAI — root Responses.
  90. {"openai responses root", EndpointResponses, "/v1/responses", service.PlatformOpenAI, EndpointResponses},
  91. {"openai responses input tokens", EndpointResponsesInputTokens, "/v1/responses/input_tokens", service.PlatformOpenAI, EndpointResponsesInputTokens},
  92. // OpenAI — compact, raw path carries the derivable "/compact"
  93. // (or nested) suffix, which must be preserved on the upstream
  94. // endpoint.
  95. {"openai responses compact", EndpointResponsesCompact, "/openai/v1/responses/compact", service.PlatformOpenAI, "/v1/responses/compact"},
  96. {"openai responses nested", EndpointResponsesCompact, "/openai/v1/responses/compact/detail", service.PlatformOpenAI, "/v1/responses/compact/detail"},
  97. {"openai bare responses compact", EndpointResponsesCompact, "/responses/compact", service.PlatformOpenAI, "/v1/responses/compact"},
  98. {"openai bare responses compact detail", EndpointResponsesCompact, "/responses/compact/detail", service.PlatformOpenAI, "/v1/responses/compact/detail"},
  99. {"openai codex direct responses compact", EndpointResponsesCompact, "/backend-api/codex/responses/compact", service.PlatformOpenAI, "/v1/responses/compact"},
  100. {"openai codex direct responses compact detail", EndpointResponsesCompact, "/backend-api/codex/responses/compact/detail", service.PlatformOpenAI, "/v1/responses/compact/detail"},
  101. // OpenAI — bare root alias routes normalize to root Responses.
  102. {"openai bare responses", EndpointResponses, "/responses", service.PlatformOpenAI, EndpointResponses},
  103. {"openai codex direct responses", EndpointResponses, "/backend-api/codex/responses", service.PlatformOpenAI, EndpointResponses},
  104. // OpenAI — inbound is already the canonical compact endpoint but
  105. // the raw path carries no derivable "/responses..." suffix (e.g.
  106. // it was already normalized upstream). Must not silently fall
  107. // back to the root Responses endpoint.
  108. {"openai responses compact inbound only, unrelated raw path", EndpointResponsesCompact, "/v1/messages", service.PlatformOpenAI, EndpointResponsesCompact},
  109. {"openai from messages", EndpointMessages, "/v1/messages", service.PlatformOpenAI, EndpointResponses},
  110. {"openai from completions", EndpointChatCompletions, "/v1/chat/completions", service.PlatformOpenAI, EndpointResponses},
  111. {"openai embeddings", EndpointEmbeddings, "/v1/embeddings", service.PlatformOpenAI, EndpointEmbeddings},
  112. {"openai alpha search", EndpointAlphaSearch, "/backend-api/codex/alpha/search", service.PlatformOpenAI, EndpointAlphaSearch},
  113. {"openai image generations", EndpointImagesGenerations, "/v1/images/generations", service.PlatformOpenAI, EndpointImagesGenerations},
  114. {"openai image edits", EndpointImagesEdits, "/openai/v1/images/edits", service.PlatformOpenAI, EndpointImagesEdits},
  115. {"grok chat defaults to responses without runtime result", EndpointChatCompletions, "/v1/chat/completions", service.PlatformGrok, EndpointResponses},
  116. {"grok responses", EndpointResponses, "/v1/responses", service.PlatformGrok, EndpointResponses},
  117. {"grok video generations", EndpointVideosGenerations, "/v1/videos/generations", service.PlatformGrok, EndpointVideosGenerations},
  118. {"grok video status", EndpointVideos, "/videos/req_123", service.PlatformGrok, EndpointVideos},
  119. // Antigravity — uses inbound to pick Claude vs Gemini upstream.
  120. {"antigravity claude", EndpointMessages, "/antigravity/v1/messages", service.PlatformAntigravity, EndpointMessages},
  121. {"antigravity gemini", EndpointGeminiModels, "/antigravity/v1beta/models", service.PlatformAntigravity, EndpointGeminiModels},
  122. // Unknown platform — passthrough.
  123. {"unknown platform", "/v1/embeddings", "/v1/embeddings", "unknown", "/v1/embeddings"},
  124. }
  125. for _, tt := range tests {
  126. t.Run(tt.name, func(t *testing.T) {
  127. require.Equal(t, tt.want, DeriveUpstreamEndpoint(tt.inbound, tt.rawPath, tt.platform))
  128. })
  129. }
  130. }
  131. func TestShouldUseAntigravityCompat(t *testing.T) {
  132. tests := []struct {
  133. name string
  134. account *service.Account
  135. want bool
  136. }{
  137. {"oauth", &service.Account{Platform: service.PlatformAntigravity, Type: service.AccountTypeOAuth}, true},
  138. {"setup token", &service.Account{Platform: service.PlatformAntigravity, Type: service.AccountTypeSetupToken}, false},
  139. {"upstream", &service.Account{Platform: service.PlatformAntigravity, Type: service.AccountTypeUpstream}, false},
  140. {"api key", &service.Account{Platform: service.PlatformAntigravity, Type: service.AccountTypeAPIKey}, false},
  141. {"anthropic oauth", &service.Account{Platform: service.PlatformAnthropic, Type: service.AccountTypeOAuth}, false},
  142. {"nil", nil, false},
  143. }
  144. for _, tt := range tests {
  145. t.Run(tt.name, func(t *testing.T) {
  146. require.Equal(t, tt.want, shouldUseAntigravityCompat(tt.account))
  147. })
  148. }
  149. }
  150. func TestGetUpstreamEndpointPrefersRuntimeOverride(t *testing.T) {
  151. recorder := httptest.NewRecorder()
  152. c, _ := gin.CreateTestContext(recorder)
  153. c.Request = httptest.NewRequest(http.MethodPost, EndpointChatCompletions, nil)
  154. c.Set(ctxKeyInboundEndpoint, EndpointChatCompletions)
  155. setActualUpstreamEndpoint(c, EndpointAntigravityGenerateContent)
  156. require.Equal(t, EndpointAntigravityGenerateContent, GetUpstreamEndpoint(c, service.PlatformAntigravity))
  157. setActualUpstreamEndpoint(c, "")
  158. require.Equal(t, EndpointMessages, GetUpstreamEndpoint(c, service.PlatformAntigravity))
  159. }
  160. func TestGetUpstreamEndpointUsesOpenAIRuntimeOverride(t *testing.T) {
  161. recorder := httptest.NewRecorder()
  162. c, _ := gin.CreateTestContext(recorder)
  163. c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil)
  164. c.Set(ctxKeyInboundEndpoint, EndpointResponses)
  165. service.SetActualOpenAIUpstreamEndpoint(c, EndpointChatCompletions)
  166. require.Equal(t, EndpointChatCompletions, GetUpstreamEndpoint(c, service.PlatformOpenAI))
  167. }
  168. func TestResolveOpenAIUpstreamEndpointPrefersForwardResult(t *testing.T) {
  169. tests := []struct {
  170. name string
  171. account *service.Account
  172. result *service.OpenAIForwardResult
  173. runtimeEndpoint string
  174. want string
  175. }{
  176. {
  177. name: "grok raw chat result overrides stale context",
  178. account: &service.Account{Platform: service.PlatformGrok, Type: service.AccountTypeOAuth},
  179. result: &service.OpenAIForwardResult{UpstreamEndpoint: EndpointChatCompletions},
  180. runtimeEndpoint: EndpointResponses,
  181. want: EndpointChatCompletions,
  182. },
  183. {
  184. name: "grok chat bridged to responses",
  185. account: &service.Account{Platform: service.PlatformGrok, Type: service.AccountTypeOAuth},
  186. result: &service.OpenAIForwardResult{UpstreamEndpoint: EndpointResponses},
  187. want: EndpointResponses,
  188. },
  189. {
  190. name: "grok empty result keeps responses default",
  191. account: &service.Account{Platform: service.PlatformGrok, Type: service.AccountTypeOAuth},
  192. result: &service.OpenAIForwardResult{},
  193. want: EndpointResponses,
  194. },
  195. {
  196. name: "grok raw error uses runtime endpoint",
  197. account: &service.Account{Platform: service.PlatformGrok, Type: service.AccountTypeOAuth},
  198. runtimeEndpoint: EndpointChatCompletions,
  199. want: EndpointChatCompletions,
  200. },
  201. {
  202. name: "openai behavior remains responses",
  203. account: &service.Account{Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth},
  204. result: &service.OpenAIForwardResult{},
  205. want: EndpointResponses,
  206. },
  207. }
  208. for _, tt := range tests {
  209. t.Run(tt.name, func(t *testing.T) {
  210. rec := httptest.NewRecorder()
  211. c, _ := gin.CreateTestContext(rec)
  212. c.Request = httptest.NewRequest(http.MethodPost, EndpointChatCompletions, nil)
  213. c.Set(ctxKeyInboundEndpoint, EndpointChatCompletions)
  214. service.SetActualOpenAIUpstreamEndpoint(c, tt.runtimeEndpoint)
  215. require.Equal(t, tt.want, resolveOpenAIUpstreamEndpoint(c, tt.account, tt.result))
  216. })
  217. }
  218. }
  219. // ──────────────────────────────────────────────────────────
  220. // responsesSubpathSuffix
  221. // ──────────────────────────────────────────────────────────
  222. func TestResponsesSubpathSuffix(t *testing.T) {
  223. tests := []struct {
  224. raw string
  225. want string
  226. }{
  227. {"/v1/responses", ""},
  228. {"/v1/responses/", ""},
  229. {"/v1/responses/compact", "/compact"},
  230. {"/openai/v1/responses/compact/detail", "/compact/detail"},
  231. {"/responses", ""},
  232. {"/responses/compact", "/compact"},
  233. {"/responses/compact/detail", "/compact/detail"},
  234. {"/backend-api/codex/responses", ""},
  235. {"/backend-api/codex/responses/compact", "/compact"},
  236. {"/backend-api/codex/responses/compact/detail", "/compact/detail"},
  237. {"/v1/messages", ""},
  238. {"", ""},
  239. }
  240. for _, tt := range tests {
  241. t.Run(tt.raw, func(t *testing.T) {
  242. require.Equal(t, tt.want, responsesSubpathSuffix(tt.raw))
  243. })
  244. }
  245. }
  246. // ──────────────────────────────────────────────────────────
  247. // InboundEndpointMiddleware + context helpers
  248. // ──────────────────────────────────────────────────────────
  249. func TestInboundEndpointMiddleware(t *testing.T) {
  250. router := gin.New()
  251. router.Use(InboundEndpointMiddleware())
  252. var captured string
  253. router.POST("/v1/messages", func(c *gin.Context) {
  254. captured = GetInboundEndpoint(c)
  255. c.Status(http.StatusOK)
  256. })
  257. req := httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  258. rec := httptest.NewRecorder()
  259. router.ServeHTTP(rec, req)
  260. require.Equal(t, EndpointMessages, captured)
  261. }
  262. func TestGetInboundEndpoint_FallbackWithoutMiddleware(t *testing.T) {
  263. rec := httptest.NewRecorder()
  264. c, _ := gin.CreateTestContext(rec)
  265. c.Request = httptest.NewRequest(http.MethodPost, "/antigravity/v1/messages", nil)
  266. // Middleware did not run — fallback to normalizing c.Request.URL.Path.
  267. got := GetInboundEndpoint(c)
  268. require.Equal(t, EndpointMessages, got)
  269. }
  270. // TestInboundEndpointMiddleware_WildcardRoutes verifies that, when a
  271. // gateway route is registered with a Gin wildcard pattern (e.g.
  272. // "/v1/responses/*subpath"), InboundEndpointMiddleware normalizes based
  273. // on the concrete request path (c.Request.URL.Path) rather than the
  274. // route pattern (c.FullPath()). Using c.FullPath() here would collapse
  275. // every request under the wildcard — including "/v1/responses/compact"
  276. // — down to the literal pattern string, which never matches the
  277. // "compact" alias detection and would incorrectly normalize to the root
  278. // Responses endpoint.
  279. func TestInboundEndpointMiddleware_WildcardRoutes(t *testing.T) {
  280. tests := []struct {
  281. name string
  282. routePath string
  283. requestPath string
  284. want string
  285. }{
  286. {
  287. name: "v1 responses wildcard route, compact request",
  288. routePath: "/v1/responses/*subpath",
  289. requestPath: "/v1/responses/compact",
  290. want: EndpointResponsesCompact,
  291. },
  292. {
  293. name: "bare responses wildcard route, compact request",
  294. routePath: "/responses/*subpath",
  295. requestPath: "/responses/compact",
  296. want: EndpointResponsesCompact,
  297. },
  298. {
  299. name: "codex direct wildcard route, compact request",
  300. routePath: "/backend-api/codex/responses/*subpath",
  301. requestPath: "/backend-api/codex/responses/compact",
  302. want: EndpointResponsesCompact,
  303. },
  304. {
  305. name: "v1 responses wildcard route, non-compact subpath request",
  306. routePath: "/v1/responses/*subpath",
  307. requestPath: "/v1/responses/foo",
  308. want: EndpointResponses,
  309. },
  310. {
  311. name: "bare responses wildcard route, non-compact subpath request",
  312. routePath: "/responses/*subpath",
  313. requestPath: "/responses/foo",
  314. want: EndpointResponses,
  315. },
  316. {
  317. name: "codex direct wildcard route, non-compact subpath request",
  318. routePath: "/backend-api/codex/responses/*subpath",
  319. requestPath: "/backend-api/codex/responses/foo",
  320. want: EndpointResponses,
  321. },
  322. }
  323. for _, tt := range tests {
  324. t.Run(tt.name, func(t *testing.T) {
  325. router := gin.New()
  326. router.Use(InboundEndpointMiddleware())
  327. var captured string
  328. router.POST(tt.routePath, func(c *gin.Context) {
  329. captured = GetInboundEndpoint(c)
  330. c.Status(http.StatusOK)
  331. })
  332. req := httptest.NewRequest(http.MethodPost, tt.requestPath, nil)
  333. rec := httptest.NewRecorder()
  334. router.ServeHTTP(rec, req)
  335. require.Equal(t, http.StatusOK, rec.Code)
  336. require.Equal(t, tt.want, captured)
  337. })
  338. }
  339. }
  340. // TestInboundEndpointMiddleware_GeminiWildcardRoute verifies that a Gemini
  341. // wildcard route (e.g. "/v1beta/models/*modelAction", used to capture the
  342. // ":generateContent"-style action suffix embedded in the path) is normalized
  343. // to EndpointGeminiModels via InboundEndpointMiddleware, using the same real
  344. // Gin routing path as TestInboundEndpointMiddleware_WildcardRoutes above.
  345. func TestInboundEndpointMiddleware_GeminiWildcardRoute(t *testing.T) {
  346. router := gin.New()
  347. router.Use(InboundEndpointMiddleware())
  348. var captured string
  349. router.POST("/v1beta/models/*modelAction", func(c *gin.Context) {
  350. captured = GetInboundEndpoint(c)
  351. c.Status(http.StatusOK)
  352. })
  353. req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-pro:generateContent", nil)
  354. rec := httptest.NewRecorder()
  355. router.ServeHTTP(rec, req)
  356. require.Equal(t, http.StatusOK, rec.Code)
  357. require.Equal(t, EndpointGeminiModels, captured)
  358. }
  359. // TestGetInboundEndpoint_FallbackWildcardRouteWithoutMiddleware verifies
  360. // that when InboundEndpointMiddleware did NOT run (so no value is stored
  361. // in gin.Context), the GetInboundEndpoint fallback path still prefers
  362. // c.Request.URL.Path over c.FullPath(). This guards against the fallback
  363. // regressing to prefer c.FullPath() again, which would misnormalize
  364. // concrete requests matched by a wildcard route pattern (e.g.
  365. // "/v1/responses/*subpath" matching "/v1/responses/compact") down to
  366. // the root Responses endpoint.
  367. func TestGetInboundEndpoint_FallbackWildcardRouteWithoutMiddleware(t *testing.T) {
  368. router := gin.New()
  369. // Deliberately do NOT register InboundEndpointMiddleware.
  370. var captured string
  371. router.POST("/v1/responses/*subpath", func(c *gin.Context) {
  372. // Sanity check: FullPath returns the route pattern, not the
  373. // concrete request path, when a wildcard route matches.
  374. require.Equal(t, "/v1/responses/*subpath", c.FullPath())
  375. captured = GetInboundEndpoint(c)
  376. c.Status(http.StatusOK)
  377. })
  378. req := httptest.NewRequest(http.MethodPost, "/v1/responses/compact", nil)
  379. rec := httptest.NewRecorder()
  380. router.ServeHTTP(rec, req)
  381. require.Equal(t, http.StatusOK, rec.Code)
  382. require.Equal(t, EndpointResponsesCompact, captured)
  383. }
  384. func TestGetUpstreamEndpoint_FullFlow(t *testing.T) {
  385. rec := httptest.NewRecorder()
  386. c, _ := gin.CreateTestContext(rec)
  387. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses/compact", nil)
  388. // Simulate middleware.
  389. c.Set(ctxKeyInboundEndpoint, NormalizeInboundEndpoint(c.Request.URL.Path))
  390. got := GetUpstreamEndpoint(c, service.PlatformOpenAI)
  391. require.Equal(t, "/v1/responses/compact", got)
  392. }