Skip to content

Commit b0ca379

Browse files
committed
Merge pull request #3834 from dcrdev/main
2 parents 790ec30 + 1ed1f7b commit b0ca379

4 files changed

Lines changed: 237 additions & 12 deletions

File tree

internal/api/server.go

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -947,10 +947,20 @@ func (s *Server) watchKeepAlive() {
947947
}
948948
}
949949

950+
// isAnthropicModelsRequest reports whether a /v1/models request should be served in
951+
// Anthropic format. Anthropic API clients send the Anthropic-Version header; Claude
952+
// Code additionally uses a claude-cli User-Agent.
953+
func isAnthropicModelsRequest(c *gin.Context) bool {
954+
if c.GetHeader("Anthropic-Version") != "" {
955+
return true
956+
}
957+
return strings.HasPrefix(c.GetHeader("User-Agent"), "claude-cli")
958+
}
959+
950960
// unifiedModelsHandler creates a unified handler for the /v1/models endpoint
951-
// that routes to different handlers based on the User-Agent header.
952-
// If User-Agent starts with "claude-cli", it routes to Claude handler,
953-
// otherwise it routes to OpenAI handler.
961+
// that routes to different handlers based on the request.
962+
// Anthropic API requests (Anthropic-Version header, or a claude-cli User-Agent)
963+
// route to the Claude handler, otherwise they route to the OpenAI handler.
954964
func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, claudeHandler *claude.ClaudeCodeAPIHandler) gin.HandlerFunc {
955965
return func(c *gin.Context) {
956966
if _, ok := c.Request.URL.Query()["client_version"]; ok {
@@ -967,14 +977,10 @@ func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, cl
967977
return
968978
}
969979

970-
userAgent := c.GetHeader("User-Agent")
971-
972-
// Route to Claude handler if User-Agent starts with "claude-cli"
973-
if strings.HasPrefix(userAgent, "claude-cli") {
974-
// log.Debugf("Routing /v1/models to Claude handler for User-Agent: %s", userAgent)
980+
// Route to Claude handler for Anthropic API requests.
981+
if isAnthropicModelsRequest(c) {
975982
claudeHandler.ClaudeModels(c)
976983
} else {
977-
// log.Debugf("Routing /v1/models to OpenAI handler for User-Agent: %s", userAgent)
978984
openaiHandler.OpenAIModels(c)
979985
}
980986
}
@@ -1043,8 +1049,7 @@ func (s *Server) handleHomeModels(c *gin.Context) {
10431049
return
10441050
}
10451051

1046-
userAgent := c.GetHeader("User-Agent")
1047-
isClaude := strings.HasPrefix(userAgent, "claude-cli")
1052+
isClaude := isAnthropicModelsRequest(c)
10481053

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

internal/api/server_test.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,166 @@ func TestHomeEnabledHidesManagementEndpointsAndControlPanel(t *testing.T) {
332332
})
333333
}
334334

