fix(ollama): share model context discovery

This commit is contained in:
Peter Steinberger
2026-03-11 20:08:35 +00:00
parent 9329a0ab24
commit 620bae4ec7
5 changed files with 273 additions and 117 deletions

View File

@@ -30,6 +30,53 @@ function jsonResponse(body: unknown, status = 200): Response {
});
}
function requestUrl(input: string | URL | Request): string {
if (typeof input === "string") {
return input;
}
if (input instanceof URL) {
return input.toString();
}
return input.url;
}
function requestBody(body: BodyInit | null | undefined): string {
return typeof body === "string" ? body : "{}";
}
function createOllamaFetchMock(params: {
tags?: string[];
show?: Record<string, number | undefined>;
meResponses?: Response[];
pullResponse?: Response;
tagsError?: Error;
}) {
const meResponses = [...(params.meResponses ?? [])];
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(requestBody(init?.body)) as { name?: string };
const contextWindow = body.name ? params.show?.[body.name] : undefined;
return contextWindow
? jsonResponse({ model_info: { "llama.context_length": contextWindow } })
: jsonResponse({});
}
if (url.endsWith("/api/me")) {
return meResponses.shift() ?? jsonResponse({ username: "testuser" });
}
if (url.endsWith("/api/pull")) {
return params.pullResponse ?? new Response('{"status":"success"}\n', { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
});
}
describe("ollama setup", () => {
afterEach(() => {
vi.unstubAllGlobals();
@@ -45,9 +92,7 @@ describe("ollama setup", () => {
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ models: [{ name: "llama3:8b" }] }));
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] });
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({ cfg: {}, prompter });
@@ -62,10 +107,7 @@ describe("ollama setup", () => {
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ models: [{ name: "llama3:8b" }] }))
.mockResolvedValueOnce(jsonResponse({ username: "testuser" }));
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] });
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({ cfg: {}, prompter });
@@ -80,11 +122,7 @@ describe("ollama setup", () => {
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
jsonResponse({ models: [{ name: "llama3:8b" }, { name: "glm-4.7-flash" }] }),
);
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b", "glm-4.7-flash"] });
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({ cfg: {}, prompter });
@@ -103,13 +141,13 @@ describe("ollama setup", () => {
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ models: [{ name: "llama3:8b" }] }))
.mockResolvedValueOnce(
const fetchMock = createOllamaFetchMock({
tags: ["llama3:8b"],
meResponses: [
jsonResponse({ error: "not signed in", signin_url: "https://ollama.com/signin" }, 401),
)
.mockResolvedValueOnce(jsonResponse({ username: "testuser" }));
jsonResponse({ username: "testuser" }),
],
});
vi.stubGlobal("fetch", fetchMock);
await promptAndConfigureOllama({ cfg: {}, prompter });
@@ -127,13 +165,13 @@ describe("ollama setup", () => {
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ models: [{ name: "llama3:8b" }] }))
.mockResolvedValueOnce(
const fetchMock = createOllamaFetchMock({
tags: ["llama3:8b"],
meResponses: [
jsonResponse({ error: "not signed in", signin_url: "https://ollama.com/signin" }, 401),
)
.mockResolvedValueOnce(jsonResponse({ username: "testuser" }));
jsonResponse({ username: "testuser" }),
],
});
vi.stubGlobal("fetch", fetchMock);
await promptAndConfigureOllama({ cfg: {}, prompter });
@@ -148,15 +186,16 @@ describe("ollama setup", () => {
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ models: [{ name: "llama3:8b" }] }));
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] });
vi.stubGlobal("fetch", fetchMock);
await promptAndConfigureOllama({ cfg: {}, prompter });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toContain("/api/tags");
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0]?.[0]).toContain("/api/tags");
expect(fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("/api/me"))).toBe(
false,
);
});
it("suggested models appear first in model list (cloud+local)", async () => {
@@ -166,14 +205,9 @@ describe("ollama setup", () => {
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
jsonResponse({
models: [{ name: "llama3:8b" }, { name: "glm-4.7-flash" }, { name: "deepseek-r1:14b" }],
}),
)
.mockResolvedValueOnce(jsonResponse({ username: "testuser" }));
const fetchMock = createOllamaFetchMock({
tags: ["llama3:8b", "glm-4.7-flash", "deepseek-r1:14b"],
});
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({ cfg: {}, prompter });
@@ -189,6 +223,27 @@ describe("ollama setup", () => {
]);
});
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"),
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);
});
describe("ensureOllamaModelPulled", () => {
it("pulls model when not available locally", async () => {
const progress = { update: vi.fn(), stop: vi.fn() };
@@ -196,12 +251,10 @@ describe("ollama setup", () => {
progress: vi.fn(() => progress),
} as unknown as WizardPrompter;
const fetchMock = vi
.fn()
// /api/tags — model not present
.mockResolvedValueOnce(jsonResponse({ models: [{ name: "llama3:8b" }] }))
// /api/pull
.mockResolvedValueOnce(new Response('{"status":"success"}\n', { status: 200 }));
const fetchMock = createOllamaFetchMock({
tags: ["llama3:8b"],
pullResponse: new Response('{"status":"success"}\n', { status: 200 }),
});
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
@@ -219,9 +272,7 @@ describe("ollama setup", () => {
it("skips pull when model is already available", async () => {
const prompter = {} as unknown as WizardPrompter;
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ models: [{ name: "glm-4.7-flash" }] }));
const fetchMock = createOllamaFetchMock({ tags: ["glm-4.7-flash"] });
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
@@ -268,10 +319,10 @@ describe("ollama setup", () => {
});
it("uses discovered model when requested non-interactive download fails", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ models: [{ name: "qwen2.5-coder:7b" }] }))
.mockResolvedValueOnce(new Response('{"error":"disk full"}\n', { status: 200 }));
const fetchMock = createOllamaFetchMock({
tags: ["qwen2.5-coder:7b"],
pullResponse: new Response('{"error":"disk full"}\n', { status: 200 }),
});
vi.stubGlobal("fetch", fetchMock);
const runtime = {
@@ -306,10 +357,10 @@ describe("ollama setup", () => {
});
it("normalizes ollama/ prefix in non-interactive custom model download", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ models: [] }))
.mockResolvedValueOnce(new Response('{"status":"success"}\n', { status: 200 }));
const fetchMock = createOllamaFetchMock({
tags: [],
pullResponse: new Response('{"status":"success"}\n', { status: 200 }),
});
vi.stubGlobal("fetch", fetchMock);
const runtime = {
@@ -328,14 +379,14 @@ describe("ollama setup", () => {
});
const pullRequest = fetchMock.mock.calls[1]?.[1];
expect(JSON.parse(String(pullRequest?.body))).toEqual({ name: "llama3.2:latest" });
expect(JSON.parse(requestBody(pullRequest?.body))).toEqual({ name: "llama3.2:latest" });
expect(result.agents?.defaults?.model).toEqual(
expect.objectContaining({ primary: "ollama/llama3.2:latest" }),
);
});
it("accepts cloud models in non-interactive mode without pulling", async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse({ models: [] }));
const fetchMock = createOllamaFetchMock({ tags: [] });
vi.stubGlobal("fetch", fetchMock);
const runtime = {
@@ -363,7 +414,9 @@ describe("ollama setup", () => {
});
it("exits when Ollama is unreachable", async () => {
const fetchMock = vi.fn().mockRejectedValueOnce(new Error("connect ECONNREFUSED"));
const fetchMock = createOllamaFetchMock({
tagsError: new Error("connect ECONNREFUSED"),
});
vi.stubGlobal("fetch", fetchMock);
const runtime = {

View File

@@ -2,8 +2,10 @@ import { upsertAuthProfileWithLock } from "../agents/auth-profiles.js";
import {
OLLAMA_DEFAULT_BASE_URL,
buildOllamaModelDefinition,
enrichOllamaModelsWithContext,
fetchOllamaModels,
resolveOllamaApiBase,
type OllamaModelWithContext,
} from "../agents/ollama-models.js";
import type { OpenClawConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -239,14 +241,20 @@ async function pullOllamaModelNonInteractive(
return true;
}
function buildOllamaModelsConfig(modelNames: string[]) {
return modelNames.map((name) => buildOllamaModelDefinition(name));
function buildOllamaModelsConfig(
modelNames: string[],
discoveredModelsByName?: Map<string, OllamaModelWithContext>,
) {
return modelNames.map((name) =>
buildOllamaModelDefinition(name, discoveredModelsByName?.get(name)?.contextWindow),
);
}
function applyOllamaProviderConfig(
cfg: OpenClawConfig,
baseUrl: string,
modelNames: string[],
discoveredModelsByName?: Map<string, OllamaModelWithContext>,
): OpenClawConfig {
return {
...cfg,
@@ -259,7 +267,7 @@ function applyOllamaProviderConfig(
baseUrl,
api: "ollama",
apiKey: "OLLAMA_API_KEY", // pragma: allowlist secret
models: buildOllamaModelsConfig(modelNames),
models: buildOllamaModelsConfig(modelNames, discoveredModelsByName),
},
},
},
@@ -299,7 +307,6 @@ export async function promptAndConfigureOllama(params: {
// 2. Check reachability
const { reachable, models } = await fetchOllamaModels(baseUrl);
const modelNames = models.map((m) => m.name);
if (!reachable) {
await prompter.note(
@@ -314,6 +321,10 @@ export async function promptAndConfigureOllama(params: {
throw new WizardCancelledError("Ollama not reachable");
}
const enrichedModels = await enrichOllamaModelsWithContext(baseUrl, models.slice(0, 50));
const discoveredModelsByName = new Map(enrichedModels.map((model) => [model.name, model]));
const modelNames = models.map((m) => m.name);
// 3. Mode selection
const mode = (await prompter.select({
message: "Ollama mode",
@@ -387,7 +398,12 @@ export async function promptAndConfigureOllama(params: {
await storeOllamaCredential(params.agentDir);
const defaultModelId = suggestedModels[0] ?? OLLAMA_DEFAULT_MODEL;
const config = applyOllamaProviderConfig(params.cfg, baseUrl, orderedModelNames);
const config = applyOllamaProviderConfig(
params.cfg,
baseUrl,
orderedModelNames,
discoveredModelsByName,
);
return { config, defaultModelId };
}
@@ -405,7 +421,6 @@ export async function configureOllamaNonInteractive(params: {
const baseUrl = resolveOllamaApiBase(configuredBaseUrl);
const { reachable, models } = await fetchOllamaModels(baseUrl);
const modelNames = models.map((m) => m.name);
const explicitModel = normalizeOllamaModelName(opts.customModelId);
if (!reachable) {
@@ -421,6 +436,10 @@ export async function configureOllamaNonInteractive(params: {
await storeOllamaCredential();
const enrichedModels = await enrichOllamaModelsWithContext(baseUrl, models.slice(0, 50));
const discoveredModelsByName = new Map(enrichedModels.map((model) => [model.name, model]));
const modelNames = models.map((m) => m.name);
// Apply local suggested model ordering.
const suggestedModels = OLLAMA_SUGGESTED_MODELS_LOCAL;
const orderedModelNames = [
@@ -478,7 +497,12 @@ export async function configureOllamaNonInteractive(params: {
}
}
const config = applyOllamaProviderConfig(params.nextConfig, baseUrl, allModelNames);
const config = applyOllamaProviderConfig(
params.nextConfig,
baseUrl,
allModelNames,
discoveredModelsByName,
);
const modelRef = `ollama/${defaultModelId}`;
runtime.log(`Default Ollama model: ${defaultModelId}`);
return applyAgentDefaultModelPrimary(config, modelRef);