diff --git a/backend/internal/pkg/openai/request.go b/backend/internal/pkg/openai/request.go index 1165804f1c..33e46c5187 100644 --- a/backend/internal/pkg/openai/request.go +++ b/backend/internal/pkg/openai/request.go @@ -265,6 +265,62 @@ func CodexUserAgentVersion(userAgent string) string { return strings.TrimSpace(rest) } +// SetCodexUserAgentVersion 用 version 重建 Codex 形态 UA 中的版本声明,其余部分 +// (客户端名、OS / 架构 / 终端指纹)原样保留;UA 不是 `{client}/{version}` 形态时返回空串, +// 由调用方决定整体回退。 +// +// 尾部官方客户端标识组 `(name; version)` 与首段是同一个版本声明的两个出口 +// (CODEX_INTERNAL_ORIGINATOR_OVERRIDE 场景,如 `cccc/0.142.0 ... (codex-tui; 0.142.0)`), +// 必须一并更新,否则会拼出首段声明新版本、尾部仍是旧版本的自相矛盾身份。 +// 仅在括号组确为官方客户端标识时才改写,避免误伤 OS 组(如 `(Ubuntu 22.4.0; x86_64)`)。 +func SetCodexUserAgentVersion(userAgent, version string) string { + ua := strings.TrimSpace(userAgent) + version = strings.TrimSpace(version) + if version == "" { + return "" + } + slash := strings.IndexByte(ua, '/') + if slash <= 0 { + return "" + } + client := strings.TrimSpace(ua[:slash]) + if client == "" { + return "" + } + rest := ua[slash+1:] + tail := "" + if space := strings.IndexByte(rest, ' '); space >= 0 { + tail = rest[space:] + } else if strings.TrimSpace(rest) == "" { + // `client/` 没有版本段,不是可重建的 Codex 形态。 + return "" + } + return rewriteCodexUATrailerVersion(client+"/"+version+tail, version) +} + +// rewriteCodexUATrailerVersion 把尾部官方客户端标识组 `(name; version)` 的版本改成 version。 +// 括号组缺少 `;` 分隔的版本、或 name 不是官方 originator 时原样返回。 +func rewriteCodexUATrailerVersion(ua, version string) string { + open := strings.LastIndex(ua, "(") + if open < 0 { + return ua + } + closeIdx := strings.Index(ua[open+1:], ")") + if closeIdx < 0 { + return ua + } + inner := ua[open+1 : open+1+closeIdx] + semi := strings.Index(inner, ";") + if semi < 0 { + return ua + } + name := strings.TrimSpace(inner[:semi]) + if name == "" || !IsCodexOfficialClientOriginator(name) { + return ua + } + return ua[:open+1] + name + "; " + version + ua[open+1+closeIdx:] +} + // codexEngineVersionPattern 提取版本段开头的三段数字 X.Y.Z(忽略 -alpha 等后缀)。 var codexEngineVersionPattern = regexp.MustCompile(`^(\d+\.\d+\.\d+)`) diff --git a/backend/internal/pkg/openai/request_codex_version_test.go b/backend/internal/pkg/openai/request_codex_version_test.go new file mode 100644 index 0000000000..daf12fa8fe --- /dev/null +++ b/backend/internal/pkg/openai/request_codex_version_test.go @@ -0,0 +1,46 @@ +package openai + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCodexUserAgentVersion(t *testing.T) { + require.Equal(t, "0.146.0", CodexUserAgentVersion("codex_cli_rs/0.146.0 (Ubuntu 22.4.0; x86_64) xterm-256color")) + require.Equal(t, "0.146.0", CodexUserAgentVersion(" codex_cli_rs/0.146.0 ")) + // 预发布后缀原样保留:出站 version 头必须与 UA 版本段逐字一致。 + require.Equal(t, "0.147.0-alpha.4", CodexUserAgentVersion("codex-tui/0.147.0-alpha.4 (Mac OS X 14.0; arm64) iTerm")) + // 非 `{client}/{version}` 形态取不到版本段。 + require.Empty(t, CodexUserAgentVersion("curl 8.7.1")) + require.Empty(t, CodexUserAgentVersion("/0.146.0")) + require.Empty(t, CodexUserAgentVersion("")) +} + +// 管理员在面板 / 账号上配置的 UA 填写于某个历史版本,逐字沿用会把出站身份钉死在陈旧版本上。 +// 重建只动版本声明,OS / 架构 / 终端指纹必须原样保留——那是该配置项唯一不可替代的价值。 +func TestSetCodexUserAgentVersion(t *testing.T) { + require.Equal(t, + "codex_cli_rs/0.146.0 (Ubuntu 22.4.0; x86_64) xterm-256color", + SetCodexUserAgentVersion("codex_cli_rs/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color", "0.146.0"), + ) + require.Equal(t, "codex_cli_rs/0.146.0", SetCodexUserAgentVersion("codex_cli_rs/0.1.0", "0.146.0")) + + // 尾部官方客户端标识组与首段是同一个版本声明的两个出口,必须一并更新, + // 否则拼出首段声明新版本、尾部仍是旧版本的自相矛盾身份。 + require.Equal(t, + "cccc/0.146.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.146.0)", + SetCodexUserAgentVersion("cccc/0.142.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.142.0)", "0.146.0"), + ) + // OS 括号组不是客户端标识,不得被误改。 + require.Equal(t, + "codex_cli_rs/0.146.0 (Ubuntu 22.4.0; x86_64)", + SetCodexUserAgentVersion("codex_cli_rs/0.125.0 (Ubuntu 22.4.0; x86_64)", "0.146.0"), + ) + + // 无法重建时返回空串,由调用方决定整体回退,绝不拼出畸形身份。 + require.Empty(t, SetCodexUserAgentVersion("not-a-codex-client", "0.146.0")) + require.Empty(t, SetCodexUserAgentVersion("codex_cli_rs/", "0.146.0")) + require.Empty(t, SetCodexUserAgentVersion("/0.1.0", "0.146.0")) + require.Empty(t, SetCodexUserAgentVersion("codex_cli_rs/0.1.0", "")) +} diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 134abe3819..58a6c2f82a 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -653,8 +653,9 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account req.Header.Set("User-Agent", codexCLIUserAgent) } setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) - // 与真实转发一致:originator 与最终 User-Agent 首段配套,否则上游 404(issue #3901)。 - enforceCodexIdentityHeaders(req.Header) + // 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入,否则测试用的身份 + // 与该账号真实出站的身份不是同一个(issue #3901 的配对不变式由收口保证)。 + enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent()) } // 账号级请求头覆写:测试请求与真实转发保持一致的最终头 @@ -1866,8 +1867,9 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co req.Header.Set("User-Agent", codexCLIUserAgent) } setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) - // 与真实转发一致:originator 与最终 User-Agent 首段配套(原 opencode 与 Codex UA 错配会 404,issue #3901)。 - enforceCodexIdentityHeaders(req.Header) + // 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入,否则测试用的身份 + // 与该账号真实出站的身份不是同一个(issue #3901 的配对不变式由收口保证)。 + enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent()) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index a0b82890b7..7f727b470f 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -767,9 +767,10 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco req.Header.Set("User-Agent", strings.TrimSpace(fp.UserAgent)) } } - // 与真实转发一致:originator 与最终 User-Agent(可能来自指纹缓存)首段配套,否则探针被上游 - // 404(issue #3901);缓存里被优先降载的身份同样在此归一化,避免探针被回 server_is_overloaded。 - enforceCodexIdentityHeaders(req.Header) + // 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入。 + // 上面写进 header 的指纹缓存 UA 只在强制统一被关闭时才参与配对(保持回滚后的历史语义); + // 强制统一开启时客户端身份不参与构造,探针与真实转发用同一套规范身份出站。 + enforceCodexIdentityHeadersWithUA(req.Header, account.GetOpenAIUserAgent()) setOpenAIChatGPTAccountHeaders(req.Header, account) proxyURL := "" diff --git a/backend/internal/service/openai_codex_identity.go b/backend/internal/service/openai_codex_identity.go index 1ef39799d3..aac5301c36 100644 --- a/backend/internal/service/openai_codex_identity.go +++ b/backend/internal/service/openai_codex_identity.go @@ -100,25 +100,42 @@ type codexOutboundIdentity struct { // resolveCodexOutboundIdentity 由候选 User-Agent 推导自洽的出站身份。 // candidateUA 为空时使用规范 User-Agent;推导不出官方身份时整体回退为规范 CLI 身份。 +// +// 候选 UA(面板 / 账号级的管理员显式配置)只贡献客户端名与 OS / 架构 / 终端指纹, +// 其自带的版本段一律用当前生效版本重建:一条填写于某个历史版本的 UA 否则会把出站身份 +// 永久钉死在陈旧版本上,绕过版本自动同步,落回上游优先降载的那一侧。 +// 需要固定版本请填「Codex 客户端版本号」并关闭自动同步。 func resolveCodexOutboundIdentity(candidateUA string) codexOutboundIdentity { + canonical := codexCanonicalUserAgent() ua := strings.TrimSpace(candidateUA) if ua == "" { - ua = codexCanonicalUserAgent() + ua = canonical } originator, pairedUA, ok := openai.PairCodexClientIdentity(ua) if !ok { - canonical := codexCanonicalUserAgent() if originator, pairedUA, ok = openai.PairCodexClientIdentity(canonical); !ok { originator, pairedUA = openai.CodexCLIOriginator, codexCLIUserAgent } } - version := NormalizeCodexClientVersion(openai.CodexUserAgentVersion(pairedUA)) - if version == "" || CompareVersions(version, codexUpstreamMinVersion) < 0 { - version = codexCLIVersion + // 生效版本只有一个来源:规范身份(面板版本号 → 自动同步值 → 内置常量,见 + // SettingService.GetOpenAICodexClientVersion)。UA 与 version 头由此同源派生。 + version := codexClientVersionFromUA(canonical) + if rebuilt := openai.SetCodexUserAgentVersion(pairedUA, version); rebuilt != "" { + pairedUA = rebuilt } return codexOutboundIdentity{userAgent: pairedUA, originator: originator, version: version} } +// codexClientVersionFromUA 取 UA 的版本段作为生效版本; +// 非法或低于上游门槛(低于则上游 404,issue #3901)时回退编译期常量。 +func codexClientVersionFromUA(ua string) string { + version := NormalizeCodexClientVersion(openai.CodexUserAgentVersion(ua)) + if version == "" || CompareVersions(version, codexUpstreamMinVersion) < 0 { + return codexCLIVersion + } + return version +} + // ensureCodexIdentityHeaders 补齐 OAuth(ChatGPT 内部接口)出站请求所需的 Codex 身份头。 // 已有 User-Agent 与 version 保持不变,交给紧随其后的 enforceCodexIdentityHeaders 收口。 func ensureCodexIdentityHeaders(h http.Header) { @@ -158,8 +175,8 @@ func enforceCodexIdentityHeaders(h http.Header) { // 降载,被降载的请求会拿到 HTTP 200 + 流内 server_is_overloaded;统一出口可确保没有请求带着 // 第三方或陈旧身份出站,也天然满足 originator 与 UA 首段配套的上游校验(issue #3901)。 // -// overrideUA 是账号级自定义 User-Agent:管理员的显式配置仍然生效,但会被重新配对出配套的 -// originator 与同源 version,不允许出现自相矛盾的身份。 +// overrideUA 是账号级自定义 User-Agent:管理员的显式配置仍然生效,但只贡献客户端名与 +// OS / 架构 / 终端指纹——版本段与 originator 都由规范身份重建,不允许出现自相矛盾或陈旧的身份。 // // 强制统一被 gateway.disable_codex_identity_enforcement 关闭时,退回「按最终 User-Agent 配对 // originator + version 门槛校正」的收口语义,供上游策略变动时回滚。 diff --git a/backend/internal/service/openai_codex_identity_test.go b/backend/internal/service/openai_codex_identity_test.go index ec8c4ad296..70a4fbc3d6 100644 --- a/backend/internal/service/openai_codex_identity_test.go +++ b/backend/internal/service/openai_codex_identity_test.go @@ -119,10 +119,10 @@ func TestEnforceCodexIdentityHeaders(t *testing.T) { } } -// 账号级自定义 UA 是管理员的显式配置,仍然生效;但必须被重新配对出配套的 originator -// 与同源 version,不允许出现自相矛盾的身份。 +// 账号级自定义 UA 是管理员的显式配置,仍然生效;但它只贡献客户端名与 OS / 架构 / 终端指纹, +// originator 与版本段一律由规范身份重建,不允许出现自相矛盾或陈旧的身份。 func TestEnforceCodexIdentityHeadersWithAccountOverrideUA(t *testing.T) { - t.Run("官方形态覆写 UA 被配对并对齐 version", func(t *testing.T) { + t.Run("官方形态覆写 UA 保留指纹但重建版本段", func(t *testing.T) { h := make(http.Header) h.Set("originator", "codex-tui") h.Set("user-agent", "luna/1.0.0") @@ -131,8 +131,8 @@ func TestEnforceCodexIdentityHeadersWithAccountOverrideUA(t *testing.T) { enforceCodexIdentityHeadersWithUA(h, "codex_vscode/0.150.0 (Ubuntu 22.4.0; x86_64) vscode") require.Equal(t, "codex_vscode", h.Get("originator")) - require.Equal(t, "codex_vscode/0.150.0 (Ubuntu 22.4.0; x86_64) vscode", h.Get("user-agent")) - require.Equal(t, "0.150.0", h.Get("version")) + require.Equal(t, "codex_vscode/"+codexCLIVersion+" (Ubuntu 22.4.0; x86_64) vscode", h.Get("user-agent")) + require.Equal(t, codexCLIVersion, h.Get("version")) }) t.Run("非官方形态覆写 UA 回退规范身份", func(t *testing.T) { @@ -146,16 +146,36 @@ func TestEnforceCodexIdentityHeadersWithAccountOverrideUA(t *testing.T) { require.Equal(t, codexCLIVersion, h.Get("version")) }) - // 覆写 UA 自报版本低于上游门槛时 version 头必须抬升,否则上游 404(issue #3901)。 - t.Run("覆写 UA 版本低于门槛时抬升 version", func(t *testing.T) { + // 回归:覆写 UA 填写于某个历史版本时,其版本段必须被重建而不是逐字沿用—— + // 否则这条配置会绕过版本自动同步,把出站身份永久钉死在陈旧版本上, + // 稳定落在上游优先降载的那一侧(UA 与 version 头也不再同源)。 + t.Run("陈旧覆写 UA 的版本段被重建", func(t *testing.T) { h := make(http.Header) h.Set("originator", "codex_cli_rs") enforceCodexIdentityHeadersWithUA(h, "codex_cli_rs/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color") require.Equal(t, "codex_cli_rs", h.Get("originator")) - require.Equal(t, "codex_cli_rs/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color", h.Get("user-agent")) + require.Equal(t, "codex_cli_rs/"+codexCLIVersion+" (Ubuntu 22.4.0; x86_64) xterm-256color", h.Get("user-agent")) require.Equal(t, codexCLIVersion, h.Get("version")) + require.NotContains(t, h.Get("user-agent"), "0.125.0") + }) + + // 陈旧覆写 UA 同样跟随自动同步到的新版本,无需管理员重新编辑那条 UA。 + t.Run("陈旧覆写 UA 跟随同步版本", func(t *testing.T) { + SetCodexCanonicalUserAgentResolver(func() string { + return "codex_cli_rs/0.200.1" + codexCLIUserAgentSuffix + }) + t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) }) + + h := make(http.Header) + h.Set("originator", "codex_cli_rs") + + enforceCodexIdentityHeadersWithUA(h, "codex-tui/0.125.0 (Mac OS X 14.0; arm64) iTerm") + + require.Equal(t, "codex-tui", h.Get("originator")) + require.Equal(t, "codex-tui/0.200.1 (Mac OS X 14.0; arm64) iTerm", h.Get("user-agent")) + require.Equal(t, "0.200.1", h.Get("version")) }) } diff --git a/backend/internal/service/openai_codex_version_sync_service_test.go b/backend/internal/service/openai_codex_version_sync_service_test.go index 586f447f2b..6633f6aec2 100644 --- a/backend/internal/service/openai_codex_version_sync_service_test.go +++ b/backend/internal/service/openai_codex_version_sync_service_test.go @@ -247,6 +247,59 @@ func TestGetOpenAICodexCanonicalUserAgentBuildsFromVersion(t *testing.T) { ) } +// 回归:面板完整 UA 是唯一能改 OS / 架构 / 终端指纹的地方,必须保留;但它填写于某个 +// 历史版本,逐字沿用会绕过版本自动同步、把出站身份永久钉死在陈旧版本上——而陈旧身份 +// 正是上游优先降载的那一侧。因此只借它的指纹,版本段一律用生效版本重建。 +func TestGetOpenAICodexCanonicalUserAgentRebuildsPanelUAVersion(t *testing.T) { + t.Run("陈旧面板 UA 跟随生效版本", func(t *testing.T) { + svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{ + // 历史面板 placeholder 的原文,照抄填写过的存量部署就是这个值。 + SettingKeyOpenAICodexUserAgent: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color", + SettingKeyOpenAICodexClientVersionSynced: "0.200.1", + }}, nil) + + require.Equal(t, + "codex_cli_rs/0.200.1 (Ubuntu 22.4.0; x86_64) xterm-256color", + svc.GetOpenAICodexCanonicalUserAgent(context.Background()), + ) + }) + + t.Run("自定义指纹原样保留", func(t *testing.T) { + svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{ + SettingKeyOpenAICodexUserAgent: "codex_cli_rs/0.140.0 (Mac OS X 15.1.0; arm64) iTerm.app", + SettingKeyOpenAICodexClientVersionSynced: "0.200.1", + }}, nil) + + require.Equal(t, + "codex_cli_rs/0.200.1 (Mac OS X 15.1.0; arm64) iTerm.app", + svc.GetOpenAICodexCanonicalUserAgent(context.Background()), + ) + }) + + // 面板版本号覆写优先级仍然高于同步值:管理员固定版本的诉求不被重建绕开。 + t.Run("面板版本号覆写优先", func(t *testing.T) { + svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{ + SettingKeyOpenAICodexUserAgent: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color", + SettingKeyOpenAICodexClientVersion: "0.150.0", + SettingKeyOpenAICodexClientVersionSynced: "0.200.1", + }}, nil) + + require.Equal(t, + "codex_cli_rs/0.150.0 (Ubuntu 22.4.0; x86_64) xterm-256color", + svc.GetOpenAICodexCanonicalUserAgent(context.Background()), + ) + }) + + // 非 `{client}/{version}` 形态无法重建,原样返回,由收口整体回退规范身份。 + t.Run("非 Codex 形态原样返回", func(t *testing.T) { + svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{ + SettingKeyOpenAICodexUserAgent: "not-a-codex-client", + }}, nil) + + require.Equal(t, "not-a-codex-client", svc.GetOpenAICodexCanonicalUserAgent(context.Background())) + }) +} + func (r *codexVersionSyncSettingRepoStub) Get(_ context.Context, key string) (*Setting, error) { r.mu.Lock() defer r.mu.Unlock() diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index b598813f18..a4418527cb 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -148,11 +148,10 @@ func (s *OpenAIGatewayService) buildOpenAIWSHeaders( if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI { headers.Set("user-agent", codexCLIUserAgent) } - // 终态收口:originator 必须与最终 user-agent 首段配套且为官方身份,非官方 UA 整体回退为 - // 默认 Codex CLI 身份(承接原「非 Codex UA 兜底」,并修复其把 codex-tui 等官方 UA 改写为 - // codex_cli_rs 造成的 originator 错配 404),详见 issue #3901。 + // 终态收口:WS 握手与 HTTP 出站共用同一套身份语义,账号级自定义 UA 同样作为 + // 管理员显式配置传入(上面写进 headers 的值只在强制统一被关闭时才参与配对)。 if account != nil && account.Type == AccountTypeOAuth { - enforceCodexIdentityHeaders(headers) + enforceCodexIdentityHeadersWithUA(headers, s.codexIdentityOverrideUA(account)) } // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)。 diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index a0ac478c2c..ef0a110456 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -846,6 +846,75 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthOriginatorCompatibility(t *testi } } +// 账号级自定义 UA 是管理员的显式配置,WS 握手与 HTTP 出站必须一视同仁地生效—— +// 否则同一个账号在两种传输上以不同身份出站。指纹保留、版本段重建、originator 配套。 +func TestOpenAIGatewayService_Forward_WSv2_OAuthHonorsAccountUserAgent(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + c.Request.Header.Set("User-Agent", "luna/1.0.0") + + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.OAuthEnabled = true + cfg.Gateway.OpenAIWS.APIKeyEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.AllowStoreRecovery = false + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + + captureConn := &openAIWSCaptureConn{ + events: [][]byte{ + []byte(`{"type":"response.completed","response":{"id":"resp_oauth_account_ua","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`), + }, + } + captureDialer := &openAIWSCaptureDialer{conn: captureConn} + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(captureDialer) + + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + openaiWSPool: pool, + } + account := &Account{ + ID: 130, + Name: "openai-oauth-custom-ua", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token-1", + // 填写于某个历史版本的账号级 UA:指纹要保留,版本段不能被逐字沿用。 + "user_agent": "codex-tui/0.125.0 (Mac OS X 15.1.0; arm64) iTerm.app", + }, + Extra: map[string]any{ + "responses_websockets_v2_enabled": true, + }, + } + + body := []byte(`{"model":"gpt-5.1","stream":false,"input":[{"type":"input_text","text":"hello"}]}`) + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "codex-tui", captureDialer.lastHeaders.Get("originator")) + require.Equal(t, + "codex-tui/"+codexCLIVersion+" (Mac OS X 15.1.0; arm64) iTerm.app", + captureDialer.lastHeaders.Get("user-agent"), + ) + require.Equal(t, codexCLIVersion, captureDialer.lastHeaders.Get("version")) +} + func TestOpenAIGatewayService_Forward_WSv2_HeaderSessionFallbackFromPromptCacheKey(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/setting_gateway_runtime.go b/backend/internal/service/setting_gateway_runtime.go index 378e6a3fde..93dfd8546a 100644 --- a/backend/internal/service/setting_gateway_runtime.go +++ b/backend/internal/service/setting_gateway_runtime.go @@ -366,18 +366,27 @@ func (s *SettingService) InvalidateOpenAICodexClientVersionCache() { } // GetOpenAICodexCanonicalUserAgent 返回出站规范 Codex User-Agent。 -// 管理员在面板显式填写的完整 UA 优先;否则按当前生效的客户端版本号拼出标准 CLI UA。 +// 未填面板 UA 时按当前生效的客户端版本号拼出标准 CLI UA。 // -// 面板值等于编译期兜底常量时视同未填写:两者不可区分,而按版本号拼装能拿到更新的版本, +// 面板 UA 只贡献客户端名与 OS / 架构 / 终端指纹,版本段一律用生效版本重建:该输入框是 +// 唯一能改 UA 后缀的地方,但它填写于某个历史版本,逐字沿用会把出站身份永久钉死在陈旧 +// 版本上并绕过自动同步——而陈旧身份正是上游优先降载的那一侧。 // 需要固定版本请填「Codex 客户端版本号」并关闭自动同步。 func (s *SettingService) GetOpenAICodexCanonicalUserAgent(ctx context.Context) string { if s == nil { return codexCLIUserAgent } - if ua := strings.TrimSpace(s.GetOpenAICodexUserAgent(ctx)); ua != "" && ua != DefaultOpenAICodexUserAgent { - return ua + version := s.GetOpenAICodexClientVersion(ctx) + ua := strings.TrimSpace(s.GetOpenAICodexUserAgent(ctx)) + if ua == "" { + return buildCodexCLIUserAgent(version) } - return buildCodexCLIUserAgent(s.GetOpenAICodexClientVersion(ctx)) + if rebuilt := openai.SetCodexUserAgentVersion(ua, version); rebuilt != "" { + return rebuilt + } + // 非 `{client}/{version}` 形态:交给 PairCodexClientIdentity 判定, + // 推导不出官方身份时由收口整体回退规范身份。 + return ua } var legacyClaudeCodeCodexWhitelistEntry = openai.AllowedClientEntry{ diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index a67075aa4f..035a5808f8 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -424,7 +424,7 @@ export default { antigravityUserAgentVersionHint: 'Leave empty to use ANTIGRAVITY_USER_AGENT_VERSION or the built-in default 1.23.2; when set, the admin setting takes precedence.', openaiCodexUserAgent: 'OpenAI Codex UA', openaiCodexUserAgentPlaceholder: 'codex_cli_rs/0.146.0 (Ubuntu 22.4.0; x86_64) xterm-256color', - openaiCodexUserAgentHint: 'The full Codex User-Agent used for all outbound requests. Leave empty to build the standard codex_cli_rs identity from the version below (recommended). Under capacity pressure the upstream sheds load by client identity and drops stale or non-official identities first with server_is_overloaded.', + openaiCodexUserAgentHint: 'The full Codex User-Agent used for all outbound requests, for customizing the OS / arch / terminal fingerprint. Leave empty to build the standard codex_cli_rs identity from the version below (recommended). If set, its version segment is still replaced by the version below, so the UA never stays pinned to whatever release you typed it on — under capacity pressure the upstream sheds load by client identity and drops stale or non-official identities first with server_is_overloaded.', openaiCodexClientVersion: 'Codex client version', openaiCodexClientVersionPlaceholder: 'Leave empty to follow auto-sync', openaiCodexClientVersionHint: 'The Codex client version this gateway declares upstream, shared by the User-Agent and the version header. Leave empty to use the auto-synced latest stable release; setting a value pins it and stops following auto-sync.', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 68531375c3..cc0859ba24 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -417,7 +417,7 @@ export default { antigravityUserAgentVersionHint: '留空时使用 ANTIGRAVITY_USER_AGENT_VERSION 或内置默认值 1.23.2;填写后后台设置优先。', openaiCodexUserAgent: 'OpenAI Codex UA', openaiCodexUserAgentPlaceholder: 'codex_cli_rs/0.146.0 (Ubuntu 22.4.0; x86_64) xterm-256color', - openaiCodexUserAgentHint: '出站统一使用的完整 Codex User-Agent。留空则按下方版本号自动拼出标准 codex_cli_rs 形态(推荐)。上游在容量紧张时按客户端身份分优先级降载,陈旧或非官方形态的身份会被优先丢弃并回 server_is_overloaded。', + openaiCodexUserAgentHint: '出站统一使用的完整 Codex User-Agent,用于自定义 OS / 架构 / 终端指纹。留空则按下方版本号拼出标准 codex_cli_rs 形态(推荐)。填写后其版本段仍会被下方版本号覆盖,避免这条 UA 停在填写时的旧版本——上游在容量紧张时按客户端身份分优先级降载,陈旧或非官方形态的身份会被优先丢弃并回 server_is_overloaded。', openaiCodexClientVersion: 'Codex 客户端版本号', openaiCodexClientVersionPlaceholder: '留空则跟随自动同步', openaiCodexClientVersionHint: '网关对上游声明的 Codex 客户端版本号,User-Agent 与 version 头同源使用。留空表示使用自动同步到的官方最新稳定版;填写后固定为该版本,不再跟随同步。',