mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-09 18:06:50 +08:00
Merge pull request #3834 from dcrdev/main
This commit is contained in:
+16
-11
@@ -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))
|
||||
|
||||
@@ -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,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"}})
|
||||
|
||||
Reference in New Issue
Block a user