fix(cli): streamline local model probes

This commit is contained in:
Peter Steinberger
2026-04-27 23:02:26 +01:00
parent d7dcd0e21e
commit 42dddbbe78
14 changed files with 605 additions and 56 deletions

View File

@@ -369,6 +369,57 @@ describe("ollama plugin", () => {
});
});
it("resolves dynamic local models from Ollama without generating PI models.json", async () => {
const provider = registerProvider();
const previous = process.env.OLLAMA_API_KEY;
process.env.OLLAMA_API_KEY = "ollama-local";
buildOllamaProviderMock.mockResolvedValueOnce({
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [
{
id: "llama3.2:latest",
name: "llama3.2:latest",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 2048,
},
],
});
try {
await provider.prepareDynamicModel?.({
config: {},
provider: "ollama",
modelId: "llama3.2:latest",
modelRegistry: { find: vi.fn(() => null) },
} as never);
expect(
provider.resolveDynamicModel?.({
config: {},
provider: "ollama",
modelId: "llama3.2:latest",
modelRegistry: { find: vi.fn(() => null) },
} as never),
).toMatchObject({
provider: "ollama",
id: "llama3.2:latest",
api: "ollama",
baseUrl: "http://127.0.0.1:11434",
});
expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, { quiet: true });
} finally {
if (previous === undefined) {
delete process.env.OLLAMA_API_KEY;
} else {
process.env.OLLAMA_API_KEY = previous;
}
}
});
it("skips implicit localhost discovery when a custom remote Ollama provider is configured", async () => {
const provider = registerProvider();

View File

@@ -7,8 +7,13 @@ import {
type ProviderAuthMethodNonInteractiveContext,
type ProviderAuthResult,
type ProviderDiscoveryContext,
type ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import { buildApiKeyCredential } from "openclaw/plugin-sdk/provider-auth";
import type {
ModelDefinitionConfig,
ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
buildOpenAICompatibleReplayPolicy,
OPENAI_COMPATIBLE_REPLAY_HOOKS,
@@ -57,6 +62,44 @@ function usesOllamaOpenAICompatTransport(model: {
);
}
const dynamicModelCache = new Map<string, ProviderRuntimeModel[]>();
function buildDynamicCacheKey(provider: string, baseUrl: string | undefined): string {
return `${provider}\0${baseUrl ?? ""}`;
}
function hasOllamaDiscoverySignal(providerConfig: ModelProviderConfig | undefined): boolean {
return (
Boolean(process.env.OLLAMA_API_KEY?.trim()) ||
shouldUseSyntheticOllamaAuth(providerConfig) ||
Boolean(providerConfig?.apiKey)
);
}
function toDynamicOllamaModel(params: {
provider: string;
providerConfig: ModelProviderConfig;
model: ModelDefinitionConfig;
}): ProviderRuntimeModel {
const input = (params.model.input ?? ["text"]).filter(
(value): value is "text" | "image" => value === "text" || value === "image",
);
return {
id: params.model.id,
name: params.model.name ?? params.model.id,
provider: params.provider,
api: "ollama",
baseUrl: readProviderBaseUrl(params.providerConfig) ?? "",
reasoning: params.model.reasoning ?? false,
input: input.length > 0 ? input : ["text"],
cost: params.model.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: params.model.contextWindow ?? 8192,
maxTokens: params.model.maxTokens ?? 8192,
...(params.model.compat ? { compat: params.model.compat as never } : {}),
...(params.model.params ? { params: params.model.params } : {}),
};
}
export default definePluginEntry({
id: "ollama",
name: "Ollama Provider",
@@ -215,6 +258,36 @@ export default definePluginEntry({
},
shouldDeferSyntheticProfileAuth: ({ resolvedApiKey }) =>
resolvedApiKey?.trim() === OLLAMA_DEFAULT_API_KEY,
prepareDynamicModel: async (ctx) => {
const providerConfig = resolveConfiguredOllamaProviderConfig({
config: ctx.config,
providerId: ctx.provider,
});
if (!hasOllamaDiscoverySignal(providerConfig)) {
return;
}
const baseUrl = readProviderBaseUrl(providerConfig);
const provider = await buildOllamaProvider(baseUrl, { quiet: true });
dynamicModelCache.set(
buildDynamicCacheKey(ctx.provider, baseUrl),
(provider.models ?? []).map((model) =>
toDynamicOllamaModel({
provider: ctx.provider,
providerConfig: provider,
model,
}),
),
);
},
resolveDynamicModel: (ctx) => {
const providerConfig = resolveConfiguredOllamaProviderConfig({
config: ctx.config,
providerId: ctx.provider,
});
return dynamicModelCache
.get(buildDynamicCacheKey(ctx.provider, readProviderBaseUrl(providerConfig)))
?.find((model) => model.id === ctx.modelId);
},
buildUnknownModelHint: () =>
"Ollama requires authentication to be registered as a provider. " +
'Set OLLAMA_API_KEY="ollama-local" (any value works) or run "openclaw configure". ' +

View File

@@ -1,3 +1,8 @@
import { spawnSync } from "node:child_process";
import * as fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createOllamaEmbeddingProvider } from "./src/embedding-provider.js";
import { createOllamaStreamFn } from "./src/stream.js";
@@ -20,7 +25,133 @@ async function collectStreamEvents<T>(stream: AsyncIterable<T>): Promise<T[]> {
return events;
}
async function withTempOpenClawState<T>(run: (paths: { root: string }) => Promise<T>): Promise<T> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ollama-cli-live-"));
try {
await fs.writeFile(
path.join(root, "openclaw.json"),
JSON.stringify(
{
models: {
providers: {
ollama: {
api: "ollama",
baseUrl: OLLAMA_BASE_URL,
apiKey: "ollama-local",
models: [],
},
},
},
},
null,
2,
),
);
return await run({ root });
} finally {
await fs.rm(root, { recursive: true, force: true });
}
}
async function runOpenClawCli(args: string[], env: NodeJS.ProcessEnv) {
const outputRoot = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-ollama-cli-output-"));
const stdoutPath = path.join(outputRoot, "stdout.txt");
const stderrPath = path.join(outputRoot, "stderr.txt");
const stdoutFd = fsSync.openSync(stdoutPath, "w");
const stderrFd = fsSync.openSync(stderrPath, "w");
let stdoutClosed = false;
let stderrClosed = false;
try {
const result = spawnSync(process.execPath, ["openclaw.mjs", ...args], {
cwd: process.cwd(),
env,
timeout: 90_000,
stdio: ["ignore", stdoutFd, stderrFd],
});
fsSync.closeSync(stdoutFd);
stdoutClosed = true;
fsSync.closeSync(stderrFd);
stderrClosed = true;
return {
exitCode: result.status ?? (result.error ? 1 : 0),
stdout: fsSync.readFileSync(stdoutPath, "utf8"),
stderr: fsSync.readFileSync(stderrPath, "utf8"),
};
} finally {
if (!stdoutClosed) {
fsSync.closeSync(stdoutFd);
}
if (!stderrClosed) {
fsSync.closeSync(stderrFd);
}
fsSync.rmSync(outputRoot, { recursive: true, force: true });
}
}
function parseJsonEnvelope(stdout: string): Record<string, unknown> {
const trimmed = stdout.trim();
const jsonStart = trimmed.lastIndexOf("\n{");
const rawJson = jsonStart >= 0 ? trimmed.slice(jsonStart + 1) : trimmed;
return JSON.parse(rawJson) as Record<string, unknown>;
}
function buildCliEnv(root: string): NodeJS.ProcessEnv {
return {
PATH: process.env.PATH,
HOME: process.env.HOME,
USER: process.env.USER,
TMPDIR: process.env.TMPDIR,
NODE_PATH: process.env.NODE_PATH,
NODE_OPTIONS: process.env.NODE_OPTIONS,
OPENCLAW_LIVE_TEST: "1",
OPENCLAW_LIVE_OLLAMA: "1",
OPENCLAW_LIVE_OLLAMA_WEB_SEARCH: "0",
OPENCLAW_STATE_DIR: path.join(root, "state"),
OPENCLAW_CONFIG_PATH: path.join(root, "openclaw.json"),
OPENCLAW_NO_RESPAWN: "1",
OPENCLAW_TEST_FAST: "1",
OLLAMA_API_KEY: "ollama-local",
};
}
describe.skipIf(!LIVE)("ollama live", () => {
it("runs infer model run through the local CLI path without PI model discovery", async () => {
await withTempOpenClawState(async ({ root }) => {
const result = await runOpenClawCli(
[
"infer",
"model",
"run",
"--local",
"--model",
`ollama/${CHAT_MODEL}`,
"--prompt",
"Reply with exactly one word: pong",
"--json",
],
buildCliEnv(root),
);
expect(result.exitCode).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 {
ok?: boolean;
transport?: string;
provider?: string;
model?: string;
outputs?: Array<{ text?: string }>;
};
expect(payload).toMatchObject({
ok: true,
transport: "local",
provider: "ollama",
model: CHAT_MODEL,
});
expect(payload.outputs?.[0]?.text?.trim().length ?? 0).toBeGreaterThan(0);
});
}, 120_000);
it("runs native chat with a custom provider prefix and normalized tool schemas", async () => {
const streamFn = createOllamaStreamFn(OLLAMA_BASE_URL);
let payload: