History

feat(executor): support `$`-based custom headers from downstream request headers

- Propagate request headers into custom-header resolution for OpenAI/Gemini/XAI/Codex execution and websocket flows. - Resolve auth `header:` values like `$ABC` from incoming request headers at request time and omit headers when no value is available. - Add documentation for the dynamic custom-header behavior in `config.example.yaml`.

Luis Pater committed 2 months ago

Commit e424bfa1 parent f3e836c20 files · +646 −59Browse files

Unified · Split

20 files
  1. config.example.yaml +21 −0
  2. internal/runtime/executor/claude_executor_request.go +1 −1
  3. internal/runtime/executor/codex_executor_execute.go +2 −2
  4. internal/runtime/executor/codex_executor_request.go +10 −5
  5. internal/runtime/executor/codex_executor_stream.go +1 −1
  6. internal/runtime/executor/codex_openai_images.go +2 −2
  7. internal/runtime/executor/codex_websockets_execute.go +1 −1
  8. internal/runtime/executor/codex_websockets_request.go +5 −3
  9. internal/runtime/executor/codex_websockets_stream.go +1 −1
  10. internal/runtime/executor/custom_magic_headers_test.go +408 −0
  11. internal/runtime/executor/gemini_executor.go +7 −7
  12. internal/runtime/executor/gemini_vertex_executor.go +12 −12
  13. internal/runtime/executor/openai_compat_executor.go +4 −4
  14. internal/runtime/executor/xai_executor_execute.go +3 −3
  15. internal/runtime/executor/xai_executor_media.go +3 −3
  16. internal/runtime/executor/xai_executor_request.go +7 −7
  17. internal/runtime/executor/xai_executor_stream.go +1 −1
  18. internal/runtime/executor/xai_websockets_executor.go +3 −3
  19. internal/util/header_helpers.go +38 −3
  20. internal/util/header_helpers_test.go +116 −0
