| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446 |
- 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/embeddings", EndpointEmbeddings},
- {"/v1/alpha/search", EndpointAlphaSearch},
- {"/v1/responses", EndpointResponses},
- {"/v1/responses/input_tokens", EndpointResponsesInputTokens},
- {"/v1/responses/compact", EndpointResponsesCompact},
- {"/v1/responses/compact/detail", EndpointResponsesCompact},
- {"/v1/images/generations", EndpointImagesGenerations},
- {"/v1/images/edits", EndpointImagesEdits},
- {"/v1/images/tasks/imgtask_123", EndpointImageTasks},
- {"/v1/videos/generations", EndpointVideosGenerations},
- {"/v1/videos/req_123", EndpointVideos},
- {"/v1beta/models", EndpointGeminiModels},
- // Prefixed paths (antigravity, openai) — root Responses.
- {"/antigravity/v1/messages", EndpointMessages},
- {"/openai/v1/responses", EndpointResponses},
- {"/openai/v1/images/generations", EndpointImagesGenerations},
- {"/openai/v1/images/edits", EndpointImagesEdits},
- {"/antigravity/v1beta/models/gemini:generateContent", EndpointGeminiModels},
- // Prefixed paths — "/responses/compact" is its OWN distinct
- // inbound endpoint, not folded into the root Responses endpoint.
- {"/openai/v1/responses/compact", EndpointResponsesCompact},
- {"/openai/v1/responses/compact/detail", EndpointResponsesCompact},
- // Bare top-level alias route "/responses" — root vs. compact.
- {"/responses", EndpointResponses},
- {"/responses/input_tokens", EndpointResponsesInputTokens},
- {"/responses/compact", EndpointResponsesCompact},
- {"/responses/compact/detail", EndpointResponsesCompact},
- {"/alpha/search", EndpointAlphaSearch},
- {"/images/tasks/imgtask_123", EndpointImageTasks},
- // Bare Codex direct alias route — root vs. compact.
- {"/backend-api/codex/responses", EndpointResponses},
- {"/backend-api/codex/responses/input_tokens", EndpointResponsesInputTokens},
- {"/backend-api/codex/responses/compact", EndpointResponsesCompact},
- {"/backend-api/codex/responses/compact/detail", EndpointResponsesCompact},
- {"/backend-api/codex/alpha/search", EndpointAlphaSearch},
- // Must NOT generalize to arbitrary paths merely ending in
- // "/responses" (or "/responses/compact") that are unrelated to
- // the two known bare alias roots, unless they already carry a
- // supported "/v1/responses..." prefix form.
- {"/foo/responses", "/foo/responses"},
- {"/foo/responses/compact", "/foo/responses/compact"},
- // 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},
- // OpenAI — root Responses.
- {"openai responses root", EndpointResponses, "/v1/responses", service.PlatformOpenAI, EndpointResponses},
- {"openai responses input tokens", EndpointResponsesInputTokens, "/v1/responses/input_tokens", service.PlatformOpenAI, EndpointResponsesInputTokens},
- // OpenAI — compact, raw path carries the derivable "/compact"
- // (or nested) suffix, which must be preserved on the upstream
- // endpoint.
- {"openai responses compact", EndpointResponsesCompact, "/openai/v1/responses/compact", service.PlatformOpenAI, "/v1/responses/compact"},
- {"openai responses nested", EndpointResponsesCompact, "/openai/v1/responses/compact/detail", service.PlatformOpenAI, "/v1/responses/compact/detail"},
- {"openai bare responses compact", EndpointResponsesCompact, "/responses/compact", service.PlatformOpenAI, "/v1/responses/compact"},
- {"openai bare responses compact detail", EndpointResponsesCompact, "/responses/compact/detail", service.PlatformOpenAI, "/v1/responses/compact/detail"},
- {"openai codex direct responses compact", EndpointResponsesCompact, "/backend-api/codex/responses/compact", service.PlatformOpenAI, "/v1/responses/compact"},
- {"openai codex direct responses compact detail", EndpointResponsesCompact, "/backend-api/codex/responses/compact/detail", service.PlatformOpenAI, "/v1/responses/compact/detail"},
- // OpenAI — bare root alias routes normalize to root Responses.
- {"openai bare responses", EndpointResponses, "/responses", service.PlatformOpenAI, EndpointResponses},
- {"openai codex direct responses", EndpointResponses, "/backend-api/codex/responses", service.PlatformOpenAI, EndpointResponses},
- // OpenAI — inbound is already the canonical compact endpoint but
- // the raw path carries no derivable "/responses..." suffix (e.g.
- // it was already normalized upstream). Must not silently fall
- // back to the root Responses endpoint.
- {"openai responses compact inbound only, unrelated raw path", EndpointResponsesCompact, "/v1/messages", service.PlatformOpenAI, EndpointResponsesCompact},
- {"openai from messages", EndpointMessages, "/v1/messages", service.PlatformOpenAI, EndpointResponses},
- {"openai from completions", EndpointChatCompletions, "/v1/chat/completions", service.PlatformOpenAI, EndpointResponses},
- {"openai embeddings", EndpointEmbeddings, "/v1/embeddings", service.PlatformOpenAI, EndpointEmbeddings},
- {"openai alpha search", EndpointAlphaSearch, "/backend-api/codex/alpha/search", service.PlatformOpenAI, EndpointAlphaSearch},
- {"openai image generations", EndpointImagesGenerations, "/v1/images/generations", service.PlatformOpenAI, EndpointImagesGenerations},
- {"openai image edits", EndpointImagesEdits, "/openai/v1/images/edits", service.PlatformOpenAI, EndpointImagesEdits},
- {"grok chat defaults to responses without runtime result", EndpointChatCompletions, "/v1/chat/completions", service.PlatformGrok, EndpointResponses},
- {"grok responses", EndpointResponses, "/v1/responses", service.PlatformGrok, EndpointResponses},
- {"grok video generations", EndpointVideosGenerations, "/v1/videos/generations", service.PlatformGrok, EndpointVideosGenerations},
- {"grok video status", EndpointVideos, "/videos/req_123", service.PlatformGrok, EndpointVideos},
- // 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))
- })
- }
- }
- func TestShouldUseAntigravityCompat(t *testing.T) {
- tests := []struct {
- name string
- account *service.Account
- want bool
- }{
- {"oauth", &service.Account{Platform: service.PlatformAntigravity, Type: service.AccountTypeOAuth}, true},
- {"setup token", &service.Account{Platform: service.PlatformAntigravity, Type: service.AccountTypeSetupToken}, false},
- {"upstream", &service.Account{Platform: service.PlatformAntigravity, Type: service.AccountTypeUpstream}, false},
- {"api key", &service.Account{Platform: service.PlatformAntigravity, Type: service.AccountTypeAPIKey}, false},
- {"anthropic oauth", &service.Account{Platform: service.PlatformAnthropic, Type: service.AccountTypeOAuth}, false},
- {"nil", nil, false},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- require.Equal(t, tt.want, shouldUseAntigravityCompat(tt.account))
- })
- }
- }
- func TestGetUpstreamEndpointPrefersRuntimeOverride(t *testing.T) {
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodPost, EndpointChatCompletions, nil)
- c.Set(ctxKeyInboundEndpoint, EndpointChatCompletions)
- setActualUpstreamEndpoint(c, EndpointAntigravityGenerateContent)
- require.Equal(t, EndpointAntigravityGenerateContent, GetUpstreamEndpoint(c, service.PlatformAntigravity))
- setActualUpstreamEndpoint(c, "")
- require.Equal(t, EndpointMessages, GetUpstreamEndpoint(c, service.PlatformAntigravity))
- }
- func TestGetUpstreamEndpointUsesOpenAIRuntimeOverride(t *testing.T) {
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil)
- c.Set(ctxKeyInboundEndpoint, EndpointResponses)
- service.SetActualOpenAIUpstreamEndpoint(c, EndpointChatCompletions)
- require.Equal(t, EndpointChatCompletions, GetUpstreamEndpoint(c, service.PlatformOpenAI))
- }
- func TestResolveOpenAIUpstreamEndpointPrefersForwardResult(t *testing.T) {
- tests := []struct {
- name string
- account *service.Account
- result *service.OpenAIForwardResult
- runtimeEndpoint string
- want string
- }{
- {
- name: "grok raw chat result overrides stale context",
- account: &service.Account{Platform: service.PlatformGrok, Type: service.AccountTypeOAuth},
- result: &service.OpenAIForwardResult{UpstreamEndpoint: EndpointChatCompletions},
- runtimeEndpoint: EndpointResponses,
- want: EndpointChatCompletions,
- },
- {
- name: "grok chat bridged to responses",
- account: &service.Account{Platform: service.PlatformGrok, Type: service.AccountTypeOAuth},
- result: &service.OpenAIForwardResult{UpstreamEndpoint: EndpointResponses},
- want: EndpointResponses,
- },
- {
- name: "grok empty result keeps responses default",
- account: &service.Account{Platform: service.PlatformGrok, Type: service.AccountTypeOAuth},
- result: &service.OpenAIForwardResult{},
- want: EndpointResponses,
- },
- {
- name: "grok raw error uses runtime endpoint",
- account: &service.Account{Platform: service.PlatformGrok, Type: service.AccountTypeOAuth},
- runtimeEndpoint: EndpointChatCompletions,
- want: EndpointChatCompletions,
- },
- {
- name: "openai behavior remains responses",
- account: &service.Account{Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth},
- result: &service.OpenAIForwardResult{},
- want: EndpointResponses,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, EndpointChatCompletions, nil)
- c.Set(ctxKeyInboundEndpoint, EndpointChatCompletions)
- service.SetActualOpenAIUpstreamEndpoint(c, tt.runtimeEndpoint)
- require.Equal(t, tt.want, resolveOpenAIUpstreamEndpoint(c, tt.account, tt.result))
- })
- }
- }
- // ──────────────────────────────────────────────────────────
- // 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"},
- {"/responses", ""},
- {"/responses/compact", "/compact"},
- {"/responses/compact/detail", "/compact/detail"},
- {"/backend-api/codex/responses", ""},
- {"/backend-api/codex/responses/compact", "/compact"},
- {"/backend-api/codex/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)
- }
- // TestInboundEndpointMiddleware_WildcardRoutes verifies that, when a
- // gateway route is registered with a Gin wildcard pattern (e.g.
- // "/v1/responses/*subpath"), InboundEndpointMiddleware normalizes based
- // on the concrete request path (c.Request.URL.Path) rather than the
- // route pattern (c.FullPath()). Using c.FullPath() here would collapse
- // every request under the wildcard — including "/v1/responses/compact"
- // — down to the literal pattern string, which never matches the
- // "compact" alias detection and would incorrectly normalize to the root
- // Responses endpoint.
- func TestInboundEndpointMiddleware_WildcardRoutes(t *testing.T) {
- tests := []struct {
- name string
- routePath string
- requestPath string
- want string
- }{
- {
- name: "v1 responses wildcard route, compact request",
- routePath: "/v1/responses/*subpath",
- requestPath: "/v1/responses/compact",
- want: EndpointResponsesCompact,
- },
- {
- name: "bare responses wildcard route, compact request",
- routePath: "/responses/*subpath",
- requestPath: "/responses/compact",
- want: EndpointResponsesCompact,
- },
- {
- name: "codex direct wildcard route, compact request",
- routePath: "/backend-api/codex/responses/*subpath",
- requestPath: "/backend-api/codex/responses/compact",
- want: EndpointResponsesCompact,
- },
- {
- name: "v1 responses wildcard route, non-compact subpath request",
- routePath: "/v1/responses/*subpath",
- requestPath: "/v1/responses/foo",
- want: EndpointResponses,
- },
- {
- name: "bare responses wildcard route, non-compact subpath request",
- routePath: "/responses/*subpath",
- requestPath: "/responses/foo",
- want: EndpointResponses,
- },
- {
- name: "codex direct wildcard route, non-compact subpath request",
- routePath: "/backend-api/codex/responses/*subpath",
- requestPath: "/backend-api/codex/responses/foo",
- want: EndpointResponses,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- router := gin.New()
- router.Use(InboundEndpointMiddleware())
- var captured string
- router.POST(tt.routePath, func(c *gin.Context) {
- captured = GetInboundEndpoint(c)
- c.Status(http.StatusOK)
- })
- req := httptest.NewRequest(http.MethodPost, tt.requestPath, nil)
- rec := httptest.NewRecorder()
- router.ServeHTTP(rec, req)
- require.Equal(t, http.StatusOK, rec.Code)
- require.Equal(t, tt.want, captured)
- })
- }
- }
- // TestInboundEndpointMiddleware_GeminiWildcardRoute verifies that a Gemini
- // wildcard route (e.g. "/v1beta/models/*modelAction", used to capture the
- // ":generateContent"-style action suffix embedded in the path) is normalized
- // to EndpointGeminiModels via InboundEndpointMiddleware, using the same real
- // Gin routing path as TestInboundEndpointMiddleware_WildcardRoutes above.
- func TestInboundEndpointMiddleware_GeminiWildcardRoute(t *testing.T) {
- router := gin.New()
- router.Use(InboundEndpointMiddleware())
- var captured string
- router.POST("/v1beta/models/*modelAction", func(c *gin.Context) {
- captured = GetInboundEndpoint(c)
- c.Status(http.StatusOK)
- })
- req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-pro:generateContent", nil)
- rec := httptest.NewRecorder()
- router.ServeHTTP(rec, req)
- require.Equal(t, http.StatusOK, rec.Code)
- require.Equal(t, EndpointGeminiModels, captured)
- }
- // TestGetInboundEndpoint_FallbackWildcardRouteWithoutMiddleware verifies
- // that when InboundEndpointMiddleware did NOT run (so no value is stored
- // in gin.Context), the GetInboundEndpoint fallback path still prefers
- // c.Request.URL.Path over c.FullPath(). This guards against the fallback
- // regressing to prefer c.FullPath() again, which would misnormalize
- // concrete requests matched by a wildcard route pattern (e.g.
- // "/v1/responses/*subpath" matching "/v1/responses/compact") down to
- // the root Responses endpoint.
- func TestGetInboundEndpoint_FallbackWildcardRouteWithoutMiddleware(t *testing.T) {
- router := gin.New()
- // Deliberately do NOT register InboundEndpointMiddleware.
- var captured string
- router.POST("/v1/responses/*subpath", func(c *gin.Context) {
- // Sanity check: FullPath returns the route pattern, not the
- // concrete request path, when a wildcard route matches.
- require.Equal(t, "/v1/responses/*subpath", c.FullPath())
- captured = GetInboundEndpoint(c)
- c.Status(http.StatusOK)
- })
- req := httptest.NewRequest(http.MethodPost, "/v1/responses/compact", nil)
- rec := httptest.NewRecorder()
- router.ServeHTTP(rec, req)
- require.Equal(t, http.StatusOK, rec.Code)
- require.Equal(t, EndpointResponsesCompact, captured)
- }
- 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)
- }
|