Files
openclaw/extensions/ollama/src/setup.test.ts

1131 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Ollama tests cover setup plugin behavior.
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { WizardPrompter } from "openclaw/plugin-sdk/setup";
import { jsonResponse, requestBodyText, requestUrl } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
checkOllamaCloudAuth,
configureOllamaNonInteractive,
ensureOllamaModelPulled,
promptAndConfigureOllama,
} from "./setup.js";
const upsertAuthProfileWithLock = vi.hoisted(() => vi.fn(async () => {}));
const fetchWithSsrFGuardMock = vi.hoisted(() =>
vi.fn(async (params: { url: string; init?: RequestInit; signal?: AbortSignal }) => ({
response: await globalThis.fetch(params.url, {
...params.init,
...(params.signal ? { signal: params.signal } : {}),
}),
finalUrl: params.url,
release: async () => {},
})),
);
vi.mock("openclaw/plugin-sdk/provider-auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/provider-auth")>();
return {
...actual,
upsertAuthProfileWithLock,
};
});
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
return {
...actual,
fetchWithSsrFGuard: (...args: Parameters<typeof actual.fetchWithSsrFGuard>) =>
fetchWithSsrFGuardMock(...args),
};
});
function createOllamaFetchMock(params: {
tags?: string[];
show?: Record<string, number | undefined>;
capabilities?: Record<string, string[] | undefined>;
pullResponse?: Response;
tagsError?: Error;
meResponse?: Response;
}) {
return vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = requestUrl(input);
if (url.endsWith("/api/tags")) {
if (params.tagsError) {
throw params.tagsError;
}
return jsonResponse({ models: (params.tags ?? []).map((name) => ({ name })) });
}
if (url.endsWith("/api/show")) {
const body = JSON.parse(requestBodyText(init?.body)) as { name?: string };
const contextWindow = body.name ? params.show?.[body.name] : undefined;
const capabilities = body.name
? params.capabilities === undefined
? ["tools"]
: params.capabilities[body.name]
: undefined;
return jsonResponse({
...(contextWindow ? { model_info: { "llama.context_length": contextWindow } } : {}),
...(capabilities ? { capabilities } : {}),
});
}
if (url.endsWith("/api/me")) {
return params.meResponse ?? jsonResponse({});
}
if (url.endsWith("/api/pull")) {
return params.pullResponse ?? new Response('{"status":"success"}\n', { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
});
}
function mockCall(mock: { mock: { calls: unknown[][] } }, index = 0) {
return mock.mock.calls.at(index);
}
function mockCallArg(mock: { mock: { calls: unknown[][] } }, index = 0, argIndex = 0) {
return mockCall(mock, index)?.at(argIndex);
}
function abortReasonAsError(signal: AbortSignal): Error {
return signal.reason instanceof Error
? signal.reason
: new Error("Request aborted", { cause: signal.reason });
}
function createLocalPrompter(): WizardPrompter {
return {
select: vi.fn().mockResolvedValueOnce("local-only"),
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
}
function createCloudPrompter(): WizardPrompter {
return {
select: vi.fn().mockResolvedValueOnce("cloud-only"),
confirm: vi.fn().mockResolvedValueOnce(false),
text: vi.fn().mockResolvedValueOnce("test-ollama-key"),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
}
function createCloudLocalPrompter(): WizardPrompter {
return {
select: vi.fn().mockResolvedValueOnce("cloud-local"),
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
}
function createDefaultOllamaConfig(primary: string) {
return {
agents: { defaults: { model: { primary } } },
models: { providers: { ollama: { baseUrl: "http://127.0.0.1:11434", models: [] } } },
};
}
function createRuntime() {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as unknown as RuntimeEnv;
}
describe("ollama setup", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
upsertAuthProfileWithLock.mockClear();
fetchWithSsrFGuardMock.mockClear();
});
it("puts suggested local model first in local mode", async () => {
const prompter = createLocalPrompter();
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] });
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
});
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
expect(modelIds?.[0]).toBe("gemma4");
});
it("Docker setup defaults to the host Ollama endpoint", async () => {
vi.stubEnv("OPENCLAW_DOCKER_SETUP", "1");
const text = vi.fn().mockResolvedValueOnce("http://host.docker.internal:11434");
const prompter = {
select: vi.fn().mockResolvedValueOnce("local-only"),
text,
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] });
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
});
const baseUrlPrompt = mockCallArg(text) as {
message?: string;
initialValue?: string;
placeholder?: string;
validate?: unknown;
};
expect(baseUrlPrompt).toEqual({
message: "Ollama base URL",
initialValue: "http://host.docker.internal:11434",
placeholder: "http://host.docker.internal:11434",
validate: baseUrlPrompt.validate,
});
expect(typeof baseUrlPrompt.validate).toBe("function");
expect(mockCallArg(fetchMock)).toBe("http://host.docker.internal:11434/api/tags");
expect(result.config.models?.providers?.ollama?.baseUrl).toBe(
"http://host.docker.internal:11434",
);
});
it("puts suggested cloud model first in cloud mode", async () => {
const prompter = createCloudPrompter();
vi.stubGlobal("fetch", createOllamaFetchMock({ tags: [] }));
const result = await promptAndConfigureOllama({
cfg: {},
env: {},
prompter,
allowSecretRefPrompt: false,
});
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
expect(modelIds?.[0]).toBe("minimax-m2.7");
expect(result.config.models?.providers?.ollama?.baseUrl).toBe("https://ollama.com");
expect(result.config.models?.providers?.ollama?.apiKey).toBe("test-ollama-key");
expect(result.credential).toBe("test-ollama-key");
});
it("uses generic token flags for cloud-only setup", async () => {
const prompter = createCloudPrompter();
vi.stubGlobal("fetch", createOllamaFetchMock({ tags: [] }));
const result = await promptAndConfigureOllama({
cfg: {},
env: {},
opts: {
token: "generic-ollama-key",
tokenProvider: "ollama",
},
prompter,
allowSecretRefPrompt: false,
});
expect(result.credential).toBe("generic-ollama-key");
expect(prompter.text).not.toHaveBeenCalled();
});
it("puts hybrid cloud model suggestions after the local default when signed in", async () => {
const prompter = createCloudLocalPrompter();
const fetchMock = createOllamaFetchMock({
tags: ["llama3:8b"],
meResponse: jsonResponse({ user: "signed-in" }),
});
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
});
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
expect(modelIds).toEqual([
"gemma4",
"minimax-m2.7:cloud",
"glm-5.1:cloud",
"glm-5.2:cloud",
"llama3:8b",
]);
expect(result.config.models?.providers?.ollama?.baseUrl).toBe("http://127.0.0.1:11434");
expect(result.credential).toBe("ollama-local");
});
it("mode selection affects model ordering (local)", async () => {
const prompter = createLocalPrompter();
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b", "gemma4"] });
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
});
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
expect(modelIds?.[0]).toBe("gemma4");
expect(modelIds).toContain("llama3:8b");
});
it("dedupes the suggested local model against a discovered latest tag", async () => {
const prompter = createLocalPrompter();
const fetchMock = createOllamaFetchMock({ tags: ["gemma4:latest", "llama3:8b"] });
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
});
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
expect(modelIds).toEqual(["gemma4:latest", "llama3:8b"]);
});
it("cloud mode does not hit local Ollama endpoints", async () => {
const prompter = createCloudPrompter();
const fetchMock = createOllamaFetchMock({ tags: [] });
vi.stubGlobal("fetch", fetchMock);
await promptAndConfigureOllama({
cfg: {},
env: {},
prompter,
allowSecretRefPrompt: false,
});
const requestUrls = fetchMock.mock.calls.map((call) => requestUrl(call[0]));
expect(requestUrls).toEqual(["https://ollama.com/api/tags"]);
expect(new Headers(fetchMock.mock.calls[0]?.[1]?.headers).get("Authorization")).toBe(
"Bearer test-ollama-key",
);
});
it("rejects the local marker during cloud-only setup", async () => {
const prompter = createCloudPrompter();
await expect(
promptAndConfigureOllama({
cfg: {},
env: {},
opts: {
ollamaApiKey: "ollama-local",
},
prompter,
allowSecretRefPrompt: false,
}),
).rejects.toThrow("Cloud-only Ollama setup requires a real OLLAMA_API_KEY.");
});
it("local mode only hits local model discovery endpoints", async () => {
const prompter = createLocalPrompter();
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] });
vi.stubGlobal("fetch", fetchMock);
await promptAndConfigureOllama({
cfg: {},
prompter,
});
expect(fetchMock.mock.calls.map((call) => requestUrl(call[0]))).toEqual([
"http://127.0.0.1:11434/api/tags",
"http://127.0.0.1:11434/api/show",
]);
});
it("asks for Ollama mode before cloud api key", async () => {
const events: string[] = [];
const prompter = {
select: vi.fn(async () => {
events.push("select");
return "cloud-only";
}),
confirm: vi.fn(async () => false),
text: vi.fn(async () => {
events.push("text");
return "test-ollama-key";
}),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
vi.stubGlobal("fetch", createOllamaFetchMock({ tags: [] }));
await promptAndConfigureOllama({
cfg: {},
env: {},
prompter,
allowSecretRefPrompt: false,
});
expect(events).toEqual(["select", "text"]);
});
it("retries the configured host after showing unreachable guidance", async () => {
const prompter = createLocalPrompter();
prompter.confirm = vi.fn().mockResolvedValueOnce(true);
const reachableFetch = createOllamaFetchMock({ tags: ["qwen3:0.6b"] });
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new Error("down"))
.mockImplementation(reachableFetch);
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
});
expect(prompter.note).toHaveBeenCalledWith(
[
"Ollama could not be reached at http://127.0.0.1:11434.",
"Start or restart the Ollama server for this address.",
"If Ollama is not installed on that machine, download it at https://ollama.com/download",
"",
"Continue when it is running. OpenClaw will retry this address.",
].join("\n"),
"Ollama",
);
expect(prompter.confirm).toHaveBeenCalledWith({
message: "Retry this Ollama address now?",
initialValue: true,
});
expect(result.config.models?.providers?.ollama?.models).toEqual(
expect.arrayContaining([expect.objectContaining({ id: "qwen3:0.6b" })]),
);
});
it("reports the configured host when the retry is still unreachable", async () => {
const prompter = createLocalPrompter();
prompter.confirm = vi.fn().mockResolvedValueOnce(true);
const fetchMock = createOllamaFetchMock({ tagsError: new Error("down") });
vi.stubGlobal("fetch", fetchMock);
await expect(
promptAndConfigureOllama({
cfg: {},
prompter,
}),
).rejects.toThrow("Ollama is still not reachable at http://127.0.0.1:11434");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("cloud + local mode falls back to local models when ollama signin is missing", async () => {
const prompter = createCloudLocalPrompter();
const fetchMock = createOllamaFetchMock({
tags: ["llama3:8b"],
meResponse: new Response(JSON.stringify({ signin_url: "https://ollama.com/signin" }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
});
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
});
expect(result.config.models?.providers?.ollama?.models?.map((m) => m.id)).toEqual([
"gemma4",
"llama3:8b",
]);
expect(prompter.note).toHaveBeenCalledWith(
[
"Cloud models on this Ollama host need `ollama signin`.",
"https://ollama.com/signin",
"",
"Continuing with local models only for now.",
].join("\n"),
"Ollama Cloud + Local",
);
});
it("cloud mode falls back to the hardcoded cloud model list when /api/tags is empty", async () => {
const prompter = createCloudPrompter();
vi.stubGlobal("fetch", createOllamaFetchMock({ tags: [] }));
const result = await promptAndConfigureOllama({
cfg: {},
env: {},
prompter,
allowSecretRefPrompt: false,
});
const models = result.config.models?.providers?.ollama?.models;
const modelIds = models?.map((m) => m.id);
expect(modelIds).toEqual(["minimax-m2.7", "glm-5.1", "glm-5.2"]);
expect(models).toEqual([
expect.objectContaining({
id: "minimax-m2.7",
contextWindow: 196_608,
reasoning: true,
input: ["text"],
compat: { supportsTools: true, supportsUsageInStreaming: true },
}),
expect.objectContaining({
id: "glm-5.1",
contextWindow: 202_752,
reasoning: true,
input: ["text"],
compat: { supportsTools: true, supportsUsageInStreaming: true },
}),
expect.objectContaining({
id: "glm-5.2",
contextWindow: 1_000_000,
reasoning: true,
input: ["text"],
compat: { supportsTools: true, supportsUsageInStreaming: true },
}),
]);
});
it("cloud mode populates models from ollama.com /api/tags when reachable", async () => {
const prompter = createCloudPrompter();
const fetchMock = createOllamaFetchMock({
tags: ["qwen3-coder:480b-cloud", "gpt-oss:120b-cloud"],
show: { "qwen3-coder:480b-cloud": 262144 },
});
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
env: {},
prompter,
allowSecretRefPrompt: false,
});
const models = result.config.models?.providers?.ollama?.models;
const modelIds = models?.map((m) => m.id);
expect(modelIds).toEqual([
"minimax-m2.7",
"glm-5.1",
"glm-5.2",
"qwen3-coder:480b-cloud",
"gpt-oss:120b-cloud",
]);
const requestUrls = fetchMock.mock.calls.map((call) => requestUrl(call[0]));
expect(requestUrls.filter((url) => url.endsWith("/api/show"))).toEqual([]);
expect(requestUrls).toContain("https://ollama.com/api/tags");
});
it("uses /api/show context windows when building Ollama model configs", async () => {
const prompter = {
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
select: vi.fn().mockResolvedValueOnce("local-only"),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = createOllamaFetchMock({
tags: ["llama3:8b"],
show: { "llama3:8b": 65536 },
});
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
});
const model = result.config.models?.providers?.ollama?.models?.find(
(m) => m.id === "llama3:8b",
);
expect(model?.contextWindow).toBe(65536);
});
it("offers and streams a recommended pull when no installed model supports tools", async () => {
const progress = { update: vi.fn(), stop: vi.fn() };
const prompter = {
select: vi.fn().mockResolvedValueOnce("local-only"),
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
confirm: vi.fn().mockResolvedValueOnce(true),
progress: vi.fn(() => progress),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = createOllamaFetchMock({
tags: ["llama3:8b"],
show: { "gemma4:e4b": 131072 },
capabilities: {
"llama3:8b": ["generate"],
"gemma4:e4b": ["tools"],
},
pullResponse: new Response(
[
'{"status":"pulling sha256:12345678","total":100,"completed":50}',
'{"status":"pulling sha256:12345678","total":100,"completed":100}',
'{"status":"success"}',
"",
].join("\n"),
{ status: 200 },
),
});
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({ cfg: {}, prompter });
expect(prompter.confirm).toHaveBeenCalledWith({
message: "No tools-capable Ollama model is installed. Pull gemma4:e4b (about 9.6 GB)?",
initialValue: false,
});
const pullCall = fetchMock.mock.calls.find((call) => requestUrl(call[0]).endsWith("/api/pull"));
expect(pullCall).toBeDefined();
expect(JSON.parse(requestBodyText(pullCall?.[1]?.body))).toEqual({ name: "gemma4:e4b" });
expect(progress.update).toHaveBeenCalledWith("Downloading gemma4:e4b - pulling part - 50%");
expect(progress.stop).toHaveBeenCalledWith("Downloaded gemma4:e4b");
expect(result.config.models?.providers?.ollama?.models?.map((model) => model.id)).toContain(
"gemma4:e4b",
);
expect(
result.config.models?.providers?.ollama?.models?.find((model) => model.id === "gemma4:e4b"),
).toMatchObject({
contextWindow: 131072,
compat: { supportsTools: true },
});
});
it("does not offer a pull when an installed Ollama model supports tools", async () => {
const prompter = {
...createLocalPrompter(),
confirm: vi.fn(),
} as unknown as WizardPrompter;
const fetchMock = createOllamaFetchMock({
tags: ["llama3:8b"],
capabilities: { "llama3:8b": ["tools"] },
});
vi.stubGlobal("fetch", fetchMock);
await promptAndConfigureOllama({ cfg: {}, prompter });
expect(prompter.confirm).not.toHaveBeenCalled();
expect(fetchMock.mock.calls.map((call) => requestUrl(call[0]))).not.toContain(
"http://127.0.0.1:11434/api/pull",
);
});
it("does not pull the recommended Ollama model when declined", async () => {
const prompter = {
...createLocalPrompter(),
confirm: vi.fn().mockResolvedValueOnce(false),
} as unknown as WizardPrompter;
const fetchMock = createOllamaFetchMock({
tags: ["llama3:8b"],
capabilities: { "llama3:8b": ["generate"] },
});
vi.stubGlobal("fetch", fetchMock);
await promptAndConfigureOllama({ cfg: {}, prompter });
expect(prompter.confirm).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls.map((call) => requestUrl(call[0]))).not.toContain(
"http://127.0.0.1:11434/api/pull",
);
});
it("does not offer a pull when installed-model capability inspection fails", async () => {
const prompter = {
...createLocalPrompter(),
confirm: vi.fn(),
} as unknown as WizardPrompter;
const baseFetch = createOllamaFetchMock({ tags: ["llama3:8b"] });
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
if (requestUrl(input).endsWith("/api/show")) {
return new Response("unavailable", { status: 503 });
}
return await baseFetch(input, init);
});
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({ cfg: {}, prompter });
expect(prompter.confirm).not.toHaveBeenCalled();
expect(prompter.note).toHaveBeenCalledWith(
expect.stringContaining("could not be inspected"),
"Ollama",
);
expect(result.config.models?.providers?.ollama).toBeDefined();
});
it("skips a broken model and continues setup when one inspection fails", async () => {
const prompter = {
...createLocalPrompter(),
confirm: vi.fn(),
} as unknown as WizardPrompter;
const baseFetch = createOllamaFetchMock({
tags: ["broken:20b", "gemma4:e4b"],
capabilities: { "gemma4:e4b": ["tools"] },
});
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
if (requestUrl(input).endsWith("/api/show")) {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
if (body.name === "broken:20b") {
return new Response("boom", { status: 500 });
}
}
return await baseFetch(input, init);
});
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({ cfg: {}, prompter });
expect(prompter.confirm).not.toHaveBeenCalled();
expect(prompter.note).toHaveBeenCalledWith(expect.stringContaining("broken:20b"), "Ollama");
expect(
result.config.models?.providers?.ollama?.models?.find((model) => model.id === "gemma4:e4b"),
).toMatchObject({ compat: { supportsTools: true } });
expect(
result.config.models?.providers?.ollama?.models?.find((model) => model.id === "broken:20b"),
).toMatchObject({ compat: { supportsTools: false } });
});
it("checks all installed Ollama models before offering a recommended pull", async () => {
const prompter = {
...createLocalPrompter(),
confirm: vi.fn(),
} as unknown as WizardPrompter;
const tags = Array.from({ length: 201 }, (_, index) => `model-${index}`);
const capabilities = Object.fromEntries(
tags.map((name, index) => [name, index === 200 ? ["tools"] : ["generate"]]),
);
const fetchMock = createOllamaFetchMock({ tags, capabilities });
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({ cfg: {}, prompter });
expect(prompter.confirm).not.toHaveBeenCalled();
expect(
fetchMock.mock.calls.filter((call) => requestUrl(call[0]).endsWith("/api/show")),
).toHaveLength(201);
expect(
result.config.models?.providers?.ollama?.models?.find((model) => model.id === "model-200"),
).toMatchObject({ compat: { supportsTools: true } });
});
it("aborts the exhaustive tools-capability scan with the setup session", async () => {
const controller = new AbortController();
const prompter = {
...createLocalPrompter(),
confirm: vi.fn(),
} as unknown as WizardPrompter;
const tags = Array.from({ length: 201 }, (_, index) => `model-${index}`);
const capabilities = Object.fromEntries(tags.map((name) => [name, ["generate"]]));
const baseFetch = createOllamaFetchMock({ tags, capabilities });
let markScanStarted!: () => void;
const scanStarted = new Promise<void>((resolve) => {
markScanStarted = resolve;
});
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const body = init?.body ? (JSON.parse(requestBodyText(init.body)) as { name?: string }) : {};
if (!requestUrl(input).endsWith("/api/show") || body.name !== "model-200") {
return await baseFetch(input, init);
}
markScanStarted();
return await new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
if (!signal) {
reject(new Error("expected tools scan abort signal"));
return;
}
signal.addEventListener("abort", () => reject(abortReasonAsError(signal)), { once: true });
});
});
vi.stubGlobal("fetch", fetchMock);
const setup = promptAndConfigureOllama({ cfg: {}, prompter, signal: controller.signal });
await scanStarted;
controller.abort();
await expect(setup).rejects.toMatchObject({ name: "AbortError" });
expect(prompter.confirm).not.toHaveBeenCalled();
});
it("aborts a recommended Ollama pull when the setup session is cancelled", async () => {
const controller = new AbortController();
const progress = { update: vi.fn(), stop: vi.fn() };
const prompter = {
select: vi.fn().mockResolvedValueOnce("local-only"),
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
confirm: vi.fn().mockResolvedValueOnce(true),
progress: vi.fn(() => progress),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const baseFetch = createOllamaFetchMock({
tags: ["llama3:8b"],
capabilities: { "llama3:8b": ["generate"] },
});
let markPullStarted!: () => void;
const pullStarted = new Promise<void>((resolve) => {
markPullStarted = resolve;
});
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
if (!requestUrl(input).endsWith("/api/pull")) {
return await baseFetch(input, init);
}
markPullStarted();
return await new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
if (!signal) {
reject(new Error("expected pull abort signal"));
return;
}
signal.addEventListener("abort", () => reject(abortReasonAsError(signal)), { once: true });
});
});
vi.stubGlobal("fetch", fetchMock);
const setup = promptAndConfigureOllama({ cfg: {}, prompter, signal: controller.signal });
await pullStarted;
controller.abort();
await expect(setup).rejects.toThrow("Failed to download recommended Ollama model");
expect(progress.stop).toHaveBeenCalledWith(expect.stringContaining("Failed to download"));
});
describe("ensureOllamaModelPulled", () => {
it("pulls model when not available locally", async () => {
vi.useFakeTimers();
try {
const progress = { update: vi.fn(), stop: vi.fn() };
const prompter = {
progress: vi.fn(() => progress),
} as unknown as WizardPrompter;
const fetchMock = createOllamaFetchMock({
tags: ["llama3:8b"],
pullResponse: new Response('{"status":"success"}\n', { status: 200 }),
});
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
config: createDefaultOllamaConfig("ollama/gemma4"),
model: "ollama/gemma4",
prompter,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(mockCallArg(fetchMock, 1)).toContain("/api/pull");
const pullInit = mockCallArg(fetchMock, 1, 1) as RequestInit | undefined;
expect(pullInit?.signal).toBeInstanceOf(AbortSignal);
expect(pullInit?.signal?.aborted).toBe(false);
await vi.advanceTimersByTimeAsync(30_000);
expect(pullInit?.signal?.aborted).toBe(false);
} finally {
vi.useRealTimers();
}
});
it("fails stalled model pull streams after an idle timeout", async () => {
vi.useFakeTimers();
try {
const progress = { update: vi.fn(), stop: vi.fn() };
const prompter = {
progress: vi.fn(() => progress),
} as unknown as WizardPrompter;
const fetchMock = vi.fn(async (input: string | URL | Request) => {
const url = requestUrl(input);
if (url.endsWith("/api/tags")) {
return jsonResponse({ models: [] });
}
if (url.endsWith("/api/pull")) {
return new Response(new ReadableStream<Uint8Array>(), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const pullPromise = ensureOllamaModelPulled({
config: createDefaultOllamaConfig("ollama/gemma4"),
model: "ollama/gemma4",
prompter,
}).catch((err: unknown) => err);
await vi.waitFor(() => expect(mockCallArg(fetchMock, 1)).toContain("/api/pull"));
await vi.advanceTimersByTimeAsync(300_000);
const pullError = await pullPromise;
expect(pullError).toBeInstanceOf(Error);
expect((pullError as Error).name).toBe("WizardCancelledError");
expect((pullError as Error).message).toBe("Failed to download selected Ollama model");
expect(progress.stop).toHaveBeenCalledWith(
"Failed to download gemma4: Ollama pull stalled: no data received for 300s",
);
} finally {
vi.useRealTimers();
}
});
it("skips pull when model is already available", async () => {
const prompter = {} as unknown as WizardPrompter;
const fetchMock = createOllamaFetchMock({ tags: ["gemma4"] });
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
config: createDefaultOllamaConfig("ollama/gemma4"),
model: "ollama/gemma4",
prompter,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("skips pull when an untagged model is available as latest", async () => {
const prompter = {} as unknown as WizardPrompter;
const fetchMock = createOllamaFetchMock({ tags: ["gemma4:latest"] });
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
config: createDefaultOllamaConfig("ollama/gemma4"),
model: "ollama/gemma4",
prompter,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("uses baseURL alias when checking and pulling models", async () => {
const progress = { update: vi.fn(), stop: vi.fn() };
const prompter = {
progress: vi.fn(() => progress),
} as unknown as WizardPrompter;
const fetchMock = createOllamaFetchMock({
tags: [],
pullResponse: new Response('{"status":"success"}\n', { status: 200 }),
});
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
config: {
agents: { defaults: { model: { primary: "ollama/gemma4" } } },
models: {
providers: {
ollama: {
baseURL: "http://127.0.0.1:11435",
models: [],
} as never,
},
},
},
model: "ollama/gemma4",
prompter,
});
expect(mockCallArg(fetchMock)).toBe("http://127.0.0.1:11435/api/tags");
expect(mockCallArg(fetchMock, 1)).toBe("http://127.0.0.1:11435/api/pull");
});
it("skips pull for cloud models", async () => {
const prompter = {} as unknown as WizardPrompter;
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
config: createDefaultOllamaConfig("ollama/kimi-k2.5:cloud"),
model: "ollama/kimi-k2.5:cloud",
prompter,
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("skips when model is not an ollama model", async () => {
const prompter = {} as unknown as WizardPrompter;
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
config: {
agents: { defaults: { model: { primary: "openai/gpt-4o" } } },
},
model: "openai/gpt-4o",
prompter,
});
expect(fetchMock).not.toHaveBeenCalled();
});
});
it("uses discovered model when requested non-interactive download fails", async () => {
const fetchMock = createOllamaFetchMock({
tags: ["qwen2.5-coder:7b"],
pullResponse: new Response('{"error":"disk full"}\n', { status: 200 }),
});
vi.stubGlobal("fetch", fetchMock);
const runtime = createRuntime();
const result = await configureOllamaNonInteractive({
nextConfig: {
agents: {
defaults: {
model: {
primary: "openai/gpt-4o-mini",
fallbacks: ["anthropic/claude-sonnet-4-5"],
},
},
},
},
opts: {
customBaseUrl: "http://127.0.0.1:11434",
customModelId: "missing-model",
},
runtime,
});
expect(runtime.error).toHaveBeenCalledWith("Download failed: disk full");
expect(result.agents?.defaults?.model).toEqual({
primary: "ollama/qwen2.5-coder:7b",
fallbacks: ["anthropic/claude-sonnet-4-5"],
});
expect(upsertAuthProfileWithLock).toHaveBeenCalledTimes(1);
});
it("normalizes ollama/ prefix in non-interactive custom model download", async () => {
const fetchMock = createOllamaFetchMock({
tags: [],
pullResponse: new Response('{"status":"success"}\n', { status: 200 }),
});
vi.stubGlobal("fetch", fetchMock);
const runtime = createRuntime();
const result = await configureOllamaNonInteractive({
nextConfig: {},
opts: {
customBaseUrl: "http://127.0.0.1:11434",
customModelId: "ollama/llama3.2:latest",
},
runtime,
});
const pullRequest = mockCallArg(fetchMock, 1, 1) as RequestInit | undefined;
expect(JSON.parse(requestBodyText(pullRequest?.body))).toEqual({ name: "llama3.2:latest" });
expect(result.agents?.defaults?.model).toEqual({ primary: "ollama/llama3.2:latest" });
expect(upsertAuthProfileWithLock).toHaveBeenCalledTimes(1);
});
it("uses the discovered latest tag as the non-interactive default without pulling", async () => {
const fetchMock = createOllamaFetchMock({ tags: ["gemma4:latest"] });
vi.stubGlobal("fetch", fetchMock);
const runtime = createRuntime();
const result = await configureOllamaNonInteractive({
nextConfig: {},
opts: {
customBaseUrl: "http://127.0.0.1:11434",
},
runtime,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
const requestUrls = fetchMock.mock.calls.map((call) => requestUrl(call[0]));
expect(requestUrls.filter((url) => url.endsWith("/api/pull"))).toEqual([]);
expect(result.models?.providers?.ollama?.models?.map((model) => model.id)).toEqual([
"gemma4:latest",
]);
expect(result.agents?.defaults?.model).toEqual({ primary: "ollama/gemma4:latest" });
expect(runtime.log).toHaveBeenCalledWith("Default Ollama model: gemma4:latest");
expect(upsertAuthProfileWithLock).toHaveBeenCalledTimes(1);
});
it.each(["kimi-k2.5:cloud", "gpt-oss:120b-cloud"])(
"accepts cloud model %s in non-interactive mode without pulling",
async (modelId) => {
const fetchMock = createOllamaFetchMock({ tags: [] });
vi.stubGlobal("fetch", fetchMock);
const runtime = createRuntime();
const result = await configureOllamaNonInteractive({
nextConfig: {},
opts: {
customBaseUrl: "http://127.0.0.1:11434",
customModelId: modelId,
},
runtime,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.models?.providers?.ollama?.models?.map((model) => model.id)).toContain(modelId);
expect(result.agents?.defaults?.model).toEqual({ primary: `ollama/${modelId}` });
},
);
it("exits when Ollama is unreachable", async () => {
const fetchMock = createOllamaFetchMock({
tagsError: new Error("connect ECONNREFUSED"),
});
vi.stubGlobal("fetch", fetchMock);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as unknown as RuntimeEnv;
const nextConfig = {};
const result = await configureOllamaNonInteractive({
nextConfig,
opts: {
customBaseUrl: "http://127.0.0.1:11435",
customModelId: "llama3.2:latest",
},
runtime,
});
expect(runtime.error).toHaveBeenCalledWith(
[
"Ollama could not be reached at http://127.0.0.1:11435.",
"Start or restart the Ollama server for this address.",
"If Ollama is not installed on that machine, download it at https://ollama.com/download",
].join("\n"),
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(result).toBe(nextConfig);
expect(upsertAuthProfileWithLock).not.toHaveBeenCalled();
});
});
describe("checkOllamaCloudAuth", () => {
afterEach(() => {
fetchWithSsrFGuardMock.mockClear();
});
it("bounds oversized 401 body and cancels the stream", async () => {
const chunk = new Uint8Array(1024 * 1024); // 1 MiB chunk
let readCount = 0;
let canceled = false;
// 64 chunks × 1 MiB = 64 MiB — exceeds the 16 MiB cap
const oversizedBody = new ReadableStream<Uint8Array>({
pull(controller) {
if (readCount >= 64) {
controller.close();
return;
}
readCount += 1;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(oversizedBody, {
status: 401,
headers: { "Content-Type": "application/json" },
}),
finalUrl: "https://ollama.com/api/me",
release: async () => {},
});
await expect(checkOllamaCloudAuth("https://ollama.com")).resolves.toEqual({
signedIn: false,
signinUrl: undefined,
});
// Stream must be cancelled before all 64 MiB are consumed
expect(readCount).toBeLessThan(64);
expect(canceled).toBe(true);
});
});