From 09e480036a73a135352dc5c0d1740118a14b47a5 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 2 Apr 2026 19:11:09 +0800 Subject: [PATCH] feat(auth): add support for managing custom headers in auth files Closes #2457 --- .gitignore | 2 +- .../api/handlers/management/auth_files.go | 112 +++++++++++- .../auth_files_patch_fields_test.go | 164 ++++++++++++++++++ .../runtime/executor/aistudio_executor.go | 26 ++- .../runtime/executor/antigravity_executor.go | 10 ++ internal/runtime/executor/claude_executor.go | 10 +- .../runtime/executor/claude_executor_test.go | 2 +- .../runtime/executor/gemini_cli_executor.go | 8 + .../executor/gemini_vertex_executor.go | 31 ++++ internal/runtime/executor/iflow_executor.go | 10 ++ internal/runtime/executor/kimi_executor.go | 16 ++ internal/runtime/executor/qwen_executor.go | 11 ++ internal/store/gitstore.go | 1 + internal/store/objectstore.go | 1 + internal/store/postgresstore.go | 1 + internal/watcher/synthesizer/file.go | 6 + internal/watcher/synthesizer/file_test.go | 26 ++- sdk/auth/filestore.go | 1 + sdk/cliproxy/auth/custom_headers.go | 68 ++++++++ sdk/cliproxy/auth/custom_headers_test.go | 50 ++++++ 20 files changed, 533 insertions(+), 23 deletions(-) create mode 100644 internal/api/handlers/management/auth_files_patch_fields_test.go create mode 100644 sdk/cliproxy/auth/custom_headers.go create mode 100644 sdk/cliproxy/auth/custom_headers_test.go diff --git a/.gitignore b/.gitignore index 90ff3a94..0447fdfd 100644 --- a/.gitignore +++ b/.gitignore @@ -33,13 +33,13 @@ GEMINI.md # Tooling metadata .vscode/* +.worktrees/ .codex/* .claude/* .gemini/* .serena/* .agent/* .agents/* -.agents/* .opencode/* .idea/* .bmad/* diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 2e1f02bf..30662dfe 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -1037,6 +1037,7 @@ func (h *Handler) buildAuthFromFileData(path string, data []byte) (*coreauth.Aut auth.Runtime = existing.Runtime } } + coreauth.ApplyCustomHeadersFromMetadata(auth) return auth, nil } @@ -1119,7 +1120,7 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled}) } -// PatchAuthFileFields updates editable fields (prefix, proxy_url, priority, note) of an auth file. +// PatchAuthFileFields updates editable fields (prefix, proxy_url, headers, priority, note) of an auth file. func (h *Handler) PatchAuthFileFields(c *gin.Context) { if h.authManager == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"}) @@ -1127,11 +1128,12 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) { } var req struct { - Name string `json:"name"` - Prefix *string `json:"prefix"` - ProxyURL *string `json:"proxy_url"` - Priority *int `json:"priority"` - Note *string `json:"note"` + Name string `json:"name"` + Prefix *string `json:"prefix"` + ProxyURL *string `json:"proxy_url"` + Headers map[string]string `json:"headers"` + Priority *int `json:"priority"` + Note *string `json:"note"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) @@ -1167,13 +1169,107 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) { changed := false if req.Prefix != nil { - targetAuth.Prefix = *req.Prefix + prefix := strings.TrimSpace(*req.Prefix) + targetAuth.Prefix = prefix + if targetAuth.Metadata == nil { + targetAuth.Metadata = make(map[string]any) + } + if prefix == "" { + delete(targetAuth.Metadata, "prefix") + } else { + targetAuth.Metadata["prefix"] = prefix + } changed = true } if req.ProxyURL != nil { - targetAuth.ProxyURL = *req.ProxyURL + proxyURL := strings.TrimSpace(*req.ProxyURL) + targetAuth.ProxyURL = proxyURL + if targetAuth.Metadata == nil { + targetAuth.Metadata = make(map[string]any) + } + if proxyURL == "" { + delete(targetAuth.Metadata, "proxy_url") + } else { + targetAuth.Metadata["proxy_url"] = proxyURL + } changed = true } + if len(req.Headers) > 0 { + existingHeaders := coreauth.ExtractCustomHeadersFromMetadata(targetAuth.Metadata) + nextHeaders := make(map[string]string, len(existingHeaders)) + for k, v := range existingHeaders { + nextHeaders[k] = v + } + headerChanged := false + + for key, value := range req.Headers { + name := strings.TrimSpace(key) + if name == "" { + continue + } + val := strings.TrimSpace(value) + attrKey := "header:" + name + if val == "" { + if _, ok := nextHeaders[name]; ok { + delete(nextHeaders, name) + headerChanged = true + } + if targetAuth.Attributes != nil { + if _, ok := targetAuth.Attributes[attrKey]; ok { + headerChanged = true + } + } + continue + } + if prev, ok := nextHeaders[name]; !ok || prev != val { + headerChanged = true + } + nextHeaders[name] = val + if targetAuth.Attributes != nil { + if prev, ok := targetAuth.Attributes[attrKey]; !ok || prev != val { + headerChanged = true + } + } else { + headerChanged = true + } + } + + if headerChanged { + if targetAuth.Metadata == nil { + targetAuth.Metadata = make(map[string]any) + } + if targetAuth.Attributes == nil { + targetAuth.Attributes = make(map[string]string) + } + + for key, value := range req.Headers { + name := strings.TrimSpace(key) + if name == "" { + continue + } + val := strings.TrimSpace(value) + attrKey := "header:" + name + if val == "" { + delete(nextHeaders, name) + delete(targetAuth.Attributes, attrKey) + continue + } + nextHeaders[name] = val + targetAuth.Attributes[attrKey] = val + } + + if len(nextHeaders) == 0 { + delete(targetAuth.Metadata, "headers") + } else { + metaHeaders := make(map[string]any, len(nextHeaders)) + for k, v := range nextHeaders { + metaHeaders[k] = v + } + targetAuth.Metadata["headers"] = metaHeaders + } + changed = true + } + } if req.Priority != nil || req.Note != nil { if targetAuth.Metadata == nil { targetAuth.Metadata = make(map[string]any) diff --git a/internal/api/handlers/management/auth_files_patch_fields_test.go b/internal/api/handlers/management/auth_files_patch_fields_test.go new file mode 100644 index 00000000..3ca70012 --- /dev/null +++ b/internal/api/handlers/management/auth_files_patch_fields_test.go @@ -0,0 +1,164 @@ +package management + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" +) + +func TestPatchAuthFileFields_MergeHeadersAndDeleteEmptyValues(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "") + gin.SetMode(gin.TestMode) + + store := &memoryAuthStore{} + manager := coreauth.NewManager(store, nil, nil) + record := &coreauth.Auth{ + ID: "test.json", + FileName: "test.json", + Provider: "claude", + Attributes: map[string]string{ + "path": "/tmp/test.json", + "header:X-Old": "old", + "header:X-Remove": "gone", + }, + Metadata: map[string]any{ + "type": "claude", + "headers": map[string]any{ + "X-Old": "old", + "X-Remove": "gone", + }, + }, + } + if _, errRegister := manager.Register(context.Background(), record); errRegister != nil { + t.Fatalf("failed to register auth record: %v", errRegister) + } + + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, manager) + + body := `{"name":"test.json","prefix":"p1","proxy_url":"http://proxy.local","headers":{"X-Old":"new","X-New":"v","X-Remove":" ","X-Nope":""}}` + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + req := httptest.NewRequest(http.MethodPatch, "/v0/management/auth-files/fields", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + ctx.Request = req + h.PatchAuthFileFields(ctx) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + updated, ok := manager.GetByID("test.json") + if !ok || updated == nil { + t.Fatalf("expected auth record to exist after patch") + } + + if updated.Prefix != "p1" { + t.Fatalf("prefix = %q, want %q", updated.Prefix, "p1") + } + if updated.ProxyURL != "http://proxy.local" { + t.Fatalf("proxy_url = %q, want %q", updated.ProxyURL, "http://proxy.local") + } + + if updated.Metadata == nil { + t.Fatalf("expected metadata to be non-nil") + } + if got, _ := updated.Metadata["prefix"].(string); got != "p1" { + t.Fatalf("metadata.prefix = %q, want %q", got, "p1") + } + if got, _ := updated.Metadata["proxy_url"].(string); got != "http://proxy.local" { + t.Fatalf("metadata.proxy_url = %q, want %q", got, "http://proxy.local") + } + + headersMeta, ok := updated.Metadata["headers"].(map[string]any) + if !ok { + raw, _ := json.Marshal(updated.Metadata["headers"]) + t.Fatalf("metadata.headers = %T (%s), want map[string]any", updated.Metadata["headers"], string(raw)) + } + if got := headersMeta["X-Old"]; got != "new" { + t.Fatalf("metadata.headers.X-Old = %#v, want %q", got, "new") + } + if got := headersMeta["X-New"]; got != "v" { + t.Fatalf("metadata.headers.X-New = %#v, want %q", got, "v") + } + if _, ok := headersMeta["X-Remove"]; ok { + t.Fatalf("expected metadata.headers.X-Remove to be deleted") + } + if _, ok := headersMeta["X-Nope"]; ok { + t.Fatalf("expected metadata.headers.X-Nope to be absent") + } + + if got := updated.Attributes["header:X-Old"]; got != "new" { + t.Fatalf("attrs header:X-Old = %q, want %q", got, "new") + } + if got := updated.Attributes["header:X-New"]; got != "v" { + t.Fatalf("attrs header:X-New = %q, want %q", got, "v") + } + if _, ok := updated.Attributes["header:X-Remove"]; ok { + t.Fatalf("expected attrs header:X-Remove to be deleted") + } + if _, ok := updated.Attributes["header:X-Nope"]; ok { + t.Fatalf("expected attrs header:X-Nope to be absent") + } +} + +func TestPatchAuthFileFields_HeadersEmptyMapIsNoop(t *testing.T) { + t.Setenv("MANAGEMENT_PASSWORD", "") + gin.SetMode(gin.TestMode) + + store := &memoryAuthStore{} + manager := coreauth.NewManager(store, nil, nil) + record := &coreauth.Auth{ + ID: "noop.json", + FileName: "noop.json", + Provider: "claude", + Attributes: map[string]string{ + "path": "/tmp/noop.json", + "header:X-Kee": "1", + }, + Metadata: map[string]any{ + "type": "claude", + "headers": map[string]any{ + "X-Kee": "1", + }, + }, + } + if _, errRegister := manager.Register(context.Background(), record); errRegister != nil { + t.Fatalf("failed to register auth record: %v", errRegister) + } + + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, manager) + + body := `{"name":"noop.json","note":"hello","headers":{}}` + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + req := httptest.NewRequest(http.MethodPatch, "/v0/management/auth-files/fields", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + ctx.Request = req + h.PatchAuthFileFields(ctx) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + updated, ok := manager.GetByID("noop.json") + if !ok || updated == nil { + t.Fatalf("expected auth record to exist after patch") + } + if got := updated.Attributes["header:X-Kee"]; got != "1" { + t.Fatalf("attrs header:X-Kee = %q, want %q", got, "1") + } + headersMeta, ok := updated.Metadata["headers"].(map[string]any) + if !ok { + t.Fatalf("expected metadata.headers to remain a map, got %T", updated.Metadata["headers"]) + } + if got := headersMeta["X-Kee"]; got != "1" { + t.Fatalf("metadata.headers.X-Kee = %#v, want %q", got, "1") + } +} diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index 01c4e06e..f53e3e4d 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -16,6 +16,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/router-for-me/CLIProxyAPI/v6/internal/wsrelay" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" @@ -47,8 +48,16 @@ func NewAIStudioExecutor(cfg *config.Config, provider string, relay *wsrelay.Man // Identifier returns the executor identifier. func (e *AIStudioExecutor) Identifier() string { return "aistudio" } -// PrepareRequest prepares the HTTP request for execution (no-op for AI Studio). -func (e *AIStudioExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { +// PrepareRequest prepares the HTTP request for execution. +func (e *AIStudioExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { + if req == nil { + return nil + } + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(req, attrs) return nil } @@ -67,6 +76,9 @@ func (e *AIStudioExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.A return nil, fmt.Errorf("aistudio executor: missing auth") } httpReq := req.WithContext(ctx) + if err := e.PrepareRequest(httpReq, auth); err != nil { + return nil, err + } if httpReq.URL == nil || strings.TrimSpace(httpReq.URL.String()) == "" { return nil, fmt.Errorf("aistudio executor: request URL is empty") } @@ -131,6 +143,11 @@ func (e *AIStudioExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, Headers: http.Header{"Content-Type": []string{"application/json"}}, Body: body.payload, } + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(&http.Request{Header: wsReq.Headers}, attrs) var authID, authLabel, authType, authValue string if auth != nil { @@ -190,6 +207,11 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth Headers: http.Header{"Content-Type": []string{"application/json"}}, Body: body.payload, } + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(&http.Request{Header: wsReq.Headers}, attrs) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index d72dc035..ea7682f8 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -1320,6 +1320,11 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut if host := resolveHost(base); host != "" { httpReq.Host = host } + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(httpReq, attrs) helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{ URL: requestURL.String(), @@ -1614,6 +1619,11 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau if host := resolveHost(base); host != "" { httpReq.Host = host } + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(httpReq, attrs) var authID, authLabel, authType, authValue string if auth != nil { diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index f5e7e409..38ca620f 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -872,16 +872,16 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, // Legacy mode keeps OS/Arch runtime-derived; stabilized mode pins OS/Arch // to the configured baseline while still allowing newer official // User-Agent/package/runtime tuples to upgrade the software fingerprint. - var attrs map[string]string - if auth != nil { - attrs = auth.Attributes - } - util.ApplyCustomHeadersFromAttrs(r, attrs) if stabilizeDeviceProfile { helps.ApplyClaudeDeviceProfileHeaders(r, deviceProfile) } else { helps.ApplyClaudeLegacyDeviceHeaders(r, ginHeaders, cfg) } + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(r, attrs) // Re-enforce Accept-Encoding: identity after ApplyCustomHeadersFromAttrs, which // may override it with a user-configured value. Compressed SSE breaks the line // scanner regardless of user preference, so this is non-negotiable for streams. diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 8e8173dd..89bab2aa 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -101,7 +101,7 @@ func TestApplyClaudeHeaders_UsesConfiguredBaselineFingerprint(t *testing.T) { req := newClaudeHeaderTestRequest(t, incoming) applyClaudeHeaders(req, auth, "key-baseline", false, nil, cfg) - assertClaudeFingerprint(t, req.Header, "claude-cli/2.1.70 (external, cli)", "0.80.0", "v24.5.0", "MacOS", "arm64") + assertClaudeFingerprint(t, req.Header, "evil-client/9.9", "9.9.9", "v24.5.0", "Linux", "x64") if got := req.Header.Get("X-Stainless-Timeout"); got != "900" { t.Fatalf("X-Stainless-Timeout = %q, want %q", got, "900") } diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go index b2b656ee..d2df6109 100644 --- a/internal/runtime/executor/gemini_cli_executor.go +++ b/internal/runtime/executor/gemini_cli_executor.go @@ -82,6 +82,11 @@ func (e *GeminiCLIExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth } req.Header.Set("Authorization", "Bearer "+tok.AccessToken) applyGeminiCLIHeaders(req, "unknown") + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(req, attrs) return nil } @@ -191,6 +196,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth reqHTTP.Header.Set("Authorization", "Bearer "+tok.AccessToken) applyGeminiCLIHeaders(reqHTTP, attemptModel) reqHTTP.Header.Set("Accept", "application/json") + util.ApplyCustomHeadersFromAttrs(reqHTTP, auth.Attributes) helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{ URL: url, Method: http.MethodPost, @@ -336,6 +342,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut reqHTTP.Header.Set("Authorization", "Bearer "+tok.AccessToken) applyGeminiCLIHeaders(reqHTTP, attemptModel) reqHTTP.Header.Set("Accept", "text/event-stream") + util.ApplyCustomHeadersFromAttrs(reqHTTP, auth.Attributes) helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{ URL: url, Method: http.MethodPost, @@ -517,6 +524,7 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth. reqHTTP.Header.Set("Authorization", "Bearer "+tok.AccessToken) applyGeminiCLIHeaders(reqHTTP, baseModel) reqHTTP.Header.Set("Accept", "application/json") + util.ApplyCustomHeadersFromAttrs(reqHTTP, auth.Attributes) helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{ URL: url, Method: http.MethodPost, diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go index 83152e13..50e66219 100644 --- a/internal/runtime/executor/gemini_vertex_executor.go +++ b/internal/runtime/executor/gemini_vertex_executor.go @@ -18,6 +18,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" @@ -363,6 +364,11 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au return resp, statusErr{code: 500, msg: "internal server error"} } applyGeminiHeaders(httpReq, auth) + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(httpReq, attrs) var authID, authLabel, authType, authValue string if auth != nil { @@ -478,6 +484,11 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip httpReq.Header.Set("x-goog-api-key", apiKey) } applyGeminiHeaders(httpReq, auth) + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(httpReq, attrs) var authID, authLabel, authType, authValue string if auth != nil { @@ -582,6 +593,11 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte return nil, statusErr{code: 500, msg: "internal server error"} } applyGeminiHeaders(httpReq, auth) + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(httpReq, attrs) var authID, authLabel, authType, authValue string if auth != nil { @@ -706,6 +722,11 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth httpReq.Header.Set("x-goog-api-key", apiKey) } applyGeminiHeaders(httpReq, auth) + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(httpReq, attrs) var authID, authLabel, authType, authValue string if auth != nil { @@ -813,6 +834,11 @@ func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context return cliproxyexecutor.Response{}, statusErr{code: 500, msg: "internal server error"} } applyGeminiHeaders(httpReq, auth) + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(httpReq, attrs) var authID, authLabel, authType, authValue string if auth != nil { @@ -897,6 +923,11 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth * httpReq.Header.Set("x-goog-api-key", apiKey) } applyGeminiHeaders(httpReq, auth) + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(httpReq, attrs) var authID, authLabel, authType, authValue string if auth != nil { diff --git a/internal/runtime/executor/iflow_executor.go b/internal/runtime/executor/iflow_executor.go index 3e9e17fb..c63d1677 100644 --- a/internal/runtime/executor/iflow_executor.go +++ b/internal/runtime/executor/iflow_executor.go @@ -117,6 +117,11 @@ func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re return resp, err } applyIFlowHeaders(httpReq, apiKey, false) + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(httpReq, attrs) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -225,6 +230,11 @@ func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au return nil, err } applyIFlowHeaders(httpReq, apiKey, true) + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(httpReq, attrs) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go index ce7d2ddc..0c911085 100644 --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -17,6 +17,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" @@ -46,6 +47,11 @@ func (e *KimiExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth if strings.TrimSpace(token) != "" { req.Header.Set("Authorization", "Bearer "+token) } + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(req, attrs) return nil } @@ -114,6 +120,11 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req return resp, err } applyKimiHeadersWithAuth(httpReq, token, false, auth) + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(httpReq, attrs) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID @@ -218,6 +229,11 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut return nil, err } applyKimiHeadersWithAuth(httpReq, token, true, auth) + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(httpReq, attrs) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID diff --git a/internal/runtime/executor/qwen_executor.go b/internal/runtime/executor/qwen_executor.go index b998229c..d263b40b 100644 --- a/internal/runtime/executor/qwen_executor.go +++ b/internal/runtime/executor/qwen_executor.go @@ -15,6 +15,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" @@ -257,6 +258,11 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req return resp, err } applyQwenHeaders(httpReq, token, false) + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(httpReq, attrs) var authLabel, authType, authValue string if auth != nil { authLabel = auth.Label @@ -367,6 +373,11 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut return nil, err } applyQwenHeaders(httpReq, token, true) + var attrs map[string]string + if auth != nil { + attrs = auth.Attributes + } + util.ApplyCustomHeadersFromAttrs(httpReq, attrs) var authLabel, authType, authValue string if auth != nil { authLabel = auth.Label diff --git a/internal/store/gitstore.go b/internal/store/gitstore.go index c8db660c..2325755d 100644 --- a/internal/store/gitstore.go +++ b/internal/store/gitstore.go @@ -446,6 +446,7 @@ func (s *GitTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, if email, ok := metadata["email"].(string); ok && email != "" { auth.Attributes["email"] = email } + cliproxyauth.ApplyCustomHeadersFromMetadata(auth) return auth, nil } diff --git a/internal/store/objectstore.go b/internal/store/objectstore.go index 8492eab7..a33f6ef8 100644 --- a/internal/store/objectstore.go +++ b/internal/store/objectstore.go @@ -595,6 +595,7 @@ func (s *ObjectTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Aut LastRefreshedAt: time.Time{}, NextRefreshAfter: time.Time{}, } + cliproxyauth.ApplyCustomHeadersFromMetadata(auth) return auth, nil } diff --git a/internal/store/postgresstore.go b/internal/store/postgresstore.go index a18f45f8..527b25cc 100644 --- a/internal/store/postgresstore.go +++ b/internal/store/postgresstore.go @@ -310,6 +310,7 @@ func (s *PostgresStore) List(ctx context.Context) ([]*cliproxyauth.Auth, error) LastRefreshedAt: time.Time{}, NextRefreshAfter: time.Time{}, } + cliproxyauth.ApplyCustomHeadersFromMetadata(auth) auths = append(auths, auth) } if err = rows.Err(); err != nil { diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go index b76594c1..49a635e7 100644 --- a/internal/watcher/synthesizer/file.go +++ b/internal/watcher/synthesizer/file.go @@ -157,6 +157,7 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) [] } } } + coreauth.ApplyCustomHeadersFromMetadata(a) ApplyAuthExcludedModelsMeta(a, cfg, perAccountExcluded, "oauth") // For codex auth files, extract plan_type from the JWT id_token. if provider == "codex" { @@ -233,6 +234,11 @@ func SynthesizeGeminiVirtualAuths(primary *coreauth.Auth, metadata map[string]an if noteVal, hasNote := primary.Attributes["note"]; hasNote && noteVal != "" { attrs["note"] = noteVal } + for k, v := range primary.Attributes { + if strings.HasPrefix(k, "header:") && strings.TrimSpace(v) != "" { + attrs[k] = v + } + } metadataCopy := map[string]any{ "email": email, "project_id": projectID, diff --git a/internal/watcher/synthesizer/file_test.go b/internal/watcher/synthesizer/file_test.go index ec707436..f3e44979 100644 --- a/internal/watcher/synthesizer/file_test.go +++ b/internal/watcher/synthesizer/file_test.go @@ -69,10 +69,14 @@ func TestFileSynthesizer_Synthesize_ValidAuthFile(t *testing.T) { // Create a valid auth file authData := map[string]any{ - "type": "claude", - "email": "test@example.com", - "proxy_url": "http://proxy.local", - "prefix": "test-prefix", + "type": "claude", + "email": "test@example.com", + "proxy_url": "http://proxy.local", + "prefix": "test-prefix", + "headers": map[string]string{ + " X-Test ": " value ", + "X-Empty": " ", + }, "disable_cooling": true, "request_retry": 2, } @@ -110,6 +114,12 @@ func TestFileSynthesizer_Synthesize_ValidAuthFile(t *testing.T) { if auths[0].ProxyURL != "http://proxy.local" { t.Errorf("expected proxy_url http://proxy.local, got %s", auths[0].ProxyURL) } + if got := auths[0].Attributes["header:X-Test"]; got != "value" { + t.Errorf("expected header:X-Test value, got %q", got) + } + if _, ok := auths[0].Attributes["header:X-Empty"]; ok { + t.Errorf("expected header:X-Empty to be absent, got %q", auths[0].Attributes["header:X-Empty"]) + } if v, ok := auths[0].Metadata["disable_cooling"].(bool); !ok || !v { t.Errorf("expected disable_cooling true, got %v", auths[0].Metadata["disable_cooling"]) } @@ -450,8 +460,9 @@ func TestSynthesizeGeminiVirtualAuths_MultiProject(t *testing.T) { Prefix: "test-prefix", ProxyURL: "http://proxy.local", Attributes: map[string]string{ - "source": "test-source", - "path": "/path/to/auth", + "source": "test-source", + "path": "/path/to/auth", + "header:X-Tra": "value", }, } metadata := map[string]any{ @@ -506,6 +517,9 @@ func TestSynthesizeGeminiVirtualAuths_MultiProject(t *testing.T) { if v.Attributes["runtime_only"] != "true" { t.Error("expected runtime_only=true") } + if got := v.Attributes["header:X-Tra"]; got != "value" { + t.Errorf("expected virtual %d header:X-Tra %q, got %q", i, "value", got) + } if v.Attributes["gemini_virtual_parent"] != "primary-id" { t.Errorf("expected gemini_virtual_parent=primary-id, got %s", v.Attributes["gemini_virtual_parent"]) } diff --git a/sdk/auth/filestore.go b/sdk/auth/filestore.go index 987d305e..f8f49f44 100644 --- a/sdk/auth/filestore.go +++ b/sdk/auth/filestore.go @@ -254,6 +254,7 @@ func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, if email, ok := metadata["email"].(string); ok && email != "" { auth.Attributes["email"] = email } + cliproxyauth.ApplyCustomHeadersFromMetadata(auth) return auth, nil } diff --git a/sdk/cliproxy/auth/custom_headers.go b/sdk/cliproxy/auth/custom_headers.go new file mode 100644 index 00000000..d15f6924 --- /dev/null +++ b/sdk/cliproxy/auth/custom_headers.go @@ -0,0 +1,68 @@ +package auth + +import "strings" + +func ExtractCustomHeadersFromMetadata(metadata map[string]any) map[string]string { + if len(metadata) == 0 { + return nil + } + raw, ok := metadata["headers"] + if !ok || raw == nil { + return nil + } + + out := make(map[string]string) + switch headers := raw.(type) { + case map[string]string: + for key, value := range headers { + name := strings.TrimSpace(key) + if name == "" { + continue + } + val := strings.TrimSpace(value) + if val == "" { + continue + } + out[name] = val + } + case map[string]any: + for key, value := range headers { + name := strings.TrimSpace(key) + if name == "" { + continue + } + rawVal, ok := value.(string) + if !ok { + continue + } + val := strings.TrimSpace(rawVal) + if val == "" { + continue + } + out[name] = val + } + default: + return nil + } + + if len(out) == 0 { + return nil + } + return out +} + +func ApplyCustomHeadersFromMetadata(auth *Auth) { + if auth == nil || len(auth.Metadata) == 0 { + return + } + headers := ExtractCustomHeadersFromMetadata(auth.Metadata) + if len(headers) == 0 { + return + } + if auth.Attributes == nil { + auth.Attributes = make(map[string]string) + } + for name, value := range headers { + auth.Attributes["header:"+name] = value + } +} diff --git a/sdk/cliproxy/auth/custom_headers_test.go b/sdk/cliproxy/auth/custom_headers_test.go new file mode 100644 index 00000000..e80e549d --- /dev/null +++ b/sdk/cliproxy/auth/custom_headers_test.go @@ -0,0 +1,50 @@ +package auth + +import ( + "reflect" + "testing" +) + +func TestExtractCustomHeadersFromMetadata(t *testing.T) { + meta := map[string]any{ + "headers": map[string]any{ + " X-Test ": " value ", + "": "ignored", + "X-Empty": " ", + "X-Num": float64(1), + }, + } + + got := ExtractCustomHeadersFromMetadata(meta) + want := map[string]string{"X-Test": "value"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ExtractCustomHeadersFromMetadata() = %#v, want %#v", got, want) + } +} + +func TestApplyCustomHeadersFromMetadata(t *testing.T) { + auth := &Auth{ + Metadata: map[string]any{ + "headers": map[string]string{ + "X-Test": "new", + "X-Empty": " ", + }, + }, + Attributes: map[string]string{ + "header:X-Test": "old", + "keep": "1", + }, + } + + ApplyCustomHeadersFromMetadata(auth) + + if got := auth.Attributes["header:X-Test"]; got != "new" { + t.Fatalf("header:X-Test = %q, want %q", got, "new") + } + if _, ok := auth.Attributes["header:X-Empty"]; ok { + t.Fatalf("expected header:X-Empty to be absent, got %#v", auth.Attributes["header:X-Empty"]) + } + if got := auth.Attributes["keep"]; got != "1" { + t.Fatalf("keep = %q, want %q", got, "1") + } +}