Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions internal/api/handlers/management/config_lists.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down
34 changes: 31 additions & 3 deletions internal/runtime/executor/claude_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Comment on lines +943 to +944
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The TrimSuffix call that removes /v1 from the baseURL is too aggressive and may lead to incorrect URLs when combined with the new messages-path feature.

As per the PR description, a user might configure a base-url that includes a version path and use messages-path for the final segment. For instance:

- api-key: "your-relay-key"
  base-url: "https://new-api.example.com/v1"
  messages-path: "/messages"

The expected final URL is https://new-api.example.com/v1/messages. However, this function will strip /v1, resulting in an incorrect URL: https://new-api.example.com/messages.

To ensure user configurations are respected, it would be safer to only trim the trailing slash from the base URL. The logic that constructs the final URL should be responsible for handling path segments correctly.

Suggested change
}
return "/v1/messages"
baseURL = strings.TrimRight(baseURL, "/")

}

// 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)
}
Expand Down
111 changes: 111 additions & 0 deletions internal/runtime/executor/claude_executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
4 changes: 4 additions & 0 deletions internal/tui/keys_tab.go
Original file line number Diff line number Diff line change
Expand Up @@ -385,13 +385,17 @@ 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 + ")"
}
if baseURL != "" {
info += " → " + baseURL
}
if messagesPath != "" {
info += " [path: " + messagesPath + "]"
}
sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, info))
}
sb.WriteString("\n")
Expand Down
6 changes: 6 additions & 0 deletions internal/watcher/synthesizer/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading