diff --git a/config.example.yaml b/config.example.yaml index f8d6ed835f9..bf788480f34 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -35,6 +35,12 @@ remote-management: # Authentication directory (supports ~ for home directory) auth-dir: "~/.cli-proxy-api" +# OAuth auth files may optionally include base_url or base-url to override the +# upstream endpoint for that account only. This is useful for routing one OAuth +# account through a local gateway while leaving other accounts on the default +# provider endpoint. Example auth file field: +# "base_url": "http://127.0.0.1:18081" + # API keys for authentication api-keys: - "your-api-key-1" @@ -106,6 +112,13 @@ redis-usage-queue-retention-seconds: 60 # Per-entry proxy-url also supports "direct" or "none" to bypass both the global proxy-url and environment proxies explicitly. proxy-url: "" +# Provider-wide upstream base URL overrides for OAuth/file-backed credentials. +# Per-auth-file base_url/base-url values and per-api-key base-url values take precedence. +# Empty keeps the official upstream defaults. +# claude-base-url: "http://127.0.0.1:18081" +# codex-base-url: "http://127.0.0.1:18082" + + # When true, unprefixed model requests only use credentials without a prefix (except when prefix == model name). force-model-prefix: false diff --git a/internal/config/config.go b/internal/config/config.go index 7fe3120f988..7a85b08e324 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -122,6 +122,11 @@ type Config struct { // Codex defines a list of Codex API key configurations as specified in the YAML configuration file. CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"` + // CodexBaseURL globally overrides the upstream Codex API endpoint for + // OAuth/file-backed credentials that do not set their own base URL. + // Per-credential base URLs still take precedence. + CodexBaseURL string `yaml:"codex-base-url,omitempty" json:"codex-base-url,omitempty"` + // Codex configures provider-wide Codex request behavior. Codex CodexConfig `yaml:"codex" json:"codex"` @@ -132,6 +137,11 @@ type Config struct { // ClaudeKey defines a list of Claude API key configurations as specified in the YAML configuration file. ClaudeKey []ClaudeKey `yaml:"claude-api-key" json:"claude-api-key"` + // ClaudeBaseURL globally overrides the upstream Claude API endpoint for + // OAuth/file-backed credentials that do not set their own base URL. + // Per-credential base URLs still take precedence. + ClaudeBaseURL string `yaml:"claude-base-url,omitempty" json:"claude-base-url,omitempty"` + // ClaudeHeaderDefaults configures default header values for Claude API requests. // These are used as fallbacks when the client does not send its own headers. ClaudeHeaderDefaults ClaudeHeaderDefaults `yaml:"claude-header-defaults" json:"claude-header-defaults"` diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 5ece6fbdef2..3715e99c466 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -158,7 +158,7 @@ func (e *ClaudeExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Au if req == nil { return nil } - apiKey, _ := claudeCreds(auth) + apiKey, _ := claudeCredsWithConfig(e.cfg, auth) if strings.TrimSpace(apiKey) == "" { return nil } @@ -201,7 +201,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } baseModel := thinking.ParseSuffix(req.Model).ModelName - apiKey, baseURL := claudeCreds(auth) + apiKey, baseURL := claudeCredsWithConfig(e.cfg, auth) if baseURL == "" { baseURL = "https://api.anthropic.com" } @@ -393,7 +393,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } baseModel := thinking.ParseSuffix(req.Model).ModelName - apiKey, baseURL := claudeCreds(auth) + apiKey, baseURL := claudeCredsWithConfig(e.cfg, auth) if baseURL == "" { baseURL = "https://api.anthropic.com" } @@ -675,7 +675,7 @@ func validateClaudeStreamingResponse(data []byte) error { func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { baseModel := thinking.ParseSuffix(req.Model).ModelName - apiKey, baseURL := claudeCreds(auth) + apiKey, baseURL := claudeCredsWithConfig(e.cfg, auth) if baseURL == "" { baseURL = "https://api.anthropic.com" } @@ -1135,12 +1135,25 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, } func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { + return claudeCredsWithConfig(nil, a) +} + +func claudeCredsWithConfig(cfg *config.Config, a *cliproxyauth.Auth) (apiKey, baseURL string) { if a == nil { return "", "" } if a.Attributes != nil { apiKey = a.Attributes["api_key"] - baseURL = a.Attributes["base_url"] + baseURL = strings.TrimSpace(a.Attributes["base_url"]) + } + if baseURL == "" && a.AuthKind() == cliproxyauth.AuthKindOAuth && a.Metadata != nil { + baseURL = cliproxyauth.BaseURLFromMetadata(a.Metadata) + } + if baseURL == "" && a.AuthKind() == cliproxyauth.AuthKindOAuth && cfg != nil { + baseURL = strings.TrimSpace(cfg.ClaudeBaseURL) + } + if baseURL != "" { + baseURL = normalizeClaudeBaseURL(baseURL) } if apiKey == "" && a.Metadata != nil { if v, ok := a.Metadata["access_token"].(string); ok { diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index c78923dfc98..433215330b5 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -2887,3 +2887,82 @@ func TestRestoreClaudeOAuthToolNamesFromStreamLine_MixedCaseWithPrefix(t *testin t.Fatalf("Glob should be restored to glob, got: %s", string(out)) } } + +func TestClaudeCredsUsesGlobalBaseURLWhenAuthHasNoBaseURL(t *testing.T) { + apiKey, baseURL := claudeCredsWithConfig(&config.Config{ClaudeBaseURL: "http://127.0.0.1:18081"}, &cliproxyauth.Auth{ + Metadata: map[string]any{"access_token": "oauth-token"}, + }) + if apiKey != "oauth-token" { + t.Fatalf("apiKey = %q, want oauth-token", apiKey) + } + if baseURL != "http://127.0.0.1:18081" { + t.Fatalf("baseURL = %q, want global override", baseURL) + } +} + +func TestClaudeCredsAuthBaseURLPrecedesGlobalBaseURL(t *testing.T) { + _, baseURL := claudeCredsWithConfig(&config.Config{ClaudeBaseURL: "http://127.0.0.1:18081"}, &cliproxyauth.Auth{ + Attributes: map[string]string{"base_url": "http://127.0.0.1:18101"}, + Metadata: map[string]any{"access_token": "oauth-token"}, + }) + if baseURL != "http://127.0.0.1:18101" { + t.Fatalf("baseURL = %q, want auth override", baseURL) + } +} + +func TestClaudeCredsMetadataBaseURLPrecedesGlobalBaseURL(t *testing.T) { + _, baseURL := claudeCredsWithConfig(&config.Config{ClaudeBaseURL: "http://127.0.0.1:18081"}, &cliproxyauth.Auth{ + Metadata: map[string]any{"access_token": "oauth-token", "base-url": "http://127.0.0.1:18103/"}, + }) + if baseURL != "http://127.0.0.1:18103" { + t.Fatalf("baseURL = %q, want metadata override", baseURL) + } +} + +func TestClaudeCredsDoesNotApplyGlobalBaseURLToAPIKeyAuth(t *testing.T) { + _, baseURL := claudeCredsWithConfig(&config.Config{ClaudeBaseURL: "http://127.0.0.1:18081"}, &cliproxyauth.Auth{ + Attributes: map[string]string{"api_key": "sk-ant-test"}, + }) + if baseURL != "" { + t.Fatalf("baseURL = %q, want empty for API-key auth without per-key base_url", baseURL) + } +} + +func TestClaudeCredsTrimsTrailingSlashFromBaseURLOverrides(t *testing.T) { + _, authBaseURL := claudeCredsWithConfig(&config.Config{ClaudeBaseURL: "http://127.0.0.1:18081/"}, &cliproxyauth.Auth{ + Attributes: map[string]string{"base_url": "http://127.0.0.1:18101/"}, + Metadata: map[string]any{"access_token": "oauth-token"}, + }) + if authBaseURL != "http://127.0.0.1:18101" { + t.Fatalf("auth baseURL = %q, want trimmed trailing slash", authBaseURL) + } + + _, globalBaseURL := claudeCredsWithConfig(&config.Config{ClaudeBaseURL: "http://127.0.0.1:18081/"}, &cliproxyauth.Auth{ + Metadata: map[string]any{"access_token": "oauth-token"}, + }) + if globalBaseURL != "http://127.0.0.1:18081" { + t.Fatalf("global baseURL = %q, want trimmed trailing slash", globalBaseURL) + } +} + +func TestResolveClaudeKeyConfigNormalizesTrailingSlashForBaseURLMatch(t *testing.T) { + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "key-123", + BaseURL: "http://127.0.0.1:18101/", + Cloak: &config.CloakConfig{Mode: "always"}, + }}, + } + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "api_key": "key-123", + "base_url": "http://127.0.0.1:18101/", + }} + + cloakCfg := resolveClaudeKeyCloakConfig(cfg, auth) + if cloakCfg == nil { + t.Fatal("expected trailing-slash base URL to match Claude key config") + } + if cloakCfg.Mode != "always" { + t.Fatalf("cloak mode = %q, want always", cloakCfg.Mode) + } +} diff --git a/internal/runtime/executor/claude_signing.go b/internal/runtime/executor/claude_signing.go index 8afd57a6756..978ef8d5630 100644 --- a/internal/runtime/executor/claude_signing.go +++ b/internal/runtime/executor/claude_signing.go @@ -53,7 +53,7 @@ func resolveClaudeKeyConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config for i := range cfg.ClaudeKey { entry := &cfg.ClaudeKey[i] cfgKey := strings.TrimSpace(entry.APIKey) - cfgBase := strings.TrimSpace(entry.BaseURL) + cfgBase := normalizeClaudeBaseURL(entry.BaseURL) if !strings.EqualFold(cfgKey, apiKey) { continue } @@ -66,6 +66,10 @@ func resolveClaudeKeyConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config return nil } +func normalizeClaudeBaseURL(baseURL string) string { + return strings.TrimRight(strings.TrimSpace(baseURL), "/") +} + // resolveClaudeKeyCloakConfig finds the matching ClaudeKey config and returns its CloakConfig. func resolveClaudeKeyCloakConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config.CloakConfig { entry := resolveClaudeKeyConfig(cfg, auth) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 966a1320d08..af8a27b05d7 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -710,7 +710,7 @@ func (e *CodexExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Aut if req == nil { return nil } - apiKey, _ := codexCreds(auth) + apiKey, _ := codexCredsWithConfig(e.cfg, auth) if strings.TrimSpace(apiKey) != "" { req.Header.Set("Authorization", "Bearer "+apiKey) } @@ -747,7 +747,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re } baseModel := thinking.ParseSuffix(req.Model).ModelName - apiKey, baseURL := codexCreds(auth) + apiKey, baseURL := codexCredsWithConfig(e.cfg, auth) if baseURL == "" { baseURL = "https://chatgpt.com/backend-api/codex" } @@ -927,7 +927,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { baseModel := thinking.ParseSuffix(req.Model).ModelName - apiKey, baseURL := codexCreds(auth) + apiKey, baseURL := codexCredsWithConfig(e.cfg, auth) if baseURL == "" { baseURL = "https://chatgpt.com/backend-api/codex" } @@ -1034,7 +1034,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au } baseModel := thinking.ParseSuffix(req.Model).ModelName - apiKey, baseURL := codexCreds(auth) + apiKey, baseURL := codexCredsWithConfig(e.cfg, auth) if baseURL == "" { baseURL = "https://chatgpt.com/backend-api/codex" } @@ -1853,12 +1853,22 @@ func parseCodexRetryAfter(statusCode int, errorBody []byte, now time.Time) *time } func codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { + return codexCredsWithConfig(nil, a) +} + +func codexCredsWithConfig(cfg *config.Config, a *cliproxyauth.Auth) (apiKey, baseURL string) { if a == nil { return "", "" } if a.Attributes != nil { apiKey = a.Attributes["api_key"] - baseURL = a.Attributes["base_url"] + baseURL = strings.TrimSpace(a.Attributes["base_url"]) + } + if baseURL == "" && a.AuthKind() == cliproxyauth.AuthKindOAuth && a.Metadata != nil { + baseURL = cliproxyauth.BaseURLFromMetadata(a.Metadata) + } + if baseURL == "" && a.AuthKind() == cliproxyauth.AuthKindOAuth && cfg != nil { + baseURL = strings.TrimSpace(cfg.CodexBaseURL) } if apiKey == "" && a.Metadata != nil { if v, ok := a.Metadata["access_token"].(string); ok { diff --git a/internal/runtime/executor/codex_oauth_base_url_test.go b/internal/runtime/executor/codex_oauth_base_url_test.go new file mode 100644 index 00000000000..bf248c90ff4 --- /dev/null +++ b/internal/runtime/executor/codex_oauth_base_url_test.go @@ -0,0 +1,48 @@ +package executor + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" +) + +func TestCodexCredsUsesGlobalBaseURLWhenAuthHasNoBaseURL(t *testing.T) { + apiKey, baseURL := codexCredsWithConfig(&config.Config{CodexBaseURL: "http://127.0.0.1:18082"}, &cliproxyauth.Auth{ + Metadata: map[string]any{"access_token": "oauth-token"}, + }) + if apiKey != "oauth-token" { + t.Fatalf("apiKey = %q, want oauth-token", apiKey) + } + if baseURL != "http://127.0.0.1:18082" { + t.Fatalf("baseURL = %q, want global override", baseURL) + } +} + +func TestCodexCredsAuthBaseURLPrecedesGlobalBaseURL(t *testing.T) { + _, baseURL := codexCredsWithConfig(&config.Config{CodexBaseURL: "http://127.0.0.1:18082"}, &cliproxyauth.Auth{ + Attributes: map[string]string{"base_url": "http://127.0.0.1:18102"}, + Metadata: map[string]any{"access_token": "oauth-token"}, + }) + if baseURL != "http://127.0.0.1:18102" { + t.Fatalf("baseURL = %q, want auth override", baseURL) + } +} + +func TestCodexCredsMetadataBaseURLPrecedesGlobalBaseURL(t *testing.T) { + _, baseURL := codexCredsWithConfig(&config.Config{CodexBaseURL: "http://127.0.0.1:18082"}, &cliproxyauth.Auth{ + Metadata: map[string]any{"access_token": "oauth-token", "base_url": "http://127.0.0.1:18104"}, + }) + if baseURL != "http://127.0.0.1:18104" { + t.Fatalf("baseURL = %q, want metadata override", baseURL) + } +} + +func TestCodexCredsDoesNotApplyGlobalBaseURLToAPIKeyAuth(t *testing.T) { + _, baseURL := codexCredsWithConfig(&config.Config{CodexBaseURL: "http://127.0.0.1:18082"}, &cliproxyauth.Auth{ + Attributes: map[string]string{"api_key": "sk-codex-test"}, + }) + if baseURL != "" { + t.Fatalf("baseURL = %q, want empty for API-key auth without per-key base_url", baseURL) + } +} diff --git a/internal/runtime/executor/codex_openai_images.go b/internal/runtime/executor/codex_openai_images.go index 5398179968d..8a60c271af6 100644 --- a/internal/runtime/executor/codex_openai_images.go +++ b/internal/runtime/executor/codex_openai_images.go @@ -91,7 +91,7 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau return resp, errPrepare } - apiKey, baseURL := codexCreds(auth) + apiKey, baseURL := codexCredsWithConfig(e.cfg, auth) if baseURL == "" { baseURL = "https://chatgpt.com/backend-api/codex" } @@ -187,7 +187,7 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip return nil, errPrepare } - apiKey, baseURL := codexCreds(auth) + apiKey, baseURL := codexCredsWithConfig(e.cfg, auth) if baseURL == "" { baseURL = "https://chatgpt.com/backend-api/codex" } @@ -319,7 +319,7 @@ func (e *CodexExecutor) executeDirectOpenAIImage(ctx context.Context, auth *clip return resp, errPrepare } - apiKey, baseURL := codexCreds(auth) + apiKey, baseURL := codexCredsWithConfig(e.cfg, auth) if baseURL == "" { baseURL = "https://chatgpt.com/backend-api/codex" } @@ -379,7 +379,7 @@ func (e *CodexExecutor) executeDirectOpenAIImageStream(ctx context.Context, auth return nil, errPrepare } - apiKey, baseURL := codexCreds(auth) + apiKey, baseURL := codexCredsWithConfig(e.cfg, auth) if baseURL == "" { baseURL = "https://chatgpt.com/backend-api/codex" } diff --git a/internal/runtime/executor/codex_openai_images_test.go b/internal/runtime/executor/codex_openai_images_test.go index 6bc5b63890d..1f42733fc5e 100644 --- a/internal/runtime/executor/codex_openai_images_test.go +++ b/internal/runtime/executor/codex_openai_images_test.go @@ -315,3 +315,26 @@ func TestCodexExecutorDirectOpenAIImageEditUsesImagesEditEndpointForMultipart(t t.Fatalf("mask.image_url = %q, want mask-data data URL; body=%s", maskURL, string(gotBody)) } } + +func TestCodexExecutorDirectOpenAIImageGenerationUsesGlobalBaseURLForOAuthAuth(t *testing.T) { + var gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"created":1713833628,"data":[{"b64_json":"AA=="}],"usage":{"total_tokens":1,"input_tokens":1,"output_tokens":0}}`)) + })) + defer server.Close() + + executor := NewCodexExecutor(&config.Config{CodexBaseURL: server.URL}) + auth := &cliproxyauth.Auth{Provider: "codex", Metadata: map[string]any{"access_token": "oauth-token"}} + _, errExecute := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "codex/gpt-image-1.5", + Payload: []byte(`{"model":"codex/gpt-image-1.5","prompt":"draw","stream":false}`), + }, codexOpenAIImageTestOptions(codexImagesGenerationsPath, false)) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + if gotPath != "/images/generations" { + t.Fatalf("path = %q, want /images/generations", gotPath) + } +} diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go index 7e74d953d6c..7ff6888b8e0 100644 --- a/internal/runtime/executor/codex_websockets_executor.go +++ b/internal/runtime/executor/codex_websockets_executor.go @@ -179,7 +179,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut } baseModel := thinking.ParseSuffix(req.Model).ModelName - apiKey, baseURL := codexCreds(auth) + apiKey, baseURL := codexCredsWithConfig(e.cfg, auth) if baseURL == "" { baseURL = "https://chatgpt.com/backend-api/codex" } @@ -403,7 +403,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr } baseModel := thinking.ParseSuffix(req.Model).ModelName - apiKey, baseURL := codexCreds(auth) + apiKey, baseURL := codexCredsWithConfig(e.cfg, auth) if baseURL == "" { baseURL = "https://chatgpt.com/backend-api/codex" } diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go index db76f06214b..051b9c33c8c 100644 --- a/internal/runtime/executor/codex_websockets_executor_test.go +++ b/internal/runtime/executor/codex_websockets_executor_test.go @@ -882,3 +882,34 @@ func TestNewProxyAwareWebsocketDialerDirectDisablesProxy(t *testing.T) { t.Fatal("expected websocket proxy function to be nil for direct mode") } } + +func TestCodexWebsocketsExecuteUsesGlobalBaseURLForOAuthAuth(t *testing.T) { + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/responses" { + t.Fatalf("request path = %s, want /responses", r.URL.Path) + } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Fatalf("upgrade websocket: %v", err) + } + defer func() { _ = conn.Close() }() + if _, _, errRead := conn.ReadMessage(); errRead != nil { + t.Fatalf("read upstream websocket message: %v", errRead) + } + completed := []byte(`{"type":"response.completed","response":{"id":"resp-1","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`) + if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil { + t.Fatalf("write completed websocket message: %v", errWrite) + } + })) + defer server.Close() + + exec := NewCodexWebsocketsExecutor(&config.Config{CodexBaseURL: server.URL, SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}}) + auth := &cliproxyauth.Auth{Metadata: map[string]any{"access_token": "oauth-token"}} + req := cliproxyexecutor.Request{Model: "gpt-5-codex", Payload: []byte(`{"model":"gpt-5-codex","input":"hello"}`)} + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("codex")} + + if _, err := exec.Execute(context.Background(), auth, req, opts); err != nil { + t.Fatalf("Execute() error = %v", err) + } +} diff --git a/internal/store/gitstore.go b/internal/store/gitstore.go index cd2099d6f41..3d3efa9f391 100644 --- a/internal/store/gitstore.go +++ b/internal/store/gitstore.go @@ -497,6 +497,7 @@ func (s *GitTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, LastRefreshedAt: time.Time{}, NextRefreshAfter: time.Time{}, } + cliproxyauth.ApplyBaseURLFromMetadata(auth) if email, ok := metadata["email"].(string); ok && email != "" { auth.Attributes["email"] = email } diff --git a/internal/store/gitstore_test.go b/internal/store/gitstore_test.go index bdb2ccc5382..e17c5e14c2d 100644 --- a/internal/store/gitstore_test.go +++ b/internal/store/gitstore_test.go @@ -617,3 +617,24 @@ func assertRemoteBranchContents(t *testing.T, remoteDir, branch, wantContents st t.Fatalf("remote branch %s contents = %q, want %q", branch, contents, wantContents) } } + +func TestGitTokenStoreReadAuthFileCopiesBaseURL(t *testing.T) { + root := t.TempDir() + authDir := filepath.Join(root, "auths") + if err := os.MkdirAll(authDir, 0o700); err != nil { + t.Fatalf("mkdir auth dir: %v", err) + } + path := filepath.Join(authDir, "codex.json") + if err := os.WriteFile(path, []byte(`{"type":"codex","email":"codex@example.com","base_url":"http://127.0.0.1:18081"}`), 0o600); err != nil { + t.Fatalf("write auth file: %v", err) + } + + store := NewGitTokenStore("", "", "", "") + auth, err := store.readAuthFile(path, authDir) + if err != nil { + t.Fatalf("readAuthFile() error = %v", err) + } + if got := auth.Attributes["base_url"]; got != "http://127.0.0.1:18081" { + t.Fatalf("base_url attr = %q, want copied metadata base URL", got) + } +} diff --git a/internal/store/objectstore.go b/internal/store/objectstore.go index dff9211c5ef..e474f881695 100644 --- a/internal/store/objectstore.go +++ b/internal/store/objectstore.go @@ -607,6 +607,7 @@ func (s *ObjectTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Aut LastRefreshedAt: time.Time{}, NextRefreshAfter: time.Time{}, } + cliproxyauth.ApplyBaseURLFromMetadata(auth) cliproxyauth.ApplyCustomHeadersFromMetadata(auth) if disabled, ok := metadata["disabled"].(bool); ok && disabled { auth.Disabled = true diff --git a/internal/store/objectstore_test.go b/internal/store/objectstore_test.go new file mode 100644 index 00000000000..43dbce58576 --- /dev/null +++ b/internal/store/objectstore_test.go @@ -0,0 +1,28 @@ +package store + +import ( + "os" + "path/filepath" + "testing" +) + +func TestObjectTokenStoreReadAuthFileCopiesBaseURL(t *testing.T) { + root := t.TempDir() + authDir := filepath.Join(root, "auths") + if err := os.MkdirAll(authDir, 0o700); err != nil { + t.Fatalf("mkdir auth dir: %v", err) + } + path := filepath.Join(authDir, "claude.json") + if err := os.WriteFile(path, []byte(`{"type":"claude","email":"claude@example.com","base-url":"http://127.0.0.1:18082"}`), 0o600); err != nil { + t.Fatalf("write auth file: %v", err) + } + + store := &ObjectTokenStore{} + auth, err := store.readAuthFile(path, authDir) + if err != nil { + t.Fatalf("readAuthFile() error = %v", err) + } + if got := auth.Attributes["base_url"]; got != "http://127.0.0.1:18082" { + t.Fatalf("base_url attr = %q, want copied metadata base URL", got) + } +} diff --git a/internal/store/postgresstore.go b/internal/store/postgresstore.go index 4b979486ec5..75881c20fcd 100644 --- a/internal/store/postgresstore.go +++ b/internal/store/postgresstore.go @@ -322,6 +322,7 @@ func (s *PostgresStore) List(ctx context.Context) ([]*cliproxyauth.Auth, error) LastRefreshedAt: time.Time{}, NextRefreshAfter: time.Time{}, } + cliproxyauth.ApplyBaseURLFromMetadata(auth) cliproxyauth.ApplyCustomHeadersFromMetadata(auth) if disabled, ok := metadata["disabled"].(bool); ok && disabled { auth.Disabled = true diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go index 44abdded200..c3daeb69daf 100644 --- a/internal/watcher/synthesizer/file.go +++ b/internal/watcher/synthesizer/file.go @@ -175,6 +175,7 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) [] CreatedAt: now, UpdatedAt: now, } + coreauth.ApplyBaseURLFromMetadata(a) // Read priority from auth file. if rawPriority, ok := metadata["priority"]; ok { switch v := rawPriority.(type) { diff --git a/internal/watcher/synthesizer/file_test.go b/internal/watcher/synthesizer/file_test.go index b52385549c3..394b803e702 100644 --- a/internal/watcher/synthesizer/file_test.go +++ b/internal/watcher/synthesizer/file_test.go @@ -132,6 +132,72 @@ func TestFileSynthesizer_Synthesize_ValidAuthFile(t *testing.T) { } } +func TestFileSynthesizer_Synthesize_AuthFileBaseURL(t *testing.T) { + tempDir := t.TempDir() + + authData := map[string]any{ + "type": "codex", + "email": "codex@example.com", + "base_url": "http://127.0.0.1:18081", + } + data, _ := json.Marshal(authData) + if err := os.WriteFile(filepath.Join(tempDir, "codex-auth.json"), data, 0644); err != nil { + t.Fatalf("failed to write auth file: %v", err) + } + + synth := NewFileSynthesizer() + ctx := &SynthesisContext{ + Config: &config.Config{}, + AuthDir: tempDir, + Now: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), + IDGenerator: NewStableIDGenerator(), + } + + auths, err := synth.Synthesize(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(auths) != 1 { + t.Fatalf("expected 1 auth, got %d", len(auths)) + } + if got := auths[0].Attributes["base_url"]; got != "http://127.0.0.1:18081" { + t.Fatalf("base_url attr = %q, want %q", got, "http://127.0.0.1:18081") + } +} + +func TestFileSynthesizer_Synthesize_AuthFileBaseURLDashAlias(t *testing.T) { + tempDir := t.TempDir() + + authData := map[string]any{ + "type": "claude", + "email": "claude@example.com", + "base-url": "http://127.0.0.1:18082", + } + data, _ := json.Marshal(authData) + if err := os.WriteFile(filepath.Join(tempDir, "claude-auth.json"), data, 0644); err != nil { + t.Fatalf("failed to write auth file: %v", err) + } + + synth := NewFileSynthesizer() + ctx := &SynthesisContext{ + Config: &config.Config{}, + AuthDir: tempDir, + Now: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), + IDGenerator: NewStableIDGenerator(), + } + + auths, err := synth.Synthesize(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(auths) != 1 { + t.Fatalf("expected 1 auth, got %d", len(auths)) + } + if got := auths[0].Attributes["base_url"]; got != "http://127.0.0.1:18082" { + t.Fatalf("base_url attr = %q, want %q", got, "http://127.0.0.1:18082") + } +} + func TestFileSynthesizer_Synthesize_IgnoresGeminiProviderFile(t *testing.T) { tempDir := t.TempDir() diff --git a/sdk/auth/filestore.go b/sdk/auth/filestore.go index 9e17589d206..5edfac0a49e 100644 --- a/sdk/auth/filestore.go +++ b/sdk/auth/filestore.go @@ -326,6 +326,7 @@ func (s *FileTokenStore) readAuthFiles(path, baseDir string) ([]*cliproxyauth.Au LastRefreshedAt: time.Time{}, NextRefreshAfter: time.Time{}, } + cliproxyauth.ApplyBaseURLFromMetadata(auth) if email, ok := metadata["email"].(string); ok && email != "" { auth.Attributes["email"] = email } diff --git a/sdk/auth/filestore_test.go b/sdk/auth/filestore_test.go index 32164bed16e..ae91bbe32b2 100644 --- a/sdk/auth/filestore_test.go +++ b/sdk/auth/filestore_test.go @@ -158,6 +158,48 @@ func TestFileTokenStoreListExpandsPluginMultiAuths(t *testing.T) { } } +func TestFileTokenStoreListCopiesAuthFileBaseURL(t *testing.T) { + baseDir := t.TempDir() + path := filepath.Join(baseDir, "codex.json") + if errWrite := os.WriteFile(path, []byte(`{"type":"codex","email":"codex@example.com","base_url":"http://127.0.0.1:18081"}`), 0o600); errWrite != nil { + t.Fatalf("write auth file: %v", errWrite) + } + + store := NewFileTokenStore() + store.SetBaseDir(baseDir) + auths, errList := store.List(context.Background()) + if errList != nil { + t.Fatalf("List() error = %v", errList) + } + if len(auths) != 1 { + t.Fatalf("List() len = %d, want one auth", len(auths)) + } + if got := auths[0].Attributes["base_url"]; got != "http://127.0.0.1:18081" { + t.Fatalf("base_url attr = %q, want %q", got, "http://127.0.0.1:18081") + } +} + +func TestFileTokenStoreListCopiesAuthFileBaseURLDashAlias(t *testing.T) { + baseDir := t.TempDir() + path := filepath.Join(baseDir, "claude.json") + if errWrite := os.WriteFile(path, []byte(`{"type":"claude","email":"claude@example.com","base-url":"http://127.0.0.1:18082"}`), 0o600); errWrite != nil { + t.Fatalf("write auth file: %v", errWrite) + } + + store := NewFileTokenStore() + store.SetBaseDir(baseDir) + auths, errList := store.List(context.Background()) + if errList != nil { + t.Fatalf("List() error = %v", errList) + } + if len(auths) != 1 { + t.Fatalf("List() len = %d, want one auth", len(auths)) + } + if got := auths[0].Attributes["base_url"]; got != "http://127.0.0.1:18082" { + t.Fatalf("base_url attr = %q, want %q", got, "http://127.0.0.1:18082") + } +} + func TestFileTokenStoreListPluginHandledEmptySuppressesBuiltin(t *testing.T) { baseDir := t.TempDir() path := filepath.Join(baseDir, "codex.json") diff --git a/sdk/cliproxy/auth/base_url.go b/sdk/cliproxy/auth/base_url.go new file mode 100644 index 00000000000..c53d5282f9c --- /dev/null +++ b/sdk/cliproxy/auth/base_url.go @@ -0,0 +1,47 @@ +package auth + +import "strings" + +// BaseURLFromMetadata returns the per-auth upstream base URL stored in auth metadata. +// Both base_url and base-url are accepted to match config naming conventions. +func BaseURLFromMetadata(metadata map[string]any) string { + for _, key := range []string{"base_url", "base-url"} { + if raw, ok := metadata[key].(string); ok { + if trimmed := strings.TrimSpace(raw); trimmed != "" { + return trimmed + } + } + } + return "" +} + +// ApplyBaseURLFromMetadata copies the per-auth upstream base URL from metadata +// into auth attributes so executors can resolve it consistently across stores. +func ApplyBaseURLFromMetadata(auth *Auth) { + if auth == nil { + return + } + if !shouldApplyBaseURLFromMetadata(auth) { + return + } + baseURL := BaseURLFromMetadata(auth.Metadata) + if baseURL == "" { + return + } + if auth.Attributes == nil { + auth.Attributes = make(map[string]string) + } + auth.Attributes["base_url"] = baseURL +} + +func shouldApplyBaseURLFromMetadata(auth *Auth) bool { + if auth == nil || auth.AuthKind() != AuthKindOAuth { + return false + } + switch strings.ToLower(strings.TrimSpace(auth.Provider)) { + case "claude", "codex": + return true + default: + return false + } +} diff --git a/sdk/cliproxy/auth/base_url_test.go b/sdk/cliproxy/auth/base_url_test.go new file mode 100644 index 00000000000..e5ddd15af40 --- /dev/null +++ b/sdk/cliproxy/auth/base_url_test.go @@ -0,0 +1,58 @@ +package auth + +import "testing" + +func TestBaseURLFromMetadata(t *testing.T) { + tests := []struct { + name string + metadata map[string]any + want string + }{ + {name: "underscore", metadata: map[string]any{"base_url": " http://127.0.0.1:18081 "}, want: "http://127.0.0.1:18081"}, + {name: "dash alias", metadata: map[string]any{"base-url": "http://127.0.0.1:18082"}, want: "http://127.0.0.1:18082"}, + {name: "underscore wins", metadata: map[string]any{"base_url": "http://a", "base-url": "http://b"}, want: "http://a"}, + {name: "empty", metadata: map[string]any{"base_url": " "}, want: ""}, + {name: "wrong type", metadata: map[string]any{"base_url": 123}, want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := BaseURLFromMetadata(tt.metadata); got != tt.want { + t.Fatalf("BaseURLFromMetadata() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestApplyBaseURLFromMetadata(t *testing.T) { + auth := &Auth{ + Provider: "claude", + Metadata: map[string]any{"base_url": "http://127.0.0.1:18081", "access_token": "oauth-token"}, + } + ApplyBaseURLFromMetadata(auth) + if got := auth.Attributes["base_url"]; got != "http://127.0.0.1:18081" { + t.Fatalf("base_url attr = %q, want copied metadata base URL", got) + } +} + +func TestApplyBaseURLFromMetadataSkipsPluginProvider(t *testing.T) { + auth := &Auth{ + Provider: "my-plugin", + Metadata: map[string]any{"base_url": "http://127.0.0.1:18081", "access_token": "oauth-token"}, + } + ApplyBaseURLFromMetadata(auth) + if _, ok := auth.Attributes["base_url"]; ok { + t.Fatalf("base_url attr should not be copied for plugin provider") + } +} + +func TestApplyBaseURLFromMetadataSkipsAPIKeyAuth(t *testing.T) { + auth := &Auth{ + Provider: "claude", + Attributes: map[string]string{AttributeAPIKey: "sk-ant-test"}, + Metadata: map[string]any{"base_url": "http://127.0.0.1:18081"}, + } + ApplyBaseURLFromMetadata(auth) + if _, ok := auth.Attributes["base_url"]; ok { + t.Fatalf("base_url attr should not be copied for API-key auth") + } +}