fix(ollama): activate the model selected during setup

This commit is contained in:
Vincent Koc
2026-08-04 09:19:53 +08:00
parent dea04651b9
commit f70821908e
7 changed files with 225 additions and 44 deletions
+69 -2
View File
@@ -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({
+94 -41
View File
@@ -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<OpenClawConfig, Map<str
const OLLAMA_CLOUD_DEFAULT_MODEL_REF = `${OLLAMA_CLOUD_PROVIDER_ID}/${OLLAMA_CLOUD_DEFAULT_MODELS[0].id}`;
const OLLAMA_CONFIGURED_SHOW_CONCURRENCY = 4;
const OLLAMA_CONFIGURED_SHOW_MAX_MODELS = 8;
const OLLAMA_APP_GUIDED_MIN_CONTEXT_TOKENS = 16_384;
type OllamaNonInteractiveValidationContext = Parameters<
NonNullable<ProviderAuthMethod["validateNonInteractive"]>
@@ -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,
+1 -1
View File
@@ -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 {
@@ -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");
});
});
@@ -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>): 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<string, OllamaModelWithContext>,
+2
View File
@@ -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 () => {
+14
View File
@@ -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,