Merge pull request #5233 from Wei-Shaw/fix/codex-identity-enforcement

fix(codex): 强制统一出站身份并让客户端版本号跟随官方发布
This commit is contained in:
Wesley Liddick
2026-08-04 14:50:44 +08:00
committed by GitHub
41 changed files with 1789 additions and 449 deletions
+5
View File
@@ -88,6 +88,7 @@ func provideCleanup(
schedulerSnapshot *service.SchedulerSnapshotService,
tokenRefresh *service.TokenRefreshService,
accountExpiry *service.AccountExpiryService,
codexVersionSync *service.OpenAICodexVersionSyncService,
proxyExpiry *service.ProxyExpiryService,
subscriptionExpiry *service.SubscriptionExpiryService,
usageCleanup *service.UsageCleanupService,
@@ -236,6 +237,10 @@ func provideCleanup(
accountExpiry.Stop()
return nil
}},
{"OpenAICodexVersionSyncService", func() error {
codexVersionSync.Stop()
return nil
}},
{"ProxyExpiryService", func() error {
proxyExpiry.Stop()
return nil
+7 -1
View File
@@ -320,6 +320,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
opsIngressRejectAggregator := service.ProvideOpsIngressRejectAggregator(opsRepository, opsService)
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
openAICodexVersionSyncService := service.ProvideOpenAICodexVersionSyncService(settingRepository, settingService, gitHubReleaseClient)
proxyExpiryService := service.ProvideProxyExpiryService(proxyRepository)
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService, leaderLockCache, db)
batchImageWorkerRuntime := service.ProvideBatchImageWorkerRuntime(batchImageRepository, accountRepository, batchImageQueue, usageBillingRepository, usageLogRepository, batchImageModelPricingResolver, apiKeyAuthCacheInvalidator, configConfig)
@@ -327,7 +328,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db)
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService)
application := &Application{
Server: httpServer,
PromptAudit: promptService,
@@ -371,6 +372,7 @@ func provideCleanup(
schedulerSnapshot *service.SchedulerSnapshotService,
tokenRefresh *service.TokenRefreshService,
accountExpiry *service.AccountExpiryService,
codexVersionSync *service.OpenAICodexVersionSyncService,
proxyExpiry *service.ProxyExpiryService,
subscriptionExpiry *service.SubscriptionExpiryService,
usageCleanup *service.UsageCleanupService,
@@ -518,6 +520,10 @@ func provideCleanup(
accountExpiry.Stop()
return nil
}},
{"OpenAICodexVersionSyncService", func() error {
codexVersionSync.Stop()
return nil
}},
{"ProxyExpiryService", func() error {
proxyExpiry.Stop()
return nil
+2
View File
@@ -40,6 +40,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil,
)
accountExpirySvc := service.NewAccountExpiryService(nil, time.Second)
codexVersionSyncSvc := service.NewOpenAICodexVersionSyncService(nil, nil, nil, time.Second)
proxyExpirySvc := service.NewProxyExpiryService(nil, time.Second)
subscriptionExpirySvc := service.NewSubscriptionExpiryService(nil, time.Second)
pricingSvc := service.NewPricingService(cfg, nil)
@@ -65,6 +66,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
schedulerSnapshotSvc,
tokenRefreshSvc,
accountExpirySvc,
codexVersionSyncSvc,
proxyExpirySvc,
subscriptionExpirySvc,
&service.UsageCleanupService{},
+17 -5
View File
@@ -907,13 +907,17 @@ type GatewayConfig struct {
// ForceCodexCLI: 强制将 OpenAI `/v1/responses` 请求按 Codex CLI 处理。
// 用于网关未透传/改写 User-Agent 时的兼容兜底(默认关闭,避免影响其他客户端)。
ForceCodexCLI bool `mapstructure:"force_codex_cli"`
// DisableCodexOriginatorNormalization: 关闭「把落在上游降载桶的 Codex originator 改写为
// 官方 CLI 身份」。上游 /backend-api/codex 按 originator 分桶调度容量,命中降载桶的请求会被回
// server_is_overloaded,网关据此冷却账号,表现为账号频繁过载不可用。
// DisableCodexIdentityEnforcement: 关闭「强制统一 Codex 出站身份」。上游 /backend-api/codex
// 在容量紧张时按客户端身份分优先级降载,被降载的请求会拿到 HTTP 200 + 流内
// server_is_overloaded,该次请求失败。默认强制统一出口:所有 OAuth 出站的
// User-Agent / originator / version 都改写为网关规范身份,确保没有请求带着第三方或陈旧身份
// 出站。置 true 后退回「仅按最终 User-Agent 配对 originator」的收口语义,供上游策略变动时回滚。
//
// 取反义命名是为了让零值安全:该开关会发布为进程级快照,未经 viper 加载而手工构造的
// Config(测试、工具)其零值必须落在「归一化开启」这一侧,否则会静默丢掉这层保护。
// 仅当上游调整分桶、使归一化反而落入降载桶时才置 true。
// Config(测试、工具)其零值必须落在「强制统一开启」这一侧,否则会静默丢掉这层保护。
DisableCodexIdentityEnforcement bool `mapstructure:"disable_codex_identity_enforcement"`
// DisableCodexOriginatorNormalization: 已废弃,等价于 DisableCodexIdentityEnforcement。
// 保留以兼容既有配置文件;加载时会折叠进新键,不要在新代码里直接读取。
DisableCodexOriginatorNormalization bool `mapstructure:"disable_codex_originator_normalization"`
// CodexImageGenerationBridgeEnabled: 是否为 Codex `/v1/responses` 自动注入 image_generation 工具和桥接指令。
// 默认关闭,避免纯文本 Codex 请求被意外改写;显式携带 image_generation 工具的请求仍按分组能力转发。
@@ -1774,6 +1778,13 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
cfg.Gateway.ForcedCodexInstructionsTemplate = string(content)
}
// 兼容旧键 gateway.disable_codex_originator_normalization:语义已被
// disable_codex_identity_enforcement 取代(身份改写升级为强制统一出口),
// 任一为 true 即关闭强制统一。
if cfg.Gateway.DisableCodexOriginatorNormalization {
cfg.Gateway.DisableCodexIdentityEnforcement = true
}
// 兼容旧键 gateway.openai_ws.sticky_previous_response_ttl_seconds。
// 新键未配置(<=0)时回退旧键;新键优先。
if cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds <= 0 && cfg.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds > 0 {
@@ -2225,6 +2236,7 @@ func setDefaults() {
viper.SetDefault("gateway.max_account_switches", 10)
viper.SetDefault("gateway.max_account_switches_gemini", 3)
viper.SetDefault("gateway.force_codex_cli", false)
viper.SetDefault("gateway.disable_codex_identity_enforcement", false)
viper.SetDefault("gateway.disable_codex_originator_normalization", false)
viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false)
viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false)
@@ -279,6 +279,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
EnableClientDatelineNormalization: settings.EnableClientDatelineNormalization,
AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion,
OpenAICodexUserAgent: settings.OpenAICodexUserAgent,
OpenAICodexClientVersion: settings.OpenAICodexClientVersion,
OpenAICodexClientVersionSynced: settings.OpenAICodexClientVersionSynced,
OpenAICodexVersionAutoSyncEnabled: settings.OpenAICodexVersionAutoSyncEnabled,
MinCodexVersion: settings.MinCodexVersion,
MaxCodexVersion: settings.MaxCodexVersion,
CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist,
@@ -443,6 +443,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.OpenAICodexUserAgent != after.OpenAICodexUserAgent {
changed = append(changed, "openai_codex_user_agent")
}
if before.OpenAICodexClientVersion != after.OpenAICodexClientVersion {
changed = append(changed, "openai_codex_client_version")
}
if before.OpenAICodexVersionAutoSyncEnabled != after.OpenAICodexVersionAutoSyncEnabled {
changed = append(changed, "openai_codex_version_auto_sync_enabled")
}
if before.PaymentVisibleMethodAlipaySource != after.PaymentVisibleMethodAlipaySource {
changed = append(changed, "payment_visible_method_alipay_source")
}
@@ -236,6 +236,8 @@ type UpdateSettingsRequest struct {
EnableClientDatelineNormalization *bool `json:"enable_client_dateline_normalization"`
AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"`
OpenAICodexUserAgent *string `json:"openai_codex_user_agent"`
OpenAICodexClientVersion *string `json:"openai_codex_client_version"`
OpenAICodexVersionAutoSyncEnabled *bool `json:"openai_codex_version_auto_sync_enabled"`
// codex_cli_only 加固(global-only)
MinCodexVersion string `json:"min_codex_version"`
@@ -1276,6 +1278,15 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
return
}
}
if req.OpenAICodexClientVersion != nil {
// 该值会被拼进出站 User-Agent 与 version 头,必须是合法版本号;空串表示跟随自动同步。
normalized := strings.TrimSpace(*req.OpenAICodexClientVersion)
if normalized != "" && service.NormalizeCodexClientVersion(normalized) == "" {
response.Error(c, http.StatusBadRequest, "openai_codex_client_version must be empty or a valid version (e.g. 0.146.0)")
return
}
req.OpenAICodexClientVersion = &normalized
}
// codex_cli_only 加固:最低/最高 Codex 版本(空=禁用,或合法 semver;max>=min)
if req.MinCodexVersion != "" && !semverPattern.MatchString(req.MinCodexVersion) {
@@ -1555,6 +1566,20 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.OpenAICodexUserAgent
}(),
OpenAICodexClientVersion: func() string {
if req.OpenAICodexClientVersion != nil {
return *req.OpenAICodexClientVersion
}
return previousSettings.OpenAICodexClientVersion
}(),
// 同步值由自动同步任务独占写入,面板保存时原样带回,避免被清空。
OpenAICodexClientVersionSynced: previousSettings.OpenAICodexClientVersionSynced,
OpenAICodexVersionAutoSyncEnabled: func() bool {
if req.OpenAICodexVersionAutoSyncEnabled != nil {
return *req.OpenAICodexVersionAutoSyncEnabled
}
return previousSettings.OpenAICodexVersionAutoSyncEnabled
}(),
MinCodexVersion: strings.TrimSpace(req.MinCodexVersion),
MaxCodexVersion: strings.TrimSpace(req.MaxCodexVersion),
CodexCLIOnlyBlacklist: strings.TrimSpace(req.CodexCLIOnlyBlacklist),
@@ -2027,6 +2052,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EnableClientDatelineNormalization: updatedSettings.EnableClientDatelineNormalization,
AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion,
OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent,
OpenAICodexClientVersion: updatedSettings.OpenAICodexClientVersion,
OpenAICodexClientVersionSynced: updatedSettings.OpenAICodexClientVersionSynced,
OpenAICodexVersionAutoSyncEnabled: updatedSettings.OpenAICodexVersionAutoSyncEnabled,
MinCodexVersion: updatedSettings.MinCodexVersion,
MaxCodexVersion: updatedSettings.MaxCodexVersion,
CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist,
+3
View File
@@ -199,6 +199,9 @@ type SystemSettings struct {
EnableClientDatelineNormalization bool `json:"enable_client_dateline_normalization"`
AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"`
OpenAICodexUserAgent string `json:"openai_codex_user_agent"`
OpenAICodexClientVersion string `json:"openai_codex_client_version"`
OpenAICodexClientVersionSynced string `json:"openai_codex_client_version_synced"`
OpenAICodexVersionAutoSyncEnabled bool `json:"openai_codex_version_auto_sync_enabled"`
// codex_cli_only 加固
MinCodexVersion string `json:"min_codex_version"`
+66 -46
View File
@@ -57,17 +57,6 @@ var codexOfficialClientOriginators = map[string]bool{
"codex_sdk_ts": true, // TypeScript SDK
}
// IsBrowserUserAgent 判断 User-Agent 是否来自浏览器(Chrome/Firefox/Safari/Edge/Opera 等)。
// 所有现代浏览器的 UA 均以 "Mozilla/" 作为前缀,CLI 工具(codex/claude/curl/postman/python-requests 等)不会。
// 该判定用于避免 Cloudflare 对浏览器型 UA 在 OpenAI 上游接口上触发 JS 质询。
func IsBrowserUserAgent(userAgent string) bool {
ua := strings.TrimSpace(userAgent)
if ua == "" {
return false
}
return strings.HasPrefix(strings.ToLower(ua), "mozilla/")
}
// IsCodexCLIRequest checks if the User-Agent indicates a Codex CLI request
func IsCodexCLIRequest(userAgent string) bool {
ua := normalizeCodexClientHeader(userAgent)
@@ -259,46 +248,77 @@ func canonicalizeCodexOriginator(name string) string {
// 也是身份归一化的目标身份。
const CodexCLIOriginator = "codex_cli_rs"
// codexLoadShedOriginators:上游 /backend-api/codex 按 originator 分桶调度容量,命中降载桶的
// 请求即使 HTTP 200 也会立刻推 SSE `event: error`(code=server_is_overloaded)并以
// response.failed 收尾。2026-07-29 起 codex-tui 被观测到落入降载桶:同账号、同请求体、同 UA,
// 仅把 originator 换成 codex_cli_rs 即恢复正常(换言之 UA 不是判定因子,originator 才是)。
// 网关会把该错误判定为瞬时上游故障并让账号进入冷却,对外表现为「账号过载不可用」,
// 因此出站前需要把命中的身份改写为 CLI 身份。
//
// 该集合是上游容量策略的快照而非协议常量,上游调整分桶后需同步修订。
var codexLoadShedOriginators = map[string]bool{
"codex-tui": true,
}
// IsCodexLoadShedOriginator 判断 originator 是否落在上游降载桶。
func IsCodexLoadShedOriginator(originator string) bool {
return codexLoadShedOriginators[normalizeCodexClientHeader(originator)]
}
// NormalizeCodexClientIdentityToCLI 把落在降载桶的官方身份改写为 Codex CLI 身份:
// UA 首段替换为 codex_cli_rs,并裁掉尾部 `(name; version)` 客户端标识组(真实 CLI UA 无该组),
// 版本 / OS / 架构 / 终端指纹原样保留。返回配套的 originator 与 UA,未命中降载桶时 changed=false。
//
// 入参应为 PairCodexClientIdentity 输出的已配对身份;改写后 UA 首段与 originator 仍然配套,
// 不破坏上游的配对校验。
func NormalizeCodexClientIdentityToCLI(originator, userAgent string) (string, string, bool) {
if !IsCodexLoadShedOriginator(originator) {
return originator, userAgent, false
}
// CodexUserAgentVersion 提取 Codex UA 的完整版本段,即 `{client}/{version} (...` 中的 version。
// 与 ParseCodexEngineVersion 的区别:后者只取三段数字用于引擎版本比较(会丢掉 -alpha.4
// 之类的预发布后缀),本函数保留原样,因为出站 version 头必须与 UA 版本段逐字一致。
// 取不到(非 Codex 形态 UA)时返回空串。
func CodexUserAgentVersion(userAgent string) string {
ua := strings.TrimSpace(userAgent)
slash := strings.IndexByte(ua, '/')
if slash <= 0 {
return CodexCLIOriginator, ua, true
return ""
}
rest := ua[slash:]
// 仅当尾部括号组确为官方客户端标识时才裁剪,避免误截合法 UA 尾巴(如 `(Ubuntu 22.4.0; x86_64)`)。
if trailer := codexUATrailerName(ua); trailer != "" && IsCodexOfficialClientOriginator(trailer) {
if open := strings.LastIndex(rest, "("); open > 0 {
rest = strings.TrimRight(rest[:open], " ")
}
rest := ua[slash+1:]
if space := strings.IndexByte(rest, ' '); space >= 0 {
rest = rest[:space]
}
return CodexCLIOriginator, CodexCLIOriginator + rest, true
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 等后缀)。
@@ -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", ""))
}
@@ -1,106 +0,0 @@
package openai
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestIsCodexLoadShedOriginator(t *testing.T) {
require.True(t, IsCodexLoadShedOriginator("codex-tui"))
require.True(t, IsCodexLoadShedOriginator(" CODEX-TUI "))
require.False(t, IsCodexLoadShedOriginator("codex_cli_rs"))
require.False(t, IsCodexLoadShedOriginator("codex_vscode"))
require.False(t, IsCodexLoadShedOriginator("Codex Desktop"))
require.False(t, IsCodexLoadShedOriginator(""))
}
func TestNormalizeCodexClientIdentityToCLI(t *testing.T) {
tests := []struct {
name string
originator string
ua string
wantOriginator string
wantUA string
wantChanged bool
}{
{
name: "tui 完整 UA 改写首段并裁掉客户端标识组",
originator: "codex-tui",
ua: "codex-tui/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.144.1)",
wantOriginator: "codex_cli_rs",
wantUA: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color",
wantChanged: true,
},
{
name: "无客户端标识组时仅改写首段",
originator: "codex-tui",
ua: "codex-tui/0.144.1 (Mac OS X 14.0; arm64)",
wantOriginator: "codex_cli_rs",
wantUA: "codex_cli_rs/0.144.1 (Mac OS X 14.0; arm64)",
wantChanged: true,
},
{
name: "OS 括号组不是客户端标识不得被裁剪",
originator: "codex-tui",
ua: "codex-tui/0.144.1 (Ubuntu 22.4.0; x86_64)",
wantOriginator: "codex_cli_rs",
wantUA: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64)",
wantChanged: true,
},
{
name: "缺少版本段时只替换 originator",
originator: "codex-tui",
ua: "codex-tui",
wantOriginator: "codex_cli_rs",
wantUA: "codex-tui",
wantChanged: true,
},
{
name: "健康身份原样返回",
originator: "codex_cli_rs",
ua: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color",
wantOriginator: "codex_cli_rs",
wantUA: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color",
wantChanged: false,
},
{
name: "其他官方身份不受影响",
originator: "codex_vscode",
ua: "codex_vscode/1.0.0 (Ubuntu 22.4.0; x86_64) vscode (codex_vscode; 1.0.0)",
wantOriginator: "codex_vscode",
wantUA: "codex_vscode/1.0.0 (Ubuntu 22.4.0; x86_64) vscode (codex_vscode; 1.0.0)",
wantChanged: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotOriginator, gotUA, changed := NormalizeCodexClientIdentityToCLI(tt.originator, tt.ua)
require.Equal(t, tt.wantOriginator, gotOriginator)
require.Equal(t, tt.wantUA, gotUA)
require.Equal(t, tt.wantChanged, changed)
})
}
}
// 归一化后的身份必须仍然通过上游的 originator ↔ UA 首段配对校验(issue #3901),
// 且再次归一化保持幂等。
func TestNormalizeCodexClientIdentityToCLIStaysPaired(t *testing.T) {
originator, ua, changed := NormalizeCodexClientIdentityToCLI(
"codex-tui",
"codex-tui/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.144.1)",
)
require.True(t, changed)
pairedOriginator, pairedUA, ok := PairCodexClientIdentity(ua)
require.True(t, ok)
require.Equal(t, originator, pairedOriginator)
require.Equal(t, ua, pairedUA)
againOriginator, againUA, againChanged := NormalizeCodexClientIdentityToCLI(originator, ua)
require.False(t, againChanged)
require.Equal(t, originator, againOriginator)
require.Equal(t, ua, againUA)
}
@@ -926,6 +926,9 @@ func TestAPIContracts(t *testing.T) {
"openai_advanced_scheduler_effective_weight_previous_response": "5",
"openai_advanced_scheduler_effective_weight_session_sticky": "3",
"openai_codex_user_agent": "",
"openai_codex_client_version": "",
"openai_codex_client_version_synced": "",
"openai_codex_version_auto_sync_enabled": true,
"openai_fast_policy_settings": {
"rules": []
},
@@ -1216,6 +1219,9 @@ func TestAPIContracts(t *testing.T) {
"openai_advanced_scheduler_effective_weight_previous_response": "5",
"openai_advanced_scheduler_effective_weight_session_sticky": "3",
"openai_codex_user_agent": "",
"openai_codex_client_version": "",
"openai_codex_client_version_synced": "",
"openai_codex_version_auto_sync_enabled": true,
"openai_fast_policy_settings": {
"rules": []
},
@@ -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 {
@@ -113,7 +113,7 @@ const (
openAIProbeCacheTTL = 10 * time.Minute
grokProbeRetryTTL = 1 * time.Minute
grokFreeQuotaWindow = 24 * time.Hour
openAICodexProbeVersion = "0.144.1"
openAICodexProbeVersion = codexCLIVersion // 与网关出站身份同源,避免两处硬编码版本各自漂移
)
// UsageCache 封装账户使用量相关的缓存
@@ -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 := ""
@@ -526,6 +526,16 @@ const (
// 当客户端 UA 被识别为浏览器(Chrome/Firefox/Safari/Edge 等)时,转发给 OpenAI 上游前会替换为此值,
// 用于避免 Cloudflare 对浏览器型 UA 的质询拦截。
SettingKeyOpenAICodexUserAgent = "openai_codex_user_agent"
// SettingKeyOpenAICodexClientVersion 网关对 ChatGPT 上游声明的 Codex 客户端版本号(管理员覆写)。
// 空值表示跟随自动同步值;自动同步也没有结果时回退到内置常量。
// 上游在容量紧张时按客户端身份分优先级降载,陈旧版本会被优先丢弃,故该值需保持跟随官方发布。
SettingKeyOpenAICodexClientVersion = "openai_codex_client_version"
// SettingKeyOpenAICodexClientVersionSynced 自动同步任务写入的官方 Codex 最新稳定版版本号。
// 由 OpenAICodexVersionSyncService 独占写入,面板只读展示;管理员覆写请用
// SettingKeyOpenAICodexClientVersion。
SettingKeyOpenAICodexClientVersionSynced = "openai_codex_client_version_synced"
// SettingKeyOpenAICodexVersionAutoSyncEnabled 是否启用 Codex 客户端版本号自动同步(默认 true)。
SettingKeyOpenAICodexVersionAutoSyncEnabled = "openai_codex_version_auto_sync_enabled"
// SettingKeyOpenAIAllowClaudeCodeCodexPlugin 已废弃:历史全局开关只作为升级迁移输入读取。
// 迁移后等价规则写入 SettingKeyCodexCLIOnlyWhitelist,不再参与运行时判定。
SettingKeyOpenAIAllowClaudeCodeCodexPlugin = "openai_allow_claude_code_codex_plugin"
@@ -633,6 +633,7 @@ type UpstreamFailoverError struct {
ResponseHeaders http.Header // 上游响应头,用于透传 cf-ray/cf-mitigated/content-type 等诊断信息
ForceCacheBilling bool // Antigravity 粘性会话切换时设为 true
RetryableOnSameAccount bool // 临时性错误(如 Google 间歇性 400、空响应),应在同一账号上重试 N 次再切换
RequestScopedTransient bool // 故障因素与账号无关(如上游按客户端身份/模型容量降载):可同账号重试,但不得据此对账号做临时封禁
SafeToFailoverAfterWrite bool // 仅写出 SSE 注释等非语义字节时,仍可在同一客户端流中切换账号
Stage GatewayFailureStage
Scope GatewayFailureScope
@@ -684,6 +685,11 @@ func (s *GatewayService) TempUnscheduleRetryableError(ctx context.Context, accou
if failoverErr == nil || !failoverErr.RetryableOnSameAccount {
return
}
// 请求级瞬时故障与账号健康无关:封禁只会把与故障无关的账号一并摘掉,
// 而故障因素(客户端身份、模型容量)在下一个账号上完全相同。
if failoverErr.RequestScopedTransient {
return
}
// 根据状态码选择封禁策略
switch failoverErr.StatusCode {
case http.StatusBadRequest:
@@ -264,8 +264,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(c
req.Header.Set("Session_ID", isolated)
req.Header.Set("Conversation_ID", isolated)
}
s.overrideBrowserUserAgent(ctx, account, req)
enforceCodexIdentityHeaders(req.Header)
enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
account.ApplyHeaderOverrides(req.Header)
return req, nil
}
@@ -402,8 +401,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context
if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
req.Header.Set("User-Agent", codexCLIUserAgent)
}
s.overrideBrowserUserAgent(ctx, account, req)
enforceCodexIdentityHeaders(req.Header)
enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
}
account.ApplyHeaderOverrides(req.Header)
@@ -88,7 +88,7 @@ func TestForwardAlphaSearchOAuthPreservesWire(t *testing.T) {
require.Equal(t, "Bearer oauth-token", upstream.lastReq.Header.Get("Authorization"))
require.Equal(t, "chatgpt-account", upstream.lastReq.Header.Get("chatgpt-account-id"))
require.Equal(t, "application/json", upstream.lastReq.Header.Get("Accept"))
require.Equal(t, "0.144.1", upstream.lastReq.Header.Get("Version"))
require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("Version"))
require.Empty(t, upstream.lastReq.Header.Get("OpenAI-Beta"))
require.JSONEq(t, string(body), string(upstream.lastBody))
}
@@ -153,7 +153,7 @@ func TestForwardAlphaSearchPATUsesResponsesWebSearchFallback(t *testing.T) {
require.Equal(t, "application/json", upstream.lastReq.Header.Get("Content-Type"))
require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept"))
require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta"))
require.Equal(t, "0.144.1", upstream.lastReq.Header.Get("Version"))
require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("Version"))
require.Equal(t, `{"turn_id":"turn-1"}`, upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"))
require.Equal(t, "codex_cli_rs", upstream.lastReq.Header.Get("Originator"))
require.Empty(t, upstream.lastReq.Header.Get("X-Codex-Beta-Features"))
@@ -0,0 +1,85 @@
package service
import (
"context"
"net/http"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// --- mock: 只记录临时不可调度写入,其余方法不应被调用 ---
type capacityShedAccountRepoStub struct {
AccountRepository // 嵌入接口,未实现的方法会 panic(不应被调用)
tempUnschedCalls int
}
func (r *capacityShedAccountRepoStub) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, _ string) error {
r.tempUnschedCalls++
return nil
}
// 上游容量降载是请求级信号:故障因素(客户端身份、模型容量)与账号无关,
// 同账号重试用尽后不得把账号临时摘掉——否则一个被降载的请求会顺着 failover
// 把整池账号逐个封禁,而每个账号都会以同一个错误失败。
func TestTempUnscheduleRetryableErrorSkipsRequestScopedTransient(t *testing.T) {
t.Run("请求级瞬时故障不写账号状态", func(t *testing.T) {
repo := &capacityShedAccountRepoStub{}
svc := &GatewayService{accountRepo: repo}
svc.TempUnscheduleRetryableError(context.Background(), 1, &UpstreamFailoverError{
StatusCode: http.StatusBadGateway,
RetryableOnSameAccount: true,
RequestScopedTransient: true,
})
require.Zero(t, repo.tempUnschedCalls)
})
// 对照组:同样的 502 在未标记请求级瞬时故障时仍按原有语义临时摘号,
// 确认上面的断言来自新增守卫而非其他前置条件。
t.Run("未标记时保持原有临时摘号语义", func(t *testing.T) {
repo := &capacityShedAccountRepoStub{}
svc := &GatewayService{accountRepo: repo}
svc.TempUnscheduleRetryableError(context.Background(), 1, &UpstreamFailoverError{
StatusCode: http.StatusBadGateway,
RetryableOnSameAccount: true,
})
require.Equal(t, 1, repo.tempUnschedCalls)
})
}
// 非池模式账号同样要先在同账号重试:换号不改变降载因素。
func TestStreamFailedEventCapacityShedRetriesOnSameAccount(t *testing.T) {
nonPool := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
for _, code := range []string{"server_is_overloaded", "slow_down"} {
payload := []byte(`{"type":"response.failed","response":{"error":{"code":"` + code + `"}}}`)
require.True(t, isOpenAIUpstreamCapacityShedEvent(payload), code)
require.True(t, openAIStreamFailedEventRetryableOnSameAccount(nonPool, payload, "overloaded"), code)
}
// 非降载的 failed 事件在非池模式下仍不做同账号重试,避免放大改动面。
other := []byte(`{"type":"response.failed","response":{"error":{"code":"server_error"}}}`)
require.False(t, isOpenAIUpstreamCapacityShedEvent(other))
require.False(t, openAIStreamFailedEventRetryableOnSameAccount(nonPool, other, "boom"))
}
// 出站身份的版本声明只能有一个来源:UA 的版本段、version 头、探针版本三处必须同源,
// 各自硬编码会漂移成互相矛盾的身份,而自相矛盾或陈旧的身份会被上游优先降载。
func TestCodexOutboundVersionHasSingleSource(t *testing.T) {
require.True(t,
strings.HasPrefix(codexCLIUserAgent, "codex_cli_rs/"+codexCLIVersion+" "),
"codexCLIUserAgent=%q 必须以 codexCLIVersion=%q 作为版本段", codexCLIUserAgent, codexCLIVersion,
)
require.Equal(t, codexCLIVersion, openAICodexProbeVersion)
require.GreaterOrEqual(t, CompareVersions(codexCLIVersion, codexUpstreamMinVersion), 0,
"codexCLIVersion=%q 不得低于上游最低门槛 %q", codexCLIVersion, codexUpstreamMinVersion,
)
}
+154 -27
View File
@@ -2,7 +2,9 @@ package service
import (
"net/http"
"regexp"
"strings"
"sync"
"sync/atomic"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
@@ -13,37 +15,142 @@ import (
// 若请求携带 version 且低于该值,上游直接 404(issue #3901,2026-07 实测)。
const codexUpstreamMinVersion = "0.144.0"
// codexOriginatorNormalization 控制 enforceCodexIdentityHeaders 是否把落在上游降载桶的
// Codex 身份改写为 CLI 身份,由 gateway.disable_codex_originator_normalization 在服务构造时取反发布。
// 默认开启:降载桶命中会让上游回 server_is_overloaded,网关据此判定瞬时故障并冷却账号。
var codexOriginatorNormalization = func() *atomic.Bool {
// codexClientVersionMaxLen 官方版本号均为短 ASCII 串,远低于此上限。
const codexClientVersionMaxLen = 64
// codexClientVersionPattern 允许 0.146.0 与 0.147.0-alpha.4 两类官方形态。
var codexClientVersionPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+){1,3}(-[0-9A-Za-z.]+)?$`)
// NormalizeCodexClientVersion 校验并归一化 Codex 客户端版本号,非法值返回空串。
// 该值会被拼进出站 User-Agent 与 version 头,必须拒绝任意字节,避免管理员误填或
// 自动同步拿到异常值时把不可控内容透给上游。
func NormalizeCodexClientVersion(version string) string {
version = strings.TrimSpace(version)
if version == "" || len(version) > codexClientVersionMaxLen || !codexClientVersionPattern.MatchString(version) {
return ""
}
return version
}
// buildCodexCLIUserAgent 按版本号拼出规范 Codex CLI User-Agent。
// UA 形态只在 codexCLIUserAgentSuffix 一处定义,避免多处拼装漂移。
func buildCodexCLIUserAgent(version string) string {
if version = NormalizeCodexClientVersion(version); version == "" {
return codexCLIUserAgent
}
return openai.CodexCLIOriginator + "/" + version + codexCLIUserAgentSuffix
}
// codexIdentityEnforcement 控制 enforceCodexIdentityHeaders 是否强制统一出站身份,
// 由 gateway.disable_codex_identity_enforcement 在服务构造时取反发布。
// 默认开启:上游在容量紧张时按客户端身份分优先级降载,被降载的请求会拿到
// HTTP 200 + 流内 server_is_overloaded,本次请求即失败;强制统一出口可确保没有
// 请求带着第三方或陈旧身份出站。关闭后退回「仅按最终 UA 配对 originator」的收口语义。
var codexIdentityEnforcement = func() *atomic.Bool {
v := &atomic.Bool{}
v.Store(true)
return v
}()
// SetCodexOriginatorNormalizationEnabled 发布 Codex 降载身份归一化开关。
// SetCodexIdentityEnforcementEnabled 发布 Codex 出站身份强制统一开关。
// enforceCodexIdentityHeaders 是所有出站路径共用的纯函数收口点,无法在热路径注入配置,
// 故由持有配置的服务在构造时发布进程级快照。
func SetCodexOriginatorNormalizationEnabled(enabled bool) {
codexOriginatorNormalization.Store(enabled)
func SetCodexIdentityEnforcementEnabled(enabled bool) {
codexIdentityEnforcement.Store(enabled)
}
// codexCanonicalUserAgentResolver 返回当前生效的规范 Codex User-Agent(后台设置 / 自动同步版本号)。
// 由 SettingService 在装配时注入;解析器内部自带 TTL 缓存,热路径不触库。
type codexCanonicalUserAgentResolver func() string
var (
codexCanonicalUAMu sync.RWMutex
codexCanonicalUAResolver codexCanonicalUserAgentResolver
)
// SetCodexCanonicalUserAgentResolver 注入规范 User-Agent 解析器。
// 未注入或解析结果非法时回退到编译期常量 codexCLIUserAgent。
func SetCodexCanonicalUserAgentResolver(resolver func() string) {
codexCanonicalUAMu.Lock()
defer codexCanonicalUAMu.Unlock()
codexCanonicalUAResolver = resolver
}
// codexCanonicalUserAgent 返回出站规范 User-Agent。
func codexCanonicalUserAgent() string {
codexCanonicalUAMu.RLock()
resolver := codexCanonicalUAResolver
codexCanonicalUAMu.RUnlock()
if resolver != nil {
if ua := strings.TrimSpace(resolver()); ua != "" {
return ua
}
}
return codexCLIUserAgent
}
// codexOutboundIdentity 出站身份三元组,三者必须同源自洽:
// originator 与 User-Agent 首段配套(否则上游 404,issue #3901),
// version 等于 User-Agent 的版本段且不低于上游门槛。
type codexOutboundIdentity struct {
userAgent string
originator string
version string
}
// 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 = canonical
}
originator, pairedUA, ok := openai.PairCodexClientIdentity(ua)
if !ok {
if originator, pairedUA, ok = openai.PairCodexClientIdentity(canonical); !ok {
originator, pairedUA = openai.CodexCLIOriginator, codexCLIUserAgent
}
}
// 生效版本只有一个来源:规范身份(面板版本号 → 自动同步值 → 内置常量,见
// 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
// 做官方身份配对与最低版本校正。
// 已有 User-Agent 与 version 保持不变,交给紧随其后的 enforceCodexIdentityHeaders 收口。
func ensureCodexIdentityHeaders(h http.Header) {
if h == nil {
return
}
identity := resolveCodexOutboundIdentity("")
if strings.TrimSpace(h.Get("user-agent")) == "" {
h.Set("user-agent", codexCLIUserAgent)
h.Set("user-agent", identity.userAgent)
}
if strings.TrimSpace(h.Get("originator")) == "" {
h.Set("originator", openai.CodexCLIOriginator)
h.Set("originator", identity.originator)
}
if strings.TrimSpace(h.Get("version")) == "" {
h.Set("version", codexCLIVersion)
h.Set("version", identity.version)
}
h.Set("OpenAI-Beta", "responses=experimental")
}
@@ -58,27 +165,47 @@ func applyOpenAICodexProbeHeaders(h http.Header) {
}
// enforceCodexIdentityHeaders 收口 OAuth(ChatGPT 内部接口)出站请求的客户端身份头。
// 上游要求 originator 与 User-Agent 首段配套且为官方客户端标识,version 头(若携带)
// 不低于 0.144.0,任一不满足即 404(issue #3901)。以最终 User-Agent 为准推导配套
// originator;推导不出官方身份(第三方 UA / UA 缺失)时整体回退为默认 Codex CLI 身份。
//
// 配对之后再做降载身份归一化:上游按 originator 分桶调度容量,命中降载桶的请求会被回
// server_is_overloaded,网关据此判定瞬时上游故障并冷却账号(对外表现为账号过载不可用),
// 故这类身份统一改写为 CLI 身份——只替换身份段,保留版本 / OS / 架构 / 终端指纹。
//
// 仅对携带 originator 的请求生效;需要从缺失身份头恢复的调用方应先调用
// ensureCodexIdentityHeaders。
// 必须在所有 User-Agent 改写(自定义 UA / ForceCodexCLI / 浏览器 UA 兜底)之后调用。
// 见 enforceCodexIdentityHeadersWithUA;无账号级自定义 User-Agent 时使用本函数。
func enforceCodexIdentityHeaders(h http.Header) {
enforceCodexIdentityHeadersWithUA(h, "")
}
// enforceCodexIdentityHeadersWithUA 强制统一 OAuth 出站身份:User-Agent / originator / version
// 一律改写为网关的规范身份,客户端自报身份不参与构造。上游在容量紧张时按客户端身份分优先级
// 降载,被降载的请求会拿到 HTTP 200 + 流内 server_is_overloaded;统一出口可确保没有请求带着
// 第三方或陈旧身份出站,也天然满足 originator 与 UA 首段配套的上游校验(issue #3901)。
//
// overrideUA 是账号级自定义 User-Agent:管理员的显式配置仍然生效,但只贡献客户端名与
// OS / 架构 / 终端指纹——版本段与 originator 都由规范身份重建,不允许出现自相矛盾或陈旧的身份。
//
// 强制统一被 gateway.disable_codex_identity_enforcement 关闭时,退回「按最终 User-Agent 配对
// originator + version 门槛校正」的收口语义,供上游策略变动时回滚。
//
// 仅对携带 originator 的请求生效:compat 桥接等非 ChatGPT 内部接口路径会显式删除 originator,
// 不应被补回。需要从缺失身份头恢复的调用方应先调用 ensureCodexIdentityHeaders。
// 必须在所有 User-Agent 改写之后调用。
func enforceCodexIdentityHeadersWithUA(h http.Header, overrideUA string) {
if h == nil || h.Get("originator") == "" {
return
}
if !codexIdentityEnforcement.Load() {
pairCodexIdentityHeaders(h)
return
}
identity := resolveCodexOutboundIdentity(overrideUA)
h.Set("user-agent", identity.userAgent)
h.Set("originator", identity.originator)
h.Set("version", identity.version)
}
// pairCodexIdentityHeaders 是关闭强制统一后的兜底收口:保留客户端真实身份,
// 仅保证 originator 与最终 User-Agent 首段配套、version 不低于上游门槛(issue #3901)。
func pairCodexIdentityHeaders(h http.Header) {
originator, pairedUA, ok := openai.PairCodexClientIdentity(h.Get("user-agent"))
if !ok {
originator, pairedUA = openai.CodexCLIOriginator, codexCLIUserAgent
}
if codexOriginatorNormalization.Load() {
originator, pairedUA, _ = openai.NormalizeCodexClientIdentityToCLI(originator, pairedUA)
identity := resolveCodexOutboundIdentity("")
originator, pairedUA = identity.originator, identity.userAgent
h.Set("version", identity.version)
}
h.Set("user-agent", pairedUA)
h.Set("originator", originator)
@@ -17,6 +17,9 @@ func requireOpenAICodexProbeHeaders(t *testing.T, h http.Header) {
require.NotEmpty(t, h.Get("X-Codex-Window-ID"))
}
// 强制统一出口:无论客户端自报什么身份,OAuth 出站的 User-Agent / originator / version
// 一律是网关规范身份。上游在容量紧张时按客户端身份分优先级降载,统一出口确保没有请求
// 带着第三方或陈旧身份出站。
func TestEnsureCodexIdentityHeaders(t *testing.T) {
t.Run("补齐缺失身份头", func(t *testing.T) {
h := make(http.Header)
@@ -30,124 +33,76 @@ func TestEnsureCodexIdentityHeaders(t *testing.T) {
require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta"))
})
t.Run("保留已有官方UA和合法version并重新配对", func(t *testing.T) {
const vscodeUA = "codex_vscode/9.9.9 (Mac OS X 14.0; arm64) vscode (codex_vscode; 9.9.9)"
t.Run("官方非 CLI 客户端身份同样被统一", func(t *testing.T) {
h := make(http.Header)
h.Set("user-agent", vscodeUA)
h.Set("user-agent", "codex_vscode/9.9.9 (Mac OS X 14.0; arm64) vscode (codex_vscode; 9.9.9)")
h.Set("version", "9.9.9")
h.Set("OpenAI-Beta", "assistants=v2")
ensureCodexIdentityHeaders(h)
enforceCodexIdentityHeaders(h)
require.Equal(t, "codex_vscode", h.Get("originator"))
require.Equal(t, vscodeUA, h.Get("user-agent"))
require.Equal(t, "9.9.9", h.Get("version"))
require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta"))
})
t.Run("降载身份归一化后保留版本与终端指纹", func(t *testing.T) {
h := make(http.Header)
h.Set("user-agent", "codex-tui/9.9.9 (Mac OS X 14.0; arm64) iTerm (codex-tui; 9.9.9)")
h.Set("version", "9.9.9")
ensureCodexIdentityHeaders(h)
enforceCodexIdentityHeaders(h)
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, "codex_cli_rs/9.9.9 (Mac OS X 14.0; arm64) iTerm", h.Get("user-agent"))
require.Equal(t, "9.9.9", h.Get("version"))
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
require.Equal(t, codexCLIVersion, h.Get("version"))
require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta"))
})
}
func TestEnforceCodexIdentityHeaders(t *testing.T) {
const tuiUA = "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)"
// codex-tui 落在上游降载桶,收口时统一改写为 CLI 身份(保留版本/OS/架构/终端指纹)。
const tuiNormalizedUA = "codex_cli_rs/0.140.2 (Mac OS X 14.0; arm64) iTerm"
tests := []struct {
name string
originator string
userAgent string
version string
wantOriginator string
wantUA string
wantVersion string
name string
originator string
userAgent string
version string
}{
{
name: "错配 originator 按最终 UA 重配后归一化",
originator: "codex_cli_rs",
userAgent: tuiUA,
wantOriginator: "codex_cli_rs",
wantUA: tuiNormalizedUA,
name: "TUI 身份",
originator: "codex-tui",
userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)",
},
{
name: "降载身份改写为 CLI 身份",
originator: "codex-tui",
userAgent: tuiUA,
wantOriginator: "codex_cli_rs",
wantUA: tuiNormalizedUA,
name: "错配 originator",
originator: "codex_cli_rs",
userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)",
},
{
name: "非降载官方身份原样保留",
originator: "codex_vscode",
userAgent: "codex_vscode/1.2.3 (Ubuntu 22.4.0; x86_64) vscode (codex_vscode; 1.2.3)",
wantOriginator: "codex_vscode",
wantUA: "codex_vscode/1.2.3 (Ubuntu 22.4.0; x86_64) vscode (codex_vscode; 1.2.3)",
name: "官方 vscode 身份",
originator: "codex_vscode",
userAgent: "codex_vscode/1.2.3 (Ubuntu 22.4.0; x86_64) vscode (codex_vscode; 1.2.3)",
},
{
name: "第三方 UA 整体回退默认身份",
originator: "opencode",
userAgent: "luna/1.0.0",
wantOriginator: "codex_cli_rs",
wantUA: codexCLIUserAgent,
name: "第三方客户端身份",
originator: "opencode",
userAgent: "luna/1.0.0",
version: "2.1.0",
},
{
name: "UA 缺失回退默认身份",
originator: "codex_vscode",
wantOriginator: "codex_cli_rs",
wantUA: codexCLIUserAgent,
name: "浏览器型 UA(原浏览器兜底已被统一出口吸收)",
originator: "codex_cli_rs",
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
{
name: "originator override UA 首段被尾部真实身份重写后归一化",
originator: "cccc",
userAgent: "cccc/0.142.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.142.0)",
wantOriginator: "codex_cli_rs",
wantUA: "codex_cli_rs/0.142.0 (Ubuntu 22.4.0; x86_64) screen",
name: "UA 缺失",
originator: "codex_vscode",
},
{
name: "低于门槛的 version 提升为内置版本",
originator: "codex_cli_rs",
userAgent: "codex_cli_rs/0.125.0",
version: "0.125.0",
wantOriginator: "codex_cli_rs",
wantUA: "codex_cli_rs/0.125.0",
wantVersion: codexCLIVersion,
name: "originator override 的真实 TUI 客户端",
originator: "cccc",
userAgent: "cccc/0.142.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.142.0)",
},
{
name: "达标 version 原样保留",
originator: "codex_cli_rs",
userAgent: "codex_cli_rs/0.145.0",
version: "0.145.0",
wantOriginator: "codex_cli_rs",
wantUA: "codex_cli_rs/0.145.0",
wantVersion: "0.145.0",
},
{
name: "未携带 version 不注入",
originator: "codex_cli_rs",
userAgent: "codex_cli_rs/0.98.0",
wantOriginator: "codex_cli_rs",
wantUA: "codex_cli_rs/0.98.0",
name: "陈旧客户端版本",
originator: "codex_cli_rs",
userAgent: "codex_cli_rs/0.125.0",
version: "0.125.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := make(http.Header)
if tt.originator != "" {
h.Set("originator", tt.originator)
}
h.Set("originator", tt.originator)
if tt.userAgent != "" {
h.Set("user-agent", tt.userAgent)
}
@@ -157,24 +112,81 @@ func TestEnforceCodexIdentityHeaders(t *testing.T) {
enforceCodexIdentityHeaders(h)
require.Equal(t, tt.wantOriginator, h.Get("originator"))
require.Equal(t, tt.wantUA, h.Get("user-agent"))
require.Equal(t, tt.wantVersion, h.Get("version"))
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
require.Equal(t, codexCLIVersion, h.Get("version"))
})
}
}
// 开关是进程级快照,零值 Config(测试 / 工具手工构造,不经 viper)必须落在「归一化开启」
// 一侧,否则任意一处零值构造都会静默关掉全局保护。
//
// 不得给本文件的开关类用例加 t.Parallel():它们改写进程级状态。
func TestCodexOriginatorNormalizationZeroValueConfigKeepsItEnabled(t *testing.T) {
var cfg config.Config
require.False(t, cfg.Gateway.DisableCodexOriginatorNormalization,
"零值必须表示归一化开启;若改为正向命名的 NormalizeCodexOriginator,零值会静默关闭保护")
// 账号级自定义 UA 是管理员的显式配置,仍然生效;但它只贡献客户端名与 OS / 架构 / 终端指纹,
// originator 与版本段一律由规范身份重建,不允许出现自相矛盾或陈旧的身份。
func TestEnforceCodexIdentityHeadersWithAccountOverrideUA(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")
h.Set("version", "2.1.0")
SetCodexOriginatorNormalizationEnabled(!cfg.Gateway.DisableCodexOriginatorNormalization)
t.Cleanup(func() { SetCodexOriginatorNormalizationEnabled(true) })
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/"+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) {
h := make(http.Header)
h.Set("originator", "codex_cli_rs")
enforceCodexIdentityHeadersWithUA(h, "luna/1.0.0")
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
require.Equal(t, codexCLIVersion, h.Get("version"))
})
// 回归:覆写 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/"+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"))
})
}
// 规范身份跟随注入的解析器(后台面板 UA / 自动同步版本号),无需重启或发版。
//
// 不得给本用例加 t.Parallel():它改写进程级解析器。
func TestEnforceCodexIdentityHeadersFollowsCanonicalResolver(t *testing.T) {
SetCodexCanonicalUserAgentResolver(func() string {
return "codex_cli_rs/0.200.1 (Ubuntu 22.4.0; x86_64) xterm-256color"
})
t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) })
h := make(http.Header)
h.Set("originator", "codex-tui")
@@ -183,41 +195,104 @@ func TestCodexOriginatorNormalizationZeroValueConfigKeepsItEnabled(t *testing.T)
enforceCodexIdentityHeaders(h)
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, "codex_cli_rs/0.200.1 (Ubuntu 22.4.0; x86_64) xterm-256color", h.Get("user-agent"))
require.Equal(t, "0.200.1", h.Get("version"))
}
// 关闭归一化后必须完整退回配对语义:降载身份逐字保留,供上游调整分桶后回滚使用。
func TestEnforceCodexIdentityHeaders_NormalizationDisabled(t *testing.T) {
const tuiUA = "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)"
// 解析器返回非法值(配置被写坏、同步到异常内容)时必须回退到内置身份,
// 绝不能把不可控内容拼进出站身份。
//
// 不得给本用例加 t.Parallel():它改写进程级解析器。
func TestEnforceCodexIdentityHeadersRejectsInvalidCanonicalUA(t *testing.T) {
SetCodexCanonicalUserAgentResolver(func() string { return "not-a-codex-client" })
t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) })
SetCodexOriginatorNormalizationEnabled(false)
t.Cleanup(func() { SetCodexOriginatorNormalizationEnabled(true) })
h := make(http.Header)
h.Set("originator", "codex_cli_rs")
enforceCodexIdentityHeaders(h)
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
require.Equal(t, codexCLIVersion, h.Get("version"))
}
// 开关是进程级快照,零值 Config(测试 / 工具手工构造,不经 viper)必须落在「强制统一开启」
// 一侧,否则任意一处零值构造都会静默关掉全局保护。
//
// 不得给本文件的开关类用例加 t.Parallel():它们改写进程级状态。
func TestCodexIdentityEnforcementZeroValueConfigKeepsItEnabled(t *testing.T) {
var cfg config.Config
require.False(t, cfg.Gateway.DisableCodexIdentityEnforcement,
"零值必须表示强制统一开启;若改为正向命名,零值会静默关闭保护")
SetCodexIdentityEnforcementEnabled(!cfg.Gateway.DisableCodexIdentityEnforcement)
t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) })
h := make(http.Header)
h.Set("originator", "codex-tui")
h.Set("user-agent", "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)")
enforceCodexIdentityHeaders(h)
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
}
// 关闭强制统一后退回配对语义:客户端真实身份逐字保留,仅保证 originator 与 UA 首段配套
// (issue #3901),供上游策略变动时回滚。
func TestEnforceCodexIdentityHeaders_EnforcementDisabled(t *testing.T) {
const tuiUA = "codex-tui/0.145.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.145.2)"
SetCodexIdentityEnforcementEnabled(false)
t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) })
h := make(http.Header)
h.Set("originator", "codex-tui")
h.Set("user-agent", tuiUA)
h.Set("version", "0.145.2")
enforceCodexIdentityHeaders(h)
require.Equal(t, "codex-tui", h.Get("originator"))
require.Equal(t, tuiUA, h.Get("user-agent"))
require.Equal(t, "0.145.2", h.Get("version"))
}
// 归一化必须是幂等的:重复收口(如透传路径先后经过多次改写)不得反复裁剪 UA。
func TestEnforceCodexIdentityHeaders_NormalizationIsIdempotent(t *testing.T) {
// 关闭强制统一后,第三方 UA 仍整体回退为规范身份并对齐 version。
func TestEnforceCodexIdentityHeaders_EnforcementDisabledThirdPartyFallback(t *testing.T) {
SetCodexIdentityEnforcementEnabled(false)
t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) })
h := make(http.Header)
h.Set("originator", "opencode")
h.Set("user-agent", "luna/1.0.0")
h.Set("version", "2.1.0")
enforceCodexIdentityHeaders(h)
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
require.Equal(t, codexCLIVersion, h.Get("version"))
}
// 收口必须幂等:透传等路径可能先后多次经过收口。
func TestEnforceCodexIdentityHeadersIsIdempotent(t *testing.T) {
h := make(http.Header)
h.Set("originator", "codex-tui")
h.Set("user-agent", "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)")
enforceCodexIdentityHeaders(h)
first := h.Get("user-agent")
firstUA, firstVersion := h.Get("user-agent"), h.Get("version")
enforceCodexIdentityHeaders(h)
require.Equal(t, first, h.Get("user-agent"))
require.Equal(t, firstUA, h.Get("user-agent"))
require.Equal(t, firstVersion, h.Get("version"))
require.Equal(t, "codex_cli_rs", h.Get("originator"))
}
// enforce 本身仍只负责收口:缺少 originator 时必须保持 no-op,由需要恢复身份的
// 调用方先显式调用 ensureCodexIdentityHeaders。
// 缺少 originator 时必须保持 no-op:compat 桥接等非 ChatGPT 内部接口路径会显式删除
// originator,不应被补回身份头。
func TestEnforceCodexIdentityHeaders_NoOriginatorIsNoop(t *testing.T) {
h := make(http.Header)
h.Set("user-agent", "third-party-client/1.0.0")
@@ -225,5 +300,24 @@ func TestEnforceCodexIdentityHeaders_NoOriginatorIsNoop(t *testing.T) {
enforceCodexIdentityHeaders(h)
require.Empty(t, h.Get("originator"))
require.Empty(t, h.Get("version"))
require.Equal(t, "third-party-client/1.0.0", h.Get("user-agent"))
}
func TestNormalizeCodexClientVersion(t *testing.T) {
require.Equal(t, "0.146.0", NormalizeCodexClientVersion(" 0.146.0 "))
require.Equal(t, "0.147.0-alpha.4", NormalizeCodexClientVersion("0.147.0-alpha.4"))
require.Equal(t, "1.2", NormalizeCodexClientVersion("1.2"))
require.Empty(t, NormalizeCodexClientVersion(""))
require.Empty(t, NormalizeCodexClientVersion("v0.146.0"))
require.Empty(t, NormalizeCodexClientVersion("0.146.0 (Ubuntu)"))
require.Empty(t, NormalizeCodexClientVersion("0.146.0\r\nX-Injected: 1"))
require.Empty(t, NormalizeCodexClientVersion("latest"))
}
func TestBuildCodexCLIUserAgent(t *testing.T) {
require.Equal(t, "codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix, buildCodexCLIUserAgent("0.200.1"))
// 非法版本号必须回退到内置 UA,不能拼出畸形身份。
require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent("bogus version"))
require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent(""))
}
@@ -0,0 +1,223 @@
package service
import (
"context"
"log/slog"
"strings"
"sync"
"time"
)
const (
// openAICodexVersionSyncInterval 自动同步间隔。上游客户端发版频率是天级,
// 6 小时足够及时跟上,同时把对 GitHub API 的调用压到每天 4 次。
openAICodexVersionSyncInterval = 6 * time.Hour
// openAICodexVersionSyncTimeout 单次同步的整体超时。
openAICodexVersionSyncTimeout = 30 * time.Second
// openAICodexVersionSyncRepo 官方 Codex 客户端仓库。
openAICodexVersionSyncRepo = "openai/codex"
// openAICodexVersionSyncPerPage 回退路径单次拉取的 release 数量(主路径见
// fetchLatestStableVersion)。该仓库预发布极密集——0.145.0 与 0.146.0 之间隔着 20 多个
// alpha,实测 30 条里只有 2 条稳定版,第二条已排在第 26 位,因此这个页大小不能再往下调,
// 否则整页扫不到稳定版、同步会静默停更。
openAICodexVersionSyncPerPage = 30
// openAICodexVersionTagPrefix 客户端 release 的 tag 前缀(如 rust-v0.146.0)。
// 同仓库还有其他组件的 tag(如 rusty-v8-*),必须按前缀过滤,否则会同步到无关版本号。
openAICodexVersionTagPrefix = "rust-v"
)
// OpenAICodexVersionSyncService 周期性把官方 Codex 客户端的最新稳定版版本号同步到设置,
// 供出站规范身份使用,避免为了跟上游版本而发新版本。
//
// 同步值写入 SettingKeyOpenAICodexClientVersionSynced(本服务独占写入);管理员在面板填写的
// SettingKeyOpenAICodexClientVersion 优先级更高,因此手工固定版本不会被同步覆盖。
type OpenAICodexVersionSyncService struct {
settingRepo SettingRepository
settingService *SettingService
githubClient GitHubReleaseClient
interval time.Duration
stopCh chan struct{}
stopOnce sync.Once
wg sync.WaitGroup
}
func NewOpenAICodexVersionSyncService(
settingRepo SettingRepository,
settingService *SettingService,
githubClient GitHubReleaseClient,
interval time.Duration,
) *OpenAICodexVersionSyncService {
return &OpenAICodexVersionSyncService{
settingRepo: settingRepo,
settingService: settingService,
githubClient: githubClient,
interval: interval,
stopCh: make(chan struct{}),
}
}
func (s *OpenAICodexVersionSyncService) Start() {
if s == nil || s.settingRepo == nil || s.githubClient == nil || s.interval <= 0 {
return
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
s.runInitial()
for {
select {
case <-ticker.C:
s.runOnce()
case <-s.stopCh:
return
}
}
}()
}
func (s *OpenAICodexVersionSyncService) Stop() {
if s == nil {
return
}
s.stopOnce.Do(func() {
close(s.stopCh)
})
s.wg.Wait()
}
// runInitial 执行启动时的首次同步。若同步值在一个同步周期内已被刷新过则跳过:
// 频繁重启、滚动发布或崩溃重启会让「启动即同步」放大成对 GitHub 的连续请求,
// 而版本号是天级变化的,重启后没有立刻重新拉取的必要。
func (s *OpenAICodexVersionSyncService) runInitial() {
if s.syncedWithinInterval() {
return
}
s.runOnce()
}
// syncedWithinInterval 判断已同步值是否仍在一个同步周期内。
// 借设置行自身的 UpdatedAt 判断,无需额外记录时间戳的设置项。
// 读取失败或尚无有效同步值时返回 false,让启动同步照常执行。
func (s *OpenAICodexVersionSyncService) syncedWithinInterval() bool {
if s.interval <= 0 {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), openAICodexVersionSyncTimeout)
defer cancel()
setting, err := s.settingRepo.Get(ctx, SettingKeyOpenAICodexClientVersionSynced)
if err != nil || setting == nil || setting.UpdatedAt.IsZero() {
return false
}
if NormalizeCodexClientVersion(setting.Value) == "" {
return false
}
return time.Since(setting.UpdatedAt) < s.interval
}
func (s *OpenAICodexVersionSyncService) runOnce() {
ctx, cancel := context.WithTimeout(context.Background(), openAICodexVersionSyncTimeout)
defer cancel()
if !s.autoSyncEnabled(ctx) {
return
}
latest := s.fetchLatestStableVersion(ctx)
if latest == "" {
return
}
current := NormalizeCodexClientVersion(s.currentSyncedVersion(ctx))
// 只向前推进:上游偶发返回旧数据或重新发布历史 tag 时不把已同步的版本号降级。
if current != "" && CompareVersions(latest, current) <= 0 {
return
}
if err := s.settingRepo.Set(ctx, SettingKeyOpenAICodexClientVersionSynced, latest); err != nil {
slog.Warn("openai_codex_version_sync_persist_failed", "version", latest, "error", err)
return
}
s.settingService.InvalidateOpenAICodexClientVersionCache()
slog.Info("openai_codex_version_synced", "previous", current, "version", latest)
}
// fetchLatestStableVersion 取官方最新稳定版客户端版本号;取不到时返回空串,
// 由调用方保持既有值(不清空、不降级),各失败分支自行落日志。
//
// 主路径 /releases/latest:该端点本身就排除 draft 与 prerelease,直接给出最新正式发布,
// 因此不受该仓库预发布密度的影响,也不需要为了「窗口里得有一条稳定版」而多拉数据——
// 实测单条 release 约 0.3MB,而 per_page=30 的列表页约 10MB。
//
// 回退列表扫描:latest 是跨 tag 家族按 published_at 取的,若同仓库其他组件
// (如 rusty-v8-*)某天发了正式 release 而成为 latest,主路径会被 rust-v 前缀过滤挡掉;
// 此时必须扫一页 release 才能继续跟随官方版本,否则版本号会静默停更。
// 两条路径共用同一套过滤(前缀 / draft / prerelease / 版本号形态),语义不会分叉。
func (s *OpenAICodexVersionSyncService) fetchLatestStableVersion(ctx context.Context) string {
release, err := s.githubClient.FetchLatestRelease(ctx, openAICodexVersionSyncRepo)
if err != nil {
slog.Warn("openai_codex_version_sync_latest_fetch_failed", "error", err)
} else if version := latestCodexStableReleaseVersion([]*GitHubRelease{release}); version != "" {
return version
}
// 主路径没拿到可用版本(抓取失败,或 latest 不是客户端 tag 家族的稳定版)。
releases, err := s.githubClient.FetchRecentReleases(ctx, openAICodexVersionSyncRepo, openAICodexVersionSyncPerPage)
if err != nil {
slog.Warn("openai_codex_version_sync_fetch_failed", "error", err)
return ""
}
version := latestCodexStableReleaseVersion(releases)
if version == "" {
slog.Warn("openai_codex_version_sync_no_stable_release", "repo", openAICodexVersionSyncRepo)
}
return version
}
// autoSyncEnabled 读取面板开关。缺失或空值视为开启,与设置默认值一致;
// 读取失败时保持开启,避免一次数据库抖动就静默停掉版本跟随。
func (s *OpenAICodexVersionSyncService) autoSyncEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyOpenAICodexVersionAutoSyncEnabled)
if err != nil {
return true
}
if strings.TrimSpace(value) == "" {
return true
}
return strings.TrimSpace(value) == "true"
}
func (s *OpenAICodexVersionSyncService) currentSyncedVersion(ctx context.Context) string {
value, err := s.settingRepo.GetValue(ctx, SettingKeyOpenAICodexClientVersionSynced)
if err != nil {
return ""
}
return value
}
// latestCodexStableReleaseVersion 从 release 列表里挑出最大的稳定版客户端版本号。
// 过滤条件:tag 前缀为 rust-v(排除同仓库其他组件的 tag)、非草稿、非预发布、
// 版本号不带 -alpha/-beta 之类后缀。取最大值而非最新发布,避免重新发布历史 tag 造成回退。
// 主路径的单条 /releases/latest 结果也走本函数(单元素切片),保证两条取数路径的过滤语义一致。
func latestCodexStableReleaseVersion(releases []*GitHubRelease) string {
best := ""
for _, release := range releases {
if release == nil || release.Draft || release.Prerelease {
continue
}
tag := strings.TrimSpace(release.TagName)
if !strings.HasPrefix(tag, openAICodexVersionTagPrefix) {
continue
}
version := NormalizeCodexClientVersion(strings.TrimPrefix(tag, openAICodexVersionTagPrefix))
if version == "" || strings.Contains(version, "-") {
continue
}
if best == "" || CompareVersions(version, best) > 0 {
best = version
}
}
return best
}
@@ -0,0 +1,468 @@
package service
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// --- mock: 只实现同步任务用到的读写,其余方法不应被调用 ---
type codexVersionSyncSettingRepoStub struct {
SettingRepository // 嵌入接口,未实现的方法会 panic(不应被调用)
mu sync.Mutex
values map[string]string
getErr error
setErr error
updatedAt time.Time
writes []string
}
func newCodexVersionSyncSettingRepoStub(values map[string]string) *codexVersionSyncSettingRepoStub {
if values == nil {
values = map[string]string{}
}
return &codexVersionSyncSettingRepoStub{values: values}
}
func (r *codexVersionSyncSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.getErr != nil {
return "", r.getErr
}
return r.values[key], nil
}
func (r *codexVersionSyncSettingRepoStub) Set(_ context.Context, key, value string) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.setErr != nil {
return r.setErr
}
r.values[key] = value
r.writes = append(r.writes, value)
return nil
}
func (r *codexVersionSyncSettingRepoStub) syncedWrites() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.writes...)
}
type codexVersionSyncGitHubStub struct {
GitHubReleaseClient // 嵌入接口,未实现的方法会 panic(不应被调用)
// 主路径 /releases/latest;latest 为 nil 且 latestErr 为 nil 时模拟「拿不到可用值」,
// 使调用落到回退的列表扫描上。
latest *GitHubRelease
latestErr error
latestCalls int
releases []*GitHubRelease
err error
calls int
}
func (c *codexVersionSyncGitHubStub) FetchLatestRelease(_ context.Context, _ string) (*GitHubRelease, error) {
c.latestCalls++
if c.latestErr != nil {
return nil, c.latestErr
}
return c.latest, nil
}
func (c *codexVersionSyncGitHubStub) FetchRecentReleases(_ context.Context, _ string, _ int) ([]*GitHubRelease, error) {
c.calls++
if c.err != nil {
return nil, c.err
}
return c.releases, nil
}
func newCodexVersionSyncService(
repo SettingRepository,
github GitHubReleaseClient,
) *OpenAICodexVersionSyncService {
return NewOpenAICodexVersionSyncService(repo, &SettingService{}, github, openAICodexVersionSyncInterval)
}
// 同仓库同时发布预发布版与其他组件的 tag,必须只认 rust-v 前缀的稳定版,
// 否则会把无关组件的版本号(如 rusty-v8-v150.4.0)当成客户端版本同步出去。
func TestLatestCodexStableReleaseVersion(t *testing.T) {
releases := []*GitHubRelease{
{TagName: "rusty-v8-v150.4.0"},
{TagName: "rust-v0.147.0-alpha.4", Prerelease: true},
{TagName: "rust-v0.146.0"},
{TagName: "rust-v0.145.0"},
{TagName: "rust-v0.999.0", Draft: true},
{TagName: "not-a-tag"},
nil,
}
require.Equal(t, "0.146.0", latestCodexStableReleaseVersion(releases))
require.Empty(t, latestCodexStableReleaseVersion(nil))
require.Empty(t, latestCodexStableReleaseVersion([]*GitHubRelease{{TagName: "rusty-v8-v150.4.0"}}))
// 预发布 tag 即使漏标 Prerelease 也要被版本号后缀挡住。
require.Empty(t, latestCodexStableReleaseVersion([]*GitHubRelease{{TagName: "rust-v0.147.0-alpha.4"}}))
}
func TestOpenAICodexVersionSyncWritesLatestStableVersion(t *testing.T) {
repo := newCodexVersionSyncSettingRepoStub(nil)
github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{
{TagName: "rust-v0.145.0"},
{TagName: "rust-v0.146.0"},
}}
newCodexVersionSyncService(repo, github).runOnce()
require.Equal(t, []string{"0.146.0"}, repo.syncedWrites())
}
// 只向前推进:上游偶发返回旧数据或重新发布历史 tag 时不把已同步版本降级。
func TestOpenAICodexVersionSyncNeverMovesBackwards(t *testing.T) {
repo := newCodexVersionSyncSettingRepoStub(map[string]string{
SettingKeyOpenAICodexClientVersionSynced: "0.146.0",
})
github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.145.0"}}}
newCodexVersionSyncService(repo, github).runOnce()
require.Empty(t, repo.syncedWrites())
}
func TestOpenAICodexVersionSyncSkippedWhenDisabled(t *testing.T) {
repo := newCodexVersionSyncSettingRepoStub(map[string]string{
SettingKeyOpenAICodexVersionAutoSyncEnabled: "false",
})
github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}}}
newCodexVersionSyncService(repo, github).runOnce()
require.Zero(t, github.latestCalls, "关闭自动同步后不应请求上游")
require.Zero(t, github.calls, "关闭自动同步后不应请求上游")
require.Empty(t, repo.syncedWrites())
}
// 面板开关缺失或为空一律视为开启,与设置默认值一致。
func TestOpenAICodexVersionSyncEnabledByDefault(t *testing.T) {
for _, value := range []string{"", "true"} {
repo := newCodexVersionSyncSettingRepoStub(map[string]string{
SettingKeyOpenAICodexVersionAutoSyncEnabled: value,
})
github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}}}
newCodexVersionSyncService(repo, github).runOnce()
require.Equal(t, []string{"0.146.0"}, repo.syncedWrites(), "开关值 %q", value)
}
}
// 抓取失败保持既有值,不清空、不降级。两条取数路径都失败才算真正拿不到。
func TestOpenAICodexVersionSyncKeepsValueOnFetchError(t *testing.T) {
repo := newCodexVersionSyncSettingRepoStub(map[string]string{
SettingKeyOpenAICodexClientVersionSynced: "0.146.0",
})
github := &codexVersionSyncGitHubStub{
latestErr: errors.New("network down"),
err: errors.New("network down"),
}
newCodexVersionSyncService(repo, github).runOnce()
require.Equal(t, 1, github.latestCalls)
require.Equal(t, 1, github.calls)
require.Empty(t, repo.syncedWrites())
value, err := repo.GetValue(context.Background(), SettingKeyOpenAICodexClientVersionSynced)
require.NoError(t, err)
require.Equal(t, "0.146.0", value)
}
// 主路径 /releases/latest:该端点已排除 draft / prerelease,直接给出最新正式发布,
// 因此不受该仓库预发布密度影响,也不必再去拉 ~10MB 的列表页。
func TestOpenAICodexVersionSyncUsesLatestReleaseEndpoint(t *testing.T) {
repo := newCodexVersionSyncSettingRepoStub(nil)
github := &codexVersionSyncGitHubStub{
latest: &GitHubRelease{TagName: "rust-v0.146.0"},
// 列表若被调用会给出不同答案,用于证明取值确实来自主路径。
releases: []*GitHubRelease{{TagName: "rust-v0.145.0"}},
}
newCodexVersionSyncService(repo, github).runOnce()
require.Equal(t, 1, github.latestCalls)
require.Zero(t, github.calls, "主路径可用时不应再拉列表页")
require.Equal(t, []string{"0.146.0"}, repo.syncedWrites())
}
// 回退列表扫描:latest 是跨 tag 家族按 published_at 取的,主路径拿不到客户端稳定版时
// 必须继续扫一页 release,否则版本号会静默停更。三种拿不到的形态都要回退。
func TestOpenAICodexVersionSyncFallsBackToReleaseList(t *testing.T) {
tests := []struct {
name string
latest *GitHubRelease
latestErr error
}{
// 同仓库其他组件(rusty-v8-*)某天发了正式 release 而成为 latest。
{name: "latest 属于其他 tag 家族", latest: &GitHubRelease{TagName: "rusty-v8-v150.4.0"}},
// 端点语义若变化(返回 draft/prerelease),过滤仍然拦得住,并照常回退。
{name: "latest 是预发布", latest: &GitHubRelease{TagName: "rust-v0.147.0-alpha.4", Prerelease: true}},
{name: "latest 抓取失败", latestErr: errors.New("network down")},
// 上游返回空对象:不得因此 panic,按拿不到处理。
{name: "latest 为空", latest: nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := newCodexVersionSyncSettingRepoStub(nil)
github := &codexVersionSyncGitHubStub{
latest: tt.latest,
latestErr: tt.latestErr,
releases: []*GitHubRelease{
{TagName: "rust-v0.147.0-alpha.4", Prerelease: true},
{TagName: "rust-v0.146.0"},
{TagName: "rust-v0.145.0"},
},
}
newCodexVersionSyncService(repo, github).runOnce()
require.Equal(t, 1, github.latestCalls)
require.Equal(t, 1, github.calls)
require.Equal(t, []string{"0.146.0"}, repo.syncedWrites())
})
}
}
// 两条路径共用同一套过滤:主路径的单条 latest 同样要过前缀 / 版本号形态校验,
// 不能因为「端点保证是正式发布」就直接采信 tag。
func TestOpenAICodexVersionSyncLatestSharesFiltering(t *testing.T) {
repo := newCodexVersionSyncSettingRepoStub(nil)
github := &codexVersionSyncGitHubStub{
// 仓库里确实存在 rust-vv0.99.0-alpha.8 / rust-vrust-v0.145.0-alpha.6 这类畸形 tag:
// 剥掉前缀后不是合法版本号,必须被拒绝而不是写成 v0.99.0。
latest: &GitHubRelease{TagName: "rust-vv0.99.0"},
releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}},
}
newCodexVersionSyncService(repo, github).runOnce()
require.Equal(t, []string{"0.146.0"}, repo.syncedWrites())
}
// 依赖缺失时 Start 必须直接返回,不能起一个空转的 goroutine。
func TestOpenAICodexVersionSyncStartRequiresDependencies(t *testing.T) {
require.NotPanics(t, func() {
svc := NewOpenAICodexVersionSyncService(nil, nil, nil, openAICodexVersionSyncInterval)
svc.Start()
svc.Stop()
})
}
// --- mock: 版本号读取只用到 GetMultiple ---
type codexVersionSettingRepoStub struct {
SettingRepository // 嵌入接口,未实现的方法会 panic(不应被调用)
values map[string]string
err error
}
func (r *codexVersionSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
if r.err != nil {
return nil, r.err
}
out := make(map[string]string, len(keys))
for _, key := range keys {
out[key] = r.values[key]
}
return out, nil
}
// 规范 UA 解析会先读面板的完整 UA 键。
func (r *codexVersionSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
if r.err != nil {
return "", r.err
}
return r.values[key], nil
}
// 版本号优先级:管理员面板覆写 → 自动同步值 → 内置常量。
// 管理员覆写必须压过同步值,否则「固定版本」的诉求会被 3 小时后的同步冲掉。
func TestGetOpenAICodexClientVersionPriority(t *testing.T) {
tests := []struct {
name string
override string
synced string
want string
}{
{name: "面板覆写优先", override: "0.150.0", synced: "0.146.0", want: "0.150.0"},
{name: "覆写为空时用同步值", synced: "0.146.0", want: "0.146.0"},
{name: "两者皆空时用内置常量", want: codexCLIVersion},
{name: "非法覆写回退同步值", override: "latest", synced: "0.146.0", want: "0.146.0"},
{name: "非法同步值回退内置常量", synced: "not-a-version", want: codexCLIVersion},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{
SettingKeyOpenAICodexClientVersion: tt.override,
SettingKeyOpenAICodexClientVersionSynced: tt.synced,
}}, nil)
require.Equal(t, tt.want, svc.GetOpenAICodexClientVersion(context.Background()))
})
}
}
// 读取失败必须回退内置常量,不能返回空串把畸形身份拼给上游。
func TestGetOpenAICodexClientVersionFallsBackOnError(t *testing.T) {
svc := NewSettingService(&codexVersionSettingRepoStub{err: errors.New("db down")}, nil)
require.Equal(t, codexCLIVersion, svc.GetOpenAICodexClientVersion(context.Background()))
}
// 规范 UA:面板未填完整 UA 时按当前生效版本号拼出标准 CLI 形态。
func TestGetOpenAICodexCanonicalUserAgentBuildsFromVersion(t *testing.T) {
svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{
SettingKeyOpenAICodexClientVersionSynced: "0.200.1",
}}, nil)
require.Equal(t,
"codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix,
svc.GetOpenAICodexCanonicalUserAgent(context.Background()),
)
}
// 回归:面板完整 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()
if r.getErr != nil {
return nil, r.getErr
}
value, ok := r.values[key]
if !ok {
return nil, ErrSettingNotFound
}
return &Setting{Key: key, Value: value, UpdatedAt: r.updatedAt}, nil
}
// 启动同步防抖:同步值仍在一个周期内时跳过,避免频繁重启/滚动发布把启动同步
// 放大成对 GitHub 的连续请求。
func TestOpenAICodexVersionSyncInitialSkipsWhenRecentlySynced(t *testing.T) {
repo := newCodexVersionSyncSettingRepoStub(map[string]string{
SettingKeyOpenAICodexClientVersionSynced: "0.146.0",
})
repo.updatedAt = time.Now().Add(-time.Hour)
github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.147.0"}}}
newCodexVersionSyncService(repo, github).runInitial()
require.Zero(t, github.calls, "同步值仍在周期内时不应请求上游")
require.Empty(t, repo.syncedWrites())
}
func TestOpenAICodexVersionSyncInitialRunsWhenStaleOrMissing(t *testing.T) {
t.Run("同步值已过期", func(t *testing.T) {
repo := newCodexVersionSyncSettingRepoStub(map[string]string{
SettingKeyOpenAICodexClientVersionSynced: "0.146.0",
})
repo.updatedAt = time.Now().Add(-7 * time.Hour)
github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.147.0"}}}
newCodexVersionSyncService(repo, github).runInitial()
require.Equal(t, 1, github.calls)
require.Equal(t, []string{"0.147.0"}, repo.syncedWrites())
})
// 首次部署尚无同步值:必须立刻同步,不能被防抖挡住。
t.Run("尚无同步值", func(t *testing.T) {
repo := newCodexVersionSyncSettingRepoStub(nil)
repo.updatedAt = time.Now()
github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}}}
newCodexVersionSyncService(repo, github).runInitial()
require.Equal(t, 1, github.calls)
require.Equal(t, []string{"0.146.0"}, repo.syncedWrites())
})
}
// 版本比较必须按段取数字:字典序会把 0.99.0 判为大于 0.146.0,
// 从而让「取最大值」和「只向前推进」两处逻辑同时判错。
func TestCodexVersionComparisonIsNumericNotLexical(t *testing.T) {
require.Greater(t, CompareVersions("0.146.0", "0.99.0"), 0)
require.Equal(t, "0.146.0", latestCodexStableReleaseVersion([]*GitHubRelease{
{TagName: "rust-v0.99.0"},
{TagName: "rust-v0.146.0"},
}))
repo := newCodexVersionSyncSettingRepoStub(map[string]string{
SettingKeyOpenAICodexClientVersionSynced: "0.146.0",
})
github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.99.0"}}}
newCodexVersionSyncService(repo, github).runOnce()
require.Empty(t, repo.syncedWrites(), "0.99.0 低于已同步的 0.146.0,不得写入")
}
@@ -1021,34 +1021,15 @@ func TestForwardAsAnthropic_OAuthRestoresCodexIdentityHeaders(t *testing.T) {
const tuiUA = "codex-tui/9.9.9 (Mac OS X 14.0; arm64) iTerm (codex-tui; 9.9.9)"
const vscodeUA = "codex_vscode/9.9.9 (Mac OS X 14.0; arm64) vscode (codex_vscode; 9.9.9)"
// messages 桥接路径同样强制统一出站身份:三类客户端身份都收敛到规范身份。
tests := []struct {
name string
userAgent string
originator string
wantUserAgent string
wantOriginator string
name string
userAgent string
originator string
}{
{
name: "官方UA逐字保留并重新配对",
userAgent: vscodeUA,
originator: "opencode",
wantUserAgent: vscodeUA,
wantOriginator: "codex_vscode",
},
{
name: "降载身份改写为CLI身份并保留终端指纹",
userAgent: tuiUA,
originator: "opencode",
wantUserAgent: "codex_cli_rs/9.9.9 (Mac OS X 14.0; arm64) iTerm",
wantOriginator: "codex_cli_rs",
},
{
name: "第三方UA回退为默认Codex身份",
userAgent: "third-party-client/1.0.0",
originator: "opencode",
wantUserAgent: codexCLIUserAgent,
wantOriginator: "codex_cli_rs",
},
{name: "官方vscode身份", userAgent: vscodeUA, originator: "opencode"},
{name: "TUI身份", userAgent: tuiUA, originator: "opencode"},
{name: "第三方UA", userAgent: "third-party-client/1.0.0", originator: "opencode"},
}
for _, tt := range tests {
@@ -1081,7 +1062,7 @@ func TestForwardAsAnthropic_OAuthRestoresCodexIdentityHeaders(t *testing.T) {
result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4")
require.NoError(t, err)
require.NotNil(t, result)
requireOpenAIMessagesCodexIdentity(t, upstream.lastReq, tt.wantUserAgent, tt.wantOriginator)
requireOpenAIMessagesCodexIdentity(t, upstream.lastReq, codexCLIUserAgent, "codex_cli_rs")
})
}
}
@@ -1098,13 +1098,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
req.Header.Set("user-agent", codexCLIUserAgent)
}
// 浏览器型 UA 兜底:仅 OAuth(ChatGPT 内部接口)账号生效,若最终 user-agent 仍为浏览器
// (Chrome/Firefox/Safari/Edge 等),替换为后台配置的 Codex UA,避免 Cloudflare 触发 JS 质询。
s.overrideBrowserUserAgent(ctx, account, req)
// 终态收口:originator 必须与最终 User-Agent 首段配套且为官方身份,否则上游 404(issue #3901)。
// 终态收口:强制统一 OAuth 出站身份(User-Agent / originator / version 同源自洽)。
// 客户端自报身份不参与构造,浏览器型 UA 也因此不会再到达上游(原浏览器 UA 兜底已被吸收)。
if account.Type == AccountTypeOAuth {
enforceCodexIdentityHeaders(req.Header)
enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
}
// Ensure required headers exist
@@ -1118,26 +1115,12 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
return req, nil
}
// overrideBrowserUserAgent 检查请求的最终 user-agent,若为浏览器 UA 则替换为后台配置的 Codex UA。
// 用于规避 Cloudflare 对浏览器型 UA 在 ChatGPT 内部接口上的访问质询。
// 影响范围严格限定:仅 OAuth(Codex/ChatGPT 内部接口)账号生效;API Key 等其他账号原样透传。
// 仅在识别为浏览器(Mozilla/...)时改写,其他 CLI/工具 UA 不动。
func (s *OpenAIGatewayService) overrideBrowserUserAgent(ctx context.Context, account *Account, req *http.Request) {
if req == nil || account == nil {
return
// codexIdentityOverrideUA 返回账号级显式配置的出站 User-Agent,供强制统一身份时作为覆写来源。
// ForceCodexCLI 语义是「强制使用 Codex CLI 身份」,等价于使用网关规范身份,故返回空串;
// 该优先级与历史行为一致(ForceCodexCLI 在账号自定义 UA 之后生效)。
func (s *OpenAIGatewayService) codexIdentityOverrideUA(account *Account) string {
if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
return ""
}
if account.Type != AccountTypeOAuth {
return
}
currentUA := req.Header.Get("user-agent")
if !openai.IsBrowserUserAgent(currentUA) {
return
}
codexUA := DefaultOpenAICodexUserAgent
if s != nil && s.settingService != nil {
if v := strings.TrimSpace(s.settingService.GetOpenAICodexUserAgent(ctx)); v != "" {
codexUA = v
}
}
req.Header.Set("user-agent", codexUA)
return account.GetOpenAIUserAgent()
}
@@ -443,15 +443,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
req.Header.Set("user-agent", codexCLIUserAgent)
}
// 浏览器型 UA 兜底:仅 OAuth(ChatGPT 内部接口)账号生效,若最终 user-agent 仍为浏览器
// (Chrome/Firefox/Safari/Edge 等),替换为后台配置的 Codex UA,避免 Cloudflare 触发 JS 质询。
s.overrideBrowserUserAgent(ctx, account, req)
// 终态收口:originator 必须与最终 User-Agent 首段配套且为官方身份,非官方 UA 整体回退为
// 默认 Codex CLI 身份(承接原「非 Codex UA 安全兜底」,并修复其把 codex-tui 等官方 UA 改写为
// codex_cli_rs 造成的 originator 错配 404),详见 issue #3901。
// 终态收口:透传路径的 OAuth 与非透传完全一致,同样强制统一出站身份
// (User-Agent / originator / version 同源自洽),客户端自报身份不会到达上游。
if account.Type == AccountTypeOAuth {
enforceCodexIdentityHeaders(req.Header)
enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
}
if req.Header.Get("content-type") == "" {
@@ -767,15 +762,34 @@ func openAIStreamDataStartsClientOutput(data, eventType string) bool {
return !openAIStreamEventIsPreamble(eventType)
}
// openAIStreamFailedEventErrorCode 提取流内 failed 事件的错误码(小写),
// 兼容 response.failed 的嵌套形态与裸 error 形态。
func openAIStreamFailedEventErrorCode(payload []byte) string {
code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String()))
if code == "" {
code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String()))
}
return code
}
// isOpenAIUpstreamCapacityShedEvent 判断流内 failed 事件是否为上游容量降载信号。
// 上游在容量紧张时会把请求丢进降载路径:HTTP 200 之后立刻推 event: error
// (code=server_is_overloaded / slow_down)并以 response.failed 收尾。
func isOpenAIUpstreamCapacityShedEvent(payload []byte) bool {
switch openAIStreamFailedEventErrorCode(payload) {
case "server_is_overloaded", "slow_down":
return true
default:
return false
}
}
func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int {
if isOpenAIContextWindowError(message, payload) {
return http.StatusBadRequest
}
code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String()))
if code == "" {
code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String()))
}
code := openAIStreamFailedEventErrorCode(payload)
errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.type").String()))
if errType == "" {
errType = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.type").String()))
@@ -925,7 +939,17 @@ func openAIStreamFailedEventShouldFailover(payload []byte, message string) bool
}
func openAIStreamFailedEventRetryableOnSameAccount(account *Account, payload []byte, message string) bool {
if account == nil || !account.IsPoolMode() {
if account == nil {
return false
}
// 容量降载是请求级信号,不是账号级故障:上游只是让本次请求稍后再试。
// 换账号并不改变被降载的因素(客户端身份、模型容量都与账号无关),
// 只会让单个请求把整池账号逐个消耗掉,最终仍以同一个错误告终。
// 因此先在同一账号上做有界重试,用尽后才按常规流程切号。
if isOpenAIUpstreamCapacityShedEvent(payload) {
return true
}
if !account.IsPoolMode() {
return false
}
semanticStatus := openAIStreamFailedEventSemanticStatus(payload, message)
@@ -1013,6 +1037,7 @@ func (s *OpenAIGatewayService) newOpenAIStreamFailoverError(
ResponseBody: body,
ResponseHeaders: headers,
RetryableOnSameAccount: openAIStreamFailedEventRetryableOnSameAccount(account, payload, message),
RequestScopedTransient: isOpenAIUpstreamCapacityShedEvent(payload),
}
}
@@ -35,8 +35,13 @@ const (
openaiStickySessionTTL = time.Hour // 粘性会话TTL
// 与真实 Codex CLI 的 User-Agent 结构对齐:
// {originator}/{version} ({OS} {OS_version}; {arch}) {terminal}
// 旧值 "codex_cli_rs/0.125.0" 缺少 OS/架构/终端后缀,易被上游指纹识别为非官方客户端。
codexCLIUserAgent = "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color"
// 缺少 OS/架构/终端后缀的形态易被上游指纹识别为非官方客户端。
// 该后缀是 UA 形态的唯一定义处,buildCodexCLIUserAgent 按运行时版本号复用它。
codexCLIUserAgentSuffix = " (Ubuntu 22.4.0; x86_64) xterm-256color"
// codexCLIUserAgent 是编译期兜底 UA;运行时优先使用由后台版本号拼出的规范 UA。
// 版本段必须来自 codexCLIVersion:UA 与 version 头是同一个版本声明的两个出口,
// 各自硬编码会漂移成互相矛盾的身份。
codexCLIUserAgent = "codex_cli_rs/" + codexCLIVersion + codexCLIUserAgentSuffix
// codex_cli_only 拒绝时单个请求头日志长度上限(字符)
codexCLIOnlyHeaderValueMaxBytes = 256
@@ -51,7 +56,12 @@ const (
openAIWSRetryJitterRatioDefault = 0.2
openAICompactSessionSeedKey = "openai_compact_session_seed"
openAIUpstreamEndpointContextKey = "openai_actual_upstream_endpoint"
codexCLIVersion = "0.144.1"
// codexCLIVersion 是网关对上游声明的 Codex 客户端版本,同时供 codexCLIUserAgent
// 与 version 头使用。上游 /backend-api/codex 在容量紧张时按客户端身份分优先级降载,
// 陈旧版本会被优先丢弃(HTTP 200 + 流内 server_is_overloaded);非官方客户端配不出
// 官方身份时整体回退到本常量,因此它必须跟随官方 CLI 的当前发布版本,
// 落后多个版本会让这些请求稳定落在被优先丢弃的一侧。
codexCLIVersion = "0.146.0"
// Codex 限额快照仅用于后台展示/诊断,不需要每个成功请求都立即落库。
openAICodexSnapshotPersistMinInterval = 30 * time.Second
// 配额自动暂停时,超过该时长仍未刷新的 used% 快照视为陈旧,不再据此暂停账号。
@@ -472,9 +482,9 @@ func NewOpenAIGatewayService(
userPlatformQuotaRepo UserPlatformQuotaRepository,
) *OpenAIGatewayService {
// enforceCodexIdentityHeaders 是 HTTP / 透传 / WS / 探针 等出站路径共用的纯函数收口点,
// 拿不到配置,故在此发布进程级开关快照。配置取反义,零值即「归一化开启」。
// 拿不到配置,故在此发布进程级开关快照。配置取反义,零值即「强制统一出口开启」。
if cfg != nil {
SetCodexOriginatorNormalizationEnabled(!cfg.Gateway.DisableCodexOriginatorNormalization)
SetCodexIdentityEnforcementEnabled(!cfg.Gateway.DisableCodexIdentityEnforcement)
}
svc := &OpenAIGatewayService{
accountRepo: accountRepo,
@@ -1692,6 +1692,10 @@ func TestOpenAIStreamingResponseFailedBeforeOutputServerOverloadedCodeReturnsFai
require.ErrorAs(t, err, &failoverErr)
require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
require.Contains(t, string(failoverErr.ResponseBody), "Please retry later")
// 容量降载是请求级信号:非池模式账号也要先在同账号重试,且不得据此临时封禁账号。
// 否则单个被降载的请求会把整池账号逐个消耗掉,而降载因素在每个账号上都相同。
require.True(t, failoverErr.RetryableOnSameAccount)
require.True(t, failoverErr.RequestScopedTransient)
require.False(t, c.Writer.Written())
require.Empty(t, rec.Body.String())
}
@@ -2983,33 +2987,26 @@ func TestOpenAIBuildUpstreamRequestPreservesCodexIdentityHeaders(t *testing.T) {
func TestOpenAIBuildUpstreamRequestOAuthOfficialClientOriginatorCompatibility(t *testing.T) {
gin.SetMode(gin.TestMode)
// 上游要求 originator 与最终 User-Agent 首段配套(issue #3901):
// originator 一律由最终 UA 推导;推导不出官方身份时整体回退默认 Codex CLI 身份。
// 强制统一出口:客户端自报的 originator / User-Agent 都不参与上游身份构造,
// 一律改写为网关规范身份,天然满足 originator 与 UA 首段配套的上游校验(issue #3901)。
tests := []struct {
name string
userAgent string
originator string
wantOriginator string
wantUA string
name string
userAgent string
originator string
}{
{name: "official ua pairs originator", userAgent: "Codex Desktop/1.2.3", wantOriginator: "Codex Desktop", wantUA: "Codex Desktop/1.2.3"},
{name: "official desktop ua", userAgent: "Codex Desktop/1.2.3"},
{
name: "mismatched originator repaired from ua",
userAgent: "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)",
originator: "codex_cli_rs",
wantOriginator: "codex_vscode",
wantUA: "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)",
name: "mismatched originator",
userAgent: "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)",
originator: "codex_cli_rs",
},
{
// 降载桶身份改写为 CLI 身份,只替换身份段、保留版本/OS/架构/终端指纹。
name: "load-shed originator normalized to cli identity",
userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)",
originator: "codex-tui",
wantOriginator: "codex_cli_rs",
wantUA: "codex_cli_rs/0.140.2 (Mac OS X 14.0; arm64) iTerm",
name: "tui identity",
userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)",
originator: "codex-tui",
},
{name: "official originator without ua falls back to default identity", originator: "codex_vscode", wantOriginator: "codex_cli_rs", wantUA: codexCLIUserAgent},
{name: "third-party ua masked to default identity", userAgent: "luna/1.2.0", wantOriginator: "codex_cli_rs", wantUA: codexCLIUserAgent},
{name: "official originator without ua", originator: "codex_vscode"},
{name: "third-party ua", userAgent: "luna/1.2.0"},
}
for _, tt := range tests {
@@ -3033,8 +3030,9 @@ func TestOpenAIBuildUpstreamRequestOAuthOfficialClientOriginatorCompatibility(t
isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator"))
req, err := svc.buildUpstreamRequest(c.Request.Context(), c, account, []byte(`{"model":"gpt-5"}`), "token", false, "", isCodexCLI)
require.NoError(t, err)
require.Equal(t, tt.wantOriginator, req.Header.Get("originator"))
require.Equal(t, tt.wantUA, req.Header.Get("User-Agent"))
require.Equal(t, "codex_cli_rs", req.Header.Get("originator"))
require.Equal(t, codexCLIUserAgent, req.Header.Get("User-Agent"))
require.Equal(t, codexCLIVersion, req.Header.Get("version"))
})
}
}
@@ -414,7 +414,8 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
// 2) only auth is replaced; inbound auth/cookie are not forwarded
require.Equal(t, "Bearer oauth-token", upstream.lastReq.Header.Get("Authorization"))
require.Equal(t, "codex_cli_rs/0.1.0", upstream.lastReq.Header.Get("User-Agent"))
// 强制统一出口:客户端自报的 codex_cli_rs/0.1.0 不会到达上游。
require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent"))
require.Empty(t, upstream.lastReq.Header.Get("Cookie"))
require.Empty(t, upstream.lastReq.Header.Get("X-Api-Key"))
require.Empty(t, upstream.lastReq.Header.Get("X-Goog-Api-Key"))
@@ -1784,13 +1785,10 @@ func TestOpenAIGatewayService_OAuthPassthrough_NonCodexUAFallbackToCodexUA(t *te
require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent"))
}
// 回归(issue #3901):官方 UA 在透传模式下必须逐字保留,且 originator 由最终 UA 推导
// 配套——历史实现会把官方 UA 强改为 codex_cli_rs,而 originator 保留客户端原值,
// 造成 originator/UA 首段错配被上游 404。
//
// 用例取 codex_vscode 而非 codex-tui:后者落在上游降载桶,会被身份归一化改写,
// 该行为由 ..._CodexTuiIdentityNormalizedToCLI 单独覆盖。
func TestOpenAIGatewayService_OAuthPassthrough_OfficialIdentityPreservedAndPaired(t *testing.T) {
// 透传模式的 OAuth 与非透传一致:官方客户端身份同样被强制统一为网关规范身份,
// originator 与 UA 首段天然配套,不会出现历史上 originator/UA 错配被上游 404 的形态
// (issue #3901)。
func TestOpenAIGatewayService_OAuthPassthrough_OfficialIdentityUnified(t *testing.T) {
gin.SetMode(gin.TestMode)
const tuiUA = "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)"
@@ -1832,14 +1830,13 @@ func TestOpenAIGatewayService_OAuthPassthrough_OfficialIdentityPreservedAndPaire
_, err := svc.Forward(context.Background(), c, account, inputBody)
require.NoError(t, err)
require.NotNil(t, upstream.lastReq)
require.Equal(t, tuiUA, upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, "codex_vscode", upstream.lastReq.Header.Get("originator"))
require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, "codex_cli_rs", upstream.lastReq.Header.Get("originator"))
require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("version"))
}
// 透传模式同样要做降载身份归一化:codex-tui 落在上游降载桶,会被回
// server_is_overloaded 并触发账号冷却,故改写为 CLI 身份,仅替换身份段、
// 保留版本 / OS / 架构 / 终端指纹,且改写后仍与 originator 配套(issue #3901)。
func TestOpenAIGatewayService_OAuthPassthrough_CodexTuiIdentityNormalizedToCLI(t *testing.T) {
// 透传模式下真实 TUI 客户端的身份同样被统一:被优先降载的身份不会带到上游。
func TestOpenAIGatewayService_OAuthPassthrough_CodexTuiIdentityUnified(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
@@ -1877,8 +1874,9 @@ func TestOpenAIGatewayService_OAuthPassthrough_CodexTuiIdentityNormalizedToCLI(t
_, err := svc.Forward(context.Background(), c, account, inputBody)
require.NoError(t, err)
require.NotNil(t, upstream.lastReq)
require.Equal(t, "codex_cli_rs/0.140.2 (Mac OS X 14.0; arm64) iTerm", upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, "codex_cli_rs", upstream.lastReq.Header.Get("originator"))
require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("version"))
}
func TestOpenAIGatewayService_CodexCLIOnly_RejectsNonCodexClient(t *testing.T) {
@@ -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)。
@@ -757,32 +757,25 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T
func TestOpenAIGatewayService_Forward_WSv2_OAuthOriginatorCompatibility(t *testing.T) {
gin.SetMode(gin.TestMode)
// 上游要求 originator 与最终 user-agent 首段配套(issue #3901):
// originator 一律由最终 UA 推导;推导不出官方身份时整体回退默认 Codex CLI 身份。
// WS 握手头与 HTTP 出站共用身份收口:同样强制统一为网关规范身份,
// 客户端自报的 originator / user-agent 不参与构造(issue #3901 的配对不变式自然满足)。
tests := []struct {
name string
userAgent string
originator string
wantOriginator string
wantUA string
name string
userAgent string
originator string
}{
{name: "official ua pairs originator", userAgent: "Codex Desktop/1.2.3", wantOriginator: "Codex Desktop", wantUA: "Codex Desktop/1.2.3"},
{name: "official desktop ua", userAgent: "Codex Desktop/1.2.3"},
{
name: "mismatched originator repaired from ua",
userAgent: "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)",
originator: "codex_cli_rs",
wantOriginator: "codex_vscode",
wantUA: "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)",
name: "mismatched originator",
userAgent: "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)",
originator: "codex_cli_rs",
},
{
// WS 握手头与 HTTP 出站共用收口,降载桶身份同样改写为 CLI 身份。
name: "load-shed originator normalized to cli identity",
userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)",
originator: "codex-tui",
wantOriginator: "codex_cli_rs",
wantUA: "codex_cli_rs/0.140.2 (Mac OS X 14.0; arm64) iTerm",
name: "tui identity",
userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)",
originator: "codex-tui",
},
{name: "official originator without ua falls back to default identity", originator: "codex_vscode", wantOriginator: "codex_cli_rs", wantUA: codexCLIUserAgent},
{name: "official originator without ua", originator: "codex_vscode"},
}
for _, tt := range tests {
@@ -846,12 +839,82 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthOriginatorCompatibility(t *testi
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, tt.wantOriginator, captureDialer.lastHeaders.Get("originator"))
require.Equal(t, tt.wantUA, captureDialer.lastHeaders.Get("user-agent"))
require.Equal(t, "codex_cli_rs", captureDialer.lastHeaders.Get("originator"))
require.Equal(t, codexCLIUserAgent, captureDialer.lastHeaders.Get("user-agent"))
require.Equal(t, codexCLIVersion, captureDialer.lastHeaders.Get("version"))
})
}
}
// 账号级自定义 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)
@@ -83,8 +83,9 @@ const antigravityUserAgentVersionErrorTTL = 5 * time.Second
const antigravityUserAgentVersionDBTimeout = 5 * time.Second
// DefaultOpenAICodexUserAgent OpenAI Codex 默认 User-Agent(用于规避 Cloudflare 对浏览器 UA 的质询)。
// 取官方 CLI 身份而非 TUI 身份:上游按 originator 分桶调度容量,TUI 身份命中降载桶会被回
// server_is_overloaded 并触发账号冷却,而该默认值是浏览器 UA 兜底路径上最主要的身份来源。
// 取官方 CLI 身份而非 TUI 身份:上游在容量紧张时按客户端身份分优先级降载,TUI 身份被观测到
// 优先命中降载,而该默认值是浏览器 UA 兜底路径上最主要的身份来源。版本段随 codexCLIVersion
// 一起更新——陈旧版本同样会被优先降载。
const DefaultOpenAICodexUserAgent = codexCLIUserAgent
// cachedOpenAICodexUserAgent 缓存 OpenAI Codex UA(进程内缓存,60s TTL)
@@ -93,6 +94,19 @@ type cachedOpenAICodexUserAgent struct {
expiresAt int64 // unix nano
}
// cachedOpenAICodexClientVersion 缓存出站 Codex 客户端版本号(进程内缓存,60s TTL)
type cachedOpenAICodexClientVersion struct {
version string
expiresAt int64 // unix nano
}
const openAICodexClientVersionCacheTTL = 60 * time.Second
const openAICodexClientVersionErrorTTL = 5 * time.Second
const openAICodexClientVersionDBTimeout = 5 * time.Second
// openAICodexClientVersionSFKey singleflight 键。
const openAICodexClientVersionSFKey = "openai_codex_client_version"
type cachedOpenAIQuotaAutoPauseSettings struct {
settings OpsOpenAIAccountQuotaAutoPauseSettings
expiresAt int64
@@ -284,6 +298,97 @@ func (s *SettingService) GetOpenAICodexUserAgent(ctx context.Context) string {
return fallback
}
// GetOpenAICodexClientVersion 返回出站声明的 Codex 客户端版本号。
// 优先级:管理员在面板覆写的版本 → 自动同步到的官方最新稳定版 → 内置常量。
// 上游在容量紧张时按客户端身份分优先级降载,陈旧版本会被优先丢弃,故该值需保持跟随官方发布;
// 自动同步让运维不必为了跟版本而发新版本。
func (s *SettingService) GetOpenAICodexClientVersion(ctx context.Context) string {
fallback := codexCLIVersion
if s == nil || s.settingRepo == nil {
return fallback
}
if cached, ok := s.openAICodexVersionCache.Load().(*cachedOpenAICodexClientVersion); ok && cached != nil {
if time.Now().UnixNano() < cached.expiresAt {
return cached.version
}
}
result, _, _ := s.openAICodexVersionSF.Do(openAICodexClientVersionSFKey, func() (any, error) {
if cached, ok := s.openAICodexVersionCache.Load().(*cachedOpenAICodexClientVersion); ok && cached != nil {
if time.Now().UnixNano() < cached.expiresAt {
return cached.version, nil
}
}
if ctx == nil {
ctx = context.Background()
}
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAICodexClientVersionDBTimeout)
defer cancel()
values, err := s.settingRepo.GetMultiple(dbCtx, []string{
SettingKeyOpenAICodexClientVersion,
SettingKeyOpenAICodexClientVersionSynced,
})
if err != nil {
slog.Warn("failed to get openai codex client version setting", "error", err)
s.openAICodexVersionCache.Store(&cachedOpenAICodexClientVersion{
version: fallback,
expiresAt: time.Now().Add(openAICodexClientVersionErrorTTL).UnixNano(),
})
return fallback, nil
}
version := NormalizeCodexClientVersion(values[SettingKeyOpenAICodexClientVersion])
if version == "" {
version = NormalizeCodexClientVersion(values[SettingKeyOpenAICodexClientVersionSynced])
}
if version == "" {
version = fallback
}
s.openAICodexVersionCache.Store(&cachedOpenAICodexClientVersion{
version: version,
expiresAt: time.Now().Add(openAICodexClientVersionCacheTTL).UnixNano(),
})
return version, nil
})
if version, ok := result.(string); ok && version != "" {
return version
}
return fallback
}
// InvalidateOpenAICodexClientVersionCache 丢弃版本号缓存,下次读取回源。
// 面板保存与自动同步写入后调用。
func (s *SettingService) InvalidateOpenAICodexClientVersionCache() {
if s == nil {
return
}
s.openAICodexVersionSF.Forget(openAICodexClientVersionSFKey)
s.openAICodexVersionCache.Store((*cachedOpenAICodexClientVersion)(nil))
}
// GetOpenAICodexCanonicalUserAgent 返回出站规范 Codex User-Agent。
// 未填面板 UA 时按当前生效的客户端版本号拼出标准 CLI UA。
//
// 面板 UA 只贡献客户端名与 OS / 架构 / 终端指纹,版本段一律用生效版本重建:该输入框是
// 唯一能改 UA 后缀的地方,但它填写于某个历史版本,逐字沿用会把出站身份永久钉死在陈旧
// 版本上并绕过自动同步——而陈旧身份正是上游优先降载的那一侧。
// 需要固定版本请填「Codex 客户端版本号」并关闭自动同步。
func (s *SettingService) GetOpenAICodexCanonicalUserAgent(ctx context.Context) string {
if s == nil {
return codexCLIUserAgent
}
version := s.GetOpenAICodexClientVersion(ctx)
ua := strings.TrimSpace(s.GetOpenAICodexUserAgent(ctx))
if ua == "" {
return buildCodexCLIUserAgent(version)
}
if rebuilt := openai.SetCodexUserAgentVersion(ua, version); rebuilt != "" {
return rebuilt
}
// 非 `{client}/{version}` 形态:交给 PairCodexClientIdentity 判定,
// 推导不出官方身份时由收口整体回退规范身份。
return ua
}
var legacyClaudeCodeCodexWhitelistEntry = openai.AllowedClientEntry{
Originator: "Claude Code",
UAContains: []string{"Claude Code/"},
+11
View File
@@ -227,6 +227,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyEnableClientDatelineNormalization: "true",
SettingKeyAntigravityUserAgentVersion: "",
SettingKeyOpenAICodexUserAgent: "",
SettingKeyOpenAICodexClientVersion: "",
SettingKeyOpenAICodexClientVersionSynced: "",
SettingKeyOpenAICodexVersionAutoSyncEnabled: "true",
SettingPaymentVisibleMethodAlipaySource: "",
SettingPaymentVisibleMethodWxpaySource: "",
SettingPaymentVisibleMethodAlipayEnabled: "false",
@@ -824,6 +827,14 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
}
result.AntigravityUserAgentVersion = antigravity.NormalizeUserAgentVersion(settings[SettingKeyAntigravityUserAgentVersion])
result.OpenAICodexUserAgent = strings.TrimSpace(settings[SettingKeyOpenAICodexUserAgent])
result.OpenAICodexClientVersion = NormalizeCodexClientVersion(settings[SettingKeyOpenAICodexClientVersion])
result.OpenAICodexClientVersionSynced = NormalizeCodexClientVersion(settings[SettingKeyOpenAICodexClientVersionSynced])
// 自动同步默认开启:缺失/空值一律视为开启,与 enable_client_dateline_normalization 同一惯例。
if v, ok := settings[SettingKeyOpenAICodexVersionAutoSyncEnabled]; ok && v != "" {
result.OpenAICodexVersionAutoSyncEnabled = v == "true"
} else {
result.OpenAICodexVersionAutoSyncEnabled = true
}
// codex_cli_only 加固
result.MinCodexVersion = settings[SettingKeyMinCodexVersion]
result.MaxCodexVersion = settings[SettingKeyMaxCodexVersion]
@@ -57,6 +57,8 @@ type SettingService struct {
antigravityUAVersionSF singleflight.Group
openAICodexUACache atomic.Value // *cachedOpenAICodexUserAgent
openAICodexUASF singleflight.Group
openAICodexVersionCache atomic.Value // *cachedOpenAICodexClientVersion
openAICodexVersionSF singleflight.Group
codexRestrictionPolicyCache atomic.Value // *cachedCodexRestrictionPolicy
codexRestrictionPolicySF singleflight.Group
@@ -438,6 +438,10 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyEnableClientDatelineNormalization] = strconv.FormatBool(settings.EnableClientDatelineNormalization)
updates[SettingKeyAntigravityUserAgentVersion] = antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion)
updates[SettingKeyOpenAICodexUserAgent] = strings.TrimSpace(settings.OpenAICodexUserAgent)
updates[SettingKeyOpenAICodexClientVersion] = NormalizeCodexClientVersion(settings.OpenAICodexClientVersion)
updates[SettingKeyOpenAICodexVersionAutoSyncEnabled] = strconv.FormatBool(settings.OpenAICodexVersionAutoSyncEnabled)
// SettingKeyOpenAICodexClientVersionSynced 由自动同步任务独占写入,此处不得覆盖,
// 否则面板保存会把同步结果清空。
// codex_cli_only 加固
updates[SettingKeyMinCodexVersion] = strings.TrimSpace(settings.MinCodexVersion)
updates[SettingKeyMaxCodexVersion] = strings.TrimSpace(settings.MaxCodexVersion)
@@ -610,6 +614,9 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) {
value: codexUA,
expiresAt: time.Now().Add(openAICodexUserAgentCacheTTL).UnixNano(),
})
// 版本号缓存只做失效,不在此重算:生效值还取决于自动同步写入的 synced 键,
// 这里没有它的最新值,重算会把同步结果覆盖成陈旧值。
s.InvalidateOpenAICodexClientVersionCache()
openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey)
openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{
lowUpstreamRatePriorityEnabled: settings.OpenAILowUpstreamRatePriorityEnabled,
+4 -1
View File
@@ -212,7 +212,10 @@ type SystemSettings struct {
EnableClientDatelineNormalization bool // 是否对 Anthropic OAuth/SetupToken 请求体做客户端 dateline 归一化(默认 true)
RewriteMessageCacheControl bool // 是否改写 messages[*].content[*].cache_control(默认 false)
AntigravityUserAgentVersion string // Antigravity 上游 User-Agent 版本号;空值使用配置/默认值
OpenAICodexUserAgent string // OpenAI Codex 上游完整 User-Agent;空值使用内置默认
OpenAICodexUserAgent string // OpenAI Codex 上游完整 User-Agent;空值由 Codex 客户端版本号拼出标准 CLI UA
OpenAICodexClientVersion string // 出站声明的 Codex 客户端版本号(管理员覆写);空值跟随自动同步值
OpenAICodexClientVersionSynced string // 自动同步到的官方最新稳定版版本号(只读展示)
OpenAICodexVersionAutoSyncEnabled bool // 是否启用 Codex 客户端版本号自动同步(默认 true)
MinCodexVersion string // codex_cli_only 最低 Codex 引擎版本;空=不检查
MaxCodexVersion string // codex_cli_only 最高 Codex 引擎版本;空=不检查
CodexCLIOnlyBlacklist string // codex_cli_only 全局黑名单 JSON([]AllowedClientEntry,OR deny)
+18
View File
@@ -274,6 +274,18 @@ func ProvideAccountExpiryService(accountRepo AccountRepository) *AccountExpirySe
return svc
}
// ProvideOpenAICodexVersionSyncService creates and starts OpenAICodexVersionSyncService.
// 出站 Codex 身份的版本号靠它跟随官方发布,无需为了跟版本而发新版本;面板可关闭。
func ProvideOpenAICodexVersionSyncService(
settingRepo SettingRepository,
settingService *SettingService,
githubClient GitHubReleaseClient,
) *OpenAICodexVersionSyncService {
svc := NewOpenAICodexVersionSyncService(settingRepo, settingService, githubClient, openAICodexVersionSyncInterval)
svc.Start()
return svc
}
// ProvideProxyExpiryService creates and starts ProxyExpiryService.
func ProvideProxyExpiryService(proxyRepo ProxyRepository) *ProxyExpiryService {
svc := NewProxyExpiryService(proxyRepo, time.Minute)
@@ -637,6 +649,11 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit
logger.LegacyPrintf("service.setting", "Warning: migrate codex body fingerprint to signals failed: %v", err)
}
antigravity.SetUserAgentVersionResolver(svc.GetAntigravityUserAgentVersion)
// enforceCodexIdentityHeaders 是所有 Codex 出站路径共用的纯函数收口点,拿不到 ctx,
// 故注入无参解析器;解析器内部自带 60s TTL 缓存,热路径不触库。
SetCodexCanonicalUserAgentResolver(func() string {
return svc.GetOpenAICodexCanonicalUserAgent(context.Background())
})
return svc
}
@@ -756,6 +773,7 @@ var ProviderSet = wire.NewSet(
ProvideTokenRefreshService,
wire.Bind(new(GrokOAuthReconciler), new(*TokenRefreshService)),
ProvideAccountExpiryService,
ProvideOpenAICodexVersionSyncService,
ProvideProxyExpiryService,
ProvideSubscriptionExpiryService,
ProvideTimingWheelService,
+5
View File
@@ -574,6 +574,9 @@ export interface SystemSettings {
enable_client_dateline_normalization: boolean;
antigravity_user_agent_version: string;
openai_codex_user_agent: string;
openai_codex_client_version: string;
openai_codex_client_version_synced: string;
openai_codex_version_auto_sync_enabled: boolean;
// codex_cli_only 加固
min_codex_version: string;
max_codex_version: string;
@@ -865,6 +868,8 @@ export interface UpdateSettingsRequest {
enable_client_dateline_normalization?: boolean;
antigravity_user_agent_version?: string;
openai_codex_user_agent?: string;
openai_codex_client_version?: string;
openai_codex_version_auto_sync_enabled?: boolean;
// codex_cli_only 加固
min_codex_version?: string;
max_codex_version?: string;
@@ -423,8 +423,14 @@ export default {
antigravityUserAgentVersionPlaceholder: '1.23.2',
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.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color',
openaiCodexUserAgentHint: 'Used to bypass Cloudflare browser-UA challenges on the OpenAI upstream. Only applies when the client User-Agent is detected as a browser (Mozilla/...). Leave empty to use the built-in default. Prefer a codex_cli_rs identity: the upstream schedules capacity per originator, and identities in a load-shed bucket get server_is_overloaded, which puts the account into cooldown.',
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, 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.',
openaiCodexVersionAutoSync: 'Auto-sync Codex version',
openaiCodexVersionAutoSyncHint: 'Fetches the latest stable client version from the official repository every 6 hours, so you never need to upgrade this service just to keep the version current. When disabled, only the version above or the built-in default is used.',
openaiCodexVersionSyncedValue: 'Currently synced: {version}',
codexHardeningTitle: "Codex Settings",
codexClientRestrictionTitle: "Codex client restriction",
codexHardeningDesc:
@@ -416,8 +416,14 @@ export default {
antigravityUserAgentVersionPlaceholder: '1.23.2',
antigravityUserAgentVersionHint: '留空时使用 ANTIGRAVITY_USER_AGENT_VERSION 或内置默认值 1.23.2;填写后后台设置优先。',
openaiCodexUserAgent: 'OpenAI Codex UA',
openaiCodexUserAgentPlaceholder: 'codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color',
openaiCodexUserAgentHint: '用于规避 OpenAI 上游 Cloudflare 对浏览器 UA 的访问质询。仅在检测到客户端 User-Agent 为浏览器(Mozilla/...)时生效,其他客户端原样透传。留空使用内置默认值。建议填写 codex_cli_rs 形态:上游按 originator 分桶调度容量,落在降载桶的身份会被回 server_is_overloaded 并触发账号冷却。',
openaiCodexUserAgentPlaceholder: 'codex_cli_rs/0.146.0 (Ubuntu 22.4.0; x86_64) xterm-256color',
openaiCodexUserAgentHint: '出站统一使用的完整 Codex User-Agent,用于自定义 OS / 架构 / 终端指纹。留空则按下方版本号拼出标准 codex_cli_rs 形态(推荐)。填写后其版本段仍会被下方版本号覆盖,避免这条 UA 停在填写时的旧版本——上游在容量紧张时按客户端身份分优先级降载,陈旧或非官方形态的身份会被优先丢弃并回 server_is_overloaded。',
openaiCodexClientVersion: 'Codex 客户端版本号',
openaiCodexClientVersionPlaceholder: '留空则跟随自动同步',
openaiCodexClientVersionHint: '网关对上游声明的 Codex 客户端版本号,User-Agent 与 version 头同源使用。留空表示使用自动同步到的官方最新稳定版;填写后固定为该版本,不再跟随同步。',
openaiCodexVersionAutoSync: '自动同步 Codex 版本号',
openaiCodexVersionAutoSyncHint: '每 6 小时从官方仓库获取最新稳定版客户端版本号,无需为了跟版本而升级本服务。关闭后仅使用上方手填版本或内置版本。',
openaiCodexVersionSyncedValue: '当前同步到:{version}',
codexHardeningTitle: 'Codex 设置',
codexClientRestrictionTitle: 'Codex 客户端限制',
codexHardeningDesc:
+75
View File
@@ -5168,6 +5168,65 @@
</p>
</div>
<!-- Codex 客户端版本号 -->
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t(
"admin.settings.gatewayForwarding.openaiCodexClientVersion",
)
}}
</label>
<input
v-model="form.openai_codex_client_version"
type="text"
class="input w-full font-mono text-sm"
:placeholder="
t(
'admin.settings.gatewayForwarding.openaiCodexClientVersionPlaceholder',
)
"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{
t(
"admin.settings.gatewayForwarding.openaiCodexClientVersionHint",
)
}}
</p>
</div>
<!-- Codex 版本号自动同步 -->
<div class="flex items-center justify-between">
<div>
<label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t(
"admin.settings.gatewayForwarding.openaiCodexVersionAutoSync",
)
}}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{
t(
"admin.settings.gatewayForwarding.openaiCodexVersionAutoSyncHint",
)
}}
</p>
<p
v-if="codexSyncedVersionLabel"
class="mt-0.5 text-xs text-gray-500 dark:text-gray-400"
>
{{ codexSyncedVersionLabel }}
</p>
</div>
<Toggle v-model="form.openai_codex_version_auto_sync_enabled" />
</div>
</div>
</div>
@@ -9038,6 +9097,10 @@ const form = reactive<SettingsForm>({
enable_client_dateline_normalization: true,
antigravity_user_agent_version: "",
openai_codex_user_agent: "",
openai_codex_client_version: "",
// 只读展示:自动同步任务写入的官方最新稳定版,不参与提交(提交载荷按字段显式构造)
openai_codex_client_version_synced: "",
openai_codex_version_auto_sync_enabled: true,
// codex_cli_only 加固
min_codex_version: "",
max_codex_version: "",
@@ -9957,6 +10020,14 @@ function removeCodexWhitelistRow(i: number): void {
codexWhitelistRows.value.splice(i, 1);
}
const codexSyncedVersionLabel = computed(() => {
const synced = form.openai_codex_client_version_synced?.trim();
if (!synced) return "";
return t("admin.settings.gatewayForwarding.openaiCodexVersionSyncedValue", {
version: synced,
});
});
async function loadSettings() {
loading.value = true;
loadFailed.value = false;
@@ -10510,6 +10581,10 @@ async function saveSettings() {
form.antigravity_user_agent_version?.trim() || "",
openai_codex_user_agent:
form.openai_codex_user_agent?.trim() || "",
openai_codex_client_version:
form.openai_codex_client_version?.trim() || "",
openai_codex_version_auto_sync_enabled:
form.openai_codex_version_auto_sync_enabled,
min_codex_version: form.min_codex_version?.trim() || "",
max_codex_version: form.max_codex_version?.trim() || "",
codex_cli_only_allow_app_server_clients: