From de695471f0b82e29c18bed11d9e9018485386986 Mon Sep 17 00:00:00 2001 From: Sachin Meena <84434145+sachin-detrax@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:30:29 +0530 Subject: [PATCH 1/2] List models from the server instead of typing model ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #31. The OpenAI-compatible wizard asked operators to type a chat model id by hand, which nobody can do for a remote vLLM deployment whose model list changes without notice. The chat model step now GETs `{baseUrl}/v1/models` (bearer key from the wizard buffer or the resolved env key) and shows the ids as a windowed picker. Typing still works — any keystroke is treated as a deliberate override and hides the list; backspacing to empty brings it back. When the server refuses or is unreachable the step degrades to the old typed field with the error in the hint. Base URLs are also normalised on save: a pasted `https://host/v1/` used to be stored verbatim and produce `https://host/v1//v1/chat/completions` on every request. --- .../openai/fetch-openai-compat-models.test.ts | 53 ++++++++++++ .../openai/fetch-openai-compat-models.ts | 46 ++++++++++ src/tui/components/providers-wizard.test.tsx | 67 +++++++++++++++ src/tui/components/providers-wizard.tsx | 86 +++++++++++++++++-- .../providers/providers-wizard-build-entry.ts | 4 +- .../providers-wizard-key-bindings.test.ts | 56 +++++++++++- .../providers-wizard-key-bindings.ts | 47 ++++++++++ .../providers/save-provider-wizard.test.ts | 3 +- 8 files changed, 351 insertions(+), 11 deletions(-) create mode 100644 src/llm/provider/openai/fetch-openai-compat-models.test.ts create mode 100644 src/llm/provider/openai/fetch-openai-compat-models.ts create mode 100644 src/tui/components/providers-wizard.test.tsx 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..3deca72 --- /dev/null +++ b/src/tui/components/providers-wizard.test.tsx @@ -0,0 +1,67 @@ +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( + , + ); + 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..d4022c9 100644 --- a/src/tui/providers/providers-wizard-key-bindings.ts +++ b/src/tui/providers/providers-wizard-key-bindings.ts @@ -1,9 +1,11 @@ import type { Key } from "ink"; +import { getCachedOpenAiCompatModels } 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 +118,24 @@ function isLinePhase(phase: ProvidersWizardPhase): boolean { ); } +export function baseUrlForWizard(wizard: ProvidersWizardState): string { + return wizard.baseUrlLine.trim() || 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 +185,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", }); }); From ccf405fdebee7208e3ca65334c966e998d2120bf Mon Sep 17 00:00:00 2001 From: Sachin Meena <84434145+sachin-detrax@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:30:29 +0530 Subject: [PATCH 2/2] Show the normalized models URL in the picker header Live check against openrouter with a base URL pasted as `.../api/v1` rendered `.../api/v1/v1/models` in the header while requesting the correct URL. Normalize at the single source both the fetch and the header read from. --- src/tui/components/providers-wizard.test.tsx | 3 ++- src/tui/providers/providers-wizard-key-bindings.ts | 11 +++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/tui/components/providers-wizard.test.tsx b/src/tui/components/providers-wizard.test.tsx index 3deca72..54dc44c 100644 --- a/src/tui/components/providers-wizard.test.tsx +++ b/src/tui/components/providers-wizard.test.tsx @@ -38,7 +38,8 @@ describe("ProvidersWizard chat model step", () => { ); const { lastFrame } = render( - , + // pasted with `/v1` — the header must show the URL actually requested + , ); await flush(); diff --git a/src/tui/providers/providers-wizard-key-bindings.ts b/src/tui/providers/providers-wizard-key-bindings.ts index d4022c9..f4a8ffd 100644 --- a/src/tui/providers/providers-wizard-key-bindings.ts +++ b/src/tui/providers/providers-wizard-key-bindings.ts @@ -1,5 +1,8 @@ import type { Key } from "ink"; -import { getCachedOpenAiCompatModels } from "../../llm/provider/openai/fetch-openai-compat-models.js"; +import { + getCachedOpenAiCompatModels, + normalizeOpenAiCompatBaseUrl, +} from "../../llm/provider/openai/fetch-openai-compat-models.js"; import { listAimlapiChatModels, listAimlapiEmbeddingModels, @@ -118,8 +121,12 @@ 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 wizard.baseUrlLine.trim() || OPENAI_COMPAT_DEFAULT_BASE_URL; + return ( + normalizeOpenAiCompatBaseUrl(wizard.baseUrlLine) || + OPENAI_COMPAT_DEFAULT_BASE_URL + ); } /**