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.
This commit is contained in:
Luis Pater
2026-07-25 01:41:07 +08:00
parent 71d591296b
commit 0296600be6
8 changed files with 224 additions and 211 deletions
+3 -28
View File
@@ -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",
+4 -22
View File
@@ -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": [
+100
View File
@@ -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)
}
@@ -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)
}
})
}
}
-53
View File
@@ -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)
}
-48
View File
@@ -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)
}
})
}
}
+3 -42
View File
@@ -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.
@@ -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"