mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-10 18:36:47 +08:00
feat(api): Route Anthropic /v1/models requests to the Claude format
/v1/models is shared by the OpenAI and Anthropic surfaces and was dispatched to the Claude format only for claude-cli User-Agents, so generic Anthropic API clients received the OpenAI format. Distinguish Anthropic requests by the Anthropic-Version header (sent by every Anthropic client, never by OpenAI/Codex clients) in addition to the existing claude-cli check, in both unifiedModelsHandler and handleHomeModels. Additive and non-breaking for existing clients.
This commit is contained in:
+16
-11
@@ -996,10 +996,20 @@ func (s *Server) watchKeepAlive() {
|
||||
}
|
||||
}
|
||||
|
||||
// isAnthropicModelsRequest reports whether a /v1/models request should be served in
|
||||
// Anthropic format. Anthropic API clients send the Anthropic-Version header; Claude
|
||||
// Code additionally uses a claude-cli User-Agent.
|
||||
func isAnthropicModelsRequest(c *gin.Context) bool {
|
||||
if c.GetHeader("Anthropic-Version") != "" {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(c.GetHeader("User-Agent"), "claude-cli")
|
||||
}
|
||||
|
||||
// unifiedModelsHandler creates a unified handler for the /v1/models endpoint
|
||||
// that routes to different handlers based on the User-Agent header.
|
||||
// If User-Agent starts with "claude-cli", it routes to Claude handler,
|
||||
// otherwise it routes to OpenAI handler.
|
||||
// that routes to different handlers based on the request.
|
||||
// Anthropic API requests (Anthropic-Version header, or a claude-cli User-Agent)
|
||||
// route to the Claude handler, otherwise they route to the OpenAI handler.
|
||||
func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, claudeHandler *claude.ClaudeCodeAPIHandler) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if _, ok := c.Request.URL.Query()["client_version"]; ok {
|
||||
@@ -1016,14 +1026,10 @@ func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, cl
|
||||
return
|
||||
}
|
||||
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
|
||||
// Route to Claude handler if User-Agent starts with "claude-cli"
|
||||
if strings.HasPrefix(userAgent, "claude-cli") {
|
||||
// log.Debugf("Routing /v1/models to Claude handler for User-Agent: %s", userAgent)
|
||||
// Route to Claude handler for Anthropic API requests.
|
||||
if isAnthropicModelsRequest(c) {
|
||||
claudeHandler.ClaudeModels(c)
|
||||
} else {
|
||||
// log.Debugf("Routing /v1/models to OpenAI handler for User-Agent: %s", userAgent)
|
||||
openaiHandler.OpenAIModels(c)
|
||||
}
|
||||
}
|
||||
@@ -1092,8 +1098,7 @@ func (s *Server) handleHomeModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
isClaude := strings.HasPrefix(userAgent, "claude-cli")
|
||||
isClaude := isAnthropicModelsRequest(c)
|
||||
|
||||
if isClaude {
|
||||
out := make([]map[string]any, 0, len(entries))
|
||||
|
||||
@@ -359,6 +359,100 @@ func TestAmpProviderModelRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelsDispatchByAnthropicVersionHeader(t *testing.T) {
|
||||
modelRegistry := registry.GetGlobalRegistry()
|
||||
clientID := "test-anthropic-version-dispatch"
|
||||
modelRegistry.RegisterClient(clientID, "claude", []*registry.ModelInfo{
|
||||
{
|
||||
ID: "claude-sonnet-4-6",
|
||||
Object: "model",
|
||||
OwnedBy: "anthropic",
|
||||
Type: "claude",
|
||||
DisplayName: "Claude 4.6 Sonnet",
|
||||
ContextLength: 200000,
|
||||
MaxCompletionTokens: 64000,
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
modelRegistry.UnregisterClient(clientID)
|
||||
})
|
||||
|
||||
server := newTestServer(t)
|
||||
|
||||
// Anthropic API request (Anthropic-Version header, non-claude-cli User-Agent) -> Claude format.
|
||||
t.Run("anthropic version header routes to claude format", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-key")
|
||||
req.Header.Set("User-Agent", "Zed/1.0")
|
||||
req.Header.Set("Anthropic-Version", "2023-06-01")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Object string `json:"object"`
|
||||
HasMore *bool `json:"has_more"`
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response JSON: %v; body=%s", err, rr.Body.String())
|
||||
}
|
||||
if resp.Object == "list" {
|
||||
t.Fatalf("expected Claude format (no object=list), got OpenAI format: %s", rr.Body.String())
|
||||
}
|
||||
if resp.HasMore == nil {
|
||||
t.Fatalf("expected Claude envelope with has_more, got %s", rr.Body.String())
|
||||
}
|
||||
|
||||
var claudeModel map[string]any
|
||||
for _, m := range resp.Data {
|
||||
if id, _ := m["id"].(string); id == "claude-sonnet-4-6" {
|
||||
claudeModel = m
|
||||
}
|
||||
}
|
||||
if claudeModel == nil {
|
||||
t.Fatalf("expected claude-sonnet-4-6 in response, got %s", rr.Body.String())
|
||||
}
|
||||
for _, field := range []string{"max_input_tokens", "max_tokens", "display_name"} {
|
||||
if _, ok := claudeModel[field]; !ok {
|
||||
t.Fatalf("expected Claude model to include %q, got %v", field, claudeModel)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Plain request (no Anthropic-Version, non-claude-cli User-Agent) -> OpenAI format, unaffected.
|
||||
t.Run("plain request stays on openai format", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-key")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Object string `json:"object"`
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response JSON: %v; body=%s", err, rr.Body.String())
|
||||
}
|
||||
if resp.Object != "list" {
|
||||
t.Fatalf("expected OpenAI format (object=list), got %s", rr.Body.String())
|
||||
}
|
||||
for _, m := range resp.Data {
|
||||
if _, ok := m["max_input_tokens"]; ok {
|
||||
t.Fatalf("did not expect max_input_tokens in OpenAI format, got %v", m)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestModelsWithClientVersionReturnsCodexCatalog(t *testing.T) {
|
||||
modelRegistry := registry.GetGlobalRegistry()
|
||||
clientID := "test-client-version-catalog"
|
||||
|
||||
Reference in New Issue
Block a user