feat(tui): implement live model fetching and enhance model selection menus
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Tests for LlmClient.listModels() — the live /models fetch backing the TUI
|
||||
* model picker. Mocks global fetch so no network is touched.
|
||||
*/
|
||||
import { describe, test, expect, mock } from "bun:test";
|
||||
import { LlmClient } from "./provider.ts";
|
||||
|
||||
/** Replace global fetch once, returning the given JSON body for /models. */
|
||||
async function listWith(fetchImpl: (url: string) => Promise<Response>): Promise<string[]> {
|
||||
const orig = globalThis.fetch;
|
||||
globalThis.fetch = fetchImpl as typeof fetch;
|
||||
try {
|
||||
const client = new LlmClient("key", "some-model", "https://api.example.com/v1");
|
||||
return await client.listModels();
|
||||
} finally {
|
||||
globalThis.fetch = orig;
|
||||
}
|
||||
}
|
||||
|
||||
describe("LlmClient.listModels", () => {
|
||||
test("parses OpenAI-style { data: [{id}] }", async () => {
|
||||
const rows = await listWith(async (url: string) => {
|
||||
expect(url).toBe("https://api.example.com/v1/models");
|
||||
return new Response(JSON.stringify({ data: [{ id: "gpt-5" }, { id: "deepseek-v3" }] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
expect(rows).toEqual(["gpt-5", "deepseek-v3"]);
|
||||
});
|
||||
|
||||
test("parses { models: [string] } variant", async () => {
|
||||
const rows = await listWith(async () =>
|
||||
new Response(JSON.stringify({ models: ["a", "b"] }), { status: 200 }),
|
||||
);
|
||||
expect(rows).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("filters out missing ids", async () => {
|
||||
const rows = await listWith(async () =>
|
||||
new Response(JSON.stringify({ data: [{ id: "x" }, {}, { id: "y" }] }), { status: 200 }),
|
||||
);
|
||||
expect(rows).toEqual(["x", "y"]);
|
||||
});
|
||||
|
||||
test("returns [] on non-OK or throw", async () => {
|
||||
const rows = await listWith(async () => new Response("forbidden", { status: 403 }));
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
void mock;
|
||||
@@ -123,6 +123,35 @@ export class LlmClient implements ProviderService {
|
||||
return `API request failed: ${err?.message ?? String(e)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* List the model ids exposed by the provider's `/models` endpoint
|
||||
* (OpenAI/Anthropic-compatible). Returns an empty array when the endpoint
|
||||
* isn't exposed or the call fails — callers fall back to static catalogs.
|
||||
*/
|
||||
async listModels(): Promise<string[]> {
|
||||
const url = `${this.baseUrl}/models`;
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) return [];
|
||||
const data = (await resp.json()) as {
|
||||
data?: { id?: string }[] | null;
|
||||
models?: (string | { id?: string })[] | null;
|
||||
};
|
||||
const fromData = (data.data ?? []).map((m) => m?.id ?? "").filter(Boolean);
|
||||
if (fromData.length) return fromData;
|
||||
const fromModels = (data.models ?? [])
|
||||
.map((m) => (typeof m === "string" ? m : m?.id ?? ""))
|
||||
.filter(Boolean);
|
||||
return fromModels;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async chat(
|
||||
messages: ChatMessage[],
|
||||
tools?: ToolDef[],
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface WiredRuntime {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
apiBase?: string;
|
||||
/** Fetch the provider's available model ids (via GET /models). */
|
||||
listModels?: () => Promise<string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +78,15 @@ export async function runSingleProcess(): Promise<WiredRuntime> {
|
||||
toolDefs(allTools()) as never,
|
||||
);
|
||||
|
||||
return { store, executor, turnService, apiKey, model, apiBase: baseUrl };
|
||||
return {
|
||||
store,
|
||||
executor,
|
||||
turnService,
|
||||
apiKey,
|
||||
model,
|
||||
apiBase: baseUrl,
|
||||
listModels: () => llmClient.listModels(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Rooted, long-lived event sink that collects events into an array. */
|
||||
|
||||
+20
-14
@@ -35,24 +35,30 @@ export function modelProviderMenu(state: AppStateRest): MenuFrame {
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the per-provider model sub-menu. */
|
||||
export function modelSubMenu(_state: AppStateRest, provider: string): MenuFrame | null {
|
||||
// Candidate models for this provider. In a fuller build these come from a
|
||||
// catalog / API; here we present a sensible, sortable list.
|
||||
const known = [
|
||||
"claude-opus-5",
|
||||
"claude-sonnet-5",
|
||||
"claude-haiku-4-5",
|
||||
"deepseek-v4-flash-free",
|
||||
"gpt-5",
|
||||
];
|
||||
/** Build the per-provider model sub-menu, preferring models fetched live. */
|
||||
export function modelSubMenu(state: AppStateRest, provider: string): MenuFrame | null {
|
||||
const live = state.misc.availableModels;
|
||||
const current = String(state.settings.model ?? "");
|
||||
if (live.length > 0) {
|
||||
return {
|
||||
id: `model:${provider}`,
|
||||
title: `Models — ${provider} (${live.length})`,
|
||||
items: [...live],
|
||||
details: live.map((m) => (m === current ? " (current)" : (m === state.settings.model ? " (current)" : ""))),
|
||||
hint: "Enter applies · Esc back to providers",
|
||||
selected: Math.max(0, live.indexOf(current)),
|
||||
};
|
||||
}
|
||||
// No /models endpoint (or it's still fetching) — degrade to a small static
|
||||
// catalog so the picker never blocks.
|
||||
const known = ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5", "deepseek-v4-flash-free", "gpt-5"];
|
||||
return {
|
||||
id: `model:${provider}`,
|
||||
title: `Models — ${provider}`,
|
||||
items: known,
|
||||
details: known.map((m) => (m === _state.settings.model ? " (current)" : "")),
|
||||
hint: "Enter applies · Esc back to providers",
|
||||
selected: Math.max(0, known.indexOf(String(_state.settings.model ?? ""))),
|
||||
details: known.map((m) => (m === current ? " (current)" : "")),
|
||||
hint: "live /models unavailable — fallback list · Enter applies",
|
||||
selected: Math.max(0, known.indexOf(current)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -165,5 +165,16 @@ export async function runTui(): Promise<void> {
|
||||
sessionId: state.sessionId,
|
||||
} as Record<string, unknown>;
|
||||
|
||||
// Prime the /models list in the background so /model shows live models
|
||||
// instead of a hardcoded catalog; the menu falls back until it lands.
|
||||
if (runtime.listModels) {
|
||||
void runtime.listModels().then((models) => {
|
||||
if (models.length > 0) {
|
||||
state.misc.availableModels = models;
|
||||
state.dirty = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await bootTui(state);
|
||||
}
|
||||
|
||||
@@ -384,6 +384,8 @@ export class MiscState {
|
||||
activity: ActivityEntry[] = [];
|
||||
/** Stack of interactive menu frames (top = currently shown). */
|
||||
menuStack: MenuFrame[] = [];
|
||||
/** Model ids fetched live from the provider's /models endpoint. */
|
||||
availableModels: string[] = [];
|
||||
|
||||
pushToast(toast: { kind: string; message: string; createdMs: number; ttlMs: number }): void {
|
||||
this.toasts.push(toast);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createTuiState, pushTranscript, makeChatMessage, reconcileStream, type
|
||||
import { applyAction } from "./action.ts";
|
||||
import { handleKey, decodeKey } from "./controller.ts";
|
||||
import { toControllerKey } from "./ui.tsx";
|
||||
import { modelProviderMenu, modelSubMenu, resolveMenu } from "./menu.ts";
|
||||
import { Roles } from "@zesdex/domain";
|
||||
|
||||
function freshState() {
|
||||
@@ -359,3 +360,51 @@ describe("reconcileStream (9router sliding-window vs incremental)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("menu model lists (live vs fallback)", () => {
|
||||
test("provider menu lists configured providers", () => {
|
||||
const s = freshState();
|
||||
s.appConfig = { providers: { zen: {}, router: {}, claude: {} } };
|
||||
s.settings = { provider: "router" };
|
||||
const frame = modelProviderMenu(s);
|
||||
expect(frame.id).toBe("model:provider");
|
||||
expect(frame.items).toEqual(["zen", "router", "claude"].map(() => expect.anything()).map((_, i) => ["zen","router","claude"][i]!));
|
||||
expect(frame.selected).toBe(1); // router active
|
||||
});
|
||||
|
||||
test("model sub-menu uses live /models list when available", () => {
|
||||
const s = freshState();
|
||||
s.misc.availableModels = ["gpt-5", "claude-sonnet-5", "deepseek-v3"];
|
||||
s.settings = { provider: "zen", model: "claude-sonnet-5" };
|
||||
const frame = modelSubMenu(s, "zen")!;
|
||||
expect(frame.items).toEqual(["gpt-5", "claude-sonnet-5", "deepseek-v3"]);
|
||||
expect(frame.selected).toBe(1); // current model highlighted
|
||||
expect(frame.title).toContain("(3)");
|
||||
});
|
||||
|
||||
test("model sub-menu falls back to a static catalog without a live list", () => {
|
||||
const s = freshState();
|
||||
s.misc.availableModels = [];
|
||||
s.settings = { provider: "zen", model: "" };
|
||||
const frame = modelSubMenu(s, "zen")!;
|
||||
expect(frame.items.length).toBeGreaterThan(0);
|
||||
expect(frame.hint).toContain("fallback");
|
||||
});
|
||||
|
||||
test("resolving a live model row applies it and pops the stack", () => {
|
||||
const s = freshState();
|
||||
s.misc.availableModels = ["gpt-5", "claude-sonnet-5"];
|
||||
s.appConfig = { providers: { zen: {} } };
|
||||
s.settings = { provider: "zen", model: "claude-sonnet-5" };
|
||||
applyAction(s, { tag: "OpenMenu", frame: modelProviderMenu(s) });
|
||||
applyAction(s, { tag: "OpenMenu", frame: modelSubMenu(s, "zen")! });
|
||||
// The sub-menu pre-selects the CURRENT model (claude-sonnet-5, index 1).
|
||||
expect(s.misc.menuStack.at(-1)?.selected).toBe(1);
|
||||
// Move to index 0 (gpt-5), then resolve Enter for the model row.
|
||||
applyAction(s, { tag: "MenuHome" });
|
||||
const acts = resolveMenu(s, "model:zen");
|
||||
expect(acts).toEqual([{ tag: "MenuBackAll" }]);
|
||||
expect(s.settings.provider).toBe("zen");
|
||||
expect(s.settings.model).toBe("gpt-5");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user