Merge pull request #3834 from dcrdev/main

This commit is contained in:
Luis Pater
2026-06-20 12:56:25 +08:00
4 changed files with 237 additions and 12 deletions
+16 -11
View File
@@ -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))
+160
View File
@@ -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"
+18 -1
View File
@@ -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":
@@ -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"}})