diff --git a/src/llm/provider/openai/fetch-openai-compat-models.test.ts b/src/llm/provider/openai/fetch-openai-compat-models.test.ts new file mode 100644 index 0000000..2da5604 --- /dev/null +++ b/src/llm/provider/openai/fetch-openai-compat-models.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + fetchOpenAiCompatModels, + getCachedOpenAiCompatModels, + normalizeOpenAiCompatBaseUrl, +} from "./fetch-openai-compat-models.js"; + +describe("fetchOpenAiCompatModels", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("strips trailing slashes and an explicit /v1 from the base url", () => { + expect(normalizeOpenAiCompatBaseUrl(" https://vllm.example/v1/ ")).toBe( + "https://vllm.example", + ); + expect(normalizeOpenAiCompatBaseUrl("https://vllm.example")).toBe( + "https://vllm.example", + ); + }); + + it("lists sorted model ids, sends the bearer key, and caches per base url", async () => { + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({ + ok: true, + json: async () => ({ + data: [{ id: "zephyr" }, { id: "Qwen/Qwen3-8B" }, { id: 42 }], + }), + })); + vi.stubGlobal("fetch", fetchMock); + + const ids = await fetchOpenAiCompatModels("https://vllm.example/v1", "key"); + expect(ids).toEqual(["Qwen/Qwen3-8B", "zephyr"]); + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://vllm.example/v1/models"); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + headers: { Authorization: "Bearer key" }, + }); + + expect(getCachedOpenAiCompatModels("https://vllm.example/")).toEqual(ids); + await fetchOpenAiCompatModels("https://vllm.example", "key"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("throws on a rejected request so callers can fall back to typing", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 401 })), + ); + await expect( + fetchOpenAiCompatModels("https://locked.example"), + ).rejects.toThrow("http 401"); + expect(getCachedOpenAiCompatModels("https://locked.example")).toBeUndefined(); + }); +}); diff --git a/src/llm/provider/openai/fetch-openai-compat-models.ts b/src/llm/provider/openai/fetch-openai-compat-models.ts new file mode 100644 index 0000000..2596b2a --- /dev/null +++ b/src/llm/provider/openai/fetch-openai-compat-models.ts @@ -0,0 +1,46 @@ +/** + * Model discovery for OpenAI-compatible servers (vLLM, llama-server, LM Studio, + * OpenAI itself): `GET {baseUrl}/v1/models`. The wizard reads the result + * synchronously through the module cache, same shape as the OpenRouter picker. + */ + +const cache = new Map(); + +/** Callers append `/v1/...`, so a base URL pasted with `/v1` must lose it. */ +export function normalizeOpenAiCompatBaseUrl(raw: string): string { + return raw + .trim() + .replace(/\/+$/, "") + .replace(/\/v\d+$/, ""); +} + +export function getCachedOpenAiCompatModels( + baseUrl: string, +): readonly string[] | undefined { + return cache.get(normalizeOpenAiCompatBaseUrl(baseUrl)); +} + +/** Throws on unreachable/unauthorized servers so the caller can fall back to typing. */ +export async function fetchOpenAiCompatModels( + baseUrl: string, + apiKey?: string, +): Promise { + const base = normalizeOpenAiCompatBaseUrl(baseUrl); + const cached = cache.get(base); + if (cached) return cached; + + const res = await fetch(`${base}/v1/models`, { + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) throw new Error(`http ${res.status}`); + const json = (await res.json()) as { data?: readonly { id?: unknown }[] }; + const ids = (json.data ?? []) + .map((row) => row?.id) + .filter((id): id is string => typeof id === "string" && id.length > 0) + .sort((a, b) => a.localeCompare(b)); + if (ids.length === 0) throw new Error("server listed no models"); + + cache.set(base, ids); + return ids; +} diff --git a/src/tui/components/providers-wizard.test.tsx b/src/tui/components/providers-wizard.test.tsx new file mode 100644 index 0000000..54dc44c --- /dev/null +++ b/src/tui/components/providers-wizard.test.tsx @@ -0,0 +1,68 @@ +import { render } from "ink-testing-library"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createProvidersWizardState } from "../providers/providers-wizard-state.js"; +import { ProvidersWizard } from "./providers-wizard.js"; + +function stripAnsi(value: string): string { + return value.replace(/\[[0-9;]*m/g, ""); +} + +function chatModelStep(baseUrlLine: string, cursor = 0) { + return { + ...createProvidersWizardState("add", { kind: "openai-compatible" }), + phase: "chat_model_line" as const, + baseUrlLine, + cursor, + }; +} + +async function flush(): Promise { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); +} + +describe("ProvidersWizard chat model step", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("windows a long discovered model list around the cursor", async () => { + const ids = Array.from({ length: 30 }, (_, i) => `model-${i + 1}`); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ data: ids.map((id) => ({ id })) }), + })), + ); + + const { lastFrame } = render( + // pasted with `/v1` — the header must show the URL actually requested + , + ); + await flush(); + + const text = stripAnsi(lastFrame() ?? ""); + expect(text).toContain("30 from https://many.example/v1/models"); + // ids are listed sorted; cursor 20 lands on the 21st of them + expect(text).toContain("> model-28"); + expect(text).toContain("(21/30)"); + expect(text).not.toContain("model-10"); + }); + + it("falls back to a typed id when the server refuses the list", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 403 })), + ); + + const { lastFrame } = render( + , + ); + await flush(); + + const text = stripAnsi(lastFrame() ?? ""); + expect(text).toContain("model list unavailable (http 403)"); + }); +}); diff --git a/src/tui/components/providers-wizard.tsx b/src/tui/components/providers-wizard.tsx index 452eb65..57f4ab8 100644 --- a/src/tui/components/providers-wizard.tsx +++ b/src/tui/components/providers-wizard.tsx @@ -1,5 +1,11 @@ import { Box, Text } from "ink"; -import type { ReactElement } from "react"; +import { useEffect, useState, type ReactElement } from "react"; +import { resolveLlmProviderApiKey } from "../../config/resolve-llm-api-key.js"; +import { fetchOpenAiCompatModels } from "../../llm/provider/openai/fetch-openai-compat-models.js"; +import { + baseUrlForWizard, + listCompatChatModelPicks, +} from "../providers/providers-wizard-key-bindings.js"; import { theme } from "../theme/theme.js"; import { listAimlapiChatModels, @@ -103,6 +109,76 @@ function renderLineField(props: { ); } +/** Keep long server model lists inside a fixed viewport around the cursor. */ +const PICK_WINDOW = 12; + +function CompatChatModelStep(props: { + wizard: ProvidersWizardState; +}): ReactElement { + const w = props.wizard; + const baseUrl = baseUrlForWizard(w); + const [status, setStatus] = useState<{ loading: boolean; error: string | null }>( + { loading: true, error: null }, + ); + + useEffect(() => { + let alive = true; + setStatus({ loading: true, error: null }); + const apiKey = + w.apiKeyBuffer.trim() || + resolveLlmProviderApiKey({ + id: "openai-compatible", + kind: "openai-compatible", + }); + fetchOpenAiCompatModels(baseUrl, apiKey).then( + () => { + if (alive) setStatus({ loading: false, error: null }); + }, + (err: unknown) => { + if (alive) { + setStatus({ + loading: false, + error: err instanceof Error ? err.message : String(err), + }); + } + }, + ); + return () => { + alive = false; + }; + // Re-fetch only when the server changes; the key is fixed for this wizard run. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [baseUrl]); + + const picks = listCompatChatModelPicks(w); + if (picks.length > 0) { + const cursor = Math.min(w.cursor, picks.length - 1); + const start = Math.min( + Math.max(0, cursor - Math.floor(PICK_WINDOW / 2)), + Math.max(0, picks.length - PICK_WINDOW), + ); + return renderPickList( + `Chat model — ${picks.length} from ${baseUrl}/v1/models`, + picks.slice(start, start + PICK_WINDOW).map((id) => ({ label: id })), + cursor - start, + `↑/↓ move (${cursor + 1}/${picks.length}) · Enter select · type to enter an id by hand · Esc cancel`, + ); + } + + const hint = status.loading + ? `listing models from ${baseUrl}/v1/models…` + : status.error + ? `model list unavailable (${status.error}) — type the id · Enter to continue` + : "Enter to continue · Backspace to empty for the model list · Esc cancel"; + return renderLineField({ + title: "Chat model id", + value: w.chatModelLine, + placeholder: OPENAI_COMPAT_DEFAULT_CHAT_MODEL, + hint, + error: w.error, + }); +} + export function ProvidersWizard(props: { wizard: ProvidersWizardState; }): ReactElement { @@ -202,13 +278,7 @@ export function ProvidersWizard(props: { } if (w.phase === "chat_model_line") { - return renderLineField({ - title: "Chat model id", - value: w.chatModelLine, - placeholder: OPENAI_COMPAT_DEFAULT_CHAT_MODEL, - hint: "Enter to continue · Esc cancel", - error: w.error, - }); + return ; } if (w.phase === "embedding_model_line") { diff --git a/src/tui/providers/providers-wizard-build-entry.ts b/src/tui/providers/providers-wizard-build-entry.ts index 8900bb5..18b47ff 100644 --- a/src/tui/providers/providers-wizard-build-entry.ts +++ b/src/tui/providers/providers-wizard-build-entry.ts @@ -1,4 +1,5 @@ import type { UserLlmProviderEntry } from "../../config/llm-config.js"; +import { normalizeOpenAiCompatBaseUrl } from "../../llm/provider/openai/fetch-openai-compat-models.js"; import { LOCAL_EMBEDDING_CHOICE_ID, OPENAI_COMPAT_DEFAULT_BASE_URL, @@ -58,7 +59,8 @@ export function buildProviderEntryFromWizard(input: { ...(input.kind === "openai-compatible" ? { baseUrl: - input.baseUrl?.trim() || OPENAI_COMPAT_DEFAULT_BASE_URL, + normalizeOpenAiCompatBaseUrl(input.baseUrl ?? "") || + OPENAI_COMPAT_DEFAULT_BASE_URL, } : {}), }; diff --git a/src/tui/providers/providers-wizard-key-bindings.test.ts b/src/tui/providers/providers-wizard-key-bindings.test.ts index 9e46dee..4237fa0 100644 --- a/src/tui/providers/providers-wizard-key-bindings.test.ts +++ b/src/tui/providers/providers-wizard-key-bindings.test.ts @@ -1,6 +1,7 @@ import type { Key } from "ink"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchOpenAiCompatModels } from "../../llm/provider/openai/fetch-openai-compat-models.js"; import { LOCAL_EMBEDDING_CHOICE_ID } from "./providers-model-options.js"; import { handleProvidersWizardKey } from "./providers-wizard-key-bindings.js"; import { createProvidersWizardState } from "./providers-wizard-state.js"; @@ -62,6 +63,59 @@ describe("handleProvidersWizardKey", () => { expect(wizard.phase).toBe("api_key"); }); + describe("openai-compatible chat model step", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + async function wizardAtChatModelStep( + baseUrl: string, + ): Promise { + let wizard = createProvidersWizardState("add", { + kind: "openai-compatible", + }); + wizard = { ...wizard, phase: "base_url" }; + for (const ch of baseUrl) wizard = next(wizard, ch, emptyKey()); + return next(wizard, "", emptyKey({ return: true })); + } + + it("picks a discovered model with arrows + Enter", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ data: [{ id: "a-model" }, { id: "b-model" }] }), + })), + ); + await fetchOpenAiCompatModels("https://picks.example"); + + let wizard = await wizardAtChatModelStep("https://picks.example"); + expect(wizard.phase).toBe("chat_model_line"); + + wizard = next(wizard, "", emptyKey({ downArrow: true })); + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard.chatModelLine).toBe("b-model"); + expect(wizard.phase).toBe("embedding_model_line"); + }); + + it("lets typing override the discovered list", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ data: [{ id: "a-model" }] }), + })), + ); + await fetchOpenAiCompatModels("https://typed.example"); + + let wizard = await wizardAtChatModelStep("https://typed.example"); + for (const ch of "my-own") wizard = next(wizard, ch, emptyKey()); + wizard = next(wizard, "", emptyKey({ return: true })); + expect(wizard.chatModelLine).toBe("my-own"); + expect(wizard.phase).toBe("embedding_model_line"); + }); + }); + it("walks the OpenRouter onboarding flow through model and embedding picks", () => { let wizard = createProvidersWizardState("add"); diff --git a/src/tui/providers/providers-wizard-key-bindings.ts b/src/tui/providers/providers-wizard-key-bindings.ts index 2c48ba6..f4a8ffd 100644 --- a/src/tui/providers/providers-wizard-key-bindings.ts +++ b/src/tui/providers/providers-wizard-key-bindings.ts @@ -1,9 +1,14 @@ import type { Key } from "ink"; +import { + getCachedOpenAiCompatModels, + normalizeOpenAiCompatBaseUrl, +} from "../../llm/provider/openai/fetch-openai-compat-models.js"; import { listAimlapiChatModels, listAimlapiEmbeddingModels, listOpenRouterChatModels, listOpenRouterEmbeddingModels, + OPENAI_COMPAT_DEFAULT_BASE_URL, } from "./providers-model-options.js"; import type { ProvidersWizardKind, @@ -116,6 +121,28 @@ function isLinePhase(phase: ProvidersWizardPhase): boolean { ); } +/** Normalized so the fetch, the cache key and the displayed URL always agree. */ +export function baseUrlForWizard(wizard: ProvidersWizardState): string { + return ( + normalizeOpenAiCompatBaseUrl(wizard.baseUrlLine) || + OPENAI_COMPAT_DEFAULT_BASE_URL + ); +} + +/** + * Chat model ids discovered from `{baseUrl}/v1/models`. Empty once the operator + * types anything — a typed id is a deliberate override, so the picker steps + * aside (backspacing back to empty brings it back). + */ +export function listCompatChatModelPicks( + wizard: ProvidersWizardState, +): readonly string[] { + if (wizard.phase !== "chat_model_line") return []; + if (wizard.kind !== "openai-compatible") return []; + if (wizard.chatModelLine.length > 0) return []; + return getCachedOpenAiCompatModels(baseUrlForWizard(wizard)) ?? []; +} + export type ProvidersWizardKeyResult = | { handled: true; wizard: ProvidersWizardState; submit?: false } | { handled: true; wizard: ProvidersWizardState; submit: true } @@ -165,6 +192,33 @@ export function handleProvidersWizardKey( } if (isLinePhase(wizard.phase)) { + const picks = listCompatChatModelPicks(wizard); + if (picks.length > 0) { + // Arrows only: printable keys fall through to line editing so an id the + // server does not advertise can still be typed. + if (key.downArrow) { + return { + handled: true, + wizard: { ...wizard, cursor: (wizard.cursor + 1) % picks.length }, + }; + } + if (key.upArrow) { + return { + handled: true, + wizard: { + ...wizard, + cursor: (wizard.cursor - 1 + picks.length) % picks.length, + }, + }; + } + if (key.return) { + const picked = picks[wizard.cursor] ?? picks[0]!; + return { + handled: true, + wizard: advanceWizardPhase({ ...wizard, chatModelLine: picked }), + }; + } + } const field = wizard.phase === "base_url" ? "baseUrlLine" diff --git a/src/tui/providers/save-provider-wizard.test.ts b/src/tui/providers/save-provider-wizard.test.ts index f181cd1..cf9e4bc 100644 --- a/src/tui/providers/save-provider-wizard.test.ts +++ b/src/tui/providers/save-provider-wizard.test.ts @@ -110,7 +110,8 @@ describe("saveProviderWizardToConfig", () => { expect(getConfig().llm?.providers.find((p) => p.id === "openai-compatible")) .toMatchObject({ kind: "openai-compatible", - baseUrl: "https://api.venice.ai/api/", + // trailing slash normalized away — callers append `/v1/...` + baseUrl: "https://api.venice.ai/api", defaultChatModel: "venice-uncensored", }); });