From a79ae80f6eb2e952186ed9089bb5dcf738af9a65 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 20 Jun 2026 13:09:44 +0800 Subject: [PATCH] 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 --- internal/api/server.go | 110 ++++++++----- internal/api/server_test.go | 149 ++++++++++-------- internal/registry/model_registry.go | 12 +- .../registry/model_registry_cache_test.go | 11 +- 4 files changed, 168 insertions(+), 114 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 58c7d0d0..e31955eb 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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. // diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 0258f82c..320776ba 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -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 diff --git a/internal/registry/model_registry.go b/internal/registry/model_registry.go index 252aaffd..0b8e8415 100644 --- a/internal/registry/model_registry.go +++ b/internal/registry/model_registry.go @@ -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 diff --git a/internal/registry/model_registry_cache_test.go b/internal/registry/model_registry_cache_test.go index 1951b433..fb49e1f4 100644 --- a/internal/registry/model_registry_cache_test.go +++ b/internal/registry/model_registry_cache_test.go @@ -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) { -- knotmirror 0.0.1