feat(executor): implement identity obfuscation for Codex requests and responses

- Added `applyCodexIdentityConfuse*` functions for remapping request and response payloads and headers to enhance security.
- Updated WebSocket and HTTP logic to handle identity state transformations seamlessly.
- Introduced unit tests to verify remapping and restoration of identity-related fields.
This commit is contained in:
Luis Pater
2026-05-31 22:59:40 +08:00
parent 33983b6f3e
commit 0f24cafbdd
12 changed files with 397 additions and 91 deletions

View File

@@ -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

View File

@@ -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")
}
}

View File

@@ -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.

View File

@@ -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, &param)
clientCompletedData := applyCodexIdentityExposeResponsePayload(completedData, identityState)
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, clientCompletedData, &param)
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, &param)
clientData := applyCodexIdentityExposeResponsePayload(upstreamData, identityState)
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, body, clientData, &param)
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, &param)
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 {

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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, &param)
clientPayload := applyCodexIdentityExposeResponsePayload(payload, identityState)
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, originalPayload, clientBody, clientPayload, &param)
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, &param)
clientPayload := applyCodexIdentityExposeResponsePayload(payload, identityState)
line := encodeCodexWebsocketAsSSE(clientPayload)
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, clientBody, clientBody, line, &param)
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 {

View File

@@ -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"}}

View File

@@ -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))
}

View File

@@ -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 ""
}

View File

@@ -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)
}

View File

@@ -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)
}
}