mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
fix(ollama): dedupe latest models during setup
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
Reference in New Issue
Block a user