mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ollama): honor model requests and pull completion contracts (#117171)
Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
committed by
GitHub
parent
5d97721564
commit
865a7286c8
@@ -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" }));
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user