mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-11 02:46:47 +08:00
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:
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user