From 272735b0a7bcc4a56c1c4ebe45792da444abb010 Mon Sep 17 00:00:00 2001 From: InkCrow Date: Tue, 28 Jul 2026 09:19:13 +0800 Subject: [PATCH] fix(openai): preserve Codex namespace tools on OAuth Responses forwarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/internal/service/account.go | 16 ++ .../service/openai_gateway_forward.go | 11 +- .../service/openai_oauth_passthrough_test.go | 92 ++++++++++- .../service/openai_responses_namespace.go | 105 ++++++++++++- ...openai_responses_namespace_forward_test.go | 147 ++++++++++++++++++ .../openai_responses_namespace_test.go | 142 +++++++++++++++-- .../account/BulkEditAccountModal.vue | 73 +++++++++ .../components/account/CreateAccountModal.vue | 41 +++++ .../components/account/EditAccountModal.vue | 42 +++++ .../__tests__/BulkEditAccountModal.spec.ts | 28 ++++ .../__tests__/CreateAccountModal.spec.ts | 15 ++ .../__tests__/EditAccountModal.spec.ts | 51 ++++++ .../src/i18n/locales/en/admin/accounts.ts | 3 + .../src/i18n/locales/zh/admin/accounts.ts | 3 + 14 files changed, 743 insertions(+), 26 deletions(-) create mode 100644 backend/internal/service/openai_responses_namespace_forward_test.go diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index b7f6d2266a..5728d1a83f 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -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 { diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index b3963535b1..c4f54c0301 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -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) diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 43f8109f88..12bff4a046 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -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) diff --git a/backend/internal/service/openai_responses_namespace.go b/backend/internal/service/openai_responses_namespace.go index bd548b69ec..73760f567b 100644 --- a/backend/internal/service/openai_responses_namespace.go +++ b/backend/internal/service/openai_responses_namespace.go @@ -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..` 寻址约定,模型据此寻址必然落空 +// (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 diff --git a/backend/internal/service/openai_responses_namespace_forward_test.go b/backend/internal/service/openai_responses_namespace_forward_test.go new file mode 100644 index 0000000000..e99d0199fd --- /dev/null +++ b/backend/internal/service/openai_responses_namespace_forward_test.go @@ -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)) +} diff --git a/backend/internal/service/openai_responses_namespace_test.go b/backend/internal/service/openai_responses_namespace_test.go index 0b12355a3d..80095f3293 100644 --- a/backend/internal/service/openai_responses_namespace_test.go +++ b/backend/internal/service/openai_responses_namespace_test.go @@ -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..` 寻址(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()) } } diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index a4268fb926..1c277cb552 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -82,6 +82,57 @@ + +
+
+
+ +

+ {{ t('admin.accounts.openai.flattenNamespacesDesc') }} +

+
+ +
+
+ +
+
+
@@ -1331,6 +1382,16 @@ const allOpenAIOAuth = computed(() => { ) }) +// 严格 OAuth(不含 setup-token):namespace 摊平兼容开关只对 OAuth 账号生效 +const allOpenAIOAuthOnly = computed(() => { + return ( + targetSelectedPlatforms.value.length === 1 && + targetSelectedPlatforms.value[0] === 'openai' && + targetSelectedTypes.value.length > 0 && + targetSelectedTypes.value.every(t => t === 'oauth') + ) +}) + const allOpenAIAPIKey = computed(() => { return ( targetSelectedPlatforms.value.length === 1 && @@ -1398,6 +1459,7 @@ const enableRateMultiplier = ref(false) const enableStatus = ref(false) const enableGroups = ref(false) const enableOpenAIPassthrough = ref(false) +const enableOpenAIFlattenNamespaces = ref(false) const enableOpenAIWSMode = ref(false) const enableOpenAIAPIKeyWSMode = ref(false) const enableUpstreamBillingAutoProbe = ref(false) @@ -1429,6 +1491,8 @@ const rateMultiplier = ref(1) const status = ref<'active' | 'inactive'>('active') const groupIds = ref([]) const openaiPassthroughEnabled = ref(false) +// Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留 +const openaiFlattenNamespacesEnabled = ref(false) const openaiOAuthResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const openaiAPIKeyResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const upstreamBillingAutoProbeMode = ref<'enabled' | 'disabled'>('enabled') @@ -1638,6 +1702,12 @@ const buildUpdatePayload = (): Record | null => { } } + // 同时校验可见性:勾选后又改了目标筛选条件时,不应把该键写到非 OAuth 账号上 + if (enableOpenAIFlattenNamespaces.value && allOpenAIOAuthOnly.value) { + const extra = ensureExtra() + extra.openai_responses_flatten_namespaces = openaiFlattenNamespacesEnabled.value + } + if (enableModelRestriction.value && !isOpenAIModelRestrictionDisabled.value) { // 统一使用 model_mapping 字段 if (modelRestrictionMode.value === 'whitelist') { @@ -1806,6 +1876,7 @@ const handleSubmit = async () => { const hasAnyFieldEnabled = enableBaseUrl.value || enableOpenAIPassthrough.value || + enableOpenAIFlattenNamespaces.value || enableModelRestriction.value || enableCustomErrorCodes.value || enableInterceptWarmup.value || @@ -1946,6 +2017,7 @@ watch( enableStatus.value = false enableGroups.value = false enableOpenAIPassthrough.value = false + enableOpenAIFlattenNamespaces.value = false enableOpenAIWSMode.value = false enableOpenAIAPIKeyWSMode.value = false enableUpstreamBillingAutoProbe.value = false @@ -1958,6 +2030,7 @@ watch( // Reset all values baseUrl.value = '' openaiPassthroughEnabled.value = false + openaiFlattenNamespacesEnabled.value = false modelRestrictionMode.value = 'whitelist' allowedModels.value = [] modelMappings.value = [] diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index f71bbbada8..dd63736fd2 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2781,6 +2781,37 @@
+ +
+
+
+ +

