mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 09:01:33 +00:00
1131 lines
38 KiB
TypeScript
1131 lines
38 KiB
TypeScript
// 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);
|
||
});
|
||
});
|