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:
Luis Pater
2026-06-20 13:09:44 +08:00
parent b0ca3794ad
commit a79ae80f6e
4 changed files with 168 additions and 114 deletions
+73 -37
View File
@@ -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
View File
@@ -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
+5 -7
View File
@@ -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) {