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))) {