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())
}
}
@@ -82,6 +82,57 @@
</div>
</div>
<!-- OpenAI Codex namespace 工具摊平(兼容开关,仅 OAuth) -->
<div
v-if="allOpenAIOAuthOnly"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="mb-3 flex items-center justify-between">
<div class="flex-1 pr-4">
<label
id="bulk-edit-openai-flatten-namespaces-label"
class="input-label mb-0"
for="bulk-edit-openai-flatten-namespaces-enabled"
>
{{ t('admin.accounts.openai.flattenNamespaces') }}
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.flattenNamespacesDesc') }}
</p>
</div>
<input
v-model="enableOpenAIFlattenNamespaces"
id="bulk-edit-openai-flatten-namespaces-enabled"
type="checkbox"
aria-controls="bulk-edit-openai-flatten-namespaces-body"
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</div>
<div
id="bulk-edit-openai-flatten-namespaces-body"
:class="!enableOpenAIFlattenNamespaces && 'pointer-events-none opacity-50'"
role="group"
aria-labelledby="bulk-edit-openai-flatten-namespaces-label"
>
<button
id="bulk-edit-openai-flatten-namespaces-toggle"
type="button"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
openaiFlattenNamespacesEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
@click="openaiFlattenNamespacesEnabled = !openaiFlattenNamespacesEnabled"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
openaiFlattenNamespacesEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<!-- Base URL (API Key only) -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
@@ -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<number[]>([])
const openaiPassthroughEnabled = ref(false)
// Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留
const openaiFlattenNamespacesEnabled = ref(false)
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const upstreamBillingAutoProbeMode = ref<'enabled' | 'disabled'>('enabled')
@@ -1638,6 +1702,12 @@ const buildUpdatePayload = (): Record<string, unknown> | 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 = []
@@ -2781,6 +2781,37 @@
</div>
</div>
<!-- OpenAI Codex namespace 工具摊平(兼容开关,仅 OAuth) -->
<div
v-if="form.platform === 'openai' && form.type === 'oauth'"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.openai.flattenNamespaces') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.flattenNamespacesDesc') }}
</p>
</div>
<button
type="button"
data-testid="create-openai-flatten-namespaces-toggle"
@click="openaiFlattenNamespacesEnabled = !openaiFlattenNamespacesEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
openaiFlattenNamespacesEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
openaiFlattenNamespacesEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<!-- OpenAI WS Mode 三态(off/ctx_pool/passthrough) -->
<div
v-if="form.platform === 'openai' && (accountCategory === 'oauth-based' || accountCategory === 'apikey')"
@@ -3778,6 +3809,8 @@ const applyGrokOAuthUpstreamConfig = (credentials: Record<string, unknown>) => {
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<OpenAICompactMode>('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<string, unknown>): Record<string, unknow
delete extra.openai_passthrough
delete extra.openai_oauth_passthrough
}
// 缺省即保留 namespace,不写空值,避免 extra 里堆积默认项
if (form.type === 'oauth' && openaiFlattenNamespacesEnabled.value) {
extra.openai_responses_flatten_namespaces = true
} else {
delete extra.openai_responses_flatten_namespaces
}
extra.openai_long_context_billing_enabled = openAILongContextBillingEnabled.value
if (accountCategory.value === 'oauth-based' && codexCLIOnlyEnabled.value) {
@@ -1481,6 +1481,37 @@
</div>
</div>
<!-- OpenAI Codex namespace 工具摊平(兼容开关,仅 OAuth) -->
<div
v-if="account?.platform === 'openai' && account?.type === 'oauth'"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.openai.flattenNamespaces') }}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.openai.flattenNamespacesDesc') }}
</p>
</div>
<button
type="button"
data-testid="edit-openai-flatten-namespaces-toggle"
@click="openaiFlattenNamespacesEnabled = !openaiFlattenNamespacesEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
openaiFlattenNamespacesEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
openaiFlattenNamespacesEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<!-- OpenAI Codex hosted image_generation bridge policy -->
<div
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'setup-token' || account?.type === 'apikey')"
@@ -2841,6 +2872,8 @@ const customBaseUrl = ref('')
// OpenAI 自动透传开关(OAuth/API Key)
const openaiPassthroughEnabled = ref(false)
// OpenAI Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留
const openaiFlattenNamespacesEnabled = ref(false)
const openAILongContextBillingEnabled = ref(false)
// OpenAI 订阅档位(Plus/Pro/Free)手动覆盖值,存于 credentials.plan_type;'' 表示清空/自动识别
const editPlanType = ref<string>('')
@@ -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 {
@@ -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'],
@@ -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')
@@ -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()
@@ -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.<namespace>.<tool>. 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.',
@@ -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',