From 2a6ad7bf1765bf4894de8eeba67d26254b62fcab Mon Sep 17 00:00:00 2001 From: admccc Date: Wed, 11 Mar 2026 18:39:21 +0800 Subject: [PATCH 01/13] fix: normalize claude base URL to support /v1 suffix in relay services When users configure relay services (e.g., new-api) as Claude upstream, they often set base-url with a trailing /v1 (e.g., https://example.com/v1). The code was appending /v1/messages to the base URL, resulting in double /v1 path (/v1/v1/messages) causing 404 errors. Add normalizeClaudeBaseURL() helper that strips trailing slashes and /v1 suffix before URL construction in Execute, ExecuteStream and CountTokens. Fixes: https://github.com/router-for-me/CLIProxyAPI/issues/2055 --- internal/runtime/executor/claude_executor.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 82b12a2f80..e6b888c48b 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -101,6 +101,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if baseURL == "" { baseURL = "https://api.anthropic.com" } + baseURL = normalizeClaudeBaseURL(baseURL) reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth) defer reporter.trackFailure(ctx, &err) @@ -269,6 +270,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if baseURL == "" { baseURL = "https://api.anthropic.com" } + baseURL = normalizeClaudeBaseURL(baseURL) reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth) defer reporter.trackFailure(ctx, &err) @@ -462,6 +464,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut if baseURL == "" { baseURL = "https://api.anthropic.com" } + baseURL = normalizeClaudeBaseURL(baseURL) from := opts.SourceFormat to := sdktranslator.FromString("claude") @@ -928,6 +931,16 @@ func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { return } +// normalizeClaudeBaseURL normalizes the base URL for Claude API requests by removing +// trailing slashes and the /v1 path suffix if present. This allows users to configure +// base URLs with or without the /v1 prefix (e.g., "https://example.com" or +// "https://example.com/v1"), both of which are common when using relay services like new-api. +func normalizeClaudeBaseURL(baseURL string) string { + baseURL = strings.TrimRight(baseURL, "/") + baseURL = strings.TrimSuffix(baseURL, "/v1") + return baseURL +} + func checkSystemInstructions(payload []byte) []byte { return checkSystemInstructionsWithMode(payload, false) } From e293e6c9a2b9c6833158265610cc95cc0409df78 Mon Sep 17 00:00:00 2001 From: admccc Date: Wed, 11 Mar 2026 18:39:29 +0800 Subject: [PATCH 02/13] test: add TestNormalizeClaudeBaseURL for base URL normalization Add unit tests for the normalizeClaudeBaseURL helper, covering: - plain URL without version suffix (unchanged) - trailing slash removal - /v1 suffix stripping - /v1 with trailing slash - longer paths with /v1 - unrelated paths - empty string --- .../runtime/executor/claude_executor_test.go | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index fa458c0fd0..0a8a7c41bd 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -1064,3 +1064,58 @@ func TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) { t.Fatalf("blocks[2] text mangled, got %q", blocks[2].Get("text").String()) } } + +// TestNormalizeClaudeBaseURL verifies that base URLs with or without the /v1 suffix +// are normalized correctly, preventing double /v1 paths when relay services are used +// as upstream (e.g., new-api). Fixes: https://github.com/router-for-me/CLIProxyAPI/issues/2055 +func TestNormalizeClaudeBaseURL(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "plain URL unchanged", + input: "https://api.anthropic.com", + expected: "https://api.anthropic.com", + }, + { + name: "trailing slash removed", + input: "https://api.anthropic.com/", + expected: "https://api.anthropic.com", + }, + { + name: "/v1 suffix stripped", + input: "https://new-api.example.com/v1", + expected: "https://new-api.example.com", + }, + { + name: "/v1 with trailing slash stripped", + input: "https://new-api.example.com/v1/", + expected: "https://new-api.example.com", + }, + { + name: "path longer than /v1 preserved", + input: "https://example.com/api/v1", + expected: "https://example.com/api", + }, + { + name: "unrelated path preserved", + input: "https://example.com/claude", + expected: "https://example.com/claude", + }, + { + name: "empty string unchanged", + input: "", + expected: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeClaudeBaseURL(tt.input) + if got != tt.expected { + t.Errorf("normalizeClaudeBaseURL(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} From 86b6cc4c12affbde97d08aecb0f75209d739adcc Mon Sep 17 00:00:00 2001 From: admccc Date: Wed, 11 Mar 2026 18:49:15 +0800 Subject: [PATCH 03/13] fix: replace hardcoded /v1/messages with configurable messages-path Add claudeMessagesPath() helper that reads messages_path from auth.Attributes, falling back to "/v1/messages" by default. Use this helper in Execute, ExecuteStream, and CountTokens to build upstream URLs. This allows users to configure relay services (e.g., new-api) that expose Claude-compatible APIs at a non-standard path via the new messages-path config field. --- internal/runtime/executor/claude_executor.go | 27 ++++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index e6b888c48b..f8a0671257 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -101,7 +101,6 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if baseURL == "" { baseURL = "https://api.anthropic.com" } - baseURL = normalizeClaudeBaseURL(baseURL) reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth) defer reporter.trackFailure(ctx, &err) @@ -156,7 +155,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix) } - url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL) + url := fmt.Sprintf("%s%s?beta=true", baseURL, claudeMessagesPath(auth)) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream)) if err != nil { return resp, err @@ -270,7 +269,6 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if baseURL == "" { baseURL = "https://api.anthropic.com" } - baseURL = normalizeClaudeBaseURL(baseURL) reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth) defer reporter.trackFailure(ctx, &err) @@ -320,7 +318,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix) } - url := fmt.Sprintf("%s/v1/messages?beta=true", baseURL) + url := fmt.Sprintf("%s%s?beta=true", baseURL, claudeMessagesPath(auth)) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream)) if err != nil { return nil, err @@ -464,7 +462,6 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut if baseURL == "" { baseURL = "https://api.anthropic.com" } - baseURL = normalizeClaudeBaseURL(baseURL) from := opts.SourceFormat to := sdktranslator.FromString("claude") @@ -488,7 +485,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut body = applyClaudeToolPrefix(body, claudeToolPrefix) } - url := fmt.Sprintf("%s/v1/messages/count_tokens?beta=true", baseURL) + url := fmt.Sprintf("%s%s/count_tokens?beta=true", baseURL, claudeMessagesPath(auth)) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return cliproxyexecutor.Response{}, err @@ -931,14 +928,16 @@ func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { return } -// normalizeClaudeBaseURL normalizes the base URL for Claude API requests by removing -// trailing slashes and the /v1 path suffix if present. This allows users to configure -// base URLs with or without the /v1 prefix (e.g., "https://example.com" or -// "https://example.com/v1"), both of which are common when using relay services like new-api. -func normalizeClaudeBaseURL(baseURL string) string { - baseURL = strings.TrimRight(baseURL, "/") - baseURL = strings.TrimSuffix(baseURL, "/v1") - return baseURL +// claudeMessagesPath returns the configured messages API path for the given auth, +// falling back to the default "/v1/messages". This allows users to configure relay +// services (e.g., new-api) that expose Claude-compatible APIs at non-standard paths. +func claudeMessagesPath(a *cliproxyauth.Auth) string { + if a != nil && a.Attributes != nil { + if mp := strings.TrimSpace(a.Attributes["messages_path"]); mp != "" { + return mp + } + } + return "/v1/messages" } func checkSystemInstructions(payload []byte) []byte { From fbbdf2d70faf279001e03009fcd5c6476c15a9b0 Mon Sep 17 00:00:00 2001 From: admccc Date: Wed, 11 Mar 2026 18:49:25 +0800 Subject: [PATCH 04/13] test: replace TestNormalizeClaudeBaseURL with TestClaudeMessagesPath Test the new claudeMessagesPath helper for custom path support. --- .../runtime/executor/claude_executor_test.go | 55 ++++++++----------- 1 file changed, 23 insertions(+), 32 deletions(-) diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 0a8a7c41bd..319c106564 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -1065,56 +1065,47 @@ func TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) { } } -// TestNormalizeClaudeBaseURL verifies that base URLs with or without the /v1 suffix -// are normalized correctly, preventing double /v1 paths when relay services are used -// as upstream (e.g., new-api). Fixes: https://github.com/router-for-me/CLIProxyAPI/issues/2055 -func TestNormalizeClaudeBaseURL(t *testing.T) { +// TestClaudeMessagesPath verifies that claudeMessagesPath returns the correct API path, +// defaulting to /v1/messages when not configured and using the custom path when set. +// This supports relay services (e.g., new-api) that expose Claude-compatible APIs at +// non-standard paths. Fixes: https://github.com/router-for-me/CLIProxyAPI/issues/2055 +func TestClaudeMessagesPath(t *testing.T) { tests := []struct { name string - input string + auth *cliproxyauth.Auth expected string }{ { - name: "plain URL unchanged", - input: "https://api.anthropic.com", - expected: "https://api.anthropic.com", + name: "nil auth returns default", + auth: nil, + expected: "/v1/messages", }, { - name: "trailing slash removed", - input: "https://api.anthropic.com/", - expected: "https://api.anthropic.com", + name: "empty attributes returns default", + auth: &cliproxyauth.Auth{Attributes: map[string]string{}}, + expected: "/v1/messages", }, { - name: "/v1 suffix stripped", - input: "https://new-api.example.com/v1", - expected: "https://new-api.example.com", + name: "custom path without /v1", + auth: &cliproxyauth.Auth{Attributes: map[string]string{"messages_path": "/messages"}}, + expected: "/messages", }, { - name: "/v1 with trailing slash stripped", - input: "https://new-api.example.com/v1/", - expected: "https://new-api.example.com", + name: "custom path with /v1", + auth: &cliproxyauth.Auth{Attributes: map[string]string{"messages_path": "/v1/messages"}}, + expected: "/v1/messages", }, { - name: "path longer than /v1 preserved", - input: "https://example.com/api/v1", - expected: "https://example.com/api", - }, - { - name: "unrelated path preserved", - input: "https://example.com/claude", - expected: "https://example.com/claude", - }, - { - name: "empty string unchanged", - input: "", - expected: "", + name: "whitespace-only path returns default", + auth: &cliproxyauth.Auth{Attributes: map[string]string{"messages_path": " "}}, + expected: "/v1/messages", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := normalizeClaudeBaseURL(tt.input) + got := claudeMessagesPath(tt.auth) if got != tt.expected { - t.Errorf("normalizeClaudeBaseURL(%q) = %q, want %q", tt.input, got, tt.expected) + t.Errorf("claudeMessagesPath() = %q, want %q", got, tt.expected) } }) } From 197b4badb4a979d7c9f6c5455df2a3cc25854ad2 Mon Sep 17 00:00:00 2001 From: admccc Date: Wed, 11 Mar 2026 18:49:35 +0800 Subject: [PATCH 05/13] feat: add messages-path field to ClaudeKey config Allow users to customize the API path appended to base-url when making requests to Claude upstream. Defaults to /v1/messages. --- internal/config/config.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 5a6595f778..fe0443f518 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -327,6 +327,12 @@ type ClaudeKey struct { // If empty, the default Claude API URL will be used. BaseURL string `yaml:"base-url" json:"base-url"` + // MessagesPath is the API path appended to BaseURL for the messages endpoint. + // If empty, defaults to "/v1/messages". + // Use this when connecting to relay services that expose Claude-compatible APIs + // at a non-standard path (e.g., set to "/messages" if the relay does not use /v1). + MessagesPath string `yaml:"messages-path,omitempty" json:"messages-path,omitempty"` + // ProxyURL overrides the global proxy setting for this API key if provided. ProxyURL string `yaml:"proxy-url" json:"proxy-url"` From 500c6e5be3b1e157af6343475476dc7980d5f98c Mon Sep 17 00:00:00 2001 From: admccc Date: Wed, 11 Mar 2026 18:49:45 +0800 Subject: [PATCH 06/13] feat: propagate messages_path attribute from ClaudeKey config to auth When synthesizing Claude API key auth entries, copy the messages_path configuration to auth.Attributes so it is available to the executor. --- internal/watcher/synthesizer/config.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/watcher/synthesizer/config.go b/internal/watcher/synthesizer/config.go index 52ae9a4808..7907f00eed 100644 --- a/internal/watcher/synthesizer/config.go +++ b/internal/watcher/synthesizer/config.go @@ -113,6 +113,9 @@ func (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*corea if base != "" { attrs["base_url"] = base } + if mp := strings.TrimSpace(ck.MessagesPath); mp != "" { + attrs["messages_path"] = mp + } if hash := diff.ComputeClaudeModelsHash(ck.Models); hash != "" { attrs["models_hash"] = hash } From 245ae8b722dc9bf754801854f570a69dce5fd292 Mon Sep 17 00:00:00 2001 From: admccc Date: Wed, 11 Mar 2026 18:49:54 +0800 Subject: [PATCH 07/13] docs: document messages-path option for claude-api-key config Add example and explanation for the new messages-path field that allows users to connect relay services without the /v1 prefix. --- config.example.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config.example.yaml b/config.example.yaml index 348aabd846..3b61e100cd 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -143,6 +143,11 @@ nonstream-keepalive-interval: 0 # - api-key: "sk-atSM..." # prefix: "test" # optional: require calls like "test/claude-sonnet-latest" to target this credential # base-url: "https://www.example.com" # use the custom claude API endpoint +# messages-path: "/messages" # optional: custom messages API path (default: "/v1/messages") +# # use this when your relay service (e.g., new-api) does not +# # use the /v1 prefix. Example: set to "/messages" if the relay +# # exposes the endpoint at {base-url}/messages instead of +# # the standard {base-url}/v1/messages # headers: # X-Custom-Header: "custom-value" # proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override From b65076d419167a8782cf0b30927ada4feffa8baf Mon Sep 17 00:00:00 2001 From: admccc Date: Wed, 11 Mar 2026 18:57:14 +0800 Subject: [PATCH 08/13] feat: support messages-path in Claude key management API Add MessagesPath to claudeKeyPatch struct and PatchClaudeKey handler so the field can be set/updated via the management REST API. Also normalise the field in normalizeClaudeKey(). --- internal/api/handlers/management/config_lists.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index 503179c11c..157b3ff815 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -274,6 +274,7 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) { APIKey *string `json:"api-key"` Prefix *string `json:"prefix"` BaseURL *string `json:"base-url"` + MessagesPath *string `json:"messages-path"` ProxyURL *string `json:"proxy-url"` Models *[]config.ClaudeModel `json:"models"` Headers *map[string]string `json:"headers"` @@ -316,6 +317,9 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) { if body.Value.BaseURL != nil { entry.BaseURL = strings.TrimSpace(*body.Value.BaseURL) } + if body.Value.MessagesPath != nil { + entry.MessagesPath = strings.TrimSpace(*body.Value.MessagesPath) + } if body.Value.ProxyURL != nil { entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL) } @@ -975,6 +979,7 @@ func normalizeClaudeKey(entry *config.ClaudeKey) { } entry.APIKey = strings.TrimSpace(entry.APIKey) entry.BaseURL = strings.TrimSpace(entry.BaseURL) + entry.MessagesPath = strings.TrimSpace(entry.MessagesPath) entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) entry.Headers = config.NormalizeHeaders(entry.Headers) entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels) From 318161ab779f5e237816cac2285b4767a632e6ff Mon Sep 17 00:00:00 2001 From: admccc Date: Wed, 11 Mar 2026 18:57:23 +0800 Subject: [PATCH 09/13] feat: display messages-path in TUI keys tab for Claude keys Show the configured messages-path alongside base-url in the provider keys list when the field is non-empty. --- internal/tui/keys_tab.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/tui/keys_tab.go b/internal/tui/keys_tab.go index 770f7f1e57..c40abebcec 100644 --- a/internal/tui/keys_tab.go +++ b/internal/tui/keys_tab.go @@ -385,6 +385,7 @@ func renderProviderKeys(sb *strings.Builder, title string, keys []map[string]any apiKey := getString(key, "api-key") prefix := getString(key, "prefix") baseURL := getString(key, "base-url") + messagesPath := getString(key, "messages-path") info := maskKey(apiKey) if prefix != "" { info += " (prefix: " + prefix + ")" @@ -392,6 +393,9 @@ func renderProviderKeys(sb *strings.Builder, title string, keys []map[string]any if baseURL != "" { info += " → " + baseURL } + if messagesPath != "" { + info += " [path: " + messagesPath + "]" + } sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, info)) } sb.WriteString("\n") From a1e9f07d8908c882d412899a4ff9a8379364d128 Mon Sep 17 00:00:00 2001 From: admccc Date: Thu, 12 Mar 2026 14:52:15 +0800 Subject: [PATCH 10/13] fix: normalize base URL and messages-path to prevent malformed URLs Add normalizeBaseURL() to strip trailing /v1 from base URLs, preventing double-path issues when relay services include /v1 in their base URL (e.g., https://relay.example.com/v1 + /v1/messages -> /v1/v1/messages). Also normalize claudeMessagesPath() to ensure the returned path always starts with / and has no trailing /. Fixes: https://github.com/router-for-me/CLIProxyAPI/issues/2055 --- internal/runtime/executor/claude_executor.go | 24 ++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index f8a0671257..e3e3e692a6 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -155,7 +155,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix) } - url := fmt.Sprintf("%s%s?beta=true", baseURL, claudeMessagesPath(auth)) + url := fmt.Sprintf("%s%s?beta=true", normalizeBaseURL(baseURL), claudeMessagesPath(auth)) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream)) if err != nil { return resp, err @@ -318,7 +318,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix) } - url := fmt.Sprintf("%s%s?beta=true", baseURL, claudeMessagesPath(auth)) + url := fmt.Sprintf("%s%s?beta=true", normalizeBaseURL(baseURL), claudeMessagesPath(auth)) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyForUpstream)) if err != nil { return nil, err @@ -485,7 +485,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut body = applyClaudeToolPrefix(body, claudeToolPrefix) } - url := fmt.Sprintf("%s%s/count_tokens?beta=true", baseURL, claudeMessagesPath(auth)) + url := fmt.Sprintf("%s%s/count_tokens?beta=true", normalizeBaseURL(baseURL), claudeMessagesPath(auth)) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return cliproxyexecutor.Response{}, err @@ -931,15 +931,31 @@ func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { // claudeMessagesPath returns the configured messages API path for the given auth, // falling back to the default "/v1/messages". This allows users to configure relay // services (e.g., new-api) that expose Claude-compatible APIs at non-standard paths. +// The returned path always starts with "/" and has no trailing "/". func claudeMessagesPath(a *cliproxyauth.Auth) string { if a != nil && a.Attributes != nil { if mp := strings.TrimSpace(a.Attributes["messages_path"]); mp != "" { - return mp + if !strings.HasPrefix(mp, "/") { + mp = "/" + mp + } + return strings.TrimRight(mp, "/") } } return "/v1/messages" } +// normalizeBaseURL strips a trailing "/v1" segment from the base URL to prevent +// double-path issues when users configure relay services that already include "/v1" +// in their base URL (e.g., "https://relay.example.com/v1"). The combined URL with +// the default messages-path "/v1/messages" would otherwise produce ".../v1/v1/messages". +func normalizeBaseURL(baseURL string) string { + u := strings.TrimRight(baseURL, "/") + if strings.HasSuffix(u, "/v1") { + return u[:len(u)-3] + } + return u +} + func checkSystemInstructions(payload []byte) []byte { return checkSystemInstructionsWithMode(payload, false) } From a26a6534ac578b58bea61797983489711841f4d4 Mon Sep 17 00:00:00 2001 From: admccc Date: Thu, 12 Mar 2026 14:59:10 +0800 Subject: [PATCH 11/13] test: add TestNormalizeBaseURL and expand TestClaudeMessagesPath Add comprehensive tests for the new normalizeBaseURL() function covering trailing /v1 stripping, nested paths, and non-matching suffixes. Expand TestClaudeMessagesPath with cases for missing leading slash, trailing slash, and combined normalization scenarios. --- .../runtime/executor/claude_executor_test.go | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 319c106564..e57aa91ad5 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -1100,6 +1100,21 @@ func TestClaudeMessagesPath(t *testing.T) { auth: &cliproxyauth.Auth{Attributes: map[string]string{"messages_path": " "}}, expected: "/v1/messages", }, + { + name: "path without leading slash gets slash prepended", + auth: &cliproxyauth.Auth{Attributes: map[string]string{"messages_path": "v1/messages"}}, + expected: "/v1/messages", + }, + { + name: "path with trailing slash gets slash trimmed", + auth: &cliproxyauth.Auth{Attributes: map[string]string{"messages_path": "/v1/messages/"}}, + expected: "/v1/messages", + }, + { + name: "path without leading slash and with trailing slash normalized", + auth: &cliproxyauth.Auth{Attributes: map[string]string{"messages_path": "messages/"}}, + expected: "/messages", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1110,3 +1125,53 @@ func TestClaudeMessagesPath(t *testing.T) { }) } } + +// TestNormalizeBaseURL verifies that normalizeBaseURL strips trailing "/v1" from +// base URLs to prevent double-path issues when relay services include "/v1" in +// their base URL configuration. +func TestNormalizeBaseURL(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "strips trailing /v1", + input: "https://relay.example.com/v1", + expected: "https://relay.example.com", + }, + { + name: "no change when no trailing /v1", + input: "https://api.anthropic.com", + expected: "https://api.anthropic.com", + }, + { + name: "no change when path is deeper than /v1", + input: "https://relay.example.com/v1/proxy", + expected: "https://relay.example.com/v1/proxy", + }, + { + name: "strips trailing slash after /v1", + input: "https://relay.example.com/v1/", + expected: "https://relay.example.com", + }, + { + name: "no change for /v1api suffix (not a path segment)", + input: "https://relay.example.com/v1api", + expected: "https://relay.example.com/v1api", + }, + { + name: "handles nested /v1 in path correctly", + input: "https://relay.example.com/api/v1", + expected: "https://relay.example.com/api", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeBaseURL(tt.input) + if got != tt.expected { + t.Errorf("normalizeBaseURL(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} From 6bc44259977ade115187499cabf5cd2515b93242 Mon Sep 17 00:00:00 2001 From: admccc Date: Thu, 12 Mar 2026 14:59:19 +0800 Subject: [PATCH 12/13] fix: normalize messages-path in management API (enforce leading /, trim trailing /) Update normalizeClaudeKey() to enforce a leading slash and strip trailing slashes from messages-path values. This prevents malformed URLs from values like "messages", "v1/messages", or "/messages/". --- internal/api/handlers/management/config_lists.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index 157b3ff815..a8b327df67 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -979,7 +979,11 @@ func normalizeClaudeKey(entry *config.ClaudeKey) { } entry.APIKey = strings.TrimSpace(entry.APIKey) entry.BaseURL = strings.TrimSpace(entry.BaseURL) - entry.MessagesPath = strings.TrimSpace(entry.MessagesPath) + mp := strings.TrimSpace(entry.MessagesPath) + if mp != "" && !strings.HasPrefix(mp, "/") { + mp = "/" + mp + } + entry.MessagesPath = strings.TrimRight(mp, "/") entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) entry.Headers = config.NormalizeHeaders(entry.Headers) entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels) From 0cbbe775e513edbcc0250dbe68bd07a33ec00e85 Mon Sep 17 00:00:00 2001 From: admccc Date: Thu, 12 Mar 2026 14:59:26 +0800 Subject: [PATCH 13/13] fix: normalize messages-path in synthesizer (enforce leading /, trim trailing /) Apply the same messages-path normalization in the config synthesizer so that auth.Attributes["messages_path"] always holds a canonical path starting with / and without trailing slashes. --- internal/watcher/synthesizer/config.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/watcher/synthesizer/config.go b/internal/watcher/synthesizer/config.go index 7907f00eed..8fbb2e8ed1 100644 --- a/internal/watcher/synthesizer/config.go +++ b/internal/watcher/synthesizer/config.go @@ -114,7 +114,10 @@ func (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*corea attrs["base_url"] = base } if mp := strings.TrimSpace(ck.MessagesPath); mp != "" { - attrs["messages_path"] = mp + if !strings.HasPrefix(mp, "/") { + mp = "/" + mp + } + attrs["messages_path"] = strings.TrimRight(mp, "/") } if hash := diff.ComputeClaudeModelsHash(ck.Models); hash != "" { attrs["models_hash"] = hash