fix(ollama): dedupe latest models during setup

This commit is contained in:
Peter Steinberger
2026-04-27 12:13:22 +01:00
parent 78577ac147
commit 2cfe6bf4e5
4 changed files with 104 additions and 13 deletions

View File

@@ -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.

View File

@@ -59,7 +59,7 @@ Choose your preferred setup method and mode.
- **Local only** — local models only
</Step>
<Step title="Select a model">
`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.
</Step>
<Step title="Verify the model is available">
```bash

View File

@@ -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);

View File

@@ -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<string>();
const indexByKey = new Map<string, number>();
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<string>) {
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))) {