fix(openai): preserve declared API-key namespace calls

This commit is contained in:
zhaotao1
2026-08-27 15:22:43 +08:00
parent efb46db0a9
commit 00b7c855cc
4 changed files with 60 additions and 8 deletions
@@ -121,7 +121,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
if shouldStripOpenAIResponsesInputNamespaces(account, wsDecision.Transport, passthroughEnabled) {
keepToolCallNamespaces := shouldKeepOpenAIResponsesToolCallNamespaces(
account, wsDecision.Transport, passthroughEnabled, compactPath,
account, wsDecision.Transport, passthroughEnabled, compactPath, body,
)
body, err = stripOpenAIResponsesInputNamespaces(body, keepToolCallNamespaces)
if err != nil {
@@ -77,25 +77,48 @@ func shouldStripOpenAIResponsesInputNamespaces(account *Account, transport OpenA
// 故 OAuth 非 compact 请求必须保留。
// - compact 端点的 schema 不含该字段,携带即 400 `Unknown parameter:
// input[N].namespace`issue #4761 正文),故 compact 一律清理。
// - API Key 出口标准 Responses APIapi.openai.com 或自定义 base_url),同样
// 不认识该字段,维持全量清理;否则只能退化成
// openai_responses_rejected_field_retry 的逐项删除,6 次上限根本盖不住长历史
// - API Key 出口默认按标准 Responses API 处理并清理该字段;但当请求本身声明
// namespace 工具时,上游显然使用了 namespace 扩展,此时必须保留调用项上的
// namespace,否则声明与历史调用会失配并触发 Missing namespace
// - 摊平模式下调用项已被改写成平名,残留 namespace 指向的声明已不存在,一律清理。
func shouldKeepOpenAIResponsesToolCallNamespaces(
account *Account,
transport OpenAIUpstreamTransport,
passthroughEnabled bool,
compactPath bool,
body []byte,
) bool {
if account == nil || !account.IsOpenAIOAuthLike() {
if account == nil {
return false
}
if compactPath {
return false
}
if account.IsOpenAIApiKey() {
return hasOpenAIResponsesNamespaceToolDeclaration(body)
}
if !account.IsOpenAIOAuthLike() {
return false
}
return !shouldFlattenOpenAIResponsesNamespaces(account, transport, passthroughEnabled, compactPath)
}
func hasOpenAIResponsesNamespaceToolDeclaration(body []byte) bool {
tools := gjson.GetBytes(body, "tools")
if !tools.IsArray() {
return false
}
found := false
tools.ForEach(func(_, tool gjson.Result) bool {
if strings.EqualFold(strings.TrimSpace(tool.Get("type").String()), "namespace") {
found = true
return false
}
return true
})
return found
}
// openAIResponsesToolCallItemTypes 是携带 namespace 的调用项类型集合。与
// removeOpenAIResponsesRejectedNamespaceAtIndex 的反应式白名单保持一致;codex-rs
// protocol/src/models.rs 中只有 FunctionCall 与 CustomToolCall 序列化 namespace
@@ -66,6 +66,29 @@ func TestOpenAIGatewayService_OAuthPreservesCodexNamespaceTools(t *testing.T) {
require.Empty(t, openAIResponsesNamespaceNames(c))
}
// API Key 自定义上游若接受 namespace 工具声明,也要求历史 function_call 原样携带
// namespace。声明仍为命名空间工具却清掉调用项字段,会触发 Missing namespace。
func TestOpenAIGatewayService_APIKeyPreservesDeclaredNamespaceToolCalls(t *testing.T) {
body := []byte(codexNamespaceRequestBody)
upstream := &httpUpstreamRecorder{responses: []*http.Response{
newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
}}
c := newOpenAIRejectedFieldTestContext(body)
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
context.Background(), c, newOpenAIRejectedFieldTestAccount(), body,
)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, upstream.bodies, 1)
forwarded := upstream.bodies[0]
require.True(t, gjson.GetBytes(forwarded, `tools.#(type=="namespace")`).Exists())
require.Equal(t, "collaboration", gjson.GetBytes(forwarded, "input.0.namespace").String())
require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists())
}
// compact 端点 schema 更窄:input[].namespace 会 400 Unknown parameterissue #4761),
// 且没有证据表明它接受 namespace 工具声明。compact 只做历史摘要、不需要模型寻址工具,
// 因此保持既有的摊平 + 全量清理行为,不随默认值翻转扩大风险面。
@@ -78,6 +78,7 @@ func TestShouldKeepOpenAIResponsesToolCallNamespaces(t *testing.T) {
transport OpenAIUpstreamTransport
passthroughEnabled bool
compactPath bool
body []byte
want bool
}{
// 上游按 namespace 解析历史调用,缺字段会 400 "Missing namespace for function_call"。
@@ -92,15 +93,20 @@ func TestShouldKeepOpenAIResponsesToolCallNamespaces(t *testing.T) {
// WSv2 + compact 是唯一「不摊平但仍必须清理」的组合,钉住 compact 判定本身,
// 使其不会被误当成可由 shouldFlatten 推导出的冗余分支。
{name: "oauth_compact_wsv2_strips", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, compactPath: true, want: false},
// API Key 出口是标准 Responses API,不认识该字段。
{name: "apikey_strips", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
// API Key 默认按标准 Responses API 清理;请求显式声明 namespace 工具时,
// 自定义上游需要原样接收对应的历史调用。
{name: "apikey_without_namespace_tool_strips", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
{name: "apikey_with_namespace_tool_keeps", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, body: []byte(`{"tools":[{"type":"namespace","name":"mcp__codex_app","tools":[]}]}`), want: true},
{name: "apikey_with_mixed_case_namespace_tool_keeps", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, body: []byte(`{"tools":[{"type":" Namespace ","name":"mcp__codex_app","tools":[]}]}`), want: true},
{name: "apikey_function_tool_with_namespace_field_strips", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, body: []byte(`{"tools":[{"type":"function","name":"automation_update","namespace":"mcp__codex_app"}]}`), want: false},
{name: "apikey_compact_with_namespace_tool_strips", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, body: []byte(`{"tools":[{"type":"namespace","name":"mcp__codex_app","tools":[]}]}`), want: false},
{name: "setup_token_keeps", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
{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, shouldKeepOpenAIResponsesToolCallNamespaces(
tt.account, tt.transport, tt.passthroughEnabled, tt.compactPath,
tt.account, tt.transport, tt.passthroughEnabled, tt.compactPath, tt.body,
))
})
}