diff --git a/extensions/ollama/provider-discovery.test.ts b/extensions/ollama/provider-discovery.test.ts index c8ba8eb0134c..5e66ddb39617 100644 --- a/extensions/ollama/provider-discovery.test.ts +++ b/extensions/ollama/provider-discovery.test.ts @@ -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 } }); } } diff --git a/extensions/ollama/src/node-inference.paired-node.e2e.test.ts b/extensions/ollama/src/node-inference.paired-node.e2e.test.ts index e412a9885d81..acebd2d8873c 100644 --- a/extensions/ollama/src/node-inference.paired-node.e2e.test.ts +++ b/extensions/ollama/src/node-inference.paired-node.e2e.test.ts @@ -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" })); diff --git a/extensions/ollama/src/node-inference.test.ts b/extensions/ollama/src/node-inference.test.ts index e40b81d26747..ba5d5668951a 100644 --- a/extensions/ollama/src/node-inference.test.ts +++ b/extensions/ollama/src/node-inference.test.ts @@ -79,16 +79,16 @@ async function withOllamaServer( 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"], diff --git a/extensions/ollama/src/provider-models.test.ts b/extensions/ollama/src/provider-models.test.ts index 3be6e9e3d436..9e3d77c60bd6 100644 --- a/extensions/ollama/src/provider-models.test.ts +++ b/extensions/ollama/src/provider-models.test.ts @@ -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"], diff --git a/extensions/ollama/src/provider-models.ts b/extensions/ollama/src/provider-models.ts index b282c56ac214..c1a392e527a1 100644 --- a/extensions/ollama/src/provider-models.ts +++ b/extensions/ollama/src/provider-models.ts @@ -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), diff --git a/extensions/ollama/src/setup-pull.test.ts b/extensions/ollama/src/setup-pull.test.ts index 62547b12492b..025fc04476b5 100644 --- a/extensions/ollama/src/setup-pull.test.ts +++ b/extensions/ollama/src/setup-pull.test.ts @@ -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( diff --git a/extensions/ollama/src/setup-pull.ts b/extensions/ollama/src/setup-pull.ts index 81e8389b3768..b5c8b017187f 100644 --- a/extensions/ollama/src/setup-pull.ts +++ b/extensions/ollama/src/setup-pull.ts @@ -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(); - 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); } diff --git a/extensions/ollama/src/setup.non-interactive-auth.test.ts b/extensions/ollama/src/setup.non-interactive-auth.test.ts index c732b9d8f012..5569cfcd5858 100644 --- a/extensions/ollama/src/setup.non-interactive-auth.test.ts +++ b/extensions/ollama/src/setup.non-interactive-auth.test.ts @@ -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); }); diff --git a/extensions/ollama/src/setup.test.ts b/extensions/ollama/src/setup.test.ts index 103cd3e95e94..96e0a210db55 100644 --- a/extensions/ollama/src/setup.test.ts +++ b/extensions/ollama/src/setup.test.ts @@ -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); });