Skip to content

Commit 822478b

Browse files
committed
feat(model): live model list + switching for BYOK, local, and cloud
/model and /models were proxy-only — BYOK and local-LLM users had to relaunch with env vars to change models. Add a provider-aware model-list fetcher (OpenAI-compatible/local {data}, the proxy's {models}, Anthropic and Google with their own shapes) and open the picker for any session, so an Ollama/LM Studio user sees their pulled models live and switches in place. The proxy-only "Codebase Auto" reset entry is hidden for BYOK.
1 parent 449008e commit 822478b

5 files changed

Lines changed: 205 additions & 75 deletions

File tree

src/agent/model-list.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { Model } from "@earendil-works/pi-ai";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import { fetchAvailableModels } from "./model-list.js";
4+
5+
function model(provider: string, baseUrl?: string): Model<string> {
6+
return { provider, baseUrl, id: "x", name: "x" } as unknown as Model<string>;
7+
}
8+
9+
function mockFetch(status: number, body: unknown) {
10+
const fn = vi.fn(async () => ({
11+
ok: status >= 200 && status < 300,
12+
status,
13+
statusText: status === 200 ? "OK" : "ERR",
14+
json: async () => body,
15+
}));
16+
return fn as unknown as typeof fetch;
17+
}
18+
19+
describe("fetchAvailableModels", () => {
20+
afterEach(() => vi.unstubAllGlobals());
21+
22+
it("parses an OpenAI-compatible / local server's {data:[{id}]}", async () => {
23+
vi.stubGlobal("fetch", mockFetch(200, { data: [{ id: "llama3.1" }, { id: "qwen2.5-coder" }] }));
24+
const out = await fetchAvailableModels(model("openai-compat", "http://localhost:1234/v1"), "key");
25+
expect(out.map((m) => m.id)).toEqual(["llama3.1", "qwen2.5-coder"]);
26+
expect(out[0].provider).toBe("openai-compat");
27+
});
28+
29+
it("parses the proxy's {models:[{id,name,provider}]} shape", async () => {
30+
vi.stubGlobal("fetch", mockFetch(200, { models: [{ id: "d4f", name: "Codebase Auto", provider: "codebase" }] }));
31+
const out = await fetchAvailableModels(model("codebase", "https://codebase.design/api/inference"), "tok");
32+
expect(out).toEqual([{ id: "d4f", name: "Codebase Auto", provider: "codebase" }]);
33+
});
34+
35+
it("hits Anthropic's endpoint and maps display_name", async () => {
36+
const f = mockFetch(200, { data: [{ id: "claude-opus-4-1", display_name: "Claude Opus 4.1" }] });
37+
vi.stubGlobal("fetch", f);
38+
const out = await fetchAvailableModels(model("anthropic"), "sk-ant");
39+
expect(out).toEqual([{ id: "claude-opus-4-1", name: "Claude Opus 4.1", provider: "anthropic" }]);
40+
expect(String((f as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0])).toContain("api.anthropic.com");
41+
});
42+
43+
it("parses Google models and strips the models/ prefix", async () => {
44+
vi.stubGlobal(
45+
"fetch",
46+
mockFetch(200, {
47+
models: [
48+
{
49+
name: "models/gemini-2.5-pro",
50+
displayName: "Gemini 2.5 Pro",
51+
supportedGenerationMethods: ["generateContent"],
52+
},
53+
{ name: "models/embedding-001", supportedGenerationMethods: ["embedContent"] },
54+
],
55+
}),
56+
);
57+
const out = await fetchAvailableModels(model("google"), "key");
58+
// The embedding model is filtered out (no generateContent support).
59+
expect(out).toEqual([{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", provider: "google" }]);
60+
});
61+
62+
it("throws on a non-OK response", async () => {
63+
vi.stubGlobal("fetch", mockFetch(500, {}));
64+
await expect(fetchAvailableModels(model("openai-compat", "http://x/v1"), "k")).rejects.toThrow();
65+
});
66+
67+
it("returns [] when an openai-compat session has no baseUrl", async () => {
68+
const out = await fetchAvailableModels(model("openai-compat", undefined), "k");
69+
expect(out).toEqual([]);
70+
});
71+
});

src/agent/model-list.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { Model } from "@earendil-works/pi-ai";
2+
3+
/**
4+
* Live model discovery for the /model picker and /models list. Works for
5+
* any session that talks to an endpoint with a model-list API: the
6+
* codebase proxy, OpenAI-compatible servers (OpenAI, Groq, OpenRouter,
7+
* Mistral, DeepSeek, xAI, and local Ollama / LM Studio / vLLM), plus
8+
* Anthropic and Google which use their own shapes. Returns [] (or throws)
9+
* when the endpoint can't be listed; callers degrade gracefully.
10+
*/
11+
export interface ModelOption {
12+
id: string;
13+
name: string;
14+
provider: string;
15+
}
16+
17+
export async function fetchAvailableModels(
18+
model: Model<string>,
19+
apiKey: string | undefined,
20+
signal?: AbortSignal,
21+
): Promise<ModelOption[]> {
22+
const provider = String(model.provider);
23+
if (provider === "anthropic") return fetchAnthropic(apiKey, signal);
24+
if (provider === "google") return fetchGoogle(apiKey, signal);
25+
const baseUrl = (model.baseUrl ?? "").replace(/\/+$/, "");
26+
if (!baseUrl) return [];
27+
return fetchOpenAiCompat(baseUrl, apiKey, provider, signal);
28+
}
29+
30+
/** OpenAI-compatible `GET {baseUrl}/models`. Also parses our proxy's `{models:[…]}` shape. */
31+
async function fetchOpenAiCompat(
32+
baseUrl: string,
33+
apiKey: string | undefined,
34+
provider: string,
35+
signal?: AbortSignal,
36+
): Promise<ModelOption[]> {
37+
const headers: Record<string, string> = { Accept: "application/json" };
38+
// Local servers (Ollama/LM Studio) usually need no auth; only send a key
39+
// when we have a real one.
40+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
41+
const res = await fetch(`${baseUrl}/models`, { headers, signal });
42+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
43+
const json = (await res.json()) as { data?: unknown; models?: unknown };
44+
const raw = Array.isArray(json.data) ? json.data : Array.isArray(json.models) ? json.models : [];
45+
return normalize(raw, provider);
46+
}
47+
48+
async function fetchAnthropic(apiKey: string | undefined, signal?: AbortSignal): Promise<ModelOption[]> {
49+
if (!apiKey) throw new Error("not signed in");
50+
const res = await fetch("https://api.anthropic.com/v1/models?limit=100", {
51+
headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01", Accept: "application/json" },
52+
signal,
53+
});
54+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
55+
const json = (await res.json()) as { data?: Array<{ id?: string; display_name?: string }> };
56+
return (json.data ?? [])
57+
.filter((m): m is { id: string; display_name?: string } => typeof m.id === "string")
58+
.map((m) => ({ id: m.id, name: m.display_name ?? m.id, provider: "anthropic" }));
59+
}
60+
61+
async function fetchGoogle(apiKey: string | undefined, signal?: AbortSignal): Promise<ModelOption[]> {
62+
if (!apiKey) throw new Error("not signed in");
63+
const url = `https://generativelanguage.googleapis.com/v1beta/models?pageSize=200&key=${encodeURIComponent(apiKey)}`;
64+
const res = await fetch(url, { headers: { Accept: "application/json" }, signal });
65+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
66+
const json = (await res.json()) as {
67+
models?: Array<{ name?: string; displayName?: string; supportedGenerationMethods?: string[] }>;
68+
};
69+
return (json.models ?? [])
70+
.filter((m) => typeof m.name === "string")
71+
.filter((m) => !m.supportedGenerationMethods || m.supportedGenerationMethods.includes("generateContent"))
72+
.map((m) => {
73+
const id = (m.name as string).replace(/^models\//, "");
74+
return { id, name: m.displayName ?? id, provider: "google" };
75+
});
76+
}
77+
78+
/** Map a raw model array (OpenAI `data` or proxy `models`) to ModelOptions. */
79+
function normalize(raw: unknown[], fallbackProvider: string): ModelOption[] {
80+
const out: ModelOption[] = [];
81+
for (const item of raw) {
82+
if (!item || typeof item !== "object") continue;
83+
const m = item as { id?: unknown; name?: unknown; provider?: unknown };
84+
const id = typeof m.id === "string" ? m.id : typeof m.name === "string" ? m.name : "";
85+
if (!id) continue;
86+
const name = typeof m.name === "string" && m.name !== id ? m.name : id;
87+
const provider = typeof m.provider === "string" ? m.provider : fallbackProvider;
88+
out.push({ id, name, provider });
89+
}
90+
return out;
91+
}

src/commands/builtins/model.ts

Lines changed: 28 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { fetchAvailableModels } from "../../agent/model-list.js";
12
import type { Command } from "../types.js";
23

34
/**
@@ -37,15 +38,9 @@ export const modelCmd: Command = {
3738
handler: async (args, ctx) => {
3839
const arg = args.trim();
3940
if (!arg) {
40-
// No-args opens the inline picker. For BYOK / non-proxy sessions
41-
// there's no live model list to fetch, so print the static info
42-
// instead of an empty overlay.
43-
if (ctx.bundle.source !== "proxy") {
44-
const m = ctx.state.model;
45-
ctx.emit(`Current model: ${m.name} (${m.provider}/${m.id})`);
46-
ctx.emit("BYOK session — switch via CODEBASE_PROVIDER + CODEBASE_MODEL env vars at launch.");
47-
return { handled: true };
48-
}
41+
// No-args opens the inline picker — it fetches the live model list
42+
// from whatever this session talks to (proxy, an OpenAI-compatible /
43+
// local server, Anthropic, Google) and switches in place.
4944
ctx.openModelPicker();
5045
return { handled: true };
5146
}
@@ -67,61 +62,38 @@ export const modelCmd: Command = {
6762
export const modelsCmd: Command = {
6863
name: "models",
6964
aliases: ["lm"],
70-
description: "List models available to your account (fetched live from the proxy).",
65+
description: "List the models this session can switch to (live from the proxy, provider API, or local server).",
7166
handler: async (_args, ctx) => {
72-
// BYOK users don't go through our proxy — there's no central
73-
// "available models" endpoint for arbitrary upstreams. Tell them
74-
// how to switch and bail.
75-
if (ctx.bundle.source !== "proxy") {
67+
const apiKey = await ctx.bundle.agent.getApiKey?.(ctx.bundle.model.provider);
68+
let models: Array<{ id: string; name: string; provider: string }>;
69+
try {
70+
models = await fetchAvailableModels(ctx.bundle.model, apiKey);
71+
} catch (err) {
72+
ctx.emit(`(couldn't list models: ${err instanceof Error ? err.message : String(err)})`);
7673
ctx.emit(`Current: ${ctx.state.model.name} (${ctx.state.model.provider}/${ctx.state.model.id})`);
77-
ctx.emit("BYOK session — switch by re-launching with CODEBASE_PROVIDER + CODEBASE_MODEL env vars.");
7874
return { handled: true };
7975
}
80-
const baseUrl = (ctx.bundle.model.baseUrl ?? "").replace(/\/+$/, "");
81-
if (!baseUrl) {
82-
ctx.emit("(model has no baseUrl — can't query the proxy)");
76+
if (models.length === 0) {
77+
ctx.emit(`(no models returned for ${ctx.bundle.model.provider})`);
8378
return { handled: true };
8479
}
85-
try {
86-
const apiKey = await ctx.bundle.agent.getApiKey?.(ctx.bundle.model.provider);
87-
if (!apiKey) {
88-
ctx.emit("(not signed in — run `codebase auth login`)");
89-
return { handled: true };
90-
}
91-
const res = await fetch(`${baseUrl}/models`, {
92-
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
93-
});
94-
if (!res.ok) {
95-
ctx.emit(`(failed to fetch models: ${res.status} ${res.statusText})`);
96-
return { handled: true };
97-
}
98-
const json = (await res.json()) as { models?: Array<{ id: string; name: string; provider: string }> };
99-
const models = json.models ?? [];
100-
if (models.length === 0) {
101-
ctx.emit("(no models returned)");
102-
return { handled: true };
103-
}
104-
const current = `${ctx.state.model.provider}/${ctx.state.model.id}`;
105-
ctx.emit("Available models (* = active):");
106-
// Group by provider so the list reads as a tree.
107-
const byProvider = new Map<string, Array<{ id: string; name: string }>>();
108-
for (const m of models) {
109-
const arr = byProvider.get(m.provider) ?? [];
110-
arr.push({ id: m.id, name: m.name });
111-
byProvider.set(m.provider, arr);
112-
}
113-
const providers = [...byProvider.keys()].sort();
114-
for (const p of providers) {
115-
ctx.emit(` ${p}:`);
116-
for (const m of byProvider.get(p) ?? []) {
117-
const marker = `${p}/${m.id}` === current ? "*" : " ";
118-
ctx.emit(` ${marker} ${m.id} ${m.name === m.id ? "" : ${m.name}`}`);
119-
}
80+
const current = `${ctx.state.model.provider}/${ctx.state.model.id}`;
81+
ctx.emit("Available models (* = active):");
82+
// Group by provider so the list reads as a tree.
83+
const byProvider = new Map<string, Array<{ id: string; name: string }>>();
84+
for (const m of models) {
85+
const arr = byProvider.get(m.provider) ?? [];
86+
arr.push({ id: m.id, name: m.name });
87+
byProvider.set(m.provider, arr);
88+
}
89+
for (const p of [...byProvider.keys()].sort()) {
90+
ctx.emit(` ${p}:`);
91+
for (const m of byProvider.get(p) ?? []) {
92+
const marker = `${p}/${m.id}` === current || m.id === ctx.state.model.id ? "*" : " ";
93+
ctx.emit(` ${marker} ${m.id}${m.name === m.id ? "" : ` · ${m.name}`}`);
12094
}
121-
ctx.emit("Switch: /model <id> · /model <provider>:<id>");
122-
} catch (err) {
123-
ctx.emit(`(error fetching models: ${err instanceof Error ? err.message : String(err)})`);
12495
}
96+
ctx.emit("Switch: /model <id> · /model <provider>:<id> · or /model for the picker");
12597
return { handled: true };
12698
},
12799
};

src/ui-pi/app.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { type AgentBundle, createAgent } from "../agent/agent.js";
1515
import { CHARS_PER_TOKEN, estimateContextTokens, streamingChars } from "../agent/context-estimate.js";
1616
import { listRewindPoints, type RewindPoint, truncateBefore } from "../agent/conversation-rewind.js";
17+
import { fetchAvailableModels } from "../agent/model-list.js";
1718
import { generateSuggestion } from "../agent/prompt-suggestion.js";
1819
import { routeUserInput } from "../agent/router.js";
1920
import { buildEnvironmentReminder } from "../agent/system-prompt.js";
@@ -1102,6 +1103,12 @@ export class App extends Container {
11021103
try {
11031104
const models = await loadAvailableModels(this.bundle);
11041105
this.statusBar.note("");
1106+
// BYOK with no list (or a one-model endpoint) → don't open an empty
1107+
// picker; the reset entry only exists for proxy sessions.
1108+
if (models.length === 0 && this.bundle.source !== "proxy") {
1109+
this.statusBar.note(`no models to switch to for ${this.bundle.model.provider}.`);
1110+
return;
1111+
}
11051112
this.showModelPicker(models);
11061113
} catch (err) {
11071114
this.statusBar.note(`model list failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -1120,6 +1127,7 @@ export class App extends Container {
11201127
void this.switchModel(spec);
11211128
},
11221129
() => this.hideModelPicker(),
1130+
this.bundle.source === "proxy",
11231131
);
11241132
const handle = this.tui.showOverlay(component, { anchor: "center", width: "70%", minWidth: 50 });
11251133
this.tui.setFocus(component.getFocusTarget());
@@ -1535,19 +1543,8 @@ export class App extends Container {
15351543
* back into `src/ui/App.tsx`, which goes away in phase 5.
15361544
*/
15371545
async function loadAvailableModels(bundle: AgentBundle): Promise<ModelOption[]> {
1538-
if (bundle.source !== "proxy") {
1539-
throw new Error("BYOK session — use CODEBASE_PROVIDER + CODEBASE_MODEL env vars at launch to switch.");
1540-
}
1541-
const baseUrl = (bundle.model.baseUrl ?? "").replace(/\/+$/, "");
1542-
if (!baseUrl) throw new Error("model has no baseUrl — can't query the proxy");
15431546
const apiKey = await bundle.agent.getApiKey?.(bundle.model.provider);
1544-
if (!apiKey) throw new Error("not signed in — run `codebase auth login`");
1545-
const res = await fetch(`${baseUrl}/models`, {
1546-
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
1547-
});
1548-
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
1549-
const json = (await res.json()) as { models?: ModelOption[] };
1550-
return json.models ?? [];
1547+
return fetchAvailableModels(bundle.model, apiKey);
15511548
}
15521549

15531550
/**

src/ui-pi/model-picker-overlay.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,19 @@ export class ModelPickerOverlay extends Container {
2525
models: ModelOption[],
2626
onSelect: (spec: { provider?: string; modelId: string } | null) => void,
2727
onCancel: () => void,
28+
includeReset = true,
2829
) {
2930
super();
3031

3132
this.addChild(new Text(ansi.bold("Switch model"), 1, 0));
3233
this.addChild(new Text(ansi.dim("↑↓ choose · Enter select · Esc cancel"), 1, 0));
3334

34-
// First synthetic entry: reset to Codebase Auto. Always first so it
35-
// stays in the same slot regardless of provider order.
35+
// First synthetic entry: reset to Codebase Auto — a proxy-only concept,
36+
// omitted for BYOK / local sessions where there's no proxy default.
3637
const items = [
37-
{
38-
value: "__reset__",
39-
label: "Codebase Auto",
40-
description: "let the proxy pick (default for OAuth)",
41-
},
38+
...(includeReset
39+
? [{ value: "__reset__", label: "Codebase Auto", description: "let the proxy pick (default for OAuth)" }]
40+
: []),
4241
...models.map((m) => {
4342
const active = m.id === currentId && m.provider === currentProvider;
4443
return {

0 commit comments

Comments
 (0)