Skip to content

Commit 05ebb10

Browse files
committed
Merge branch 'main' into feat/copilot
2 parents e4a6b8c + 0f24caf commit 05ebb10

13 files changed

Lines changed: 470 additions & 99 deletions

config.example.yaml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,21 @@ routing:
119119
strategy: "round-robin" # round-robin (default), fill-first
120120
# Enable universal session-sticky routing for all clients.
121121
# Session IDs are extracted from: metadata.user_id (Claude Code session format),
122-
# X-Session-ID, Session_id (Codex), X-Amp-Thread-Id (Amp CLI),
122+
# X-Session-ID, X-Amp-Thread-Id (Amp CLI),
123123
# X-Client-Request-Id (PI), conversation_id, or first few messages hash.
124124
# Automatic failover is always enabled when bound auth becomes unavailable.
125125
session-affinity: false # default: false
126126
# How long session-to-auth bindings are retained. Default: 1h
127127
session-affinity-ttl: "1h"
128128

129+
# Codex provider behavior.
130+
codex:
131+
# When true, and routing.strategy is fill-first or routing.session-affinity is true,
132+
# remap Codex prompt_cache_key and installation identity per selected auth.
133+
# Some superstitious users believe request tracking identifiers can be used
134+
# as evidence for TOS enforcement bans; this option only satisfies those odd concerns.
135+
identity-confuse: false
136+
129137
# When true, enable authentication for the WebSocket API (/v1/ws).
130138
ws-auth: true
131139

internal/config/codex_websocket_header_defaults_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,24 @@ codex-header-defaults:
3030
t.Fatalf("BetaFeatures = %q, want %q", got, "feature-a,feature-b")
3131
}
3232
}
33+
34+
func TestLoadConfigOptional_CodexIdentityConfuse(t *testing.T) {
35+
dir := t.TempDir()
36+
configPath := filepath.Join(dir, "config.yaml")
37+
configYAML := []byte(`
38+
codex:
39+
identity-confuse: true
40+
`)
41+
if err := os.WriteFile(configPath, configYAML, 0o600); err != nil {
42+
t.Fatalf("failed to write config: %v", err)
43+
}
44+
45+
cfg, err := LoadConfigOptional(configPath, false)
46+
if err != nil {
47+
t.Fatalf("LoadConfigOptional() error = %v", err)
48+
}
49+
50+
if !cfg.Codex.IdentityConfuse {
51+
t.Fatalf("IdentityConfuse = false, want true")
52+
}
53+
}

