Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions components/ModelsConfig.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const {
deleteProviderEntry,
hasModelCostDraftValue,
modelCostToDraft,
modelSidebarLabel,
parseCompleteModelCost,
providerMatchesSearch,
renameProviderEntry,
Expand Down Expand Up @@ -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: "" }), "");
});
3 changes: 2 additions & 1 deletion components/ModelsConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import {
hasModelCostDraftValue,
modelCostToDraft,
modelSidebarLabel,
parseCompleteModelCost,
providerMatchesSearch,
savedModelIds,
Expand Down Expand Up @@ -2258,7 +2259,7 @@ export function ModelsConfig({ onClose, embedded = false, cwd = null }: {
onClick={() => setSelection({ type: "model", providerName: pName, index: i })}
>
<ConfigSidebarText className="is-grow" style={{ color: m.id ? "var(--text-muted)" : "var(--text-dim)" }}>
{m.id || t("i18n.newModel")}
{modelSidebarLabel(m) || t("i18n.newModel")}
</ConfigSidebarText>
{m.reasoning && (
<span style={{ fontSize: 9, padding: "1px 4px", background: "rgba(99,102,241,0.12)", color: "rgba(99,102,241,0.8)", borderRadius: 3, flexShrink: 0 }}>T</span>
Expand Down
5 changes: 5 additions & 0 deletions components/models-config-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
2 changes: 1 addition & 1 deletion docs/agents/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 4 additions & 1 deletion e2e/model-discovery.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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");
Expand Down
Loading