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 diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index 503179c11c..a8b327df67 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,11 @@ func normalizeClaudeKey(entry *config.ClaudeKey) { } entry.APIKey = strings.TrimSpace(entry.APIKey) entry.BaseURL = strings.TrimSpace(entry.BaseURL) + 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) 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"` diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 82b12a2f80..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/v1/messages?beta=true", baseURL) + 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/v1/messages?beta=true", baseURL) + 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/v1/messages/count_tokens?beta=true", baseURL) + 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 @@ -928,6 +928,34 @@ func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { return } +// 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 != "" { + 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) } diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index fa458c0fd0..e57aa91ad5 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -1064,3 +1064,114 @@ func TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) { t.Fatalf("blocks[2] text mangled, got %q", blocks[2].Get("text").String()) } } + +// 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 + auth *cliproxyauth.Auth + expected string + }{ + { + name: "nil auth returns default", + auth: nil, + expected: "/v1/messages", + }, + { + name: "empty attributes returns default", + auth: &cliproxyauth.Auth{Attributes: map[string]string{}}, + expected: "/v1/messages", + }, + { + name: "custom path without /v1", + auth: &cliproxyauth.Auth{Attributes: map[string]string{"messages_path": "/messages"}}, + expected: "/messages", + }, + { + name: "custom path with /v1", + auth: &cliproxyauth.Auth{Attributes: map[string]string{"messages_path": "/v1/messages"}}, + expected: "/v1/messages", + }, + { + name: "whitespace-only path returns default", + 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) { + got := claudeMessagesPath(tt.auth) + if got != tt.expected { + t.Errorf("claudeMessagesPath() = %q, want %q", got, tt.expected) + } + }) + } +} + +// 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) + } + }) + } +} 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") diff --git a/internal/watcher/synthesizer/config.go b/internal/watcher/synthesizer/config.go index 52ae9a4808..8fbb2e8ed1 100644 --- a/internal/watcher/synthesizer/config.go +++ b/internal/watcher/synthesizer/config.go @@ -113,6 +113,12 @@ func (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*corea if base != "" { attrs["base_url"] = base } + if mp := strings.TrimSpace(ck.MessagesPath); mp != "" { + if !strings.HasPrefix(mp, "/") { + mp = "/" + mp + } + attrs["messages_path"] = strings.TrimRight(mp, "/") + } if hash := diff.ComputeClaudeModelsHash(ck.Models); hash != "" { attrs["models_hash"] = hash }