mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
Flattening Codex `type:"namespace"` tool declarations into `namespace__name` renames the tool but cannot rewrite the contract the client already handed the model: Codex tells it to call `to=functions.collaboration.spawn_agent`. Under `tool_mode: code_mode_only` (gpt-5.6-*) collaboration tools are the model's only direct channel, so the rename left it unable to address them — it fell back to `functions.exec` and looped on meaningless shell commands until the session collapsed, with no upstream error anywhere. Fixes #4978. Preserving is what the upstream actually wants. OAuth egress is unconditionally chatgpt.com/backend-api/codex/responses (base_url is read only for API-key accounts), i.e. the party that defines the extension; codex-rs builds one request for both WS and HTTP and falls back WS->HTTP mid-session, so these declarations already reach that host over HTTP — as they do today via the responses-lite carrier and the WS/HTTP bridge, neither of which ever flattened. Namespace names cannot be allowlisted: `features.multi_agent_v2.tool_namespace` is user-configurable (openai/codex#31864 recommends renaming it to `agents`) and MCP/connector namespaces are generated at runtime (mcp__codex_apps__gmail). So the default is inverted rather than extended with more names. - Preserve namespace declarations by default for OpenAI OAuth; the new per-account `extra.openai_responses_flatten_namespaces` restores the previous behavior for deployments routing OAuth traffic to a relay that rejects them. - Keep `input[].namespace` on tool-call items for OAuth non-compact requests. The upstream requires the round-trip ("Missing namespace for function_call '...'. Round-trip the model's function_call item with its namespace field included."), so preserving declarations while stripping the calls would 400 on the second turn. The item-type allowlist mirrors the existing reactive strip. - Keep compact on its current behavior: that endpoint rejects the field outright ("Unknown parameter: 'input[894].namespace'", #4761) and there is no evidence either way about namespace declarations there, so this change does not widen its surface. API-key accounts are untouched — their upstream is a standard Responses API and the reactive retry only clears one index per round trip. - Clear the flatten mapping at the start of each Forward attempt so an account reached through failover cannot restore responses with the previous account's mapping. Admin UI gets an OAuth-only toggle (create/edit/bulk) plus zh/en copy noting the compact exception.
148 lines
6.6 KiB
Go
148 lines
6.6 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"testing"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
|
||
"github.com/stretchr/testify/require"
|
||
"github.com/tidwall/gjson"
|
||
)
|
||
|
||
// codexNamespaceRequestBody 模拟 Codex 多智能体请求:collaboration 命名空间声明 +
|
||
// 历史里的命名空间调用项 + 带残留 namespace 的普通消息项。
|
||
const codexNamespaceRequestBody = `{
|
||
"model":"gpt-5.6-terra",
|
||
"stream":false,
|
||
"instructions":"test",
|
||
"tools":[
|
||
{"type":"namespace","name":"collaboration","description":"Tools for spawning and managing sub-agents.","tools":[
|
||
{"type":"function","name":"spawn_agent","description":"Call as to=functions.collaboration.spawn_agent","parameters":{"type":"object"}},
|
||
{"type":"function","name":"wait_agent","parameters":{"type":"object"}}
|
||
]},
|
||
{"type":"function","name":"exec","parameters":{"type":"object"}}
|
||
],
|
||
"input":[
|
||
{"type":"function_call","namespace":"collaboration","name":"spawn_agent","call_id":"call_1","arguments":"{}"},
|
||
{"type":"message","role":"user","namespace":"leftover","content":[{"type":"input_text","text":"hello"}]}
|
||
]
|
||
}`
|
||
|
||
const namespaceForwardOKResponse = `{"id":"resp_ns","output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`
|
||
|
||
// OAuth 出口即 namespace 扩展的定义方:声明必须原样送达,历史调用项必须保留
|
||
// namespace(缺字段上游会 400 "Missing namespace for function_call"),而非调用项上的
|
||
// 残留 namespace 仍要清掉。回归 issue #4978。
|
||
func TestOpenAIGatewayService_OAuthPreservesCodexNamespaceTools(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, newOpenAIOAuthNamespaceTestAccount(), body,
|
||
)
|
||
|
||
require.NoError(t, err)
|
||
require.NotNil(t, result)
|
||
require.Len(t, upstream.bodies, 1)
|
||
forwarded := upstream.bodies[0]
|
||
|
||
namespaceTool := gjson.GetBytes(forwarded, `tools.#(type=="namespace")`)
|
||
require.True(t, namespaceTool.Exists(), "namespace 声明必须原样转发")
|
||
require.Equal(t, "collaboration", namespaceTool.Get("name").String())
|
||
require.Equal(t, "spawn_agent", namespaceTool.Get("tools.0.name").String())
|
||
require.Equal(t, "wait_agent", namespaceTool.Get("tools.1.name").String())
|
||
// 摊平名一旦出现,模型就无法按工具描述里的 to=functions.collaboration.spawn_agent 寻址。
|
||
require.NotContains(t, string(forwarded), "collaboration__spawn_agent")
|
||
|
||
require.Equal(t, "collaboration", gjson.GetBytes(forwarded, "input.0.namespace").String())
|
||
require.Equal(t, "spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String())
|
||
require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists())
|
||
|
||
// 未摊平即无需回程还原,不得登记映射。
|
||
require.Empty(t, openAIResponsesNamespaceNames(c))
|
||
}
|
||
|
||
// compact 端点 schema 更窄:input[].namespace 会 400 Unknown parameter(issue #4761),
|
||
// 且没有证据表明它接受 namespace 工具声明。compact 只做历史摘要、不需要模型寻址工具,
|
||
// 因此保持既有的摊平 + 全量清理行为,不随默认值翻转扩大风险面。
|
||
func TestOpenAIGatewayService_OAuthCompactKeepsFlattening(t *testing.T) {
|
||
body := []byte(codexNamespaceRequestBody)
|
||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||
newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
|
||
}}
|
||
c := newOpenAIRejectedFieldTestContext(body)
|
||
c.Request.URL.Path = "/v1/responses/compact"
|
||
|
||
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||
context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body,
|
||
)
|
||
|
||
require.NoError(t, err)
|
||
require.NotNil(t, result)
|
||
require.Len(t, upstream.bodies, 1)
|
||
forwarded := upstream.bodies[0]
|
||
|
||
require.False(t, gjson.GetBytes(forwarded, "input.0.namespace").Exists())
|
||
require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists())
|
||
require.False(t, gjson.GetBytes(forwarded, `tools.#(type=="namespace")`).Exists())
|
||
require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String())
|
||
}
|
||
|
||
// 账号开关为不认识 namespace 的兼容上游保留退路:打开后恢复 0.1.166 的摊平行为。
|
||
func TestOpenAIGatewayService_OAuthFlattenFlagRestoresLegacyBehavior(t *testing.T) {
|
||
body := []byte(codexNamespaceRequestBody)
|
||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||
newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
|
||
}}
|
||
c := newOpenAIRejectedFieldTestContext(body)
|
||
account := newOpenAIOAuthNamespaceTestAccount()
|
||
account.Extra = map[string]any{"openai_responses_flatten_namespaces": true}
|
||
|
||
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||
context.Background(), c, account, body,
|
||
)
|
||
|
||
require.NoError(t, err)
|
||
require.NotNil(t, result)
|
||
require.Len(t, upstream.bodies, 1)
|
||
forwarded := upstream.bodies[0]
|
||
|
||
require.False(t, gjson.GetBytes(forwarded, `tools.#(type=="namespace")`).Exists())
|
||
require.True(t, gjson.GetBytes(forwarded, `tools.#(name=="collaboration__spawn_agent")`).Exists())
|
||
require.True(t, gjson.GetBytes(forwarded, `tools.#(name=="collaboration__wait_agent")`).Exists())
|
||
// 摊平后调用项已改写成平名,不得再带 namespace。
|
||
require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String())
|
||
require.False(t, gjson.GetBytes(forwarded, "input.0.namespace").Exists())
|
||
require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists())
|
||
|
||
names := openAIResponsesNamespaceNames(c)
|
||
require.Equal(t,
|
||
apicompat.ResponsesNamespaceName{Namespace: "collaboration", Name: "spawn_agent"},
|
||
names["collaboration__spawn_agent"],
|
||
)
|
||
}
|
||
|
||
// handler 的 failover 在同一个 *gin.Context 上重试下一个账号;保留 namespace 的账号
|
||
// 不得沿用上一个账号登记的摊平名映射做回程还原。
|
||
func TestOpenAIGatewayService_ForwardClearsStaleNamespaceNames(t *testing.T) {
|
||
body := []byte(codexNamespaceRequestBody)
|
||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||
newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
|
||
}}
|
||
c := newOpenAIRejectedFieldTestContext(body)
|
||
setOpenAIResponsesNamespaceNames(c, map[string]apicompat.ResponsesNamespaceName{
|
||
"stale__tool": {Namespace: "stale", Name: "tool"},
|
||
})
|
||
|
||
_, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||
context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body,
|
||
)
|
||
|
||
require.NoError(t, err)
|
||
require.Empty(t, openAIResponsesNamespaceNames(c))
|
||
}
|