diff --git a/components/ModelsConfig.test.mjs b/components/ModelsConfig.test.mjs
index 0e545ab7bb..ef07e0cfff 100644
--- a/components/ModelsConfig.test.mjs
+++ b/components/ModelsConfig.test.mjs
@@ -8,6 +8,7 @@ const {
deleteProviderEntry,
hasModelCostDraftValue,
modelCostToDraft,
+ modelSidebarLabel,
parseCompleteModelCost,
providerMatchesSearch,
renameProviderEntry,
@@ -244,3 +245,10 @@ test("Add provider's search matches a provider by id or name", () => {
assert.equal(providerMatchesSearch("missing", oauth.id, oauth.name), false);
assert.equal(providerMatchesSearch(" ", oauth.id, oauth.name), true);
});
+
+test("the Models sidebar labels a model by its name and falls back to the id", () => {
+ assert.equal(modelSidebarLabel({ id: "qwen3-coder-480b-a35b-instruct", name: "Qwen Coder" }), "Qwen Coder");
+ assert.equal(modelSidebarLabel({ id: "m1" }), "m1");
+ assert.equal(modelSidebarLabel({ id: "m1", name: " " }), "m1");
+ assert.equal(modelSidebarLabel({ id: "", name: "" }), "");
+});
diff --git a/components/ModelsConfig.tsx b/components/ModelsConfig.tsx
index ff1fc22dac..3802930b51 100644
--- a/components/ModelsConfig.tsx
+++ b/components/ModelsConfig.tsx
@@ -13,6 +13,7 @@ import {
import {
hasModelCostDraftValue,
modelCostToDraft,
+ modelSidebarLabel,
parseCompleteModelCost,
providerMatchesSearch,
savedModelIds,
@@ -2258,7 +2259,7 @@ export function ModelsConfig({ onClose, embedded = false, cwd = null }: {
onClick={() => setSelection({ type: "model", providerName: pName, index: i })}
>
- {m.id || t("i18n.newModel")}
+ {modelSidebarLabel(m) || t("i18n.newModel")}
{m.reasoning && (
T
diff --git a/components/models-config-helpers.ts b/components/models-config-helpers.ts
index 4345cc3360..bc1e3b2a61 100644
--- a/components/models-config-helpers.ts
+++ b/components/models-config-helpers.ts
@@ -222,3 +222,8 @@ export function providerMatchesSearch(search: string, id: string, label: string)
const query = search.trim().toLowerCase();
return !query || label.toLowerCase().includes(query) || id.toLowerCase().includes(query);
}
+
+/** Models sidebar: a model shows its models.json name, as the selector does; the id when it has none. */
+export function modelSidebarLabel(model: { id: string; name?: string }): string {
+ return model.name?.trim() || model.id;
+}
diff --git a/docs/agents/models.md b/docs/agents/models.md
index 7d22899f12..5952b79ea0 100644
--- a/docs/agents/models.md
+++ b/docs/agents/models.md
@@ -19,7 +19,7 @@ No model listing starts a session: `/api/models` builds a fresh `ModelRuntime` p
## `enabledModels` scoping
`enabledModels` uses pi's `--models` syntax: minimatch globs against `provider/modelId` or a bare `modelId`, fuzzy matching for non-glob patterns, an optional `:thinkingLevel` suffix. Never compare patterns as strings: `lib/model-scope.ts` delegates to the SDK's `resolveModelScopeWithDiagnostics()` so pi-web and the TUI agree, and falls back to every available model when the patterns resolve to nothing. `startRpcSession()` resolves the scope before creating an AgentSession and passes the initial model, thinking pin and SDK-native `scopedModels` atomically; `GET /api/models` uses the helper only for selector data, `thinkingLevelPins` and `modelScopeWarnings`.
-The selector lists models in pi's `/model` order (`orderSelectorModels()`): a scope in `enabledModels` order, otherwise sorted by provider id only, so a models.json provider keeps its file's order (#783). `ModelSelector` groups by provider in first-appearance order and never re-sorts; never sort by name in the route or the client.
+The selector lists models in pi's `/model` order (`orderSelectorModels()`): a scope in `enabledModels` order, otherwise sorted by provider id only, so a models.json provider keeps its file's order (#783). `ModelSelector` groups by provider in first-appearance order and never re-sorts; never sort by name in the route or the client. Model rows show the models.json `name` and fall back to the id, here and in the Models panel's sidebar (`modelSidebarLabel()`); the id is fixed by the provider and often hard to read (#885).
The Models panel edits it only through `/api/models/enabled`, never with patterns composed in the browser. Every toggle is a **minimal edit** (`lib/enabled-models.ts`, ADR 0004): an unmatched pattern stays verbatim, only the pattern covering a switched-off model is expanded in place (keeping its `:level`), and a fully enabled provider with two or more entries collapses into one glob (an enumerated list rots when a catalog renames a model); a lone exact reference stays. Never rewrite the whole list from `getAvailable()` like the TUI's `/scoped-models`: it sees only providers passing `checkAuth()` right now, so it would drop a signed-out provider's entries and flatten globs and pins. **Never assume `provider/*` covers a provider**: minimatch's `*` stops at `/`, missing nested ids (`commandcode/sakana/fugu-ultra`, most OpenRouter ids); `resolveProviderGlobs()` keeps `provider/*` or `provider/**` only when it matches exactly the provider's models, else writes the provider model by model.
diff --git a/e2e/model-discovery.mjs b/e2e/model-discovery.mjs
index d9db8feffb..be2061d663 100644
--- a/e2e/model-discovery.mjs
+++ b/e2e/model-discovery.mjs
@@ -5,7 +5,7 @@ import assert from "node:assert/strict";
export async function checkModelDiscovery(page) {
const config = { providers: {
anthropic: { models: [{ id: "claude-existing" }] },
- relay: { baseUrl: "https://example.invalid/v1", api: "openai-responses", models: [{ id: "relay-existing" }] },
+ relay: { baseUrl: "https://example.invalid/v1", api: "openai-responses", models: [{ id: "relay-existing", name: "Relay Existing" }] },
} };
const discoveries = [];
let saved;
@@ -28,6 +28,9 @@ export async function checkModelDiscovery(page) {
await page.getByRole("button", { name: "Settings", exact: true }).click();
const settings = page.getByRole("dialog", { name: "Settings", exact: true });
await settings.getByRole("button", { name: "Models", exact: true }).click();
+ const modelRows = settings.locator(".models-sidebar-indented-item");
+ await modelRows.filter({ hasText: /^claude-existing$/ }).waitFor(); // a model without a name is listed by its id
+ assert.equal(await modelRows.filter({ hasText: /^Relay Existing$/ }).count(), 1, "A model is listed by its models.json name");
await settings.locator(".config-sidebar-item").filter({ hasText: /^anthropic$/ }).click();
const api = settings.locator(".config-field-label").filter({ hasText: /^API$/ }).locator("..").locator("select");
assert.equal(await api.inputValue(), "", "A missing protocol stays unspecified in the editor");