config.example.yaml+21−0
@@ -312,6 +312,9 @@ nonstream-keepalive-interval: 0 · 311 unmodified lines
312312 # base-url: "https://generativelanguage.googleapis.com"
313313 # headers:
314314 # X-Custom-Header: "custom-value"
315+# # Values starting with "$" dynamically copy the header value from downstream client requests.
316+# # If the client did not send the specified header, the header is omitted.
317+# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
315318 # proxy-url: "socks5://proxy.example.com:1080"
316319 # # proxy-url: "direct" # optional: explicit direct connect for this credential
317320 # models:
@@ -346,6 +349,9 @@ nonstream-keepalive-interval: 0 · 28 unmodified lines
346349 # base-url: "https://generativelanguage.googleapis.com"
347350 # headers:
348351 # X-Custom-Header: "custom-value"
352+# # Values starting with "$" dynamically copy the header value from downstream client requests.
353+# # If the client did not send the specified header, the header is omitted.
354+# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
349355 # proxy-url: "socks5://proxy.example.com:1080"
350356 # # proxy-url: "direct" # optional: explicit direct connect for this credential
351357 # models:
@@ -374,6 +380,9 @@ nonstream-keepalive-interval: 0 · 22 unmodified lines
374380 # alpha-search: false # optional: allow this key to serve /v1/alpha/search via base-url + /alpha/search
375381 # headers:
376382 # X-Custom-Header: "custom-value"
383+# # Values starting with "$" dynamically copy the header value from downstream client requests.
384+# # If the client did not send the specified header, the header is omitted.
385+# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
377386 # proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
378387 # # proxy-url: "direct" # optional: explicit direct connect for this credential
379388 # models:
@@ -413,6 +422,9 @@ nonstream-keepalive-interval: 0 · 33 unmodified lines
413422 # websockets: true # optional: use the xAI upstream websocket transport for downstream websocket requests
414423 # headers:
415424 # X-Custom-Header: "custom-value"
425+# # Values starting with "$" dynamically copy the header value from downstream client requests.
426+# # If the client did not send the specified header, the header is omitted.
427+# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
416428 # proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
417429 # # proxy-url: "direct" # optional: explicit direct connect for this credential
418430 # models:
@@ -444,6 +456,9 @@ nonstream-keepalive-interval: 0 · 25 unmodified lines
444456 # base-url: "https://www.example.com" # use the custom claude API endpoint
445457 # headers:
446458 # X-Custom-Header: "custom-value"
459+# # Values starting with "$" dynamically copy the header value from downstream client requests.
460+# # If the client did not send the specified header, the header is omitted.
461+# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
447462 # proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
448463 # # proxy-url: "direct" # optional: explicit direct connect for this credential
449464 # models:
@@ -589,6 +604,9 @@ nonstream-keepalive-interval: 0 · 139 unmodified lines
589604 # action: "stop" # "stop", "stop-and-cooldown", "continue", "continue-and-cooldown"
590605 # headers:
591606 # X-Custom-Header: "custom-value"
607+# # Values starting with "$" dynamically copy the header value from downstream client requests.
608+# # If the client did not send the specified header, the header is omitted.
609+# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
592610 # api-key-entries:
593611 # - api-key: "sk-or-v1-...b780"
594612 # weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000
@@ -630,6 +648,9 @@ nonstream-keepalive-interval: 0 · 35 unmodified lines
630648 # # proxy-url: "direct" # optional: explicit direct connect for this credential
631649 # headers:
632650 # X-Custom-Header: "custom-value"
651+# # Values starting with "$" dynamically copy the header value from downstream client requests.
652+# # If the client did not send the specified header, the header is omitted.
653+# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
633654 # models: # optional: map aliases to upstream model names
634655 # - name: "gemini-2.5-flash" # upstream model name
635656 # alias: "vertex-flash" # client-visible alias
internal/runtime/executor/claude_executor_request.go+1−1
@@ -927,7 +927,7 @@ func applyClaudeHeadersWithNativeProfile( · 926 unmodified lines
927927 if auth != nil {
928928 attrs = auth.Attributes
929929 }
930− util.ApplyCustomHeadersFromAttrs(r, attrs)
930+ util.ApplyCustomHeadersFromAttrs(r, attrs, incomingHeaders)
931931 // Custom credential headers are a configuration escape hatch for third-party
932932 // gateways, so they keep the last word there. On api.anthropic.com they must
933933 // not rewrite the reconstructed identity: an overridden Anthropic-Beta yields a
internal/runtime/executor/codex_executor_execute.go+2−2
@@ -79,7 +79,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re · 78 unmodified lines
7979 if err != nil {
8080 return resp, err
8181 }
82− applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
82+ applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg, opts.Headers)
8383 applyModelHeaderOverrides(httpReq.Header, baseModel)
8484 applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)
8585 var authID, authLabel, authType, authValue string
@@ -239,7 +239,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A · 153 unmodified lines
239239 if err != nil {
240240 return resp, err
241241 }
242− applyCodexHeaders(httpReq, auth, apiKey, false, e.cfg)
242+ applyCodexHeaders(httpReq, auth, apiKey, false, e.cfg, opts.Headers)
243243 applyModelHeaderOverrides(httpReq.Header, baseModel)
244244 applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)
245245 var authID, authLabel, authType, authValue string
internal/runtime/executor/codex_executor_request.go+10−5
@@ -276,9 +276,11 @@ func codexIdentityConfuseUUID(authID string, kind string, value string) string { · 275 unmodified lines
276276 return uuid.NewSHA1(uuid.NameSpaceOID, []byte(name)).String()
277277 }
278278
279−func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config) {
279+func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config, clientHeaders ...http.Header) {
280280 var ginHeaders http.Header
281− if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
281+ if len(clientHeaders) > 0 && clientHeaders[0] != nil {
282+ ginHeaders = clientHeaders[0]
283+ } else if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
282284 ginHeaders = ginCtx.Request.Header
283285 }
284286 applyCodexHeadersFromSources(r, auth, token, stream, cfg, ginHeaders)
@@ -303,9 +305,12 @@ func applyModelHeaderOverrides(headers http.Header, modelName string) { · 18 unmodified lines
303305
304306 // applyCodexDirectImageHeaders sets Codex upstream headers for direct /images/* calls.
305307 // Downstream client User-Agent values are not forwarded to reduce Cloudflare 1010 blocks.
306−func applyCodexDirectImageHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config) {
308+func applyCodexDirectImageHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config, clientHeaders ...http.Header) {
307309 var ginHeaders http.Header
308− if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
310+ if len(clientHeaders) > 0 && clientHeaders[0] != nil {
311+ ginHeaders = clientHeaders[0].Clone()
312+ ginHeaders.Del("User-Agent")
313+ } else if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
309314 ginHeaders = ginCtx.Request.Header.Clone()
310315 ginHeaders.Del("User-Agent")
311316 }
@@ -359,7 +364,7 @@ func applyCodexHeadersFromSources(r *http.Request, auth *cliproxyauth.Auth, toke · 47 unmodified lines
359364 if auth != nil {
360365 attrs = auth.Attributes
361366 }
362− util.ApplyCustomHeadersFromAttrs(r, attrs)
367+ util.ApplyCustomHeadersFromAttrs(r, attrs, ginHeaders)
363368 applyCodexCloakingHeaders(r.Header, cfg)
364369 }
365370
internal/runtime/executor/codex_executor_stream.go+1−1
@@ -83,7 +83,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au · 82 unmodified lines
8383 if err != nil {
8484 return nil, err
8585 }
86− applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
86+ applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg, opts.Headers)
8787 applyModelHeaderOverrides(httpReq.Header, baseModel)
8888 applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)
8989 var authID, authLabel, authType, authValue string
internal/runtime/executor/codex_openai_images.go+2−2
@@ -112,7 +112,7 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau · 111 unmodified lines
112112 if errCache != nil {
113113 return resp, errCache
114114 }
115− applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
115+ applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg, opts.Headers)
116116 applyModelHeaderOverrides(httpReq.Header, mainModel)
117117 applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)
118118 recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body)
@@ -209,7 +209,7 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip · 90 unmodified lines
209209 if errCache != nil {
210210 return nil, errCache
211211 }
212− applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)
212+ applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg, opts.Headers)
213213 applyModelHeaderOverrides(httpReq.Header, mainModel)
214214 applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)
215215 recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body)
internal/runtime/executor/codex_websockets_execute.go+1−1
@@ -85,7 +85,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut · 84 unmodified lines
8585 var identityState codexIdentityConfuseState
8686 upstreamBody, identityState := applyCodexIdentityConfuseBody(e.cfg, auth, originalPayloadSource, body)
8787 reporter.SetTranslatedReasoningEffort(clientBody, to.String())
88− wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg)
88+ wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg, opts.Headers)
8989 applyModelHeaderOverrides(wsHeaders, baseModel)
9090 applyCodexIdentityConfuseHeaders(wsHeaders, &identityState)
9191
internal/runtime/executor/codex_websockets_request.go+5−3
@@ -64,7 +64,7 @@ func applyCodexPromptCacheHeadersWithContext(ctx context.Context, from sdktransl · 63 unmodified lines
6464 return rawJSON, headers, nil
6565 }
6666
67−func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *cliproxyauth.Auth, token string, cfg *config.Config) http.Header {
67+func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *cliproxyauth.Auth, token string, cfg *config.Config, clientHeaders ...http.Header) http.Header {
6868 if headers == nil {
6969 headers = http.Header{}
7070 }
@@ -73,7 +73,9 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * · 2 unmodified lines
7373 }
7474
7575 var ginHeaders http.Header
76− if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
76+ if len(clientHeaders) > 0 && clientHeaders[0] != nil {
77+ ginHeaders = clientHeaders[0].Clone()
78+ } else if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
7779 ginHeaders = ginCtx.Request.Header.Clone()
7880 }
7981
@@ -123,7 +125,7 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * · 43 unmodified lines
123125 if auth != nil {
124126 attrs = auth.Attributes
125127 }
126− util.ApplyCustomHeadersFromAttrs(&http.Request{Header: headers}, attrs)
128+ util.ApplyCustomHeadersFromAttrs(&http.Request{Header: headers}, attrs, ginHeaders)
127129 applyCodexCloakingHeaders(headers, cfg)
128130
129131 return headers
internal/runtime/executor/codex_websockets_stream.go+1−1
@@ -82,7 +82,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr · 81 unmodified lines
8282 var identityState codexIdentityConfuseState
8383 upstreamBody, identityState := applyCodexIdentityConfuseBody(e.cfg, auth, originalPayloadSource, body)
8484 reporter.SetTranslatedReasoningEffort(clientBody, to.String())
85− wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg)
85+ wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg, opts.Headers)
8686 applyModelHeaderOverrides(wsHeaders, baseModel)
8787 applyCodexIdentityConfuseHeaders(wsHeaders, &identityState)
8888
internal/runtime/executor/custom_magic_headers_test.go+408−0
@@ -0,0 +1,408 @@
1+package executor
2+
3+import (
4+ "context"
5+ "io"
6+ "net/http"
7+ "net/http/httptest"
8+ "testing"
9+
10+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
11+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
12+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
13+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
14+)
15+
16+func TestCustomMagicHeaders_OpenAICompat(t *testing.T) {
17+ var gotHeaders http.Header
18+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+ gotHeaders = r.Header.Clone()
20+ w.Header().Set("Content-Type", "application/json")
21+ _, _ = w.Write([]byte(`{"id":"chatcmpl-1","choices":[{"message":{"role":"assistant","content":"ok"}}]}`))
22+ }))
23+ defer server.Close()
24+
25+ executor := NewOpenAICompatExecutor("openai-compatibility", &config.Config{
26+ OpenAICompatibility: []config.OpenAICompatibility{{
27+ Name: "compat",
28+ }},
29+ })
30+ auth := &cliproxyauth.Auth{
31+ Provider: "openai-compatibility",
32+ Attributes: map[string]string{
33+ "base_url": server.URL,
34+ "api_key": "test-key",
35+ "header:X-Claude-Code-Session-Id": "$ABC",
36+ "header:X-Forwarded-Session": "$X-Client-Session",
37+ "header:X-Missing": "$NONEXISTENT",
38+ "header:X-Static": "static-value",
39+ },
40+ }
41+
42+ req := cliproxyexecutor.Request{
43+ Model: "gpt-4o",
44+ Payload: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
45+ }
46+ opts := cliproxyexecutor.Options{
47+ SourceFormat: sdktranslator.FormatOpenAI,
48+ Headers: http.Header{
49+ "Abc": []string{"session-abc-value"},
50+ "X-Client-Session": []string{"client-session-uuid-123"},
51+ },
52+ }
53+
54+ _, err := executor.Execute(context.Background(), auth, req, opts)
55+ if err != nil {
56+ t.Fatalf("Execute() error = %v", err)
57+ }
58+
59+ if got := gotHeaders.Get("X-Claude-Code-Session-Id"); got != "session-abc-value" {
60+ t.Errorf("X-Claude-Code-Session-Id = %q, want %q", got, "session-abc-value")
61+ }
62+ if got := gotHeaders.Get("X-Forwarded-Session"); got != "client-session-uuid-123" {
63+ t.Errorf("X-Forwarded-Session = %q, want %q", got, "client-session-uuid-123")
64+ }
65+ if got := gotHeaders.Get("X-Static"); got != "static-value" {
66+ t.Errorf("X-Static = %q, want %q", got, "static-value")
67+ }
68+ if _, exists := gotHeaders["X-Missing"]; exists {
69+ t.Errorf("expected X-Missing to be omitted, got %q", gotHeaders.Get("X-Missing"))
70+ }
71+}
72+
73+func TestCustomMagicHeaders_Gemini(t *testing.T) {
74+ var gotHeaders http.Header
75+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
76+ gotHeaders = r.Header.Clone()
77+ w.Header().Set("Content-Type", "application/json")
78+ _, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"hello"}]}}]}`))
79+ }))
80+ defer server.Close()
81+
82+ executor := NewGeminiExecutor(&config.Config{})
83+ auth := &cliproxyauth.Auth{
84+ Provider: "gemini",
85+ Attributes: map[string]string{
86+ "base_url": server.URL,
87+ "api_key": "gemini-key",
88+ "header:X-Claude-Code-Session-Id": "$ABC",
89+ "header:X-Missing": "$NONEXISTENT",
90+ "header:X-Static": "gemini-static",
91+ },
92+ }
93+
94+ req := cliproxyexecutor.Request{
95+ Model: "gemini-2.5-flash",
96+ Payload: []byte(`{"contents":[{"parts":[{"text":"hi"}]}]}`),
97+ }
98+ opts := cliproxyexecutor.Options{
99+ SourceFormat: sdktranslator.FormatGemini,
100+ Headers: http.Header{
101+ "Abc": []string{"gemini-session-abc"},
102+ },
103+ }
104+
105+ _, err := executor.Execute(context.Background(), auth, req, opts)
106+ if err != nil {
107+ t.Fatalf("Execute() error = %v", err)
108+ }
109+
110+ if got := gotHeaders.Get("X-Claude-Code-Session-Id"); got != "gemini-session-abc" {
111+ t.Errorf("X-Claude-Code-Session-Id = %q, want %q", got, "gemini-session-abc")
112+ }
113+ if got := gotHeaders.Get("X-Static"); got != "gemini-static" {
114+ t.Errorf("X-Static = %q, want %q", got, "gemini-static")
115+ }
116+ if _, exists := gotHeaders["X-Missing"]; exists {
117+ t.Errorf("expected X-Missing to be omitted, got %q", gotHeaders.Get("X-Missing"))
118+ }
119+}
120+
121+func TestCustomMagicHeaders_GeminiInteractions(t *testing.T) {
122+ var gotHeaders http.Header
123+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
124+ gotHeaders = r.Header.Clone()
125+ w.Header().Set("Content-Type", "application/json")
126+ _, _ = w.Write([]byte(`{"id":"interaction_1","status":"completed","outputs":[{"text":"ok"}]}`))
127+ }))
128+ defer server.Close()
129+
130+ executor := NewGeminiExecutor(&config.Config{})
131+ auth := &cliproxyauth.Auth{
132+ Provider: "gemini-interactions",
133+ Attributes: map[string]string{
134+ "base_url": server.URL,
135+ "api_key": "interactions-key",
136+ "header:X-Claude-Code-Session-Id": "$ABC",
137+ "header:X-Missing": "$NONEXISTENT",
138+ "header:X-Static": "interactions-static",
139+ },
140+ }
141+
142+ req := cliproxyexecutor.Request{
143+ Model: "gemini-3.1-flash-lite",
144+ Payload: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
145+ }
146+ opts := cliproxyexecutor.Options{
147+ SourceFormat: sdktranslator.FormatOpenAI,
148+ Headers: http.Header{
149+ "Abc": []string{"interactions-session-123"},
150+ },
151+ }
152+
153+ _, err := executor.Execute(context.Background(), auth, req, opts)
154+ if err != nil {
155+ t.Fatalf("Execute() error = %v", err)
156+ }
157+
158+ if got := gotHeaders.Get("X-Claude-Code-Session-Id"); got != "interactions-session-123" {
159+ t.Errorf("X-Claude-Code-Session-Id = %q, want %q", got, "interactions-session-123")
160+ }
161+ if got := gotHeaders.Get("X-Static"); got != "interactions-static" {
162+ t.Errorf("X-Static = %q, want %q", got, "interactions-static")
163+ }
164+ if _, exists := gotHeaders["X-Missing"]; exists {
165+ t.Errorf("expected X-Missing to be omitted, got %q", gotHeaders.Get("X-Missing"))
166+ }
167+}
168+
169+func TestCustomMagicHeaders_GeminiVertex(t *testing.T) {
170+ var gotHeaders http.Header
171+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
172+ gotHeaders = r.Header.Clone()
173+ w.Header().Set("Content-Type", "application/json")
174+ _, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"vertex-response"}]}}]}`))
175+ }))
176+ defer server.Close()
177+
178+ executor := NewGeminiVertexExecutor(&config.Config{})
179+ auth := &cliproxyauth.Auth{
180+ Provider: "vertex",
181+ Attributes: map[string]string{
182+ "base_url": server.URL,
183+ "api_key": "vertex-api-key",
184+ "header:X-Claude-Code-Session-Id": "$ABC",
185+ "header:X-Missing": "$NONEXISTENT",
186+ },
187+ }
188+
189+ req := cliproxyexecutor.Request{
190+ Model: "gemini-2.5-flash",
191+ Payload: []byte(`{"contents":[{"parts":[{"text":"hi"}]}]}`),
192+ }
193+ opts := cliproxyexecutor.Options{
194+ SourceFormat: sdktranslator.FormatGemini,
195+ Headers: http.Header{
196+ "Abc": []string{"vertex-session-123"},
197+ },
198+ }
199+
200+ _, err := executor.Execute(context.Background(), auth, req, opts)
201+ if err != nil {
202+ t.Fatalf("Execute() error = %v", err)
203+ }
204+
205+ if got := gotHeaders.Get("X-Claude-Code-Session-Id"); got != "vertex-session-123" {
206+ t.Errorf("X-Claude-Code-Session-Id = %q, want %q", got, "vertex-session-123")
207+ }
208+ if _, exists := gotHeaders["X-Missing"]; exists {
209+ t.Errorf("expected X-Missing to be omitted, got %q", gotHeaders.Get("X-Missing"))
210+ }
211+}
212+
213+func TestCustomMagicHeaders_XAI(t *testing.T) {
214+ var gotHeaders http.Header
215+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
216+ gotHeaders = r.Header.Clone()
217+ w.Header().Set("Content-Type", "text/event-stream")
218+ _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"background\":false,\"error\":null,\"output\":[]}}\n\n"))
219+ }))
220+ defer server.Close()
221+
222+ executor := NewXAIExecutor(&config.Config{})
223+ auth := &cliproxyauth.Auth{
224+ Provider: "xai",
225+ Attributes: map[string]string{
226+ "base_url": server.URL,
227+ "api_key": "xai-key",
228+ "header:X-Claude-Code-Session-Id": "$ABC",
229+ "header:X-Missing": "$NONEXISTENT",
230+ },
231+ }
232+
233+ req := cliproxyexecutor.Request{
234+ Model: "grok-2",
235+ Payload: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
236+ }
237+ opts := cliproxyexecutor.Options{
238+ SourceFormat: sdktranslator.FormatOpenAI,
239+ Headers: http.Header{
240+ "ABC": []string{"xai-session-value"},
241+ },
242+ }
243+
244+ _, err := executor.Execute(context.Background(), auth, req, opts)
245+ if err != nil {
246+ t.Fatalf("Execute() error = %v", err)
247+ }
248+
249+ if got := gotHeaders.Get("X-Claude-Code-Session-Id"); got != "xai-session-value" {
250+ t.Errorf("X-Claude-Code-Session-Id = %q, want %q", got, "xai-session-value")
251+ }
252+ if _, exists := gotHeaders["X-Missing"]; exists {
253+ t.Errorf("expected X-Missing to be omitted, got %q", gotHeaders.Get("X-Missing"))
254+ }
255+}
256+
257+func TestCustomMagicHeaders_Claude(t *testing.T) {
258+ var gotHeaders http.Header
259+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
260+ gotHeaders = r.Header.Clone()
261+ w.Header().Set("Content-Type", "application/json")
262+ _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"hi"}]}`))
263+ }))
264+ defer server.Close()
265+
266+ executor := NewClaudeExecutor(&config.Config{})
267+ auth := &cliproxyauth.Auth{
268+ Provider: "claude",
269+ Attributes: map[string]string{
270+ "base_url": server.URL,
271+ "api_key": "sk-ant-test",
272+ "header:X-Claude-Code-Session-Id": "$ABC",
273+ "header:X-Missing": "$NONEXISTENT",
274+ },
275+ }
276+
277+ req := cliproxyexecutor.Request{
278+ Model: "claude-3-7-sonnet-20250219",
279+ Payload: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
280+ }
281+ opts := cliproxyexecutor.Options{
282+ SourceFormat: sdktranslator.FormatClaude,
283+ Headers: http.Header{
284+ "Abc": []string{"claude-session-value"},
285+ },
286+ }
287+
288+ _, err := executor.Execute(context.Background(), auth, req, opts)
289+ if err != nil {
290+ t.Fatalf("Execute() error = %v", err)
291+ }
292+
293+ if got := gotHeaders.Get("X-Claude-Code-Session-Id"); got != "claude-session-value" {
294+ t.Errorf("X-Claude-Code-Session-Id = %q, want %q", got, "claude-session-value")
295+ }
296+ if _, exists := gotHeaders["X-Missing"]; exists {
297+ t.Errorf("expected X-Missing to be omitted, got %q", gotHeaders.Get("X-Missing"))
298+ }
299+}
300+
301+func TestCustomMagicHeaders_OpenAICompat_Stream(t *testing.T) {
302+ var gotHeaders http.Header
303+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
304+ gotHeaders = r.Header.Clone()
305+ w.Header().Set("Content-Type", "text/event-stream")
306+ _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\ndata: [DONE]\n\n"))
307+ }))
308+ defer server.Close()
309+
310+ executor := NewOpenAICompatExecutor("openai-compatibility", &config.Config{
311+ OpenAICompatibility: []config.OpenAICompatibility{{
312+ Name: "compat",
313+ }},
314+ })
315+ auth := &cliproxyauth.Auth{
316+ Provider: "openai-compatibility",
317+ Attributes: map[string]string{
318+ "base_url": server.URL,
319+ "api_key": "test-key",
320+ "header:X-Claude-Code-Session-Id": "$ABC",
321+ "header:X-Empty-Var": "$ ",
322+ "header:X-Only-Dollar": "$",
323+ "header:X-Missing": "$NONEXISTENT",
324+ },
325+ }
326+
327+ req := cliproxyexecutor.Request{
328+ Model: "gpt-4o",
329+ Payload: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
330+ }
331+ opts := cliproxyexecutor.Options{
332+ SourceFormat: sdktranslator.FormatOpenAI,
333+ Stream: true,
334+ Headers: http.Header{
335+ "Abc": []string{"stream-session-abc"},
336+ },
337+ }
338+
339+ result, err := executor.ExecuteStream(context.Background(), auth, req, opts)
340+ if err != nil {
341+ t.Fatalf("ExecuteStream() error = %v", err)
342+ }
343+ for range result.Chunks {
344+ }
345+
346+ if got := gotHeaders.Get("X-Claude-Code-Session-Id"); got != "stream-session-abc" {
347+ t.Errorf("X-Claude-Code-Session-Id = %q, want %q", got, "stream-session-abc")
348+ }
349+ if _, exists := gotHeaders["X-Missing"]; exists {
350+ t.Errorf("expected X-Missing to be omitted, got %q", gotHeaders.Get("X-Missing"))
351+ }
352+ if _, exists := gotHeaders["X-Empty-Var"]; exists {
353+ t.Errorf("expected X-Empty-Var to be omitted, got %q", gotHeaders.Get("X-Empty-Var"))
354+ }
355+ if _, exists := gotHeaders["X-Only-Dollar"]; exists {
356+ t.Errorf("expected X-Only-Dollar to be omitted, got %q", gotHeaders.Get("X-Only-Dollar"))
357+ }
358+}
359+
360+func TestCustomMagicHeaders_Codex(t *testing.T) {
361+ var gotHeaders http.Header
362+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
363+ gotHeaders = r.Header.Clone()
364+ body, _ := io.ReadAll(r.Body)
365+ _ = body
366+ w.Header().Set("Content-Type", "text/event-stream")
367+ _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"background\":false,\"error\":null,\"output\":[]}}\n\n"))
368+ }))
369+ defer server.Close()
370+
371+ executor := NewCodexExecutor(&config.Config{
372+ Codex: config.CodexConfig{
373+ DisableCodexCloaking: true,
374+ },
375+ })
376+ auth := &cliproxyauth.Auth{
377+ Provider: "codex",
378+ Attributes: map[string]string{
379+ "base_url": server.URL,
380+ "api_key": "codex-key",
381+ "header:X-Claude-Code-Session-Id": "$ABC",
382+ "header:X-Missing": "$NONEXISTENT",
383+ },
384+ }
385+
386+ req := cliproxyexecutor.Request{
387+ Model: "gpt-5-codex",
388+ Payload: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
389+ }
390+ opts := cliproxyexecutor.Options{
391+ SourceFormat: sdktranslator.FormatCodex,
392+ Headers: http.Header{
393+ "Abc": []string{"codex-session-value"},
394+ },
395+ }
396+
397+ _, err := executor.Execute(context.Background(), auth, req, opts)
398+ if err != nil {
399+ t.Fatalf("Execute() error = %v", err)
400+ }
401+
402+ if got := gotHeaders.Get("X-Claude-Code-Session-Id"); got != "codex-session-value" {
403+ t.Errorf("X-Claude-Code-Session-Id = %q, want %q", got, "codex-session-value")
404+ }
405+ if _, exists := gotHeaders["X-Missing"]; exists {
406+ t.Errorf("expected X-Missing to be omitted, got %q", gotHeaders.Get("X-Missing"))
407+ }
408+}
internal/runtime/executor/gemini_executor.go+7−7
@@ -183,7 +183,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r · 182 unmodified lines
183183 if apiKey != "" {
184184 httpReq.Header.Set("x-goog-api-key", apiKey)
185185 }
186− applyGeminiHeaders(httpReq, auth)
186+ applyGeminiHeaders(httpReq, auth, opts.Headers)
187187 var authID, authLabel, authType, authValue string
188188 if auth != nil {
189189 authID = auth.ID
@@ -295,7 +295,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A · 105 unmodified lines
295295 if apiKey != "" {
296296 httpReq.Header.Set("x-goog-api-key", apiKey)
297297 }
298− applyGeminiHeaders(httpReq, auth)
298+ applyGeminiHeaders(httpReq, auth, opts.Headers)
299299 var authID, authLabel, authType, authValue string
300300 if auth != nil {
301301 authID = auth.ID
@@ -415,7 +415,7 @@ func (e *GeminiExecutor) executeInteractions(ctx context.Context, auth *cliproxy · 113 unmodified lines
415415 if apiKey != "" {
416416 httpReq.Header.Set("x-goog-api-key", apiKey)
417417 }
418− applyGeminiHeaders(httpReq, auth)
418+ applyGeminiHeaders(httpReq, auth, opts.Headers)
419419 applyGeminiInteractionsRequestHeaders(httpReq, opts.Headers)
420420 applyGeminiInteractionsRevisionHeader(httpReq)
421421
@@ -495,7 +495,7 @@ func (e *GeminiExecutor) executeInteractionsStream(ctx context.Context, auth *cl · 73 unmodified lines
495495 if apiKey != "" {
496496 httpReq.Header.Set("x-goog-api-key", apiKey)
497497 }
498− applyGeminiHeaders(httpReq, auth)
498+ applyGeminiHeaders(httpReq, auth, opts.Headers)
499499 applyGeminiInteractionsRequestHeaders(httpReq, opts.Headers)
500500 applyGeminiInteractionsRevisionHeader(httpReq)
501501
@@ -654,7 +654,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut · 152 unmodified lines
654654 if apiKey != "" {
655655 httpReq.Header.Set("x-goog-api-key", apiKey)
656656 }
657− applyGeminiHeaders(httpReq, auth)
657+ applyGeminiHeaders(httpReq, auth, opts.Headers)
658658 var authID, authLabel, authType, authValue string
659659 if auth != nil {
660660 authID = auth.ID
@@ -896,12 +896,12 @@ func geminiAuthLogFields(auth *cliproxyauth.Auth) (string, string, string, strin · 235 unmodified lines
896896 return auth.ID, auth.Label, authType, authValue
897897 }
898898
899−func applyGeminiHeaders(req *http.Request, auth *cliproxyauth.Auth) {
899+func applyGeminiHeaders(req *http.Request, auth *cliproxyauth.Auth, clientHeaders ...http.Header) {
900900 var attrs map[string]string
901901 if auth != nil {
902902 attrs = auth.Attributes
903903 }
904− util.ApplyCustomHeadersFromAttrs(req, attrs)
904+ util.ApplyCustomHeadersFromAttrs(req, attrs, clientHeaders...)
905905 }
906906
907907 func capGeminiMaxOutputTokens(body []byte, modelName string) []byte {
internal/runtime/executor/gemini_vertex_executor.go+12−12
@@ -369,12 +369,12 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au · 368 unmodified lines
369369 log.Errorf("vertex executor: access token error: %v", errTok)
370370 return resp, statusErr{code: 500, msg: "internal server error"}
371371 }
372− applyGeminiHeaders(httpReq, auth)
372+ applyGeminiHeaders(httpReq, auth, opts.Headers)
373373 var attrs map[string]string
374374 if auth != nil {
375375 attrs = auth.Attributes
376376 }
377− util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
377+ util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
378378
379379 var authID, authLabel, authType, authValue string
380380 if auth != nil {
@@ -497,12 +497,12 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip · 116 unmodified lines
497497 if apiKey != "" {
498498 httpReq.Header.Set("x-goog-api-key", apiKey)
499499 }
500− applyGeminiHeaders(httpReq, auth)
500+ applyGeminiHeaders(httpReq, auth, opts.Headers)
501501 var attrs map[string]string
502502 if auth != nil {
503503 attrs = auth.Attributes
504504 }
505− util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
505+ util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
506506
507507 var authID, authLabel, authType, authValue string
508508 if auth != nil {
@@ -614,12 +614,12 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte · 105 unmodified lines
614614 log.Errorf("vertex executor: access token error: %v", errTok)
615615 return nil, statusErr{code: 500, msg: "internal server error"}
616616 }
617− applyGeminiHeaders(httpReq, auth)
617+ applyGeminiHeaders(httpReq, auth, opts.Headers)
618618 var attrs map[string]string
619619 if auth != nil {
620620 attrs = auth.Attributes
621621 }
622− util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
622+ util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
623623
624624 var authID, authLabel, authType, authValue string
625625 if auth != nil {
@@ -761,12 +761,12 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth · 135 unmodified lines
761761 if apiKey != "" {
762762 httpReq.Header.Set("x-goog-api-key", apiKey)
763763 }
764− applyGeminiHeaders(httpReq, auth)
764+ applyGeminiHeaders(httpReq, auth, opts.Headers)
765765 var attrs map[string]string
766766 if auth != nil {
767767 attrs = auth.Attributes
768768 }
769− util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
769+ util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
770770
771771 var authID, authLabel, authType, authValue string
772772 if auth != nil {
@@ -889,12 +889,12 @@ func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context · 116 unmodified lines
889889 log.Errorf("vertex executor: access token error: %v", errTok)
890890 return cliproxyexecutor.Response{}, statusErr{code: 500, msg: "internal server error"}
891891 }
892− applyGeminiHeaders(httpReq, auth)
892+ applyGeminiHeaders(httpReq, auth, opts.Headers)
893893 var attrs map[string]string
894894 if auth != nil {
895895 attrs = auth.Attributes
896896 }
897− util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
897+ util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
898898
899899 var authID, authLabel, authType, authValue string
900900 if auth != nil {
@@ -980,12 +980,12 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth * · 79 unmodified lines
980980 if apiKey != "" {
981981 httpReq.Header.Set("x-goog-api-key", apiKey)
982982 }
983− applyGeminiHeaders(httpReq, auth)
983+ applyGeminiHeaders(httpReq, auth, opts.Headers)
984984 var attrs map[string]string
985985 if auth != nil {
986986 attrs = auth.Attributes
987987 }
988− util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
988+ util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
989989
990990 var authID, authLabel, authType, authValue string
991991 if auth != nil {
internal/runtime/executor/openai_compat_executor.go+4−4
@@ -157,7 +157,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A · 156 unmodified lines
157157 if auth != nil {
158158 attrs = auth.Attributes
159159 }
160− util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
160+ util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
161161 var authID, authLabel, authType, authValue string
162162 if auth != nil {
163163 authID = auth.ID
@@ -251,7 +251,7 @@ func (e *OpenAICompatExecutor) executeImages(ctx context.Context, auth *cliproxy · 87 unmodified lines
251251 if auth != nil {
252252 attrs = auth.Attributes
253253 }
254− util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
254+ util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
255255 var authID, authLabel, authType, authValue string
256256 if auth != nil {
257257 authID = auth.ID
@@ -369,7 +369,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy · 111 unmodified lines
369369 if auth != nil {
370370 attrs = auth.Attributes
371371 }
372− util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
372+ util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
373373 httpReq.Header.Set("Accept", "text/event-stream")
374374 httpReq.Header.Set("Cache-Control", "no-cache")
375375 var authID, authLabel, authType, authValue string
@@ -608,7 +608,7 @@ func (e *OpenAICompatExecutor) executeImagesStream(ctx context.Context, auth *cl · 232 unmodified lines
608608 if auth != nil {
609609 attrs = auth.Attributes
610610 }
611− util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
611+ util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
612612 var authID, authLabel, authType, authValue string
613613 if auth != nil {
614614 authID = auth.ID
internal/runtime/executor/xai_executor_execute.go+3−3
@@ -23,7 +23,7 @@ func (e *XAIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req · 22 unmodified lines
2323 return e.executeCompact(ctx, auth, req, opts)
2424 }
2525 if endpointPath := xaiImageEndpointPath(opts); endpointPath != "" {
26− return e.executeImages(ctx, auth, req, endpointPath)
26+ return e.executeImages(ctx, auth, req, opts, endpointPath)
2727 }
2828 if xaiIsVideoRequest(opts) {
2929 return e.executeVideos(ctx, auth, req, opts)
@@ -47,7 +47,7 @@ func (e *XAIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req · 17 unmodified lines
4747 if err != nil {
4848 return resp, err
4949 }
50− applyXAIChatHeaders(httpReq, auth, token, true, prepared.sessionID)
50+ applyXAIChatHeaders(httpReq, auth, token, true, prepared.sessionID, opts.Headers)
5151 e.recordXAIRequest(ctx, auth, url, httpReq.Header.Clone(), prepared.body)
5252
5353 httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
@@ -159,7 +159,7 @@ func (e *XAIExecutor) executeCompactRequest(ctx context.Context, auth *cliproxya · 105 unmodified lines
159159 }
160160 // Official API / custom compact endpoints use standard API headers, not CLI
161161 // chat-proxy identity headers (which applyXAIChatHeaders may still attach for OAuth chat).
162− applyXAIHeaders(httpReq, auth, token, false, prepared.sessionID)
162+ applyXAIHeaders(httpReq, auth, token, false, prepared.sessionID, opts.Headers)
163163 e.recordXAIRequest(ctx, auth, requestURL, httpReq.Header.Clone(), prepared.body)
164164
165165 httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
internal/runtime/executor/xai_executor_media.go+3−3
@@ -16,7 +16,7 @@ import ( · 15 unmodified lines
1616 "github.com/tidwall/gjson"
1717 )
1818
19−func (e *XAIExecutor) executeImages(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, endpointPath string) (resp cliproxyexecutor.Response, err error) {
19+func (e *XAIExecutor) executeImages(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, endpointPath string) (resp cliproxyexecutor.Response, err error) {
2020 model := strings.TrimSpace(gjson.GetBytes(req.Payload, "model").String())
2121 if model == "" {
2222 model = strings.TrimSpace(req.Model)
@@ -39,7 +39,7 @@ func (e *XAIExecutor) executeImages(ctx context.Context, auth *cliproxyauth.Auth · 16 unmodified lines
3939 if err != nil {
4040 return resp, err
4141 }
42− applyXAIHeaders(httpReq, auth, token, false, "")
42+ applyXAIHeaders(httpReq, auth, token, false, "", opts.Headers)
4343 e.recordXAIRequest(ctx, auth, url, httpReq.Header.Clone(), payload)
4444
4545 httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
@@ -107,7 +107,7 @@ func (e *XAIExecutor) executeVideos(ctx context.Context, auth *cliproxyauth.Auth · 61 unmodified lines
107107 if err != nil {
108108 return resp, err
109109 }
110− applyXAIHeaders(httpReq, auth, token, false, "")
110+ applyXAIHeaders(httpReq, auth, token, false, "", opts.Headers)
111111 if method == http.MethodPost {
112112 key := xaiMetadataString(opts.Metadata, xaiIdempotencyKeyMetaKey)
113113 if key == "" && opts.Headers != nil {
internal/runtime/executor/xai_executor_request.go+7−7
@@ -276,9 +276,9 @@ func logXAIResolvedBaseURL(ctx context.Context, baseURL string) { · 275 unmodified lines
276276 helps.LogWithRequestID(ctx).Infof("xai: using base_url=%s source=%s", baseURL, xaiBaseURLSource(baseURL))
277277 }
278278
279−func applyXAIHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, sessionID string) {
279+func applyXAIHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, sessionID string, clientHeaders ...http.Header) {
280280 applyXAIDefaultHeaders(r, token, stream, sessionID)
281− applyXAICustomHeaders(r, auth)
281+ applyXAICustomHeaders(r, auth, clientHeaders...)
282282 }
283283
284284 func applyXAIDefaultHeaders(r *http.Request, token string, stream bool, sessionID string) {
@@ -297,12 +297,12 @@ func applyXAIDefaultHeaders(r *http.Request, token string, stream bool, sessionI · 12 unmodified lines
297297 }
298298 }
299299
300−func applyXAICustomHeaders(r *http.Request, auth *cliproxyauth.Auth) {
300+func applyXAICustomHeaders(r *http.Request, auth *cliproxyauth.Auth, clientHeaders ...http.Header) {
301301 var attrs map[string]string
302302 if auth != nil {
303303 attrs = auth.Attributes
304304 }
305− util.ApplyCustomHeadersFromAttrs(r, attrs)
305+ util.ApplyCustomHeadersFromAttrs(r, attrs, clientHeaders...)
306306 }
307307
308308 // applyXAIChatHeaders applies standard xAI headers for non-image/video chat
@@ -310,9 +310,9 @@ func applyXAICustomHeaders(r *http.Request, auth *cliproxyauth.Auth) { · 1 unmodified line
310310 // applyXAIHeaders behavior. CLI chat-proxy identity headers are only attached
311311 // when using_api is false and the resolved chat base URL is the official CLI
312312 // chat-proxy endpoint.
313−func applyXAIChatHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, sessionID string) {
313+func applyXAIChatHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, sessionID string, clientHeaders ...http.Header) {
314314 if xaiUsingAPI(auth) {
315− applyXAIHeaders(r, auth, token, stream, sessionID)
315+ applyXAIHeaders(r, auth, token, stream, sessionID, clientHeaders...)
316316 return
317317 }
318318 applyXAIDefaultHeaders(r, token, stream, sessionID)
@@ -323,7 +323,7 @@ func applyXAIChatHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, · 4 unmodified lines
323323 r.Header.Set(xaiClientIdentifierHeader, xaiClientIdentifierValue)
324324 r.Header.Set(xaiAuthenticateResponseHeader, xaiAuthenticateResponseValue)
325325 }
326− applyXAICustomHeaders(r, auth)
326+ applyXAICustomHeaders(r, auth, clientHeaders...)
327327 }
328328
329329 func xaiResolveComposerSessionID(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, baseModel string) (string, error) {
internal/runtime/executor/xai_executor_stream.go+1−1
@@ -41,7 +41,7 @@ func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth · 40 unmodified lines
4141 if err != nil {
4242 return nil, err
4343 }
44− applyXAIChatHeaders(httpReq, auth, token, true, prepared.sessionID)
44+ applyXAIChatHeaders(httpReq, auth, token, true, prepared.sessionID, opts.Headers)
4545 e.recordXAIRequest(ctx, auth, url, httpReq.Header.Clone(), prepared.body)
4646
4747 httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
internal/runtime/executor/xai_websockets_executor.go+3−3
@@ -507,7 +507,7 @@ func (e *XAIWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *cliprox · 506 unmodified lines
507507 }
508508 reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier())
509509
510− wsHeaders := applyXAIWebsocketHeaders(http.Header{}, auth, token, prepared.sessionID)
510+ wsHeaders := applyXAIWebsocketHeaders(http.Header{}, auth, token, prepared.sessionID, opts.Headers)
511511 wsReqBody := buildXAIWebsocketRequestBody(prepared.body)
512512 requestType := strings.TrimSpace(gjson.GetBytes(req.Payload, "type").String())
513513 transcriptReset := strings.TrimSpace(gjson.GetBytes(wsReqBody, "previous_response_id").String()) == "" &&
@@ -1442,7 +1442,7 @@ func buildXAIResponsesWebsocketURL(httpURL string) (string, error) { · 928 unmodified lines
14421442 return parsed.String(), nil
14431443 }
14441444
1445−func applyXAIWebsocketHeaders(headers http.Header, auth *cliproxyauth.Auth, token string, sessionID string) http.Header {
1445+func applyXAIWebsocketHeaders(headers http.Header, auth *cliproxyauth.Auth, token string, sessionID string, clientHeaders ...http.Header) http.Header {
14461446 if headers == nil {
14471447 headers = http.Header{}
14481448 }
@@ -1457,7 +1457,7 @@ func applyXAIWebsocketHeaders(headers http.Header, auth *cliproxyauth.Auth, toke · 8 unmodified lines
14571457 if auth != nil {
14581458 attrs = auth.Attributes
14591459 }
1460− util.ApplyCustomHeadersFromAttrs(&http.Request{Header: headers}, attrs)
1460+ util.ApplyCustomHeadersFromAttrs(&http.Request{Header: headers}, attrs, clientHeaders...)
14611461 return headers
14621462 }
14631463
internal/util/header_helpers.go+38−3
@@ -3,18 +3,34 @@ package util · 2 unmodified lines
33 import (
44 "net/http"
55 "strings"
6+
7+ "github.com/gin-gonic/gin"
68 )
79
810 // ApplyCustomHeadersFromAttrs applies user-defined headers stored in the provided attributes map.
911 // Custom headers override built-in defaults when conflicts occur.
10−func ApplyCustomHeadersFromAttrs(r *http.Request, attrs map[string]string) {
12+// If clientHeaders is provided (or if the request context carries a Gin context), any custom header
13+// whose value starts with "$" (e.g. "$ABC" or "$X-Claude-Code-Session-Id") is dynamically
14+// resolved from the client's request headers. If the client did not provide that header,
15+// the custom header is omitted from the outgoing request.
16+func ApplyCustomHeadersFromAttrs(r *http.Request, attrs map[string]string, clientHeaders ...http.Header) {
1117 if r == nil {
1218 return
1319 }
14− applyCustomHeaders(r, extractCustomHeaders(attrs))
20+ var ch http.Header
21+ if len(clientHeaders) > 0 && clientHeaders[0] != nil {
22+ ch = clientHeaders[0]
23+ } else if r.Context() != nil {
24+ if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
25+ ch = ginCtx.Request.Header
26+ } else if ginCtx, ok := r.Context().(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
27+ ch = ginCtx.Request.Header
28+ }
29+ }
30+ applyCustomHeaders(r, extractCustomHeaders(attrs, ch))
1531 }
1632
17−func extractCustomHeaders(attrs map[string]string) map[string]string {
33+func extractCustomHeaders(attrs map[string]string, clientHeaders http.Header) map[string]string {
1834 if len(attrs) == 0 {
1935 return nil
2036 }
@@ -31,6 +47,25 @@ func extractCustomHeaders(attrs map[string]string) map[string]string { · 10 unmodified lines
3147 if val == "" {
3248 continue
3349 }
50+ if strings.HasPrefix(val, "$") {
51+ varName := strings.TrimSpace(strings.TrimPrefix(val, "$"))
52+ if varName == "" || clientHeaders == nil {
53+ continue
54+ }
55+ clientVal := clientHeaders.Get(varName)
56+ if clientVal == "" {
57+ for ck, cv := range clientHeaders {
58+ if strings.EqualFold(ck, varName) && len(cv) > 0 && cv[0] != "" {
59+ clientVal = cv[0]
60+ break
61+ }
62+ }
63+ }
64+ if clientVal == "" {
65+ continue
66+ }
67+ val = clientVal
68+ }
3469 headers[name] = val
3570 }
3671 if len(headers) == 0 {
internal/util/header_helpers_test.go+116−0
@@ -0,0 +1,116 @@
1+package util
2+
3+import (
4+ "net/http"
5+ "net/http/httptest"
6+ "testing"
7+
8+ "github.com/gin-gonic/gin"
9+)
10+
11+func TestApplyCustomHeadersFromAttrs_StaticHeaders(t *testing.T) {
12+ req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
13+ attrs := map[string]string{
14+ "header:X-Custom-Static": "static-value",
15+ "header:Host": "custom.host.com",
16+ }
17+
18+ ApplyCustomHeadersFromAttrs(req, attrs)
19+
20+ if got := req.Header.Get("X-Custom-Static"); got != "static-value" {
21+ t.Errorf("X-Custom-Static = %q, want %q", got, "static-value")
22+ }
23+ if got := req.Host; got != "custom.host.com" {
24+ t.Errorf("req.Host = %q, want %q", got, "custom.host.com")
25+ }
26+}
27+
28+func TestApplyCustomHeadersFromAttrs_MagicVariable(t *testing.T) {
29+ t.Run("present in clientHeaders sets header", func(t *testing.T) {
30+ req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
31+ attrs := map[string]string{
32+ "header:X-Claude-Code-Session-Id": "$ABC",
33+ "header:X-Target-Session": "$X-Claude-Code-Session-Id",
34+ "header:Static-Header": "static-123",
35+ }
36+ clientHeaders := http.Header{
37+ "Abc": []string{"session-abc-456"},
38+ "X-Claude-Code-Session-Id": []string{"claude-code-uuid-789"},
39+ }
40+
41+ ApplyCustomHeadersFromAttrs(req, attrs, clientHeaders)
42+
43+ if got := req.Header.Get("X-Claude-Code-Session-Id"); got != "session-abc-456" {
44+ t.Errorf("X-Claude-Code-Session-Id = %q, want %q", got, "session-abc-456")
45+ }
46+ if got := req.Header.Get("X-Target-Session"); got != "claude-code-uuid-789" {
47+ t.Errorf("X-Target-Session = %q, want %q", got, "claude-code-uuid-789")
48+ }
49+ if got := req.Header.Get("Static-Header"); got != "static-123" {
50+ t.Errorf("Static-Header = %q, want %q", got, "static-123")
51+ }
52+ })
53+
54+ t.Run("absent in clientHeaders does not set header", func(t *testing.T) {
55+ req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
56+ attrs := map[string]string{
57+ "header:X-Claude-Code-Session-Id": "$ABC",
58+ "header:X-Other": "$NONEXISTENT",
59+ "header:Static-Header": "static-123",
60+ }
61+ clientHeaders := http.Header{
62+ "Other-Header": []string{"some-value"},
63+ }
64+
65+ ApplyCustomHeadersFromAttrs(req, attrs, clientHeaders)
66+
67+ if _, exists := req.Header["X-Claude-Code-Session-Id"]; exists {
68+ t.Errorf("expected X-Claude-Code-Session-Id to be omitted when $ABC is absent in clientHeaders, got %q", req.Header.Get("X-Claude-Code-Session-Id"))
69+ }
70+ if _, exists := req.Header["X-Other"]; exists {
71+ t.Errorf("expected X-Other to be omitted when $NONEXISTENT is absent in clientHeaders, got %q", req.Header.Get("X-Other"))
72+ }
73+ if got := req.Header.Get("Static-Header"); got != "static-123" {
74+ t.Errorf("Static-Header = %q, want %q", got, "static-123")
75+ }
76+ })
77+
78+ t.Run("nil clientHeaders does not set variable headers", func(t *testing.T) {
79+ req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
80+ attrs := map[string]string{
81+ "header:X-Claude-Code-Session-Id": "$ABC",
82+ "header:Static-Header": "static-123",
83+ }
84+
85+ ApplyCustomHeadersFromAttrs(req, attrs)
86+
87+ if _, exists := req.Header["X-Claude-Code-Session-Id"]; exists {
88+ t.Errorf("expected X-Claude-Code-Session-Id to be omitted with nil clientHeaders, got %q", req.Header.Get("X-Claude-Code-Session-Id"))
89+ }
90+ if got := req.Header.Get("Static-Header"); got != "static-123" {
91+ t.Errorf("Static-Header = %q, want %q", got, "static-123")
92+ }
93+ })
94+
95+ t.Run("fallback to gin context in request context", func(t *testing.T) {
96+ gin.SetMode(gin.TestMode)
97+ w := httptest.NewRecorder()
98+ ginCtx, _ := gin.CreateTestContext(w)
99+ ginReq := httptest.NewRequest(http.MethodPost, "/", nil)
100+ ginReq.Header.Set("ABC", "from-gin-ctx-123")
101+ ginCtx.Request = ginReq
102+
103+ req := httptest.NewRequest(http.MethodPost, "https://api.example.com", nil)
104+ req = req.WithContext(ginCtx)
105+
106+ attrs := map[string]string{
107+ "header:X-Claude-Code-Session-Id": "$ABC",
108+ }
109+
110+ ApplyCustomHeadersFromAttrs(req, attrs)
111+
112+ if got := req.Header.Get("X-Claude-Code-Session-Id"); got != "from-gin-ctx-123" {
113+ t.Errorf("X-Claude-Code-Session-Id = %q, want %q", got, "from-gin-ctx-123")
114+ }
115+ })
116+}