feat: enhance model listing to include aliases (#400)

introduce includeAliasesInList as a new configuration setting (default false) that includes aliases in v1/models

Fixes #399
This commit is contained in:
Ryan Steed
2025-11-15 22:35:26 +00:00
committed by GitHub
parent 3567b7df08
commit 554d29e87d
5 changed files with 108 additions and 18 deletions

View File

@@ -74,6 +74,11 @@
"default": false, "default": false,
"description": "Inject loading status updates into the reasoning field. When true, a stream of loading messages will be sent to the client." "description": "Inject loading status updates into the reasoning field. When true, a stream of loading messages will be sent to the client."
}, },
"includeAliasesInList": {
"type": "boolean",
"default": false,
"description": "Present aliases within the /v1/models OpenAI API listing. when true, model aliases will be output to the API model listing duplicating all fields except for Id so chat UIs can use the alias equivalent to the original."
},
"macros": { "macros": {
"$ref": "#/definitions/macros" "$ref": "#/definitions/macros"
}, },
@@ -247,4 +252,4 @@
"description": "A dictionary of event triggers and actions. Only supported hook is on_startup." "description": "A dictionary of event triggers and actions. Only supported hook is on_startup."
} }
} }
} }

View File

@@ -46,6 +46,12 @@ startPort: 10001
# - see #366 for more details # - see #366 for more details
sendLoadingState: true sendLoadingState: true
# includeAliasesInList: present aliases within the /v1/models OpenAI API listing
# - optional, default: false
# - when true, model aliases will be output to the API model listing duplicating
# all fields except for Id so chat UIs can use the alias equivalent to the original.
includeAliasesInList: false
# macros: a dictionary of string substitutions # macros: a dictionary of string substitutions
# - optional, default: empty dictionary # - optional, default: empty dictionary
# - macros are reusable snippets # - macros are reusable snippets

View File

@@ -132,6 +132,9 @@ type Config struct {
// send loading state in reasoning // send loading state in reasoning
SendLoadingState bool `yaml:"sendLoadingState"` SendLoadingState bool `yaml:"sendLoadingState"`
// present aliases to /v1/models OpenAI API listing
IncludeAliasesInList bool `yaml:"includeAliasesInList"`
} }
func (c *Config) RealModelName(search string) (string, bool) { func (c *Config) RealModelName(search string) (string, bool) {

View File

@@ -376,28 +376,40 @@ func (pm *ProxyManager) listModelsHandler(c *gin.Context) {
continue continue
} }
record := gin.H{ newRecord := func(modelId string) gin.H {
"id": id, record := gin.H{
"object": "model", "id": modelId,
"created": createdTime, "object": "model",
"owned_by": "llama-swap", "created": createdTime,
"owned_by": "llama-swap",
}
if name := strings.TrimSpace(modelConfig.Name); name != "" {
record["name"] = name
}
if desc := strings.TrimSpace(modelConfig.Description); desc != "" {
record["description"] = desc
}
// Add metadata if present
if len(modelConfig.Metadata) > 0 {
record["meta"] = gin.H{
"llamaswap": modelConfig.Metadata,
}
}
return record
} }
if name := strings.TrimSpace(modelConfig.Name); name != "" { data = append(data, newRecord(id))
record["name"] = name
}
if desc := strings.TrimSpace(modelConfig.Description); desc != "" {
record["description"] = desc
}
// Add metadata if present // Include aliases
if len(modelConfig.Metadata) > 0 { if pm.config.IncludeAliasesInList {
record["meta"] = gin.H{ for _, alias := range modelConfig.Aliases {
"llamaswap": modelConfig.Metadata, if alias := strings.TrimSpace(alias); alias != "" {
data = append(data, newRecord(alias))
}
} }
} }
data = append(data, record)
} }
// Sort by the "id" key // Sort by the "id" key

View File

@@ -437,6 +437,70 @@ func TestProxyManager_ListModelsHandler_SortedByID(t *testing.T) {
} }
} }
func TestProxyManager_ListModelsHandler_IncludeAliasesInList(t *testing.T) {
// Configure alias
config := config.Config{
HealthCheckTimeout: 15,
IncludeAliasesInList: true,
Models: map[string]config.ModelConfig{
"model1": func() config.ModelConfig {
mc := getTestSimpleResponderConfig("model1")
mc.Name = "Model 1"
mc.Aliases = []string{"alias1"}
return mc
}(),
},
LogLevel: "error",
}
proxy := New(config)
// Request models list
req := httptest.NewRequest("GET", "/v1/models", nil)
w := CreateTestResponseRecorder()
proxy.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response struct {
Data []map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse JSON response: %v", err)
}
// We expect both base id and alias
var model1Data, alias1Data map[string]any
for _, model := range response.Data {
if model["id"] == "model1" {
model1Data = model
} else if model["id"] == "alias1" {
alias1Data = model
}
}
// Verify model1 has name
assert.NotNil(t, model1Data)
_, exists := model1Data["name"]
if !assert.True(t, exists, "model1 should have name key") {
t.FailNow()
}
name1, ok := model1Data["name"].(string)
assert.True(t, ok, "name1 should be a string")
// Verify alias1 has name
assert.NotNil(t, alias1Data)
_, exists = alias1Data["name"]
if !assert.True(t, exists, "alias1 should have name key") {
t.FailNow()
}
name2, ok := alias1Data["name"].(string)
assert.True(t, ok, "name2 should be a string")
// Name keys should match
assert.Equal(t, name1, name2)
}
func TestProxyManager_Shutdown(t *testing.T) { func TestProxyManager_Shutdown(t *testing.T) {
// make broken model configurations // make broken model configurations
model1Config := getTestSimpleResponderConfigPort("model1", 9991) model1Config := getTestSimpleResponderConfigPort("model1", 9991)