fix(model-picker): list each provider/model combo separately (#970)

* fix(model-picker): list each provider/model combo separately

Previously, /model grouped models by name and showed all providers
that offer the same model (e.g. 'claude-sonnet-4-5 — anthropic, google-antigravity').
This was confusing because:
1. Users couldn't tell which provider would be used when selecting by number
2. The display implied choice between providers but selection was automatic

Now each provider/model combination is listed separately so users
can explicitly select the exact provider they want.

- Remove model grouping in buildModelPickerItems
- Display format changed from 'model — providers' to 'provider/model'
- pickProviderForModel now returns the single provider directly
- Updated tests to reflect new behavior

* fix: simplify model picker entries (#970) (thanks @mcinteerj)

---------

Co-authored-by: Keith the Silly Goose <keith@42bolton.macnet.nz>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Jake
2026-01-16 11:20:11 +13:00
committed by GitHub
parent bf90815b9e
commit 634a429c50
6 changed files with 57 additions and 106 deletions

View File

@@ -2,6 +2,7 @@
## 2026.1.15 (unreleased)
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.

View File

@@ -85,8 +85,8 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("claude-opus-4-5 — anthropic");
expect(text).toContain("gpt-4.1-mini — openai");
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -116,8 +116,8 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("claude-opus-4-5 — anthropic");
expect(text).toContain("gpt-4.1-mini — openai");
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -166,9 +166,9 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("claude-opus-4-5 — anthropic");
expect(text).toContain("gpt-4.1-mini — openai");
expect(text).toContain("MiniMax-M2.1 — minimax");
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).toContain("minimax/MiniMax-M2.1");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});

View File

@@ -213,7 +213,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("gpt-4.1-mini — openai");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).not.toContain("claude-sonnet-4-1");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});

View File

