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
This commit is contained in:
Luis Pater
2026-07-12 22:13:23 +08:00
parent f4a8aee695
commit 3586d3e750
15 changed files with 453 additions and 65 deletions
+5
View File
@@ -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
+28 -12
View File
@@ -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,
@@ -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)
}
})
}
}
+7 -3
View File
@@ -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() {
+5 -5
View File
@@ -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)
+42
View File
@@ -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"})
+4 -4
View File
@@ -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{}
+1 -1
View File
@@ -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)
@@ -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
@@ -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)
}
@@ -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)
@@ -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)
@@ -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)
}
}
@@ -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)
}
+72 -40
View File
@@ -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 = &registry.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 {