From d998ea7ef55a25dfd7a30aa220485d8a6562cb36 Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Sun, 27 Sep 2026 16:51:09 +0200 Subject: [PATCH] Let a model say what it is for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gateway serving nine models offered nine names and nothing to choose between them. /v1/models is the only view most clients ever have, and it listed ids. Models carry a description now: written when the model is added, shown on the dashboard and sent to clients in /v1/models. Written rather than derived. A registry offers a pipeline tag and a wall of README, and neither answers the question somebody picking from a list is asking — which of these should I send this request to. "General chat, strong Swedish; slower than the 8B" is not in any metadata field. It is optional, and absent means absent rather than an empty string in the response: a model nobody described should read as undescribed, not as described with nothing. On the dashboard it sits under its row rather than in a column. A sentence in a table cell either wraps every other column into a stripe or gets cut off. Co-Authored-By: Claude Opus 5 (1M context) --- docs/reference.md | 16 +++++++++++++ internal/control/adminapi/api.go | 22 ++++++++++-------- internal/control/catalog/catalog.go | 11 +++++++++ internal/router/handlers.go | 6 +++++ internal/web/admin_serve.go | 1 + internal/web/admin_serve_test.go | 29 ++++++++++++++++++++++++ internal/web/templates/admin_mirror.html | 6 +++++ internal/web/templates/dashboard.html | 6 ++++- 8 files changed, 86 insertions(+), 11 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 06dfb008..9ae7312e 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -84,6 +84,7 @@ rather than repeated. | `timeout` | `time.Duration` | [Mirror](#mirror) | | `name` | `string` | [Model](#model) | | `aliases` | `[]string` | [Model](#model) | +| `description` | `string` | [Model](#model) | | `source` | `string` | [Model](#model) | | `profiles` | `[]Profile` | [Model](#model) | | `scaling` | `Scaling` | [Model](#model) | @@ -632,6 +633,21 @@ arriving long after this deadline has passed. `[]string` +### `description` + +`string` + +Description is what this model is for, in a line. + +Written by whoever added it, because nobody else can say it. A registry +offers a pipeline tag and a wall of README, and neither answers the +question a person picking from a list is asking: which of these nine +names should I send this request to. + +It reaches clients through /v1/models, which is the only view most of +them ever see. A gateway serving nine models by name alone makes that +choice by guesswork. + ### `source` `string` diff --git a/internal/control/adminapi/api.go b/internal/control/adminapi/api.go index 9303f572..97747687 100644 --- a/internal/control/adminapi/api.go +++ b/internal/control/adminapi/api.go @@ -108,15 +108,17 @@ type NodeView struct { // ModelView is a model as `ctl models` shows it. type ModelView struct { - Name string `json:"name"` - Aliases []string `json:"aliases,omitempty"` - Source string `json:"source"` - Min int `json:"min_replicas"` - Max int `json:"max_replicas"` - Ready int `json:"ready_replicas"` - Total int `json:"total_replicas"` - Fallbacks []string `json:"fallbacks,omitempty"` - Profiles []string `json:"profiles"` + Name string `json:"name"` + // Description is what the model is for, when somebody said. + Description string `json:"description,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Source string `json:"source"` + Min int `json:"min_replicas"` + Max int `json:"max_replicas"` + Ready int `json:"ready_replicas"` + Total int `json:"total_replicas"` + Fallbacks []string `json:"fallbacks,omitempty"` + Profiles []string `json:"profiles"` } // ReplicaView is a replica as `ctl replicas` shows it. @@ -260,7 +262,7 @@ func (a *API) Models() []ModelView { out := make([]ModelView, 0) for _, m := range a.ctl.Catalog().Models() { v := ModelView{ - Name: m.Name, Aliases: m.Aliases, Source: m.Source, + Name: m.Name, Description: m.Description, Aliases: m.Aliases, Source: m.Source, Min: m.Scaling.Min, Max: m.Scaling.Max, Ready: ready[m.Name], Total: total[m.Name], Fallbacks: m.Fallbacks, diff --git a/internal/control/catalog/catalog.go b/internal/control/catalog/catalog.go index 644fbff7..0750920e 100644 --- a/internal/control/catalog/catalog.go +++ b/internal/control/catalog/catalog.go @@ -123,6 +123,17 @@ func cpuCapable(engine string) bool { return engine == "llamacpp" } type Model struct { Name string `json:"name,omitempty" koanf:"name"` Aliases []string `json:"aliases,omitempty" koanf:"aliases"` + // Description is what this model is for, in a line. + // + // Written by whoever added it, because nobody else can say it. A registry + // offers a pipeline tag and a wall of README, and neither answers the + // question a person picking from a list is asking: which of these nine + // names should I send this request to. + // + // It reaches clients through /v1/models, which is the only view most of + // them ever see. A gateway serving nine models by name alone makes that + // choice by guesswork. + Description string `json:"description,omitempty" koanf:"description"` // Source is the weights path or a HuggingFace id. Source string `json:"source,omitempty" koanf:"source"` Profiles []Profile `json:"profiles,omitempty" koanf:"profiles"` diff --git a/internal/router/handlers.go b/internal/router/handlers.go index f1effbfc..5f7091d0 100644 --- a/internal/router/handlers.go +++ b/internal/router/handlers.go @@ -715,6 +715,12 @@ func (r *Router) handleModels(w http.ResponseWriter, _ *http.Request) { if m.MaxOutput > 0 { entry["max_output_tokens"] = m.MaxOutput } + // Not part of the OpenAI shape, and the reason to send it anyway is + // that this list is the only view most clients have: nine names and + // nothing to choose between them. + if m.Description != "" { + entry["description"] = m.Description + } entry["capabilities"] = map[string]bool{ "tools": m.Capabilities.Tools, "vision": m.Capabilities.Vision, "embeddings": m.Capabilities.Embeddings, diff --git a/internal/web/admin_serve.go b/internal/web/admin_serve.go index a583068d..ea7836d3 100644 --- a/internal/web/admin_serve.go +++ b/internal/web/admin_serve.go @@ -169,6 +169,7 @@ func gpus(fleet Source) int { func buildModel(get func(string) string) (catalog.Model, error) { m := catalog.Model{ Name: strings.TrimSpace(get("name")), + Description: strings.TrimSpace(get("description")), Source: strings.TrimSpace(get("source")), RoutingProfile: "chat", } diff --git a/internal/web/admin_serve_test.go b/internal/web/admin_serve_test.go index 97f6b8fd..e0fb9475 100644 --- a/internal/web/admin_serve_test.go +++ b/internal/web/admin_serve_test.go @@ -295,3 +295,32 @@ func TestAnEstimateIsNotMadeWhenTheSizeIsUnknown(t *testing.T) { t.Errorf("an unknown size should leave no figure; got %s", body) } } + +func TestADescriptionReachesTheCatalog(t *testing.T) { + cat := &fakeCatalog{} + mirrorPost(t, servePage(t, nil, cat, 0), "/admin/mirror/serve", url.Values{ + "csrf": {"tok"}, "name": {"coder"}, "source": {"http://x/m.gguf"}, + "engine": {"llamacpp"}, "replicas": {"1"}, "context_length": {"4096"}, + "description": {"code completion and review; 7B, fits the large node"}, + }) + + if len(cat.added) != 1 { + t.Fatalf("expected one model, got %d", len(cat.added)) + } + // The line a client sees in /v1/models, which for most of them is the only + // view of what this gateway serves. + if cat.added[0].Description != "code completion and review; 7B, fits the large node" { + t.Errorf("the description was lost: %q", cat.added[0].Description) + } +} + +func TestAModelWithNoDescriptionIsStillAccepted(t *testing.T) { + cat := &fakeCatalog{} + mirrorPost(t, servePage(t, nil, cat, 0), "/admin/mirror/serve", url.Values{ + "csrf": {"tok"}, "name": {"coder"}, "source": {"http://x/m.gguf"}, + "engine": {"llamacpp"}, "replicas": {"1"}, "context_length": {"4096"}, + }) + if len(cat.added) != 1 || cat.added[0].Description != "" { + t.Errorf("a description is optional: %+v", cat.added) + } +} diff --git a/internal/web/templates/admin_mirror.html b/internal/web/templates/admin_mirror.html index 5bde8acc..0d4fbbce 100644 --- a/internal/web/templates/admin_mirror.html +++ b/internal/web/templates/admin_mirror.html @@ -100,6 +100,12 @@ reachable from this machine.

+ {{/* Reaches clients through /v1/models, which for most of them is the only + view of what this gateway serves. Nine names and nothing to choose + between them is a choice made by guesswork. */}} +
diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index b8ecd327..bcd9d942 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -129,7 +129,11 @@ requested whenever a fallback served it.

{{.Min}}–{{.Max}} {{join .Profiles}} {{join .Fallbacks}} -{{end}} + +{{/* Under the row rather than in a column: a sentence in a table cell either + wraps every other column into a stripe or gets cut off. */}} +{{if .Description}}{{.Description}}{{end}} +{{end}} {{else}}

The catalog is empty.

{{end}} {{if .Unplaced}}