@@ -1,4 +1,3 @@
import fs from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
@@ -96,7 +95,7 @@ afterEach(() => {
});
describe("trigger handling", () => {
it("shows a quick /model picker grouped by model with providers", async () => {
it("shows a quick /model picker listing provider/model pairs", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const res = await getReplyFromConfig(
@@ -116,8 +115,11 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? "");
expect(normalized).toContain("Pick: /model <#> or /model <provider/model>");
expect(normalized).toContain("1) claude-opus-4-5 — anthropic, openrouter");
expect(normalized).toContain("3) gpt-5.2 — openai, openai-codex");
// Each provider/model combo is listed separately for clear selection
expect(normalized).toContain("anthropic/claude-opus-4-5");
expect(normalized).toContain("openrouter/anthropic/claude-opus-4-5");
expect(normalized).toContain("openai/gpt-5.2");
expect(normalized).toContain("openai-codex/gpt-5.2");
expect(normalized).toContain("More: /model status");
expect(normalized).not.toContain("reasoning");
expect(normalized).not.toContain("image");
@@ -152,27 +154,12 @@ describe("trigger handling", () => {
expect(store[sessionKey]?.modelOverride).toBeUndefined();
});
});
it("prefers the current provider when selecting /model <#>", async () => {
it("selects exact provider/model combo by index via /model <#>", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const sessionKey = "telegram:slash:111";
await fs.writeFile(
cfg.session.store,
JSON.stringify(
{
[sessionKey]: {
sessionId: "session-openrouter",
updatedAt: Date.now(),
providerOverride: "openrouter",
modelOverride: "anthropic/claude-opus-4-5",
},
},
null,
2,
),
);
// /model 1 should select the first item (anthropic/claude-opus-4-5)
const res = await getReplyFromConfig(
{
Body: "/model 1",
@@ -188,13 +175,15 @@ describe("trigger handling", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
// Selecting the default model shows "reset to default" instead of "set to"
expect(normalizeTestText(text ?? "")).toContain(
"Model set to openrouter/anthropic/claude-opus-4-5",
"anthropic/claude-opus-4-5",
);
const store = loadSessionStore(cfg.session.store);
expect(store[sessionKey]?.providerOverride).toBe("openrouter");
expect(store[sessionKey]?.modelOverride).toBe("anthropic/claude-opus-4-5");
// When selecting the default, overrides are cleared
expect(store[sessionKey]?.providerOverride).toBeUndefined();
expect(store[sessionKey]?.modelOverride).toBeUndefined();
});
});
it("selects a model by index via /model <#>", async () => {

View File

@@ -1,4 +1,4 @@
import { normalizeProviderId } from "../../agents/model-selection.js";
import { type ModelRef, normalizeProviderId } from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js";
export type ModelPickerCatalogEntry = {
@@ -7,11 +7,7 @@ export type ModelPickerCatalogEntry = {
name?: string;
};
export type ModelPickerItem = {
model: string;
providers: string[];
providerModels: Record<string, string>;
};
export type ModelPickerItem = ModelRef;
const MODEL_PICK_PROVIDER_PREFERENCE = [
"anthropic",
@@ -31,68 +27,43 @@ const MODEL_PICK_PROVIDER_PREFERENCE = [
"lmstudio",
] as const;
function normalizeModelFamilyId(id: string): string {
const trimmed = id.trim();
if (!trimmed) return trimmed;
const parts = trimmed.split("/").filter(Boolean);
return parts.length > 0 ? (parts[parts.length - 1] ?? trimmed) : trimmed;
}
const PROVIDER_RANK = new Map<string, number>(
MODEL_PICK_PROVIDER_PREFERENCE.map((provider, idx) => [provider, idx]),
);
function sortProvidersForPicker(providers: string[]): string[] {
const pref = new Map<string, number>(
MODEL_PICK_PROVIDER_PREFERENCE.map((provider, idx) => [provider, idx]),
);
return providers.sort((a, b) => {
const pa = pref.get(a);
const pb = pref.get(b);
if (pa !== undefined && pb !== undefined) return pa - pb;
if (pa !== undefined) return -1;
if (pb !== undefined) return 1;
return a.localeCompare(b);
});
function compareProvidersForPicker(a: string, b: string): number {
const pa = PROVIDER_RANK.get(a);
const pb = PROVIDER_RANK.get(b);
if (pa !== undefined && pb !== undefined) return pa - pb;
if (pa !== undefined) return -1;
if (pb !== undefined) return 1;
return a.localeCompare(b);
}
export function buildModelPickerItems(catalog: ModelPickerCatalogEntry[]): ModelPickerItem[] {
const byModel = new Map<string, { providerModels: Record<string, string> }>();
const seen = new Set<string>();
const out: ModelPickerItem[] = [];
for (const entry of catalog) {
const provider = normalizeProviderId(entry.provider);
const model = normalizeModelFamilyId(entry.id);
const model = entry.id?.trim();
if (!provider || !model) continue;
const existing = byModel.get(model);
if (existing) {
existing.providerModels[provider] = entry.id;
continue;
}
byModel.set(model, { providerModels: { [provider]: entry.id } });
}
const out: ModelPickerItem[] = [];
for (const [model, data] of byModel.entries()) {
const providers = sortProvidersForPicker(Object.keys(data.providerModels));
out.push({ model, providers, providerModels: data.providerModels });
}
out.sort((a, b) => a.model.toLowerCase().localeCompare(b.model.toLowerCase()));
return out;
}
export function pickProviderForModel(params: {
item: ModelPickerItem;
preferredProvider?: string;
}): { provider: string; model: string } | null {
const preferred = params.preferredProvider
? normalizeProviderId(params.preferredProvider)
: undefined;
if (preferred && params.item.providerModels[preferred]) {
return {
provider: preferred,
model: params.item.providerModels[preferred],
};
const key = `${provider}/${model}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ model, provider });
}
const first = params.item.providers[0];
if (!first) return null;
return {
provider: first,
model: params.item.providerModels[first] ?? params.item.model,
};
// Sort by provider preference first, then by model name
out.sort((a, b) => {
const providerOrder = compareProvidersForPicker(a.provider, b.provider);
if (providerOrder !== 0) return providerOrder;
return a.model.toLowerCase().localeCompare(b.model.toLowerCase());
});
return out;
}
export function resolveProviderEndpointLabel(

View File

@@ -18,7 +18,6 @@ import {
import {
buildModelPickerItems,
type ModelPickerCatalogEntry,
pickProviderForModel,
resolveProviderEndpointLabel,
} from "./directive-handling.model-picker.js";
import type { InlineDirectives } from "./directive-handling.parse.js";
@@ -126,7 +125,7 @@ export async function maybeHandleModelDirectiveInfo(params: {
const current = `${params.provider}/${params.model}`;
const lines: string[] = [`Current: ${current}`, "Pick: /model <#> or /model <provider/model>"];
for (const [idx, item] of items.entries()) {
lines.push(`${idx + 1}) ${item.model}${item.providers.join(", ")}`);
lines.push(`${idx + 1}) ${item.provider}/${item.model}`);
}
lines.push("", "More: /model status");
return { text: lines.join("\n") };
@@ -236,22 +235,13 @@ export function resolveModelSelectionFromDirective(params: {
errorText: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const picked = pickProviderForModel({
item,
preferredProvider: params.provider,
});
if (!picked) {
return {
errorText: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const key = `${picked.provider}/${picked.model}`;
const key = `${item.provider}/${item.model}`;
const aliases = params.aliasIndex.byKey.get(key);
const alias = aliases && aliases.length > 0 ? aliases[0] : undefined;
modelSelection = {
provider: picked.provider,
model: picked.model,
isDefault: picked.provider === params.defaultProvider && picked.model === params.defaultModel,
provider: item.provider,
model: item.model,
isDefault: item.provider === params.defaultProvider && item.model === params.defaultModel,
...(alias ? { alias } : {}),
};
} else {