From 554d29e87d5611d38f3b36b30cf92201f7f7be3c Mon Sep 17 00:00:00 2001 From: Ryan Steed Date: Sat, 15 Nov 2025 22:35:26 +0000 Subject: [PATCH] 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 --- config-schema.json | 7 ++++- config.example.yaml | 6 ++++ proxy/config/config.go | 3 ++ proxy/proxymanager.go | 46 +++++++++++++++++---------- proxy/proxymanager_test.go | 64 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 18 deletions(-) diff --git a/config-schema.json b/config-schema.json index f44c703..2f539a9 100644 --- a/config-schema.json +++ b/config-schema.json @@ -74,6 +74,11 @@ "default": false, "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": { "$ref": "#/definitions/macros" }, @@ -247,4 +252,4 @@ "description": "A dictionary of event triggers and actions. Only supported hook is on_startup." } } -} \ No newline at end of file +} diff --git a/config.example.yaml b/config.example.yaml index 6af6268..b8437bb 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -46,6 +46,12 @@ startPort: 10001 # - see #366 for more details 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 # - optional, default: empty dictionary # - macros are reusable snippets diff --git a/proxy/config/config.go b/proxy/config/config.go index d957cd4..e549758 100644 --- a/proxy/config/config.go +++ b/proxy/config/config.go @@ -132,6 +132,9 @@ type Config struct { // send loading state in reasoning SendLoadingState bool `yaml:"sendLoadingState"` + + // present aliases to /v1/models OpenAI API listing + IncludeAliasesInList bool `yaml:"includeAliasesInList"` } func (c *Config) RealModelName(search string) (string, bool) { diff --git a/proxy/proxymanager.go b/proxy/proxymanager.go index 1589341..d83f50e 100644 --- a/proxy/proxymanager.go +++ b/proxy/proxymanager.go @@ -376,28 +376,40 @@ func (pm *ProxyManager) listModelsHandler(c *gin.Context) { continue } - record := gin.H{ - "id": id, - "object": "model", - "created": createdTime, - "owned_by": "llama-swap", + newRecord := func(modelId string) gin.H { + record := gin.H{ + "id": modelId, + "object": "model", + "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 != "" { - record["name"] = name - } - if desc := strings.TrimSpace(modelConfig.Description); desc != "" { - record["description"] = desc - } + data = append(data, newRecord(id)) - // Add metadata if present - if len(modelConfig.Metadata) > 0 { - record["meta"] = gin.H{ - "llamaswap": modelConfig.Metadata, + // Include aliases + if pm.config.IncludeAliasesInList { + for _, alias := range modelConfig.Aliases { + if alias := strings.TrimSpace(alias); alias != "" { + data = append(data, newRecord(alias)) + } } } - - data = append(data, record) } // Sort by the "id" key diff --git a/proxy/proxymanager_test.go b/proxy/proxymanager_test.go index 1bc5a12..bbe5e93 100644 --- a/proxy/proxymanager_test.go +++ b/proxy/proxymanager_test.go @@ -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) { // make broken model configurations model1Config := getTestSimpleResponderConfigPort("model1", 9991)