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)}`;
}
/**
* 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[],