From f70821908ee2b2f7978f5f812d85ba127020e770 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 09:19:53 +0800 Subject: [PATCH] fix(ollama): activate the model selected during setup --- extensions/ollama/index.test.ts | 71 ++++++++- extensions/ollama/index.ts | 135 ++++++++++++------ extensions/ollama/ollama.live.test.ts | 2 +- .../ollama/src/setup-model-selection.test.ts | 11 ++ .../ollama/src/setup-model-selection.ts | 34 +++++ extensions/ollama/src/setup.test.ts | 2 + extensions/ollama/src/setup.ts | 14 ++ 7 files changed, 225 insertions(+), 44 deletions(-) diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index 9ceb83958fff..75efc387e7d1 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -14,6 +14,7 @@ import { OLLAMA_DEFAULT_API_KEY } from "./src/discovery-shared.js"; const promptAndConfigureOllamaMock = vi.hoisted(() => vi.fn(async () => ({ credential: "ollama-local", + defaultModel: "ollama/qwen-tool", config: { models: { providers: { @@ -518,7 +519,7 @@ describe("ollama plugin", () => { expect(registerTool).toHaveBeenCalledWith(expect.objectContaining({ name: "node_inference" })); }); - it("does not preselect a default model during provider auth setup", async () => { + it("returns the exact model selected during provider auth setup", async () => { const provider = registerProvider(); const result = await provider.auth[0].run({ @@ -547,7 +548,7 @@ describe("ollama plugin", () => { }, }, }); - expect(result.defaultModel).toBeUndefined(); + expect(result.defaultModel).toBe("ollama/qwen-tool"); }); it("discovers and prepares a loaded tool-capable model without pulling it", async () => { @@ -638,6 +639,72 @@ describe("ollama plugin", () => { expect(queryOllamaModelShowInfoMock).not.toHaveBeenCalled(); }); + it("prepares the exact configured model even when it is installed but idle", async () => { + const provider = registerProvider(); + const config = { + models: { + providers: { + ollama: { + baseUrl: "http://127.0.0.1:11434", + api: "ollama" as const, + models: [ + { + id: "qwen-tool", + name: "qwen-tool", + reasoning: false, + input: ["text"] as const, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 32_768, + maxTokens: 8_192, + compat: { supportsTools: true }, + }, + ], + }, + }, + }, + }; + fetchLoadedOllamaModelNamesMock.mockResolvedValue({ reachable: true, models: [] }); + mockDiscoveredOllamaProvider([ + { id: "qwen-tool", name: "qwen-tool", compat: { supportsTools: true } }, + ]); + + await expect( + provider.auth[0].appGuidedSetup?.prepare({ + config, + env: {}, + modelRef: "ollama/qwen-tool", + }), + ).resolves.toMatchObject({ + defaultModel: "ollama/qwen-tool", + configPatch: { + models: { + providers: { + ollama: { + models: [expect.objectContaining({ id: "qwen-tool" })], + }, + }, + }, + }, + }); + expect(fetchLoadedOllamaModelNamesMock).not.toHaveBeenCalled(); + }); + + it("rejects an explicit installed model that setup did not configure", async () => { + const provider = registerProvider(); + mockDiscoveredOllamaProvider([ + { id: "other-model", name: "other-model", compat: { supportsTools: true } }, + ]); + + await expect( + provider.auth[0].appGuidedSetup?.prepare({ + config: {}, + env: {}, + modelRef: "ollama/other-model", + }), + ).resolves.toBeNull(); + expect(queryOllamaModelShowInfoMock).not.toHaveBeenCalled(); + }); + it("selects only from loaded models when stronger installed models are idle", async () => { const provider = registerProvider(); fetchLoadedOllamaModelNamesMock.mockResolvedValue({ diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 6b3ae54ca4b3..d2365613564a 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -27,10 +27,7 @@ import type { ModelDefinitionConfig, ModelProviderConfig, } from "openclaw/plugin-sdk/provider-model-shared"; -import { - buildOpenAICompatibleReplayPolicy, - selectPreferredLocalModelId, -} from "openclaw/plugin-sdk/provider-model-shared"; +import { buildOpenAICompatibleReplayPolicy } from "openclaw/plugin-sdk/provider-model-shared"; import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools"; import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime"; import { @@ -81,7 +78,11 @@ import { fetchLoadedOllamaModelNames, isOllamaCloudModel, } from "./src/provider-models.js"; -import { findAvailableOllamaModelName } from "./src/setup-model-selection.js"; +import { + findAvailableOllamaModelName, + OLLAMA_APP_GUIDED_MIN_CONTEXT_TOKENS, + orderPreferredOllamaModelIds, +} from "./src/setup-model-selection.js"; import { OLLAMA_INCOMPLETE_STREAM_ERROR, createConfiguredOllamaCompatStreamWrapper, @@ -116,7 +117,6 @@ const dynamicManagedCredentialFingerprints = new WeakMap @@ -207,22 +207,6 @@ async function buildLocalOllamaProvider( return capLocalOllamaProviderContext(await buildOllamaProvider(configuredBaseUrl, opts)); } -function orderAppGuidedOllamaModels(models: ModelDefinitionConfig[]): ModelDefinitionConfig[] { - const remaining = [...models]; - const ordered: ModelDefinitionConfig[] = []; - while (remaining.length > 0) { - const preferredId = selectPreferredLocalModelId(remaining.map((candidate) => candidate.id)); - const preferredIndex = preferredId - ? remaining.findIndex((candidate) => candidate.id.trim() === preferredId) - : 0; - const [candidate] = remaining.splice(Math.max(preferredIndex, 0), 1); - if (candidate) { - ordered.push(candidate); - } - } - return ordered; -} - async function resolveAppGuidedOllamaConnection(ctx: ProviderAppGuidedSetupContext) { const pluginConfig = resolvePluginConfigObject(ctx.config, OLLAMA_PROVIDER_ID) as | OllamaPluginConfig @@ -260,42 +244,106 @@ async function detectAppGuidedOllamaAvailability( return result.reachable; } -async function discoverAppGuidedOllamaModel(ctx: ProviderAppGuidedSetupContext) { +async function discoverAppGuidedOllamaModel( + ctx: ProviderAppGuidedSetupContext, + options?: { modelRef?: string }, +) { const connection = await resolveAppGuidedOllamaConnection(ctx); if (!connection) { return null; } - // 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(connection.baseUrl, { - ...connection.discoveryAccess, - ...(ctx.signal ? { signal: ctx.signal } : {}), - }); - if (!loaded.reachable || loaded.models.length === 0) { + const requestedPrefix = `${OLLAMA_PROVIDER_ID}/`; + const requestedModelId = options?.modelRef?.startsWith(requestedPrefix) + ? options.modelRef.slice(requestedPrefix.length) + : undefined; + if (options?.modelRef && !requestedModelId) { return null; } + const configuredModels = connection.existing?.models ?? []; + const requestedConfiguredModel = requestedModelId + ? configuredModels.find( + (candidate) => findAvailableOllamaModelName(candidate.id, [requestedModelId]) !== undefined, + ) + : undefined; + let requestedModelIsLoaded = false; + let availableModelNames: string[]; + if (requestedModelId) { + if (!requestedConfiguredModel && !isOllamaCloudModel(requestedModelId)) { + const loaded = await fetchLoadedOllamaModelNames(connection.baseUrl, { + ...connection.discoveryAccess, + ...(ctx.signal ? { signal: ctx.signal } : {}), + }); + if ( + !loaded.reachable || + findAvailableOllamaModelName(requestedModelId, loaded.models) === undefined + ) { + return null; + } + requestedModelIsLoaded = true; + } + availableModelNames = [requestedModelId]; + } else { + // Ambient discovery must not turn an installed-but-idle model into a + // surprise memory allocation. Only /api/ps owns the resident model set. + const loaded = await fetchLoadedOllamaModelNames(connection.baseUrl, { + ...connection.discoveryAccess, + ...(ctx.signal ? { signal: ctx.signal } : {}), + }); + if (!loaded.reachable || loaded.models.length === 0) { + return null; + } + availableModelNames = loaded.models; + } const provider = await buildOllamaProvider(connection.baseUrl, { quiet: true, ...connection.discoveryAccess, }); - const toolModels = - provider.models?.filter( - (candidate) => - candidate.compat?.supportsTools === true && - findAvailableOllamaModelName(candidate.id, loaded.models) !== undefined, - ) ?? []; + const providerModels = provider.models ?? []; + const requestedProviderModel = requestedModelId + ? providerModels.find( + (candidate) => + candidate.compat?.supportsTools === true && + findAvailableOllamaModelName(candidate.id, [requestedModelId]) !== undefined, + ) + : undefined; + // Explicit setup completion may activate an idle configured model. Local + // routes must either be configured by setup or still resident from ambient + // detection, and must exist in /api/tags. Authenticated cloud routes can use + // the static model definition written by their setup flow. + const requestedModel = + (requestedConfiguredModel || requestedModelIsLoaded) && requestedProviderModel + ? requestedProviderModel + : requestedConfiguredModel && requestedModelId && isOllamaCloudModel(requestedModelId) + ? requestedConfiguredModel + : undefined; + const toolModels = requestedModelId + ? requestedModel + ? [requestedModel] + : [] + : providerModels.filter( + (candidate) => + candidate.compat?.supportsTools === true && + findAvailableOllamaModelName(candidate.id, availableModelNames) !== 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; - for (const candidate of orderAppGuidedOllamaModels(toolModels)) { + const candidatesById = new Map(toolModels.map((candidate) => [candidate.id, candidate])); + for (const candidateId of orderPreferredOllamaModelIds(candidatesById.keys())) { + const candidate = candidatesById.get(candidateId); + if (!candidate) { + continue; + } const showInfo = await queryOllamaModelShowInfo( provider.baseUrl, candidate.id, connection.accessValue ? { apiKey: connection.accessValue } : undefined, ); - const contextWindow = showInfo.contextWindow; + const contextWindow = showInfo.contextWindow ?? candidate.contextWindow; + const supportsTools = + showInfo.capabilities?.includes("tools") ?? candidate.compat?.supportsTools === true; if ( - !showInfo.capabilities?.includes("tools") || + !supportsTools || contextWindow === undefined || contextWindow < OLLAMA_APP_GUIDED_MIN_CONTEXT_TOKENS ) { @@ -314,7 +362,9 @@ async function discoverAppGuidedOllamaModel(ctx: ProviderAppGuidedSetupContext) } const preparedProvider = capLocalOllamaProviderContext({ ...provider, - models: provider.models?.map((candidate) => (candidate.id === model.id ? model : candidate)), + models: providerModels.some((candidate) => candidate.id === model.id) + ? providerModels.map((candidate) => (candidate.id === model.id ? model : candidate)) + : [...providerModels, model], }); let ownerValue = connection.existing?.apiKey; if (ownerValue === undefined) { @@ -917,7 +967,9 @@ export default definePluginEntry({ }; }, prepare: async (ctx) => { - const discovered = await discoverAppGuidedOllamaModel(ctx); + const discovered = await discoverAppGuidedOllamaModel(ctx, { + modelRef: ctx.modelRef, + }); const prefix = `${OLLAMA_PROVIDER_ID}/`; if (!discovered || !ctx.modelRef.startsWith(prefix)) { return null; @@ -977,6 +1029,7 @@ export default definePluginEntry({ }, ], configPatch: result.config, + ...(result.defaultModel ? { defaultModel: result.defaultModel } : {}), }; }, validateNonInteractive: validateOllamaNonInteractive, diff --git a/extensions/ollama/ollama.live.test.ts b/extensions/ollama/ollama.live.test.ts index ff31919db779..942b2ed30d95 100644 --- a/extensions/ollama/ollama.live.test.ts +++ b/extensions/ollama/ollama.live.test.ts @@ -177,7 +177,7 @@ describe.skipIf(!LIVE)("ollama live", () => { buildCliEnv(root), ); - expect(result.exitCode).toBe(0); + expect(result.exitCode, result.stderr || result.stdout).toBe(0); expect(result.stderr).not.toContain("[agents/auth-profiles]"); expect(result.stdout.trim(), result.stderr).not.toHaveLength(0); const payload = parseJsonEnvelope(result.stdout) as { diff --git a/extensions/ollama/src/setup-model-selection.test.ts b/extensions/ollama/src/setup-model-selection.test.ts index 67107cea5c4c..41878dfe77b7 100644 --- a/extensions/ollama/src/setup-model-selection.test.ts +++ b/extensions/ollama/src/setup-model-selection.test.ts @@ -4,6 +4,7 @@ import { findAvailableOllamaModelName, mergeUniqueModelNames, normalizeOllamaModelName, + selectAppGuidedOllamaModelId, } from "./setup-model-selection.js"; describe("Ollama onboarding model selection", () => { @@ -55,4 +56,14 @@ describe("Ollama onboarding model selection", () => { compat: { supportsTools: true }, }); }); + + it("selects a deterministic tools-capable model with enough context", () => { + expect( + selectAppGuidedOllamaModelId([ + { id: "llama3:8b", contextWindow: 32_768, supportsTools: true }, + { id: "qwen3:0.6b", contextWindow: 40_960, supportsTools: true }, + { id: "gemma4:e4b", contextWindow: 8_192, supportsTools: true }, + ]), + ).toBe("qwen3:0.6b"); + }); }); diff --git a/extensions/ollama/src/setup-model-selection.ts b/extensions/ollama/src/setup-model-selection.ts index 39fa911980ff..8d19c56718a1 100644 --- a/extensions/ollama/src/setup-model-selection.ts +++ b/extensions/ollama/src/setup-model-selection.ts @@ -1,4 +1,5 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { selectPreferredLocalModelId } from "openclaw/plugin-sdk/provider-model-shared"; import { OLLAMA_CLOUD_DEFAULT_MODELS } from "./defaults.js"; import { buildDefaultOllamaCloudModelDefinition, @@ -12,6 +13,7 @@ import { const OLLAMA_CONTEXT_ENRICH_LIMIT = 200; const OLLAMA_TOOLS_SCAN_CONCURRENCY = 8; +export const OLLAMA_APP_GUIDED_MIN_CONTEXT_TOKENS = 16_384; type OllamaCloudDefaultModel = (typeof OLLAMA_CLOUD_DEFAULT_MODELS)[number]; @@ -61,6 +63,38 @@ export function findAvailableOllamaModelName( return undefined; } +export function orderPreferredOllamaModelIds(modelIds: Iterable): string[] { + const remaining = [...modelIds]; + const ordered: string[] = []; + while (remaining.length > 0) { + const preferredId = selectPreferredLocalModelId(remaining); + const preferredIndex = preferredId ? remaining.indexOf(preferredId) : 0; + const [candidate] = remaining.splice(Math.max(preferredIndex, 0), 1); + if (candidate) { + ordered.push(candidate); + } + } + return ordered; +} + +export function selectAppGuidedOllamaModelId( + models: Iterable<{ + id: string; + contextWindow?: number; + supportsTools?: boolean; + }>, +): string | undefined { + const eligibleIds = [...models] + .filter( + (model) => + model.supportsTools === true && + model.contextWindow !== undefined && + model.contextWindow >= OLLAMA_APP_GUIDED_MIN_CONTEXT_TOKENS, + ) + .map((model) => model.id); + return orderPreferredOllamaModelIds(eligibleIds)[0]; +} + export function buildOllamaModelsConfig( modelNames: string[], discoveredModelsByName?: Map, diff --git a/extensions/ollama/src/setup.test.ts b/extensions/ollama/src/setup.test.ts index 96e0a210db55..e24f01ad0236 100644 --- a/extensions/ollama/src/setup.test.ts +++ b/extensions/ollama/src/setup.test.ts @@ -531,6 +531,7 @@ describe("ollama setup", () => { ); expect(model?.contextWindow).toBe(65536); + expect(result.defaultModel).toBe("ollama/llama3:8b"); }); it("offers and streams a recommended pull when no installed model supports tools", async () => { @@ -581,6 +582,7 @@ describe("ollama setup", () => { contextWindow: 131072, compat: { supportsTools: true }, }); + expect(result.defaultModel).toBe("ollama/gemma4:e4b"); }); it("does not offer a pull when an installed Ollama model supports tools", async () => { diff --git a/extensions/ollama/src/setup.ts b/extensions/ollama/src/setup.ts index 659a2abdc94b..f056748bfe2a 100644 --- a/extensions/ollama/src/setup.ts +++ b/extensions/ollama/src/setup.ts @@ -42,6 +42,7 @@ import { inspectOllamaModelsForSetup, mergeUniqueModelNames, normalizeOllamaModelName, + selectAppGuidedOllamaModelId, } from "./setup-model-selection.js"; import { pullOllamaModel, pullOllamaModelNonInteractive } from "./setup-pull.js"; @@ -67,6 +68,7 @@ type OllamaSetupResult = { config: OpenClawConfig; credential: SecretInput; credentialMode?: SecretInputMode; + defaultModel?: string; }; function isTruthyEnvValue(value: string | undefined): boolean { @@ -381,9 +383,19 @@ async function promptAndConfigureHostBackedOllama(params: { baseUrl, prompter: params.prompter, }); + const localDefaultModelId = selectAppGuidedOllamaModelId( + [...discoveredModelsByName.values()].map((model) => ({ + id: model.name, + contextWindow: model.contextWindow, + supportsTools: model.capabilities?.includes("tools") === true, + })), + ); + const cloudDefaultModelId = suggestedModelNames.find(isOllamaCloudModel); + const defaultModelId = localDefaultModelId ?? cloudDefaultModelId; return { credential: "ollama-local", + ...(defaultModelId ? { defaultModel: `ollama/${defaultModelId}` } : {}), config: applyOllamaProviderConfig( params.cfg, baseUrl, @@ -432,9 +444,11 @@ export async function promptAndConfigureOllama(params: { discoveredModelNames.length > 0 ? mergeUniqueModelNames(OLLAMA_SUGGESTED_MODELS_CLOUD, discoveredModelNames) : OLLAMA_SUGGESTED_MODELS_CLOUD; + const defaultModelId = modelNames[0]; return { credential, credentialMode, + ...(defaultModelId ? { defaultModel: `ollama/${defaultModelId}` } : {}), config: applyOllamaProviderConfig( params.cfg, OLLAMA_CLOUD_BASE_URL,