Skip to content

Commit 2357dd5

Browse files
committed
feat(oauth): allow upstream base URL overrides
1 parent cde9336 commit 2357dd5

22 files changed

Lines changed: 515 additions & 17 deletions

config.example.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ remote-management:
3535
# Authentication directory (supports ~ for home directory)
3636
auth-dir: "~/.cli-proxy-api"
3737

38+
# OAuth auth files may optionally include base_url or base-url to override the
39+
# upstream endpoint for that account only. This is useful for routing one OAuth
40+
# account through a local gateway while leaving other accounts on the default
41+
# provider endpoint. Example auth file field:
42+
# "base_url": "http://127.0.0.1:18081"
43+
3844
# API keys for authentication
3945
api-keys:
4046
- "your-api-key-1"
@@ -106,6 +112,13 @@ redis-usage-queue-retention-seconds: 60
106112
# Per-entry proxy-url also supports "direct" or "none" to bypass both the global proxy-url and environment proxies explicitly.
107113
proxy-url: ""
108114

115+
# Provider-wide upstream base URL overrides for OAuth/file-backed credentials.
116+
# Per-auth-file base_url/base-url values and per-api-key base-url values take precedence.
117+
# Empty keeps the official upstream defaults.
118+
# claude-base-url: "http://127.0.0.1:18081"
119+
# codex-base-url: "http://127.0.0.1:18082"
120+
121+
109122
# When true, unprefixed model requests only use credentials without a prefix (except when prefix == model name).
110123
force-model-prefix: false
111124

