mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 16:48:45 +08:00
Merge pull request #4981 from INKCR0W/fix/openai-preserve-codex-namespaces
fix(openai): OAuth 原生 Responses 默认保留 Codex namespace,修复 code_mode_only 模型无法派发子代理
This commit is contained in:
@@ -1844,6 +1844,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',
|
||||
|
||||
Reference in New Issue
Block a user