diff --git a/config.example.yaml b/config.example.yaml index 6a53c9400..be84de3b5 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -119,13 +119,21 @@ routing: strategy: "round-robin" # round-robin (default), fill-first # Enable universal session-sticky routing for all clients. # Session IDs are extracted from: metadata.user_id (Claude Code session format), - # X-Session-ID, Session_id (Codex), X-Amp-Thread-Id (Amp CLI), + # X-Session-ID, X-Amp-Thread-Id (Amp CLI), # X-Client-Request-Id (PI), conversation_id, or first few messages hash. # Automatic failover is always enabled when bound auth becomes unavailable. session-affinity: false # default: false # How long session-to-auth bindings are retained. Default: 1h session-affinity-ttl: "1h" +# Codex provider behavior. +codex: + # When true, and routing.strategy is fill-first or routing.session-affinity is true, + # remap Codex prompt_cache_key and installation identity per selected auth. + # Some superstitious users believe request tracking identifiers can be used + # as evidence for TOS enforcement bans; this option only satisfies those odd concerns. + identity-confuse: false + # When true, enable authentication for the WebSocket API (/v1/ws). ws-auth: true diff --git a/internal/config/codex_websocket_header_defaults_test.go b/internal/config/codex_websocket_header_defaults_test.go index 49947c1cf..1ccb82e4e 100644 --- a/internal/config/codex_websocket_header_defaults_test.go +++ b/internal/config/codex_websocket_header_defaults_test.go @@ -30,3 +30,24 @@ codex-header-defaults: t.Fatalf("BetaFeatures = %q, want %q", got, "feature-a,feature-b") } } + +func TestLoadConfigOptional_CodexIdentityConfuse(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + configYAML := []byte(` +codex: + identity-confuse: true +`) + if err := os.WriteFile(configPath, configYAML, 0o600); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cfg, err := LoadConfigOptional(configPath, false) + if err != nil { + t.Fatalf("LoadConfigOptional() error = %v", err) + } + + if !cfg.Codex.IdentityConfuse { + t.Fatalf("IdentityConfuse = false, want true") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index dd0b05c72..7c660cd23 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -111,6 +111,9 @@ type Config struct { // Codex defines a list of Codex API key configurations as specified in the YAML configuration file. CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"` + // Codex configures provider-wide Codex request behavior. + Codex CodexConfig `yaml:"codex" json:"codex"` + // CodexHeaderDefaults configures fallback headers for Codex OAuth model requests. // These are used only when the client does not send its own headers. CodexHeaderDefaults CodexHeaderDefaults `yaml:"codex-header-defaults" json:"codex-header-defaults"` @@ -172,6 +175,11 @@ type CodexHeaderDefaults struct { BetaFeatures string `yaml:"beta-features" json:"beta-features"` } +// CodexConfig configures provider-wide Codex request behavior. +type CodexConfig struct { + IdentityConfuse bool `yaml:"identity-confuse" json:"identity-confuse"` +} + // TLSConfig holds HTTPS server settings. type TLSConfig struct { // Enable toggles HTTPS server mode. @@ -229,7 +237,7 @@ type RoutingConfig struct { // SessionAffinity enables universal session-sticky routing for all clients. // Session IDs are extracted from multiple sources: - // metadata.user_id (Claude Code session format), X-Session-ID, Session_id (Codex), + // metadata.user_id (Claude Code session format), X-Session-ID, // X-Amp-Thread-Id (Amp CLI thread), X-Client-Request-Id (PI), metadata.user_id, // conversation_id, or message hash. // Automatic failover is always enabled when bound auth becomes unavailable. diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 7b6079440..c8a9246e4 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -298,11 +298,13 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re reporter.SetTranslatedReasoningEffort(body, to.String()) url := strings.TrimSuffix(baseURL, "/") + "/responses" - httpReq, err := e.cacheHelper(ctx, from, url, req, body) + var identityState codexIdentityConfuseState + httpReq, upstreamBody, identityState, err := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body) if err != nil { return resp, err } applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg) + applyCodexIdentityConfuseHeaders(httpReq.Header, identityState) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -313,7 +315,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re URL: url, Method: http.MethodPost, Headers: httpReq.Header.Clone(), - Body: body, + Body: upstreamBody, Provider: e.Identifier(), AuthID: authID, AuthLabel: authLabel, @@ -335,6 +337,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { b, _ := io.ReadAll(httpResp.Body) + b = applyCodexIdentityConfuseResponsePayload(b, identityState) helps.AppendAPIResponseChunk(ctx, e.cfg, b) helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b)) err = newCodexStatusErr(httpResp.StatusCode, b) @@ -345,9 +348,10 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re helps.RecordAPIResponseError(ctx, e.cfg, err) return resp, err } - helps.AppendAPIResponseChunk(ctx, e.cfg, data) + upstreamData := applyCodexIdentityConfuseResponsePayload(data, identityState) + helps.AppendAPIResponseChunk(ctx, e.cfg, upstreamData) - lines := bytes.Split(data, []byte("\n")) + lines := bytes.Split(upstreamData, []byte("\n")) outputItemsByIndex := make(map[int64][]byte) var outputItemsFallback [][]byte for _, line := range lines { @@ -410,7 +414,8 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re } var param any - out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, completedData, ¶m) + clientCompletedData := applyCodexIdentityExposeResponsePayload(completedData, identityState) + out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, clientCompletedData, ¶m) resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()} return resp, nil } @@ -456,11 +461,13 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A reporter.SetTranslatedReasoningEffort(body, to.String()) url := strings.TrimSuffix(baseURL, "/") + "/responses/compact" - httpReq, err := e.cacheHelper(ctx, from, url, req, body) + var identityState codexIdentityConfuseState + httpReq, upstreamBody, identityState, err := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body) if err != nil { return resp, err } applyCodexHeaders(httpReq, auth, apiKey, false, e.cfg) + applyCodexIdentityConfuseHeaders(httpReq.Header, identityState) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -471,7 +478,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A URL: url, Method: http.MethodPost, Headers: httpReq.Header.Clone(), - Body: body, + Body: upstreamBody, Provider: e.Identifier(), AuthID: authID, AuthLabel: authLabel, @@ -493,6 +500,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { b, _ := io.ReadAll(httpResp.Body) + b = applyCodexIdentityConfuseResponsePayload(b, identityState) helps.AppendAPIResponseChunk(ctx, e.cfg, b) helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b)) err = newCodexStatusErr(httpResp.StatusCode, b) @@ -503,11 +511,13 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A helps.RecordAPIResponseError(ctx, e.cfg, err) return resp, err } - helps.AppendAPIResponseChunk(ctx, e.cfg, data) - reporter.Publish(ctx, helps.ParseOpenAIUsage(data)) + upstreamData := applyCodexIdentityConfuseResponsePayload(data, identityState) + helps.AppendAPIResponseChunk(ctx, e.cfg, upstreamData) + reporter.Publish(ctx, helps.ParseOpenAIUsage(upstreamData)) reporter.EnsurePublished(ctx) var param any - out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, data, ¶m) + clientData := applyCodexIdentityExposeResponsePayload(upstreamData, identityState) + out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, clientData, ¶m) resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()} return resp, nil } @@ -559,11 +569,13 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au reporter.SetTranslatedReasoningEffort(body, to.String()) url := strings.TrimSuffix(baseURL, "/") + "/responses" - httpReq, err := e.cacheHelper(ctx, from, url, req, body) + var identityState codexIdentityConfuseState + httpReq, upstreamBody, identityState, err := e.cacheHelper(ctx, from, url, auth, req, originalPayloadSource, body) if err != nil { return nil, err } applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg) + applyCodexIdentityConfuseHeaders(httpReq.Header, identityState) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -574,7 +586,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au URL: url, Method: http.MethodPost, Headers: httpReq.Header.Clone(), - Body: body, + Body: upstreamBody, Provider: e.Identifier(), AuthID: authID, AuthLabel: authLabel, @@ -599,6 +611,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au helps.RecordAPIResponseError(ctx, e.cfg, readErr) return nil, readErr } + data = applyCodexIdentityConfuseResponsePayload(data, identityState) helps.AppendAPIResponseChunk(ctx, e.cfg, data) helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data)) err = newCodexStatusErr(httpResp.StatusCode, data) @@ -618,7 +631,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au outputItemsByIndex := make(map[int64][]byte) var outputItemsFallback [][]byte for scanner.Scan() { - line := scanner.Bytes() + line := applyCodexIdentityConfuseResponsePayload(scanner.Bytes(), identityState) helps.AppendAPIResponseChunk(ctx, e.cfg, line) translatedLine := bytes.Clone(line) @@ -646,6 +659,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au } } + translatedLine = applyCodexIdentityExposeResponsePayload(translatedLine, identityState) chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, body, translatedLine, ¶m) for i := range chunks { select { @@ -866,7 +880,12 @@ func (e *CodexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (* return auth, nil } -func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Format, url string, req cliproxyexecutor.Request, rawJSON []byte) (*http.Request, error) { +type codexIdentityConfuseState struct { + originalPromptCacheKey string + promptCacheKey string +} + +func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Format, url string, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, userPayload []byte, rawJSON []byte) (*http.Request, []byte, codexIdentityConfuseState, error) { var cache helps.CodexCache if from == "claude" { userIDResult := gjson.GetBytes(req.Payload, "metadata.user_id") @@ -895,14 +914,98 @@ func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Form if cache.ID != "" { rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", cache.ID) } + var identityState codexIdentityConfuseState + rawJSON, identityState = applyCodexIdentityConfuseBody(e.cfg, auth, userPayload, rawJSON) + if identityState.promptCacheKey != "" { + cache.ID = identityState.promptCacheKey + } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(rawJSON)) if err != nil { - return nil, err + return nil, nil, codexIdentityConfuseState{}, err } - if cache.ID != "" { - httpReq.Header.Set("Session_id", cache.ID) + return httpReq, rawJSON, identityState, nil +} + +func applyCodexIdentityConfuseBody(cfg *config.Config, auth *cliproxyauth.Auth, userPayload []byte, rawJSON []byte) ([]byte, codexIdentityConfuseState) { + if !codexIdentityConfuseEnabled(cfg) || auth == nil || strings.TrimSpace(auth.ID) == "" || len(rawJSON) == 0 { + return rawJSON, codexIdentityConfuseState{} } - return httpReq, nil + + state := codexIdentityConfuseState{} + if promptCacheKey := strings.TrimSpace(gjson.GetBytes(userPayload, "prompt_cache_key").String()); promptCacheKey != "" { + state.originalPromptCacheKey = promptCacheKey + state.promptCacheKey = codexIdentityConfuseUUID(auth.ID, "prompt-cache", promptCacheKey) + rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", state.promptCacheKey) + } + if installationID := strings.TrimSpace(gjson.GetBytes(userPayload, "client_metadata.x-codex-installation-id").String()); installationID != "" { + rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.x-codex-installation-id", codexIdentityConfuseUUID(auth.ID, "installation", installationID)) + } + if state.promptCacheKey != "" { + if turnMetadata := strings.TrimSpace(gjson.GetBytes(rawJSON, "client_metadata.x-codex-turn-metadata").String()); turnMetadata != "" { + rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.x-codex-turn-metadata", applyCodexTurnMetadataIdentityConfuse(turnMetadata, state)) + } + if windowID := strings.TrimSpace(gjson.GetBytes(rawJSON, "client_metadata.x-codex-window-id").String()); windowID != "" { + rawJSON, _ = sjson.SetBytes(rawJSON, "client_metadata.x-codex-window-id", state.promptCacheKey+":0") + } + } + + return rawJSON, state +} + +func applyCodexIdentityConfuseHeaders(headers http.Header, state codexIdentityConfuseState) { + if headers == nil || state.promptCacheKey == "" { + return + } + + setHeaderCasePreserved(headers, "Session-Id", state.promptCacheKey) + headers.Set("Conversation_id", state.promptCacheKey) + headers.Set("X-Client-Request-Id", state.promptCacheKey) + headers.Set("Thread-Id", state.promptCacheKey) + headers.Set("X-Codex-Window-Id", state.promptCacheKey+":0") + + if rawTurnMetadata := strings.TrimSpace(headers.Get("X-Codex-Turn-Metadata")); rawTurnMetadata != "" { + headers.Set("X-Codex-Turn-Metadata", applyCodexTurnMetadataIdentityConfuse(rawTurnMetadata, state)) + } +} + +func applyCodexTurnMetadataIdentityConfuse(rawTurnMetadata string, state codexIdentityConfuseState) string { + updatedTurnMetadata := rawTurnMetadata + if gjson.Get(rawTurnMetadata, "prompt_cache_key").Exists() { + updatedTurnMetadata, _ = sjson.Set(updatedTurnMetadata, "prompt_cache_key", state.promptCacheKey) + } else if state.originalPromptCacheKey != "" { + updatedTurnMetadata = strings.ReplaceAll(updatedTurnMetadata, state.originalPromptCacheKey, state.promptCacheKey) + } + return updatedTurnMetadata +} + +func applyCodexIdentityConfuseResponsePayload(payload []byte, state codexIdentityConfuseState) []byte { + return replaceCodexIdentityResponsePayload(payload, state.originalPromptCacheKey, state.promptCacheKey) +} + +func applyCodexIdentityExposeResponsePayload(payload []byte, state codexIdentityConfuseState) []byte { + return replaceCodexIdentityResponsePayload(payload, state.promptCacheKey, state.originalPromptCacheKey) +} + +func replaceCodexIdentityResponsePayload(payload []byte, from string, to string) []byte { + from = strings.TrimSpace(from) + to = strings.TrimSpace(to) + if len(payload) == 0 || from == "" || to == "" || from == to || !bytes.Contains(payload, []byte(from)) { + return payload + } + return bytes.ReplaceAll(payload, []byte(from), []byte(to)) +} + +func codexIdentityConfuseEnabled(cfg *config.Config) bool { + if cfg == nil || !cfg.Codex.IdentityConfuse { + return false + } + strategy := strings.ToLower(strings.TrimSpace(cfg.Routing.Strategy)) + return cfg.Routing.SessionAffinity || strategy == "fill-first" || strategy == "fillfirst" || strategy == "ff" +} + +func codexIdentityConfuseUUID(authID string, kind string, value string) string { + name := strings.Join([]string{"cli-proxy-api", "codex", "identity-confuse", kind, strings.TrimSpace(authID), strings.TrimSpace(value)}, ":") + return uuid.NewSHA1(uuid.NameSpaceOID, []byte(name)).String() } func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config) { @@ -923,10 +1026,6 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s cfgUserAgent, _ := codexHeaderDefaults(cfg, auth) ensureHeaderWithConfigPrecedence(r.Header, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent) - if strings.Contains(r.Header.Get("User-Agent"), "Mac OS") { - misc.EnsureHeader(r.Header, ginHeaders, "Session_id", uuid.NewString()) - } - if stream { r.Header.Set("Accept", "text/event-stream") } else { diff --git a/internal/runtime/executor/codex_executor_cache_test.go b/internal/runtime/executor/codex_executor_cache_test.go index cb96a9028..2cf2b373b 100644 --- a/internal/runtime/executor/codex_executor_cache_test.go +++ b/internal/runtime/executor/codex_executor_cache_test.go @@ -8,6 +8,8 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" "github.com/tidwall/gjson" @@ -27,7 +29,7 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom } url := "https://example.com/responses" - httpReq, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai"), url, req, rawJSON) + httpReq, _, _, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai"), url, nil, req, req.Payload, rawJSON) if err != nil { t.Fatalf("cacheHelper error: %v", err) } @@ -45,11 +47,11 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom if gotConversation := httpReq.Header.Get("Conversation_id"); gotConversation != "" { t.Fatalf("Conversation_id = %q, want empty", gotConversation) } - if gotSession := httpReq.Header.Get("Session_id"); gotSession != expectedKey { - t.Fatalf("Session_id = %q, want %q", gotSession, expectedKey) + if gotSession := httpReq.Header.Get("Session_id"); gotSession != "" { + t.Fatalf("Session_id = %q, want empty", gotSession) } - httpReq2, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai"), url, req, rawJSON) + httpReq2, _, _, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai"), url, nil, req, req.Payload, rawJSON) if err != nil { t.Fatalf("cacheHelper error (second call): %v", err) } @@ -62,3 +64,81 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom t.Fatalf("prompt_cache_key (second call) = %q, want %q", gotKey2, expectedKey) } } + +func TestCodexExecutorCacheHelper_IdentityConfuseRemapsBodyAndHeaders(t *testing.T) { + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil) + ginCtx.Request.Header.Set("X-Codex-Turn-Metadata", `{"prompt_cache_key":"cache-1","turn_id":"turn-1"}`) + ginCtx.Request.Header.Set("X-Client-Request-Id", "client-request-1") + + ctx := context.WithValue(context.Background(), "gin", ginCtx) + executor := &CodexExecutor{cfg: &config.Config{ + Routing: config.RoutingConfig{Strategy: "fill-first"}, + Codex: config.CodexConfig{IdentityConfuse: true}, + }} + auth := &cliproxyauth.Auth{ID: "auth-1", Provider: "codex"} + rawJSON := []byte(`{"model":"gpt-5-codex","stream":true,"client_metadata":{"x-codex-turn-metadata":"{\"prompt_cache_key\":\"cache-1\",\"turn_id\":\"turn-1\"}","x-codex-window-id":"cache-1:0"}}`) + req := cliproxyexecutor.Request{ + Model: "gpt-5-codex", + Payload: []byte(`{"model":"gpt-5-codex","prompt_cache_key":"cache-1","client_metadata":{"x-codex-installation-id":"install-1"}}`), + } + url := "https://example.com/responses" + + httpReq, body, identityState, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai-response"), url, auth, req, req.Payload, rawJSON) + if err != nil { + t.Fatalf("cacheHelper error: %v", err) + } + applyCodexHeaders(httpReq, auth, "oauth-token", true, executor.cfg) + applyCodexIdentityConfuseHeaders(httpReq.Header, identityState) + + expectedPromptCacheKey := codexIdentityConfuseUUID("auth-1", "prompt-cache", "cache-1") + if gotKey := gjson.GetBytes(body, "prompt_cache_key").String(); gotKey != expectedPromptCacheKey { + t.Fatalf("prompt_cache_key = %q, want %q", gotKey, expectedPromptCacheKey) + } + expectedInstallationID := codexIdentityConfuseUUID("auth-1", "installation", "install-1") + if gotID := gjson.GetBytes(body, "client_metadata.x-codex-installation-id").String(); gotID != expectedInstallationID { + t.Fatalf("installation id = %q, want %q", gotID, expectedInstallationID) + } + if gotMetadata := gjson.GetBytes(body, "client_metadata.x-codex-turn-metadata").String(); gotMetadata != `{"prompt_cache_key":"`+expectedPromptCacheKey+`","turn_id":"turn-1"}` { + t.Fatalf("client_metadata.x-codex-turn-metadata = %s", gotMetadata) + } + if gotWindowID := gjson.GetBytes(body, "client_metadata.x-codex-window-id").String(); gotWindowID != expectedPromptCacheKey+":0" { + t.Fatalf("client_metadata.x-codex-window-id = %q, want %q", gotWindowID, expectedPromptCacheKey+":0") + } + for _, headerName := range []string{"Session-Id", "X-Client-Request-Id", "Thread-Id"} { + if gotHeader := httpReq.Header.Get(headerName); gotHeader != expectedPromptCacheKey { + t.Fatalf("%s = %q, want %q", headerName, gotHeader, expectedPromptCacheKey) + } + } + if gotSession := httpReq.Header.Get("Session_id"); gotSession != "" { + t.Fatalf("Session_id = %q, want empty", gotSession) + } + if gotWindow := httpReq.Header.Get("X-Codex-Window-Id"); gotWindow != expectedPromptCacheKey+":0" { + t.Fatalf("X-Codex-Window-Id = %q, want %q", gotWindow, expectedPromptCacheKey+":0") + } + if gotMetadata := httpReq.Header.Get("X-Codex-Turn-Metadata"); gotMetadata != `{"prompt_cache_key":"`+expectedPromptCacheKey+`","turn_id":"turn-1"}` { + t.Fatalf("X-Codex-Turn-Metadata = %s", gotMetadata) + } +} + +func TestCodexIdentityConfuseKeepsClientBodySeparateFromUpstreamBody(t *testing.T) { + cfg := &config.Config{ + Routing: config.RoutingConfig{Strategy: "fill-first"}, + Codex: config.CodexConfig{IdentityConfuse: true}, + } + auth := &cliproxyauth.Auth{ID: "auth-1", Provider: "codex"} + clientBody := []byte(`{"model":"gpt-5-codex","prompt_cache_key":"cache-1"}`) + + upstreamBody, identityState := applyCodexIdentityConfuseBody(cfg, auth, clientBody, clientBody) + expectedPromptCacheKey := codexIdentityConfuseUUID("auth-1", "prompt-cache", "cache-1") + if identityState.promptCacheKey != expectedPromptCacheKey { + t.Fatalf("identity prompt_cache_key = %q, want %q", identityState.promptCacheKey, expectedPromptCacheKey) + } + if gotKey := gjson.GetBytes(upstreamBody, "prompt_cache_key").String(); gotKey != expectedPromptCacheKey { + t.Fatalf("upstream prompt_cache_key = %q, want %q", gotKey, expectedPromptCacheKey) + } + if gotKey := gjson.GetBytes(clientBody, "prompt_cache_key").String(); gotKey != "cache-1" { + t.Fatalf("client prompt_cache_key = %q, want cache-1", gotKey) + } +} diff --git a/internal/runtime/executor/codex_openai_images.go b/internal/runtime/executor/codex_openai_images.go index 415cdf1c7..90fe4ad3e 100644 --- a/internal/runtime/executor/codex_openai_images.go +++ b/internal/runtime/executor/codex_openai_images.go @@ -99,11 +99,13 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau reporter.SetTranslatedReasoningEffort(body, "codex") url := strings.TrimSuffix(baseURL, "/") + "/responses" - httpReq, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body) + var identityState codexIdentityConfuseState + httpReq, body, identityState, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, auth, req, req.Payload, body) if errCache != nil { return resp, errCache } applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg) + applyCodexIdentityConfuseHeaders(httpReq.Header, identityState) recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body) httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) @@ -125,6 +127,7 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau helps.RecordAPIResponseError(ctx, e.cfg, errRead) return resp, errRead } + data = applyCodexIdentityConfuseResponsePayload(data, identityState) helps.AppendAPIResponseChunk(ctx, e.cfg, data) if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data)) @@ -189,11 +192,13 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip reporter.SetTranslatedReasoningEffort(body, "codex") url := strings.TrimSuffix(baseURL, "/") + "/responses" - httpReq, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body) + var identityState codexIdentityConfuseState + httpReq, body, identityState, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, auth, req, req.Payload, body) if errCache != nil { return nil, errCache } applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg) + applyCodexIdentityConfuseHeaders(httpReq.Header, identityState) recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body) httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) @@ -213,6 +218,7 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip helps.RecordAPIResponseError(ctx, e.cfg, errRead) return nil, errRead } + data = applyCodexIdentityConfuseResponsePayload(data, identityState) helps.AppendAPIResponseChunk(ctx, e.cfg, data) helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data)) err = newCodexStatusErr(httpResp.StatusCode, data) @@ -250,7 +256,7 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip outputItemsByIndex := make(map[int64][]byte) var outputItemsFallback [][]byte for scanner.Scan() { - line := scanner.Bytes() + line := applyCodexIdentityConfuseResponsePayload(scanner.Bytes(), identityState) helps.AppendAPIResponseChunk(ctx, e.cfg, line) if !bytes.HasPrefix(line, dataTag) { continue diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go index 4a2fb1f9f..2680e729b 100644 --- a/internal/runtime/executor/codex_websockets_executor.go +++ b/internal/runtime/executor/codex_websockets_executor.go @@ -221,8 +221,15 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut } body, wsHeaders := applyCodexPromptCacheHeaders(from, req, body) - reporter.SetTranslatedReasoningEffort(body, to.String()) + clientBody := body + var identityState codexIdentityConfuseState + upstreamBody, identityState := applyCodexIdentityConfuseBody(e.cfg, auth, originalPayloadSource, body) + if identityState.promptCacheKey != "" { + wsHeaders.Set("Conversation_id", identityState.promptCacheKey) + } + reporter.SetTranslatedReasoningEffort(clientBody, to.String()) wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg) + applyCodexIdentityConfuseHeaders(wsHeaders, identityState) var authID, authLabel, authType, authValue string if auth != nil { @@ -239,7 +246,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut defer sess.reqMu.Unlock() } - wsReqBody := buildCodexWebsocketRequestBody(body) + wsReqBody := buildCodexWebsocketRequestBody(upstreamBody) wsReqLog := helps.UpstreamRequestLog{ URL: wsURL, Method: "WEBSOCKET", @@ -300,7 +307,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut // execution session. connRetry, respHSRetry, errDialRetry := e.ensureUpstreamConn(ctx, auth, sess, authID, wsURL, wsHeaders) if errDialRetry == nil && connRetry != nil { - wsReqBodyRetry := buildCodexWebsocketRequestBody(body) + wsReqBodyRetry := buildCodexWebsocketRequestBody(upstreamBody) helps.RecordAPIWebsocketRequest(ctx, e.cfg, helps.UpstreamRequestLog{ URL: wsURL, Method: "WEBSOCKET", @@ -359,6 +366,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut continue } reporter.MarkFirstResponseByte() + payload = applyCodexIdentityConfuseResponsePayload(payload, identityState) helps.AppendAPIWebsocketResponse(ctx, e.cfg, payload) if wsErr, ok := parseCodexWebsocketError(payload); ok { @@ -376,7 +384,8 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut reporter.Publish(ctx, detail) } var param any - out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, payload, ¶m) + clientPayload := applyCodexIdentityExposeResponsePayload(payload, identityState) + out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, clientBody, clientPayload, ¶m) resp = cliproxyexecutor.Response{Payload: out} return resp, nil } @@ -404,6 +413,10 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr from := opts.SourceFormat to := sdktranslator.FromString("codex") body := req.Payload + userPayload := req.Payload + if len(opts.OriginalRequest) > 0 { + userPayload = opts.OriginalRequest + } body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier()) if err != nil { @@ -426,8 +439,15 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr } body, wsHeaders := applyCodexPromptCacheHeaders(from, req, body) - reporter.SetTranslatedReasoningEffort(body, to.String()) + clientBody := body + var identityState codexIdentityConfuseState + upstreamBody, identityState := applyCodexIdentityConfuseBody(e.cfg, auth, userPayload, body) + if identityState.promptCacheKey != "" { + wsHeaders.Set("Conversation_id", identityState.promptCacheKey) + } + reporter.SetTranslatedReasoningEffort(clientBody, to.String()) wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg) + applyCodexIdentityConfuseHeaders(wsHeaders, identityState) var authID, authLabel, authType, authValue string authID = auth.ID @@ -443,7 +463,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr } } - wsReqBody := buildCodexWebsocketRequestBody(body) + wsReqBody := buildCodexWebsocketRequestBody(upstreamBody) wsReqLog := helps.UpstreamRequestLog{ URL: wsURL, Method: "WEBSOCKET", @@ -506,7 +526,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr sess.reqMu.Unlock() return nil, errDialRetry } - wsReqBodyRetry := buildCodexWebsocketRequestBody(body) + wsReqBodyRetry := buildCodexWebsocketRequestBody(upstreamBody) helps.RecordAPIWebsocketRequest(ctx, e.cfg, helps.UpstreamRequestLog{ URL: wsURL, Method: "WEBSOCKET", @@ -613,6 +633,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr continue } reporter.MarkFirstResponseByte() + payload = applyCodexIdentityConfuseResponsePayload(payload, identityState) helps.AppendAPIWebsocketResponse(ctx, e.cfg, payload) if wsErr, ok := parseCodexWebsocketError(payload); ok { @@ -635,8 +656,9 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr } } - line := encodeCodexWebsocketAsSSE(payload) - chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, body, body, line, ¶m) + clientPayload := applyCodexIdentityExposeResponsePayload(payload, identityState) + line := encodeCodexWebsocketAsSSE(clientPayload) + chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, clientBody, clientBody, line, ¶m) for i := range chunks { if !send(cliproxyexecutor.StreamChunk{Payload: chunks[i]}) { terminateReason = "context_done" @@ -841,7 +863,6 @@ func applyCodexPromptCacheHeaders(from sdktranslator.Format, req cliproxyexecuto if cache.ID != "" { rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", cache.ID) - setHeaderCasePreserved(headers, "session_id", cache.ID) headers.Set("Conversation_id", cache.ID) } @@ -883,10 +904,6 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * betaHeader = codexResponsesWebsocketBetaHeaderValue } headers.Set("OpenAI-Beta", betaHeader) - if strings.Contains(headers.Get("User-Agent"), "Mac OS") { - ensureHeaderCasePreserved(headers, ginHeaders, "session_id", "", uuid.NewString()) - } - ensureHeaderCasePreserved(headers, ginHeaders, "session_id", "", "") if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" { headers.Set("Originator", originator) } else if !isAPIKey { diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go index 4342ed888..4ea1e87fa 100644 --- a/internal/runtime/executor/codex_websockets_executor_test.go +++ b/internal/runtime/executor/codex_websockets_executor_test.go @@ -197,7 +197,7 @@ func TestApplyCodexWebsocketHeadersPassesThroughClientIdentityHeaders(t *testing "Version": "0.115.0-alpha.27", "X-Codex-Turn-Metadata": `{"turn_id":"turn-1"}`, "X-Client-Request-Id": "019d2233-e240-7162-992d-38df0a2a0e0d", - "session_id": "sess-client", + "session_id": "legacy-session", }) headers := applyCodexWebsocketHeaders(ctx, http.Header{}, auth, "", nil) @@ -217,11 +217,8 @@ func TestApplyCodexWebsocketHeadersPassesThroughClientIdentityHeaders(t *testing if got := headers.Get("X-Client-Request-Id"); got != "019d2233-e240-7162-992d-38df0a2a0e0d" { t.Fatalf("X-Client-Request-Id = %s, want %s", got, "019d2233-e240-7162-992d-38df0a2a0e0d") } - if got := headerValueCaseInsensitive(headers, "session_id"); got != "sess-client" { - t.Fatalf("session_id = %s, want sess-client", got) - } - if _, ok := headers["session_id"]; !ok { - t.Fatalf("expected lowercase session_id header key, got %#v", headers) + if got := headerValueCaseInsensitive(headers, "session_id"); got != "" { + t.Fatalf("session_id = %q, want empty", got) } } @@ -344,22 +341,101 @@ func TestApplyCodexWebsocketHeadersPreservesExplicitAPIKeyUserAgent(t *testing.T } } -func TestApplyCodexPromptCacheHeadersSetsLowercaseSessionAndLegacyConversation(t *testing.T) { +func TestApplyCodexPromptCacheHeadersSetsLegacyConversationOnly(t *testing.T) { req := cliproxyexecutor.Request{Model: "gpt-5-codex", Payload: []byte(`{"prompt_cache_key":"cache-1"}`)} _, headers := applyCodexPromptCacheHeaders("openai-response", req, []byte(`{"model":"gpt-5-codex"}`)) - if got := headerValueCaseInsensitive(headers, "session_id"); got != "cache-1" { - t.Fatalf("session_id = %s, want cache-1", got) - } - if _, ok := headers["session_id"]; !ok { - t.Fatalf("expected lowercase session_id key, got %#v", headers) + if got := headerValueCaseInsensitive(headers, "session_id"); got != "" { + t.Fatalf("session_id = %q, want empty", got) } if got := headers.Get("Conversation_id"); got != "cache-1" { t.Fatalf("Conversation_id = %s, want cache-1", got) } } +func TestApplyCodexWebsocketHeadersIdentityConfuseRemapsPromptCacheKey(t *testing.T) { + cfg := &config.Config{ + Routing: config.RoutingConfig{SessionAffinity: true}, + Codex: config.CodexConfig{IdentityConfuse: true}, + } + auth := &cliproxyauth.Auth{ID: "auth-ws-1", Provider: "codex"} + req := cliproxyexecutor.Request{ + Model: "gpt-5-codex", + Payload: []byte(`{"prompt_cache_key":"cache-ws-1","client_metadata":{"x-codex-installation-id":"install-ws-1"}}`), + } + + body, headers := applyCodexPromptCacheHeaders("openai-response", req, []byte(`{"model":"gpt-5-codex"}`)) + body, identityState := applyCodexIdentityConfuseBody(cfg, auth, req.Payload, body) + if identityState.promptCacheKey != "" { + headers.Set("Conversation_id", identityState.promptCacheKey) + } + ctx := contextWithGinHeaders(map[string]string{ + "X-Codex-Turn-Metadata": `{"prompt_cache_key":"cache-ws-1"}`, + "X-Client-Request-Id": "client-request-1", + }) + headers = applyCodexWebsocketHeaders(ctx, headers, auth, "oauth-token", cfg) + applyCodexIdentityConfuseHeaders(headers, identityState) + + expectedPromptCacheKey := codexIdentityConfuseUUID("auth-ws-1", "prompt-cache", "cache-ws-1") + if gotKey := gjson.GetBytes(body, "prompt_cache_key").String(); gotKey != expectedPromptCacheKey { + t.Fatalf("prompt_cache_key = %q, want %q", gotKey, expectedPromptCacheKey) + } + if gotSession := headerValueCaseInsensitive(headers, "session_id"); gotSession != "" { + t.Fatalf("session_id = %q, want empty", gotSession) + } + if gotRequestID := headers.Get("X-Client-Request-Id"); gotRequestID != expectedPromptCacheKey { + t.Fatalf("X-Client-Request-Id = %q, want %q", gotRequestID, expectedPromptCacheKey) + } + if gotThreadID := headers.Get("Thread-Id"); gotThreadID != expectedPromptCacheKey { + t.Fatalf("Thread-Id = %q, want %q", gotThreadID, expectedPromptCacheKey) + } + if gotWindowID := headers.Get("X-Codex-Window-Id"); gotWindowID != expectedPromptCacheKey+":0" { + t.Fatalf("X-Codex-Window-Id = %q, want %q", gotWindowID, expectedPromptCacheKey+":0") + } + if gotMetadata := headers.Get("X-Codex-Turn-Metadata"); gotMetadata != `{"prompt_cache_key":"`+expectedPromptCacheKey+`"}` { + t.Fatalf("X-Codex-Turn-Metadata = %s", gotMetadata) + } + expectedInstallationID := codexIdentityConfuseUUID("auth-ws-1", "installation", "install-ws-1") + if gotInstallationID := gjson.GetBytes(body, "client_metadata.x-codex-installation-id").String(); gotInstallationID != expectedInstallationID { + t.Fatalf("installation id = %q, want %q", gotInstallationID, expectedInstallationID) + } +} + +func TestCodexIdentityConfuseResponsePayloadHidesUpstreamAndRestoresClient(t *testing.T) { + state := codexIdentityConfuseState{ + originalPromptCacheKey: "cache-ws-1", + promptCacheKey: codexIdentityConfuseUUID("auth-ws-1", "prompt-cache", "cache-ws-1"), + } + rawPayload := []byte(`{"type":"response.completed","response":{"prompt_cache_key":"cache-ws-1"},"prompt_cache_key":"cache-ws-1"}`) + + upstreamPayload := applyCodexIdentityConfuseResponsePayload(rawPayload, state) + if bytes.Contains(upstreamPayload, []byte(`cache-ws-1`)) { + t.Fatalf("upstream payload still contains original prompt_cache_key: %s", string(upstreamPayload)) + } + if !bytes.Contains(upstreamPayload, []byte(state.promptCacheKey)) { + t.Fatalf("upstream payload missing confused prompt_cache_key: %s", string(upstreamPayload)) + } + + clientPayload := applyCodexIdentityExposeResponsePayload(upstreamPayload, state) + if bytes.Contains(clientPayload, []byte(state.promptCacheKey)) { + t.Fatalf("client payload still contains confused prompt_cache_key: %s", string(clientPayload)) + } + if !bytes.Contains(clientPayload, []byte(`cache-ws-1`)) { + t.Fatalf("client payload missing original prompt_cache_key: %s", string(clientPayload)) + } + + rawSSE := []byte(`data: {"type":"response.completed","response":{"prompt_cache_key":"cache-ws-1"}}`) + upstreamSSE := applyCodexIdentityConfuseResponsePayload(rawSSE, state) + if bytes.Contains(upstreamSSE, []byte(`cache-ws-1`)) { + t.Fatalf("upstream SSE still contains original prompt_cache_key: %s", string(upstreamSSE)) + } + clientSSE := applyCodexIdentityExposeResponsePayload(upstreamSSE, state) + if !bytes.Contains(clientSSE, []byte(`cache-ws-1`)) || bytes.Contains(clientSSE, []byte(state.promptCacheKey)) { + t.Fatalf("client SSE prompt_cache_key was not restored: %s", string(clientSSE)) + } +} + func TestApplyCodexWebsocketHeadersUsesCanonicalAccountHeader(t *testing.T) { auth := &cliproxyauth.Auth{Provider: "codex", Metadata: map[string]any{"account_id": "acct-1"}} diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go index beda1be85..023b2f0be 100644 --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -93,6 +93,10 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { changes = append(changes, fmt.Sprintf("quota-exceeded.antigravity-credits: %t -> %t", oldCfg.QuotaExceeded.AntigravityCredits, newCfg.QuotaExceeded.AntigravityCredits)) } + if oldCfg.Codex.IdentityConfuse != newCfg.Codex.IdentityConfuse { + changes = append(changes, fmt.Sprintf("codex.identity-confuse: %t -> %t", oldCfg.Codex.IdentityConfuse, newCfg.Codex.IdentityConfuse)) + } + if oldCfg.Routing.Strategy != newCfg.Routing.Strategy { changes = append(changes, fmt.Sprintf("routing.strategy: %s -> %s", oldCfg.Routing.Strategy, newCfg.Routing.Strategy)) } diff --git a/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go b/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go index 22219a8ab..6e1e7a673 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go +++ b/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go @@ -147,9 +147,6 @@ func websocketDownstreamSessionKey(req *http.Request) string { return sessionID } } - if sessionID := strings.TrimSpace(req.Header.Get("Session_id")); sessionID != "" { - return sessionID - } return "" } diff --git a/sdk/cliproxy/auth/selector.go b/sdk/cliproxy/auth/selector.go index 5e23c46f5..3cf11cf14 100644 --- a/sdk/cliproxy/auth/selector.go +++ b/sdk/cliproxy/auth/selector.go @@ -471,12 +471,11 @@ func NewSessionAffinitySelectorWithConfig(cfg SessionAffinityConfig) *SessionAff // Priority for session ID extraction: // 1. metadata.user_id (Claude Code format with _session_{uuid}) - highest priority // 2. X-Session-ID header -// 3. Session_id header (Codex) -// 4. X-Amp-Thread-Id header (Amp CLI thread ID) -// 5. X-Client-Request-Id header (PI) -// 6. metadata.user_id (non-Claude Code format) -// 7. conversation_id field in request body -// 8. Stable hash from first few messages content (fallback) +// 3. X-Amp-Thread-Id header (Amp CLI thread ID) +// 4. X-Client-Request-Id header (PI) +// 5. metadata.user_id (non-Claude Code format) +// 6. conversation_id field in request body +// 7. Stable hash from first few messages content (fallback) // // Note: The cache key includes provider, session ID, and model to handle cases where // a session uses multiple models (e.g., gemini-2.5-pro and gemini-3-flash-preview) @@ -573,12 +572,11 @@ func (s *SessionAffinitySelector) InvalidateAuth(authID string) { // Priority order: // 1. metadata.user_id (Claude Code format with _session_{uuid}) - highest priority for Claude Code clients // 2. X-Session-ID header -// 3. Session_id header (Codex) -// 4. X-Amp-Thread-Id header (Amp CLI thread ID) -// 5. X-Client-Request-Id header (PI) -// 6. metadata.user_id (non-Claude Code format) -// 7. conversation_id field in request body -// 8. Stable hash from first few messages content (fallback) +// 3. X-Amp-Thread-Id header (Amp CLI thread ID) +// 4. X-Client-Request-Id header (PI) +// 5. metadata.user_id (non-Claude Code format) +// 6. conversation_id field in request body +// 7. Stable hash from first few messages content (fallback) func ExtractSessionID(headers http.Header, payload []byte, metadata map[string]any) string { primary, _ := extractSessionIDs(headers, payload, metadata) return primary @@ -614,21 +612,14 @@ func extractSessionIDs(headers http.Header, payload []byte, metadata map[string] } } - // 3. Session_id header (Codex) - if headers != nil { - if sid := headers.Get("Session_id"); sid != "" { - return "codex:" + sid, "" - } - } - - // 4. X-Amp-Thread-Id header (Amp CLI thread ID) + // 3. X-Amp-Thread-Id header (Amp CLI thread ID) if headers != nil { if tid := headers.Get("X-Amp-Thread-Id"); tid != "" { return "amp:" + tid, "" } } - // 5. X-Client-Request-Id header (PI) + // 4. X-Client-Request-Id header (PI) if headers != nil { if rid := headers.Get("X-Client-Request-Id"); rid != "" { return "clientreq:" + rid, "" @@ -639,18 +630,18 @@ func extractSessionIDs(headers http.Header, payload []byte, metadata map[string] return "", "" } - // 6. metadata.user_id (non-Claude Code format) + // 5. metadata.user_id (non-Claude Code format) userID := gjson.GetBytes(payload, "metadata.user_id").String() if userID != "" { return "user:" + userID, "" } - // 7. conversation_id field + // 6. conversation_id field if convID := gjson.GetBytes(payload, "conversation_id").String(); convID != "" { return "conv:" + convID, "" } - // 8. Hash-based fallback from message content + // 7. Hash-based fallback from message content return extractMessageHashIDs(payload) } diff --git a/sdk/cliproxy/auth/selector_test.go b/sdk/cliproxy/auth/selector_test.go index 99231bdf7..0e2eb9521 100644 --- a/sdk/cliproxy/auth/selector_test.go +++ b/sdk/cliproxy/auth/selector_test.go @@ -776,16 +776,15 @@ func TestExtractSessionID_Headers(t *testing.T) { } } -func TestExtractSessionID_CodexSessionIDHeader(t *testing.T) { +func TestExtractSessionID_IgnoresCodexSessionIDHeader(t *testing.T) { t.Parallel() headers := make(http.Header) headers.Set("Session_id", "codex-session-123") got := ExtractSessionID(headers, nil, nil) - want := "codex:codex-session-123" - if got != want { - t.Errorf("ExtractSessionID() with Session_id = %q, want %q", got, want) + if got != "" { + t.Errorf("ExtractSessionID() with deprecated Session_id = %q, want empty", got) } } @@ -802,7 +801,7 @@ func TestExtractSessionID_ClientRequestIDHeader(t *testing.T) { } } -func TestExtractSessionID_CodexSessionIDPriorityOverClientRequestID(t *testing.T) { +func TestExtractSessionID_ClientRequestIDIgnoresDeprecatedCodexSessionID(t *testing.T) { t.Parallel() headers := make(http.Header) @@ -810,9 +809,9 @@ func TestExtractSessionID_CodexSessionIDPriorityOverClientRequestID(t *testing.T headers.Set("Session_id", "codex-session-456") got := ExtractSessionID(headers, nil, nil) - want := "codex:codex-session-456" + want := "clientreq:pi-session-123" if got != want { - t.Errorf("ExtractSessionID() = %q, want %q (Session_id should take priority over X-Client-Request-Id)", got, want) + t.Errorf("ExtractSessionID() = %q, want %q (deprecated Session_id should be ignored)", got, want) } }