internal/config/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ type Config struct {
122122
// Codex defines a list of Codex API key configurations as specified in the YAML configuration file.
123123
CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"`
124124

125+
// CodexBaseURL globally overrides the upstream Codex API endpoint for
126+
// OAuth/file-backed credentials that do not set their own base URL.
127+
// Per-credential base URLs still take precedence.
128+
CodexBaseURL string `yaml:"codex-base-url,omitempty" json:"codex-base-url,omitempty"`
129+
125130
// Codex configures provider-wide Codex request behavior.
126131
Codex CodexConfig `yaml:"codex" json:"codex"`
127132

@@ -132,6 +137,11 @@ type Config struct {
132137
// ClaudeKey defines a list of Claude API key configurations as specified in the YAML configuration file.
133138
ClaudeKey []ClaudeKey `yaml:"claude-api-key" json:"claude-api-key"`
134139

140+
// ClaudeBaseURL globally overrides the upstream Claude API endpoint for
141+
// OAuth/file-backed credentials that do not set their own base URL.
142+
// Per-credential base URLs still take precedence.
143+
ClaudeBaseURL string `yaml:"claude-base-url,omitempty" json:"claude-base-url,omitempty"`
144+
135145
// ClaudeHeaderDefaults configures default header values for Claude API requests.
136146
// These are used as fallbacks when the client does not send its own headers.
137147
ClaudeHeaderDefaults ClaudeHeaderDefaults `yaml:"claude-header-defaults" json:"claude-header-defaults"`

internal/runtime/executor/claude_executor.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ func (e *ClaudeExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Au
158158
if req == nil {
159159
return nil
160160
}
161-
apiKey, _ := claudeCreds(auth)
161+
apiKey, _ := claudeCredsWithConfig(e.cfg, auth)
162162
if strings.TrimSpace(apiKey) == "" {
163163
return nil
164164
}
@@ -201,7 +201,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
201201
}
202202
baseModel := thinking.ParseSuffix(req.Model).ModelName
203203

204-
apiKey, baseURL := claudeCreds(auth)
204+
apiKey, baseURL := claudeCredsWithConfig(e.cfg, auth)
205205
if baseURL == "" {
206206
baseURL = "https://api.anthropic.com"
207207
}
@@ -393,7 +393,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
393393
}
394394
baseModel := thinking.ParseSuffix(req.Model).ModelName
395395

396-
apiKey, baseURL := claudeCreds(auth)
396+
apiKey, baseURL := claudeCredsWithConfig(e.cfg, auth)
397397
if baseURL == "" {
398398
baseURL = "https://api.anthropic.com"
399399
}
@@ -675,7 +675,7 @@ func validateClaudeStreamingResponse(data []byte) error {
675675
func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
676676
baseModel := thinking.ParseSuffix(req.Model).ModelName
677677

678-
apiKey, baseURL := claudeCreds(auth)
678+
apiKey, baseURL := claudeCredsWithConfig(e.cfg, auth)
679679
if baseURL == "" {
680680
baseURL = "https://api.anthropic.com"
681681
}
@@ -1135,12 +1135,25 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string,
11351135
}
11361136

11371137
func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
1138+
return claudeCredsWithConfig(nil, a)
1139+
}
1140+
1141+
func claudeCredsWithConfig(cfg *config.Config, a *cliproxyauth.Auth) (apiKey, baseURL string) {
11381142
if a == nil {
11391143
return "", ""
11401144
}
11411145
if a.Attributes != nil {
11421146
apiKey = a.Attributes["api_key"]
1143-
baseURL = a.Attributes["base_url"]
1147+
baseURL = strings.TrimSpace(a.Attributes["base_url"])
1148+
}
1149+
if baseURL == "" && a.AuthKind() == cliproxyauth.AuthKindOAuth && a.Metadata != nil {
1150+
baseURL = cliproxyauth.BaseURLFromMetadata(a.Metadata)
1151+
}
1152+
if baseURL == "" && a.AuthKind() == cliproxyauth.AuthKindOAuth && cfg != nil {
1153+
baseURL = strings.TrimSpace(cfg.ClaudeBaseURL)
1154+
}
1155+
if baseURL != "" {
1156+
baseURL = normalizeClaudeBaseURL(baseURL)
11441157
}
11451158
if apiKey == "" && a.Metadata != nil {
11461159
if v, ok := a.Metadata["access_token"].(string); ok {

internal/runtime/executor/claude_executor_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2887,3 +2887,82 @@ func TestRestoreClaudeOAuthToolNamesFromStreamLine_MixedCaseWithPrefix(t *testin
28872887
t.Fatalf("Glob should be restored to glob, got: %s", string(out))
28882888
}
28892889
}
2890+
2891+
func TestClaudeCredsUsesGlobalBaseURLWhenAuthHasNoBaseURL(t *testing.T) {
2892+
apiKey, baseURL := claudeCredsWithConfig(&config.Config{ClaudeBaseURL: "http://127.0.0.1:18081"}, &cliproxyauth.Auth{
2893+
Metadata: map[string]any{"access_token": "oauth-token"},
2894+
})
2895+
if apiKey != "oauth-token" {
2896+
t.Fatalf("apiKey = %q, want oauth-token", apiKey)
2897+
}
2898+
if baseURL != "http://127.0.0.1:18081" {
2899+
t.Fatalf("baseURL = %q, want global override", baseURL)
2900+
}
2901+
}
2902+
2903+
func TestClaudeCredsAuthBaseURLPrecedesGlobalBaseURL(t *testing.T) {
2904+
_, baseURL := claudeCredsWithConfig(&config.Config{ClaudeBaseURL: "http://127.0.0.1:18081"}, &cliproxyauth.Auth{
2905+
Attributes: map[string]string{"base_url": "http://127.0.0.1:18101"},
2906+
Metadata: map[string]any{"access_token": "oauth-token"},
2907+
})
2908+
if baseURL != "http://127.0.0.1:18101" {
2909+
t.Fatalf("baseURL = %q, want auth override", baseURL)
2910+
}
2911+
}
2912+
2913+
func TestClaudeCredsMetadataBaseURLPrecedesGlobalBaseURL(t *testing.T) {
2914+
_, baseURL := claudeCredsWithConfig(&config.Config{ClaudeBaseURL: "http://127.0.0.1:18081"}, &cliproxyauth.Auth{
2915+
Metadata: map[string]any{"access_token": "oauth-token", "base-url": "http://127.0.0.1:18103/"},
2916+
})
2917+
if baseURL != "http://127.0.0.1:18103" {
2918+
t.Fatalf("baseURL = %q, want metadata override", baseURL)
2919+
}
2920+
}
2921+
2922+
func TestClaudeCredsDoesNotApplyGlobalBaseURLToAPIKeyAuth(t *testing.T) {
2923+
_, baseURL := claudeCredsWithConfig(&config.Config{ClaudeBaseURL: "http://127.0.0.1:18081"}, &cliproxyauth.Auth{
2924+
Attributes: map[string]string{"api_key": "sk-ant-test"},
2925+
})
2926+
if baseURL != "" {
2927+
t.Fatalf("baseURL = %q, want empty for API-key auth without per-key base_url", baseURL)
2928+
}
2929+
}
2930+
2931+
func TestClaudeCredsTrimsTrailingSlashFromBaseURLOverrides(t *testing.T) {
2932+
_, authBaseURL := claudeCredsWithConfig(&config.Config{ClaudeBaseURL: "http://127.0.0.1:18081/"}, &cliproxyauth.Auth{
2933+
Attributes: map[string]string{"base_url": "http://127.0.0.1:18101/"},
2934+
Metadata: map[string]any{"access_token": "oauth-token"},
2935+
})
2936+
if authBaseURL != "http://127.0.0.1:18101" {
2937+
t.Fatalf("auth baseURL = %q, want trimmed trailing slash", authBaseURL)
2938+
}
2939+
2940+
_, globalBaseURL := claudeCredsWithConfig(&config.Config{ClaudeBaseURL: "http://127.0.0.1:18081/"}, &cliproxyauth.Auth{
2941+
Metadata: map[string]any{"access_token": "oauth-token"},
2942+
})
2943+
if globalBaseURL != "http://127.0.0.1:18081" {
2944+
t.Fatalf("global baseURL = %q, want trimmed trailing slash", globalBaseURL)
2945+
}
2946+
}
2947+
2948+
func TestResolveClaudeKeyConfigNormalizesTrailingSlashForBaseURLMatch(t *testing.T) {
2949+
cfg := &config.Config{
2950+
ClaudeKey: []config.ClaudeKey{{
2951+
APIKey: "key-123",
2952+
BaseURL: "http://127.0.0.1:18101/",
2953+
Cloak: &config.CloakConfig{Mode: "always"},
2954+
}},
2955+
}
2956+
auth := &cliproxyauth.Auth{Attributes: map[string]string{
2957+
"api_key": "key-123",
2958+
"base_url": "http://127.0.0.1:18101/",
2959+
}}
2960+
2961+
cloakCfg := resolveClaudeKeyCloakConfig(cfg, auth)
2962+
if cloakCfg == nil {
2963+
t.Fatal("expected trailing-slash base URL to match Claude key config")
2964+
}
2965+
if cloakCfg.Mode != "always" {
2966+
t.Fatalf("cloak mode = %q, want always", cloakCfg.Mode)
2967+
}
2968+
}

internal/runtime/executor/claude_signing.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ func resolveClaudeKeyConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config
5353
for i := range cfg.ClaudeKey {
5454
entry := &cfg.ClaudeKey[i]
5555
cfgKey := strings.TrimSpace(entry.APIKey)
56-
cfgBase := strings.TrimSpace(entry.BaseURL)
56+
cfgBase := normalizeClaudeBaseURL(entry.BaseURL)
5757
if !strings.EqualFold(cfgKey, apiKey) {
5858
continue
5959
}
@@ -66,6 +66,10 @@ func resolveClaudeKeyConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config
6666
return nil
6767
}
6868

69+
func normalizeClaudeBaseURL(baseURL string) string {
70+
return strings.TrimRight(strings.TrimSpace(baseURL), "/")
71+
}
72+
6973
// resolveClaudeKeyCloakConfig finds the matching ClaudeKey config and returns its CloakConfig.
7074
func resolveClaudeKeyCloakConfig(cfg *config.Config, auth *cliproxyauth.Auth) *config.CloakConfig {
7175
entry := resolveClaudeKeyConfig(cfg, auth)

internal/runtime/executor/codex_executor.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -710,7 +710,7 @@ func (e *CodexExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Aut
710710
if req == nil {
711711
return nil
712712
}
713-
apiKey, _ := codexCreds(auth)
713+
apiKey, _ := codexCredsWithConfig(e.cfg, auth)
714714
if strings.TrimSpace(apiKey) != "" {
715715
req.Header.Set("Authorization", "Bearer "+apiKey)
716716
}
@@ -747,7 +747,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
747747
}
748748
baseModel := thinking.ParseSuffix(req.Model).ModelName
749749

750-
apiKey, baseURL := codexCreds(auth)
750+
apiKey, baseURL := codexCredsWithConfig(e.cfg, auth)
751751
if baseURL == "" {
752752
baseURL = "https://chatgpt.com/backend-api/codex"
753753
}
@@ -927,7 +927,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
927927
func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
928928
baseModel := thinking.ParseSuffix(req.Model).ModelName
929929

930-
apiKey, baseURL := codexCreds(auth)
930+
apiKey, baseURL := codexCredsWithConfig(e.cfg, auth)
931931
if baseURL == "" {
932932
baseURL = "https://chatgpt.com/backend-api/codex"
933933
}
@@ -1034,7 +1034,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
10341034
}
10351035
baseModel := thinking.ParseSuffix(req.Model).ModelName
10361036

1037-
apiKey, baseURL := codexCreds(auth)
1037+
apiKey, baseURL := codexCredsWithConfig(e.cfg, auth)
10381038
if baseURL == "" {
10391039
baseURL = "https://chatgpt.com/backend-api/codex"
10401040
}
@@ -1853,12 +1853,22 @@ func parseCodexRetryAfter(statusCode int, errorBody []byte, now time.Time) *time
18531853
}
18541854

18551855
func codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
1856+
return codexCredsWithConfig(nil, a)
1857+
}
1858+
1859+
func codexCredsWithConfig(cfg *config.Config, a *cliproxyauth.Auth) (apiKey, baseURL string) {
18561860
if a == nil {
18571861
return "", ""
18581862
}
18591863
if a.Attributes != nil {
18601864
apiKey = a.Attributes["api_key"]
1861-
baseURL = a.Attributes["base_url"]
1865+
baseURL = strings.TrimSpace(a.Attributes["base_url"])
1866+
}
1867+
if baseURL == "" && a.AuthKind() == cliproxyauth.AuthKindOAuth && a.Metadata != nil {
1868+
baseURL = cliproxyauth.BaseURLFromMetadata(a.Metadata)
1869+
}
1870+
if baseURL == "" && a.AuthKind() == cliproxyauth.AuthKindOAuth && cfg != nil {
1871+
baseURL = strings.TrimSpace(cfg.CodexBaseURL)
18621872
}
18631873
if apiKey == "" && a.Metadata != nil {
18641874
if v, ok := a.Metadata["access_token"].(string); ok {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package executor
2+
3+
import (
4+
"testing"
5+
6+
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
7+
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
8+
)
9+
10+
func TestCodexCredsUsesGlobalBaseURLWhenAuthHasNoBaseURL(t *testing.T) {
11+
apiKey, baseURL := codexCredsWithConfig(&config.Config{CodexBaseURL: "http://127.0.0.1:18082"}, &cliproxyauth.Auth{
12+
Metadata: map[string]any{"access_token": "oauth-token"},
13+
})
14+
if apiKey != "oauth-token" {
15+
t.Fatalf("apiKey = %q, want oauth-token", apiKey)
16+
}
17+
if baseURL != "http://127.0.0.1:18082" {
18+
t.Fatalf("baseURL = %q, want global override", baseURL)
19+
}
20+
}
21+
22+
func TestCodexCredsAuthBaseURLPrecedesGlobalBaseURL(t *testing.T) {
23+
_, baseURL := codexCredsWithConfig(&config.Config{CodexBaseURL: "http://127.0.0.1:18082"}, &cliproxyauth.Auth{
24+
Attributes: map[string]string{"base_url": "http://127.0.0.1:18102"},
25+
Metadata: map[string]any{"access_token": "oauth-token"},
26+
})
27+
if baseURL != "http://127.0.0.1:18102" {
28+
t.Fatalf("baseURL = %q, want auth override", baseURL)
29+
}
30+
}
31+
32+
func TestCodexCredsMetadataBaseURLPrecedesGlobalBaseURL(t *testing.T) {
33+
_, baseURL := codexCredsWithConfig(&config.Config{CodexBaseURL: "http://127.0.0.1:18082"}, &cliproxyauth.Auth{
34+
Metadata: map[string]any{"access_token": "oauth-token", "base_url": "http://127.0.0.1:18104"},
35+
})
36+
if baseURL != "http://127.0.0.1:18104" {
37+
t.Fatalf("baseURL = %q, want metadata override", baseURL)
38+
}
39+
}
40+
41+
func TestCodexCredsDoesNotApplyGlobalBaseURLToAPIKeyAuth(t *testing.T) {
42+
_, baseURL := codexCredsWithConfig(&config.Config{CodexBaseURL: "http://127.0.0.1:18082"}, &cliproxyauth.Auth{
43+
Attributes: map[string]string{"api_key": "sk-codex-test"},
44+
})
45+
if baseURL != "" {
46+
t.Fatalf("baseURL = %q, want empty for API-key auth without per-key base_url", baseURL)
47+
}
48+
}

internal/runtime/executor/codex_openai_images.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau
9191
return resp, errPrepare
9292
}
9393

94-
apiKey, baseURL := codexCreds(auth)
94+
apiKey, baseURL := codexCredsWithConfig(e.cfg, auth)
9595
if baseURL == "" {
9696
baseURL = "https://chatgpt.com/backend-api/codex"
9797
}
@@ -187,7 +187,7 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip
187187
return nil, errPrepare
188188
}
189189

190-
apiKey, baseURL := codexCreds(auth)
190+
apiKey, baseURL := codexCredsWithConfig(e.cfg, auth)
191191
if baseURL == "" {
192192
baseURL = "https://chatgpt.com/backend-api/codex"
193193
}
@@ -319,7 +319,7 @@ func (e *CodexExecutor) executeDirectOpenAIImage(ctx context.Context, auth *clip
319319
return resp, errPrepare
320320
}
321321

322-
apiKey, baseURL := codexCreds(auth)
322+
apiKey, baseURL := codexCredsWithConfig(e.cfg, auth)
323323
if baseURL == "" {
324324
baseURL = "https://chatgpt.com/backend-api/codex"
325325
}
@@ -379,7 +379,7 @@ func (e *CodexExecutor) executeDirectOpenAIImageStream(ctx context.Context, auth
379379
return nil, errPrepare
380380
}
381381

382-
apiKey, baseURL := codexCreds(auth)
382+
apiKey, baseURL := codexCredsWithConfig(e.cfg, auth)
383383
if baseURL == "" {
384384
baseURL = "https://chatgpt.com/backend-api/codex"
385385
}

internal/runtime/executor/codex_openai_images_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,3 +315,26 @@ func TestCodexExecutorDirectOpenAIImageEditUsesImagesEditEndpointForMultipart(t
315315
t.Fatalf("mask.image_url = %q, want mask-data data URL; body=%s", maskURL, string(gotBody))
316316
}
317317
}
318+
319+
func TestCodexExecutorDirectOpenAIImageGenerationUsesGlobalBaseURLForOAuthAuth(t *testing.T) {
320+
var gotPath string
321+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
322+
gotPath = r.URL.Path
323+
w.Header().Set("Content-Type", "application/json")
324+
_, _ = w.Write([]byte(`{"created":1713833628,"data":[{"b64_json":"AA=="}],"usage":{"total_tokens":1,"input_tokens":1,"output_tokens":0}}`))
325+
}))
326+
defer server.Close()
327+
328+
executor := NewCodexExecutor(&config.Config{CodexBaseURL: server.URL})
329+
auth := &cliproxyauth.Auth{Provider: "codex", Metadata: map[string]any{"access_token": "oauth-token"}}
330+
_, errExecute := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
331+
Model: "codex/gpt-image-1.5",
332+
Payload: []byte(`{"model":"codex/gpt-image-1.5","prompt":"draw","stream":false}`),
333+
}, codexOpenAIImageTestOptions(codexImagesGenerationsPath, false))
334+
if errExecute != nil {
335+
t.Fatalf("Execute() error = %v", errExecute)
336+
}
337+
if gotPath != "/images/generations" {
338+
t.Fatalf("path = %q, want /images/generations", gotPath)
339+
}
340+
}

0 commit comments

Comments
 (0)