From 93e54643703ce7f18286bd6a988e0fab18cc9e66 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 3 Sep 2026 15:10:31 +0700 Subject: [PATCH] feat(tui): implement live model fetching and enhance model selection menus --- .../agent/infrastructure/llm/provider.test.ts | 52 +++++++++++++++++++ .../agent/infrastructure/llm/provider.ts | 29 +++++++++++ src/interfaces/cli/compose.ts | 12 ++++- src/interfaces/tui/menu.ts | 34 +++++++----- src/interfaces/tui/run.ts | 11 ++++ src/interfaces/tui/state.ts | 2 + src/interfaces/tui/tui.test.ts | 49 +++++++++++++++++ 7 files changed, 174 insertions(+), 15 deletions(-) create mode 100644 src/features/agent/infrastructure/llm/provider.test.ts diff --git a/src/features/agent/infrastructure/llm/provider.test.ts b/src/features/agent/infrastructure/llm/provider.test.ts new file mode 100644 index 0000000..6470146 --- /dev/null +++ b/src/features/agent/infrastructure/llm/provider.test.ts @@ -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): Promise { + 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; diff --git a/src/features/agent/infrastructure/llm/provider.ts b/src/features/agent/infrastructure/llm/provider.ts index 577ff95..427eee5 100644 --- a/src/features/agent/infrastructure/llm/provider.ts +++ b/src/features/agent/infrastructure/llm/provider.ts @@ -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 { + 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[], diff --git a/src/interfaces/cli/compose.ts b/src/interfaces/cli/compose.ts index 6a53433..4ef1a69 100644 --- a/src/interfaces/cli/compose.ts +++ b/src/interfaces/cli/compose.ts @@ -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; } /** @@ -76,7 +78,15 @@ export async function runSingleProcess(): Promise { 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. */ diff --git a/src/interfaces/tui/menu.ts b/src/interfaces/tui/menu.ts index cfbd191..d78f5b3 100644 --- a/src/interfaces/tui/menu.ts +++ b/src/interfaces/tui/menu.ts @@ -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)), }; } diff --git a/src/interfaces/tui/run.ts b/src/interfaces/tui/run.ts index b05f721..a1d59ce 100644 --- a/src/interfaces/tui/run.ts +++ b/src/interfaces/tui/run.ts @@ -165,5 +165,16 @@ export async function runTui(): Promise { sessionId: state.sessionId, } as Record; + // 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); } diff --git a/src/interfaces/tui/state.ts b/src/interfaces/tui/state.ts index aa230e5..99bfdda 100644 --- a/src/interfaces/tui/state.ts +++ b/src/interfaces/tui/state.ts @@ -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); diff --git a/src/interfaces/tui/tui.test.ts b/src/interfaces/tui/tui.test.ts index 382b64c..c41dde1 100644 --- a/src/interfaces/tui/tui.test.ts +++ b/src/interfaces/tui/tui.test.ts @@ -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"); + }); +});