fix(openai): 保留 Responses Lite namespace 调用

This commit is contained in:
xO-ox-ai
2026-09-14 11:35:03 +08:00
parent bdb42e22f8
commit 4bd7132ba9
3 changed files with 71 additions and 6 deletions
@@ -78,8 +78,9 @@ func shouldStripOpenAIResponsesInputNamespaces(account *Account, transport OpenA
// - compact 端点的 schema 不含该字段,携带即 400 `Unknown parameter:
// input[N].namespace`(issue #4761 正文),故 compact 一律清理。
// - API Key 出口默认按标准 Responses API 处理并清理该字段;但当请求本身声明
// namespace 工具时,上游显然使用了 namespace 扩展,此时必须保留调用项上的
// namespace,否则声明与历史调用会失配并触发 Missing namespace。
// namespace 工具时(包括 Responses Lite 的 input[].additional_tools),上游显然
// 使用了 namespace 扩展,此时必须保留调用项上的 namespace,否则声明与历史
// 调用会失配并触发 Missing namespace。
// - 摊平模式下调用项已被改写成平名,残留 namespace 指向的声明已不存在,一律清理。
func shouldKeepOpenAIResponsesToolCallNamespaces(
account *Account,
@@ -104,13 +105,39 @@ func shouldKeepOpenAIResponsesToolCallNamespaces(
}
func hasOpenAIResponsesNamespaceToolDeclaration(body []byte) bool {
tools := gjson.GetBytes(body, "tools")
if !tools.IsArray() {
hasNamespaceTool := func(tools gjson.Result) bool {
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
}
if hasNamespaceTool(gjson.GetBytes(body, "tools")) {
return true
}
// Responses Lite moves private namespace declarations out of top-level
// tools and into an input.additional_tools carrier. API-key requests are
// intentionally not rewritten by normalizeOpenAIResponsesLiteTools, so the
// declaration can arrive here only in this form.
input := gjson.GetBytes(body, "input")
if !input.IsArray() {
return false
}
found := false
tools.ForEach(func(_, tool gjson.Result) bool {
if strings.EqualFold(strings.TrimSpace(tool.Get("type").String()), "namespace") {
input.ForEach(func(_, item gjson.Result) bool {
if !strings.EqualFold(strings.TrimSpace(item.Get("type").String()), "additional_tools") {
return true
}
if hasNamespaceTool(item.Get("tools")) {
found = true
return false
}
@@ -89,6 +89,41 @@ func TestOpenAIGatewayService_APIKeyPreservesDeclaredNamespaceToolCalls(t *testi
require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists())
}
// Responses Lite 把 namespace 声明放在 input.additional_tools,而不是顶层 tools。
// OpenAI API Key 走该请求形态时也必须保留历史 function_call.namespace。
func TestOpenAIGatewayService_APIKeyPreservesLiteDeclaredNamespaceToolCalls(t *testing.T) {
body := []byte(`{
"model":"gpt-5.6-terra",
"stream":false,
"input":[
{"type":"function_call","namespace":"mcp__cua_repl","name":"js","call_id":"call_js","arguments":"{}"},
{"type":"message","role":"user","namespace":"leftover","content":[{"type":"input_text","text":"hello"}]},
{"type":"additional_tools","role":"developer","tools":[
{"type":"namespace","name":"mcp__cua_repl","tools":[{"type":"function","name":"js","parameters":{"type":"object"}}]}
]}
]
}`)
upstream := &httpUpstreamRecorder{responses: []*http.Response{
newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
}}
c := newOpenAIRejectedFieldTestContext(body)
c.Request.Header.Set(responsesLiteHeader, "true")
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, `input.#(type=="additional_tools").tools.#(type=="namespace")`).Exists())
require.Equal(t, "mcp__cua_repl", gjson.GetBytes(forwarded, "input.0.namespace").String())
require.Equal(t, "js", gjson.GetBytes(forwarded, "input.0.name").String())
require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists())
}
// compact 端点 schema 更窄:input[].namespace 会 400 Unknown parameter(issue #4761),
// 且没有证据表明它接受 namespace 工具声明。compact 只做历史摘要、不需要模型寻址工具,
// 因此保持既有的摊平 + 全量清理行为,不随默认值翻转扩大风险面。
@@ -98,6 +98,9 @@ func TestShouldKeepOpenAIResponsesToolCallNamespaces(t *testing.T) {
{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_with_lite_additional_namespace_tool_keeps", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, body: []byte(`{"input":[{"type":"additional_tools","role":"developer","tools":[{"type":"namespace","name":"mcp__cua_repl","tools":[{"type":"function","name":"js"}]}]}]}`), want: true},
{name: "apikey_with_mixed_case_lite_carrier_keeps", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, body: []byte(`{"input":[{"type":" Additional_Tools ","tools":[{"type":" Namespace ","name":"mcp__cua_repl","tools":[]}]}]}`), want: true},
{name: "apikey_with_lite_function_only_strips", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, body: []byte(`{"input":[{"type":"additional_tools","tools":[{"type":"function","name":"js","namespace":"mcp__cua_repl"}]}]}`), want: false},
{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},