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)}`;
|
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[],
|
||||||
|
|||||||
@@ -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
@@ -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)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user