Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ jobs:

- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: '1.26.5'
go-version: '1.26.6'
cache: true
cache-dependency-path: go.sum

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ concurrency:
cancel-in-progress: true

env:
GO_VERSION: '1.26.5'
GO_VERSION: '1.26.6'
GOLANGCI_LINT_VERSION: v1.64.5

jobs:
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module github.com/router-for-me/CLIProxyAPI/v6

go 1.26.0

toolchain go1.26.5
toolchain go1.26.6

require (
clirelay.local/updater v0.0.0-00010101000000-000000000000
Expand Down
103 changes: 103 additions & 0 deletions internal/config/codex_convergence_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package config

import (
"encoding/json"
"testing"

"gopkg.in/yaml.v3"
)

func TestNormalizeCodexIdentityFingerprintConvergenceDefaults(t *testing.T) {
got := NormalizeCodexIdentityFingerprint(CodexIdentityFingerprintConfig{})
if got.ConvergenceMode != CodexFingerprintConvergenceSession {
t.Fatalf("convergence mode = %q, want the session default", got.ConvergenceMode)
}

got = NormalizeCodexIdentityFingerprint(CodexIdentityFingerprintConfig{ConvergenceMode: "nonsense"})
if got.ConvergenceMode != CodexFingerprintConvergenceSession {
t.Fatalf("convergence mode = %q, want an invalid value to fall back", got.ConvergenceMode)
}

got = NormalizeCodexIdentityFingerprint(CodexIdentityFingerprintConfig{ConvergenceMode: " OFF "})
if got.ConvergenceMode != CodexFingerprintConvergenceOff {
t.Fatalf("convergence mode = %q, want off to survive trimming and case", got.ConvergenceMode)
}
}

func TestCodexIdentityFingerprintConvergenceRoundTrip(t *testing.T) {
raw := []byte(`
enabled: true
convergence-mode: full
installation-id: 11111111-2222-3333-4444-555555555555
tls-fingerprint:
enabled: true
profile: firefox
`)
var fromYAML CodexIdentityFingerprintConfig
if err := yaml.Unmarshal(raw, &fromYAML); err != nil {
t.Fatalf("yaml unmarshal: %v", err)
}
if fromYAML.ConvergenceMode != CodexFingerprintConvergenceFull {
t.Fatalf("convergence mode = %q, want full", fromYAML.ConvergenceMode)
}
if fromYAML.InstallationID != "11111111-2222-3333-4444-555555555555" {
t.Fatalf("installation id = %q, want the configured value", fromYAML.InstallationID)
}
if !fromYAML.TLSFingerprint.Enabled || fromYAML.TLSFingerprint.Profile != "firefox" {
t.Fatalf("tls fingerprint = %+v, want enabled firefox", fromYAML.TLSFingerprint)
}

encoded, err := json.Marshal(NormalizeCodexIdentityFingerprint(fromYAML))
if err != nil {
t.Fatalf("json marshal: %v", err)
}
var fromJSON CodexIdentityFingerprintConfig
if err = json.Unmarshal(encoded, &fromJSON); err != nil {
t.Fatalf("json unmarshal: %v", err)
}
if fromJSON.ConvergenceMode != CodexFingerprintConvergenceFull {
t.Fatalf("convergence mode did not survive the JSON round trip: %q", fromJSON.ConvergenceMode)
}
if !fromJSON.TLSFingerprint.Enabled || fromJSON.TLSFingerprint.Profile != "firefox" {
t.Fatalf("tls fingerprint did not survive the JSON round trip: %+v", fromJSON.TLSFingerprint)
}
}

func TestCleanTLSFingerprintDropsUnknownProfile(t *testing.T) {
got := CleanTLSFingerprint(TLSFingerprintConfig{Enabled: true, Profile: "netscape"})
if got.Profile != "" {
t.Fatalf("profile = %q, want an unknown client name dropped", got.Profile)
}
if !got.Enabled {
t.Fatal("dropping an unknown profile must not silently disable the feature")
}

got = CleanTLSFingerprint(TLSFingerprintConfig{Enabled: true, Profile: " ChRoMe "})
if got.Profile != "chrome" {
t.Fatalf("profile = %q, want it normalized to chrome", got.Profile)
}
}

// Legacy runtime payloads predate these fields; they must still be recognised as
// the old "disabled default" so upgrading does not resurrect a stale config.
func TestCodexLegacyDefaultDetectionWithNewFields(t *testing.T) {
legacy := CodexIdentityFingerprintConfig{}
if !codexLegacyDefaultDisabled(legacy) {
t.Fatal("an empty legacy payload must still be treated as the disabled default")
}

configured := CodexIdentityFingerprintConfig{ConvergenceMode: CodexFingerprintConvergenceOff}
if codexLegacyDefaultDisabled(configured) {
t.Fatal("an explicitly configured convergence mode must not be discarded as legacy")
}

pinned := CodexIdentityFingerprintConfig{InstallationID: "abc"}
if codexLegacyDefaultDisabled(pinned) {
t.Fatal("a pinned installation id must not be discarded as legacy")
}

tlsOn := CodexIdentityFingerprintConfig{TLSFingerprint: TLSFingerprintConfig{Enabled: true}}
if codexLegacyDefaultDisabled(tlsOn) {
t.Fatal("an enabled TLS fingerprint must not be discarded as legacy")
}
}
81 changes: 78 additions & 3 deletions internal/config/identity_fingerprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"strings"

"github.com/router-for-me/CLIProxyAPI/v6/internal/tlsfingerprint"
"gopkg.in/yaml.v3"
)

