Merge pull request #5351 from Wei-Shaw/fix/codex-tui-default-identity

fix(openai): default OAuth identity to codex-tui
This commit is contained in:
Wesley Liddick
2026-08-07 10:17:00 +08:00
committed by GitHub
27 changed files with 85 additions and 59 deletions
+1 -1
View File
@@ -585,7 +585,7 @@ func (c *Client) OnboardUser(ctx context.Context, accessToken, tierID string) (s
return "", lastErr
}
// done=false 时等待后重试(与 CLIProxyAPI 行为一致)
// done=false 时等待后重试,避免在上游仍未完成时提前结束轮询。
select {
case <-time.After(2 * time.Second):
case <-ctx.Done():
+4 -2
View File
@@ -244,10 +244,12 @@ func canonicalizeCodexOriginator(name string) string {
return name
}
// CodexCLIOriginator 官方 Codex CLI 默认 originator(codex-rs DEFAULT_ORIGINATOR),
// 也是身份归一化的目标身份。
// CodexCLIOriginator 是 codex-rs 客户端的历史默认 originator,保留用于兼容识别。
const CodexCLIOriginator = "codex_cli_rs"
// CodexDefaultOriginator 是网关默认使用的 Codex TUI originator。
const CodexDefaultOriginator = "codex-tui"
// CodexUserAgentVersion 提取 Codex UA 的完整版本段,即 `{client}/{version} (...` 中的 version。
// 与 ParseCodexEngineVersion 的区别:后者只取三段数字用于引擎版本比较(会丢掉 -alpha.4
// 之类的预发布后缀),本函数保留原样,因为出站 version 头必须与 UA 版本段逐字一致。
@@ -646,7 +646,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
req.Host = "chatgpt.com"
req.Header.Set("accept", "text/event-stream")
req.Header.Set("OpenAI-Beta", "responses=experimental")
req.Header.Set("Originator", "codex_cli_rs")
req.Header.Set("Originator", openai.CodexDefaultOriginator)
if customUA := strings.TrimSpace(credentialAccount.GetOpenAIUserAgent()); customUA != "" {
req.Header.Set("User-Agent", customUA)
} else {
@@ -1860,7 +1860,7 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("OpenAI-Beta", "responses=experimental")
req.Header.Set("originator", "codex_cli_rs")
req.Header.Set("originator", openai.CodexDefaultOriginator)
if customUA := strings.TrimSpace(credentialAccount.GetOpenAIUserAgent()); customUA != "" {
req.Header.Set("User-Agent", customUA)
} else {
@@ -759,7 +759,7 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco
}
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("OpenAI-Beta", "responses=experimental")
req.Header.Set("Originator", "codex_cli_rs")
req.Header.Set("Originator", openaipkg.CodexDefaultOriginator)
req.Header.Set("Version", openAICodexProbeVersion)
req.Header.Set("User-Agent", codexCLIUserAgent)
if s.identityCache != nil {
@@ -11,6 +11,7 @@ import (
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
)
@@ -246,7 +247,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(c
if originator := openAIAlphaSearchInboundHeader(c, "Originator"); originator != "" {
req.Header.Set("Originator", originator)
} else {
req.Header.Set("Originator", "codex_cli_rs")
req.Header.Set("Originator", openai.CodexDefaultOriginator)
}
if customUA := account.GetOpenAIUserAgent(); customUA != "" {
req.Header.Set("User-Agent", customUA)
@@ -389,7 +390,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context
if originator := openAIAlphaSearchInboundHeader(c, "Originator"); originator != "" {
req.Header.Set("Originator", originator)
} else {
req.Header.Set("Originator", "codex_cli_rs")
req.Header.Set("Originator", openai.CodexDefaultOriginator)
}
if customUA := account.GetOpenAIUserAgent(); customUA != "" {
req.Header.Set("User-Agent", customUA)
@@ -12,6 +12,7 @@ import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
@@ -155,7 +156,7 @@ func TestForwardAlphaSearchPATUsesResponsesWebSearchFallback(t *testing.T) {
require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta"))
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.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("Originator"))
require.Empty(t, upstream.lastReq.Header.Get("X-Codex-Beta-Features"))
require.Empty(t, upstream.lastReq.Header.Get("X-Codex-Turn-State"))
require.Empty(t, upstream.lastReq.Header.Get(responsesLiteHeaderKey))
@@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/stretchr/testify/require"
)
@@ -75,7 +76,7 @@ func TestStreamFailedEventCapacityShedRetriesOnSameAccount(t *testing.T) {
// 各自硬编码会漂移成互相矛盾的身份,而自相矛盾或陈旧的身份会被上游优先降载。
func TestCodexOutboundVersionHasSingleSource(t *testing.T) {
require.True(t,
strings.HasPrefix(codexCLIUserAgent, "codex_cli_rs/"+codexCLIVersion+" "),
strings.HasPrefix(codexCLIUserAgent, openai.CodexDefaultOriginator+"/"+codexCLIVersion+" "),
"codexCLIUserAgent=%q 必须以 codexCLIVersion=%q 作为版本段", codexCLIUserAgent, codexCLIVersion,
)
require.Equal(t, codexCLIVersion, openAICodexProbeVersion)
@@ -32,13 +32,13 @@ func NormalizeCodexClientVersion(version string) string {
return version
}
// buildCodexCLIUserAgent 按版本号拼出规范 Codex CLI User-Agent。
// buildCodexCLIUserAgent 按版本号拼出规范 Codex TUI User-Agent。
// UA 形态只在 codexCLIUserAgentSuffix 一处定义,避免多处拼装漂移。
func buildCodexCLIUserAgent(version string) string {
if version = NormalizeCodexClientVersion(version); version == "" {
return codexCLIUserAgent
}
return openai.CodexCLIOriginator + "/" + version + codexCLIUserAgentSuffix
return openai.CodexDefaultOriginator + "/" + version + codexCLIUserAgentSuffix
}
// codexIdentityEnforcement 控制 enforceCodexIdentityHeaders 是否强制统一出站身份,
@@ -99,7 +99,7 @@ type codexOutboundIdentity struct {
}
// resolveCodexOutboundIdentity 由候选 User-Agent 推导自洽的出站身份。
// candidateUA 为空时使用规范 User-Agent;推导不出官方身份时整体回退为规范 CLI 身份。
// candidateUA 为空时使用规范 User-Agent;推导不出官方身份时整体回退为规范 TUI 身份。
//
// 候选 UA(面板 / 账号级的管理员显式配置)只贡献客户端名与 OS / 架构 / 终端指纹,
// 其自带的版本段一律用当前生效版本重建:一条填写于某个历史版本的 UA 否则会把出站身份
@@ -114,7 +114,7 @@ func resolveCodexOutboundIdentity(candidateUA string) codexOutboundIdentity {
originator, pairedUA, ok := openai.PairCodexClientIdentity(ua)
if !ok {
if originator, pairedUA, ok = openai.PairCodexClientIdentity(canonical); !ok {
originator, pairedUA = openai.CodexCLIOriginator, codexCLIUserAgent
originator, pairedUA = openai.CodexDefaultOriginator, codexCLIUserAgent
}
}
// 生效版本只有一个来源:规范身份(面板版本号 → 自动同步值 → 内置常量,见
@@ -5,13 +5,14 @@ import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/stretchr/testify/require"
)
func requireOpenAICodexProbeHeaders(t *testing.T, h http.Header) {
t.Helper()
require.Equal(t, codexCLIUserAgent, h.Get("User-Agent"))
require.Equal(t, "codex_cli_rs", h.Get("Originator"))
require.Equal(t, openai.CodexDefaultOriginator, h.Get("Originator"))
require.Equal(t, codexCLIVersion, h.Get("Version"))
require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta"))
require.NotEmpty(t, h.Get("X-Codex-Window-ID"))
@@ -27,7 +28,7 @@ func TestEnsureCodexIdentityHeaders(t *testing.T) {
ensureCodexIdentityHeaders(h)
enforceCodexIdentityHeaders(h)
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
require.Equal(t, codexCLIVersion, h.Get("version"))
require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta"))
@@ -42,7 +43,7 @@ func TestEnsureCodexIdentityHeaders(t *testing.T) {
ensureCodexIdentityHeaders(h)
enforceCodexIdentityHeaders(h)
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
require.Equal(t, codexCLIVersion, h.Get("version"))
require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta"))
@@ -112,7 +113,7 @@ func TestEnforceCodexIdentityHeaders(t *testing.T) {
enforceCodexIdentityHeaders(h)
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
require.Equal(t, codexCLIVersion, h.Get("version"))
})
@@ -141,7 +142,7 @@ func TestEnforceCodexIdentityHeadersWithAccountOverrideUA(t *testing.T) {
enforceCodexIdentityHeadersWithUA(h, "luna/1.0.0")
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
require.Equal(t, codexCLIVersion, h.Get("version"))
})
@@ -212,7 +213,7 @@ func TestEnforceCodexIdentityHeadersRejectsInvalidCanonicalUA(t *testing.T) {
enforceCodexIdentityHeaders(h)
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
require.Equal(t, codexCLIVersion, h.Get("version"))
}
@@ -235,7 +236,7 @@ func TestCodexIdentityEnforcementZeroValueConfigKeepsItEnabled(t *testing.T) {
enforceCodexIdentityHeaders(h)
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
}
@@ -271,7 +272,7 @@ func TestEnforceCodexIdentityHeaders_EnforcementDisabledThirdPartyFallback(t *te
enforceCodexIdentityHeaders(h)
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
require.Equal(t, codexCLIVersion, h.Get("version"))
}
@@ -288,7 +289,7 @@ func TestEnforceCodexIdentityHeadersIsIdempotent(t *testing.T) {
require.Equal(t, firstUA, h.Get("user-agent"))
require.Equal(t, firstVersion, h.Get("version"))
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
}
// 缺少 originator 时必须保持 no-op:compat 桥接等非 ChatGPT 内部接口路径会显式删除
@@ -316,7 +317,7 @@ func TestNormalizeCodexClientVersion(t *testing.T) {
}
func TestBuildCodexCLIUserAgent(t *testing.T) {
require.Equal(t, "codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix, buildCodexCLIUserAgent("0.200.1"))
require.Equal(t, openai.CodexDefaultOriginator+"/0.200.1"+codexCLIUserAgentSuffix, buildCodexCLIUserAgent("0.200.1"))
// 非法版本号必须回退到内置 UA,不能拼出畸形身份。
require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent("bogus version"))
require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent(""))
@@ -18,6 +18,7 @@ import (
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"golang.org/x/net/http2"
"golang.org/x/sync/singleflight"
)
@@ -304,7 +305,7 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc
setOpenAIChatGPTAccountHeaders(headers, credAccount)
}
headers.Set("Accept", "application/json")
headers.Set("Originator", "codex_cli_rs")
headers.Set("Originator", openai.CodexDefaultOriginator)
headers.Set("Version", clientVersion)
headers.Set("User-Agent", codexCLIUserAgent)
@@ -17,6 +17,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/stretchr/testify/require"
"golang.org/x/net/http2"
@@ -193,7 +194,7 @@ func TestFetchCodexModelsManifestPassthrough(t *testing.T) {
if gotAccountID != "acc-123" {
t.Errorf("chatgpt-account-id header: got %q", gotAccountID)
}
if gotOriginator != "codex_cli_rs" {
if gotOriginator != openai.CodexDefaultOriginator {
t.Errorf("originator header: got %q", gotOriginator)
}
if gotClientVersion != "0.137.0" {
@@ -447,7 +448,7 @@ func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) {
if gotRequest.Header.Get("Authorization") != "Bearer sk-upstream" {
t.Errorf("authorization header: got %q", gotRequest.Header.Get("Authorization"))
}
if gotRequest.Header.Get("Originator") != "codex_cli_rs" {
if gotRequest.Header.Get("Originator") != openai.CodexDefaultOriginator {
t.Errorf("originator header: got %q", gotRequest.Header.Get("Originator"))
}
if gotRequest.Header.Get("Version") != "0.144.0" {
@@ -10,6 +10,7 @@ import (
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
)
const openAICodexPATWhoamiURLDefault = "https://auth.openai.com/api/accounts/v1/user-auth-credential/whoami"
@@ -58,7 +59,7 @@ func (s *OpenAIOAuthService) ValidateCodexPersonalAccessToken(ctx context.Contex
}
req.Header.Set("authorization", "Bearer "+accessToken)
req.Header.Set("accept", "application/json")
req.Header.Set("originator", "codex_cli_rs")
req.Header.Set("originator", openai.CodexDefaultOriginator)
req.Header.Set("user-agent", codexCLIUserAgent)
resp, err := client.Do(req)
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/stretchr/testify/require"
)
@@ -38,7 +39,7 @@ func TestOpenAIOAuthService_ValidateCodexPersonalAccessToken(t *testing.T) {
info, err := svc.ValidateCodexPersonalAccessToken(context.Background(), " at-test-token ", "")
require.NoError(t, err)
require.Equal(t, "Bearer at-test-token", gotAuthorization)
require.Equal(t, "codex_cli_rs", gotOriginator)
require.Equal(t, openai.CodexDefaultOriginator, gotOriginator)
require.Equal(t, codexCLIUserAgent, gotUserAgent)
require.Equal(t, OpenAIAuthModePersonalAccessToken, info.AuthMode)
require.Equal(t, "user@example.com", info.Email)
@@ -6,6 +6,7 @@ import (
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/stretchr/testify/require"
)
@@ -13,7 +14,7 @@ func TestCodexVersionConstants_Consistency(t *testing.T) {
require.Equal(t, codexCLIVersion, openAICodexProbeVersion,
"codexCLIVersion and openAICodexProbeVersion must stay in sync")
require.True(t, strings.Contains(codexCLIUserAgent, "codex_cli_rs/"+codexCLIVersion),
require.True(t, strings.Contains(codexCLIUserAgent, openai.CodexDefaultOriginator+"/"+codexCLIVersion),
"codexCLIUserAgent must embed codexCLIVersion")
require.True(t, strings.Contains(DefaultOpenAICodexUserAgent, codexCLIVersion),
@@ -327,14 +327,14 @@ func TestGetOpenAICodexClientVersionFallsBackOnError(t *testing.T) {
require.Equal(t, codexCLIVersion, svc.GetOpenAICodexClientVersion(context.Background()))
}
// 规范 UA:面板未填完整 UA 时按当前生效版本号拼出标准 CLI 形态。
// 规范 UA:面板未填完整 UA 时按当前生效版本号拼出标准 TUI 形态。
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,
"codex-tui/0.200.1"+codexCLIUserAgentSuffix,
svc.GetOpenAICodexCanonicalUserAgent(context.Background()),
)
}
@@ -368,6 +368,18 @@ func TestGetOpenAICodexCanonicalUserAgentRebuildsPanelUAVersion(t *testing.T) {
)
})
t.Run("TUI UA 的首尾两个版本号同时更新", func(t *testing.T) {
svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{
SettingKeyOpenAICodexUserAgent: "codex-tui/0.146.1 (Ubuntu 22.4.0; x86_64) WindowsTerminal (codex-tui; 0.146.1)",
SettingKeyOpenAICodexClientVersionSynced: "0.200.1",
}}, nil)
require.Equal(t,
"codex-tui/0.200.1 (Ubuntu 22.4.0; x86_64) WindowsTerminal (codex-tui; 0.200.1)",
svc.GetOpenAICodexCanonicalUserAgent(context.Background()),
)
})
// 面板版本号覆写优先级仍然高于同步值:管理员固定版本的诉求不被重建绕开。
t.Run("面板版本号覆写优先", func(t *testing.T) {
svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{
@@ -18,6 +18,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
@@ -997,7 +998,7 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, firstResult)
require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state"))
requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, "codex_cli_rs")
requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, openai.CodexDefaultOriginator)
secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
secondRec := httptest.NewRecorder()
@@ -1011,7 +1012,7 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) {
require.Equal(t, "turn_state_first", upstream.requests[1].Header.Get("x-codex-turn-state"))
require.Equal(t, generateSessionUUID(isolateOpenAISessionID(0, "stable-cache-key")), upstream.requests[1].Header.Get("session_id"))
require.Empty(t, upstream.requests[1].Header.Get("conversation_id"))
requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, "codex_cli_rs")
requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, openai.CodexDefaultOriginator)
require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists())
require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
}
@@ -1062,7 +1063,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, codexCLIUserAgent, "codex_cli_rs")
requireOpenAIMessagesCodexIdentity(t, upstream.lastReq, codexCLIUserAgent, openai.CodexDefaultOriginator)
})
}
}
@@ -1105,7 +1106,7 @@ func TestForwardAsAnthropic_OAuthDigestFallbackReusesTurnStateWithoutExplicitKey
firstSessionID := upstream.requests[0].Header.Get("session_id")
require.NotEmpty(t, firstSessionID)
require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state"))
requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, "codex_cli_rs")
requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, openai.CodexDefaultOriginator)
require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists())
secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
@@ -1120,7 +1121,7 @@ func TestForwardAsAnthropic_OAuthDigestFallbackReusesTurnStateWithoutExplicitKey
require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session_id"))
require.Equal(t, "turn_state_digest_first", upstream.requests[1].Header.Get("x-codex-turn-state"))
require.Empty(t, upstream.requests[1].Header.Get("conversation_id"))
requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, "codex_cli_rs")
requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, openai.CodexDefaultOriginator)
require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists())
require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
}
@@ -1275,7 +1276,7 @@ func TestForwardAsAnthropic_OAuthKeepsSystemAsDeveloperInput(t *testing.T) {
instructions := gjson.GetBytes(upstream.lastBody, "instructions")
require.True(t, instructions.Exists())
require.Empty(t, instructions.String())
requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, "codex_cli_rs")
requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, openai.CodexDefaultOriginator)
}
func TestForwardAsAnthropic_OAuthAddsClaudeCodeTodoGuardForCompatModel(t *testing.T) {
@@ -104,7 +104,7 @@ func resolveGrokCacheIdentity(c *gin.Context, body []byte, explicitKey, upstream
func explicitGrokCacheSeed(c *gin.Context, body []byte, explicitKey string) string {
// Claude Code session is the most stable multi-turn identity for
// /v1/messages → Grok bridges. Prefer it over generic session headers so
// prompt cache routing matches CPA behavior.
// prompt cache routing follows the gateway's existing cache affinity rules.
seed := extractClaudeCodeSessionID(c, body)
if seed == "" {
seed = explicitOpenAIHeaderSessionID(c)
@@ -17,6 +17,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
@@ -413,7 +414,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
req.Header.Set("OpenAI-Beta", "responses=experimental")
}
if req.Header.Get("originator") == "" {
req.Header.Set("originator", "codex_cli_rs")
req.Header.Set("originator", openai.CodexDefaultOriginator)
}
// 用隔离后的 session 标识符覆盖客户端透传值,防止跨用户会话碰撞。
if clientSessionID == "" {
@@ -14,6 +14,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
@@ -157,7 +158,7 @@ func resolveOpenAIUpstreamOriginator(c *gin.Context, isOfficialClient bool) stri
}
}
if isOfficialClient {
return "codex_cli_rs"
return openai.CodexDefaultOriginator
}
return "opencode"
}
@@ -33,7 +33,7 @@ const (
openaiPlatformAPIURL = "https://api.openai.com/v1/responses"
openaiPlatformAPIInputTokensURL = "https://api.openai.com/v1/responses/input_tokens"
openaiStickySessionTTL = time.Hour // 粘性会话TTL
// 与真实 Codex CLI 的 User-Agent 结构对齐:
// 与真实 Codex TUI 的 User-Agent 结构对齐:
// {originator}/{version} ({OS} {OS_version}; {arch}) {terminal}
// 缺少 OS/架构/终端后缀的形态易被上游指纹识别为非官方客户端。
// 该后缀是 UA 形态的唯一定义处,buildCodexCLIUserAgent 按运行时版本号复用它。
@@ -41,7 +41,7 @@ const (
// codexCLIUserAgent 是编译期兜底 UA;运行时优先使用由后台版本号拼出的规范 UA。
// 版本段必须来自 codexCLIVersion:UA 与 version 头是同一个版本声明的两个出口,
// 各自硬编码会漂移成互相矛盾的身份。
codexCLIUserAgent = "codex_cli_rs/" + codexCLIVersion + codexCLIUserAgentSuffix
codexCLIUserAgent = openai.CodexDefaultOriginator + "/" + codexCLIVersion + codexCLIUserAgentSuffix
// codex_cli_only 拒绝时单个请求头日志长度上限(字符)
codexCLIOnlyHeaderValueMaxBytes = 256
@@ -3030,7 +3030,7 @@ 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, "codex_cli_rs", req.Header.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, req.Header.Get("originator"))
require.Equal(t, codexCLIUserAgent, req.Header.Get("User-Agent"))
require.Equal(t, codexCLIVersion, req.Header.Get("version"))
})
@@ -16,6 +16,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/gin-gonic/gin"
@@ -989,10 +990,10 @@ func TestOpenAIGatewayService_OAuthLegacy_CompositeCodexUAUsesCodexOriginator(t
_, err := svc.Forward(context.Background(), c, account, inputBody)
require.NoError(t, err)
require.NotNil(t, upstream.lastReq)
// 浏览器型复合 UA 被替换为默认 Codex UA(CLI 形态,避开上游降载桶),
// 浏览器型复合 UA 被替换为默认 Codex TUI UA,
// originator 随最终 UA 配套(issue #3901)。
require.Equal(t, DefaultOpenAICodexUserAgent, upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, "codex_cli_rs", upstream.lastReq.Header.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("originator"))
require.NotEqual(t, "opencode", upstream.lastReq.Header.Get("originator"))
}
@@ -1831,7 +1832,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_OfficialIdentityUnified(t *testin
require.NoError(t, err)
require.NotNil(t, upstream.lastReq)
require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, "codex_cli_rs", upstream.lastReq.Header.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("originator"))
require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("version"))
}
@@ -1875,7 +1876,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_CodexTuiIdentityUnified(t *testin
require.NoError(t, err)
require.NotNil(t, upstream.lastReq)
require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, "codex_cli_rs", upstream.lastReq.Header.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("originator"))
require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("version"))
}
@@ -15,6 +15,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
coderws "github.com/coder/websocket"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
@@ -839,7 +840,7 @@ 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, "codex_cli_rs", captureDialer.lastHeaders.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, captureDialer.lastHeaders.Get("originator"))
require.Equal(t, codexCLIUserAgent, captureDialer.lastHeaders.Get("user-agent"))
require.Equal(t, codexCLIVersion, captureDialer.lastHeaders.Get("version"))
})
@@ -82,10 +82,8 @@ const antigravityUserAgentVersionCacheTTL = 60 * time.Second
const antigravityUserAgentVersionErrorTTL = 5 * time.Second
const antigravityUserAgentVersionDBTimeout = 5 * time.Second
// DefaultOpenAICodexUserAgent OpenAI Codex 默认 User-Agent(用于规避 Cloudflare 对浏览器 UA 的质询)。
// 取官方 CLI 身份而非 TUI 身份:上游在容量紧张时按客户端身份分优先级降载,TUI 身份被观测到
// 优先命中降载,而该默认值是浏览器 UA 兜底路径上最主要的身份来源。版本段随 codexCLIVersion
// 一起更新——陈旧版本同样会被优先降载。
// DefaultOpenAICodexUserAgent 是 OpenAI Codex 默认 User-Agent,用于规避浏览器 UA 的质询。
// 默认采用 codex-tui 身份,版本段随 codexCLIVersion 一起更新。
const DefaultOpenAICodexUserAgent = codexCLIUserAgent
// cachedOpenAICodexUserAgent 缓存 OpenAI Codex UA(进程内缓存,60s TTL)
@@ -366,7 +364,7 @@ func (s *SettingService) InvalidateOpenAICodexClientVersionCache() {
}
// GetOpenAICodexCanonicalUserAgent 返回出站规范 Codex User-Agent。
// 未填面板 UA 时按当前生效的客户端版本号拼出标准 CLI UA。
// 未填面板 UA 时按当前生效的客户端版本号拼出标准 Codex TUI UA。
//
// 面板 UA 只贡献客户端名与 OS / 架构 / 终端指纹,版本段一律用生效版本重建:该输入框是
// 唯一能改 UA 后缀的地方,但它填写于某个历史版本,逐字沿用会把出站身份永久钉死在陈旧
+1 -1
View File
@@ -228,7 +228,7 @@ 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;空值由 Codex 客户端版本号拼出标准 CLI UA
OpenAICodexUserAgent string // OpenAI Codex 上游完整 User-Agent;空值由 Codex 客户端版本号拼出标准 TUI UA
OpenAICodexClientVersion string // 出站声明的 Codex 客户端版本号(管理员覆写);空值跟随自动同步值
OpenAICodexClientVersionSynced string // 自动同步到的官方最新稳定版版本号(只读展示)
OpenAICodexVersionAutoSyncEnabled bool // 是否启用 Codex 客户端版本号自动同步(默认 true)
@@ -475,8 +475,8 @@ 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.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.',
openaiCodexUserAgentPlaceholder: 'codex-tui/0.146.1 (Ubuntu 22.4.0; x86_64) WindowsTerminal (codex-tui; 0.146.1)',
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-tui identity from the version below (recommended). If set, both the leading and trailing version declarations are synchronized to the version below, so the UA never stays pinned to the release entered here — 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.',
@@ -468,8 +468,8 @@ export default {
antigravityUserAgentVersionPlaceholder: '1.23.2',
antigravityUserAgentVersionHint: '留空时使用 ANTIGRAVITY_USER_AGENT_VERSION 或内置默认值 1.23.2;填写后后台设置优先。',
openaiCodexUserAgent: 'OpenAI Codex UA',
openaiCodexUserAgentPlaceholder: 'codex_cli_rs/0.146.0 (Ubuntu 22.4.0; x86_64) xterm-256color',
openaiCodexUserAgentHint: '出站统一使用的完整 Codex User-Agent,用于自定义 OS / 架构 / 终端指纹。留空则按下方版本号拼出标准 codex_cli_rs 形态(推荐)。填写后其版本段仍会被下方版本号覆盖,避免这条 UA 停在填写时的旧版本——上游在容量紧张时按客户端身份分优先级降载,陈旧或非官方形态的身份会被优先丢弃并回 server_is_overloaded。',
openaiCodexUserAgentPlaceholder: 'codex-tui/0.146.1 (Ubuntu 22.4.0; x86_64) WindowsTerminal (codex-tui; 0.146.1)',
openaiCodexUserAgentHint: '出站统一使用的完整 Codex User-Agent,用于自定义 OS / 架构 / 终端指纹。留空则按下方版本号拼出标准 codex-tui 形态(推荐)。填写后首段和尾部的版本号仍会被下方版本号同步覆盖,避免这条 UA 停在填写时的旧版本——上游在容量紧张时按客户端身份分优先级降载,陈旧或非官方形态的身份会被优先丢弃并回 server_is_overloaded。',
openaiCodexClientVersion: 'Codex 客户端版本号',
openaiCodexClientVersionPlaceholder: '留空则跟随自动同步',
openaiCodexClientVersionHint: '网关对上游声明的 Codex 客户端版本号,User-Agent 与 version 头同源使用。留空表示使用自动同步到的官方最新稳定版;填写后固定为该版本,不再跟随同步。',