mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-06-01 12:22:31 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user