Expand All @@ -17,6 +18,21 @@ const (
DefaultCodexFingerprintBetaFeatures = ""
DefaultCodexFingerprintSessionMode = "per-request"

// Codex device fingerprint convergence modes. Upstream counts distinct
// installation/session/thread identifiers to derive per-account device and
// session quotas, so several people sharing one OAuth account each burn a
// separate device slot. Convergence rewrites those identifiers to
// account-stable values before the request leaves the proxy.
CodexFingerprintConvergenceOff = "off"
CodexFingerprintConvergenceDevice = "device"
CodexFingerprintConvergenceSession = "session"
CodexFingerprintConvergenceFull = "full"

// DefaultCodexFingerprintConvergenceMode converges installation and session
// while keeping one thread per real client session, which is the shape a
// single user spawning sub-agents produces upstream.
DefaultCodexFingerprintConvergenceMode = CodexFingerprintConvergenceSession

DefaultClaudeFingerprintCLIVersion = "2.1.161"
DefaultClaudeFingerprintEntrypoint = "cli"
DefaultClaudeFingerprintAnthropicBeta = "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05,effort-2025-11-24,context-management-2025-06-27,extended-cache-ttl-2025-04-11"
Expand Down Expand Up @@ -53,7 +69,40 @@ type CodexIdentityFingerprintConfig struct {
SessionMode string `yaml:"session-mode,omitempty" json:"session-mode,omitempty"`
SessionID string `yaml:"session-id,omitempty" json:"session-id,omitempty"`
CustomHeaders map[string]string `yaml:"custom-headers,omitempty" json:"custom-headers,omitempty"`
enabledSet bool
// ConvergenceMode selects how aggressively per-client device identifiers are
// rewritten to account-stable values: off, device, session or full.
ConvergenceMode string `yaml:"convergence-mode,omitempty" json:"convergence-mode,omitempty"`
// InstallationID pins the converged x-codex-installation-id. Leave empty to
// derive a stable value from the account key; set it to replay an
// installation id captured from a real Codex client.
InstallationID string `yaml:"installation-id,omitempty" json:"installation-id,omitempty"`
// TLSFingerprint shapes the ClientHello sent to the Codex upstream.
TLSFingerprint TLSFingerprintConfig `yaml:"tls-fingerprint,omitempty" json:"tls-fingerprint,omitempty"`
enabledSet bool
}

// TLSFingerprintConfig selects the TLS ClientHello presented to an upstream.
//
// This is off by default: a ClientHello that does not match the client the
// User-Agent claims to be is its own inconsistency, and the correct profile
// depends on what the operator has verified against the upstream. Turn it on
// after confirming the profile with a fingerprint echo service.
type TLSFingerprintConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
// Profile names a client to imitate: chrome, firefox, safari, edge, ios,
// android or randomized. Empty selects the built-in default.
Profile string `yaml:"profile,omitempty" json:"profile,omitempty"`
}

// CleanTLSFingerprint normalizes a TLS fingerprint block, dropping a profile
// name that does not identify a known client.
func CleanTLSFingerprint(in TLSFingerprintConfig) TLSFingerprintConfig {
out := in
out.Profile = strings.ToLower(strings.TrimSpace(out.Profile))
if out.Profile != "" && !tlsfingerprint.IsValidProfile(out.Profile) {
out.Profile = ""
}
return out
}