+ {{ t('admin.accounts.openai.flattenNamespacesDesc') }} +

+
+ +
+
+
) => { const interceptWarmupRequests = ref(false) const autoPauseOnExpired = ref(true) const openaiPassthroughEnabled = ref(false) +// OpenAI Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留 +const openaiFlattenNamespacesEnabled = ref(false) const openAILongContextBillingEnabled = ref(false) const openAILongContextBillingTouched = ref(false) const openAICompactMode = ref('auto') @@ -4231,6 +4264,7 @@ watch( } if (newPlatform !== 'openai') { openaiPassthroughEnabled.value = false + openaiFlattenNamespacesEnabled.value = false openAIEndpointCapabilities.value = ['chat_completions', 'embeddings'] openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF @@ -4656,6 +4690,7 @@ const resetForm = () => { interceptWarmupRequests.value = false autoPauseOnExpired.value = true openaiPassthroughEnabled.value = false + openaiFlattenNamespacesEnabled.value = false openAILongContextBillingEnabled.value = false openAILongContextBillingTouched.value = false openAICompactMode.value = 'auto' @@ -4740,6 +4775,12 @@ const buildOpenAIExtra = (base?: Record): Record
+ +
+
+
+ +

+ {{ t('admin.accounts.openai.flattenNamespacesDesc') }} +

+
+ +
+
+
('') @@ -3276,6 +3309,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { // Load OpenAI passthrough toggle (OpenAI OAuth/SetupToken/API Key) openaiPassthroughEnabled.value = false + openaiFlattenNamespacesEnabled.value = false openAILongContextBillingEnabled.value = false editPlanType.value = '' openAICompactMode.value = 'auto' @@ -3292,6 +3326,8 @@ const syncFormFromAccount = (newAccount: Account | null) => { webSearchEmulationMode.value = 'default' if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'setup-token' || newAccount.type === 'apikey')) { openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true + openaiFlattenNamespacesEnabled.value = + newAccount.type === 'oauth' && extra?.openai_responses_flatten_namespaces === true const longContextBillingValue = extra?.openai_long_context_billing_enabled openAILongContextBillingEnabled.value = longContextBillingValue === true // plan_type 手动覆盖仅 OAuth 有实际调度语义(IsOpenAIChatGPTSubscription 要求 oauth),故只对 oauth 回填 @@ -4526,6 +4562,12 @@ const handleSubmit = async () => { delete newExtra.openai_passthrough delete newExtra.openai_oauth_passthrough } + // 缺省即保留 namespace,不写空值,避免 extra 里堆积默认项 + if (props.account.type === 'oauth' && openaiFlattenNamespacesEnabled.value) { + newExtra.openai_responses_flatten_namespaces = true + } else { + delete newExtra.openai_responses_flatten_namespaces + } if (isSparkShadow.value) { delete newExtra.openai_long_context_billing_enabled } else { diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts index cedabc58d7..51702e8fe2 100644 --- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts @@ -237,6 +237,34 @@ describe('BulkEditAccountModal', () => { }) }) + it('OpenAI OAuth 批量编辑可开启 namespace 摊平兼容开关', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['oauth'] + }) + + await wrapper.get('#bulk-edit-openai-flatten-namespaces-enabled').setValue(true) + await wrapper.get('#bulk-edit-openai-flatten-namespaces-toggle').trigger('click') + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1) + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + extra: { + openai_responses_flatten_namespaces: true + } + }) + }) + + it('namespace 摊平开关不对 setup-token 等非 OAuth 选择展示', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['oauth', 'setup-token'] + }) + + expect(wrapper.find('#bulk-edit-openai-flatten-namespaces-enabled').exists()).toBe(false) + }) + it('OpenAI OAuth 批量编辑应提交 OAuth 专属 WS mode 字段(含 http_bridge)', async () => { const wrapper = mountModal({ selectedPlatforms: ['openai'], diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index 13035a9d3b..f2e0c61a8b 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -167,6 +167,21 @@ describe('CreateAccountModal OpenAI long-context billing', () => { expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false) }) + // namespace 摊平是仅 OAuth 的兼容开关:API Key 走 chat completions 回退桥时由桥自行摊平 + it('shows the Codex namespace flatten toggle only for OpenAI OAuth accounts', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + + expect(wrapper.find('[data-testid="create-openai-flatten-namespaces-toggle"]').exists()).toBe( + true + ) + + await selectButtonByText(wrapper, 'API Key') + expect(wrapper.find('[data-testid="create-openai-flatten-namespaces-toggle"]').exists()).toBe( + false + ) + }) + it('enables upstream billing probes by default for new OpenAI API key accounts', async () => { await submitApiKeyAccount('openai') diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 8cd20e89ed..e5797bf0c6 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -416,6 +416,57 @@ describe('EditAccountModal', () => { expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_long_context_billing_enabled).toBe(false) }) + it('loads and clears the OAuth-only Codex namespace flatten toggle', async () => { + const account = buildAccount() + account.type = 'oauth' + account.extra = { + openai_responses_flatten_namespaces: true + } + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + const toggle = wrapper.get('[data-testid="edit-openai-flatten-namespaces-toggle"]') + + // 关闭后应从 extra 中删除该键,而不是写入 false + await toggle.trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty( + 'openai_responses_flatten_namespaces' + ) + }) + + it('submits the Codex namespace flatten toggle when switched on', async () => { + const account = buildAccount() + account.type = 'oauth' + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + await wrapper.get('[data-testid="edit-openai-flatten-namespaces-toggle"]').trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_responses_flatten_namespaces).toBe( + true + ) + }) + + it('hides the Codex namespace flatten toggle for non-OAuth OpenAI accounts', async () => { + const account = buildAccount() + const wrapper = mountModal(account) + + expect(wrapper.find('[data-testid="edit-openai-flatten-namespaces-toggle"]').exists()).toBe( + false + ) + }) + it('defaults legacy OpenAI accounts to long-context billing disabled', async () => { const account = buildAccount() updateAccountMock.mockReset() diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 483459061f..54146fab43 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -489,6 +489,9 @@ export default { oauthPassthrough: 'Auto passthrough (auth only)', oauthPassthroughDesc: 'When enabled, this OpenAI account uses automatic passthrough: the gateway forwards request/response as-is and only swaps auth, while keeping billing/concurrency/audit and necessary safety filtering.', + flattenNamespaces: 'Flatten Codex namespace tools (compatibility)', + flattenNamespacesDesc: + 'Disabled by default: Codex namespace tool declarations are forwarded as-is on /responses, which is what the ChatGPT Codex backend expects. Enable only when this OAuth account is routed to a relay that rejects namespace tools — flattening renames them to namespace__tool, which breaks models that address collaboration tools as functions... Compaction requests always flatten regardless of this switch.', longContextBilling: 'API long-context pricing', longContextBillingDesc: 'Disabled by default. Enable only when this account\'s upstream charges OpenAI API long-context rates above the model threshold.', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index 98d22a453a..5827300c2a 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -556,6 +556,9 @@ export default { oauthPassthrough: '自动透传(仅替换认证)', oauthPassthroughDesc: '开启后,该 OpenAI 账号将自动透传请求与响应,仅替换认证并保留计费/并发/审计及必要安全过滤;如遇兼容性问题可随时关闭回滚。', + flattenNamespaces: '摊平 Codex namespace 工具(兼容)', + flattenNamespacesDesc: + '默认关闭:/responses 上的 namespace 工具声明原样转发,这正是 ChatGPT Codex 后端期望的形态。仅当该 OAuth 账号指向不认识 namespace 的兼容上游时才开启——摊平会把工具改名为 namespace__tool,使按 functions.<命名空间>.<工具> 寻址的模型(如 gpt-5.6 多智能体)无法调用。压缩(compact)请求不受该开关影响,始终摊平。', longContextBilling: 'API 长上下文计费', longContextBillingDesc: '默认关闭。仅当该账号的上游会按模型阈值收取 OpenAI API 长上下文费率时开启。', responsesWebsocketsV2: 'Responses WebSocket v2',