mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(ollama): activate the model selected during setup
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user