internal/config/config.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ type Config struct {
111111
// Codex defines a list of Codex API key configurations as specified in the YAML configuration file.
112112
CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"`
113113

114+
// Codex configures provider-wide Codex request behavior.
115+
Codex CodexConfig `yaml:"codex" json:"codex"`
116+
114117
// CodexHeaderDefaults configures fallback headers for Codex OAuth model requests.
115118
// These are used only when the client does not send its own headers.
116119
CodexHeaderDefaults CodexHeaderDefaults `yaml:"codex-header-defaults" json:"codex-header-defaults"`
@@ -172,6 +175,11 @@ type CodexHeaderDefaults struct {
172175
BetaFeatures string `yaml:"beta-features" json:"beta-features"`
173176
}
174177

178+
// CodexConfig configures provider-wide Codex request behavior.
179+
type CodexConfig struct {
180+
IdentityConfuse bool `yaml:"identity-confuse" json:"identity-confuse"`
181+
}
182+
175183
// TLSConfig holds HTTPS server settings.
176184
type TLSConfig struct {
177185
// Enable toggles HTTPS server mode.
@@ -229,7 +237,7 @@ type RoutingConfig struct {
229237

230238
// SessionAffinity enables universal session-sticky routing for all clients.
231239
// Session IDs are extracted from multiple sources:
232-
// metadata.user_id (Claude Code session format), X-Session-ID, Session_id (Codex),
240+
// metadata.user_id (Claude Code session format), X-Session-ID,
233241
// X-Amp-Thread-Id (Amp CLI thread), X-Client-Request-Id (PI), metadata.user_id,
234242
// conversation_id, or message hash.
235243
// Automatic failover is always enabled when bound auth becomes unavailable.

internal/runtime/executor/codex_executor.go

Lines changed: 134 additions & 28 deletions
Large diffs are not rendered by default.

internal/runtime/executor/codex_executor_cache_test.go

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88

99
"github.com/gin-gonic/gin"
1010
"github.com/google/uuid"
11+
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
12+
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
1113
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
1214
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
1315
"github.com/tidwall/gjson"
@@ -27,7 +29,7 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom
2729
}
2830
url := "https://example.com/responses"
2931

30-
httpReq, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai"), url, req, rawJSON)
32+
httpReq, _, _, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai"), url, nil, req, req.Payload, rawJSON)
3133
if err != nil {
3234
t.Fatalf("cacheHelper error: %v", err)
3335
}
@@ -45,11 +47,11 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom
4547
if gotConversation := httpReq.Header.Get("Conversation_id"); gotConversation != "" {
4648
t.Fatalf("Conversation_id = %q, want empty", gotConversation)
4749
}
48-
if gotSession := httpReq.Header.Get("Session_id"); gotSession != expectedKey {
49-
t.Fatalf("Session_id = %q, want %q", gotSession, expectedKey)
50+
if gotSession := httpReq.Header.Get("Session_id"); gotSession != "" {
51+
t.Fatalf("Session_id = %q, want empty", gotSession)
5052
}
5153

52-
httpReq2, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai"), url, req, rawJSON)
54+
httpReq2, _, _, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai"), url, nil, req, req.Payload, rawJSON)
5355
if err != nil {
5456
t.Fatalf("cacheHelper error (second call): %v", err)
5557
}
@@ -62,3 +64,81 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom
6264
t.Fatalf("prompt_cache_key (second call) = %q, want %q", gotKey2, expectedKey)
6365
}
6466
}
67+
68+
func TestCodexExecutorCacheHelper_IdentityConfuseRemapsBodyAndHeaders(t *testing.T) {
69+
recorder := httptest.NewRecorder()
70+
ginCtx, _ := gin.CreateTestContext(recorder)
71+
ginCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
72+
ginCtx.Request.Header.Set("X-Codex-Turn-Metadata", `{"prompt_cache_key":"cache-1","turn_id":"turn-1"}`)
73+
ginCtx.Request.Header.Set("X-Client-Request-Id", "client-request-1")
74+
75+
ctx := context.WithValue(context.Background(), "gin", ginCtx)
76+
executor := &CodexExecutor{cfg: &config.Config{
77+
Routing: config.RoutingConfig{Strategy: "fill-first"},
78+
Codex: config.CodexConfig{IdentityConfuse: true},
79+
}}
80+
auth := &cliproxyauth.Auth{ID: "auth-1", Provider: "codex"}
81+
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"}}`)
82+
req := cliproxyexecutor.Request{
83+
Model: "gpt-5-codex",
84+
Payload: []byte(`{"model":"gpt-5-codex","prompt_cache_key":"cache-1","client_metadata":{"x-codex-installation-id":"install-1"}}`),
85+
}
86+
url := "https://example.com/responses"
87+
88+
httpReq, body, identityState, err := executor.cacheHelper(ctx, sdktranslator.FromString("openai-response"), url, auth, req, req.Payload, rawJSON)
89+
if err != nil {
90+
t.Fatalf("cacheHelper error: %v", err)
91+
}
92+
applyCodexHeaders(httpReq, auth, "oauth-token", true, executor.cfg)
93+
applyCodexIdentityConfuseHeaders(httpReq.Header, identityState)
94+
95+
expectedPromptCacheKey := codexIdentityConfuseUUID("auth-1", "prompt-cache", "cache-1")
96+
if gotKey := gjson.GetBytes(body, "prompt_cache_key").String(); gotKey != expectedPromptCacheKey {
97+
t.Fatalf("prompt_cache_key = %q, want %q", gotKey, expectedPromptCacheKey)
98+
}
99+
expectedInstallationID := codexIdentityConfuseUUID("auth-1", "installation", "install-1")
100+
if gotID := gjson.GetBytes(body, "client_metadata.x-codex-installation-id").String(); gotID != expectedInstallationID {
101+
t.Fatalf("installation id = %q, want %q", gotID, expectedInstallationID)
102+
}
103+
if gotMetadata := gjson.GetBytes(body, "client_metadata.x-codex-turn-metadata").String(); gotMetadata != `{"prompt_cache_key":"`+expectedPromptCacheKey+`","turn_id":"turn-1"}` {
104+
t.Fatalf("client_metadata.x-codex-turn-metadata = %s", gotMetadata)
105+
}
106+
if gotWindowID := gjson.GetBytes(body, "client_metadata.x-codex-window-id").String(); gotWindowID != expectedPromptCacheKey+":0" {
107+
t.Fatalf("client_metadata.x-codex-window-id = %q, want %q", gotWindowID, expectedPromptCacheKey+":0")
108+
}
109+
for _, headerName := range []string{"Session-Id", "X-Client-Request-Id", "Thread-Id"} {
110+
if gotHeader := httpReq.Header.Get(headerName); gotHeader != expectedPromptCacheKey {
111+
t.Fatalf("%s = %q, want %q", headerName, gotHeader, expectedPromptCacheKey)
112+
}
113+
}
114+
if gotSession := httpReq.Header.Get("Session_id"); gotSession != "" {
115+
t.Fatalf("Session_id = %q, want empty", gotSession)
116+
}
117+
if gotWindow := httpReq.Header.Get("X-Codex-Window-Id"); gotWindow != expectedPromptCacheKey+":0" {
118+
t.Fatalf("X-Codex-Window-Id = %q, want %q", gotWindow, expectedPromptCacheKey+":0")
119+
}
120+
if gotMetadata := httpReq.Header.Get("X-Codex-Turn-Metadata"); gotMetadata != `{"prompt_cache_key":"`+expectedPromptCacheKey+`","turn_id":"turn-1"}` {
121+
t.Fatalf("X-Codex-Turn-Metadata = %s", gotMetadata)
122+
}
123+
}
124+
125+
func TestCodexIdentityConfuseKeepsClientBodySeparateFromUpstreamBody(t *testing.T) {
126+
cfg := &config.Config{
127+
Routing: config.RoutingConfig{Strategy: "fill-first"},
128+
Codex: config.CodexConfig{IdentityConfuse: true},
129+
}
130+
auth := &cliproxyauth.Auth{ID: "auth-1", Provider: "codex"}
131+
clientBody := []byte(`{"model":"gpt-5-codex","prompt_cache_key":"cache-1"}`)
132+
133+
upstreamBody, identityState := applyCodexIdentityConfuseBody(cfg, auth, clientBody, clientBody)
134+
expectedPromptCacheKey := codexIdentityConfuseUUID("auth-1", "prompt-cache", "cache-1")
135+
if identityState.promptCacheKey != expectedPromptCacheKey {
136+
t.Fatalf("identity prompt_cache_key = %q, want %q", identityState.promptCacheKey, expectedPromptCacheKey)
137+
}
138+
if gotKey := gjson.GetBytes(upstreamBody, "prompt_cache_key").String(); gotKey != expectedPromptCacheKey {
139+
t.Fatalf("upstream prompt_cache_key = %q, want %q", gotKey, expectedPromptCacheKey)
140+
}
141+
if gotKey := gjson.GetBytes(clientBody, "prompt_cache_key").String(); gotKey != "cache-1" {
142+
t.Fatalf("client prompt_cache_key = %q, want cache-1", gotKey)
143+
}
144+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package executor
2+
3+
import (
4+
"bytes"
5+
"sync/atomic"
6+
"testing"
7+
8+
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
9+
)
10+
11+
func TestTranslateCodexRequestPairReusesEqualPayload(t *testing.T) {
12+
from := sdktranslator.Format("codex-test-from-equal")
13+
to := sdktranslator.Format("codex-test-to-equal")
14+
var calls int32
15+
sdktranslator.Register(from, to, func(model string, rawJSON []byte, stream bool) []byte {
16+
atomic.AddInt32(&calls, 1)
17+
if model != "test-model" {
18+
t.Errorf("model = %q, want test-model", model)
19+
}
20+
if !stream {
21+
t.Error("stream = false, want true")
22+
}
23+
return append([]byte(nil), rawJSON...)
24+
}, sdktranslator.ResponseTransform{})
25+
26+
payload := []byte(`{"model":"test-model","input":[{"role":"user"}]}`)
27+
originalTranslated, body := translateCodexRequestPair(from, to, "test-model", payload, bytes.Clone(payload), true)
28+
29+
if gotCalls := atomic.LoadInt32(&calls); gotCalls != 1 {
30+
t.Fatalf("TranslateRequest calls = %d, want 1", gotCalls)
31+
}
32+
if !bytes.Equal(originalTranslated, body) {
33+
t.Fatalf("translated payloads differ: original=%s body=%s", originalTranslated, body)
34+
}
35+
}
36+
37+
func TestTranslateCodexRequestPairTranslatesDifferentPayloads(t *testing.T) {
38+
from := sdktranslator.Format("codex-test-from-different")
39+
to := sdktranslator.Format("codex-test-to-different")
40+
var calls int32
41+
sdktranslator.Register(from, to, func(_ string, rawJSON []byte, _ bool) []byte {
42+
atomic.AddInt32(&calls, 1)
43+
return append([]byte(nil), rawJSON...)
44+
}, sdktranslator.ResponseTransform{})
45+
46+
originalPayload := []byte(`{"model":"test-model","input":[{"role":"system"}]}`)
47+
payload := []byte(`{"model":"test-model","input":[{"role":"user"}]}`)
48+
originalTranslated, body := translateCodexRequestPair(from, to, "test-model", originalPayload, payload, false)
49+
50+
if gotCalls := atomic.LoadInt32(&calls); gotCalls != 2 {
51+
t.Fatalf("TranslateRequest calls = %d, want 2", gotCalls)
52+
}
53+
if !bytes.Equal(originalTranslated, originalPayload) {
54+
t.Fatalf("original translated = %s, want %s", originalTranslated, originalPayload)
55+
}
56+
if !bytes.Equal(body, payload) {
57+
t.Fatalf("body = %s, want %s", body, payload)
58+
}
59+
}

internal/runtime/executor/codex_openai_images.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,13 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau
9999
reporter.SetTranslatedReasoningEffort(body, "codex")
100100

101101
url := strings.TrimSuffix(baseURL, "/") + "/responses"
102-
httpReq, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body)
102+
var identityState codexIdentityConfuseState
103+
httpReq, body, identityState, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, auth, req, req.Payload, body)
103104
if errCache != nil {
104105
return resp, errCache
105106
}
106107
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
108+
applyCodexIdentityConfuseHeaders(httpReq.Header, identityState)
107109
recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body)
108110

