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.
This commit is contained in:
shaw
2026-07-14 14:48:22 +08:00
parent 252ef8b73a
commit fa1641f05f
4 changed files with 83 additions and 18 deletions
@@ -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"])
}
@@ -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)
@@ -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
@@ -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))
})
}
}