From fa1641f05f1607276b867e20194e12ad5499f4ef Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 14 Jul 2026 14:48:22 +0800 Subject: [PATCH] fix: keep namespace verbatim on WSv2 forwards and check test type assertions WSv2 egress relays upstream events verbatim without the HTTP-path namespace restore, so flattening requests that take the WSv2 branch would surface flattened tool names the client cannot match. Resolve the WS transport decision before flattening and skip flattening only when the request will actually go WSv2 (passthrough accounts return via HTTP before the WSv2 branch and still flatten). Also check all type assertions in responses_namespace_test.go to satisfy golangci-lint errcheck. --- .../pkg/apicompat/responses_namespace_test.go | 41 +++++++++++++------ .../service/openai_gateway_forward.go | 11 +++-- .../service/openai_responses_namespace.go | 15 +++++++ .../openai_responses_namespace_test.go | 34 +++++++++++++++ 4 files changed, 83 insertions(+), 18 deletions(-) create mode 100644 backend/internal/service/openai_responses_namespace_test.go diff --git a/backend/internal/pkg/apicompat/responses_namespace_test.go b/backend/internal/pkg/apicompat/responses_namespace_test.go index ae686d38bd..4c8e313c5d 100644 --- a/backend/internal/pkg/apicompat/responses_namespace_test.go +++ b/backend/internal/pkg/apicompat/responses_namespace_test.go @@ -31,20 +31,31 @@ func TestFlattenResponsesNamespaces_RewritesDeclarationHistoryAndChoice(t *testi require.True(t, changed) require.Equal(t, ResponsesNamespaceName{Namespace: "collaboration", Name: "spawn_agent"}, names["collaboration__spawn_agent"]) - tools := req["tools"].([]any) + tools, ok := req["tools"].([]any) + require.True(t, ok) require.Len(t, tools, 2) - require.Equal(t, "plain", tools[0].(map[string]any)["name"]) - require.Equal(t, "collaboration__spawn_agent", tools[1].(map[string]any)["name"]) - require.Equal(t, "spawn", tools[1].(map[string]any)["description"]) + plainTool, ok := tools[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "plain", plainTool["name"]) + flatTool, ok := tools[1].(map[string]any) + require.True(t, ok) + require.Equal(t, "collaboration__spawn_agent", flatTool["name"]) + require.Equal(t, "spawn", flatTool["description"]) - choice := req["tool_choice"].(map[string]any) + choice, ok := req["tool_choice"].(map[string]any) + require.True(t, ok) require.Equal(t, "collaboration__spawn_agent", choice["name"]) require.NotContains(t, choice, "namespace") - call := req["input"].([]any)[0].(map[string]any) + input, ok := req["input"].([]any) + require.True(t, ok) + require.Len(t, input, 2) + call, ok := input[0].(map[string]any) + require.True(t, ok) require.Equal(t, "collaboration__spawn_agent", call["name"]) require.NotContains(t, call, "namespace") - message := req["input"].([]any)[1].(map[string]any) + message, ok := input[1].(map[string]any) + require.True(t, ok) require.Equal(t, "spawn_agent", message["name"]) require.Equal(t, "collaboration", message["namespace"]) require.Equal(t, "gpt-5.5", req["model"]) @@ -96,11 +107,17 @@ func TestFlattenResponsesNamespacesExcept_PreservesBuiltInNamespaceAndChoice(t * require.NoError(t, err) require.True(t, changed) require.Contains(t, names, "collaboration__spawn_agent") - tools := req["tools"].([]any) - require.Equal(t, "namespace", tools[0].(map[string]any)["type"]) - require.Equal(t, "image_gen", tools[0].(map[string]any)["name"]) - require.Equal(t, "function", tools[1].(map[string]any)["type"]) - require.Equal(t, "collaboration__spawn_agent", tools[1].(map[string]any)["name"]) + tools, ok := req["tools"].([]any) + require.True(t, ok) + require.Len(t, tools, 2) + preservedTool, ok := tools[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "namespace", preservedTool["type"]) + require.Equal(t, "image_gen", preservedTool["name"]) + flatTool, ok := tools[1].(map[string]any) + require.True(t, ok) + require.Equal(t, "function", flatTool["type"]) + require.Equal(t, "collaboration__spawn_agent", flatTool["name"]) require.Equal(t, map[string]any{"type": "namespace", "name": "image_gen"}, req["tool_choice"]) } diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 980d2ffe33..6c7d45946a 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -42,7 +42,11 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if normalized { body = normalizedBody } - if account.Type == AccountTypeOAuth { + wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account) + // 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。 + wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, GetOpenAIClientTransport(c)) + passthroughEnabled := account.IsOpenAIPassthroughEnabled() + if shouldFlattenOpenAIResponsesNamespaces(account, wsDecision.Transport, passthroughEnabled) { body, err = flattenOpenAIResponsesNamespaces(c, body) if err != nil { setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "") @@ -74,10 +78,6 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if isCodexCLI { codexImageGenerationExplicitToolPolicy = account.CodexImageGenerationExplicitToolPolicy() } - wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account) - clientTransport := GetOpenAIClientTransport(c) - // 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。 - wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, clientTransport) if c != nil { c.Set("openai_ws_transport_decision", string(wsDecision.Transport)) c.Set("openai_ws_transport_reason", wsDecision.Reason) @@ -106,7 +106,6 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } return nil, errors.New("openai ws v1 is temporarily unsupported; use ws v2") } - passthroughEnabled := account.IsOpenAIPassthroughEnabled() if passthroughEnabled { if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip { strippedBody, changed, stripErr := stripOpenAIImageGenerationToolsFromRawPayload(body) diff --git a/backend/internal/service/openai_responses_namespace.go b/backend/internal/service/openai_responses_namespace.go index 279eb2afeb..7d71b64814 100644 --- a/backend/internal/service/openai_responses_namespace.go +++ b/backend/internal/service/openai_responses_namespace.go @@ -11,6 +11,21 @@ import ( const openAIResponsesNamespaceNamesContextKey = "openai_responses_namespace_names" +// shouldFlattenOpenAIResponsesNamespaces 判定原生 Responses 转发前是否摊平 +// Codex namespace 工具。WSv2 上游原生支持 namespace,且 WS 出口 +// (openai_ws_forwarder_v2)原样转发上游事件、不经 HTTP 回程还原,摊平后的 +// 平名无法还原会破坏客户端工具匹配,因此实际走 WSv2 分支的请求保持 namespace +// 原样。透传账号先于 WSv2 分支经 HTTP 转发返回,仍需摊平。 +func shouldFlattenOpenAIResponsesNamespaces(account *Account, transport OpenAIUpstreamTransport, passthroughEnabled bool) bool { + if account == nil || account.Type != AccountTypeOAuth { + return false + } + if transport == OpenAIUpstreamTransportResponsesWebsocketV2 && !passthroughEnabled { + return false + } + return true +} + func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, error) { if !bytes.Contains(body, []byte(`"namespace"`)) { return body, nil diff --git a/backend/internal/service/openai_responses_namespace_test.go b/backend/internal/service/openai_responses_namespace_test.go new file mode 100644 index 0000000000..7aa1392260 --- /dev/null +++ b/backend/internal/service/openai_responses_namespace_test.go @@ -0,0 +1,34 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestShouldFlattenOpenAIResponsesNamespaces(t *testing.T) { + oauth := &Account{Type: AccountTypeOAuth} + apiKey := &Account{Type: AccountTypeAPIKey} + + tests := []struct { + name string + account *Account + transport OpenAIUpstreamTransport + passthroughEnabled bool + want bool + }{ + {name: "oauth_http", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: true}, + {name: "oauth_http_passthrough", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: true}, + // WSv2 出口原样转发上游事件、不做回程还原,摊平会让客户端收到无法匹配的平名。 + {name: "oauth_wsv2", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false}, + // 透传账号先于 WSv2 分支经 HTTP 转发返回,仍需摊平。 + {name: "oauth_wsv2_passthrough", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true}, + {name: "apikey_http", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, + {name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, shouldFlattenOpenAIResponsesNamespaces(tt.account, tt.transport, tt.passthroughEnabled)) + }) + } +}