fix(ollama): honor model requests and pull completion contracts (#117171)

Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
Peter Steinberger
2026-07-31 20:19:57 -07:00
committed by GitHub
parent 5d97721564
commit 865a7286c8
9 changed files with 155 additions and 61 deletions
+3 -3
View File
@@ -215,11 +215,11 @@ describe("Ollama provider", () => {
if (url.endsWith("/api/show")) {
const rawBody = init?.body;
const bodyText = typeof rawBody === "string" ? rawBody : "{}";
const parsed = JSON.parse(bodyText) as { name?: string };
if (parsed.name === "qwen3:32b") {
const parsed = JSON.parse(bodyText) as { model?: string };
if (parsed.model === "qwen3:32b") {
return jsonResponse({ model_info: { "qwen3.context_length": 131072 } });
}
if (parsed.name === "llama3.3:70b") {
if (parsed.model === "llama3.3:70b") {
return jsonResponse({ model_info: { "llama.context_length": 65536 } });
}
}
@@ -607,13 +607,7 @@ async function handleFakeOllamaRequest(
}
if (requestPath === "/api/show") {
const body = await readRequestJson(request);
// Ollama documents `model`; the current provider sends its supported `name` alias.
const modelName =
typeof body.model === "string"
? body.model
: typeof body.name === "string"
? body.name
: undefined;
const modelName = typeof body.model === "string" ? body.model : undefined;
if (!modelName) {
response.statusCode = 400;
response.end(JSON.stringify({ error: "model is required" }));
+5 -5
View File
@@ -79,16 +79,16 @@ async function withOllamaServer<T>(
return;
}
if (request.url === "/api/show") {
const body = (await readBody(request)) as { name?: string };
if (body.name) {
showRequests.push(body.name);
const body = (await readBody(request)) as { model?: string };
if (body.model) {
showRequests.push(body.model);
}
if (body.name === "unknown:latest") {
if (body.model === "unknown:latest") {
response.statusCode = 500;
response.end(JSON.stringify({ error: "show failed" }));
return;
}
const embedding = body.name?.startsWith("embedding") === true;
const embedding = body.model?.startsWith("embedding") === true;
response.end(
JSON.stringify({
capabilities: embedding ? ["embedding"] : ["completion", "tools"],
+20 -8
View File
@@ -50,6 +50,18 @@ describe("ollama provider models", () => {
expect(resolveOllamaApiBase("http://127.0.0.1:11434///")).toBe("http://127.0.0.1:11434");
});
it("inspects local models using Ollama's canonical model request field", async () => {
const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) =>
jsonResponse({ model_info: {} }),
);
vi.stubGlobal("fetch", fetchMock);
await readOllamaModelShowInfo("http://127.0.0.1:11434", "gemma4:e2b");
const request = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
expect(JSON.parse(requestBodyText(request?.body))).toEqual({ model: "gemma4:e2b" });
});
it("caps local discovered runtime context while preserving native metadata", () => {
const provider = capLocalOllamaProviderContext({
api: "ollama",
@@ -93,8 +105,8 @@ describe("ollama provider models", () => {
if (!url.endsWith("/api/show")) {
throw new Error(`Unexpected fetch: ${url}`);
}
const body = JSON.parse(requestBodyText(init?.body)) as { name?: string };
if (body.name === "llama3:8b") {
const body = JSON.parse(requestBodyText(init?.body)) as { model?: string };
if (body.model === "llama3:8b") {
return jsonResponse({ model_info: { "llama.context_length": 65536 } });
}
return jsonResponse({});
@@ -161,8 +173,8 @@ describe("ollama provider models", () => {
});
}
if (url.endsWith("/api/show")) {
const body = JSON.parse(requestBodyText(init?.body)) as { name?: string };
const completion = body.name === "qwen-chat:latest";
const body = JSON.parse(requestBodyText(init?.body)) as { model?: string };
const completion = body.model === "qwen-chat:latest";
return jsonResponse({
capabilities: completion ? ["completion", "tools"] : ["embedding"],
model_info: completion ? { "qwen.context_length": 32_768 } : {},
@@ -275,14 +287,14 @@ describe("ollama provider models", () => {
if (!url.endsWith("/api/show")) {
throw new Error(`Unexpected fetch: ${url}`);
}
const body = JSON.parse(requestBodyText(init?.body)) as { name?: string };
if (body.name === "kimi-k2.5:cloud") {
const body = JSON.parse(requestBodyText(init?.body)) as { model?: string };
if (body.model === "kimi-k2.5:cloud") {
return jsonResponse({
model_info: { "kimi-k2.context_length": 262144 },
capabilities: ["vision", "thinking", "completion", "tools"],
});
}
if (body.name === "glm-5.1:cloud") {
if (body.model === "glm-5.1:cloud") {
return jsonResponse({
model_info: { "glm5.context_length": 202752 },
capabilities: ["thinking", "completion", "tools"],
@@ -409,7 +421,7 @@ describe("ollama provider models", () => {
const model: OllamaTagModel = { name: "qwen3:32b", digest: "sha256:normalized-base" };
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
expect(requestUrl(input)).toBe("http://127.0.0.1:11434/api/show");
expect(JSON.parse(requestBodyText(init?.body))).toEqual({ name: "qwen3:32b" });
expect(JSON.parse(requestBodyText(init?.body))).toEqual({ model: "qwen3:32b" });
return jsonResponse({
model_info: { "qwen3.context_length": 131072 },
capabilities: ["thinking", "tools"],
+1 -1
View File
@@ -172,7 +172,7 @@ export async function readOllamaModelShowInfo(
init: {
method: "POST",
headers,
body: JSON.stringify({ name: modelName }),
body: JSON.stringify({ model: modelName }),
},
// Guard-owned timeoutMs also bounds DNS/proxy preflight; init.signal does not.
timeoutMs: Math.min(opts?.timeoutMs ?? OLLAMA_SHOW_TIMEOUT_MS, OLLAMA_SHOW_TIMEOUT_MS),
+76 -1
View File
@@ -1,6 +1,6 @@
import type { WizardPrompter } from "openclaw/plugin-sdk/setup";
import { afterEach, describe, expect, it, vi } from "vitest";
import { pullOllamaModel } from "./setup-pull.js";
import { pullOllamaModel, pullOllamaModelNonInteractive } from "./setup-pull.js";
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
@@ -17,6 +17,81 @@ describe("Ollama onboarding model pulls", () => {
fetchWithSsrFGuardMock.mockReset();
});
it("uses the canonical Ollama model request field and requires its success terminal", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response('{"status":"pulling manifest"}\n{"status":"success"}\n'),
release,
});
const progress = { update: vi.fn(), stop: vi.fn() };
const prompter = { progress: vi.fn(() => progress) } as unknown as WizardPrompter;
await expect(pullOllamaModel("http://127.0.0.1:11434", "gemma4:e2b", prompter)).resolves.toBe(
true,
);
const request = fetchWithSsrFGuardMock.mock.calls[0]?.[0] as { init?: { body?: string } };
expect(JSON.parse(request.init?.body ?? "null")).toEqual({ model: "gemma4:e2b" });
expect(progress.stop).toHaveBeenCalledWith("Downloaded gemma4:e2b");
expect(release).toHaveBeenCalledOnce();
});
it.each([
{ label: "empty response", body: "" },
{ label: "interrupted manifest download", body: '{"status":"pulling manifest"}\n' },
{
label: "interrupted model layer",
body: '{"status":"pulling abcdef123456","total":100,"completed":40}\n',
},
{ label: "malformed stream", body: "not valid json\n" },
{ label: "incomplete trailing record", body: '{"status":"success"' },
])("does not report a completed model pull for an $label", async ({ body }) => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValue({ response: new Response(body), release });
const progress = { update: vi.fn(), stop: vi.fn() };
const prompter = { progress: vi.fn(() => progress) } as unknown as WizardPrompter;
await expect(pullOllamaModel("http://127.0.0.1:11434", "gemma4:e2b", prompter)).resolves.toBe(
false,
);
expect(progress.stop).toHaveBeenCalledWith(
"Failed to download gemma4:e2b: pull stream ended before success",
);
expect(release).toHaveBeenCalledOnce();
});
it("accepts a final success record without a trailing newline", async () => {
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response('{"status":"success"}'),
release: vi.fn(async () => {}),
});
const progress = { update: vi.fn(), stop: vi.fn() };
const prompter = { progress: vi.fn(() => progress) } as unknown as WizardPrompter;
await expect(pullOllamaModel("http://127.0.0.1:11434", "gemma4:e2b", prompter)).resolves.toBe(
true,
);
expect(progress.stop).toHaveBeenCalledWith("Downloaded gemma4:e2b");
});
it("reports interrupted pulls as failures during non-interactive setup", async () => {
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response('{"status":"pulling manifest"}\n'),
release: vi.fn(async () => {}),
});
const runtime = { log: vi.fn(), error: vi.fn() };
await expect(
pullOllamaModelNonInteractive("http://127.0.0.1:11434", "gemma4:e2b", runtime as never),
).resolves.toBe(false);
expect(runtime.error).toHaveBeenCalledWith(
"Failed to download gemma4:e2b: pull stream ended before success",
);
expect(runtime.log).not.toHaveBeenCalledWith("Downloaded gemma4:e2b");
});
it("coerces non-Error stream failures through the shared error contract", async () => {
const release = vi.fn(async () => {});
const response = new Response(
+20 -20
View File
@@ -68,7 +68,7 @@ async function pullOllamaModelCore(params: {
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: modelName }),
body: JSON.stringify({ model: modelName }),
},
signal: params.signal
? AbortSignal.any([responseController.signal, params.signal])
@@ -92,28 +92,25 @@ async function pullOllamaModelCore(params: {
let pendingRecordBytes = 0;
const layers = new Map<string, { total: number; completed: number }>();
const parseLine = (line: string): OllamaPullResult => {
const trimmed = line.trim();
if (!trimmed) {
return { ok: true };
const parseLine = (line: string): OllamaPullResult | undefined => {
if (!line.trim()) {
return undefined;
}
try {
const chunk = JSON.parse(trimmed) as OllamaPullChunk;
const chunk = JSON.parse(line) as OllamaPullChunk;
if (chunk.error) {
return { ok: false, message: `Download failed: ${chunk.error}` };
}
if (!chunk.status) {
return { ok: true };
if (!chunk.status || chunk.status === "success") {
return chunk.status ? { ok: true } : undefined;
}
if (chunk.total && chunk.completed !== undefined) {
layers.set(chunk.status, { total: chunk.total, completed: chunk.completed });
const totals = [...layers.values()].reduce(
(sum, layer) => ({
total: sum.total + layer.total,
completed: sum.completed + layer.completed,
}),
{ total: 0, completed: 0 },
);
const totals = { total: 0, completed: 0 };
for (const layer of layers.values()) {
totals.total += layer.total;
totals.completed += layer.completed;
}
params.onStatus?.(
chunk.status,
totals.total > 0 ? Math.round((totals.completed / totals.total) * 100) : null,
@@ -124,14 +121,18 @@ async function pullOllamaModelCore(params: {
} catch {
// Ignore malformed streaming lines from Ollama.
}
return { ok: true };
return undefined;
};
try {
for (;;) {
const { done, value } = await readOllamaPullChunkWithIdleTimeout(reader);
if (done) {
return parseLine(buffer);
const terminal = parseLine(buffer);
if (terminal) {
return terminal;
}
throw new Error("pull stream ended before success");
}
pendingRecordBytes = checkNdjsonRecordCap(value, pendingRecordBytes);
buffer += decoder.decode(value, { stream: true });
@@ -139,7 +140,7 @@ async function pullOllamaModelCore(params: {
buffer = lines.pop() ?? "";
for (const line of lines) {
const parsed = parseLine(line);
if (!parsed.ok) {
if (parsed) {
return parsed;
}
}
@@ -154,8 +155,7 @@ async function pullOllamaModelCore(params: {
await release();
}
} catch (err) {
const reason = formatErrorMessage(err);
return { ok: false, message: `Failed to download ${modelName}: ${reason}` };
return { ok: false, message: `Failed to download ${modelName}: ${formatErrorMessage(err)}` };
} finally {
clearTimeout(responseTimeout);
}
@@ -44,9 +44,11 @@ function createOllamaFetchMock(params: {
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?.[body.name] ?? ["tools"]) : ["tools"];
const body = JSON.parse(requestBodyText(init?.body)) as { model?: string };
const contextWindow = body.model ? params.show?.[body.model] : undefined;
const capabilities = body.model
? (params.capabilities?.[body.model] ?? ["tools"])
: ["tools"];
return jsonResponse({
...(contextWindow ? { model_info: { "llama.context_length": contextWindow } } : {}),
capabilities,
@@ -73,10 +75,21 @@ describe("Ollama non-interactive onboarding", () => {
upsertAuthProfileWithLock.mockClear();
});
it("does not persist local auth when non-interactive setup cannot select a model", async () => {
it.each([
{
label: "Ollama reports a pull failure",
body: '{"error":"disk full"}\n',
error: "Download failed: disk full",
},
{
label: "the model pull ends before success",
body: '{"status":"pulling manifest"}\n',
error: "Failed to download missing-model: pull stream ended before success",
},
])("does not persist unavailable local models when $label", async ({ body, error }) => {
const fetchMock = createOllamaFetchMock({
tags: [],
pullResponse: new Response('{"error":"disk full"}\n', { status: 200 }),
pullResponse: new Response(body, { status: 200 }),
});
vi.stubGlobal("fetch", fetchMock);
const runtime = createRuntime();
@@ -91,7 +104,7 @@ describe("Ollama non-interactive onboarding", () => {
runtime,
});
expect(runtime.error).toHaveBeenCalledWith("Download failed: disk full");
expect(runtime.error).toHaveBeenCalledWith(error);
expect(runtime.error).toHaveBeenCalledWith(
[
"No Ollama models are available at http://127.0.0.1:11434.",
@@ -186,7 +199,7 @@ describe("Ollama non-interactive onboarding", () => {
return false;
}
const init = call[1] as RequestInit | undefined;
return JSON.parse(requestBodyText(init?.body)).name === modelId;
return JSON.parse(requestBodyText(init?.body)).model === modelId;
}),
).toHaveLength(1);
});
+9 -9
View File
@@ -56,12 +56,12 @@ function createOllamaFetchMock(params: {
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
const body = JSON.parse(requestBodyText(init?.body)) as { model?: string };
const contextWindow = body.model ? params.show?.[body.model] : undefined;
const capabilities = body.model
? params.capabilities === undefined
? ["tools"]
: params.capabilities[body.name]
: params.capabilities[body.model]
: undefined;
return jsonResponse({
...(contextWindow ? { model_info: { "llama.context_length": contextWindow } } : {}),
@@ -569,7 +569,7 @@ describe("ollama setup", () => {
});
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(JSON.parse(requestBodyText(pullCall?.[1]?.body))).toEqual({ model: "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(
@@ -657,7 +657,7 @@ describe("ollama setup", () => {
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") {
if (body.model === "broken:20b") {
return new Response("boom", { status: 500 });
}
}
@@ -714,8 +714,8 @@ describe("ollama setup", () => {
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") {
const body = init?.body ? (JSON.parse(requestBodyText(init.body)) as { model?: string }) : {};
if (!requestUrl(input).endsWith("/api/show") || body.model !== "model-200") {
return await baseFetch(input, init);
}
markScanStarted();
@@ -999,7 +999,7 @@ describe("ollama setup", () => {
});
const pullRequest = mockCallArg(fetchMock, 1, 1) as RequestInit | undefined;
expect(JSON.parse(requestBodyText(pullRequest?.body))).toEqual({ name: "llama3.2:latest" });
expect(JSON.parse(requestBodyText(pullRequest?.body))).toEqual({ model: "llama3.2:latest" });
expect(result.agents?.defaults?.model).toEqual({ primary: "ollama/llama3.2:latest" });
expect(upsertAuthProfileWithLock).toHaveBeenCalledTimes(1);
});