// DefaultCodexIdentityFingerprint returns the recommended Codex identity template.
Expand All @@ -67,6 +116,20 @@ func DefaultCodexIdentityFingerprint() CodexIdentityFingerprintConfig {
BetaFeatures: DefaultCodexFingerprintBetaFeatures,
SessionMode: DefaultCodexFingerprintSessionMode,
CustomHeaders: map[string]string{},

ConvergenceMode: DefaultCodexFingerprintConvergenceMode,
}
}

// IsValidCodexFingerprintConvergenceMode reports whether mode is one of the
// four supported convergence strengths.
func IsValidCodexFingerprintConvergenceMode(mode string) bool {
switch mode {
case CodexFingerprintConvergenceOff, CodexFingerprintConvergenceDevice,
CodexFingerprintConvergenceSession, CodexFingerprintConvergenceFull:
return true
default:
return false
}
}

Expand Down Expand Up @@ -235,6 +298,9 @@ func NormalizeCodexIdentityFingerprint(in CodexIdentityFingerprintConfig) CodexI
if out.SessionMode != "server-stable" && out.SessionMode != "fixed" && out.SessionMode != "per-request" {
out.SessionMode = DefaultCodexFingerprintSessionMode
}
if !IsValidCodexFingerprintConvergenceMode(out.ConvergenceMode) {
out.ConvergenceMode = DefaultCodexFingerprintConvergenceMode
}

return out
}
Expand All @@ -253,6 +319,12 @@ func CleanCodexIdentityFingerprint(in CodexIdentityFingerprintConfig) CodexIdent
if out.SessionMode != "" && out.SessionMode != "server-stable" && out.SessionMode != "fixed" && out.SessionMode != "per-request" {
out.SessionMode = DefaultCodexFingerprintSessionMode
}
out.ConvergenceMode = strings.TrimSpace(strings.ToLower(out.ConvergenceMode))
if out.ConvergenceMode != "" && !IsValidCodexFingerprintConvergenceMode(out.ConvergenceMode) {
out.ConvergenceMode = DefaultCodexFingerprintConvergenceMode
}
out.InstallationID = strings.TrimSpace(out.InstallationID)
out.TLSFingerprint = CleanTLSFingerprint(out.TLSFingerprint)
out.CustomHeaders = cleanIdentityFingerprintHeaders(out.CustomHeaders)
return out
}
Expand Down Expand Up @@ -419,7 +491,9 @@ func defaultXAIIdentityFingerprintEnabled(in XAIIdentityFingerprintConfig) XAIId
}

