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
312# base-url: "https://generativelanguage.googleapis.com"312# base-url: "https://generativelanguage.googleapis.com"
313# headers:313# headers:
314# X-Custom-Header: "custom-value"314# 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
315# proxy-url: "socks5://proxy.example.com:1080"318# proxy-url: "socks5://proxy.example.com:1080"
316# # proxy-url: "direct" # optional: explicit direct connect for this credential319# # proxy-url: "direct" # optional: explicit direct connect for this credential
317# models:320# models:
@@ -346,6 +349,9 @@ nonstream-keepalive-interval: 0 · 28 unmodified lines
346# base-url: "https://generativelanguage.googleapis.com"349# base-url: "https://generativelanguage.googleapis.com"
347# headers:350# headers:
348# X-Custom-Header: "custom-value"351# 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
349# proxy-url: "socks5://proxy.example.com:1080"355# proxy-url: "socks5://proxy.example.com:1080"
350# # proxy-url: "direct" # optional: explicit direct connect for this credential356# # proxy-url: "direct" # optional: explicit direct connect for this credential
351# models:357# models:
@@ -374,6 +380,9 @@ nonstream-keepalive-interval: 0 · 22 unmodified lines
374# alpha-search: false # optional: allow this key to serve /v1/alpha/search via base-url + /alpha/search380# alpha-search: false # optional: allow this key to serve /v1/alpha/search via base-url + /alpha/search
375# headers:381# headers:
376# X-Custom-Header: "custom-value"382# 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
377# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override386# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
378# # proxy-url: "direct" # optional: explicit direct connect for this credential387# # proxy-url: "direct" # optional: explicit direct connect for this credential
379# models:388# models:
@@ -413,6 +422,9 @@ nonstream-keepalive-interval: 0 · 33 unmodified lines
413# websockets: true # optional: use the xAI upstream websocket transport for downstream websocket requests422# websockets: true # optional: use the xAI upstream websocket transport for downstream websocket requests
414# headers:423# headers:
415# X-Custom-Header: "custom-value"424# 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
416# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override428# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
417# # proxy-url: "direct" # optional: explicit direct connect for this credential429# # proxy-url: "direct" # optional: explicit direct connect for this credential
418# models:430# models:
@@ -444,6 +456,9 @@ nonstream-keepalive-interval: 0 · 25 unmodified lines
444# base-url: "https://www.example.com" # use the custom claude API endpoint456# base-url: "https://www.example.com" # use the custom claude API endpoint
445# headers:457# headers:
446# X-Custom-Header: "custom-value"458# 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
447# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override462# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
448# # proxy-url: "direct" # optional: explicit direct connect for this credential463# # proxy-url: "direct" # optional: explicit direct connect for this credential
449# models:464# models:
@@ -589,6 +604,9 @@ nonstream-keepalive-interval: 0 · 139 unmodified lines
589# action: "stop" # "stop", "stop-and-cooldown", "continue", "continue-and-cooldown"604# action: "stop" # "stop", "stop-and-cooldown", "continue", "continue-and-cooldown"
590# headers:605# headers:
591# X-Custom-Header: "custom-value"606# 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
592# api-key-entries:610# api-key-entries:
593# - api-key: "sk-or-v1-...b780"611# - api-key: "sk-or-v1-...b780"
594# weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000612# 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
630# # proxy-url: "direct" # optional: explicit direct connect for this credential648# # proxy-url: "direct" # optional: explicit direct connect for this credential
631# headers:649# headers:
632# X-Custom-Header: "custom-value"650# 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
633# models: # optional: map aliases to upstream model names654# models: # optional: map aliases to upstream model names
634# - name: "gemini-2.5-flash" # upstream model name655# - name: "gemini-2.5-flash" # upstream model name
635# alias: "vertex-flash" # client-visible alias656# alias: "vertex-flash" # client-visible alias
internal/runtime/executor/claude_executor_request.go+1−1
@@ -927,7 +927,7 @@ func applyClaudeHeadersWithNativeProfile( · 926 unmodified lines
927 if auth != nil {927 if auth != nil {
928 attrs = auth.Attributes928 attrs = auth.Attributes
929 }929 }
930 util.ApplyCustomHeadersFromAttrs(r, attrs)930 util.ApplyCustomHeadersFromAttrs(r, attrs, incomingHeaders)
931 // Custom credential headers are a configuration escape hatch for third-party931 // Custom credential headers are a configuration escape hatch for third-party
932 // gateways, so they keep the last word there. On api.anthropic.com they must932 // gateways, so they keep the last word there. On api.anthropic.com they must
933 // not rewrite the reconstructed identity: an overridden Anthropic-Beta yields a933 // 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
79 if err != nil {79 if err != nil {
80 return resp, err80 return resp, err
81 }81 }
82 applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)82 applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg, opts.Headers)
83 applyModelHeaderOverrides(httpReq.Header, baseModel)83 applyModelHeaderOverrides(httpReq.Header, baseModel)
84 applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)84 applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)
85 var authID, authLabel, authType, authValue string85 var authID, authLabel, authType, authValue string
@@ -239,7 +239,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A · 153 unmodified lines
239 if err != nil {239 if err != nil {
240 return resp, err240 return resp, err
241 }241 }
242 applyCodexHeaders(httpReq, auth, apiKey, false, e.cfg)242 applyCodexHeaders(httpReq, auth, apiKey, false, e.cfg, opts.Headers)
243 applyModelHeaderOverrides(httpReq.Header, baseModel)243 applyModelHeaderOverrides(httpReq.Header, baseModel)
244 applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)244 applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)
245 var authID, authLabel, authType, authValue string245 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
276 return uuid.NewSHA1(uuid.NameSpaceOID, []byte(name)).String()276 return uuid.NewSHA1(uuid.NameSpaceOID, []byte(name)).String()
277}277}
278 278
279func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config) {279func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config, clientHeaders ...http.Header) {
280 var ginHeaders http.Header280 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 {
282 ginHeaders = ginCtx.Request.Header284 ginHeaders = ginCtx.Request.Header
283 }285 }
284 applyCodexHeadersFromSources(r, auth, token, stream, cfg, ginHeaders)286 applyCodexHeadersFromSources(r, auth, token, stream, cfg, ginHeaders)
@@ -303,9 +305,12 @@ func applyModelHeaderOverrides(headers http.Header, modelName string) { · 18 unmodified lines
303 305
304// applyCodexDirectImageHeaders sets Codex upstream headers for direct /images/* calls.306// applyCodexDirectImageHeaders sets Codex upstream headers for direct /images/* calls.
305// Downstream client User-Agent values are not forwarded to reduce Cloudflare 1010 blocks.307// Downstream client User-Agent values are not forwarded to reduce Cloudflare 1010 blocks.
306func applyCodexDirectImageHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config) {308func applyCodexDirectImageHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config, clientHeaders ...http.Header) {
307 var ginHeaders http.Header309 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 {
309 ginHeaders = ginCtx.Request.Header.Clone()314 ginHeaders = ginCtx.Request.Header.Clone()
310 ginHeaders.Del("User-Agent")315 ginHeaders.Del("User-Agent")
311 }316 }
@@ -359,7 +364,7 @@ func applyCodexHeadersFromSources(r *http.Request, auth *cliproxyauth.Auth, toke · 47 unmodified lines
359 if auth != nil {364 if auth != nil {
360 attrs = auth.Attributes365 attrs = auth.Attributes
361 }366 }
362 util.ApplyCustomHeadersFromAttrs(r, attrs)367 util.ApplyCustomHeadersFromAttrs(r, attrs, ginHeaders)
363 applyCodexCloakingHeaders(r.Header, cfg)368 applyCodexCloakingHeaders(r.Header, cfg)
364}369}
365 370
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
83 if err != nil {83 if err != nil {
84 return nil, err84 return nil, err
85 }85 }
86 applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)86 applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg, opts.Headers)
87 applyModelHeaderOverrides(httpReq.Header, baseModel)87 applyModelHeaderOverrides(httpReq.Header, baseModel)
88 applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)88 applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)
89 var authID, authLabel, authType, authValue string89 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
112 if errCache != nil {112 if errCache != nil {
113 return resp, errCache113 return resp, errCache
114 }114 }
115 applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)115 applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg, opts.Headers)
116 applyModelHeaderOverrides(httpReq.Header, mainModel)116 applyModelHeaderOverrides(httpReq.Header, mainModel)
117 applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)117 applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)
118 recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body)118 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
209 if errCache != nil {209 if errCache != nil {
210 return nil, errCache210 return nil, errCache
211 }211 }
212 applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg)212 applyCodexHeaders(httpReq, auth, apiKey, true, e.cfg, opts.Headers)
213 applyModelHeaderOverrides(httpReq.Header, mainModel)213 applyModelHeaderOverrides(httpReq.Header, mainModel)
214 applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)214 applyCodexIdentityConfuseHeaders(httpReq.Header, &identityState)
215 recordCodexOpenAIImageRequest(ctx, e.cfg, e.Identifier(), auth, url, httpReq.Header.Clone(), body)215 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
85 var identityState codexIdentityConfuseState85 var identityState codexIdentityConfuseState
86 upstreamBody, identityState := applyCodexIdentityConfuseBody(e.cfg, auth, originalPayloadSource, body)86 upstreamBody, identityState := applyCodexIdentityConfuseBody(e.cfg, auth, originalPayloadSource, body)
87 reporter.SetTranslatedReasoningEffort(clientBody, to.String())87 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)
89 applyModelHeaderOverrides(wsHeaders, baseModel)89 applyModelHeaderOverrides(wsHeaders, baseModel)
90 applyCodexIdentityConfuseHeaders(wsHeaders, &identityState)90 applyCodexIdentityConfuseHeaders(wsHeaders, &identityState)
91 91
internal/runtime/executor/codex_websockets_request.go+5−3
@@ -64,7 +64,7 @@ func applyCodexPromptCacheHeadersWithContext(ctx context.Context, from sdktransl · 63 unmodified lines
64 return rawJSON, headers, nil64 return rawJSON, headers, nil
65}65}
66 66
67func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *cliproxyauth.Auth, token string, cfg *config.Config) http.Header {67func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *cliproxyauth.Auth, token string, cfg *config.Config, clientHeaders ...http.Header) http.Header {
68 if headers == nil {68 if headers == nil {
69 headers = http.Header{}69 headers = http.Header{}
70 }70 }
@@ -73,7 +73,9 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * · 2 unmodified lines
73 }73 }
74 74
75 var ginHeaders http.Header75 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 {
77 ginHeaders = ginCtx.Request.Header.Clone()79 ginHeaders = ginCtx.Request.Header.Clone()
78 }80 }
79 81
@@ -123,7 +125,7 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * · 43 unmodified lines
123 if auth != nil {125 if auth != nil {
124 attrs = auth.Attributes126 attrs = auth.Attributes
125 }127 }
126 util.ApplyCustomHeadersFromAttrs(&http.Request{Header: headers}, attrs)128 util.ApplyCustomHeadersFromAttrs(&http.Request{Header: headers}, attrs, ginHeaders)
127 applyCodexCloakingHeaders(headers, cfg)129 applyCodexCloakingHeaders(headers, cfg)
128 130
129 return headers131 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
82 var identityState codexIdentityConfuseState82 var identityState codexIdentityConfuseState
83 upstreamBody, identityState := applyCodexIdentityConfuseBody(e.cfg, auth, originalPayloadSource, body)83 upstreamBody, identityState := applyCodexIdentityConfuseBody(e.cfg, auth, originalPayloadSource, body)
84 reporter.SetTranslatedReasoningEffort(clientBody, to.String())84 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)
86 applyModelHeaderOverrides(wsHeaders, baseModel)86 applyModelHeaderOverrides(wsHeaders, baseModel)
87 applyCodexIdentityConfuseHeaders(wsHeaders, &identityState)87 applyCodexIdentityConfuseHeaders(wsHeaders, &identityState)
88 88
internal/runtime/executor/custom_magic_headers_test.go+408−0
@@ -0,0 +1,408 @@
1package executor
2
3import (
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
16func 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
73func 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
121func 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
169func 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
213func 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
257func 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
301func 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
360func 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
183 if apiKey != "" {183 if apiKey != "" {
184 httpReq.Header.Set("x-goog-api-key", apiKey)184 httpReq.Header.Set("x-goog-api-key", apiKey)
185 }185 }
186 applyGeminiHeaders(httpReq, auth)186 applyGeminiHeaders(httpReq, auth, opts.Headers)
187 var authID, authLabel, authType, authValue string187 var authID, authLabel, authType, authValue string
188 if auth != nil {188 if auth != nil {
189 authID = auth.ID189 authID = auth.ID
@@ -295,7 +295,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A · 105 unmodified lines
295 if apiKey != "" {295 if apiKey != "" {
296 httpReq.Header.Set("x-goog-api-key", apiKey)296 httpReq.Header.Set("x-goog-api-key", apiKey)
297 }297 }
298 applyGeminiHeaders(httpReq, auth)298 applyGeminiHeaders(httpReq, auth, opts.Headers)
299 var authID, authLabel, authType, authValue string299 var authID, authLabel, authType, authValue string
300 if auth != nil {300 if auth != nil {
301 authID = auth.ID301 authID = auth.ID
@@ -415,7 +415,7 @@ func (e *GeminiExecutor) executeInteractions(ctx context.Context, auth *cliproxy · 113 unmodified lines
415 if apiKey != "" {415 if apiKey != "" {
416 httpReq.Header.Set("x-goog-api-key", apiKey)416 httpReq.Header.Set("x-goog-api-key", apiKey)
417 }417 }
418 applyGeminiHeaders(httpReq, auth)418 applyGeminiHeaders(httpReq, auth, opts.Headers)
419 applyGeminiInteractionsRequestHeaders(httpReq, opts.Headers)419 applyGeminiInteractionsRequestHeaders(httpReq, opts.Headers)
420 applyGeminiInteractionsRevisionHeader(httpReq)420 applyGeminiInteractionsRevisionHeader(httpReq)
421 421
@@ -495,7 +495,7 @@ func (e *GeminiExecutor) executeInteractionsStream(ctx context.Context, auth *cl · 73 unmodified lines
495 if apiKey != "" {495 if apiKey != "" {
496 httpReq.Header.Set("x-goog-api-key", apiKey)496 httpReq.Header.Set("x-goog-api-key", apiKey)
497 }497 }
498 applyGeminiHeaders(httpReq, auth)498 applyGeminiHeaders(httpReq, auth, opts.Headers)
499 applyGeminiInteractionsRequestHeaders(httpReq, opts.Headers)499 applyGeminiInteractionsRequestHeaders(httpReq, opts.Headers)
500 applyGeminiInteractionsRevisionHeader(httpReq)500 applyGeminiInteractionsRevisionHeader(httpReq)
501 501
@@ -654,7 +654,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut · 152 unmodified lines
654 if apiKey != "" {654 if apiKey != "" {
655 httpReq.Header.Set("x-goog-api-key", apiKey)655 httpReq.Header.Set("x-goog-api-key", apiKey)
656 }656 }
657 applyGeminiHeaders(httpReq, auth)657 applyGeminiHeaders(httpReq, auth, opts.Headers)
658 var authID, authLabel, authType, authValue string658 var authID, authLabel, authType, authValue string
659 if auth != nil {659 if auth != nil {
660 authID = auth.ID660 authID = auth.ID
@@ -896,12 +896,12 @@ func geminiAuthLogFields(auth *cliproxyauth.Auth) (string, string, string, strin · 235 unmodified lines
896 return auth.ID, auth.Label, authType, authValue896 return auth.ID, auth.Label, authType, authValue
897}897}
898 898
899func applyGeminiHeaders(req *http.Request, auth *cliproxyauth.Auth) {899func applyGeminiHeaders(req *http.Request, auth *cliproxyauth.Auth, clientHeaders ...http.Header) {
900 var attrs map[string]string900 var attrs map[string]string
901 if auth != nil {901 if auth != nil {
902 attrs = auth.Attributes902 attrs = auth.Attributes
903 }903 }
904 util.ApplyCustomHeadersFromAttrs(req, attrs)904 util.ApplyCustomHeadersFromAttrs(req, attrs, clientHeaders...)
905}905}
906 906
907func capGeminiMaxOutputTokens(body []byte, modelName string) []byte {907func 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
369 log.Errorf("vertex executor: access token error: %v", errTok)369 log.Errorf("vertex executor: access token error: %v", errTok)
370 return resp, statusErr{code: 500, msg: "internal server error"}370 return resp, statusErr{code: 500, msg: "internal server error"}
371 }371 }
372 applyGeminiHeaders(httpReq, auth)372 applyGeminiHeaders(httpReq, auth, opts.Headers)
373 var attrs map[string]string373 var attrs map[string]string
374 if auth != nil {374 if auth != nil {
375 attrs = auth.Attributes375 attrs = auth.Attributes
376 }376 }
377 util.ApplyCustomHeadersFromAttrs(httpReq, attrs)377 util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
378 378
379 var authID, authLabel, authType, authValue string379 var authID, authLabel, authType, authValue string
380 if auth != nil {380 if auth != nil {
@@ -497,12 +497,12 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip · 116 unmodified lines
497 if apiKey != "" {497 if apiKey != "" {
498 httpReq.Header.Set("x-goog-api-key", apiKey)498 httpReq.Header.Set("x-goog-api-key", apiKey)
499 }499 }
500 applyGeminiHeaders(httpReq, auth)500 applyGeminiHeaders(httpReq, auth, opts.Headers)
501 var attrs map[string]string501 var attrs map[string]string
502 if auth != nil {502 if auth != nil {
503 attrs = auth.Attributes503 attrs = auth.Attributes
504 }504 }
505 util.ApplyCustomHeadersFromAttrs(httpReq, attrs)505 util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
506 506
507 var authID, authLabel, authType, authValue string507 var authID, authLabel, authType, authValue string
508 if auth != nil {508 if auth != nil {
@@ -614,12 +614,12 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte · 105 unmodified lines
614 log.Errorf("vertex executor: access token error: %v", errTok)614 log.Errorf("vertex executor: access token error: %v", errTok)
615 return nil, statusErr{code: 500, msg: "internal server error"}615 return nil, statusErr{code: 500, msg: "internal server error"}
616 }616 }
617 applyGeminiHeaders(httpReq, auth)617 applyGeminiHeaders(httpReq, auth, opts.Headers)
618 var attrs map[string]string618 var attrs map[string]string
619 if auth != nil {619 if auth != nil {
620 attrs = auth.Attributes620 attrs = auth.Attributes
621 }621 }
622 util.ApplyCustomHeadersFromAttrs(httpReq, attrs)622 util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
623 623
624 var authID, authLabel, authType, authValue string624 var authID, authLabel, authType, authValue string
625 if auth != nil {625 if auth != nil {
@@ -761,12 +761,12 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth · 135 unmodified lines
761 if apiKey != "" {761 if apiKey != "" {
762 httpReq.Header.Set("x-goog-api-key", apiKey)762 httpReq.Header.Set("x-goog-api-key", apiKey)
763 }763 }
764 applyGeminiHeaders(httpReq, auth)764 applyGeminiHeaders(httpReq, auth, opts.Headers)
765 var attrs map[string]string765 var attrs map[string]string
766 if auth != nil {766 if auth != nil {
767 attrs = auth.Attributes767 attrs = auth.Attributes
768 }768 }
769 util.ApplyCustomHeadersFromAttrs(httpReq, attrs)769 util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
770 770
771 var authID, authLabel, authType, authValue string771 var authID, authLabel, authType, authValue string
772 if auth != nil {772 if auth != nil {
@@ -889,12 +889,12 @@ func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context · 116 unmodified lines
889 log.Errorf("vertex executor: access token error: %v", errTok)889 log.Errorf("vertex executor: access token error: %v", errTok)
890 return cliproxyexecutor.Response{}, statusErr{code: 500, msg: "internal server error"}890 return cliproxyexecutor.Response{}, statusErr{code: 500, msg: "internal server error"}
891 }891 }
892 applyGeminiHeaders(httpReq, auth)892 applyGeminiHeaders(httpReq, auth, opts.Headers)
893 var attrs map[string]string893 var attrs map[string]string
894 if auth != nil {894 if auth != nil {
895 attrs = auth.Attributes895 attrs = auth.Attributes
896 }896 }
897 util.ApplyCustomHeadersFromAttrs(httpReq, attrs)897 util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
898 898
899 var authID, authLabel, authType, authValue string899 var authID, authLabel, authType, authValue string
900 if auth != nil {900 if auth != nil {
@@ -980,12 +980,12 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth * · 79 unmodified lines
980 if apiKey != "" {980 if apiKey != "" {
981 httpReq.Header.Set("x-goog-api-key", apiKey)981 httpReq.Header.Set("x-goog-api-key", apiKey)
982 }982 }
983 applyGeminiHeaders(httpReq, auth)983 applyGeminiHeaders(httpReq, auth, opts.Headers)
984 var attrs map[string]string984 var attrs map[string]string
985 if auth != nil {985 if auth != nil {
986 attrs = auth.Attributes986 attrs = auth.Attributes
987 }987 }
988 util.ApplyCustomHeadersFromAttrs(httpReq, attrs)988 util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
989 989
990 var authID, authLabel, authType, authValue string990 var authID, authLabel, authType, authValue string
991 if auth != nil {991 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
157 if auth != nil {157 if auth != nil {
158 attrs = auth.Attributes158 attrs = auth.Attributes
159 }159 }
160 util.ApplyCustomHeadersFromAttrs(httpReq, attrs)160 util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
161 var authID, authLabel, authType, authValue string161 var authID, authLabel, authType, authValue string
162 if auth != nil {162 if auth != nil {
163 authID = auth.ID163 authID = auth.ID
@@ -251,7 +251,7 @@ func (e *OpenAICompatExecutor) executeImages(ctx context.Context, auth *cliproxy · 87 unmodified lines
251 if auth != nil {251 if auth != nil {
252 attrs = auth.Attributes252 attrs = auth.Attributes
253 }253 }
254 util.ApplyCustomHeadersFromAttrs(httpReq, attrs)254 util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
255 var authID, authLabel, authType, authValue string255 var authID, authLabel, authType, authValue string
256 if auth != nil {256 if auth != nil {
257 authID = auth.ID257 authID = auth.ID
@@ -369,7 +369,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy · 111 unmodified lines
369 if auth != nil {369 if auth != nil {
370 attrs = auth.Attributes370 attrs = auth.Attributes
371 }371 }
372 util.ApplyCustomHeadersFromAttrs(httpReq, attrs)372 util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
373 httpReq.Header.Set("Accept", "text/event-stream")373 httpReq.Header.Set("Accept", "text/event-stream")
374 httpReq.Header.Set("Cache-Control", "no-cache")374 httpReq.Header.Set("Cache-Control", "no-cache")
375 var authID, authLabel, authType, authValue string375 var authID, authLabel, authType, authValue string
@@ -608,7 +608,7 @@ func (e *OpenAICompatExecutor) executeImagesStream(ctx context.Context, auth *cl · 232 unmodified lines
608 if auth != nil {608 if auth != nil {
609 attrs = auth.Attributes609 attrs = auth.Attributes
610 }610 }
611 util.ApplyCustomHeadersFromAttrs(httpReq, attrs)611 util.ApplyCustomHeadersFromAttrs(httpReq, attrs, opts.Headers)
612 var authID, authLabel, authType, authValue string612 var authID, authLabel, authType, authValue string
613 if auth != nil {613 if auth != nil {
614 authID = auth.ID614 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
23 return e.executeCompact(ctx, auth, req, opts)23 return e.executeCompact(ctx, auth, req, opts)
24 }24 }
25 if endpointPath := xaiImageEndpointPath(opts); endpointPath != "" {25 if endpointPath := xaiImageEndpointPath(opts); endpointPath != "" {
26 return e.executeImages(ctx, auth, req, endpointPath)26 return e.executeImages(ctx, auth, req, opts, endpointPath)
27 }27 }
28 if xaiIsVideoRequest(opts) {28 if xaiIsVideoRequest(opts) {
29 return e.executeVideos(ctx, auth, req, opts)29 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
47 if err != nil {47 if err != nil {
48 return resp, err48 return resp, err
49 }49 }
50 applyXAIChatHeaders(httpReq, auth, token, true, prepared.sessionID)50 applyXAIChatHeaders(httpReq, auth, token, true, prepared.sessionID, opts.Headers)
51 e.recordXAIRequest(ctx, auth, url, httpReq.Header.Clone(), prepared.body)51 e.recordXAIRequest(ctx, auth, url, httpReq.Header.Clone(), prepared.body)
52 52
53 httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)53 httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
@@ -159,7 +159,7 @@ func (e *XAIExecutor) executeCompactRequest(ctx context.Context, auth *cliproxya · 105 unmodified lines
159 }159 }
160 // Official API / custom compact endpoints use standard API headers, not CLI160 // Official API / custom compact endpoints use standard API headers, not CLI
161 // chat-proxy identity headers (which applyXAIChatHeaders may still attach for OAuth chat).161 // 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)
163 e.recordXAIRequest(ctx, auth, requestURL, httpReq.Header.Clone(), prepared.body)163 e.recordXAIRequest(ctx, auth, requestURL, httpReq.Header.Clone(), prepared.body)
164 164
165 httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)165 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
16 "github.com/tidwall/gjson"16 "github.com/tidwall/gjson"
17)17)
18 18
19func (e *XAIExecutor) executeImages(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, endpointPath string) (resp cliproxyexecutor.Response, err error) {19func (e *XAIExecutor) executeImages(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, endpointPath string) (resp cliproxyexecutor.Response, err error) {
20 model := strings.TrimSpace(gjson.GetBytes(req.Payload, "model").String())20 model := strings.TrimSpace(gjson.GetBytes(req.Payload, "model").String())
21 if model == "" {21 if model == "" {
22 model = strings.TrimSpace(req.Model)22 model = strings.TrimSpace(req.Model)
@@ -39,7 +39,7 @@ func (e *XAIExecutor) executeImages(ctx context.Context, auth *cliproxyauth.Auth · 16 unmodified lines
39 if err != nil {39 if err != nil {
40 return resp, err40 return resp, err
41 }41 }
42 applyXAIHeaders(httpReq, auth, token, false, "")42 applyXAIHeaders(httpReq, auth, token, false, "", opts.Headers)
43 e.recordXAIRequest(ctx, auth, url, httpReq.Header.Clone(), payload)43 e.recordXAIRequest(ctx, auth, url, httpReq.Header.Clone(), payload)
44 44
45 httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)45 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
107 if err != nil {107 if err != nil {
108 return resp, err108 return resp, err
109 }109 }
110 applyXAIHeaders(httpReq, auth, token, false, "")110 applyXAIHeaders(httpReq, auth, token, false, "", opts.Headers)
111 if method == http.MethodPost {111 if method == http.MethodPost {
112 key := xaiMetadataString(opts.Metadata, xaiIdempotencyKeyMetaKey)112 key := xaiMetadataString(opts.Metadata, xaiIdempotencyKeyMetaKey)
113 if key == "" && opts.Headers != nil {113 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
276 helps.LogWithRequestID(ctx).Infof("xai: using base_url=%s source=%s", baseURL, xaiBaseURLSource(baseURL))276 helps.LogWithRequestID(ctx).Infof("xai: using base_url=%s source=%s", baseURL, xaiBaseURLSource(baseURL))
277}277}
278 278
279func applyXAIHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, sessionID string) {279func applyXAIHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, sessionID string, clientHeaders ...http.Header) {
280 applyXAIDefaultHeaders(r, token, stream, sessionID)280 applyXAIDefaultHeaders(r, token, stream, sessionID)
281 applyXAICustomHeaders(r, auth)281 applyXAICustomHeaders(r, auth, clientHeaders...)
282}282}
283 283
284func applyXAIDefaultHeaders(r *http.Request, token string, stream bool, sessionID string) {284func 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
297 }297 }
298}298}
299 299
300func applyXAICustomHeaders(r *http.Request, auth *cliproxyauth.Auth) {300func applyXAICustomHeaders(r *http.Request, auth *cliproxyauth.Auth, clientHeaders ...http.Header) {
301 var attrs map[string]string301 var attrs map[string]string
302 if auth != nil {302 if auth != nil {
303 attrs = auth.Attributes303 attrs = auth.Attributes
304 }304 }
305 util.ApplyCustomHeadersFromAttrs(r, attrs)305 util.ApplyCustomHeadersFromAttrs(r, attrs, clientHeaders...)
306}306}
307 307
308// applyXAIChatHeaders applies standard xAI headers for non-image/video chat308// 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
310// applyXAIHeaders behavior. CLI chat-proxy identity headers are only attached310// applyXAIHeaders behavior. CLI chat-proxy identity headers are only attached
311// when using_api is false and the resolved chat base URL is the official CLI311// when using_api is false and the resolved chat base URL is the official CLI
312// chat-proxy endpoint.312// chat-proxy endpoint.
313func applyXAIChatHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, sessionID string) {313func applyXAIChatHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, sessionID string, clientHeaders ...http.Header) {
314 if xaiUsingAPI(auth) {314 if xaiUsingAPI(auth) {
315 applyXAIHeaders(r, auth, token, stream, sessionID)315 applyXAIHeaders(r, auth, token, stream, sessionID, clientHeaders...)
316 return316 return
317 }317 }
318 applyXAIDefaultHeaders(r, token, stream, sessionID)318 applyXAIDefaultHeaders(r, token, stream, sessionID)
@@ -323,7 +323,7 @@ func applyXAIChatHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, · 4 unmodified lines
323 r.Header.Set(xaiClientIdentifierHeader, xaiClientIdentifierValue)323 r.Header.Set(xaiClientIdentifierHeader, xaiClientIdentifierValue)
324 r.Header.Set(xaiAuthenticateResponseHeader, xaiAuthenticateResponseValue)324 r.Header.Set(xaiAuthenticateResponseHeader, xaiAuthenticateResponseValue)
325 }325 }
326 applyXAICustomHeaders(r, auth)326 applyXAICustomHeaders(r, auth, clientHeaders...)
327}327}
328 328
329func xaiResolveComposerSessionID(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, baseModel string) (string, error) {329func 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
41 if err != nil {41 if err != nil {
42 return nil, err42 return nil, err
43 }43 }
44 applyXAIChatHeaders(httpReq, auth, token, true, prepared.sessionID)44 applyXAIChatHeaders(httpReq, auth, token, true, prepared.sessionID, opts.Headers)
45 e.recordXAIRequest(ctx, auth, url, httpReq.Header.Clone(), prepared.body)45 e.recordXAIRequest(ctx, auth, url, httpReq.Header.Clone(), prepared.body)
46 46
47 httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)47 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
507 }507 }
508 reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier())508 reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier())
509 509
510 wsHeaders := applyXAIWebsocketHeaders(http.Header{}, auth, token, prepared.sessionID)510 wsHeaders := applyXAIWebsocketHeaders(http.Header{}, auth, token, prepared.sessionID, opts.Headers)
511 wsReqBody := buildXAIWebsocketRequestBody(prepared.body)511 wsReqBody := buildXAIWebsocketRequestBody(prepared.body)
512 requestType := strings.TrimSpace(gjson.GetBytes(req.Payload, "type").String())512 requestType := strings.TrimSpace(gjson.GetBytes(req.Payload, "type").String())
513 transcriptReset := strings.TrimSpace(gjson.GetBytes(wsReqBody, "previous_response_id").String()) == "" &&513 transcriptReset := strings.TrimSpace(gjson.GetBytes(wsReqBody, "previous_response_id").String()) == "" &&
@@ -1442,7 +1442,7 @@ func buildXAIResponsesWebsocketURL(httpURL string) (string, error) { · 928 unmodified lines
1442 return parsed.String(), nil1442 return parsed.String(), nil
1443}1443}
1444 1444
1445func applyXAIWebsocketHeaders(headers http.Header, auth *cliproxyauth.Auth, token string, sessionID string) http.Header {1445func applyXAIWebsocketHeaders(headers http.Header, auth *cliproxyauth.Auth, token string, sessionID string, clientHeaders ...http.Header) http.Header {
1446 if headers == nil {1446 if headers == nil {
1447 headers = http.Header{}1447 headers = http.Header{}
1448 }1448 }
@@ -1457,7 +1457,7 @@ func applyXAIWebsocketHeaders(headers http.Header, auth *cliproxyauth.Auth, toke · 8 unmodified lines
1457 if auth != nil {1457 if auth != nil {
1458 attrs = auth.Attributes1458 attrs = auth.Attributes
1459 }1459 }
1460 util.ApplyCustomHeadersFromAttrs(&http.Request{Header: headers}, attrs)1460 util.ApplyCustomHeadersFromAttrs(&http.Request{Header: headers}, attrs, clientHeaders...)
1461 return headers1461 return headers
1462}1462}
1463 1463
internal/util/header_helpers.go+38−3
@@ -3,18 +3,34 @@ package util · 2 unmodified lines
3import (3import (
4 "net/http"4 "net/http"
5 "strings"5 "strings"
6
7 "github.com/gin-gonic/gin"
6)8)
7 9
8// ApplyCustomHeadersFromAttrs applies user-defined headers stored in the provided attributes map.10// ApplyCustomHeadersFromAttrs applies user-defined headers stored in the provided attributes map.
9// Custom headers override built-in defaults when conflicts occur.11// Custom headers override built-in defaults when conflicts occur.
10func 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.
16func ApplyCustomHeadersFromAttrs(r *http.Request, attrs map[string]string, clientHeaders ...http.Header) {
11 if r == nil {17 if r == nil {
12 return18 return
13 }19 }
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))
15}31}
16 32
17func extractCustomHeaders(attrs map[string]string) map[string]string {33func extractCustomHeaders(attrs map[string]string, clientHeaders http.Header) map[string]string {
18 if len(attrs) == 0 {34 if len(attrs) == 0 {
19 return nil35 return nil
20 }36 }
@@ -31,6 +47,25 @@ func extractCustomHeaders(attrs map[string]string) map[string]string { · 10 unmodified lines
31 if val == "" {47 if val == "" {
32 continue48 continue
33 }49 }
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 }
34 headers[name] = val69 headers[name] = val
35 }70 }
36 if len(headers) == 0 {71 if len(headers) == 0 {
internal/util/header_helpers_test.go+116−0
@@ -0,0 +1,116 @@
1package util
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "testing"
7
8 "github.com/gin-gonic/gin"
9)
10
11func 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
28func 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}