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.
This commit is contained in:
InkCrow
2026-07-28 09:19:13 +08:00
parent 59ce11c780
commit 272735b0a7
14 changed files with 743 additions and 26 deletions
+16
View File
@@ -1825,6 +1825,22 @@ func (a *Account) IsOpenAIWSForceHTTPEnabled() bool {
return ok && enabled
}
// IsOpenAIResponsesFlattenNamespacesEnabled 返回账号级"摊平 Codex namespace 工具"开关。
// 字段:accounts.extra.openai_responses_flatten_namespaces,缺省 false(原样保留)。
//
// namespace 是 Codex 后端定义的私有扩展,OAuth 出口恒为 chatgpt.com/backend-api/codex
// (buildUpstreamRequest 只对 API Key 账号取 base_url),即定义方本身,因此默认保留。
// 该开关只为把流量转发到不认识 namespace 的兼容上游的部署保留退路:打开后恢复
// 0.1.166 及更早版本的摊平行为。仅对 OpenAI OAuth 账号有效——API Key 走 chat
// completions 回退桥时由桥自行摊平,Grok/Anthropic 出口有各自的适配链路。
func (a *Account) IsOpenAIResponsesFlattenNamespacesEnabled() bool {
if a == nil || !a.IsOpenAI() || a.Extra == nil {
return false
}
enabled, ok := a.Extra["openai_responses_flatten_namespaces"].(bool)
return ok && enabled
}
// IsOpenAIWSAllowStoreRecoveryEnabled 返回账号级 store 恢复开关。
// 字段:accounts.extra.openai_ws_allow_store_recovery。
func (a *Account) IsOpenAIWSAllowStoreRecoveryEnabled() bool {
@@ -20,6 +20,7 @@ import (
// Forward forwards request to OpenAI API
func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) {
clearGrokResponsesClientToolMapping(c)
clearOpenAIResponsesNamespaceNames(c)
startTime := time.Now()
// 固定渠道映射后的请求级 canonical body;账号 normalize/strip 不得改写跨 failover hint。
canonicalImageIntentBody := body
@@ -62,7 +63,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
// 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。
wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, GetOpenAIClientTransport(c))
passthroughEnabled := account.IsOpenAIPassthroughEnabled()
if shouldFlattenOpenAIResponsesNamespaces(account, wsDecision.Transport, passthroughEnabled) {
compactPath := isOpenAIResponsesCompactPath(c)
if shouldFlattenOpenAIResponsesNamespaces(account, wsDecision.Transport, passthroughEnabled, compactPath) {
body, err = flattenOpenAIResponsesNamespaces(c, body)
if err != nil {
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
@@ -73,7 +75,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
}
if shouldStripOpenAIResponsesInputNamespaces(account, wsDecision.Transport, passthroughEnabled) {
body, err = stripOpenAIResponsesInputNamespaces(body)
keepToolCallNamespaces := shouldKeepOpenAIResponsesToolCallNamespaces(
account, wsDecision.Transport, passthroughEnabled, compactPath,
)
body, err = stripOpenAIResponsesInputNamespaces(body, keepToolCallNamespaces)
if err != nil {
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
@@ -273,7 +278,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
markPatchSet("model", billingModel)
}
upstreamModel := billingModel
isCompactRequest := isOpenAIResponsesCompactPath(c)
isCompactRequest := compactPath
compactMapped := false
if isCompactRequest {
compactMappedModel := resolveOpenAICompactForwardModel(account, billingModel)
@@ -433,7 +433,77 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
require.NotContains(t, body, "\"name\":\"edit\"")
}
func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse(t *testing.T) {
// 「自动透传(仅替换认证)」的默认行为必须真的只替换认证:namespace 声明、
// namespace 形态的 tool_choice、历史调用项上的 namespace 都原样转发,只清掉
// 非调用项上的残留 namespace(Codex 协议里只有调用项会带该字段)。
func TestOpenAIGatewayService_OAuthPassthrough_PreservesNamespaceRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1")
originalBody := []byte(`{
"model":"gpt-5.5",
"stream":true,
"instructions":"local-test-instructions",
"tools":[
{"type":"function","name":"plain","description":"keep","parameters":{"type":"object"}},
{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","description":"spawn","parameters":{"type":"object"}}]}
],
"tool_choice":{"type":"function","name":"spawn_agent","namespace":"collaboration"},
"input":[
{"type":"function_call","call_id":"call_old","name":"spawn_agent","namespace":"collaboration","arguments":"{}"},
{"type":"message","role":"user","namespace":"residual","content":[{"type":"input_text","text":"keep","namespace":"nested"}]}
]
}`)
upstreamSSE := strings.Join([]string{
`data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"spawn_agent","namespace":"collaboration","arguments":"{}"}}`,
"",
`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[],"usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3}}}`,
"",
"data: [DONE]",
"",
}, "\n")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_preserve"}},
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
}}
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := &Account{
ID: 125, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
}
result, err := svc.Forward(context.Background(), c, account, originalBody)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, gjson.GetBytes(upstream.lastBody, "tools").Array(), 2)
require.Equal(t, "namespace", gjson.GetBytes(upstream.lastBody, "tools.1.type").String())
require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "tools.1.name").String())
require.Equal(t, "spawn_agent", gjson.GetBytes(upstream.lastBody, "tools.1.tools.0.name").String())
require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "tool_choice.namespace").String())
require.Equal(t, "spawn_agent", gjson.GetBytes(upstream.lastBody, "tool_choice.name").String())
require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "input.0.namespace").String())
require.Equal(t, "spawn_agent", gjson.GetBytes(upstream.lastBody, "input.0.name").String())
require.False(t, gjson.GetBytes(upstream.lastBody, "input.1.namespace").Exists())
require.Equal(t, "nested", gjson.GetBytes(upstream.lastBody, "input.1.content.0.namespace").String())
require.NotContains(t, string(upstream.lastBody), "collaboration__spawn_agent")
// 未摊平即无需回程还原,上游事件原样下发。
downstream := rec.Body.String()
require.Contains(t, downstream, `"name":"spawn_agent"`)
require.Contains(t, downstream, `"namespace":"collaboration"`)
}
// 兼容开关打开时的旧行为:摊平请求、回程还原。默认路径见
// TestOpenAIGatewayService_OAuthPassthrough_PreservesNamespaceRequest。
func TestOpenAIGatewayService_OAuthPassthrough_FlattenEnabledNamespaceRequestAndStreamResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
@@ -475,7 +545,11 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse
account := &Account{
ID: 123, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
Extra: map[string]any{
"openai_passthrough": true,
"openai_responses_flatten_namespaces": true,
},
Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
}
result, err := svc.Forward(context.Background(), c, account, originalBody)
@@ -501,7 +575,9 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse
require.Contains(t, downstream, `"namespace":"collaboration"`)
}
func TestOpenAIGatewayService_NativeOAuth_NamespaceRequestAndStreamResponse(t *testing.T) {
// 兼容开关打开时的旧行为;默认保留路径见
// TestOpenAIGatewayService_OAuthPreservesCodexNamespaceTools。
func TestOpenAIGatewayService_NativeOAuth_FlattenEnabledNamespaceRequestAndStreamResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -529,6 +605,7 @@ func TestOpenAIGatewayService_NativeOAuth_NamespaceRequestAndStreamResponse(t *t
account := &Account{
ID: 124, Name: "native", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
Extra: map[string]any{"openai_responses_flatten_namespaces": true},
Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
}
@@ -596,7 +673,8 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceNonStreamingResponse(t *
require.Contains(t, rec.Body.String(), `"namespace":"collaboration"`)
}
func TestOpenAIGatewayService_OAuthPassthrough_NamespaceCollisionReturnsBadRequest(t *testing.T) {
// 摊平名冲突只在兼容开关打开时才可能发生:默认保留 namespace,不存在平名冲突。
func TestOpenAIGatewayService_OAuthPassthrough_FlattenEnabledNamespaceCollisionReturnsBadRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -614,7 +692,11 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceCollisionReturnsBadReque
account := &Account{
ID: 123, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
Extra: map[string]any{
"openai_passthrough": true,
"openai_responses_flatten_namespaces": true,
},
Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
}
result, err := svc.Forward(context.Background(), c, account, body)
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/gin-gonic/gin"
@@ -14,14 +15,39 @@ 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 {
// Codex namespace 工具。
//
// 默认不摊平:OAuth 账号的 HTTP 出口恒为 chatgpt.com/backend-api/codex/responses
// (buildUpstreamRequest 只在 API Key 分支读 base_url),也就是 namespace 扩展的
// 定义方本身;Codex 客户端对 WS 与 HTTP 两条传输发送同一份 tools(codex-rs
// client.rs build_responses_request 无传输分支,WS 失败后会 session 级回落 HTTP
// 继续发同样的声明)。摊平只改写工具名,改不掉客户端在 tools 描述与 developer
// 消息里写死的 `to=functions.<namespace>.<tool>` 寻址约定,模型据此寻址必然落空
// (issue #4978);命名空间名还可由 features.multi_agent_v2.tool_namespace 自定义、
// 或由 MCP/connector 动态生成(mcp__codex_apps__gmail),保留名单枚举不完。
//
// compact 端点例外:已知它的 schema 比 /responses 窄(连 input[].namespace 都会报
// Unknown parameter,见 issue #4761),而是否接受 namespace 工具声明没有任何实测
// 证据;compact 只做历史摘要、不需要模型寻址工具,回程也没有工具调用可还原,因此
// 保持 0.1.166 起就在跑的摊平行为,不随本次默认值翻转扩大风险面。
//
// 账号开关 openai_responses_flatten_namespaces 为不认识 namespace 的兼容上游保留
// 退路,打开后恢复旧行为:WSv2 上游原生支持 namespace,且 WS 出口
// (openai_ws_forwarder_v2)原样转发上游事件、不经 HTTP 回程还原,摊平后的平名
// 无法还原会破坏客户端工具匹配,因此实际走 WSv2 分支的请求仍保持 namespace 原样;
// 透传账号先于 WSv2 分支经 HTTP 转发返回,仍需摊平。
func shouldFlattenOpenAIResponsesNamespaces(
account *Account,
transport OpenAIUpstreamTransport,
passthroughEnabled bool,
compactPath bool,
) bool {
if account == nil || !account.IsOpenAIOAuth() {
return false
}
if !compactPath && !account.IsOpenAIResponsesFlattenNamespacesEnabled() {
return false
}
if transport == OpenAIUpstreamTransportResponsesWebsocketV2 && !passthroughEnabled {
return false
}
@@ -41,6 +67,50 @@ func shouldStripOpenAIResponsesInputNamespaces(account *Account, transport OpenA
return true
}
// shouldKeepOpenAIResponsesToolCallNamespaces 判定清理 input 残留 namespace 时是否
// 保留工具调用项上的 namespace。
//
// 上游对这个字段有两套互斥要求,判定按「出口 + 端点」而非工具声明内容:
// - /backend-api/codex/responses 会按 namespace 解析历史调用,缺字段直接 400
// `Missing namespace for function_call '...'. Round-trip the model's
// function_call item with its namespace field included.`(issue #4761 回帖),
// 故 OAuth 非 compact 请求必须保留。
// - compact 端点的 schema 不含该字段,携带即 400 `Unknown parameter:
// input[N].namespace`(issue #4761 正文),故 compact 一律清理。
// - API Key 出口是标准 Responses API(api.openai.com 或自定义 base_url),同样
// 不认识该字段,维持全量清理;否则只能退化成
// openai_responses_rejected_field_retry 的逐项删除,6 次上限根本盖不住长历史。
// - 摊平模式下调用项已被改写成平名,残留 namespace 指向的声明已不存在,一律清理。
func shouldKeepOpenAIResponsesToolCallNamespaces(
account *Account,
transport OpenAIUpstreamTransport,
passthroughEnabled bool,
compactPath bool,
) bool {
if account == nil || !account.IsOpenAIOAuth() {
return false
}
if compactPath {
return false
}
return !shouldFlattenOpenAIResponsesNamespaces(account, transport, passthroughEnabled, compactPath)
}
// openAIResponsesToolCallItemTypes 是携带 namespace 的调用项类型集合。与
// removeOpenAIResponsesRejectedNamespaceAtIndex 的反应式白名单保持一致;codex-rs
// protocol/src/models.rs 中只有 FunctionCall 与 CustomToolCall 序列化 namespace,
// 其余类型带该字段一定是非 Codex 客户端或历史残留,清掉才安全。
var openAIResponsesToolCallItemTypes = map[string]bool{
"function_call": true,
"tool_call": true,
"custom_tool_call": true,
"mcp_tool_call": true,
}
func isOpenAIResponsesToolCallItemType(itemType string) bool {
return openAIResponsesToolCallItemTypes[strings.ToLower(strings.TrimSpace(itemType))]
}
func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, error) {
if !bytes.Contains(body, []byte(`"namespace"`)) {
return body, nil
@@ -68,7 +138,11 @@ func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, erro
// array items. Namespace declarations and nested namespace fields are left
// untouched. Rebuilding the input array once keeps this linear for long
// histories and avoids decoding JSON numbers through float64.
func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) {
//
// keepToolCallNamespaces 保留工具调用项(function_call / custom_tool_call 等)上的
// namespace,让 Codex 调用能按上游要求原样回传;判定见
// shouldKeepOpenAIResponsesToolCallNamespaces。
func stripOpenAIResponsesInputNamespaces(body []byte, keepToolCallNamespaces bool) ([]byte, error) {
if !bytes.Contains(body, []byte(`"namespace"`)) {
return body, nil
}
@@ -89,7 +163,10 @@ func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) {
}
first = false
itemBody := []byte(item.Raw)
if item.IsObject() && item.Get("namespace").Exists() {
// 先判存在再判类型:长历史里绝大多数是 message/reasoning 等不带 namespace
// 的项,这样它们无需再扫一次 type。
if item.IsObject() && item.Get("namespace").Exists() &&
(!keepToolCallNamespaces || !isOpenAIResponsesToolCallItemType(item.Get("type").String())) {
itemBody, stripErr = sjson.DeleteBytes(itemBody, "namespace")
if stripErr != nil {
return false
@@ -99,13 +176,13 @@ func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) {
_, _ = rebuilt.Write(itemBody)
return true
})
_ = rebuilt.WriteByte(']')
if stripErr != nil {
return body, fmt.Errorf("delete OpenAI input namespace: %w", stripErr)
}
if !changed {
return body, nil
}
_ = rebuilt.WriteByte(']')
stripped, err := sjson.SetRawBytes(body, "input", rebuilt.Bytes())
if err != nil {
return body, fmt.Errorf("replace OpenAI input after namespace deletion: %w", err)
@@ -119,6 +196,18 @@ func setOpenAIResponsesNamespaceNames(c *gin.Context, names map[string]apicompat
}
}
// clearOpenAIResponsesNamespaceNames 清除上一次尝试登记的摊平名映射。handler 的
// failover 会在同一个 *gin.Context 上重试下一个账号,映射不清会让保留 namespace 的
// 账号拿着上一个账号的摊平名做回程还原。
func clearOpenAIResponsesNamespaceNames(c *gin.Context) {
if c == nil {
return
}
if _, exists := c.Get(openAIResponsesNamespaceNamesContextKey); exists {
c.Set(openAIResponsesNamespaceNamesContextKey, map[string]apicompat.ResponsesNamespaceName(nil))
}
}
func openAIResponsesNamespaceNames(c *gin.Context) map[string]apicompat.ResponsesNamespaceName {
if c == nil {
return nil
@@ -0,0 +1,147 @@
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))
}
@@ -12,27 +12,96 @@ func TestShouldFlattenOpenAIResponsesNamespaces(t *testing.T) {
oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
grokOAuth := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth}
// 账号级兼容开关:为不认识 namespace 的兼容上游恢复旧的摊平行为。
flattenOAuth := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Extra: map[string]any{"openai_responses_flatten_namespaces": true},
}
flattenAPIKey := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Extra: map[string]any{"openai_responses_flatten_namespaces": true},
}
tests := []struct {
name string
account *Account
transport OpenAIUpstreamTransport
passthroughEnabled bool
compactPath bool
want bool
}{
{name: "oauth_http", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
{name: "oauth_http_passthrough", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: true},
// 默认保留:OAuth 出口是 namespace 扩展的定义方,摊平会让模型无法按
// `to=functions.<namespace>.<tool>` 寻址(issue #4978)。
{name: "oauth_http_default_preserves", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
{name: "oauth_http_passthrough_default_preserves", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: false},
{name: "oauth_wsv2_default_preserves", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
// compact 端点 schema 更窄且无实测证据,保持既有摊平行为。
{name: "oauth_compact_flattens", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: true},
{name: "oauth_compact_wsv2_preserves", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, compactPath: true, want: false},
{name: "apikey_compact", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: false},
{name: "oauth_flatten_enabled_http", account: flattenOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
{name: "oauth_flatten_enabled_http_passthrough", account: flattenOAuth, 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: "oauth_flatten_enabled_wsv2", account: flattenOAuth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
// 透传账号先于 WSv2 分支经 HTTP 转发返回,开关打开时仍需摊平。
{name: "oauth_flatten_enabled_wsv2_passthrough", account: flattenOAuth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true},
// 开关仅对 OAuth 生效:API Key 走 chat completions 回退桥时由桥自行摊平。
{name: "apikey_flatten_enabled_http", account: flattenAPIKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
{name: "apikey_http", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
{name: "grok_oauth_http", account: grokOAuth, 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))
require.Equal(t, tt.want, shouldFlattenOpenAIResponsesNamespaces(
tt.account, tt.transport, tt.passthroughEnabled, tt.compactPath,
))
})
}
}
func TestShouldKeepOpenAIResponsesToolCallNamespaces(t *testing.T) {
oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
setupToken := &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken}
flattenOAuth := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Extra: map[string]any{"openai_responses_flatten_namespaces": true},
}
tests := []struct {
name string
account *Account
transport OpenAIUpstreamTransport
passthroughEnabled bool
compactPath bool
want bool
}{
// 上游按 namespace 解析历史调用,缺字段会 400 "Missing namespace for function_call"。
{name: "oauth_http_keeps", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
{name: "oauth_http_passthrough_keeps", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: true},
// compact 端点 schema 不含该字段,携带即 400 "Unknown parameter: input[N].namespace"。
{name: "oauth_compact_strips", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: false},
// 摊平后调用项已是平名,残留 namespace 指向的声明不存在。
{name: "oauth_flatten_enabled_strips", account: flattenOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
// WSv2 实际由 shouldStrip 提前短路,此处只钉住策略本身的取值。
{name: "oauth_wsv2_keeps", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: true},
// 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},
{name: "setup_token_strips", account: setupToken, 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, shouldKeepOpenAIResponsesToolCallNamespaces(
tt.account, tt.transport, tt.passthroughEnabled, tt.compactPath,
))
})
}
}
@@ -85,7 +154,7 @@ func TestStripOpenAIResponsesInputNamespaces(t *testing.T) {
]
}`)
stripped, err := stripOpenAIResponsesInputNamespaces(body)
stripped, err := stripOpenAIResponsesInputNamespaces(body, false)
require.NoError(t, err)
for index := 0; index < 8; index++ {
require.False(t, gjson.GetBytes(stripped, "input."+strconv.Itoa(index)+".namespace").Exists())
@@ -106,8 +175,61 @@ func TestStripOpenAIResponsesInputNamespacesLeavesOtherShapesByteExact(t *testin
[]byte(`{"input":[{"content":{"namespace":"nested-only"}}],"tools":[{"namespace":"keep"}]}`),
}
for _, body := range tests {
stripped, err := stripOpenAIResponsesInputNamespaces(body)
require.NoError(t, err)
require.Equal(t, body, stripped)
for _, keepToolCallNamespaces := range []bool{false, true} {
stripped, err := stripOpenAIResponsesInputNamespaces(body, keepToolCallNamespaces)
require.NoError(t, err)
require.Equal(t, body, stripped)
}
}
}
// 保留模式下只有工具调用项留住 namespace:上游按 namespace 解析历史调用,
// 而 message / reasoning / 输出项带该字段会被 schema 拒绝。
func TestStripOpenAIResponsesInputNamespacesKeepsToolCallNamespaces(t *testing.T) {
body := []byte(`{
"meta":9007199254740993,
"input":[
{"type":"function_call","namespace":"collaboration","name":"spawn_agent","arguments":"{}","large":9007199254740993},
{"type":"custom_tool_call","namespace":"codex_app","name":"exec","input":"{}"},
{"type":"tool_call","namespace":"mcp__codex_apps__gmail","name":"send"},
{"type":"mcp_tool_call","namespace":"mcp__codex_apps__gmail","name":"list"},
{"type":"message","namespace":"leftover","role":"assistant","content":[{"type":"output_text","text":"hi"}]},
{"type":"function_call_output","namespace":"leftover","output":"ok"},
{"type":"reasoning","namespace":"leftover"},
{"type":"item","namespace":"leftover"}
]
}`)
stripped, err := stripOpenAIResponsesInputNamespaces(body, true)
require.NoError(t, err)
require.Equal(t, "collaboration", gjson.GetBytes(stripped, "input.0.namespace").String())
require.Equal(t, "codex_app", gjson.GetBytes(stripped, "input.1.namespace").String())
require.Equal(t, "mcp__codex_apps__gmail", gjson.GetBytes(stripped, "input.2.namespace").String())
require.Equal(t, "mcp__codex_apps__gmail", gjson.GetBytes(stripped, "input.3.namespace").String())
for index := 4; index < 8; index++ {
require.False(t, gjson.GetBytes(stripped, "input."+strconv.Itoa(index)+".namespace").Exists())
}
// 大整数不得经 float64 往返。
require.Equal(t, gjson.GetBytes(body, "meta").Raw, gjson.GetBytes(stripped, "meta").Raw)
require.Equal(t, gjson.GetBytes(body, "input.0.large").Raw, gjson.GetBytes(stripped, "input.0.large").Raw)
// 类型比对不区分大小写与首尾空白。
mixedCase := []byte(`{"input":[{"type":" Function_Call ","namespace":"collaboration","name":"spawn_agent"}]}`)
keptMixedCase, err := stripOpenAIResponsesInputNamespaces(mixedCase, true)
require.NoError(t, err)
require.Equal(t, mixedCase, keptMixedCase)
// 全部为调用项时无改动,应原样返回。
callsOnly := []byte(`{"input":[{"type":"function_call","namespace":"collaboration","name":"spawn_agent"}]}`)
unchanged, err := stripOpenAIResponsesInputNamespaces(callsOnly, true)
require.NoError(t, err)
require.Equal(t, callsOnly, unchanged)
// 关闭保留时回到全量清理。
strippedAll, err := stripOpenAIResponsesInputNamespaces(body, false)
require.NoError(t, err)
for index := 0; index < 8; index++ {
require.False(t, gjson.GetBytes(strippedAll, "input."+strconv.Itoa(index)+".namespace").Exists())
}
}