Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/llm/provider/openai/fetch-openai-compat-models.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
46 changes: 46 additions & 0 deletions src/llm/provider/openai/fetch-openai-compat-models.ts
Original file line number Diff line number Diff line change
@@ -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<string, readonly string[]>();

/** 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<readonly string[]> {
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;
}
68 changes: 68 additions & 0 deletions src/tui/components/providers-wizard.test.tsx
Original file line number Diff line number Diff line change
@@ -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<void> {
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
<ProvidersWizard wizard={chatModelStep("https://many.example/v1", 20)} />,
);
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(
<ProvidersWizard wizard={chatModelStep("https://refused.example")} />,
);
await flush();

const text = stripAnsi(lastFrame() ?? "");
expect(text).toContain("model list unavailable (http 403)");
});
});
86 changes: 78 additions & 8 deletions src/tui/components/providers-wizard.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 <CompatChatModelStep wizard={w} />;
}

if (w.phase === "embedding_model_line") {
Expand Down
4 changes: 3 additions & 1 deletion src/tui/providers/providers-wizard-build-entry.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
}
: {}),
};
Expand Down
56 changes: 55 additions & 1 deletion src/tui/providers/providers-wizard-key-bindings.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<ProvidersWizardState> {
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");

Expand Down
Loading