fix(fingerprint): align credential-face identity with the real client and de-drift models version

- Replace ApplyCodexCanonicalIdentity with CodexCanonicalAuthIdentity /
  ApplyCodexCanonicalAuthIdentity: the credential face (auth.openai.com
  token exchange / refresh / PAT whoami) now sends the originator +
  canonical User-Agent pair and no version header, matching codex-rs
  default_headers(); the version gate (#3901) only exists on the
  /backend-api/codex inference face. whoami keeps its original header
  shape (originator + UA) with the canonical UA source.
- Token exchange and refresh send the full pair instead of a bare UA,
  eliminating the half-identity (UA without originator) combination no
  real client ever emits.
- Codex models manifest: the Version header now follows the client's
  own client_version when it is valid and >= the upstream floor (same
  source as the query param, restoring the pre-refactor consistency),
  falling back to the canonical version otherwise; the query param
  keeps its verbatim passthrough contract.
- Drop the now-unreferenced openAICodexProbeVersion constant and its
  vacuous consistency assertions; probes resolve their version through
  resolveCodexOutboundIdentity at runtime.
This commit is contained in:
yaxin
2026-08-18 15:20:08 +08:00
parent bb6c3b4f6a
commit a341239596
10 changed files with 58 additions and 29 deletions
@@ -46,9 +46,11 @@ func (s *openaiOAuthService) ExchangeCode(ctx context.Context, code, codeVerifie
var tokenResp openai.TokenResponse
authUA, authOriginator := service.CodexCanonicalAuthIdentity()
resp, err := client.R().
SetContext(ctx).
SetHeader("User-Agent", service.CodexCanonicalUserAgent()).
SetHeader("User-Agent", authUA).
SetHeader("originator", authOriginator).
SetFormDataFromValues(formData).
SetSuccessResult(&tokenResp).
Post(s.tokenURL)
@@ -94,9 +96,11 @@ func (s *openaiOAuthService) refreshTokenWithClientID(ctx context.Context, refre
var tokenResp openai.TokenResponse
authUA, authOriginator := service.CodexCanonicalAuthIdentity()
resp, err := client.R().
SetContext(ctx).
SetHeader("User-Agent", service.CodexCanonicalUserAgent()).
SetHeader("User-Agent", authUA).
SetHeader("originator", authOriginator).
SetFormDataFromValues(formData).
SetSuccessResult(&tokenResp).
Post(s.tokenURL)
@@ -78,11 +78,17 @@ func (s *OpenAIOAuthServiceSuite) TestExchangeCode_DefaultRedirectURI() {
w.WriteHeader(http.StatusBadRequest)
return
}
if got := r.Header.Get("User-Agent"); got != service.CodexCanonicalUserAgent() {
wantUA, wantOriginator := service.CodexCanonicalAuthIdentity()
if got := r.Header.Get("User-Agent"); got != wantUA {
errCh <- "user-agent mismatch"
w.WriteHeader(http.StatusBadRequest)
return
}
if got := r.Header.Get("originator"); got != wantOriginator {
errCh <- "originator mismatch"
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"access_token":"at","refresh_token":"rt","token_type":"bearer","expires_in":3600}`)
@@ -127,11 +133,17 @@ func (s *OpenAIOAuthServiceSuite) TestRefreshToken_FormFields() {
w.WriteHeader(http.StatusBadRequest)
return
}
if got := r.Header.Get("User-Agent"); got != service.CodexCanonicalUserAgent() {
wantUA, wantOriginator := service.CodexCanonicalAuthIdentity()
if got := r.Header.Get("User-Agent"); got != wantUA {
errCh <- "user-agent mismatch"
w.WriteHeader(http.StatusBadRequest)
return
}
if got := r.Header.Get("originator"); got != wantOriginator {
errCh <- "originator mismatch"
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"access_token":"at2","refresh_token":"rt2","token_type":"bearer","expires_in":3600}`)
@@ -105,15 +105,14 @@ type antigravityUsageCache struct {
}
const (
apiCacheTTL = 3 * time.Minute
apiErrorCacheTTL = 1 * time.Minute // 负缓存 TTL:429 等错误缓存 1 分钟
antigravityErrorTTL = 1 * time.Minute // Antigravity 错误缓存 TTL(可恢复错误)
apiQueryMaxJitter = 800 * time.Millisecond // 用量查询最大随机延迟
windowStatsCacheTTL = 1 * time.Minute
openAIProbeCacheTTL = 10 * time.Minute
grokProbeRetryTTL = 1 * time.Minute
grokFreeQuotaWindow = 24 * time.Hour
openAICodexProbeVersion = codexCLIVersion // 编译期兜底;运行时探针 version 走 CodexCanonicalClientVersion
apiCacheTTL = 3 * time.Minute
apiErrorCacheTTL = 1 * time.Minute // 负缓存 TTL:429 等错误缓存 1 分钟
antigravityErrorTTL = 1 * time.Minute // Antigravity 错误缓存 TTL(可恢复错误)
apiQueryMaxJitter = 800 * time.Millisecond // 用量查询最大随机延迟
windowStatsCacheTTL = 1 * time.Minute
openAIProbeCacheTTL = 10 * time.Minute
grokProbeRetryTTL = 1 * time.Minute
grokFreeQuotaWindow = 24 * time.Hour
)
// UsageCache 封装账户使用量相关的缓存
@@ -250,7 +250,6 @@ func TestCodexOutboundVersionHasSingleSource(t *testing.T) {
strings.HasPrefix(codexCLIUserAgent, openai.CodexDefaultOriginator+"/"+codexCLIVersion+" "),
"codexCLIUserAgent=%q 必须以 codexCLIVersion=%q 作为版本段", codexCLIUserAgent, codexCLIVersion,
)
require.Equal(t, codexCLIVersion, openAICodexProbeVersion)
require.GreaterOrEqual(t, CompareVersions(codexCLIVersion, codexUpstreamMinVersion), 0,
"codexCLIVersion=%q 不得低于上游最低门槛 %q", codexCLIVersion, codexUpstreamMinVersion,
)
@@ -83,15 +83,24 @@ func CodexCanonicalUserAgent() string {
return resolveCodexOutboundIdentity("").userAgent
}
// ApplyCodexCanonicalIdentity 为无账号句柄的出站请求写入与推理同源的身份三元组。
func ApplyCodexCanonicalIdentity(h http.Header) {
// CodexCanonicalAuthIdentity 返回凭据面(auth.openai.com:换 Token / 刷新 / whoami)
// 出站请求的身份对:规范 User-Agent 与配套 originator,与推理解析链同源。
// 凭据面不发 version 头——真实 Codex 客户端在该面只携带 originator 与 User-Agent
// (codex-rs login/default_client.rs 的 default_headers()),version 门槛
// (issue #3901)只存在于 /backend-api/codex 推理面。
func CodexCanonicalAuthIdentity() (userAgent, originator string) {
identity := resolveCodexOutboundIdentity("")
return identity.userAgent, identity.originator
}
// ApplyCodexCanonicalAuthIdentity 为凭据面出站请求写入身份对(不含 version)。
func ApplyCodexCanonicalAuthIdentity(h http.Header) {
if h == nil {
return
}
identity := resolveCodexOutboundIdentity("")
h.Set("user-agent", identity.userAgent)
h.Set("originator", identity.originator)
h.Set("version", identity.version)
userAgent, originator := CodexCanonicalAuthIdentity()
h.Set("user-agent", userAgent)
h.Set("originator", originator)
}
// CodexCanonicalClientVersion 返回当前生效的 Codex 客户端版本号。
@@ -333,10 +333,11 @@ func TestCodexCanonicalUserAgentFollowsResolver(t *testing.T) {
require.Equal(t, "0.200.1", CodexCanonicalClientVersion())
h := make(http.Header)
ApplyCodexCanonicalIdentity(h)
ApplyCodexCanonicalAuthIdentity(h)
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, "codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix, h.Get("user-agent"))
require.Equal(t, "0.200.1", h.Get("version"))
// 凭据面不发 version 头(真实客户端在 auth.openai.com 只带 originator + UA)。
require.Empty(t, h.Get("version"))
}
func TestCodexCanonicalUserAgentFallsBackWithoutResolver(t *testing.T) {
@@ -311,7 +311,15 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc
identity := resolveCodexOutboundIdentity(overrideUA)
headers.Set("Originator", identity.originator)
headers.Set("User-Agent", identity.userAgent)
headers.Set("Version", identity.version)
// Version 头优先与 client_version 查询参数同源:客户端自报版本合法且不低于上游
// 门槛时原样使用;否则回退规范版本,避免陈旧 version 触发上游 404(issue #3901)。
// client_version 查询参数本身始终按客户端原值透传(内容协商语义,契约见
// TestFetchCodexModelsManifestPassthrough)。
headerVersion := NormalizeCodexClientVersion(clientVersion)
if headerVersion == "" || CompareVersions(headerVersion, codexUpstreamMinVersion) < 0 {
headerVersion = identity.version
}
headers.Set("Version", headerVersion)
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
@@ -451,8 +451,8 @@ func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) {
if gotRequest.Header.Get("Originator") != openai.CodexDefaultOriginator {
t.Errorf("originator header: got %q", gotRequest.Header.Get("Originator"))
}
if gotRequest.Header.Get("Version") != CodexCanonicalClientVersion() {
t.Errorf("version header: got %q", gotRequest.Header.Get("Version"))
if gotRequest.Header.Get("Version") != "0.144.0" {
t.Errorf("version header must match the client_version query param: got %q", gotRequest.Header.Get("Version"))
}
if gotRequest.Header.Get("User-Agent") != CodexCanonicalUserAgent() {
t.Errorf("user-agent header: got %q", gotRequest.Header.Get("User-Agent"))
@@ -58,7 +58,7 @@ func (s *OpenAIOAuthService) ValidateCodexPersonalAccessToken(ctx context.Contex
}
req.Header.Set("authorization", "Bearer "+accessToken)
req.Header.Set("accept", "application/json")
ApplyCodexCanonicalIdentity(req.Header)
ApplyCodexCanonicalAuthIdentity(req.Header)
resp, err := client.Do(req)
if err != nil {
@@ -11,9 +11,6 @@ import (
)
func TestCodexVersionConstants_Consistency(t *testing.T) {
require.Equal(t, codexCLIVersion, openAICodexProbeVersion,
"codexCLIVersion and openAICodexProbeVersion must stay in sync")
require.True(t, strings.Contains(codexCLIUserAgent, openai.CodexDefaultOriginator+"/"+codexCLIVersion),
"codexCLIUserAgent must embed codexCLIVersion")