mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:20:45 +00:00
fix(cli): streamline local model probes
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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". ' +
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user