feat(tui): implement live model fetching and enhance model selection menus

This commit is contained in:
asepharyana
2026-09-03 16:13:29 +07:00
parent f5b04214cf
commit 93e5464370
7 changed files with 174 additions and 15 deletions
@@ -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)}`; 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( async chat(
messages: ChatMessage[], messages: ChatMessage[],
tools?: ToolDef[], tools?: ToolDef[],
+11 -1
View File
@@ -40,6 +40,8 @@ export interface WiredRuntime {
apiKey: string; apiKey: string;
model: string; model: string;
apiBase?: 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, 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. */ /** Rooted, long-lived event sink that collects events into an array. */
+20 -14
View File
@@ -35,24 +35,30 @@ export function modelProviderMenu(state: AppStateRest): MenuFrame {
}; };
} }
/** Build the per-provider model sub-menu. */ /** Build the per-provider model sub-menu, preferring models fetched live. */
export function modelSubMenu(_state: AppStateRest, provider: string): MenuFrame | null { export function modelSubMenu(state: AppStateRest, provider: string): MenuFrame | null {
// Candidate models for this provider. In a fuller build these come from a const live = state.misc.availableModels;
// catalog / API; here we present a sensible, sortable list. const current = String(state.settings.model ?? "");
const known = [ if (live.length > 0) {
"claude-opus-5", return {
"claude-sonnet-5", id: `model:${provider}`,
"claude-haiku-4-5", title: `Models — ${provider} (${live.length})`,
"deepseek-v4-flash-free", items: [...live],
"gpt-5", 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 { return {
id: `model:${provider}`, id: `model:${provider}`,
title: `Models — ${provider}`, title: `Models — ${provider}`,
items: known, items: known,
details: known.map((m) => (m === _state.settings.model ? " (current)" : "")), details: known.map((m) => (m === current ? " (current)" : "")),
hint: "Enter applies · Esc back to providers", hint: "live /models unavailable — fallback list · Enter applies",
selected: Math.max(0, known.indexOf(String(_state.settings.model ?? ""))), selected: Math.max(0, known.indexOf(current)),
}; };
} }
+11
View File
@@ -165,5 +165,16 @@ export async function runTui(): Promise<void> {
sessionId: state.sessionId, sessionId: state.sessionId,
} as Record<string, unknown>; } 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); await bootTui(state);
} }
+2
View File
@@ -384,6 +384,8 @@ export class MiscState {
activity: ActivityEntry[] = []; activity: ActivityEntry[] = [];
/** Stack of interactive menu frames (top = currently shown). */ /** Stack of interactive menu frames (top = currently shown). */
menuStack: MenuFrame[] = []; 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 { pushToast(toast: { kind: string; message: string; createdMs: number; ttlMs: number }): void {
this.toasts.push(toast); this.toasts.push(toast);
+49
View File
@@ -8,6 +8,7 @@ import { createTuiState, pushTranscript, makeChatMessage, reconcileStream, type
import { applyAction } from "./action.ts"; import { applyAction } from "./action.ts";
import { handleKey, decodeKey } from "./controller.ts"; import { handleKey, decodeKey } from "./controller.ts";
import { toControllerKey } from "./ui.tsx"; import { toControllerKey } from "./ui.tsx";
import { modelProviderMenu, modelSubMenu, resolveMenu } from "./menu.ts";
import { Roles } from "@zesdex/domain"; import { Roles } from "@zesdex/domain";
function freshState() { 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");
});
});