diff --git a/backend/internal/handler/endpoint.go b/backend/internal/handler/endpoint.go index f8689a6e3a..0b9930c5cc 100644 --- a/backend/internal/handler/endpoint.go +++ b/backend/internal/handler/endpoint.go @@ -19,6 +19,7 @@ const ( EndpointChatCompletions = "/v1/chat/completions" EndpointEmbeddings = "/v1/embeddings" EndpointResponses = "/v1/responses" + EndpointResponsesCompact = "/v1/responses/compact" EndpointImagesGenerations = "/v1/images/generations" EndpointImagesEdits = "/v1/images/edits" EndpointVideosGenerations = "/v1/videos/generations" @@ -42,6 +43,33 @@ const ( // "/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 { @@ -59,7 +87,9 @@ func NormalizeInboundEndpoint(path string) string { return EndpointVideosGenerations case strings.Contains(path, EndpointVideos) || strings.Contains(path, "/videos/"): return EndpointVideos - case strings.Contains(path, EndpointResponses): + 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 @@ -68,6 +98,59 @@ func NormalizeInboundEndpoint(path string) string { } } +// 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. // @@ -88,10 +171,20 @@ func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string { return inbound } // OpenAI forwards everything to the Responses API. - // Preserve subresource suffix (e.g. /v1/responses/compact). + // 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: @@ -142,10 +235,13 @@ func responsesSubpathSuffix(rawPath string) string { // 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 := "" + 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() } @@ -158,7 +254,11 @@ func InboundEndpointMiddleware() gin.HandlerFunc { // 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. +// 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 != "" { @@ -168,10 +268,12 @@ func GetInboundEndpoint(c *gin.Context) string { // Fallback: normalize on the fly. path := "" if c != nil { - path = c.FullPath() - if path == "" && c.Request != nil && c.Request.URL != nil { + if c.Request != nil && c.Request.URL != nil { path = c.Request.URL.Path } + if path == "" { + path = c.FullPath() + } } return NormalizeInboundEndpoint(path) } diff --git a/backend/internal/handler/endpoint_test.go b/backend/internal/handler/endpoint_test.go index 55e3845ed4..96ed1292b3 100644 --- a/backend/internal/handler/endpoint_test.go +++ b/backend/internal/handler/endpoint_test.go @@ -26,23 +26,42 @@ func TestNormalizeInboundEndpoint(t *testing.T) { {"/v1/chat/completions", EndpointChatCompletions}, {"/v1/embeddings", EndpointEmbeddings}, {"/v1/responses", EndpointResponses}, + {"/v1/responses/compact", EndpointResponsesCompact}, + {"/v1/responses/compact/detail", EndpointResponsesCompact}, {"/v1/images/generations", EndpointImagesGenerations}, {"/v1/images/edits", EndpointImagesEdits}, {"/v1/videos/generations", EndpointVideosGenerations}, {"/v1/videos/req_123", EndpointVideos}, {"/v1beta/models", EndpointGeminiModels}, - // Prefixed paths (antigravity, openai). + // Prefixed paths (antigravity, openai) — root Responses. {"/antigravity/v1/messages", EndpointMessages}, {"/openai/v1/responses", EndpointResponses}, - {"/openai/v1/responses/compact", EndpointResponses}, {"/openai/v1/images/generations", EndpointImagesGenerations}, {"/openai/v1/images/edits", EndpointImagesEdits}, {"/antigravity/v1beta/models/gemini:generateContent", EndpointGeminiModels}, - // Gin route patterns with wildcards. - {"/v1beta/models/*modelAction", EndpointGeminiModels}, - {"/v1/responses/*subpath", EndpointResponses}, + // 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/compact", EndpointResponsesCompact}, + {"/responses/compact/detail", EndpointResponsesCompact}, + + // Bare Codex direct alias route — root vs. compact. + {"/backend-api/codex/responses", EndpointResponses}, + {"/backend-api/codex/responses/compact", EndpointResponsesCompact}, + {"/backend-api/codex/responses/compact/detail", EndpointResponsesCompact}, + + // 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"}, @@ -74,10 +93,29 @@ func TestDeriveUpstreamEndpoint(t *testing.T) { // Gemini. {"gemini models", EndpointGeminiModels, "/v1beta/models/gemini:gen", service.PlatformGemini, EndpointGeminiModels}, - // OpenAI — always /v1/responses. + // OpenAI — root 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 — 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}, @@ -113,6 +151,12 @@ func TestResponsesSubpathSuffix(t *testing.T) { {"/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", ""}, {"", ""}, } @@ -154,6 +198,132 @@ func TestGetInboundEndpoint_FallbackWithoutMiddleware(t *testing.T) { 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) diff --git a/backend/internal/handler/stream_error_event.go b/backend/internal/handler/stream_error_event.go index f3a33a8c89..2af098dbed 100644 --- a/backend/internal/handler/stream_error_event.go +++ b/backend/internal/handler/stream_error_event.go @@ -85,22 +85,27 @@ func writeResponsesFailedSSE(c *gin.Context, errType, message string) bool { return true } -// inboundIsResponses 判断当前请求是否落在任何 /responses 路由上。 +// inboundIsResponses 判断当前请求是否落在任意 Responses 路由上 +// (不区分 root 还是 compact 变体)。 // // 不能直接用 GetInboundEndpoint(c) == EndpointResponses 比较,因为 -// NormalizeInboundEndpoint 只识别包含 "/v1/responses" 子串的路径; -// 项目里实际注册了多组路由(gateway_v1、top-level bare、codex direct), -// 其中 r.POST("/responses", ...) 和 codexDirect.POST("/responses", ...) -// 的 c.FullPath() 不含 "/v1/" 前缀,会被归一化为原始路径, -// 导致协议合规终止事件没法发出去。 +// GetInboundEndpoint/NormalizeInboundEndpoint 会把 compact 变体归一化为 +// 单独的 EndpointResponsesCompact(而不是 EndpointResponses), +// 而本函数在这里只关心“是不是 Responses 家族的请求”, +// 不需要区分 root/compact,所以不能用那个等值比较。 // -// 这里用 FullPath 的后缀判断,覆盖所有变体: +// 这里改用 FullPath 的后缀/子串判断,一次性覆盖 root 和 compact 的所有变体: // - /v1/responses // - /v1/responses/compact // - /responses // - /responses/compact // - /backend-api/codex/responses // - /backend-api/codex/responses/compact +// +// 对于通配路由(如 "/v1/responses/*action")注册的 FullPath 本身就带有 +// "/responses/" 子串(例如 "/v1/responses/*action"),所以下面的 +// strings.Contains(p, "/responses/") 分支同样能覆盖这些通配路由, +// 不需要额外处理通配符本身。 func inboundIsResponses(c *gin.Context) bool { if c == nil { return false