Files
sub2api/backend/internal/service/openai_responses_namespace_forward_test.go
T
InkCrow 272735b0a7 fix(openai): preserve Codex namespace tools on OAuth Responses forwarding
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.
2026-07-28 09:19:13 +08:00

148 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}