func codexLegacyDefaultDisabled(fp CodexIdentityFingerprintConfig) bool {
if fp.Enabled || strings.TrimSpace(fp.SessionID) != "" || len(fp.CustomHeaders) > 0 {
if fp.Enabled || strings.TrimSpace(fp.SessionID) != "" ||
strings.TrimSpace(fp.InstallationID) != "" || fp.TLSFingerprint.Enabled ||
strings.TrimSpace(fp.TLSFingerprint.Profile) != "" || len(fp.CustomHeaders) > 0 {
return false
}
defaults := DefaultCodexIdentityFingerprint()
Expand All @@ -428,7 +502,8 @@ func codexLegacyDefaultDisabled(fp CodexIdentityFingerprintConfig) bool {
emptyOrEqual(fp.Originator, defaults.Originator) &&
emptyOrEqual(fp.WebsocketBeta, defaults.WebsocketBeta) &&
emptyOrEqual(fp.BetaFeatures, defaults.BetaFeatures) &&
emptyOrEqual(fp.SessionMode, defaults.SessionMode)
emptyOrEqual(fp.SessionMode, defaults.SessionMode) &&
emptyOrEqual(fp.ConvergenceMode, defaults.ConvergenceMode)
}

func claudeLegacyDefaultDisabled(fp ClaudeIdentityFingerprintConfig) bool {
Expand Down
6 changes: 5 additions & 1 deletion internal/management/settings/runtimeconfig/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,11 @@ func codexIdentityFingerprintMeaningful(fp config.CodexIdentityFingerprintConfig
strings.TrimSpace(clean.Version) != "" ||
strings.TrimSpace(clean.Originator) != "" ||
strings.TrimSpace(clean.WebsocketBeta) != "" ||
strings.TrimSpace(clean.SessionMode) != ""
strings.TrimSpace(clean.SessionMode) != "" ||
strings.TrimSpace(clean.ConvergenceMode) != "" ||
strings.TrimSpace(clean.InstallationID) != "" ||
clean.TLSFingerprint.Enabled ||
strings.TrimSpace(clean.TLSFingerprint.Profile) != ""
}

func claudeIdentityFingerprintMeaningful(fp config.ClaudeIdentityFingerprintConfig) bool {
Expand Down
11 changes: 11 additions & 0 deletions internal/runtime/executor/codex_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ func applyCodexHeaders(r *http.Request, cfg *config.Config, auth *cliproxyauth.A
misc.EnsureHeader(r.Header, ginHeaders, "User-Agent", codexUserAgent)
}

// Device fingerprint convergence runs after the client headers above have
// been copied in, so it rewrites the values that actually reach upstream.
// The executor resolves the id set before translating the body and stores it
// on the context; requests that never pass through that path (token probes,
// PrepareRequest) resolve their own set here.
convergedIDs := codexConvergedIDsFromContext(r.Context())
if convergedIDs == nil {
convergedIDs = resolveCodexConvergedIDs(cfg, auth, ginHeaders)
}
applyCodexConvergenceHeaders(r.Header, convergedIDs, ginHeaders)

// Upstream codex-tui behavior: only attach Session_id when the UA indicates a desktop client.
if strings.Contains(r.Header.Get("User-Agent"), "Mac OS") && strings.TrimSpace(r.Header.Get("Session_id")) == "" {
r.Header.Set("Session_id", uuid.NewString())
Expand Down
6 changes: 6 additions & 0 deletions internal/runtime/executor/codex_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
return e.executeCompact(ctx, auth, req, opts)
}
req, opts = maybeStripCodexHistoryDataURLImagesOnRequest(req, opts)
ctx, convergedIDs := e.prepareCodexConvergence(ctx, auth)
execCtx := newExecutionContext(ctx, e.Identifier(), e.cfg, auth, req, opts, ExecutionOptions{
TargetFormat: sdktranslator.FromString("codex"),
})
Expand Down Expand Up @@ -158,6 +159,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
"safety_identifier",
})
body = maybeEnsureCodexImageGenerationTool(body, auth, execCtx.BaseModel, codexAdmissionHeadersFromContext(execCtx.Context))
body = applyCodexConvergenceClientMetadata(body, convergedIDs)

url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, err := e.cacheHelper(execCtx.Context, auth, execCtx.SourceFormat, url, req, body)
Expand Down Expand Up @@ -250,6 +252,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
}

func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
ctx, convergedIDs := e.prepareCodexConvergence(ctx, auth)
execCtx := newExecutionContext(ctx, e.Identifier(), e.cfg, auth, req, opts, ExecutionOptions{
TargetFormat: sdktranslator.FromString("openai-response"),
})
Expand Down Expand Up @@ -277,6 +280,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
body = ensureTranslatedCodexModel(body, execCtx.BaseModel)
body = sanitizeCodexResponsesRequest(body)
body, _ = sjson.DeleteBytes(body, "stream")
body = applyCodexConvergenceClientMetadata(body, convergedIDs)

url := strings.TrimSuffix(baseURL, "/") + "/responses/compact"
httpReq, err := e.cacheHelper(execCtx.Context, auth, execCtx.SourceFormat, url, req, body)
Expand Down Expand Up @@ -329,6 +333,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
// Shrink multi-MB Desktop history data URLs before translation/sanitize so later
// body-level passes never see the full base64 history.
req, opts = maybeStripCodexHistoryDataURLImagesOnRequest(req, opts)
ctx, convergedIDs := e.prepareCodexConvergence(ctx, auth)
execCtx := newExecutionContext(ctx, e.Identifier(), e.cfg, auth, req, opts, ExecutionOptions{
TargetFormat: sdktranslator.FromString("codex"),
TranslateAsStream: true,
Expand Down Expand Up @@ -366,6 +371,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
})
body = ensureTranslatedCodexModel(body, execCtx.BaseModel)
body = maybeEnsureCodexImageGenerationTool(body, auth, execCtx.BaseModel, codexAdmissionHeadersFromContext(execCtx.Context))
body = applyCodexConvergenceClientMetadata(body, convergedIDs)

url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, err := e.cacheHelper(execCtx.Context, auth, execCtx.SourceFormat, url, req, body)
Expand Down
Loading
Loading