109111
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
@@ -125,6 +127,7 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau
125127
helps.RecordAPIResponseError(ctx, e.cfg, errRead)
126128
return resp, errRead
127129
}
130+
data = applyCodexIdentityConfuseResponsePayload(data, identityState)
128131
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
129132
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
130133
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
189192
reporter.SetTranslatedReasoningEffort(body, "codex")
190193

191194
url := strings.TrimSuffix(baseURL, "/") + "/responses"
192-
httpReq, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body)
195+
var identityState codexIdentityConfuseState
196+
httpReq, body, identityState, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, auth, req, req.Payload, body)
193197
if errCache != nil {
194198
return nil, errCache
195199
}
196200
applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
201+
applyCodexIdentityConfuseHeaders(httpReq.Header, identityState)
197202
recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body)
198203

199204
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
@@ -213,6 +218,7 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip
213218
helps.RecordAPIResponseError(ctx, e.cfg, errRead)
214219
return nil, errRead
215220
}
221+
data = applyCodexIdentityConfuseResponsePayload(data, identityState)
216222
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
217223
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data))
218224
err = newCodexStatusErr(httpResp.StatusCode, data)
@@ -250,7 +256,7 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip
250256
outputItemsByIndex := make(map[int64][]byte)
251257
var outputItemsFallback [][]byte
252258
for scanner.Scan() {
253-
line := scanner.Bytes()
259+
line := applyCodexIdentityConfuseResponsePayload(scanner.Bytes(), identityState)
254260
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
255261
if !bytes.HasPrefix(line, dataTag) {
256262
continue

0 commit comments

Comments
 (0)