From f5484b0900333965a4374a15fb6369be5721d339 Mon Sep 17 00:00:00 2001 From: Dominic Robinson Date: Sat, 13 Jun 2026 16:03:35 +0100 Subject: [PATCH 1/3] fix(registry): Conform Claude models listing to Anthropic API schema Anthropic's List Models response (GET /v1/models) defines each model object with id, display_name, max_input_tokens, max_tokens, created_at and type. The Claude case of convertModelToMap emitted an incomplete object: it dropped max_input_tokens and max_tokens entirely and omitted display_name whenever it was unset, so the Anthropic-compatible listing did not match the schema it emulates. Emit max_input_tokens from ContextLength and max_tokens from MaxCompletionTokens (the same registry metadata the Gemini case already surfaces as inputTokenLimit/outputTokenLimit), with fallbacks, and always emit display_name, falling back to the model ID. --- internal/registry/model_registry.go | 17 ++++++++ .../registry/model_registry_cache_test.go | 40 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/internal/registry/model_registry.go b/internal/registry/model_registry.go index 3fab95e3..40221631 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 @@ -1163,7 +1168,19 @@ func (r *ModelRegistry) convertModelToMap(model *ModelInfo, handlerType string) } 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..fc08c3d7 100644 --- a/internal/registry/model_registry_cache_test.go +++ b/internal/registry/model_registry_cache_test.go @@ -22,6 +22,46 @@ 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", 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) + } + + 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"}}) From e3301ecc9832614c5e08a6e1c94b77b586ad454d Mon Sep 17 00:00:00 2001 From: Dominic Robinson Date: Sat, 13 Jun 2026 16:15:45 +0100 Subject: [PATCH 2/3] fix(registry): Emit Claude model created_at as RFC 3339 string Anthropic's List Models schema types created_at as an RFC 3339 datetime string, but the Claude case of convertModelToMap emitted model.Created as a Unix-timestamp integer. Format it with time.RFC3339 so the listing matches the documented schema. --- internal/registry/model_registry.go | 2 +- internal/registry/model_registry_cache_test.go | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/registry/model_registry.go b/internal/registry/model_registry.go index 40221631..252aaffd 100644 --- a/internal/registry/model_registry.go +++ b/internal/registry/model_registry.go @@ -1161,7 +1161,7 @@ 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" diff --git a/internal/registry/model_registry_cache_test.go b/internal/registry/model_registry_cache_test.go index fc08c3d7..1951b433 100644 --- a/internal/registry/model_registry_cache_test.go +++ b/internal/registry/model_registry_cache_test.go @@ -25,7 +25,7 @@ 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", ContextLength: 200000, MaxCompletionTokens: 64000}, + {ID: "claude-sonnet-4-6", OwnedBy: "anthropic", Type: "claude", Created: 1771372800, ContextLength: 200000, MaxCompletionTokens: 64000}, {ID: "claude-no-limits", OwnedBy: "anthropic", Type: "claude"}, }) @@ -46,6 +46,9 @@ func TestGetAvailableModelsClaudeIncludesTokenLimits(t *testing.T) { 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 { From c354f88f3bc484fe266a112c51121957c91012fc Mon Sep 17 00:00:00 2001 From: Dominic Robinson Date: Sun, 14 Jun 2026 06:03:58 +0100 Subject: [PATCH 3/3] 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. --- internal/api/server.go | 27 ++++++----- internal/api/server_test.go | 94 +++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 11 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 1d7bd28b..cf6c72de 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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)) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index f71deacd..aba06a2b 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -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"