From 04c8d418e67a4bafd7706f549313c4c96ef332d5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 3 Aug 2026 04:27:17 +0800 Subject: [PATCH 1/3] refactor(ollama): share resident model discovery --- extensions/ollama/src/node-inference.ts | 31 +------ extensions/ollama/src/provider-models.test.ts | 33 +++++++ extensions/ollama/src/provider-models.ts | 86 +++++++++++++++---- 3 files changed, 104 insertions(+), 46 deletions(-) diff --git a/extensions/ollama/src/node-inference.ts b/extensions/ollama/src/node-inference.ts index bc8b1550100e..6d64d1559de7 100644 --- a/extensions/ollama/src/node-inference.ts +++ b/extensions/ollama/src/node-inference.ts @@ -24,6 +24,7 @@ import { buildOllamaBaseUrlSsrFPolicy, enrichOllamaCompletionModels, enrichOllamaModelsWithContext, + fetchLoadedOllamaModelNames, fetchOllamaModels, isOllamaCloudModel, resolveOllamaApiBase, @@ -156,32 +157,6 @@ async function requestOllamaJson(params: { } } -async function fetchLoadedModelNames(baseUrl: string, signal?: AbortSignal): Promise> { - try { - const data = await requestOllamaJson<{ models?: Array<{ name?: unknown; model?: unknown }> }>({ - baseUrl, - path: "/api/ps", - timeoutMs: 5000, - ...(signal ? { signal } : {}), - }); - return new Set( - (data.models ?? []) - .map((model) => - typeof model.name === "string" - ? model.name.trim() - : typeof model.model === "string" - ? model.model.trim() - : "", - ) - .filter(Boolean), - ); - } catch { - throwIfOllamaRequestAborted(signal); - // Model discovery still works against Ollama versions without /api/ps. - return new Set(); - } -} - async function discoverOllamaNodeModels( baseUrl = OLLAMA_DEFAULT_BASE_URL, signal?: AbortSignal, @@ -194,7 +169,9 @@ async function discoverOllamaNodeModels( const localModels = discovered.models.filter( (model) => !model.remote_host?.trim() && !isOllamaCloudModel(model.name), ); - const loadedNames = await fetchLoadedModelNames(apiBase, signal); + const loaded = await fetchLoadedOllamaModelNames(apiBase, signal ? { signal } : undefined); + // Model discovery still works against Ollama versions without /api/ps. + const loadedNames = new Set(loaded.models); // Probe loaded models before the bounded catalog can hide already-runnable node models. const prioritizedModels = localModels.toSorted( (left, right) => Number(loadedNames.has(right.name)) - Number(loadedNames.has(left.name)), diff --git a/extensions/ollama/src/provider-models.test.ts b/extensions/ollama/src/provider-models.test.ts index 9e3d77c60bd6..7ee1ac0b5526 100644 --- a/extensions/ollama/src/provider-models.test.ts +++ b/extensions/ollama/src/provider-models.test.ts @@ -10,6 +10,7 @@ import { buildOllamaModelDefinition, capLocalOllamaProviderContext, enrichOllamaModelsWithContext, + fetchLoadedOllamaModelNames, isOllamaCloudModel, fetchOllamaModels, queryOllamaModelShowInfo, @@ -161,6 +162,26 @@ describe("ollama provider models", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it("reads loaded models from /api/ps with remote auth", async () => { + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + expect(requestUrl(input)).toBe("https://ollama.example.com/api/ps"); + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer private-key"); + return jsonResponse({ + models: [{ name: "qwen3.5:4b" }, { model: "llama3.3:70b" }, { name: " " }, {}], + }); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect( + fetchLoadedOllamaModelNames("https://ollama.example.com/v1", { + apiKey: "private-key", + }), + ).resolves.toEqual({ + reachable: true, + models: ["qwen3.5:4b", "llama3.3:70b"], + }); + }); + it("discovers a chat model after 200 embedding-only catalog entries", async () => { const embeddingModels = Array.from({ length: 200 }, (_, index) => ({ name: `embedding-${index}:latest`, @@ -533,6 +554,18 @@ describe("ollama provider models", () => { }); expect(tagsResponse.wasCanceled()).toBe(true); + const psResponse = cancelTrackedResponse("process listing unavailable", { status: 503 }); + vi.stubGlobal( + "fetch", + vi.fn(async () => psResponse.response), + ); + + await expect(fetchLoadedOllamaModelNames("http://127.0.0.1:11434")).resolves.toEqual({ + reachable: true, + models: [], + }); + expect(psResponse.wasCanceled()).toBe(true); + const showResponse = cancelTrackedResponse("model unavailable", { status: 503 }); vi.stubGlobal( "fetch", diff --git a/extensions/ollama/src/provider-models.ts b/extensions/ollama/src/provider-models.ts index be3432d1f04c..83239ffa230e 100644 --- a/extensions/ollama/src/provider-models.ts +++ b/extensions/ollama/src/provider-models.ts @@ -34,6 +34,11 @@ export type OllamaTagsResponse = { models?: OllamaTagModel[]; }; +export type OllamaRunningModel = { + name?: unknown; + model?: unknown; +}; + export type OllamaModelWithContext = OllamaTagModel & { contextWindow?: number; capabilities?: string[]; @@ -420,25 +425,29 @@ type OllamaModelsFetchDeps = { lookupFn?: LookupFn; }; -export async function fetchOllamaModels( - baseUrl: string, - opts?: OllamaModelRequestOptions, - deps?: OllamaModelsFetchDeps, -): Promise<{ reachable: boolean; models: OllamaTagModel[] }> { +async function fetchOllamaModelRows(params: { + baseUrl: string; + endpoint: "ps" | "tags"; + opts?: OllamaModelRequestOptions; + deps?: OllamaModelsFetchDeps; +}): Promise<{ reachable: boolean; models: T[] }> { try { - const apiBase = resolveOllamaApiBase(baseUrl); + const apiBase = resolveOllamaApiBase(params.baseUrl); + const auditContext = `ollama-provider-models.${params.endpoint}`; const { response, release } = await fetchWithSsrFGuard({ - url: `${apiBase}/api/tags`, + url: `${apiBase}/api/${params.endpoint}`, init: { - headers: opts?.apiKey ? { Authorization: `Bearer ${opts.apiKey}` } : undefined, + headers: params.opts?.apiKey + ? { Authorization: `Bearer ${params.opts.apiKey}` } + : undefined, }, // Guard-owned timeoutMs also bounds DNS/proxy preflight; init.signal does not. - timeoutMs: Math.min(opts?.timeoutMs ?? OLLAMA_TAGS_TIMEOUT_MS, OLLAMA_TAGS_TIMEOUT_MS), - ...(opts?.signal ? { signal: opts.signal } : {}), + timeoutMs: Math.min(params.opts?.timeoutMs ?? OLLAMA_TAGS_TIMEOUT_MS, OLLAMA_TAGS_TIMEOUT_MS), + ...(params.opts?.signal ? { signal: params.opts.signal } : {}), policy: buildOllamaBaseUrlSsrFPolicy(apiBase), - auditContext: "ollama-provider-models.tags", - ...(deps?.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}), - ...(deps?.lookupFn ? { lookupFn: deps.lookupFn } : {}), + auditContext, + ...(params.deps?.fetchImpl ? { fetchImpl: params.deps.fetchImpl } : {}), + ...(params.deps?.lookupFn ? { lookupFn: params.deps.lookupFn } : {}), }); try { if (!response.ok) { @@ -447,21 +456,60 @@ export async function fetchOllamaModels( void response.body?.cancel().catch(() => undefined); return { reachable: true, models: [] }; } - const data = await readProviderJsonResponse( - response, - "ollama-provider-models.tags", - ); - const models = (data.models ?? []).filter((m) => m.name); + const data = await readProviderJsonResponse<{ models?: T[] }>(response, auditContext); + const models = Array.isArray(data.models) ? data.models : []; return { reachable: true, models }; } finally { await release(); } } catch { - throwIfOllamaRequestAborted(opts?.signal); + throwIfOllamaRequestAborted(params.opts?.signal); return { reachable: false, models: [] }; } } +export async function fetchOllamaModels( + baseUrl: string, + opts?: OllamaModelRequestOptions, + deps?: OllamaModelsFetchDeps, +): Promise<{ reachable: boolean; models: OllamaTagModel[] }> { + const result = await fetchOllamaModelRows({ + baseUrl, + endpoint: "tags", + opts, + deps, + }); + return { + reachable: result.reachable, + models: result.models.filter((model) => model.name), + }; +} + +export async function fetchLoadedOllamaModelNames( + baseUrl: string, + opts?: OllamaModelRequestOptions, + deps?: OllamaModelsFetchDeps, +): Promise<{ reachable: boolean; models: string[] }> { + const result = await fetchOllamaModelRows({ + baseUrl, + endpoint: "ps", + opts, + deps, + }); + return { + reachable: result.reachable, + models: result.models + .map((model) => + typeof model.name === "string" + ? model.name.trim() + : typeof model.model === "string" + ? model.model.trim() + : "", + ) + .filter(Boolean), + }; +} + export async function buildOllamaProvider( configuredBaseUrl?: string, opts?: { apiKey?: string; quiet?: boolean }, From cba231bff4fe4c4a79c9c9080d58def60e6b6215 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 3 Aug 2026 04:27:17 +0800 Subject: [PATCH 2/3] fix(ollama): use only loaded models during guided setup --- extensions/ollama/index.test.ts | 88 ++++++++++++++++++++++++++++++++- extensions/ollama/index.ts | 17 ++++++- 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index e732b01cf644..ea08f9c692c5 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -31,6 +31,7 @@ const ensureOllamaModelPulledMock = vi.hoisted(() => vi.fn(async () => {})); const checkOllamaCloudAuthMock = vi.hoisted(() => vi.fn()); const configureOllamaNonInteractiveMock = vi.hoisted(() => vi.fn()); const fetchOllamaModelsMock = vi.hoisted(() => vi.fn()); +const fetchLoadedOllamaModelNamesMock = vi.hoisted(() => vi.fn()); const buildOllamaProviderMock = vi.hoisted(() => vi.fn()); const queryOllamaModelShowInfoMock = vi.hoisted(() => vi.fn()); const resolveConfiguredSecretInputStringMock = vi.hoisted(() => vi.fn()); @@ -69,6 +70,11 @@ vi.mock("./api.js", () => ({ buildOllamaModelDefinition: buildOllamaModelDefinitionMock, })); +vi.mock("./src/provider-models.js", async (importOriginal) => ({ + ...(await importOriginal()), + fetchLoadedOllamaModelNames: fetchLoadedOllamaModelNamesMock, +})); + vi.mock("openclaw/plugin-sdk/secret-input-runtime", async (importOriginal) => { const actual = await importOriginal(); return { @@ -100,6 +106,11 @@ beforeEach(() => { checkOllamaCloudAuthMock.mockResolvedValue({ signedIn: true }); configureOllamaNonInteractiveMock.mockReset(); fetchOllamaModelsMock.mockReset(); + fetchLoadedOllamaModelNamesMock.mockReset(); + fetchLoadedOllamaModelNamesMock.mockResolvedValue({ + reachable: true, + models: ["qwen-tool", "qwen3.5:4b", "llama3.3:70b", "nomic-embed-text", "unknown-tools"], + }); buildOllamaProviderMock.mockReset(); queryOllamaModelShowInfoMock.mockReset(); resolveConfiguredSecretInputStringMock.mockClear(); @@ -483,7 +494,7 @@ describe("ollama plugin", () => { expect(result.defaultModel).toBeUndefined(); }); - it("discovers and prepares an installed tool-capable model without pulling it", async () => { + it("discovers and prepares a loaded tool-capable model without pulling it", async () => { const provider = registerProvider(); const guided = provider.auth[0].appGuidedSetup; buildOllamaProviderMock.mockResolvedValue({ @@ -532,7 +543,74 @@ describe("ollama plugin", () => { expect(ensureOllamaModelPulledMock).not.toHaveBeenCalled(); }); - it("prefers the strongest tool-calling family among installed models", async () => { + it("does not auto-detect installed models that are not loaded", async () => { + const provider = registerProvider(); + fetchLoadedOllamaModelNamesMock.mockResolvedValue({ reachable: true, models: [] }); + + await expect( + provider.auth[0].appGuidedSetup?.detect({ config: {}, env: {} }), + ).resolves.toBeNull(); + + expect(buildOllamaProviderMock).not.toHaveBeenCalled(); + expect(queryOllamaModelShowInfoMock).not.toHaveBeenCalled(); + }); + + it("selects only from loaded models when stronger installed models are idle", async () => { + const provider = registerProvider(); + fetchLoadedOllamaModelNamesMock.mockResolvedValue({ + reachable: true, + models: ["llama3.3:70b"], + }); + buildOllamaProviderMock.mockResolvedValue({ + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + models: [ + { id: "llama3.3:70b", name: "llama3.3:70b", compat: { supportsTools: true } }, + { id: "qwen3.5:4b", name: "qwen3.5:4b", compat: { supportsTools: true } }, + ], + }); + + await expect(provider.auth[0].appGuidedSetup?.detect({ config: {}, env: {} })).resolves.toEqual( + { + modelRef: "ollama/llama3.3:70b", + detail: "llama3.3:70b at http://127.0.0.1:11434", + }, + ); + expect(queryOllamaModelShowInfoMock).toHaveBeenCalledTimes(1); + expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith( + "http://127.0.0.1:11434", + "llama3.3:70b", + undefined, + ); + }); + + it("rechecks loaded state before preparing the detected route", async () => { + const provider = registerProvider(); + buildOllamaProviderMock.mockResolvedValue({ + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + models: [{ id: "qwen-tool", name: "qwen-tool", compat: { supportsTools: true } }], + }); + + await expect(provider.auth[0].appGuidedSetup?.detect({ config: {}, env: {} })).resolves.toEqual( + { + modelRef: "ollama/qwen-tool", + detail: "qwen-tool at http://127.0.0.1:11434", + }, + ); + fetchLoadedOllamaModelNamesMock.mockResolvedValue({ reachable: true, models: [] }); + + await expect( + provider.auth[0].appGuidedSetup?.prepare({ + config: {}, + env: {}, + modelRef: "ollama/qwen-tool", + }), + ).resolves.toBeNull(); + expect(buildOllamaProviderMock).toHaveBeenCalledTimes(1); + }); + + it("prefers the strongest tool-calling family among loaded models", async () => { const provider = registerProvider(); buildOllamaProviderMock.mockResolvedValue({ baseUrl: "http://127.0.0.1:11434", @@ -625,6 +703,10 @@ describe("ollama plugin", () => { "https://ollama.example.com", expect.objectContaining(providerAccess), ); + expect(fetchLoadedOllamaModelNamesMock).toHaveBeenCalledWith( + "https://ollama.example.com", + providerAccess, + ); }); it("keeps environment-backed Ollama access for the completion proposal", async () => { @@ -677,6 +759,7 @@ describe("ollama plugin", () => { | { apiKey?: string; quiet?: boolean } | undefined; expect(options?.apiKey).toBeUndefined(); + expect(fetchLoadedOllamaModelNamesMock).toHaveBeenCalledWith("http://127.0.0.1:11434", {}); }); it("honors the Ollama discovery opt-out during app-guided detection", async () => { @@ -690,6 +773,7 @@ describe("ollama plugin", () => { env: {}, }), ).resolves.toBeNull(); + expect(fetchLoadedOllamaModelNamesMock).not.toHaveBeenCalled(); expect(buildOllamaProviderMock).not.toHaveBeenCalled(); }); diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 9b02cdaa3719..89c79d4d09ea 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -78,8 +78,10 @@ import { buildDefaultOllamaCloudModelDefinition, capLocalOllamaModelContext, capLocalOllamaProviderContext, + fetchLoadedOllamaModelNames, isOllamaCloudModel, } from "./src/provider-models.js"; +import { findAvailableOllamaModelName } from "./src/setup-model-selection.js"; import { OLLAMA_INCOMPLETE_STREAM_ERROR, createConfiguredOllamaCompatStreamWrapper, @@ -239,12 +241,23 @@ async function discoverAppGuidedOllamaModel(ctx: ProviderAppGuidedSetupContext) }); const accessValue = await resolveAppGuidedOllamaApiKey(ctx, existing); const discoveryAccess = accessValue ? { apiKey: accessValue } : {}; - const provider = await buildOllamaProvider(readProviderBaseUrl(existing), { + const baseUrl = resolveOllamaApiBase(readProviderBaseUrl(existing)); + // App-guided setup must not turn an installed-but-idle model into a surprise + // memory allocation. Only /api/ps owns the currently resident model set. + const loaded = await fetchLoadedOllamaModelNames(baseUrl, discoveryAccess); + if (!loaded.reachable || loaded.models.length === 0) { + return null; + } + const provider = await buildOllamaProvider(baseUrl, { quiet: true, ...discoveryAccess, }); const toolModels = - provider.models?.filter((candidate) => candidate.compat?.supportsTools === true) ?? []; + provider.models?.filter( + (candidate) => + candidate.compat?.supportsTools === true && + findAvailableOllamaModelName(candidate.id, loaded.models) !== undefined, + ) ?? []; // Automatic setup needs measured /api/show facts. The catalog fallback is // intentionally optimistic for manual use and must not qualify a weak route. let model: ModelDefinitionConfig | undefined; From 011691f734e1f9bc1f7afa7c7843ef08b526dc1e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 3 Aug 2026 12:13:17 +0800 Subject: [PATCH 3/3] fix(ollama): keep resident model rows private --- extensions/ollama/src/provider-models.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/extensions/ollama/src/provider-models.ts b/extensions/ollama/src/provider-models.ts index 83239ffa230e..bee9967b5a54 100644 --- a/extensions/ollama/src/provider-models.ts +++ b/extensions/ollama/src/provider-models.ts @@ -34,11 +34,13 @@ export type OllamaTagsResponse = { models?: OllamaTagModel[]; }; -export type OllamaRunningModel = { +type OllamaRunningModel = { name?: unknown; model?: unknown; }; +type OllamaModelRow = OllamaTagModel | OllamaRunningModel; + export type OllamaModelWithContext = OllamaTagModel & { contextWindow?: number; capabilities?: string[]; @@ -425,12 +427,12 @@ type OllamaModelsFetchDeps = { lookupFn?: LookupFn; }; -async function fetchOllamaModelRows(params: { +async function fetchOllamaModelRows(params: { baseUrl: string; endpoint: "ps" | "tags"; opts?: OllamaModelRequestOptions; deps?: OllamaModelsFetchDeps; -}): Promise<{ reachable: boolean; models: T[] }> { +}): Promise<{ reachable: boolean; models: OllamaModelRow[] }> { try { const apiBase = resolveOllamaApiBase(params.baseUrl); const auditContext = `ollama-provider-models.${params.endpoint}`; @@ -456,7 +458,10 @@ async function fetchOllamaModelRows(params: { void response.body?.cancel().catch(() => undefined); return { reachable: true, models: [] }; } - const data = await readProviderJsonResponse<{ models?: T[] }>(response, auditContext); + const data = await readProviderJsonResponse<{ models?: OllamaModelRow[] }>( + response, + auditContext, + ); const models = Array.isArray(data.models) ? data.models : []; return { reachable: true, models }; } finally { @@ -473,7 +478,7 @@ export async function fetchOllamaModels( opts?: OllamaModelRequestOptions, deps?: OllamaModelsFetchDeps, ): Promise<{ reachable: boolean; models: OllamaTagModel[] }> { - const result = await fetchOllamaModelRows({ + const result = await fetchOllamaModelRows({ baseUrl, endpoint: "tags", opts, @@ -481,7 +486,9 @@ export async function fetchOllamaModels( }); return { reachable: result.reachable, - models: result.models.filter((model) => model.name), + models: result.models.filter( + (model): model is OllamaTagModel => typeof model.name === "string" && Boolean(model.name), + ), }; } @@ -490,7 +497,7 @@ export async function fetchLoadedOllamaModelNames( opts?: OllamaModelRequestOptions, deps?: OllamaModelsFetchDeps, ): Promise<{ reachable: boolean; models: string[] }> { - const result = await fetchOllamaModelRows({ + const result = await fetchOllamaModelRows({ baseUrl, endpoint: "ps", opts, @@ -502,7 +509,7 @@ export async function fetchLoadedOllamaModelNames( .map((model) => typeof model.name === "string" ? model.name.trim() - : typeof model.model === "string" + : "model" in model && typeof model.model === "string" ? model.model.trim() : "", )