diff --git a/extensions/ollama/src/setup.non-interactive-auth.test.ts b/extensions/ollama/src/setup.non-interactive-auth.test.ts index da72116cc86c..c732b9d8f012 100644 --- a/extensions/ollama/src/setup.non-interactive-auth.test.ts +++ b/extensions/ollama/src/setup.non-interactive-auth.test.ts @@ -1,5 +1,5 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { jsonResponse, requestUrl } from "openclaw/plugin-sdk/test-env"; +import { jsonResponse, requestBodyText, requestUrl } from "openclaw/plugin-sdk/test-env"; import { afterEach, describe, expect, it, vi } from "vitest"; import { configureOllamaNonInteractive } from "./setup.js"; @@ -32,14 +32,25 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { }; }); -function createOllamaFetchMock(params: { tags: string[]; pullResponse?: Response }) { - return vi.fn(async (input: string | URL | Request) => { +function createOllamaFetchMock(params: { + tags: string[]; + show?: Record; + capabilities?: Record; + pullResponse?: Response; +}) { + return vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = requestUrl(input); if (url.endsWith("/api/tags")) { return jsonResponse({ models: params.tags.map((name) => ({ name })) }); } if (url.endsWith("/api/show")) { - return jsonResponse({ capabilities: ["tools"] }); + 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"]; + return jsonResponse({ + ...(contextWindow ? { model_info: { "llama.context_length": contextWindow } } : {}), + capabilities, + }); } if (url.endsWith("/api/pull")) { return params.pullResponse ?? new Response('{"status":"success"}\n', { status: 200 }); @@ -56,7 +67,7 @@ function createRuntime(): RuntimeEnv { } as unknown as RuntimeEnv; } -describe("Ollama non-interactive onboarding auth", () => { +describe("Ollama non-interactive onboarding", () => { afterEach(() => { vi.unstubAllGlobals(); upsertAuthProfileWithLock.mockClear(); @@ -140,4 +151,79 @@ describe("Ollama non-interactive onboarding auth", () => { ); expect(upsertAuthProfileWithLock).toHaveBeenCalledTimes(1); }); + + it("preserves the capabilities of an explicitly selected model beyond the discovery limit", async () => { + const modelId = "gemma4:e2b"; + const fetchMock = createOllamaFetchMock({ + tags: [...Array.from({ length: 200 }, (_, index) => `other-${index}`), modelId], + show: { [modelId]: 131_072 }, + capabilities: { [modelId]: ["completion", "tools", "vision", "thinking"] }, + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await configureOllamaNonInteractive({ + nextConfig: {}, + opts: { + customBaseUrl: "http://127.0.0.1:11434", + customModelId: modelId, + }, + runtime: createRuntime(), + }); + + expect(result.agents?.defaults?.model).toEqual({ primary: `ollama/${modelId}` }); + expect( + result.models?.providers?.ollama?.models?.find((model) => model.id === modelId), + ).toMatchObject({ + id: modelId, + input: ["text", "image"], + reasoning: true, + contextWindow: 131_072, + compat: { supportsTools: true }, + }); + expect( + fetchMock.mock.calls.filter((call) => { + if (!requestUrl(call[0]).endsWith("/api/show")) { + return false; + } + const init = call[1] as RequestInit | undefined; + return JSON.parse(requestBodyText(init?.body)).name === modelId; + }), + ).toHaveLength(1); + }); + + it("preserves the discovered capabilities of a newly pulled selected model", async () => { + const modelId = "gemma4:e2b"; + const fetchMock = createOllamaFetchMock({ + tags: [], + show: { [modelId]: 131_072 }, + capabilities: { [modelId]: ["completion", "tools", "vision", "thinking"] }, + pullResponse: new Response('{"status":"success"}\n', { status: 200 }), + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await configureOllamaNonInteractive({ + nextConfig: {}, + opts: { + customBaseUrl: "http://127.0.0.1:11434", + customModelId: modelId, + }, + runtime: createRuntime(), + }); + + expect(result.agents?.defaults?.model).toEqual({ primary: `ollama/${modelId}` }); + expect( + result.models?.providers?.ollama?.models?.find((model) => model.id === modelId), + ).toMatchObject({ + id: modelId, + input: ["text", "image"], + reasoning: true, + contextWindow: 131_072, + compat: { supportsTools: true }, + }); + 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/pull", + "http://127.0.0.1:11434/api/show", + ]); + }); }); diff --git a/extensions/ollama/src/setup.ts b/extensions/ollama/src/setup.ts index 57ee88082244..dde21dbb8e03 100644 --- a/extensions/ollama/src/setup.ts +++ b/extensions/ollama/src/setup.ts @@ -911,6 +911,19 @@ export async function configureOllamaNonInteractive(params: { ); } + if (!requestedCloudModel && !discoveredModelsByName.has(defaultModelId)) { + // Explicit and newly pulled models can fall outside the bounded catalog scan. + const selectedModel = expectDefined( + ( + await enrichOllamaModelsWithContext(baseUrl, [ + models.find((model) => model.name === defaultModelId) ?? { name: defaultModelId }, + ]) + )[0], + "selected Ollama setup model", + ); + discoveredModelsByName.set(defaultModelId, selectedModel); + } + // Failed setup must not leave a durable local profile behind. await storeOllamaCredential(params.agentDir);