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:
Dominic Robinson
2026-06-14 06:03:58 +01:00
parent e3301ecc98
commit c354f88f3b
2 changed files with 110 additions and 11 deletions
+16 -11
View File
@@ -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))
+94
View File
@@ -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"