mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-08 17:36:47 +08:00
feat(registry): improve model fallback logic and refactor Claude model handling
- Replaced internal model-specific constants with exported defaults (`DefaultClaudeMaxInputTokens`, `DefaultClaudeMaxOutputTokens`). - Refactored Claude model formatting to centralize schema application in `formatHomeClaudeModel`, with fields like `type`, `display_name`, and token limits. - Added new unit tests for fallback behavior, metadata fields, and token handling in Claude and Gemini models. - Removed deprecated test coverage for Amp provider model routes. Closes: #3833
This commit is contained in:
+73
-37
@@ -16,6 +16,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -32,6 +33,7 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/managementasset"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
|
||||
@@ -1037,10 +1039,12 @@ func (s *Server) geminiGetHandler(geminiHandler *gemini.GeminiAPIHandler) gin.Ha
|
||||
}
|
||||
|
||||
type homeModelEntry struct {
|
||||
id string
|
||||
created int64
|
||||
ownedBy string
|
||||
displayName string
|
||||
id string
|
||||
created int64
|
||||
ownedBy string
|
||||
displayName string
|
||||
contextLength int
|
||||
maxCompletionTokens int
|
||||
}
|
||||
|
||||
func (s *Server) handleHomeModels(c *gin.Context) {
|
||||
@@ -1052,21 +1056,7 @@ func (s *Server) handleHomeModels(c *gin.Context) {
|
||||
isClaude := isAnthropicModelsRequest(c)
|
||||
|
||||
if isClaude {
|
||||
out := make([]map[string]any, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
model := map[string]any{
|
||||
"id": entry.id,
|
||||
"object": "model",
|
||||
"owned_by": entry.ownedBy,
|
||||
}
|
||||
if entry.created > 0 {
|
||||
model["created_at"] = entry.created
|
||||
}
|
||||
if entry.displayName != "" {
|
||||
model["display_name"] = entry.displayName
|
||||
}
|
||||
out = append(out, model)
|
||||
}
|
||||
out := formatHomeClaudeModels(entries)
|
||||
firstID := ""
|
||||
lastID := ""
|
||||
if len(out) > 0 {
|
||||
@@ -1106,6 +1096,42 @@ func (s *Server) handleHomeModels(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func formatHomeClaudeModels(entries []homeModelEntry) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
out = append(out, formatHomeClaudeModel(entry))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func formatHomeClaudeModel(entry homeModelEntry) map[string]any {
|
||||
displayName := entry.displayName
|
||||
if displayName == "" {
|
||||
displayName = entry.id
|
||||
}
|
||||
maxInput := entry.contextLength
|
||||
if maxInput <= 0 {
|
||||
maxInput = registry.DefaultClaudeMaxInputTokens
|
||||
}
|
||||
maxOutput := entry.maxCompletionTokens
|
||||
if maxOutput <= 0 {
|
||||
maxOutput = registry.DefaultClaudeMaxOutputTokens
|
||||
}
|
||||
model := map[string]any{
|
||||
"id": entry.id,
|
||||
"object": "model",
|
||||
"owned_by": entry.ownedBy,
|
||||
"type": "model",
|
||||
"display_name": displayName,
|
||||
"max_input_tokens": maxInput,
|
||||
"max_tokens": maxOutput,
|
||||
}
|
||||
if entry.created > 0 {
|
||||
model["created_at"] = time.Unix(entry.created, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func (s *Server) handleHomeGeminiModels(c *gin.Context) {
|
||||
entries, ok := s.loadHomeModelEntries(c)
|
||||
if !ok {
|
||||
@@ -1321,20 +1347,6 @@ func decodeHomeModels(raw []byte) ([]homeModelEntry, error) {
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
|
||||
created := int64(0)
|
||||
switch v := model["created"].(type) {
|
||||
case float64:
|
||||
created = int64(v)
|
||||
case int64:
|
||||
created = v
|
||||
case int:
|
||||
created = int64(v)
|
||||
case json.Number:
|
||||
if n, err := v.Int64(); err == nil {
|
||||
created = n
|
||||
}
|
||||
}
|
||||
|
||||
ownedBy, _ := model["owned_by"].(string)
|
||||
ownedBy = strings.TrimSpace(ownedBy)
|
||||
displayName, _ := model["display_name"].(string)
|
||||
@@ -1345,10 +1357,12 @@ func decodeHomeModels(raw []byte) ([]homeModelEntry, error) {
|
||||
}
|
||||
|
||||
out = append(out, homeModelEntry{
|
||||
id: id,
|
||||
created: created,
|
||||
ownedBy: ownedBy,
|
||||
displayName: displayName,
|
||||
id: id,
|
||||
created: homeModelInt64Value(model, "created"),
|
||||
ownedBy: ownedBy,
|
||||
displayName: displayName,
|
||||
contextLength: int(homeModelInt64Value(model, "context_length", "contextLength", "inputTokenLimit", "max_input_tokens")),
|
||||
maxCompletionTokens: int(homeModelInt64Value(model, "max_completion_tokens", "maxCompletionTokens", "outputTokenLimit", "max_tokens")),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1360,6 +1374,28 @@ func decodeHomeModels(raw []byte) ([]homeModelEntry, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func homeModelInt64Value(model map[string]any, keys ...string) int64 {
|
||||
for _, key := range keys {
|
||||
switch value := model[key].(type) {
|
||||
case float64:
|
||||
return int64(value)
|
||||
case int64:
|
||||
return value
|
||||
case int:
|
||||
return int64(value)
|
||||
case json.Number:
|
||||
if n, errInt := value.Int64(); errInt == nil {
|
||||
return n
|
||||
}
|
||||
case string:
|
||||
if n, errParse := strconv.ParseInt(strings.TrimSpace(value), 10, 64); errParse == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Start begins listening for and serving HTTP or HTTPS requests.
|
||||
// It's a blocking call and will only return on an unrecoverable error.
|
||||
//
|
||||
|
||||
+83
-66
@@ -332,72 +332,6 @@ 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"
|
||||
@@ -751,6 +685,89 @@ func TestDefaultRequestLoggerFactory_UsesResolvedLogDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatHomeClaudeModelIncludesAnthropicSchemaFields(t *testing.T) {
|
||||
withMetadata := formatHomeClaudeModel(homeModelEntry{
|
||||
id: "claude-sonnet-4-6",
|
||||
created: 1771372800,
|
||||
ownedBy: "anthropic",
|
||||
displayName: "Claude 4.6 Sonnet",
|
||||
contextLength: 200000,
|
||||
maxCompletionTokens: 64000,
|
||||
})
|
||||
if got := withMetadata["created_at"]; got != "2026-02-18T00:00:00Z" {
|
||||
t.Fatalf("created_at = %v, want RFC3339 timestamp", got)
|
||||
}
|
||||
if got := withMetadata["type"]; got != "model" {
|
||||
t.Fatalf("type = %v, want model", got)
|
||||
}
|
||||
if got := withMetadata["display_name"]; got != "Claude 4.6 Sonnet" {
|
||||
t.Fatalf("display_name = %v, want Claude 4.6 Sonnet", got)
|
||||
}
|
||||
if got := withMetadata["max_input_tokens"]; got != 200000 {
|
||||
t.Fatalf("max_input_tokens = %v, want 200000", got)
|
||||
}
|
||||
if got := withMetadata["max_tokens"]; got != 64000 {
|
||||
t.Fatalf("max_tokens = %v, want 64000", got)
|
||||
}
|
||||
|
||||
withDefaults := formatHomeClaudeModel(homeModelEntry{id: "claude-no-limits"})
|
||||
if got := withDefaults["display_name"]; got != "claude-no-limits" {
|
||||
t.Fatalf("display_name fallback = %v, want claude-no-limits", got)
|
||||
}
|
||||
if got := withDefaults["max_input_tokens"]; got != registry.DefaultClaudeMaxInputTokens {
|
||||
t.Fatalf("max_input_tokens fallback = %v, want %d", got, registry.DefaultClaudeMaxInputTokens)
|
||||
}
|
||||
if got := withDefaults["max_tokens"]; got != registry.DefaultClaudeMaxOutputTokens {
|
||||
t.Fatalf("max_tokens fallback = %v, want %d", got, registry.DefaultClaudeMaxOutputTokens)
|
||||
}
|
||||
if _, ok := withDefaults["created_at"]; ok {
|
||||
t.Fatalf("created_at should be omitted when source created is missing, got %v", withDefaults)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeHomeModelsKeepsTokenMetadata(t *testing.T) {
|
||||
entries, errDecode := decodeHomeModels([]byte(`{
|
||||
"claude": [
|
||||
{
|
||||
"id": "claude-sonnet-4-6",
|
||||
"created": 1771372800,
|
||||
"owned_by": "anthropic",
|
||||
"context_length": 200000,
|
||||
"max_completion_tokens": 64000
|
||||
}
|
||||
],
|
||||
"gemini": [
|
||||
{
|
||||
"name": "models/gemini-3-pro",
|
||||
"inputTokenLimit": 1048576,
|
||||
"outputTokenLimit": 65536
|
||||
}
|
||||
]
|
||||
}`))
|
||||
if errDecode != nil {
|
||||
t.Fatalf("decodeHomeModels returned error: %v", errDecode)
|
||||
}
|
||||
|
||||
byID := make(map[string]homeModelEntry, len(entries))
|
||||
for _, entry := range entries {
|
||||
byID[entry.id] = entry
|
||||
}
|
||||
claudeEntry, ok := byID["claude-sonnet-4-6"]
|
||||
if !ok {
|
||||
t.Fatalf("expected claude-sonnet-4-6 entry, got %v", byID)
|
||||
}
|
||||
if claudeEntry.contextLength != 200000 || claudeEntry.maxCompletionTokens != 64000 {
|
||||
t.Fatalf("claude token metadata = %d/%d, want 200000/64000", claudeEntry.contextLength, claudeEntry.maxCompletionTokens)
|
||||
}
|
||||
geminiEntry, ok := byID["gemini-3-pro"]
|
||||
if !ok {
|
||||
t.Fatalf("expected gemini-3-pro entry, got %v", byID)
|
||||
}
|
||||
if geminiEntry.contextLength != 1048576 || geminiEntry.maxCompletionTokens != 65536 {
|
||||
t.Fatalf("gemini token metadata = %d/%d, want 1048576/65536", geminiEntry.contextLength, geminiEntry.maxCompletionTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeModelsAuthStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
const OpenAIImageModelType = "openai-image"
|
||||
|
||||
const (
|
||||
defaultClaudeMaxInputTokens = 200000
|
||||
defaultClaudeMaxOutputTokens = 64000
|
||||
DefaultClaudeMaxInputTokens = 200000
|
||||
DefaultClaudeMaxOutputTokens = 64000
|
||||
)
|
||||
|
||||
// ModelInfo represents information about an available model
|
||||
@@ -1163,9 +1163,7 @@ func (r *ModelRegistry) convertModelToMap(model *ModelInfo, handlerType string)
|
||||
if model.Created > 0 {
|
||||
result["created_at"] = time.Unix(model.Created, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
if model.Type != "" {
|
||||
result["type"] = "model"
|
||||
}
|
||||
result["type"] = "model"
|
||||
if model.DisplayName != "" {
|
||||
result["display_name"] = model.DisplayName
|
||||
} else {
|
||||
@@ -1173,11 +1171,11 @@ func (r *ModelRegistry) convertModelToMap(model *ModelInfo, handlerType string)
|
||||
}
|
||||
maxInput := model.ContextLength
|
||||
if maxInput <= 0 {
|
||||
maxInput = defaultClaudeMaxInputTokens
|
||||
maxInput = DefaultClaudeMaxInputTokens
|
||||
}
|
||||
maxOutput := model.MaxCompletionTokens
|
||||
if maxOutput <= 0 {
|
||||
maxOutput = defaultClaudeMaxOutputTokens
|
||||
maxOutput = DefaultClaudeMaxOutputTokens
|
||||
}
|
||||
result["max_input_tokens"] = maxInput
|
||||
result["max_tokens"] = maxOutput
|
||||
|
||||
@@ -54,15 +54,18 @@ func TestGetAvailableModelsClaudeIncludesTokenLimits(t *testing.T) {
|
||||
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_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["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)
|
||||
}
|
||||
if got := withDefaults["type"]; got != "model" {
|
||||
t.Fatalf("expected type to default to model, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAvailableModelsInvalidatesCacheOnRegistryChanges(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user