diff --git a/internal/api/server.go b/internal/api/server.go index e6c23722..58c7d0d0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -947,10 +947,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 { @@ -967,14 +977,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) } } @@ -1043,8 +1049,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)) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 0f42cac1..0258f82c 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -332,6 +332,166 @@ func TestHomeEnabledHidesManagementEndpointsAndControlPanel(t *testing.T) { }) } +func TestAmpProviderModelRoutes(t *testing.T) { + testCases := []struct { + name string + path string + wantStatus int + wantContains string + }{ + { + name: "openai root models", + path: "/api/provider/openai/models", + wantStatus: http.StatusOK, + wantContains: `"object":"list"`, + }, + { + name: "groq root models", + path: "/api/provider/groq/models", + wantStatus: http.StatusOK, + wantContains: `"object":"list"`, + }, + { + name: "openai models", + path: "/api/provider/openai/v1/models", + wantStatus: http.StatusOK, + wantContains: `"object":"list"`, + }, + { + name: "anthropic models", + path: "/api/provider/anthropic/v1/models", + wantStatus: http.StatusOK, + wantContains: `"data"`, + }, + { + name: "google models v1", + path: "/api/provider/google/v1/models", + wantStatus: http.StatusOK, + wantContains: `"models"`, + }, + { + name: "google models v1beta", + path: "/api/provider/google/v1beta/models", + wantStatus: http.StatusOK, + wantContains: `"models"`, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + server := newTestServer(t) + + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + req.Header.Set("Authorization", "Bearer test-key") + + rr := httptest.NewRecorder() + server.engine.ServeHTTP(rr, req) + + if rr.Code != tc.wantStatus { + t.Fatalf("unexpected status code for %s: got %d want %d; body=%s", tc.path, rr.Code, tc.wantStatus, rr.Body.String()) + } + if body := rr.Body.String(); !strings.Contains(body, tc.wantContains) { + t.Fatalf("response body for %s missing %q: %s", tc.path, tc.wantContains, body) + } + }) + } +} + +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" diff --git a/internal/registry/model_registry.go b/internal/registry/model_registry.go index 3fab95e3..252aaffd 100644 --- a/internal/registry/model_registry.go +++ b/internal/registry/model_registry.go @@ -18,6 +18,11 @@ import ( // OpenAIImageModelType marks models that are callable through OpenAI-compatible image endpoints. const OpenAIImageModelType = "openai-image" +const ( + defaultClaudeMaxInputTokens = 200000 + defaultClaudeMaxOutputTokens = 64000 +) + // ModelInfo represents information about an available model type ModelInfo struct { // ID is the unique identifier for the model @@ -1156,14 +1161,26 @@ func (r *ModelRegistry) convertModelToMap(model *ModelInfo, handlerType string) "owned_by": model.OwnedBy, } if model.Created > 0 { - result["created_at"] = model.Created + result["created_at"] = time.Unix(model.Created, 0).UTC().Format(time.RFC3339) } if model.Type != "" { result["type"] = "model" } if model.DisplayName != "" { result["display_name"] = model.DisplayName + } else { + result["display_name"] = model.ID } + maxInput := model.ContextLength + if maxInput <= 0 { + maxInput = defaultClaudeMaxInputTokens + } + maxOutput := model.MaxCompletionTokens + if maxOutput <= 0 { + maxOutput = defaultClaudeMaxOutputTokens + } + result["max_input_tokens"] = maxInput + result["max_tokens"] = maxOutput return result case "gemini": diff --git a/internal/registry/model_registry_cache_test.go b/internal/registry/model_registry_cache_test.go index 4653167b..1951b433 100644 --- a/internal/registry/model_registry_cache_test.go +++ b/internal/registry/model_registry_cache_test.go @@ -22,6 +22,49 @@ func TestGetAvailableModelsReturnsClonedSnapshots(t *testing.T) { } } +func TestGetAvailableModelsClaudeIncludesTokenLimits(t *testing.T) { + r := newTestModelRegistry() + r.RegisterClient("client-1", "Claude", []*ModelInfo{ + {ID: "claude-sonnet-4-6", OwnedBy: "anthropic", Type: "claude", Created: 1771372800, ContextLength: 200000, MaxCompletionTokens: 64000}, + {ID: "claude-no-limits", OwnedBy: "anthropic", Type: "claude"}, + }) + + models := r.GetAvailableModels("claude") + byID := make(map[string]map[string]any, len(models)) + for _, m := range models { + id, _ := m["id"].(string) + byID[id] = m + } + + withLimits, ok := byID["claude-sonnet-4-6"] + if !ok { + t.Fatalf("expected claude-sonnet-4-6 in available models, got %v", byID) + } + if got := withLimits["max_input_tokens"]; got != 200000 { + t.Fatalf("expected max_input_tokens 200000, got %v", got) + } + if got := withLimits["max_tokens"]; got != 64000 { + t.Fatalf("expected max_tokens 64000, got %v", got) + } + if got := withLimits["created_at"]; got != "2026-02-18T00:00:00Z" { + t.Fatalf("expected created_at as RFC 3339 string, got %v", got) + } + + withDefaults, ok := byID["claude-no-limits"] + if !ok { + t.Fatalf("expected claude-no-limits in available models, got %v", byID) + } + if got := withDefaults["max_input_tokens"]; got != defaultClaudeMaxInputTokens { + t.Fatalf("expected fallback max_input_tokens %d, got %v", defaultClaudeMaxInputTokens, got) + } + if got := withDefaults["max_tokens"]; got != defaultClaudeMaxOutputTokens { + t.Fatalf("expected fallback max_tokens %d, got %v", defaultClaudeMaxOutputTokens, got) + } + if got := withDefaults["display_name"]; got != "claude-no-limits" { + t.Fatalf("expected display_name to fall back to id, got %v", got) + } +} + func TestGetAvailableModelsInvalidatesCacheOnRegistryChanges(t *testing.T) { r := newTestModelRegistry() r.RegisterClient("client-1", "OpenAI", []*ModelInfo{{ID: "m1", OwnedBy: "team-a", DisplayName: "Model One"}})