Skip to content

Commit c354f88

Browse files
committed
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.
1 parent e3301ec commit c354f88

2 files changed

Lines changed: 110 additions & 11 deletions

File tree

internal/api/server.go

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -996,10 +996,20 @@ func (s *Server) watchKeepAlive() {
996996
}
997997
}
998998

999+
// isAnthropicModelsRequest reports whether a /v1/models request should be served in
1000+
// Anthropic format. Anthropic API clients send the Anthropic-Version header; Claude
1001+
// Code additionally uses a claude-cli User-Agent.
1002+
func isAnthropicModelsRequest(c *gin.Context) bool {
1003+
if c.GetHeader("Anthropic-Version") != "" {
1004+
return true
1005+
}
1006+
return strings.HasPrefix(c.GetHeader("User-Agent"), "claude-cli")
1007+
}
1008+
9991009
// unifiedModelsHandler creates a unified handler for the /v1/models endpoint
1000-
// that routes to different handlers based on the User-Agent header.
1001-
// If User-Agent starts with "claude-cli", it routes to Claude handler,
1002-
// otherwise it routes to OpenAI handler.
1010+
// that routes to different handlers based on the request.
1011+
// Anthropic API requests (Anthropic-Version header, or a claude-cli User-Agent)
1012+
// route to the Claude handler, otherwise they route to the OpenAI handler.
10031013
func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, claudeHandler *claude.ClaudeCodeAPIHandler) gin.HandlerFunc {
10041014
return func(c *gin.Context) {
10051015
if _, ok := c.Request.URL.Query()["client_version"]; ok {
@@ -1016,14 +1026,10 @@ func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, cl
10161026
return
10171027
}
10181028

1019-
userAgent := c.GetHeader("User-Agent")
1020-
1021-
// Route to Claude handler if User-Agent starts with "claude-cli"
1022-
if strings.HasPrefix(userAgent, "claude-cli") {
1023-
// log.Debugf("Routing /v1/models to Claude handler for User-Agent: %s", userAgent)
1029+
// Route to Claude handler for Anthropic API requests.
1030+
if isAnthropicModelsRequest(c) {
10241031
claudeHandler.ClaudeModels(c)
10251032
} else {
1026-
// log.Debugf("Routing /v1/models to OpenAI handler for User-Agent: %s", userAgent)
10271033
openaiHandler.OpenAIModels(c)
10281034
}
10291035
}
@@ -1092,8 +1098,7 @@ func (s *Server) handleHomeModels(c *gin.Context) {
10921098
return
10931099
}
10941100

1095-
userAgent := c.GetHeader("User-Agent")
1096-
isClaude := strings.HasPrefix(userAgent, "claude-cli")
1101+
isClaude := isAnthropicModelsRequest(c)
10971102

10981103
if isClaude {
10991104
out := make([]map[string]any, 0, len(entries))

internal/api/server_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,100 @@ func TestAmpProviderModelRoutes(t *testing.T) {
359359
}
360360
}
361361

362+
func TestModelsDispatchByAnthropicVersionHeader(t *testing.T) {
363+
modelRegistry := registry.GetGlobalRegistry()
364+
clientID := "test-anthropic-version-dispatch"
365+
modelRegistry.RegisterClient(clientID, "claude", []*registry.ModelInfo{
366+
{
367+
ID: "claude-sonnet-4-6",
368+
Object: "model",
369+
OwnedBy: "anthropic",
370+
Type: "claude",
371+
DisplayName: "Claude 4.6 Sonnet",
372+
ContextLength: 200000,
373+
MaxCompletionTokens: 64000,
374+
},
375+
})
376+
t.Cleanup(func() {
377+
modelRegistry.UnregisterClient(clientID)
378+
})
379+
380+
server := newTestServer(t)
381+
382+
// Anthropic API request (Anthropic-Version header, non-claude-cli User-Agent) -> Claude format.
383+
t.Run("anthropic version header routes to claude format", func(t *testing.T) {
384+
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
385+
req.Header.Set("Authorization", "Bearer test-key")
386+
req.Header.Set("User-Agent", "Zed/1.0")
387+
req.Header.Set("Anthropic-Version", "2023-06-01")
388+
389+
rr := httptest.NewRecorder()
390+
server.engine.ServeHTTP(rr, req)
391+
if rr.Code != http.StatusOK {
392+
t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String())
393+
}
394+
395+
var resp struct {
396+
Object string `json:"object"`
397+
HasMore *bool `json:"has_more"`
398+
Data []map[string]any `json:"data"`
399+
}
400+
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
401+
t.Fatalf("failed to parse response JSON: %v; body=%s", err, rr.Body.String())
402+
}
403+
if resp.Object == "list" {
404+
t.Fatalf("expected Claude format (no object=list), got OpenAI format: %s", rr.Body.String())
405+
}
406+
if resp.HasMore == nil {
407+
t.Fatalf("expected Claude envelope with has_more, got %s", rr.Body.String())
408+
}
409+
410+
var claudeModel map[string]any
411+
for _, m := range resp.Data {
412+
if id, _ := m["id"].(string); id == "claude-sonnet-4-6" {
413+
claudeModel = m
414+
}
415+
}
416+
if claudeModel == nil {
417+
t.Fatalf("expected claude-sonnet-4-6 in response, got %s", rr.Body.String())
418+
}
419+
for _, field := range []string{"max_input_tokens", "max_tokens", "display_name"} {
420+
if _, ok := claudeModel[field]; !ok {
421+
t.Fatalf("expected Claude model to include %q, got %v", field, claudeModel)
422+
}
423+
}
424+
})
425+
426+
// Plain request (no Anthropic-Version, non-claude-cli User-Agent) -> OpenAI format, unaffected.
427+
t.Run("plain request stays on openai format", func(t *testing.T) {
428+
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
429+
req.Header.Set("Authorization", "Bearer test-key")
430+
req.Header.Set("User-Agent", "Mozilla/5.0")
431+
432+
rr := httptest.NewRecorder()
433+
server.engine.ServeHTTP(rr, req)
434+
if rr.Code != http.StatusOK {
435+
t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String())
436+
}
437+
438+
var resp struct {
439+
Object string `json:"object"`
440+
Data []map[string]any `json:"data"`
441+
}
442+
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
443+
t.Fatalf("failed to parse response JSON: %v; body=%s", err, rr.Body.String())
444+
}
445+
if resp.Object != "list" {
446+
t.Fatalf("expected OpenAI format (object=list), got %s", rr.Body.String())
447+
}
448+
for _, m := range resp.Data {
449+
if _, ok := m["max_input_tokens"]; ok {
450+
t.Fatalf("did not expect max_input_tokens in OpenAI format, got %v", m)
451+
}
452+
}
453+
})
454+
}
455+
362456
func TestModelsWithClientVersionReturnsCodexCatalog(t *testing.T) {
363457
modelRegistry := registry.GetGlobalRegistry()
364458
clientID := "test-client-version-catalog"

0 commit comments

Comments
 (0)