From 0296600be60a16a13296c387cc6ea5733e39d790 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 25 Jul 2026 01:41:07 +0800 Subject: [PATCH] feat(models): add Claude client model catalog and response builder - Introduced a new `models` package for organizing Claude client model templates and building responses. - Migrated Claude response handling to `claudemodels.BuildResponse`. - Added comprehensive tests for model ID transformation, sorting, and metadata validation. - Removed redundant utility functions and simplified integration with the API server. --- internal/api/server.go | 31 +---- internal/api/server_test.go | 26 +--- internal/client/claude/models/models.go | 100 +++++++++++++++ internal/client/claude/models/models_test.go | 114 ++++++++++++++++++ internal/util/claude_model.go | 53 -------- internal/util/claude_model_test.go | 48 -------- sdk/api/handlers/claude/code_handlers.go | 45 +------ .../claude/code_handlers_model_test.go | 18 --- 8 files changed, 224 insertions(+), 211 deletions(-) create mode 100644 internal/client/claude/models/models.go create mode 100644 internal/client/claude/models/models_test.go diff --git a/internal/api/server.go b/internal/api/server.go index 6e8b3b34..b4b95f1e 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -28,6 +28,7 @@ import ( managementHandlers "github.com/router-for-me/CLIProxyAPI/v7/internal/api/handlers/management" "github.com/router-for-me/CLIProxyAPI/v7/internal/api/middleware" "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" + claudemodels "github.com/router-for-me/CLIProxyAPI/v7/internal/client/claude/models" codexmodels "github.com/router-for-me/CLIProxyAPI/v7/internal/client/codex/models" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/home" @@ -1411,23 +1412,7 @@ func (s *Server) handleHomeModels(c *gin.Context) { isClaude := isAnthropicModelsRequest(c) if isClaude { - out := formatHomeClaudeModels(entries) - firstID := "" - lastID := "" - if len(out) > 0 { - if id, okID := out[0]["id"].(string); okID { - firstID = id - } - if id, okID := out[len(out)-1]["id"].(string); okID { - lastID = id - } - } - c.JSON(http.StatusOK, gin.H{ - "data": out, - "has_more": false, - "first_id": firstID, - "last_id": lastID, - }) + c.JSON(http.StatusOK, claudemodels.BuildResponse(formatHomeClaudeModels(entries))) return } @@ -1456,16 +1441,6 @@ func formatHomeClaudeModels(entries []homeModelEntry) []map[string]any { for _, entry := range entries { out = append(out, formatHomeClaudeModel(entry)) } - sort.SliceStable(out, func(i, j int) bool { - di, _ := out[i]["display_name"].(string) - dj, _ := out[j]["display_name"].(string) - if di != dj { - return di < dj - } - idi, _ := out[i]["id"].(string) - idj, _ := out[j]["id"].(string) - return idi < idj - }) return out } @@ -1483,7 +1458,7 @@ func formatHomeClaudeModel(entry homeModelEntry) map[string]any { maxOutput = registry.DefaultClaudeMaxOutputTokens } model := map[string]any{ - "id": util.EnsureClaudeModelIDPrefix(entry.id), + "id": entry.id, "object": "model", "owned_by": entry.ownedBy, "type": "model", diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 27e42e10..6be5bdef 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -1719,11 +1719,11 @@ func TestFormatHomeClaudeModelIncludesAnthropicSchemaFields(t *testing.T) { t.Fatalf("display_name fallback = %v, want claude-no-limits", got) } - prefixed := formatHomeClaudeModel(homeModelEntry{id: "gpt-4o", displayName: "GPT-4o"}) - if got := prefixed["id"]; got != "claude-fable-5-dd-o4-tpg" { - t.Fatalf("id = %v, want claude-fable-5-dd-o4-tpg", got) + customModel := formatHomeClaudeModel(homeModelEntry{id: "gpt-4o", displayName: "GPT-4o"}) + if got := customModel["id"]; got != "gpt-4o" { + t.Fatalf("id = %v, want gpt-4o", got) } - if got := prefixed["display_name"]; got != "GPT-4o" { + if got := customModel["display_name"]; got != "GPT-4o" { t.Fatalf("display_name = %v, want GPT-4o", got) } if got := withDefaults["max_input_tokens"]; got != registry.DefaultClaudeMaxInputTokens { @@ -1737,24 +1737,6 @@ func TestFormatHomeClaudeModelIncludesAnthropicSchemaFields(t *testing.T) { } } -func TestFormatHomeClaudeModelsSortsByDisplayName(t *testing.T) { - out := formatHomeClaudeModels([]homeModelEntry{ - {id: "claude-z", displayName: "Zebra"}, - {id: "gpt-4o", displayName: "Alpha"}, - {id: "claude-b", displayName: "Beta"}, - }) - if len(out) != 3 { - t.Fatalf("len(out) = %d, want 3", len(out)) - } - wantNames := []string{"Alpha", "Beta", "Zebra"} - for i, want := range wantNames { - got, _ := out[i]["display_name"].(string) - if got != want { - t.Fatalf("out[%d].display_name = %q, want %q", i, got, want) - } - } -} - func TestDecodeHomeModelsKeepsTokenMetadata(t *testing.T) { entries, errDecode := decodeHomeModels([]byte(`{ "claude": [ diff --git a/internal/client/claude/models/models.go b/internal/client/claude/models/models.go new file mode 100644 index 00000000..565ad2c1 --- /dev/null +++ b/internal/client/claude/models/models.go @@ -0,0 +1,100 @@ +// Package models builds model catalogs for Anthropic clients. +package models + +import ( + "sort" + "strings" +) + +const claudeDDModelPrefix = "claude-fable-5-dd-" + +// BuildResponse builds an Anthropic model response from available models. +func BuildResponse(availableModels []map[string]any) map[string]any { + models := make([]map[string]any, len(availableModels)) + for i, model := range availableModels { + models[i] = cloneModel(model) + if id, ok := models[i]["id"].(string); ok { + models[i]["id"] = EnsureClaudeModelIDPrefix(id) + } + } + + sort.SliceStable(models, func(i, j int) bool { + displayNameI, _ := models[i]["display_name"].(string) + displayNameJ, _ := models[j]["display_name"].(string) + if displayNameI != displayNameJ { + return displayNameI < displayNameJ + } + idI, _ := models[i]["id"].(string) + idJ, _ := models[j]["id"].(string) + return idI < idJ + }) + + firstID := "" + lastID := "" + if len(models) > 0 { + firstID, _ = models[0]["id"].(string) + lastID, _ = models[len(models)-1]["id"].(string) + } + + return map[string]any{ + "data": models, + "has_more": false, + "first_id": firstID, + "last_id": lastID, + } +} + +// EnsureClaudeModelIDPrefix rewrites model IDs for Anthropic model listings. +// IDs that already start with "claude-" are returned unchanged; all other IDs +// become "claude-fable-5-dd-" plus the original ID with its characters reversed. +func EnsureClaudeModelIDPrefix(id string) string { + if id == "" || strings.HasPrefix(id, "claude-") { + return id + } + return claudeDDModelPrefix + reverseModelID(id) +} + +// ResolveClaudeModelIDPrefix reverses EnsureClaudeModelIDPrefix for request routing. +// Optional thinking suffixes in model(value) form are preserved. +func ResolveClaudeModelIDPrefix(id string) string { + if id == "" { + return id + } + base, suffix, hasSuffix := splitModelThinkingSuffix(id) + if !strings.HasPrefix(base, claudeDDModelPrefix) { + return id + } + encoded := base[len(claudeDDModelPrefix):] + if encoded == "" { + return id + } + resolved := reverseModelID(encoded) + if hasSuffix { + return resolved + "(" + suffix + ")" + } + return resolved +} + +func cloneModel(model map[string]any) map[string]any { + cloned := make(map[string]any, len(model)) + for key, value := range model { + cloned[key] = value + } + return cloned +} + +func splitModelThinkingSuffix(model string) (base, suffix string, hasSuffix bool) { + lastOpen := strings.LastIndex(model, "(") + if lastOpen == -1 || !strings.HasSuffix(model, ")") { + return model, "", false + } + return model[:lastOpen], model[lastOpen+1 : len(model)-1], true +} + +func reverseModelID(id string) string { + runes := []rune(id) + for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { + runes[i], runes[j] = runes[j], runes[i] + } + return string(runes) +} diff --git a/internal/client/claude/models/models_test.go b/internal/client/claude/models/models_test.go new file mode 100644 index 00000000..45eecbdf --- /dev/null +++ b/internal/client/claude/models/models_test.go @@ -0,0 +1,114 @@ +package models + +import "testing" + +func TestBuildResponse(t *testing.T) { + availableModels := []map[string]any{ + {"id": "claude-z", "display_name": "Zebra", "max_tokens": 64000}, + {"id": "gpt-4o", "display_name": "Alpha"}, + {"id": "claude-c", "display_name": "Alpha"}, + {"id": "claude-b", "display_name": "Beta"}, + } + + response := BuildResponse(availableModels) + models, ok := response["data"].([]map[string]any) + if !ok { + t.Fatalf("data type = %T, want []map[string]any", response["data"]) + } + + wantIDs := []string{ + "claude-c", + "claude-fable-5-dd-o4-tpg", + "claude-b", + "claude-z", + } + if len(models) != len(wantIDs) { + t.Fatalf("len(data) = %d, want %d", len(models), len(wantIDs)) + } + for i, want := range wantIDs { + if got, _ := models[i]["id"].(string); got != want { + t.Fatalf("data[%d].id = %q, want %q", i, got, want) + } + } + if got := models[3]["max_tokens"]; got != 64000 { + t.Fatalf("max_tokens = %v, want 64000", got) + } + if got := response["has_more"]; got != false { + t.Fatalf("has_more = %v, want false", got) + } + if got := response["first_id"]; got != wantIDs[0] { + t.Fatalf("first_id = %v, want %q", got, wantIDs[0]) + } + if got := response["last_id"]; got != wantIDs[len(wantIDs)-1] { + t.Fatalf("last_id = %v, want %q", got, wantIDs[len(wantIDs)-1]) + } + + if got := availableModels[1]["id"]; got != "gpt-4o" { + t.Fatalf("BuildResponse mutated input id to %v", got) + } + if got := availableModels[0]["id"]; got != "claude-z" { + t.Fatalf("BuildResponse reordered input: first id = %v", got) + } +} + +func TestBuildResponseEmpty(t *testing.T) { + response := BuildResponse(nil) + models, ok := response["data"].([]map[string]any) + if !ok { + t.Fatalf("data type = %T, want []map[string]any", response["data"]) + } + if len(models) != 0 { + t.Fatalf("len(data) = %d, want 0", len(models)) + } + if response["first_id"] != "" || response["last_id"] != "" { + t.Fatalf("empty response IDs = (%v, %v), want empty", response["first_id"], response["last_id"]) + } +} + +func TestEnsureClaudeModelIDPrefix(t *testing.T) { + tests := []struct { + name string + id string + want string + }{ + {"empty", "", ""}, + {"already has claude prefix", "claude-sonnet-4-6", "claude-sonnet-4-6"}, + {"contains claude mid-string is reversed", "my-claude-custom", "claude-fable-5-dd-motsuc-edualc-ym"}, + {"uppercase Claude prefix is reversed", "Claude-Opus-4", "claude-fable-5-dd-4-supO-edualC"}, + {"gpt model is reversed", "gpt-4o", "claude-fable-5-dd-o4-tpg"}, + {"gemini model is reversed", "gemini-2.5-pro", "claude-fable-5-dd-orp-5.2-inimeg"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := EnsureClaudeModelIDPrefix(tt.id); got != tt.want { + t.Fatalf("EnsureClaudeModelIDPrefix(%q) = %q, want %q", tt.id, got, tt.want) + } + }) + } +} + +func TestResolveClaudeModelIDPrefix(t *testing.T) { + tests := []struct { + name string + id string + want string + }{ + {"empty", "", ""}, + {"plain claude id unchanged", "claude-sonnet-4-6", "claude-sonnet-4-6"}, + {"non encoded id unchanged", "gpt-4o", "gpt-4o"}, + {"encoded gpt model", "claude-fable-5-dd-o4-tpg", "gpt-4o"}, + {"encoded gemini model", "claude-fable-5-dd-orp-5.2-inimeg", "gemini-2.5-pro"}, + {"empty encoded body unchanged", "claude-fable-5-dd-", "claude-fable-5-dd-"}, + {"preserves thinking suffix", "claude-fable-5-dd-o4-tpg(high)", "gpt-4o(high)"}, + {"round trip", EnsureClaudeModelIDPrefix("custom-model-x"), "custom-model-x"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ResolveClaudeModelIDPrefix(tt.id); got != tt.want { + t.Fatalf("ResolveClaudeModelIDPrefix(%q) = %q, want %q", tt.id, got, tt.want) + } + }) + } +} diff --git a/internal/util/claude_model.go b/internal/util/claude_model.go index ff3ef892..1534f02c 100644 --- a/internal/util/claude_model.go +++ b/internal/util/claude_model.go @@ -8,56 +8,3 @@ func IsClaudeThinkingModel(model string) bool { lower := strings.ToLower(model) return strings.Contains(lower, "claude") && strings.Contains(lower, "thinking") } - -const claudeDDModelPrefix = "claude-fable-5-dd-" - -// EnsureClaudeModelIDPrefix rewrites model IDs for Anthropic /models listings. -// IDs that already start with "claude-" are returned unchanged; all other IDs -// become "claude-fable-5-dd-" plus the original ID with its characters reversed. -func EnsureClaudeModelIDPrefix(id string) string { - if id == "" { - return id - } - if strings.HasPrefix(id, "claude-") { - return id - } - return claudeDDModelPrefix + reverseModelID(id) -} - -// ResolveClaudeModelIDPrefix reverses EnsureClaudeModelIDPrefix for request routing. -// IDs that start with "claude-fable-5-dd-" are decoded by stripping the prefix and reversing -// the remainder. Optional thinking suffixes in model(value) form are preserved. -func ResolveClaudeModelIDPrefix(id string) string { - if id == "" { - return id - } - base, suffix, hasSuffix := splitModelThinkingSuffix(id) - if !strings.HasPrefix(base, claudeDDModelPrefix) { - return id - } - encoded := base[len(claudeDDModelPrefix):] - if encoded == "" { - return id - } - resolved := reverseModelID(encoded) - if hasSuffix { - return resolved + "(" + suffix + ")" - } - return resolved -} - -func splitModelThinkingSuffix(model string) (base, suffix string, hasSuffix bool) { - lastOpen := strings.LastIndex(model, "(") - if lastOpen == -1 || !strings.HasSuffix(model, ")") { - return model, "", false - } - return model[:lastOpen], model[lastOpen+1 : len(model)-1], true -} - -func reverseModelID(id string) string { - runes := []rune(id) - for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { - runes[i], runes[j] = runes[j], runes[i] - } - return string(runes) -} diff --git a/internal/util/claude_model_test.go b/internal/util/claude_model_test.go index 8fb29c37..d20c337d 100644 --- a/internal/util/claude_model_test.go +++ b/internal/util/claude_model_test.go @@ -40,51 +40,3 @@ func TestIsClaudeThinkingModel(t *testing.T) { }) } } - -func TestEnsureClaudeModelIDPrefix(t *testing.T) { - tests := []struct { - name string - id string - want string - }{ - {"empty", "", ""}, - {"already has claude prefix", "claude-sonnet-4-6", "claude-sonnet-4-6"}, - {"contains claude mid-string is reversed", "my-claude-custom", "claude-fable-5-dd-motsuc-edualc-ym"}, - {"uppercase Claude prefix is reversed", "Claude-Opus-4", "claude-fable-5-dd-4-supO-edualC"}, - {"gpt model is reversed", "gpt-4o", "claude-fable-5-dd-o4-tpg"}, - {"gemini model is reversed", "gemini-2.5-pro", "claude-fable-5-dd-orp-5.2-inimeg"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := EnsureClaudeModelIDPrefix(tt.id); got != tt.want { - t.Fatalf("EnsureClaudeModelIDPrefix(%q) = %q, want %q", tt.id, got, tt.want) - } - }) - } -} - -func TestResolveClaudeModelIDPrefix(t *testing.T) { - tests := []struct { - name string - id string - want string - }{ - {"empty", "", ""}, - {"plain claude id unchanged", "claude-sonnet-4-6", "claude-sonnet-4-6"}, - {"non encoded id unchanged", "gpt-4o", "gpt-4o"}, - {"encoded gpt model", "claude-fable-5-dd-o4-tpg", "gpt-4o"}, - {"encoded gemini model", "claude-fable-5-dd-orp-5.2-inimeg", "gemini-2.5-pro"}, - {"empty encoded body unchanged", "claude-fable-5-dd-", "claude-fable-5-dd-"}, - {"preserves thinking suffix", "claude-fable-5-dd-o4-tpg(high)", "gpt-4o(high)"}, - {"round trip", EnsureClaudeModelIDPrefix("custom-model-x"), "custom-model-x"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := ResolveClaudeModelIDPrefix(tt.id); got != tt.want { - t.Fatalf("ResolveClaudeModelIDPrefix(%q) = %q, want %q", tt.id, got, tt.want) - } - }) - } -} diff --git a/sdk/api/handlers/claude/code_handlers.go b/sdk/api/handlers/claude/code_handlers.go index 16a75d83..5d3fc4b3 100644 --- a/sdk/api/handlers/claude/code_handlers.go +++ b/sdk/api/handlers/claude/code_handlers.go @@ -14,15 +14,14 @@ import ( "fmt" "io" "net/http" - "sort" "strings" "time" "github.com/gin-gonic/gin" + claudemodels "github.com/router-for-me/CLIProxyAPI/v7/internal/client/claude/models" . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" @@ -138,7 +137,7 @@ func (h *ClaudeCodeAPIHandler) ClaudeCountTokens(c *gin.Context) { // back into the original model name used for routing and upstream requests. func rewriteClaudeDDModelInBody(rawJSON []byte) []byte { modelName := gjson.GetBytes(rawJSON, "model").String() - resolved := util.ResolveClaudeModelIDPrefix(modelName) + resolved := claudemodels.ResolveClaudeModelIDPrefix(modelName) if resolved == modelName { return rawJSON } @@ -155,45 +154,7 @@ func rewriteClaudeDDModelInBody(rawJSON []byte) []byte { // Parameters: // - c: The Gin context for the request. func (h *ClaudeCodeAPIHandler) ClaudeModels(c *gin.Context) { - models := h.Models() - for i := range models { - if id, ok := models[i]["id"].(string); ok { - models[i]["id"] = util.EnsureClaudeModelIDPrefix(id) - } - } - sortClaudeModelsByDisplayName(models) - firstID := "" - lastID := "" - if len(models) > 0 { - if id, ok := models[0]["id"].(string); ok { - firstID = id - } - if id, ok := models[len(models)-1]["id"].(string); ok { - lastID = id - } - } - - c.JSON(http.StatusOK, gin.H{ - "data": models, - "has_more": false, - "first_id": firstID, - "last_id": lastID, - }) -} - -// sortClaudeModelsByDisplayName sorts models by display_name ascending. -// When display_name is equal or missing, id is used as a stable tie-breaker. -func sortClaudeModelsByDisplayName(models []map[string]any) { - sort.SliceStable(models, func(i, j int) bool { - di, _ := models[i]["display_name"].(string) - dj, _ := models[j]["display_name"].(string) - if di != dj { - return di < dj - } - idi, _ := models[i]["id"].(string) - idj, _ := models[j]["id"].(string) - return idi < idj - }) + c.JSON(http.StatusOK, claudemodels.BuildResponse(h.Models())) } // handleNonStreamingResponse handles non-streaming content generation requests for Claude models. diff --git a/sdk/api/handlers/claude/code_handlers_model_test.go b/sdk/api/handlers/claude/code_handlers_model_test.go index 1dc77d10..9a6e8ef5 100644 --- a/sdk/api/handlers/claude/code_handlers_model_test.go +++ b/sdk/api/handlers/claude/code_handlers_model_test.go @@ -11,24 +11,6 @@ import ( "github.com/tidwall/gjson" ) -func TestSortClaudeModelsByDisplayName(t *testing.T) { - models := []map[string]any{ - {"id": "claude-fable-5-dd-b", "display_name": "Zebra"}, - {"id": "claude-a", "display_name": "Alpha"}, - {"id": "claude-c", "display_name": "Alpha"}, - {"id": "claude-fable-5-dd-d", "display_name": "Beta"}, - } - sortClaudeModelsByDisplayName(models) - - wantIDs := []string{"claude-a", "claude-c", "claude-fable-5-dd-d", "claude-fable-5-dd-b"} - for i, want := range wantIDs { - got, _ := models[i]["id"].(string) - if got != want { - t.Fatalf("models[%d].id = %q, want %q", i, got, want) - } - } -} - func TestClaudeModelsResponseUsesConfiguredDisplayName(t *testing.T) { const clientID = "claude-display-name-catalog-test" const modelID = "claude-display-name-catalog-test" -- 2.51.2