diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a722e76228..71fd2fae686 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Media-understanding/audio: migrate deprecated `{input}` placeholders in legacy `audio.transcription.command` configs to `{{MediaPath}}`, so custom audio transcribers no longer receive the literal placeholder after doctor repair. Fixes #72760. Thanks @krisfanue3-hash. +- Ollama/onboarding: de-dupe suggested bare local models against installed `:latest` tags and skip redundant pulls, so setup shows the installed model once and no longer says it is downloading an already available model. Fixes #68952. Thanks @tleyden. - Control UI/Gateway: preserve WebChat client version labels across localhost, 127.0.0.1, and IPv6 loopback aliases on the same port, avoiding misleading `vcontrol-ui` connection logs while investigating duplicate-message reports. Refs #72753 and #72742. Thanks @LumenFromTheFuture and @allesgutefy. - Agents/reasoning: treat orphan closing reasoning tags with following answer text as a privacy boundary across delivery, history, streaming, and Control UI sanitizers so malformed local-model output cannot leak chain-of-thought text. Fixes #67092. Thanks @AnildoSilva. - Memory-core: run one-shot memory CLI commands through transient builtin and QMD managers so `memory index`, `memory status --index`, and `memory search` no longer start long-lived file watchers that can hit macOS `EMFILE` limits. Fixes #59101; carries forward #49851. Thanks @mbear469210-coder and @maoyuanxue. diff --git a/docs/providers/ollama.md b/docs/providers/ollama.md index b7d97b141d3..802154382d3 100644 --- a/docs/providers/ollama.md +++ b/docs/providers/ollama.md @@ -59,7 +59,7 @@ Choose your preferred setup method and mode. - **Local only** — local models only - `Cloud only` prompts for `OLLAMA_API_KEY` and suggests hosted cloud defaults. `Cloud + Local` and `Local only` ask for an Ollama base URL, discover available models, and auto-pull the selected local model if it is not available yet. `Cloud + Local` also checks whether that Ollama host is signed in for cloud access. + `Cloud only` prompts for `OLLAMA_API_KEY` and suggests hosted cloud defaults. `Cloud + Local` and `Local only` ask for an Ollama base URL, discover available models, and auto-pull the selected local model if it is not available yet. When Ollama reports an installed `:latest` tag such as `gemma4:latest`, setup shows that installed model once instead of showing both `gemma4` and `gemma4:latest` or pulling the bare alias again. `Cloud + Local` also checks whether that Ollama host is signed in for cloud access. ```bash diff --git a/extensions/ollama/src/setup.test.ts b/extensions/ollama/src/setup.test.ts index d103027c523..15fcc84a129 100644 --- a/extensions/ollama/src/setup.test.ts +++ b/extensions/ollama/src/setup.test.ts @@ -239,6 +239,21 @@ describe("ollama setup", () => { expect(modelIds).toContain("llama3:8b"); }); + it("dedupes the suggested local model against a discovered latest tag", async () => { + const prompter = createLocalPrompter(); + + const fetchMock = createOllamaFetchMock({ tags: ["gemma4:latest", "llama3:8b"] }); + vi.stubGlobal("fetch", fetchMock); + + const result = await promptAndConfigureOllama({ + cfg: {}, + prompter, + }); + + const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id); + expect(modelIds).toEqual(["gemma4:latest", "llama3:8b"]); + }); + it("cloud mode does not hit local Ollama endpoints", async () => { const prompter = createCloudPrompter(); const fetchMock = createOllamaFetchMock({ tags: [] }); @@ -536,6 +551,21 @@ describe("ollama setup", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("skips pull when an untagged model is available as latest", async () => { + const prompter = {} as unknown as WizardPrompter; + + const fetchMock = createOllamaFetchMock({ tags: ["gemma4:latest"] }); + vi.stubGlobal("fetch", fetchMock); + + await ensureOllamaModelPulled({ + config: createDefaultOllamaConfig("ollama/gemma4"), + model: "ollama/gemma4", + prompter, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("uses baseURL alias when checking and pulling models", async () => { const progress = { update: vi.fn(), stop: vi.fn() }; const prompter = { @@ -656,6 +686,32 @@ describe("ollama setup", () => { ); }); + it("uses the discovered latest tag as the non-interactive default without pulling", async () => { + const fetchMock = createOllamaFetchMock({ tags: ["gemma4:latest"] }); + vi.stubGlobal("fetch", fetchMock); + const runtime = createRuntime(); + + const result = await configureOllamaNonInteractive({ + nextConfig: {}, + opts: { + customBaseUrl: "http://127.0.0.1:11434", + }, + runtime, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls.some((call) => requestUrl(call[0]).endsWith("/api/pull"))).toBe( + false, + ); + expect(result.models?.providers?.ollama?.models?.map((model) => model.id)).toEqual([ + "gemma4:latest", + ]); + expect(result.agents?.defaults?.model).toEqual( + expect.objectContaining({ primary: "ollama/gemma4:latest" }), + ); + expect(runtime.log).toHaveBeenCalledWith("Default Ollama model: gemma4:latest"); + }); + it("accepts cloud models in non-interactive mode without pulling", async () => { const fetchMock = createOllamaFetchMock({ tags: [] }); vi.stubGlobal("fetch", fetchMock); diff --git a/extensions/ollama/src/setup.ts b/extensions/ollama/src/setup.ts index 88d1ebab0b6..3512e6f795d 100644 --- a/extensions/ollama/src/setup.ts +++ b/extensions/ollama/src/setup.ts @@ -420,21 +420,49 @@ function buildOllamaModelsConfig( }); } +function getOllamaLatestDedupeKey(name: string): string { + const normalized = normalizeLowercaseStringOrEmpty(name); + return normalized.endsWith(":latest") ? normalized.slice(0, -":latest".length) : normalized; +} + +function isExplicitLatestOllamaModel(name: string): boolean { + return normalizeLowercaseStringOrEmpty(name).endsWith(":latest"); +} + +function shouldReplaceOllamaModelName(existing: string, candidate: string): boolean { + return !isExplicitLatestOllamaModel(existing) && isExplicitLatestOllamaModel(candidate); +} + function mergeUniqueModelNames(...groups: string[][]): string[] { - const seen = new Set(); + const indexByKey = new Map(); const merged: string[] = []; for (const group of groups) { for (const name of group) { - if (seen.has(name)) { + const key = getOllamaLatestDedupeKey(name); + const existingIndex = indexByKey.get(key); + if (existingIndex !== undefined) { + if (shouldReplaceOllamaModelName(merged[existingIndex], name)) { + merged[existingIndex] = name; + } continue; } - seen.add(name); + indexByKey.set(key, merged.length); merged.push(name); } } return merged; } +function findAvailableOllamaModelName(modelName: string, availableModelNames: Iterable) { + const wantedKey = getOllamaLatestDedupeKey(modelName); + for (const available of availableModelNames) { + if (getOllamaLatestDedupeKey(available) === wantedKey) { + return available; + } + } + return undefined; +} + function applyOllamaProviderConfig( cfg: OpenClawConfig, baseUrl: string, @@ -632,19 +660,20 @@ export async function configureOllamaNonInteractive(params: { ); const discoveredModelsByName = new Map(enrichedModels.map((model) => [model.name, model])); const modelNames = models.map((model) => model.name); - const orderedModelNames = [ - ...OLLAMA_SUGGESTED_MODELS_LOCAL, - ...modelNames.filter((name) => !OLLAMA_SUGGESTED_MODELS_LOCAL.includes(name)), - ]; + const orderedModelNames = mergeUniqueModelNames(OLLAMA_SUGGESTED_MODELS_LOCAL, modelNames); const requestedDefaultModelId = explicitModel ?? OLLAMA_SUGGESTED_MODELS_LOCAL[0]; const availableModelNames = new Set(modelNames); + const availableDefaultModelId = findAvailableOllamaModelName( + requestedDefaultModelId, + availableModelNames, + ); const requestedCloudModel = isOllamaCloudModel(requestedDefaultModelId); let pulledRequestedModel = false; if (requestedCloudModel) { availableModelNames.add(requestedDefaultModelId); - } else if (!modelNames.includes(requestedDefaultModelId)) { + } else if (!availableDefaultModelId) { pulledRequestedModel = await pullOllamaModelNonInteractive( baseUrl, requestedDefaultModelId, @@ -656,7 +685,7 @@ export async function configureOllamaNonInteractive(params: { } let allModelNames = orderedModelNames; - let defaultModelId = requestedDefaultModelId; + let defaultModelId = availableDefaultModelId ?? requestedDefaultModelId; if ( (pulledRequestedModel || requestedCloudModel) && !allModelNames.includes(requestedDefaultModelId) @@ -664,7 +693,7 @@ export async function configureOllamaNonInteractive(params: { allModelNames = [...allModelNames, requestedDefaultModelId]; } - if (!availableModelNames.has(requestedDefaultModelId)) { + if (!findAvailableOllamaModelName(defaultModelId, availableModelNames)) { if (availableModelNames.size === 0) { params.runtime.error( [ @@ -677,7 +706,7 @@ export async function configureOllamaNonInteractive(params: { } defaultModelId = - allModelNames.find((name) => availableModelNames.has(name)) ?? + allModelNames.find((name) => findAvailableOllamaModelName(name, availableModelNames)) ?? Array.from(availableModelNames)[0]; params.runtime.log( `Ollama model ${requestedDefaultModelId} was not available; using ${defaultModelId} instead.`, @@ -709,7 +738,12 @@ export async function ensureOllamaModelPulled(params: { return; } const { models } = await fetchOllamaModels(baseUrl); - if (models.some((model) => model.name === modelName)) { + if ( + findAvailableOllamaModelName( + modelName, + models.map((model) => model.name), + ) + ) { return; } if (!(await pullOllamaModel(baseUrl, modelName, params.prompter))) {