335+
func TestAmpProviderModelRoutes(t *testing.T) {
336+
testCases := []struct {
337+
name string
338+
path string
339+
wantStatus int
340+
wantContains string
341+
}{
342+
{
343+
name: "openai root models",
344+
path: "/api/provider/openai/models",
345+
wantStatus: http.StatusOK,
346+
wantContains: `"object":"list"`,
347+
},
348+
{
349+
name: "groq root models",
350+
path: "/api/provider/groq/models",
351+
wantStatus: http.StatusOK,
352+
wantContains: `"object":"list"`,
353+
},
354+
{
355+
name: "openai models",
356+
path: "/api/provider/openai/v1/models",
357+
wantStatus: http.StatusOK,
358+
wantContains: `"object":"list"`,
359+
},
360+
{
361+
name: "anthropic models",
362+
path: "/api/provider/anthropic/v1/models",
363+
wantStatus: http.StatusOK,
364+
wantContains: `"data"`,
365+
},
366+
{
367+
name: "google models v1",
368+
path: "/api/provider/google/v1/models",
369+
wantStatus: http.StatusOK,
370+
wantContains: `"models"`,
371+
},
372+
{
373+
name: "google models v1beta",
374+
path: "/api/provider/google/v1beta/models",
375+
wantStatus: http.StatusOK,
376+
wantContains: `"models"`,
377+
},
378+
}
379+
380+
for _, tc := range testCases {
381+
tc := tc
382+
t.Run(tc.name, func(t *testing.T) {
383+
server := newTestServer(t)
384+
385+
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
386+
req.Header.Set("Authorization", "Bearer test-key")
387+
388+
rr := httptest.NewRecorder()
389+
server.engine.ServeHTTP(rr, req)
390+
391+
if rr.Code != tc.wantStatus {
392+
t.Fatalf("unexpected status code for %s: got %d want %d; body=%s", tc.path, rr.Code, tc.wantStatus, rr.Body.String())
393+
}
394+
if body := rr.Body.String(); !strings.Contains(body, tc.wantContains) {
395+
t.Fatalf("response body for %s missing %q: %s", tc.path, tc.wantContains, body)
396+
}
397+
})
398+
}
399+
}
400+
401+
func TestModelsDispatchByAnthropicVersionHeader(t *testing.T) {
402+
modelRegistry := registry.GetGlobalRegistry()
403+
clientID := "test-anthropic-version-dispatch"
404+
modelRegistry.RegisterClient(clientID, "claude", []*registry.ModelInfo{
405+
{
406+
ID: "claude-sonnet-4-6",
407+
Object: "model",
408+
OwnedBy: "anthropic",
409+
Type: "claude",
410+
DisplayName: "Claude 4.6 Sonnet",
411+
ContextLength: 200000,
412+
MaxCompletionTokens: 64000,
413+
},
414+
})
415+
t.Cleanup(func() {
416+
modelRegistry.UnregisterClient(clientID)
417+
})
418+
419+
server := newTestServer(t)
420+
421+
// Anthropic API request (Anthropic-Version header, non-claude-cli User-Agent) -> Claude format.
422+
t.Run("anthropic version header routes to claude format", func(t *testing.T) {
423+
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
424+
req.Header.Set("Authorization", "Bearer test-key")
425+
req.Header.Set("User-Agent", "Zed/1.0")
426+
req.Header.Set("Anthropic-Version", "2023-06-01")
427+
428+
rr := httptest.NewRecorder()
429+
server.engine.ServeHTTP(rr, req)
430+
if rr.Code != http.StatusOK {
431+
t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String())
432+
}
433+
434+
var resp struct {
435+
Object string `json:"object"`
436+
HasMore *bool `json:"has_more"`
437+
Data []map[string]any `json:"data"`
438+
}
439+
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
440+
t.Fatalf("failed to parse response JSON: %v; body=%s", err, rr.Body.String())
441+
}
442+
if resp.Object == "list" {
443+
t.Fatalf("expected Claude format (no object=list), got OpenAI format: %s", rr.Body.String())
444+
}
445+
if resp.HasMore == nil {
446+
t.Fatalf("expected Claude envelope with has_more, got %s", rr.Body.String())
447+
}
448+
449+
var claudeModel map[string]any
450+
for _, m := range resp.Data {
451+
if id, _ := m["id"].(string); id == "claude-sonnet-4-6" {
452+
claudeModel = m
453+
}
454+
}
455+
if claudeModel == nil {
456+
t.Fatalf("expected claude-sonnet-4-6 in response, got %s", rr.Body.String())
457+
}
458+
for _, field := range []string{"max_input_tokens", "max_tokens", "display_name"} {
459+
if _, ok := claudeModel[field]; !ok {
460+
t.Fatalf("expected Claude model to include %q, got %v", field, claudeModel)
461+
}
462+
}
463+
})
464+
465+
// Plain request (no Anthropic-Version, non-claude-cli User-Agent) -> OpenAI format, unaffected.
466+
t.Run("plain request stays on openai format", func(t *testing.T) {
467+
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
468+
req.Header.Set("Authorization", "Bearer test-key")
469+
req.Header.Set("User-Agent", "Mozilla/5.0")
470+
471+
rr := httptest.NewRecorder()
472+
server.engine.ServeHTTP(rr, req)
473+
if rr.Code != http.StatusOK {
474+
t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String())
475+
}
476+
477+
var resp struct {
478+
Object string `json:"object"`
479+
Data []map[string]any `json:"data"`
480+
}
481+
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
482+
t.Fatalf("failed to parse response JSON: %v; body=%s", err, rr.Body.String())
483+
}
484+
if resp.Object != "list" {
485+
t.Fatalf("expected OpenAI format (object=list), got %s", rr.Body.String())
486+
}
487+
for _, m := range resp.Data {
488+
if _, ok := m["max_input_tokens"]; ok {
489+
t.Fatalf("did not expect max_input_tokens in OpenAI format, got %v", m)
490+
}
491+
}
492+
})
493+
}
494+
335495
func TestModelsWithClientVersionReturnsCodexCatalog(t *testing.T) {
336496
modelRegistry := registry.GetGlobalRegistry()
337497
clientID := "test-client-version-catalog"

internal/registry/model_registry.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ import (
1818
// OpenAIImageModelType marks models that are callable through OpenAI-compatible image endpoints.
1919
const OpenAIImageModelType = "openai-image"
2020

21+
const (
22+
defaultClaudeMaxInputTokens = 200000
23+
defaultClaudeMaxOutputTokens = 64000
24+
)
25+
2126
// ModelInfo represents information about an available model
2227
type ModelInfo struct {
2328
// ID is the unique identifier for the model
@@ -1156,14 +1161,26 @@ func (r *ModelRegistry) convertModelToMap(model *ModelInfo, handlerType string)
11561161
"owned_by": model.OwnedBy,
11571162
}
11581163
if model.Created > 0 {
1159-
result["created_at"] = model.Created
1164+
result["created_at"] = time.Unix(model.Created, 0).UTC().Format(time.RFC3339)
11601165
}
11611166
if model.Type != "" {
11621167
result["type"] = "model"
11631168
}
11641169
if model.DisplayName != "" {
11651170
result["display_name"] = model.DisplayName
1171+
} else {
1172+
result["display_name"] = model.ID
1173+
}
1174+
maxInput := model.ContextLength
1175+
if maxInput <= 0 {
1176+
maxInput = defaultClaudeMaxInputTokens
1177+
}
1178+
maxOutput := model.MaxCompletionTokens
1179+
if maxOutput <= 0 {
1180+
maxOutput = defaultClaudeMaxOutputTokens
11661181
}
1182+
result["max_input_tokens"] = maxInput
1183+
result["max_tokens"] = maxOutput
11671184
return result
11681185

11691186
case "gemini":

internal/registry/model_registry_cache_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,49 @@ func TestGetAvailableModelsReturnsClonedSnapshots(t *testing.T) {
2222
}
2323
}
2424

25+
func TestGetAvailableModelsClaudeIncludesTokenLimits(t *testing.T) {
26+
r := newTestModelRegistry()
27+
r.RegisterClient("client-1", "Claude", []*ModelInfo{
28+
{ID: "claude-sonnet-4-6", OwnedBy: "anthropic", Type: "claude", Created: 1771372800, ContextLength: 200000, MaxCompletionTokens: 64000},
29+
{ID: "claude-no-limits", OwnedBy: "anthropic", Type: "claude"},
30+
})
31+
32+
models := r.GetAvailableModels("claude")
33+
byID := make(map[string]map[string]any, len(models))
34+
for _, m := range models {
35+
id, _ := m["id"].(string)
36+
byID[id] = m
37+
}
38+
39+
withLimits, ok := byID["claude-sonnet-4-6"]
40+
if !ok {
41+
t.Fatalf("expected claude-sonnet-4-6 in available models, got %v", byID)
42+
}
43+
if got := withLimits["max_input_tokens"]; got != 200000 {
44+
t.Fatalf("expected max_input_tokens 200000, got %v", got)
45+
}
46+
if got := withLimits["max_tokens"]; got != 64000 {
47+
t.Fatalf("expected max_tokens 64000, got %v", got)
48+
}
49+
if got := withLimits["created_at"]; got != "2026-02-18T00:00:00Z" {
50+
t.Fatalf("expected created_at as RFC 3339 string, got %v", got)
51+
}
52+
53+
withDefaults, ok := byID["claude-no-limits"]
54+
if !ok {
55+
t.Fatalf("expected claude-no-limits in available models, got %v", byID)
56+
}
57+
if got := withDefaults["max_input_tokens"]; got != defaultClaudeMaxInputTokens {
58+
t.Fatalf("expected fallback max_input_tokens %d, got %v", defaultClaudeMaxInputTokens, got)
59+
}
60+
if got := withDefaults["max_tokens"]; got != defaultClaudeMaxOutputTokens {
61+
t.Fatalf("expected fallback max_tokens %d, got %v", defaultClaudeMaxOutputTokens, got)
62+
}
63+
if got := withDefaults["display_name"]; got != "claude-no-limits" {
64+
t.Fatalf("expected display_name to fall back to id, got %v", got)
65+
}
66+
}
67+
2568
func TestGetAvailableModelsInvalidatesCacheOnRegistryChanges(t *testing.T) {
2669
r := newTestModelRegistry()
2770
r.RegisterClient("client-1", "OpenAI", []*ModelInfo{{ID: "m1", OwnedBy: "team-a", DisplayName: "Model One"}})

0 commit comments

Comments
 (0)