Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
5 changes: 5 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,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)
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
18 changes: 15 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", 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", 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", 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,18 @@ 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.
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"
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, "/")

}

func checkSystemInstructions(payload []byte) []byte {
return checkSystemInstructionsWithMode(payload, false)
}
Expand Down
46 changes: 46 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,49 @@ 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",
},
}
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)
}
})
}
}
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
3 changes: 3 additions & 0 deletions internal/watcher/synthesizer/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading