From 3586d3e750038c699b628122ec8e70d1fb8d8b48 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 12 Jul 2026 22:13:23 +0800 Subject: [PATCH] feat(config): add support for configurable model display names across all APIs - Added `DisplayName` to model configuration for Codex, Claude, Gemini, Vertex, and OpenAI compatibility. - Updated hash computation and model summary to account for `DisplayName`. - Enhanced API handlers to use `DisplayName` for client-facing catalogs. - Expanded tests to verify `DisplayName` usage, preservation, and fallback mechanisms. - Updated documentation and configuration examples for `DisplayName` field. Closes: #4245 --- config.example.yaml | 5 + internal/config/config.go | 40 +++++-- internal/config/model_display_name_test.go | 78 ++++++++++++ internal/config/vertex_compat.go | 10 +- internal/watcher/diff/model_hash.go | 10 +- internal/watcher/diff/model_hash_test.go | 42 +++++++ internal/watcher/diff/models_summary.go | 8 +- internal/watcher/diff/openai_compat.go | 2 +- .../claude/code_handlers_model_test.go | 40 +++++++ .../gemini/gemini_models_display_name_test.go | 46 +++++++ .../handlers/openai/codex_client_models.go | 7 ++ .../openai/codex_client_models_test.go | 14 +++ .../config_model_display_name_test.go | 97 +++++++++++++++ .../openai_compat_config_models_test.go | 7 ++ sdk/cliproxy/service.go | 112 +++++++++++------- 15 files changed, 453 insertions(+), 65 deletions(-) create mode 100644 internal/config/model_display_name_test.go create mode 100644 sdk/api/handlers/gemini/gemini_models_display_name_test.go create mode 100644 sdk/cliproxy/config_model_display_name_test.go diff --git a/config.example.yaml b/config.example.yaml index e435742a..fa72a415 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -218,6 +218,7 @@ nonstream-keepalive-interval: 0 # models: # - name: "gemini-2.5-flash" # upstream model name # alias: "gemini-flash" # client alias mapped to the upstream model +# display-name: "Gemini Flash" # optional catalog display name # excluded-models: # - "gemini-2.5-pro" # exclude specific models from this provider (exact match) # - "gemini-2.5-*" # wildcard matching prefix (e.g. gemini-2.5-flash, gemini-2.5-pro) @@ -256,6 +257,7 @@ nonstream-keepalive-interval: 0 # models: # - name: "gpt-5-codex" # upstream model name # alias: "codex-latest" # client alias mapped to the upstream model +# display-name: "Codex Latest" # optional catalog display name # force-mapping: true # optional: rewrite response model fields back to the alias # excluded-models: # - "gpt-5.1" # exclude specific models (exact match) @@ -277,6 +279,7 @@ nonstream-keepalive-interval: 0 # models: # - name: "claude-3-5-sonnet-20241022" # upstream model name # alias: "claude-sonnet-latest" # client alias mapped to the upstream model +# display-name: "Claude Sonnet" # optional catalog display name # force-mapping: true # optional: rewrite response model fields back to the alias # excluded-models: # - "claude-opus-4-5-20251101" # exclude specific models (exact match) @@ -341,6 +344,7 @@ nonstream-keepalive-interval: 0 # models: # The models supported by the provider. # - name: "moonshotai/kimi-k2:free" # The actual model name. # alias: "kimi-k2" # The alias used in the API. +# display-name: "Kimi K2" # optional catalog display name # image: false # optional: set true to allow this model on /v1/images/generations and /v1/images/edits (not chat/responses image input) # input-modalities: [text, image] # optional: declare /v1/chat/completions and /v1/responses multimodal input for Codex clients # output-modalities: [text] # optional: declare output modalities when known @@ -370,6 +374,7 @@ nonstream-keepalive-interval: 0 # models: # optional: map aliases to upstream model names # - name: "gemini-2.5-flash" # upstream model name # alias: "vertex-flash" # client-visible alias +# display-name: "Vertex Flash" # optional catalog display name # - name: "gemini-2.5-pro" # alias: "vertex-pro" # excluded-models: # optional: models to exclude from listing diff --git a/internal/config/config.go b/internal/config/config.go index fcee1bf7..58677894 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -490,13 +490,17 @@ type ClaudeModel struct { // Alias is the client-facing model name that maps to Name. Alias string `yaml:"alias" json:"alias"` + // DisplayName is the optional human-readable name shown in model catalogs. + DisplayName string `yaml:"display-name,omitempty" json:"display-name,omitempty"` + // ForceMapping rewrites upstream response model fields back to Alias. ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"` } -func (m ClaudeModel) GetName() string { return m.Name } -func (m ClaudeModel) GetAlias() string { return m.Alias } -func (m ClaudeModel) GetForceMapping() bool { return m.ForceMapping } +func (m ClaudeModel) GetName() string { return m.Name } +func (m ClaudeModel) GetAlias() string { return m.Alias } +func (m ClaudeModel) GetDisplayName() string { return m.DisplayName } +func (m ClaudeModel) GetForceMapping() bool { return m.ForceMapping } // CodexKey represents the configuration for a Codex API key, // including the API key itself and an optional base URL for the API endpoint. @@ -545,13 +549,17 @@ type CodexModel struct { // Alias is the client-facing model name that maps to Name. Alias string `yaml:"alias" json:"alias"` + // DisplayName is the optional human-readable name shown in model catalogs. + DisplayName string `yaml:"display-name,omitempty" json:"display-name,omitempty"` + // ForceMapping rewrites upstream response model fields back to Alias. ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"` } -func (m CodexModel) GetName() string { return m.Name } -func (m CodexModel) GetAlias() string { return m.Alias } -func (m CodexModel) GetForceMapping() bool { return m.ForceMapping } +func (m CodexModel) GetName() string { return m.Name } +func (m CodexModel) GetAlias() string { return m.Alias } +func (m CodexModel) GetDisplayName() string { return m.DisplayName } +func (m CodexModel) GetForceMapping() bool { return m.ForceMapping } // GeminiKey represents the configuration for a Gemini API key, // including optional overrides for upstream base URL, proxy routing, and headers. @@ -596,13 +604,17 @@ type GeminiModel struct { // Alias is the client-facing model name that maps to Name. Alias string `yaml:"alias" json:"alias"` + // DisplayName is the optional human-readable name shown in model catalogs. + DisplayName string `yaml:"display-name,omitempty" json:"display-name,omitempty"` + // ForceMapping rewrites upstream response model fields back to Alias. ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"` } -func (m GeminiModel) GetName() string { return m.Name } -func (m GeminiModel) GetAlias() string { return m.Alias } -func (m GeminiModel) GetForceMapping() bool { return m.ForceMapping } +func (m GeminiModel) GetName() string { return m.Name } +func (m GeminiModel) GetAlias() string { return m.Alias } +func (m GeminiModel) GetDisplayName() string { return m.DisplayName } +func (m GeminiModel) GetForceMapping() bool { return m.ForceMapping } // OpenAICompatibility represents the configuration for OpenAI API compatibility // with external providers, allowing model aliases to be routed through OpenAI API format. @@ -654,6 +666,9 @@ type OpenAICompatibilityModel struct { // Alias is the model name alias that clients will use to reference this model. Alias string `yaml:"alias" json:"alias"` + // DisplayName is the optional human-readable name shown in model catalogs. + DisplayName string `yaml:"display-name,omitempty" json:"display-name,omitempty"` + // ForceMapping rewrites upstream response model fields back to Alias. ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"` @@ -672,9 +687,10 @@ type OpenAICompatibilityModel struct { Thinking *registry.ThinkingSupport `yaml:"thinking,omitempty" json:"thinking,omitempty"` } -func (m OpenAICompatibilityModel) GetName() string { return m.Name } -func (m OpenAICompatibilityModel) GetAlias() string { return m.Alias } -func (m OpenAICompatibilityModel) GetForceMapping() bool { return m.ForceMapping } +func (m OpenAICompatibilityModel) GetName() string { return m.Name } +func (m OpenAICompatibilityModel) GetAlias() string { return m.Alias } +func (m OpenAICompatibilityModel) GetDisplayName() string { return m.DisplayName } +func (m OpenAICompatibilityModel) GetForceMapping() bool { return m.ForceMapping } // LoadConfig reads a YAML configuration file from the given path, // unmarshals it into a Config struct, applies environment variable overrides, diff --git a/internal/config/model_display_name_test.go b/internal/config/model_display_name_test.go new file mode 100644 index 00000000..daf49b0b --- /dev/null +++ b/internal/config/model_display_name_test.go @@ -0,0 +1,78 @@ +package config + +import ( + "encoding/json" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestModelDisplayNameConfigDecoding(t *testing.T) { + const yamlConfig = `codex-api-key: + - models: + - name: codex-upstream + alias: codex-alias + display-name: Codex Name +claude-api-key: + - models: + - name: claude-upstream + alias: claude-alias + display-name: Claude Name +gemini-api-key: + - models: + - name: gemini-upstream + alias: gemini-alias + display-name: Gemini Name +vertex-api-key: + - models: + - name: vertex-upstream + alias: vertex-alias + display-name: Vertex Name +openai-compatibility: + - models: + - name: compat-upstream + alias: compat-alias + display-name: Compatibility Name +` + const jsonConfig = `{"codex-api-key":[{"models":[{"name":"codex-upstream","alias":"codex-alias","display-name":"Codex Name"}]}],"claude-api-key":[{"models":[{"name":"claude-upstream","alias":"claude-alias","display-name":"Claude Name"}]}],"gemini-api-key":[{"models":[{"name":"gemini-upstream","alias":"gemini-alias","display-name":"Gemini Name"}]}],"vertex-api-key":[{"models":[{"name":"vertex-upstream","alias":"vertex-alias","display-name":"Vertex Name"}]}],"openai-compatibility":[{"models":[{"name":"compat-upstream","alias":"compat-alias","display-name":"Compatibility Name"}]}]}` + + for _, tt := range []struct { + name string + decode func(*Config) error + }{ + { + name: "YAML", + decode: func(cfg *Config) error { + return yaml.Unmarshal([]byte(yamlConfig), cfg) + }, + }, + { + name: "JSON", + decode: func(cfg *Config) error { + return json.Unmarshal([]byte(jsonConfig), cfg) + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + var cfg Config + if errDecode := tt.decode(&cfg); errDecode != nil { + t.Fatalf("decode config: %v", errDecode) + } + if got := cfg.CodexKey[0].Models[0].DisplayName; got != "Codex Name" { + t.Fatalf("Codex display name = %q", got) + } + if got := cfg.ClaudeKey[0].Models[0].DisplayName; got != "Claude Name" { + t.Fatalf("Claude display name = %q", got) + } + if got := cfg.GeminiKey[0].Models[0].DisplayName; got != "Gemini Name" { + t.Fatalf("Gemini display name = %q", got) + } + if got := cfg.VertexCompatAPIKey[0].Models[0].DisplayName; got != "Vertex Name" { + t.Fatalf("Vertex display name = %q", got) + } + if got := cfg.OpenAICompatibility[0].Models[0].DisplayName; got != "Compatibility Name" { + t.Fatalf("OpenAI compatibility display name = %q", got) + } + }) + } +} diff --git a/internal/config/vertex_compat.go b/internal/config/vertex_compat.go index 7261f53f..2d3d9014 100644 --- a/internal/config/vertex_compat.go +++ b/internal/config/vertex_compat.go @@ -51,13 +51,17 @@ type VertexCompatModel struct { // Alias is the model name alias that clients will use to reference this model. Alias string `yaml:"alias" json:"alias"` + // DisplayName is the optional human-readable name shown in model catalogs. + DisplayName string `yaml:"display-name,omitempty" json:"display-name,omitempty"` + // ForceMapping rewrites upstream response model fields back to Alias. ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"` } -func (m VertexCompatModel) GetName() string { return m.Name } -func (m VertexCompatModel) GetAlias() string { return m.Alias } -func (m VertexCompatModel) GetForceMapping() bool { return m.ForceMapping } +func (m VertexCompatModel) GetName() string { return m.Name } +func (m VertexCompatModel) GetAlias() string { return m.Alias } +func (m VertexCompatModel) GetDisplayName() string { return m.DisplayName } +func (m VertexCompatModel) GetForceMapping() bool { return m.ForceMapping } // SanitizeVertexCompatKeys deduplicates and normalizes Vertex-compatible API key credentials. func (cfg *Config) SanitizeVertexCompatKeys() { diff --git a/internal/watcher/diff/model_hash.go b/internal/watcher/diff/model_hash.go index a80ae575..c24e6d2c 100644 --- a/internal/watcher/diff/model_hash.go +++ b/internal/watcher/diff/model_hash.go @@ -21,7 +21,7 @@ func ComputeOpenAICompatModelsHash(models []config.OpenAICompatibilityModel) str if name == "" && alias == "" { continue } - out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + fmt.Sprintf("image=%t", model.Image)) + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName) + "|" + fmt.Sprintf("image=%t", model.Image)) } }) return hashJoined(keys) @@ -36,7 +36,7 @@ func ComputeVertexCompatModelsHash(models []config.VertexCompatModel) string { if name == "" && alias == "" { continue } - out(strings.ToLower(name) + "|" + strings.ToLower(alias)) + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName)) } }) return hashJoined(keys) @@ -51,7 +51,7 @@ func ComputeClaudeModelsHash(models []config.ClaudeModel) string { if name == "" && alias == "" { continue } - out(strings.ToLower(name) + "|" + strings.ToLower(alias)) + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName)) } }) return hashJoined(keys) @@ -66,7 +66,7 @@ func ComputeCodexModelsHash(models []config.CodexModel) string { if name == "" && alias == "" { continue } - out(strings.ToLower(name) + "|" + strings.ToLower(alias)) + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName)) } }) return hashJoined(keys) @@ -81,7 +81,7 @@ func ComputeGeminiModelsHash(models []config.GeminiModel) string { if name == "" && alias == "" { continue } - out(strings.ToLower(name) + "|" + strings.ToLower(alias)) + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName)) } }) return hashJoined(keys) diff --git a/internal/watcher/diff/model_hash_test.go b/internal/watcher/diff/model_hash_test.go index e033f328..2d1b08a4 100644 --- a/internal/watcher/diff/model_hash_test.go +++ b/internal/watcher/diff/model_hash_test.go @@ -129,6 +129,48 @@ func TestComputeCodexModelsHash_IgnoresBlankAndDedup(t *testing.T) { } } +func TestComputeModelHashesIncludeDisplayName(t *testing.T) { + tests := []struct { + name string + base string + changed string + }{ + { + name: "openai compatibility", + base: ComputeOpenAICompatModelsHash([]config.OpenAICompatibilityModel{{Name: "m", Alias: "a", DisplayName: "One"}}), + changed: ComputeOpenAICompatModelsHash([]config.OpenAICompatibilityModel{{Name: "m", Alias: "a", DisplayName: "Two"}}), + }, + { + name: "vertex", + base: ComputeVertexCompatModelsHash([]config.VertexCompatModel{{Name: "m", Alias: "a", DisplayName: "One"}}), + changed: ComputeVertexCompatModelsHash([]config.VertexCompatModel{{Name: "m", Alias: "a", DisplayName: "Two"}}), + }, + { + name: "claude", + base: ComputeClaudeModelsHash([]config.ClaudeModel{{Name: "m", Alias: "a", DisplayName: "One"}}), + changed: ComputeClaudeModelsHash([]config.ClaudeModel{{Name: "m", Alias: "a", DisplayName: "Two"}}), + }, + { + name: "codex", + base: ComputeCodexModelsHash([]config.CodexModel{{Name: "m", Alias: "a", DisplayName: "One"}}), + changed: ComputeCodexModelsHash([]config.CodexModel{{Name: "m", Alias: "a", DisplayName: "Two"}}), + }, + { + name: "gemini", + base: ComputeGeminiModelsHash([]config.GeminiModel{{Name: "m", Alias: "a", DisplayName: "One"}}), + changed: ComputeGeminiModelsHash([]config.GeminiModel{{Name: "m", Alias: "a", DisplayName: "Two"}}), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.base == "" || tt.base == tt.changed { + t.Fatalf("display name must change model hash: %q / %q", tt.base, tt.changed) + } + }) + } +} + func TestComputeExcludedModelsHash_Normalizes(t *testing.T) { hash1 := ComputeExcludedModelsHash([]string{" A ", "b", "a"}) hash2 := ComputeExcludedModelsHash([]string{"a", " b", "A"}) diff --git a/internal/watcher/diff/models_summary.go b/internal/watcher/diff/models_summary.go index 4c9b035a..c895ed26 100644 --- a/internal/watcher/diff/models_summary.go +++ b/internal/watcher/diff/models_summary.go @@ -41,7 +41,7 @@ func SummarizeGeminiModels(models []config.GeminiModel) GeminiModelsSummary { if name == "" && alias == "" { continue } - out(strings.ToLower(name) + "|" + strings.ToLower(alias)) + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName)) } }) return GeminiModelsSummary{ @@ -62,7 +62,7 @@ func SummarizeClaudeModels(models []config.ClaudeModel) ClaudeModelsSummary { if name == "" && alias == "" { continue } - out(strings.ToLower(name) + "|" + strings.ToLower(alias)) + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName)) } }) return ClaudeModelsSummary{ @@ -83,7 +83,7 @@ func SummarizeCodexModels(models []config.CodexModel) CodexModelsSummary { if name == "" && alias == "" { continue } - out(strings.ToLower(name) + "|" + strings.ToLower(alias)) + out(strings.ToLower(name) + "|" + strings.ToLower(alias) + "|" + strings.TrimSpace(model.DisplayName)) } }) return CodexModelsSummary{ @@ -107,7 +107,7 @@ func SummarizeVertexModels(models []config.VertexCompatModel) VertexModelsSummar if alias != "" { name = alias } - names = append(names, name) + names = append(names, name+"|"+strings.TrimSpace(model.DisplayName)) } if len(names) == 0 { return VertexModelsSummary{} diff --git a/internal/watcher/diff/openai_compat.go b/internal/watcher/diff/openai_compat.go index 8a1cb189..acdf39f9 100644 --- a/internal/watcher/diff/openai_compat.go +++ b/internal/watcher/diff/openai_compat.go @@ -153,7 +153,7 @@ func openAICompatSignature(entry config.OpenAICompatibility) string { if name == "" && alias == "" { continue } - models = append(models, strings.ToLower(name)+"|"+strings.ToLower(alias)+"|"+fmt.Sprintf("image=%t", model.Image)) + models = append(models, strings.ToLower(name)+"|"+strings.ToLower(alias)+"|"+strings.TrimSpace(model.DisplayName)+"|"+fmt.Sprintf("image=%t", model.Image)) } if len(models) > 0 { sort.Strings(models) diff --git a/sdk/api/handlers/claude/code_handlers_model_test.go b/sdk/api/handlers/claude/code_handlers_model_test.go index fe1fe2cd..1dc77d10 100644 --- a/sdk/api/handlers/claude/code_handlers_model_test.go +++ b/sdk/api/handlers/claude/code_handlers_model_test.go @@ -1,8 +1,13 @@ package claude import ( + "encoding/json" + "net/http/httptest" "testing" + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" "github.com/tidwall/gjson" ) @@ -24,6 +29,41 @@ func TestSortClaudeModelsByDisplayName(t *testing.T) { } } +func TestClaudeModelsResponseUsesConfiguredDisplayName(t *testing.T) { + const clientID = "claude-display-name-catalog-test" + const modelID = "claude-display-name-catalog-test" + registryRef := registry.GetGlobalRegistry() + registryRef.RegisterClient(clientID, "claude", []*registry.ModelInfo{{ + ID: modelID, Object: "model", OwnedBy: "test", DisplayName: "Configured Claude Name", + }}) + t.Cleanup(func() { + registryRef.UnregisterClient(clientID) + }) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + NewClaudeCodeAPIHandler(&handlers.BaseAPIHandler{}).ClaudeModels(ctx) + + var response struct { + Data []struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + } `json:"data"` + } + if errUnmarshal := json.Unmarshal(recorder.Body.Bytes(), &response); errUnmarshal != nil { + t.Fatalf("decode response: %v", errUnmarshal) + } + for _, model := range response.Data { + if model.ID == modelID { + if model.DisplayName != "Configured Claude Name" { + t.Fatalf("display_name = %q, want Configured Claude Name", model.DisplayName) + } + return + } + } + t.Fatalf("model %q not found in response", modelID) +} + func TestRewriteClaudeDDModelInBody(t *testing.T) { tests := []struct { name string diff --git a/sdk/api/handlers/gemini/gemini_models_display_name_test.go b/sdk/api/handlers/gemini/gemini_models_display_name_test.go new file mode 100644 index 00000000..3d047bac --- /dev/null +++ b/sdk/api/handlers/gemini/gemini_models_display_name_test.go @@ -0,0 +1,46 @@ +package gemini + +import ( + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" +) + +func TestGeminiModelsResponseUsesConfiguredDisplayName(t *testing.T) { + const clientID = "gemini-display-name-catalog-test" + const modelID = "gemini-display-name-catalog-test" + registryRef := registry.GetGlobalRegistry() + registryRef.RegisterClient(clientID, "gemini", []*registry.ModelInfo{{ + ID: modelID, Name: modelID, DisplayName: "Configured Gemini Name", + }}) + t.Cleanup(func() { + registryRef.UnregisterClient(clientID) + }) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + NewGeminiAPIHandler(&handlers.BaseAPIHandler{}).GeminiModels(ctx) + + var response struct { + Models []struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + } `json:"models"` + } + if errUnmarshal := json.Unmarshal(recorder.Body.Bytes(), &response); errUnmarshal != nil { + t.Fatalf("decode response: %v", errUnmarshal) + } + for _, model := range response.Models { + if model.Name == "models/"+modelID { + if model.DisplayName != "Configured Gemini Name" { + t.Fatalf("displayName = %q, want Configured Gemini Name", model.DisplayName) + } + return + } + } + t.Fatalf("model %q not found in response", modelID) +} diff --git a/sdk/api/handlers/openai/codex_client_models.go b/sdk/api/handlers/openai/codex_client_models.go index 1de20d29..68ec7995 100644 --- a/sdk/api/handlers/openai/codex_client_models.go +++ b/sdk/api/handlers/openai/codex_client_models.go @@ -55,6 +55,7 @@ func buildCodexClientModels(models []map[string]any) []map[string]any { if template, ok := templates[id]; ok { entry := cloneCodexClientModelMap(template) + applyCodexClientDisplayName(entry, model) sanitizeCodexClientReasoningMetadata(entry) applyCodexClientVisibilityOverride(entry, id) result = append(result, entry) @@ -155,6 +156,12 @@ func loadCodexClientModelTemplates() (map[string]map[string]any, map[string]any, return codexClientModelTemplates, codexClientDefaultTemplate, codexClientModelTemplatesErr } +func applyCodexClientDisplayName(entry map[string]any, model map[string]any) { + if displayName := stringModelValue(model, "display_name"); displayName != "" { + entry["display_name"] = displayName + } +} + func applyCodexClientModelMetadata(entry map[string]any, id string, model map[string]any) { info := registry.LookupModelInfo(id) diff --git a/sdk/api/handlers/openai/codex_client_models_test.go b/sdk/api/handlers/openai/codex_client_models_test.go index 0cda3266..64c0e96c 100644 --- a/sdk/api/handlers/openai/codex_client_models_test.go +++ b/sdk/api/handlers/openai/codex_client_models_test.go @@ -129,6 +129,20 @@ func TestCodexClientModelsResponse_InputModalitiesFromRegistry(t *testing.T) { } } +func TestCodexClientModelsResponse_AppliesDisplayNameToTemplateModel(t *testing.T) { + resp := CodexClientModelsResponse([]map[string]any{{ + "id": "gpt-5.5", + "display_name": "Configured Codex Name", + }}) + models, ok := resp["models"].([]map[string]any) + if !ok || len(models) != 1 { + t.Fatalf("models = %#v, want one model", resp["models"]) + } + if got := stringModelValue(models[0], "display_name"); got != "Configured Codex Name" { + t.Fatalf("display_name = %q, want Configured Codex Name", got) + } +} + func TestCodexClientModelsResponse_PreservesUltraReasoningEffort(t *testing.T) { resp := CodexClientModelsResponse([]map[string]any{{"id": "gpt-5.6-sol"}}) models, ok := resp["models"].([]map[string]any) diff --git a/sdk/cliproxy/config_model_display_name_test.go b/sdk/cliproxy/config_model_display_name_test.go new file mode 100644 index 00000000..c05b0ca9 --- /dev/null +++ b/sdk/cliproxy/config_model_display_name_test.go @@ -0,0 +1,97 @@ +package cliproxy + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" +) + +func TestBuildConfigModelsDisplayName(t *testing.T) { + tests := []struct { + name string + want string + got func() *ModelInfo + }{ + { + name: "claude", + want: "Claude Catalog Name", + got: func() *ModelInfo { + return buildClaudeConfigModels(&config.ClaudeKey{Models: []config.ClaudeModel{{ + Name: "claude-upstream", Alias: "claude-catalog", DisplayName: "Claude Catalog Name", + }}})[0] + }, + }, + { + name: "gemini", + want: "Gemini Catalog Name", + got: func() *ModelInfo { + return buildGeminiConfigModels(&config.GeminiKey{Models: []config.GeminiModel{{ + Name: "gemini-upstream", Alias: "gemini-catalog", DisplayName: "Gemini Catalog Name", + }}})[0] + }, + }, + { + name: "vertex", + want: "Vertex Catalog Name", + got: func() *ModelInfo { + return buildVertexCompatConfigModels(&config.VertexCompatKey{Models: []config.VertexCompatModel{{ + Name: "vertex-upstream", Alias: "vertex-catalog", DisplayName: "Vertex Catalog Name", + }}})[0] + }, + }, + { + name: "codex", + want: "Codex Catalog Name", + got: func() *ModelInfo { + return buildCodexConfigModels(&config.CodexKey{Models: []config.CodexModel{{ + Name: "gpt-5.5", Alias: "gpt-5.5", DisplayName: "Codex Catalog Name", + }}})[0] + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.got().DisplayName; got != tt.want { + t.Fatalf("DisplayName = %q, want %q", got, tt.want) + } + }) + } +} + +func TestBuildCodexConfigModelsPreservesBuiltinDisplayNames(t *testing.T) { + models := buildCodexConfigModels(&config.CodexKey{Models: []config.CodexModel{ + {Name: "gpt-image-1.5", DisplayName: "Configured Image 1.5"}, + {Name: "gpt-image-2", DisplayName: "Configured Image 2"}, + }}) + + wantDisplayNames := map[string]string{ + "gpt-image-1.5": "Configured Image 1.5", + "gpt-image-2": "Configured Image 2", + } + for _, model := range models { + wantDisplayName, ok := wantDisplayNames[model.ID] + if !ok { + continue + } + if model.DisplayName != wantDisplayName { + t.Errorf("%s DisplayName = %q, want %q", model.ID, model.DisplayName, wantDisplayName) + } + if model.Object != "model" || model.OwnedBy != "openai" || model.Type != "openai" || model.Created != 1704067200 || model.Version != model.ID || model.UserDefined { + t.Errorf("%s builtin metadata was not preserved: %#v", model.ID, model) + } + delete(wantDisplayNames, model.ID) + } + for modelID := range wantDisplayNames { + t.Errorf("missing builtin model %s", modelID) + } +} + +func TestBuildConfigModelsDisplayNameFallback(t *testing.T) { + model := buildClaudeConfigModels(&config.ClaudeKey{Models: []config.ClaudeModel{{ + Name: "claude-upstream", Alias: "claude-catalog", + }}})[0] + if model.DisplayName != "claude-upstream" { + t.Fatalf("DisplayName = %q, want upstream model name", model.DisplayName) + } +} diff --git a/sdk/cliproxy/openai_compat_config_models_test.go b/sdk/cliproxy/openai_compat_config_models_test.go index 608d8986..74ca453c 100644 --- a/sdk/cliproxy/openai_compat_config_models_test.go +++ b/sdk/cliproxy/openai_compat_config_models_test.go @@ -14,6 +14,7 @@ func TestBuildOpenAICompatibilityConfigModels_InputModalities(t *testing.T) { { Name: "upstream-vision", Alias: "mimo-v2.5-pro", + DisplayName: "Mimo Vision", InputModalities: []string{"TEXT", "image", "image"}, }, { @@ -45,12 +46,18 @@ func TestBuildOpenAICompatibilityConfigModels_InputModalities(t *testing.T) { if vision == nil { t.Fatal("expected vision model") } + if vision.DisplayName != "Mimo Vision" { + t.Fatalf("DisplayName = %q, want Mimo Vision", vision.DisplayName) + } if got := joinModalities(vision.SupportedInputModalities); got != "text,image" { t.Fatalf("SupportedInputModalities = %q, want text,image", got) } if imageModel == nil { t.Fatal("expected image model") } + if imageModel.DisplayName != "compat-image" { + t.Fatalf("image DisplayName = %q, want compat-image", imageModel.DisplayName) + } if imageModel.Type != registry.OpenAIImageModelType { t.Fatalf("image model type = %q, want %q", imageModel.Type, registry.OpenAIImageModelType) } diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 9fe0afb7..d82b5a59 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -2472,6 +2472,34 @@ func matchWildcard(pattern, value string) bool { type modelEntry interface { GetName() string GetAlias() string + GetDisplayName() string +} + +func buildConfiguredModelInfo(model modelEntry, ownedBy, modelType string, created int64, fallbackDisplayName string, userDefined bool) *ModelInfo { + name := strings.TrimSpace(model.GetName()) + alias := strings.TrimSpace(model.GetAlias()) + if alias == "" { + alias = name + } + if alias == "" { + return nil + } + displayName := strings.TrimSpace(model.GetDisplayName()) + if displayName == "" { + displayName = fallbackDisplayName + } + if displayName == "" { + displayName = alias + } + return &ModelInfo{ + ID: alias, + Object: "model", + Created: created, + OwnedBy: ownedBy, + Type: modelType, + DisplayName: displayName, + UserDefined: userDefined, + } } func buildOpenAICompatibilityConfigModels(compat *config.OpenAICompatibility) []*ModelInfo { @@ -2482,35 +2510,22 @@ func buildOpenAICompatibilityConfigModels(compat *config.OpenAICompatibility) [] models := make([]*ModelInfo, 0, len(compat.Models)) for i := range compat.Models { model := compat.Models[i] - modelID := strings.TrimSpace(model.Alias) - if modelID == "" { - modelID = strings.TrimSpace(model.Name) - } - if modelID == "" { - continue - } modelType := "openai-compatibility" if model.Image { modelType = registry.OpenAIImageModelType } + info := buildConfiguredModelInfo(model, compat.Name, modelType, now, strings.TrimSpace(model.Alias), false) + if info == nil { + continue + } thinking := model.Thinking if thinking == nil && !model.Image { thinking = ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}} } - inputModalities := normalizeCompatConfigModalities(model.InputModalities) - outputModalities := normalizeCompatConfigModalities(model.OutputModalities) - models = append(models, &ModelInfo{ - ID: modelID, - Object: "model", - Created: now, - OwnedBy: compat.Name, - Type: modelType, - DisplayName: modelID, - UserDefined: false, - Thinking: thinking, - SupportedInputModalities: inputModalities, - SupportedOutputModalities: outputModalities, - }) + info.Thinking = thinking + info.SupportedInputModalities = normalizeCompatConfigModalities(model.InputModalities) + info.SupportedOutputModalities = normalizeCompatConfigModalities(model.OutputModalities) + models = append(models, info) } return models } @@ -2548,31 +2563,16 @@ func buildConfigModels[T modelEntry](models []T, ownedBy, modelType string) []*M for i := range models { model := models[i] name := strings.TrimSpace(model.GetName()) - alias := strings.TrimSpace(model.GetAlias()) - if alias == "" { - alias = name - } - if alias == "" { + info := buildConfiguredModelInfo(model, ownedBy, modelType, now, name, true) + if info == nil { continue } + alias := info.ID key := strings.ToLower(alias) if _, exists := seen[key]; exists { continue } seen[key] = struct{}{} - display := name - if display == "" { - display = alias - } - info := &ModelInfo{ - ID: alias, - Object: "model", - Created: now, - OwnedBy: ownedBy, - Type: modelType, - DisplayName: display, - UserDefined: true, - } if name != "" { if upstream := registry.LookupStaticModelInfo(name); upstream != nil && upstream.Thinking != nil { info.Thinking = upstream.Thinking @@ -2608,7 +2608,39 @@ func buildCodexConfigModels(entry *config.CodexKey) []*ModelInfo { if entry == nil { return nil } - return registry.WithCodexBuiltins(buildConfigModels(entry.Models, "openai", "openai")) + + models := registry.WithCodexBuiltins(buildConfigModels(entry.Models, "openai", "openai")) + configuredDisplayNames := make(map[string]string, len(entry.Models)) + seenConfiguredModels := make(map[string]struct{}, len(entry.Models)) + for i := range entry.Models { + model := entry.Models[i] + alias := strings.TrimSpace(model.Alias) + if alias == "" { + alias = strings.TrimSpace(model.Name) + } + if alias == "" { + continue + } + key := strings.ToLower(alias) + if _, exists := seenConfiguredModels[key]; exists { + continue + } + seenConfiguredModels[key] = struct{}{} + + displayName := strings.TrimSpace(model.DisplayName) + if displayName != "" { + configuredDisplayNames[key] = displayName + } + } + for _, model := range models { + if model == nil { + continue + } + if displayName, ok := configuredDisplayNames[strings.ToLower(model.ID)]; ok { + model.DisplayName = displayName + } + } + return models } func rewriteModelInfoName(name, oldID, newID string) string { -- knotmirror 0.0.1