Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
# Ollama Provider
Bundled provider plugin for Ollama discovery and setup.

35
extensions/ollama/api.ts Normal file
View File

@@ -0,0 +1,35 @@
// Ollama API module exposes the plugin public contract.
export {
OLLAMA_DEFAULT_BASE_URL,
OLLAMA_DEFAULT_CONTEXT_WINDOW,
OLLAMA_DEFAULT_COST,
OLLAMA_DEFAULT_MAX_TOKENS,
OLLAMA_DEFAULT_MODEL,
} from "./src/defaults.js";
export {
buildOllamaModelDefinition,
enrichOllamaModelsWithContext,
fetchOllamaModels,
isReasoningModelHeuristic,
queryOllamaContextWindow,
queryOllamaModelShowInfo,
resolveOllamaApiBase,
type OllamaModelShowInfo,
type OllamaModelWithContext,
type OllamaTagModel,
type OllamaTagsResponse,
} from "./src/provider-models.js";
export {
buildOllamaProvider,
configureOllamaNonInteractive,
ensureOllamaModelPulled,
promptAndConfigureOllama,
} from "./src/setup.js";
export {
buildOllamaChatRequest,
createConfiguredOllamaCompatStreamWrapper,
isOllamaCompatProvider,
resolveOllamaCompatNumCtxEnabled,
shouldInjectOllamaCompatNumCtx,
wrapOllamaCompatNumCtx,
} from "./src/stream.js";

View File

@@ -0,0 +1,182 @@
// Ollama tests cover doctor contract config compatibility.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract-api.js";
type ModelDefinition = NonNullable<
NonNullable<OpenClawConfig["models"]>["providers"]
>[string]["models"][number];
const cloudModel: ModelDefinition = {
id: "kimi-k2.5:cloud",
name: "Kimi K2.5 Cloud",
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 8192,
};
function readOllamaCloudProvider(config: OpenClawConfig): Record<string, unknown> | undefined {
return config.models?.providers?.["ollama-cloud"] as Record<string, unknown> | undefined;
}
describe("ollama doctor contract", () => {
it("detects retired Ollama Cloud provider endpoints", () => {
expect(legacyConfigRules[0]?.match({ baseUrl: "https://ai.ollama.com" })).toBe(true);
expect(legacyConfigRules[0]?.match({ baseUrl: "https://ollama.com" })).toBe(false);
});
it("migrates retired Ollama Cloud provider baseUrl to the canonical endpoint", () => {
const config = {
models: {
providers: {
"ollama-cloud": {
baseUrl: "https://ai.ollama.com",
api: "ollama",
models: [cloudModel],
},
ollama: {
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
},
},
},
} as OpenClawConfig;
const result = normalizeCompatibilityConfig({ cfg: config });
expect(result.changes).toEqual([
"Updated models.providers.ollama-cloud.baseUrl from the retired Ollama Cloud endpoint to https://ollama.com.",
]);
expect(readOllamaCloudProvider(result.config)).toEqual({
baseUrl: "https://ollama.com",
api: "ollama",
models: [cloudModel],
});
expect(readOllamaCloudProvider(config)?.baseUrl).toBe("https://ai.ollama.com");
});
it("removes retired Ollama Cloud provider baseURL aliases when canonical baseUrl is present", () => {
const config = {
models: {
providers: {
"ollama-cloud": {
baseUrl: "https://ollama.com",
baseURL: "https://ai.ollama.com/",
api: "ollama",
models: [],
},
},
},
} as OpenClawConfig;
const result = normalizeCompatibilityConfig({ cfg: config });
expect(result.changes).toEqual([
"Removed retired models.providers.ollama-cloud.baseURL while preserving models.providers.ollama-cloud.baseUrl.",
]);
expect(readOllamaCloudProvider(result.config)).toEqual({
baseUrl: "https://ollama.com",
api: "ollama",
models: [],
});
expect(readOllamaCloudProvider(config)).toEqual({
baseUrl: "https://ollama.com",
baseURL: "https://ai.ollama.com/",
api: "ollama",
models: [],
});
});
it("migrates retired Ollama Cloud provider baseURL aliases when canonical baseUrl is blank", () => {
const config = {
models: {
providers: {
"ollama-cloud": {
baseUrl: " ",
baseURL: "https://ai.ollama.com/",
api: "ollama",
models: [],
},
},
},
} as OpenClawConfig;
const result = normalizeCompatibilityConfig({ cfg: config });
expect(result.changes).toEqual([
"Updated models.providers.ollama-cloud.baseURL from the retired Ollama Cloud endpoint to https://ollama.com.",
]);
expect(readOllamaCloudProvider(result.config)).toEqual({
baseUrl: "https://ollama.com",
api: "ollama",
models: [],
});
expect(readOllamaCloudProvider(config)).toEqual({
baseUrl: " ",
baseURL: "https://ai.ollama.com/",
api: "ollama",
models: [],
});
});
it("preserves custom canonical baseUrl when removing retired baseURL aliases", () => {
const config = {
models: {
providers: {
"ollama-cloud": {
baseUrl: "https://custom-ollama-cloud.example.test",
baseURL: "https://ai.ollama.com/",
api: "ollama",
models: [],
},
},
},
} as OpenClawConfig;
const result = normalizeCompatibilityConfig({ cfg: config });
expect(result.changes).toEqual([
"Removed retired models.providers.ollama-cloud.baseURL while preserving models.providers.ollama-cloud.baseUrl.",
]);
expect(readOllamaCloudProvider(result.config)).toEqual({
baseUrl: "https://custom-ollama-cloud.example.test",
api: "ollama",
models: [],
});
expect(readOllamaCloudProvider(config)).toEqual({
baseUrl: "https://custom-ollama-cloud.example.test",
baseURL: "https://ai.ollama.com/",
api: "ollama",
models: [],
});
});
it("does not expose credentials or query parameters from the retired URL", () => {
const config = {
models: {
providers: {
"ollama-cloud": {
baseUrl: "https://user:password@ai.ollama.com/?token=secret",
api: "ollama",
models: [],
},
},
},
} as OpenClawConfig;
const result = normalizeCompatibilityConfig({ cfg: config });
expect(result.changes.join("\n")).not.toContain("user");
expect(result.changes.join("\n")).not.toContain("password");
expect(result.changes.join("\n")).not.toContain("secret");
expect(readOllamaCloudProvider(result.config)?.baseUrl).toBe("https://ollama.com");
});
});

View File

@@ -0,0 +1 @@
export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/config-compat.js";

File diff suppressed because it is too large Load Diff

760
extensions/ollama/index.ts Normal file
View File

@@ -0,0 +1,760 @@
// Ollama plugin entrypoint registers its OpenClaw integration.
import { collectConfiguredModelRefValues } from "@openclaw/model-catalog-core/configured-model-refs";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import {
definePluginEntry,
type OpenClawPluginApi,
type ProviderAuthContext,
type ProviderAuthMethodNonInteractiveContext,
type ProviderAuthResult,
type ProviderAugmentModelCatalogContext,
type ProviderCatalogContext,
type ProviderReplayPolicy,
type ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import {
buildApiKeyCredential,
coerceSecretRef,
isNonSecretApiKeyMarker,
} from "openclaw/plugin-sdk/provider-auth";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import type {
ModelDefinitionConfig,
ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
buildOpenAICompatibleReplayPolicy,
OPENAI_COMPATIBLE_REPLAY_HOOKS,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
buildOllamaModelDefinition,
buildOllamaProvider,
configureOllamaNonInteractive,
ensureOllamaModelPulled,
promptAndConfigureOllama,
queryOllamaModelShowInfo,
} from "./api.js";
import { resolveThinkingProfile as resolveOllamaThinkingProfile } from "./provider-policy-api.js";
import {
OLLAMA_CLOUD_BASE_URL,
OLLAMA_CLOUD_DEFAULT_MODELS,
OLLAMA_CLOUD_PROVIDER_ID,
OLLAMA_DEFAULT_BASE_URL,
OLLAMA_GLM52_CLOUD_MODEL_ID,
} from "./src/defaults.js";
import {
OLLAMA_DEFAULT_API_KEY,
OLLAMA_PROVIDER_ID,
isLocalOllamaBaseUrl,
resolveOllamaDiscoveryResult,
resolveOllamaRuntimeBaseUrl,
shouldUseSyntheticOllamaAuth,
type OllamaPluginConfig,
} from "./src/discovery-shared.js";
import {
DEFAULT_OLLAMA_EMBEDDING_MODEL,
createOllamaEmbeddingProvider,
} from "./src/embedding-provider.js";
import { ollamaMediaUnderstandingProvider } from "./src/media-understanding-provider.js";
import { ollamaMemoryEmbeddingProviderAdapter } from "./src/memory-embedding-adapter.js";
import {
createOllamaNodeHostCommands,
createOllamaNodeInferenceTool,
createOllamaNodeInvokePolicy,
} from "./src/node-inference.js";
import { readProviderBaseUrl } from "./src/provider-base-url.js";
import {
createConfiguredOllamaCompatStreamWrapper,
createConfiguredOllamaStreamFn,
resolveConfiguredOllamaProviderConfig,
} from "./src/stream.js";
import { createOllamaWebSearchProvider } from "./src/web-search-provider.js";
import { checkWsl2CrashLoopRisk } from "./src/wsl2-crash-loop-check.js";
function buildNativeOllamaReplayPolicy(): ProviderReplayPolicy {
return {
...buildOpenAICompatibleReplayPolicy("openai-completions", {
sanitizeToolCallIds: false,
}),
sanitizeToolCallIds: false,
};
}
const dynamicModelCache = new Map<string, ProviderRuntimeModel[]>();
const OLLAMA_CLOUD_DEFAULT_MODEL_REF = `${OLLAMA_CLOUD_PROVIDER_ID}/${OLLAMA_CLOUD_DEFAULT_MODELS[0]}`;
const OLLAMA_CONFIGURED_SHOW_CONCURRENCY = 4;
const OLLAMA_CONFIGURED_SHOW_MAX_MODELS = 8;
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: params.providerConfig.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 } : {}),
};
}
function stripTrailingAuthProfile(raw: string): string {
const trimmed = raw.trim();
const lastSlash = trimmed.lastIndexOf("/");
let delimiter = trimmed.indexOf("@", lastSlash + 1);
if (delimiter <= 0) {
return trimmed;
}
const suffix = () => trimmed.slice(delimiter + 1);
if (/^\d{8}(?:@|$)/.test(suffix())) {
const next = trimmed.indexOf("@", delimiter + 9);
if (next < 0) {
return trimmed;
}
delimiter = next;
}
if (/^(?:i?q\d+(?:_[a-z0-9]+)*|\d+bit)(?:@|$)/i.test(suffix())) {
const next = trimmed.indexOf("@", delimiter + 1);
if (next < 0) {
return trimmed;
}
delimiter = next;
}
const model = trimmed.slice(0, delimiter).trim();
const profile = trimmed.slice(delimiter + 1).trim();
return model && profile ? model : trimmed;
}
function needsOllamaCatalogMetadata(entry: ProviderAugmentModelCatalogContext["entries"][number]) {
const hasContextLimit = entry.contextWindow !== undefined || entry.contextTokens !== undefined;
return (
!hasContextLimit ||
entry.reasoning === undefined ||
entry.input === undefined ||
entry.compat === undefined
);
}
function readConfiguredOllamaApiKey(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed || undefined;
}
if (value && typeof value === "object" && "value" in value) {
const resolved = (value as { value?: unknown }).value;
if (typeof resolved === "string") {
const trimmed = resolved.trim();
return trimmed || undefined;
}
}
return undefined;
}
function readConcreteOllamaApiKey(value: unknown): string | undefined {
if (coerceSecretRef(value)) {
return undefined;
}
const apiKey = readConfiguredOllamaApiKey(value);
return apiKey && !isNonSecretApiKeyMarker(apiKey) ? apiKey : undefined;
}
function readEnvBackedOllamaApiKey(value: unknown, env: NodeJS.ProcessEnv): string | undefined {
const ref = coerceSecretRef(value);
if (ref?.source === "env") {
return readConcreteOllamaApiKey(env[ref.id.trim()]);
}
return undefined;
}
function isAmbientOllamaApiKeyMarker(value: string | undefined): boolean {
return value === OLLAMA_DEFAULT_API_KEY || value === "OLLAMA_API_KEY";
}
function readUsableOllamaShowApiKey(params: {
env: NodeJS.ProcessEnv;
allowAmbientEnvFallback: boolean;
explicitApiKey?: unknown;
resolved?: { apiKey?: unknown; discoveryApiKey?: unknown };
}): string | undefined {
const explicitEnvApiKey = readEnvBackedOllamaApiKey(params.explicitApiKey, params.env);
if (explicitEnvApiKey) {
return explicitEnvApiKey;
}
const explicitApiKey = readConcreteOllamaApiKey(params.explicitApiKey);
if (explicitApiKey) {
return explicitApiKey;
}
const resolvedApiKey = readConfiguredOllamaApiKey(params.resolved?.apiKey);
const canUseResolvedDiscovery =
params.allowAmbientEnvFallback || !isAmbientOllamaApiKeyMarker(resolvedApiKey);
const discoveryApiKey = readConcreteOllamaApiKey(params.resolved?.discoveryApiKey);
if (discoveryApiKey && canUseResolvedDiscovery) {
return discoveryApiKey;
}
const resolvedEnvApiKey = readEnvBackedOllamaApiKey(params.resolved?.apiKey, params.env);
if (resolvedEnvApiKey && canUseResolvedDiscovery) {
return resolvedEnvApiKey;
}
const apiKey = readConcreteOllamaApiKey(params.resolved?.apiKey);
if (apiKey) {
return apiKey;
}
return params.allowAmbientEnvFallback
? readConcreteOllamaApiKey(params.env.OLLAMA_API_KEY)
: undefined;
}
function collectConfiguredOllamaModelIds(params: {
config?: OpenClawConfig;
provider: string;
entries?: ProviderAugmentModelCatalogContext["entries"];
}): Array<{
id: string;
api?: ProviderAugmentModelCatalogContext["entries"][number]["api"];
name?: string;
}> {
const providerPrefix = `${params.provider.toLowerCase()}/`;
const models = new Map<
string,
{
id: string;
api?: ProviderAugmentModelCatalogContext["entries"][number]["api"];
name?: string;
}
>();
const addModelId = (
modelId: string,
api?: ProviderAugmentModelCatalogContext["entries"][number]["api"],
name?: string,
) => {
const trimmed = modelId.trim();
if (!trimmed || trimmed === "*") {
return;
}
const trimmedName = typeof name === "string" ? name.trim() : "";
const existing = models.get(trimmed);
if (existing) {
if ((!existing.api && api) || (!existing.name && trimmedName)) {
models.set(trimmed, {
...existing,
...(api && !existing.api ? { api } : {}),
...(trimmedName && !existing.name ? { name: trimmedName } : {}),
});
}
return;
}
models.set(trimmed, {
id: trimmed,
...(api ? { api } : {}),
...(trimmedName ? { name: trimmedName } : {}),
});
};
const addRef = (raw: unknown) => {
if (typeof raw !== "string") {
return;
}
const trimmed = stripTrailingAuthProfile(raw);
if (!trimmed.toLowerCase().startsWith(providerPrefix)) {
return;
}
const modelId = trimmed.slice(providerPrefix.length).trim();
addModelId(modelId);
};
for (const ref of collectConfiguredModelRefValues(params.config)) {
addRef(ref);
}
for (const entry of params.entries ?? []) {
if (
entry.provider.toLowerCase() === params.provider.toLowerCase() &&
entry.id.trim() &&
needsOllamaCatalogMetadata(entry)
) {
addModelId(entry.id.trim(), entry.api, entry.name);
}
}
return [...models.values()];
}
function buildStaticOllamaCloudProvider(): ModelProviderConfig {
return {
baseUrl: OLLAMA_CLOUD_BASE_URL,
api: "ollama",
models: OLLAMA_CLOUD_DEFAULT_MODELS.map((model) => buildOllamaModelDefinition(model)),
};
}
async function buildOllamaCloudProvider(apiKey?: string): Promise<ModelProviderConfig> {
const discovered = await buildOllamaProvider(OLLAMA_CLOUD_BASE_URL, {
...(apiKey ? { apiKey } : {}),
quiet: true,
});
if (!discovered.models?.length) {
return buildStaticOllamaCloudProvider();
}
if (!apiKey || discovered.models.some((model) => model.id === OLLAMA_GLM52_CLOUD_MODEL_ID)) {
return discovered;
}
const showInfo = await queryOllamaModelShowInfo(
OLLAMA_CLOUD_BASE_URL,
OLLAMA_GLM52_CLOUD_MODEL_ID,
{ apiKey },
);
if (typeof showInfo.contextWindow !== "number" && (showInfo.capabilities?.length ?? 0) === 0) {
return discovered;
}
return {
...discovered,
models: [
...discovered.models,
buildOllamaModelDefinition(
OLLAMA_GLM52_CLOUD_MODEL_ID,
showInfo.contextWindow,
showInfo.capabilities,
),
],
};
}
async function resolveRequestedDynamicOllamaModel(params: {
provider: string;
providerConfig: ModelProviderConfig;
modelId: string;
showApiKey?: string;
}): Promise<ProviderRuntimeModel | undefined> {
const showBaseUrl = readProviderBaseUrl(params.providerConfig) ?? OLLAMA_DEFAULT_BASE_URL;
const showInfo = params.showApiKey
? await queryOllamaModelShowInfo(showBaseUrl, params.modelId, { apiKey: params.showApiKey })
: await queryOllamaModelShowInfo(showBaseUrl, params.modelId);
if (typeof showInfo.contextWindow !== "number" && (showInfo.capabilities?.length ?? 0) === 0) {
return undefined;
}
return toDynamicOllamaModel({
provider: params.provider,
providerConfig: params.providerConfig,
model: buildOllamaModelDefinition(
params.modelId,
showInfo.contextWindow,
showInfo.capabilities,
),
});
}
async function augmentConfiguredOllamaCatalogModels(params: {
config?: OpenClawConfig;
defaultBaseUrl: string;
env: NodeJS.ProcessEnv;
provider: string;
entries: ProviderAugmentModelCatalogContext["entries"];
resolveProviderApiKey: ProviderAugmentModelCatalogContext["resolveProviderApiKey"];
}): Promise<ProviderAugmentModelCatalogContext["entries"]> {
const models = collectConfiguredOllamaModelIds({
config: params.config,
provider: params.provider,
entries: params.entries,
});
if (models.length === 0) {
return [];
}
const configuredProvider = resolveConfiguredOllamaProviderConfig({
config: params.config,
providerId: params.provider,
});
const baseUrl = readProviderBaseUrl(configuredProvider) ?? params.defaultBaseUrl;
const isLocalBaseUrl = isLocalOllamaBaseUrl(baseUrl);
const showApiKey = readUsableOllamaShowApiKey({
env: params.env,
allowAmbientEnvFallback: !isLocalBaseUrl,
explicitApiKey: configuredProvider?.apiKey,
resolved: params.resolveProviderApiKey?.(params.provider),
});
if (!isLocalBaseUrl && !showApiKey) {
return [];
}
const providerConfig: ModelProviderConfig = {
...configuredProvider,
models: configuredProvider?.models ?? [],
baseUrl,
api: configuredProvider?.api ?? "ollama",
};
const entries: ProviderAugmentModelCatalogContext["entries"] = [];
const modelsToProbe = models.slice(0, OLLAMA_CONFIGURED_SHOW_MAX_MODELS);
for (let index = 0; index < modelsToProbe.length; index += OLLAMA_CONFIGURED_SHOW_CONCURRENCY) {
const batch = modelsToProbe.slice(index, index + OLLAMA_CONFIGURED_SHOW_CONCURRENCY);
const rows = await Promise.all(
batch.map(async (model) => {
const requested = await resolveRequestedDynamicOllamaModel({
provider: params.provider,
providerConfig,
modelId: model.id,
showApiKey,
});
return requested
? {
id: requested.id,
name: model.name ?? requested.name,
provider: requested.provider,
api: model.api ?? providerConfig.api,
reasoning: requested.reasoning,
input: requested.input,
contextWindow: requested.contextWindow,
compat: requested.compat,
}
: undefined;
}),
);
for (const row of rows) {
if (row) {
entries.push(row);
}
}
}
return entries;
}
export default definePluginEntry({
id: "ollama",
name: "Ollama Provider",
description: "Bundled Ollama provider plugin",
register(api: OpenClawPluginApi) {
const startupPluginConfig = (api.pluginConfig ?? {}) as OllamaPluginConfig;
if (api.registrationMode === "full") {
void checkWsl2CrashLoopRisk(api.logger);
}
api.registerMemoryEmbeddingProvider(ollamaMemoryEmbeddingProviderAdapter);
api.registerMediaUnderstandingProvider(ollamaMediaUnderstandingProvider);
if (startupPluginConfig.nodeInference?.enabled !== false) {
for (const command of createOllamaNodeHostCommands()) {
api.registerNodeHostCommand(command);
}
}
api.registerNodeInvokePolicy(createOllamaNodeInvokePolicy());
api.registerTool(createOllamaNodeInferenceTool(api));
const resolveCurrentPluginConfig = (config?: OpenClawConfig): OllamaPluginConfig => {
const runtimePluginConfig = resolvePluginConfigObject(config, "ollama");
if (runtimePluginConfig) {
return runtimePluginConfig as OllamaPluginConfig;
}
return config ? {} : startupPluginConfig;
};
api.registerWebSearchProvider(createOllamaWebSearchProvider());
api.registerProvider({
id: OLLAMA_CLOUD_PROVIDER_ID,
label: "Ollama Cloud",
docsPath: "/providers/ollama",
envVars: ["OLLAMA_API_KEY"],
auth: [
createProviderApiKeyAuthMethod({
providerId: OLLAMA_CLOUD_PROVIDER_ID,
methodId: "api-key",
label: "Ollama Cloud API key",
hint: "Hosted models via ollama.com",
optionKey: "ollamaCloudApiKey",
flagName: "--ollama-cloud-api-key",
envVar: "OLLAMA_API_KEY",
promptMessage: "Enter Ollama Cloud API key",
defaultModel: OLLAMA_CLOUD_DEFAULT_MODEL_REF,
noteTitle: "Ollama Cloud",
noteMessage: "Manage API keys at https://ollama.com/settings/keys",
wizard: {
choiceId: "ollama-cloud",
choiceLabel: "Ollama Cloud",
choiceHint: "Hosted models via ollama.com",
groupId: "ollama",
groupLabel: "Ollama",
groupHint: "Cloud and local open models",
},
}),
],
catalog: {
order: "simple",
run: async (ctx: ProviderCatalogContext) => {
const resolvedAuth = ctx.resolveProviderApiKey(OLLAMA_CLOUD_PROVIDER_ID);
const apiKey = resolvedAuth.apiKey ?? resolvedAuth.discoveryApiKey;
if (!apiKey) {
return null;
}
const discoveryApiKey = readUsableOllamaShowApiKey({
env: ctx.env,
allowAmbientEnvFallback: true,
resolved: resolvedAuth,
});
return {
provider: {
...(await buildOllamaCloudProvider(discoveryApiKey)),
apiKey,
},
};
},
},
staticCatalog: {
order: "simple",
run: async () => ({
provider: buildStaticOllamaCloudProvider(),
}),
},
createStreamFn: ({ config, model, provider }) => {
if (model.api !== "ollama") {
return undefined;
}
return createConfiguredOllamaStreamFn({
model,
providerBaseUrl:
readProviderBaseUrl(
resolveConfiguredOllamaProviderConfig({ config, providerId: provider }),
) ?? OLLAMA_CLOUD_BASE_URL,
});
},
...OPENAI_COMPATIBLE_REPLAY_HOOKS,
buildReplayPolicy: (ctx) =>
ctx.modelApi === "ollama"
? buildNativeOllamaReplayPolicy()
: buildOpenAICompatibleReplayPolicy(ctx.modelApi),
resolveReasoningOutputMode: () => "native",
resolveThinkingProfile: resolveOllamaThinkingProfile,
wrapStreamFn: createConfiguredOllamaCompatStreamWrapper,
resolveDynamicModel: ({ provider, modelId }) => {
const cloudProvider = buildStaticOllamaCloudProvider();
const model = cloudProvider.models?.find((entry) => entry.id === modelId);
return model
? toDynamicOllamaModel({ provider, providerConfig: cloudProvider, model })
: undefined;
},
augmentModelCatalog: async (ctx) =>
await augmentConfiguredOllamaCatalogModels({
config: ctx.config,
defaultBaseUrl: OLLAMA_CLOUD_BASE_URL,
env: ctx.env,
provider: OLLAMA_CLOUD_PROVIDER_ID,
entries: ctx.entries,
resolveProviderApiKey: ctx.resolveProviderApiKey,
}),
matchesContextOverflowError: ({ errorMessage }) =>
/\bollama\b.*(?:context length|too many tokens|context window)/i.test(errorMessage) ||
/\btruncating input\b.*\btoo long\b/i.test(errorMessage),
buildUnknownModelHint: () =>
"Ollama Cloud requires an API key. " +
'Set OLLAMA_API_KEY or run "openclaw onboard --auth-choice ollama-cloud". ' +
"See: https://docs.openclaw.ai/providers/ollama",
});
api.registerProvider({
id: OLLAMA_PROVIDER_ID,
label: "Ollama",
docsPath: "/providers/ollama",
envVars: ["OLLAMA_API_KEY"],
auth: [
{
id: "local",
label: "Ollama",
hint: "Cloud and local open models",
kind: "custom",
run: async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => {
const result = await promptAndConfigureOllama({
cfg: ctx.config,
env: ctx.env,
opts: ctx.opts as Record<string, unknown> | undefined,
prompter: ctx.prompter,
secretInputMode: ctx.secretInputMode,
allowSecretRefPrompt: ctx.allowSecretRefPrompt,
});
return {
profiles: [
{
profileId: "ollama:default",
credential: buildApiKeyCredential(
OLLAMA_PROVIDER_ID,
result.credential,
undefined,
result.credentialMode
? {
secretInputMode: result.credentialMode,
config: ctx.config,
}
: undefined,
),
},
],
configPatch: result.config,
};
},
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => {
return await configureOllamaNonInteractive({
nextConfig: ctx.config,
opts: {
customBaseUrl: ctx.opts.customBaseUrl as string | undefined,
customModelId: ctx.opts.customModelId as string | undefined,
},
runtime: ctx.runtime,
agentDir: ctx.agentDir,
});
},
},
],
catalog: {
order: "late",
run: async (ctx: ProviderCatalogContext) =>
await resolveOllamaDiscoveryResult({
ctx,
pluginConfig: resolveCurrentPluginConfig(ctx.config),
buildProvider: buildOllamaProvider,
}),
},
wizard: {
setup: {
choiceId: "ollama",
choiceLabel: "Ollama",
choiceHint: "Cloud and local open models",
groupId: "ollama",
groupLabel: "Ollama",
groupHint: "Cloud and local open models",
methodId: "local",
modelSelection: {
promptWhenAuthChoiceProvided: true,
allowKeepCurrent: false,
},
},
modelPicker: {
label: "Ollama (custom)",
hint: "Detect models from a local or remote Ollama instance",
methodId: "local",
},
},
onModelSelected: async ({ config, model, prompter }) => {
if (!model.startsWith("ollama/")) {
return;
}
await ensureOllamaModelPulled({ config, model, prompter });
},
createStreamFn: ({ config, model, provider }) => {
if (model.api !== "ollama") {
return undefined;
}
return createConfiguredOllamaStreamFn({
model,
providerBaseUrl: readProviderBaseUrl(
resolveConfiguredOllamaProviderConfig({ config, providerId: provider }),
),
});
},
...OPENAI_COMPATIBLE_REPLAY_HOOKS,
buildReplayPolicy: (ctx) =>
ctx.modelApi === "ollama"
? buildNativeOllamaReplayPolicy()
: buildOpenAICompatibleReplayPolicy(ctx.modelApi),
resolveReasoningOutputMode: () => "native",
resolveThinkingProfile: resolveOllamaThinkingProfile,
wrapStreamFn: createConfiguredOllamaCompatStreamWrapper,
augmentModelCatalog: async (ctx) =>
await augmentConfiguredOllamaCatalogModels({
config: ctx.config,
defaultBaseUrl: OLLAMA_DEFAULT_BASE_URL,
env: ctx.env,
provider: OLLAMA_PROVIDER_ID,
entries: ctx.entries,
resolveProviderApiKey: ctx.resolveProviderApiKey,
}),
createEmbeddingProvider: async ({ config, model, provider: embeddingProvider, remote }) => {
const { provider, client } = await createOllamaEmbeddingProvider({
config,
remote,
model: model || DEFAULT_OLLAMA_EMBEDDING_MODEL,
provider: embeddingProvider || OLLAMA_PROVIDER_ID,
});
return {
...provider,
client,
};
},
matchesContextOverflowError: ({ errorMessage }) =>
/\bollama\b.*(?:context length|too many tokens|context window)/i.test(errorMessage) ||
/\btruncating input\b.*\btoo long\b/i.test(errorMessage),
resolveSyntheticAuth: ({ provider, providerConfig }) => {
if (!shouldUseSyntheticOllamaAuth(providerConfig)) {
return undefined;
}
return {
apiKey: OLLAMA_DEFAULT_API_KEY,
source: `models.providers.${provider ?? OLLAMA_PROVIDER_ID} (synthetic local key)`,
mode: "api-key",
};
},
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 });
const dynamicApi = providerConfig?.api ?? provider.api;
const dynamicProvider = {
...provider,
baseUrl: resolveOllamaRuntimeBaseUrl({
api: dynamicApi,
configuredBaseUrl: baseUrl,
discoveredBaseUrl: provider.baseUrl,
}),
api: dynamicApi,
};
const dynamicModels = (dynamicProvider.models ?? []).map((model) =>
toDynamicOllamaModel({
provider: ctx.provider,
providerConfig: dynamicProvider,
model,
}),
);
if (!dynamicModels.some((model) => model.id === ctx.modelId)) {
const requestedModel = await resolveRequestedDynamicOllamaModel({
provider: ctx.provider,
providerConfig: dynamicProvider,
modelId: ctx.modelId,
});
if (requestedModel) {
dynamicModels.push(requestedModel);
}
}
dynamicModelCache.set(buildDynamicCacheKey(ctx.provider, baseUrl), dynamicModels);
},
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". ' +
"See: https://docs.openclaw.ai/providers/ollama",
});
},
});

View File

@@ -0,0 +1,336 @@
// Ollama tests cover ollama plugin behavior.
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 { isLocalOllamaBaseUrl } from "./src/discovery-shared.js";
import { createOllamaEmbeddingProvider } from "./src/embedding-provider.js";
import { createOllamaStreamFn } from "./src/stream.js";
import { createOllamaWebSearchProvider } from "./src/web-search-provider.js";
const LIVE = process.env.OPENCLAW_LIVE_TEST === "1" && process.env.OPENCLAW_LIVE_OLLAMA === "1";
const OLLAMA_BASE_URL =
process.env.OPENCLAW_LIVE_OLLAMA_BASE_URL?.trim() || "http://127.0.0.1:11434";
const CHAT_MODEL = process.env.OPENCLAW_LIVE_OLLAMA_MODEL?.trim() || "llama3.2:latest";
const EMBEDDING_MODEL =
process.env.OPENCLAW_LIVE_OLLAMA_EMBED_MODEL?.trim() || "embeddinggemma:latest";
const PROVIDER_ID = process.env.OPENCLAW_LIVE_OLLAMA_PROVIDER_ID?.trim() || "ollama-live-custom";
const RUN_WEB_SEARCH = process.env.OPENCLAW_LIVE_OLLAMA_WEB_SEARCH !== "0";
const RUN_EMBEDDINGS =
process.env.OPENCLAW_LIVE_OLLAMA_EMBEDDINGS === "1" ||
(process.env.OPENCLAW_LIVE_OLLAMA_EMBEDDINGS !== "0" && !isOllamaCloudBaseUrl(OLLAMA_BASE_URL));
const OLLAMA_CONFIG_API_KEY = isLocalOllamaBaseUrl(OLLAMA_BASE_URL)
? "ollama-local"
: "OLLAMA_API_KEY";
function isOllamaCloudBaseUrl(baseUrl: string): boolean {
try {
const parsed = new URL(baseUrl);
return parsed.protocol === "https:" && parsed.hostname === "ollama.com";
} catch {
return false;
}
}
function requireOllamaRuntimeApiKey(): string | undefined {
if (OLLAMA_CONFIG_API_KEY !== "OLLAMA_API_KEY") {
return undefined;
}
const apiKey = process.env.OLLAMA_API_KEY?.trim();
if (!apiKey) {
throw new Error(
"OPENCLAW_LIVE_OLLAMA_BASE_URL points at a remote Ollama host; set OLLAMA_API_KEY.",
);
}
return apiKey;
}
function resolveOllamaDirectApiKey(): string {
return requireOllamaRuntimeApiKey() ?? "ollama-local";
}
async function collectStreamEvents<T>(stream: AsyncIterable<T>): Promise<T[]> {
const events: T[] = [];
for await (const event of stream) {
events.push(event);
}
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_CONFIG_API_KEY,
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 hasBuiltEntry = ["entry.js", "entry.mjs"].some((entry) =>
fsSync.existsSync(path.join(process.cwd(), "dist", entry)),
);
const sourceRunnerAvailable = !hasBuiltEntry;
const commandArgs = sourceRunnerAvailable
? ["scripts/run-node.mjs", ...args]
: ["openclaw.mjs", ...args];
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, commandArgs, {
cwd: process.cwd(),
env,
timeout: sourceRunnerAvailable ? 180_000 : 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 {
const apiKey = requireOllamaRuntimeApiKey();
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",
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false",
pnpm_config_verify_deps_before_run: "false",
OLLAMA_API_KEY: apiKey ?? "ollama-local",
};
}
describe.skipIf(!LIVE)("ollama live", () => {
it("runs infer model run through the local CLI path without static 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.ok).toBe(true);
expect(payload.transport).toBe("local");
expect(payload.provider).toBe("ollama");
expect(payload.model).toBe(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:
| {
model?: string;
think?: boolean;
keep_alive?: string;
options?: { num_ctx?: number; top_p?: number };
tools?: Array<{
function?: {
parameters?: {
properties?: Record<string, { type?: string }>;
};
};
}>;
}
| undefined;
const stream = streamFn(
{
id: `${PROVIDER_ID}/${CHAT_MODEL}`,
api: "ollama",
provider: PROVIDER_ID,
contextWindow: 8192,
params: { num_ctx: 4096, top_p: 0.9, thinking: false, keep_alive: "5m" },
requestTimeoutMs: 120_000,
} as never,
{
messages: [{ role: "user", content: "Reply exactly OK." }],
tools: [
{
name: "lookup_weather",
description: "Lookup weather for a city.",
parameters: {
properties: {
city: { enum: ["London", "Vienna"] },
units: { enum: ["metric", "imperial"] },
options: {
properties: {
includeWind: { type: "boolean" },
},
},
},
required: ["city"],
},
},
],
} as never,
{
maxTokens: 32,
temperature: 0,
onPayload: (body: unknown) => {
payload = body as NonNullable<typeof payload>;
},
apiKey: requireOllamaRuntimeApiKey(),
} as never,
);
const events = await collectStreamEvents(await Promise.resolve(stream));
const error = events.find((event) => (event as { type?: string }).type === "error");
expect(error).toBeUndefined();
expect(events.map((event) => (event as { type?: string }).type)).toContain("done");
expect(payload?.model).toBe(CHAT_MODEL);
expect(payload?.options?.num_ctx).toBe(4096);
expect(payload?.options?.top_p).toBe(1);
expect(payload?.think).toBe(false);
expect(payload?.keep_alive).toBe("5m");
const properties = payload?.tools?.[0]?.function?.parameters?.properties;
expect(properties?.city?.type).toBe("string");
expect(properties?.units?.type).toBe("string");
expect(properties?.options?.type).toBe("object");
}, 60_000);
it.skipIf(!RUN_EMBEDDINGS)(
"embeds a batch through the current Ollama endpoint for custom providers",
async () => {
const { client } = await createOllamaEmbeddingProvider({
config: {
models: {
providers: {
[PROVIDER_ID]: {
api: "ollama",
baseUrl: OLLAMA_BASE_URL,
apiKey: resolveOllamaDirectApiKey(),
},
},
},
},
provider: PROVIDER_ID,
model: `${PROVIDER_ID}/${EMBEDDING_MODEL}`,
} as never);
const embeddings = await client.embedBatch(["hello", "world"]);
expect(embeddings).toHaveLength(2);
expect(embeddings[0]?.length ?? 0).toBeGreaterThan(0);
expect(embeddings[1]?.length).toBe(embeddings[0]?.length);
expect(Math.hypot(...embeddings[0])).toBeGreaterThan(0.99);
expect(Math.hypot(...embeddings[0])).toBeLessThan(1.01);
},
45_000,
);
it.skipIf(!RUN_WEB_SEARCH)(
"searches through Ollama web search fallback endpoints",
async () => {
const provider = createOllamaWebSearchProvider();
const tool = provider.createTool({
config: {
models: {
providers: {
ollama: {
api: "ollama",
baseUrl: OLLAMA_BASE_URL,
apiKey: resolveOllamaDirectApiKey(),
},
},
},
},
} as never);
if (!tool) {
throw new Error("Ollama web-search provider did not create a tool");
}
const result = (await tool.execute({
query: "OpenClaw documentation",
count: 1,
})) as {
provider?: string;
results?: Array<{ url?: string }>;
};
expect(result.provider).toBe("ollama");
expect(result.results?.length ?? 0).toBeGreaterThan(0);
expect(result.results?.[0]?.url).toMatch(/^https?:\/\//);
},
45_000,
);
});

View File

@@ -0,0 +1,199 @@
{
"id": "ollama",
"icon": "https://cdn.simpleicons.org/ollama",
"activation": {
"onStartup": true
},
"enabledByDefault": true,
"providers": ["ollama", "ollama-cloud"],
"providerCatalogEntry": "./provider-discovery.ts",
"providerRequest": {
"providers": {
"ollama": {
"family": "ollama"
},
"ollama-cloud": {
"family": "ollama-cloud"
}
}
},
"modelPricing": {
"providers": {
"ollama": {
"external": false
},
"ollama-cloud": {
"external": true
}
}
},
"syntheticAuthRefs": ["ollama"],
"nonSecretAuthMarkers": ["ollama-local"],
"setup": {
"providers": [
{
"id": "ollama",
"envVars": ["OLLAMA_API_KEY"]
},
{
"id": "ollama-cloud",
"envVars": ["OLLAMA_API_KEY"]
}
]
},
"providerAuthChoices": [
{
"provider": "ollama",
"method": "local",
"choiceId": "ollama",
"choiceLabel": "Ollama",
"choiceHint": "Cloud and local open models",
"groupId": "ollama",
"groupLabel": "Ollama",
"groupHint": "Cloud and local open models"
},
{
"provider": "ollama-cloud",
"method": "api-key",
"choiceId": "ollama-cloud",
"choiceLabel": "Ollama Cloud",
"choiceHint": "Hosted models via ollama.com",
"groupId": "ollama",
"groupLabel": "Ollama",
"groupHint": "Cloud and local open models",
"optionKey": "ollamaCloudApiKey",
"cliFlag": "--ollama-cloud-api-key",
"cliOption": "--ollama-cloud-api-key <key>",
"cliDescription": "Ollama Cloud API key"
}
],
"modelCatalog": {
"runtimeAugment": true,
"providers": {
"ollama-cloud": {
"baseUrl": "https://ollama.com",
"api": "ollama",
"models": [
{
"id": "kimi-k2.5:cloud",
"name": "kimi-k2.5:cloud",
"reasoning": true,
"input": ["text"],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 128000,
"maxTokens": 8192,
"compat": {
"supportsTools": true,
"supportsUsageInStreaming": true
}
},
{
"id": "minimax-m2.7:cloud",
"name": "minimax-m2.7:cloud",
"reasoning": true,
"input": ["text"],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 128000,
"maxTokens": 8192,
"compat": {
"supportsTools": true,
"supportsUsageInStreaming": true
}
},
{
"id": "glm-5.1:cloud",
"name": "glm-5.1:cloud",
"reasoning": true,
"input": ["text"],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 128000,
"maxTokens": 8192,
"compat": {
"supportsTools": true,
"supportsUsageInStreaming": true
}
},
{
"id": "glm-5.2:cloud",
"name": "glm-5.2:cloud",
"reasoning": true,
"input": ["text"],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
},
"contextWindow": 1000000,
"maxTokens": 8192,
"compat": {
"supportsTools": true,
"supportsUsageInStreaming": true
}
}
]
}
},
"discovery": {
"ollama-cloud": "refreshable"
}
},
"contracts": {
"memoryEmbeddingProviders": ["ollama"],
"tools": ["node_inference"],
"webSearchProviders": ["ollama"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"discovery": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" }
}
},
"nodeInference": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" }
}
}
}
},
"uiHints": {
"discovery": {
"label": "Model Discovery",
"help": "Plugin-owned controls for Ollama model auto-discovery."
},
"discovery.enabled": {
"label": "Enable Discovery",
"help": "When false, OpenClaw keeps the Ollama plugin available but skips implicit startup discovery of ambient local or remote Ollama models."
},
"nodeInference": {
"label": "Node Inference",
"help": "Controls whether this node host advertises its local Ollama models to agents."
},
"nodeInference.enabled": {
"label": "Enable Node Inference",
"help": "When false, this node host does not advertise or accept Ollama node-inference commands."
}
}
}

View File

@@ -0,0 +1,18 @@
{
"name": "@openclaw/ollama-provider",
"version": "2026.6.11",
"private": true,
"description": "OpenClaw Ollama provider plugin",
"type": "module",
"dependencies": {
"typebox": "1.3.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,30 @@
// Ollama tests cover provider discovery.import guard plugin behavior.
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const repoRoot = path.resolve(import.meta.dirname, "../..");
function readPluginSource(relativePath: string): string {
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
}
describe("ollama provider discovery import surface", () => {
it("stays off the full provider runtime graph", () => {
const source = readPluginSource("extensions/ollama/provider-discovery.ts");
for (const forbidden of [
"./index",
"./api",
"./runtime-api",
"./src/setup",
"./src/stream",
"./src/embedding-provider",
"./src/memory-embedding-adapter",
"./src/web-search-provider",
"openclaw/plugin-sdk/plugin-entry",
]) {
expect(source, `provider discovery must not import ${forbidden}`).not.toContain(forbidden);
}
});
});

View File

@@ -0,0 +1,665 @@
// Ollama tests cover provider discovery plugin behavior.
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { clearLiveCatalogCacheForTests } from "openclaw/plugin-sdk/provider-catalog-shared";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-onboard";
import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ollamaProviderDiscovery } from "./provider-discovery.js";
const OLLAMA_LOCAL_AUTH_MARKER = "ollama-local";
afterEach(() => {
clearLiveCatalogCacheForTests();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe("Ollama provider", () => {
const createAgentDir = () => mkdtempSync(join(tmpdir(), "openclaw-test-"));
const enableDiscoveryEnv = () => {
vi.stubEnv("VITEST", "");
vi.stubEnv("NODE_ENV", "development");
};
const fetchCallUrls = (fetchMock: ReturnType<typeof vi.fn>): string[] =>
fetchMock.mock.calls.map(([input]) => String(input));
const countFetchCallUrls = (fetchMock: ReturnType<typeof vi.fn>, suffix: string): number =>
fetchCallUrls(fetchMock).reduce((count, url) => count + (url.endsWith(suffix) ? 1 : 0), 0);
const countWarnCallsIncluding = (warnSpy: ReturnType<typeof vi.spyOn>, text: string): number => {
let count = 0;
for (const [message] of warnSpy.mock.calls) {
if (String(message).includes(text)) {
count++;
}
}
return count;
};
const expectDiscoveryCallCounts = (
fetchMock: ReturnType<typeof vi.fn>,
params: { tags: number; show: number },
) => {
expect(countFetchCallUrls(fetchMock, "/api/tags")).toBe(params.tags);
expect(countFetchCallUrls(fetchMock, "/api/show")).toBe(params.show);
};
async function withOllamaApiKey<T>(run: () => Promise<T>): Promise<T> {
process.env.OLLAMA_API_KEY = "test-key"; // pragma: allowlist secret
try {
return await run();
} finally {
delete process.env.OLLAMA_API_KEY;
}
}
async function runOllamaCatalog(params: {
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
resolveProviderApiKey?: () => { apiKey: string | undefined; discoveryApiKey?: string };
}) {
const env: NodeJS.ProcessEnv = {
...process.env,
VITEST: "1",
NODE_ENV: "test",
...params.env,
};
const result = await ollamaProviderDiscovery.catalog.run({
config: params.config ?? {},
agentDir: createAgentDir(),
env,
resolveProviderApiKey:
params.resolveProviderApiKey ??
(() => ({
apiKey: env.OLLAMA_API_KEY?.trim() ? env.OLLAMA_API_KEY : undefined,
})),
resolveProviderAuth: () => ({
apiKey: env.OLLAMA_API_KEY?.trim() ? env.OLLAMA_API_KEY : undefined,
mode: env.OLLAMA_API_KEY?.trim() ? "api_key" : "none",
source: env.OLLAMA_API_KEY?.trim() ? "env" : "none",
}),
});
return result && "provider" in result ? result.provider : undefined;
}
async function withoutAmbientOllamaEnv<T>(run: () => Promise<T>): Promise<T> {
const previous = process.env.OLLAMA_API_KEY;
delete process.env.OLLAMA_API_KEY;
try {
return await run();
} finally {
if (previous === undefined) {
delete process.env.OLLAMA_API_KEY;
} else {
process.env.OLLAMA_API_KEY = previous;
}
}
}
const createTagModel = (name: string) => ({ name, modified_at: "", size: 1, digest: "" });
const jsonResponse = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
const tagsResponse = (names: string[]) =>
jsonResponse({ models: names.map((name) => createTagModel(name)) });
const notFoundJsonResponse = () => jsonResponse({}, 404);
const stubTagsFetch = (names: string[] = []) => {
const fetchMock = vi.fn(async (input: unknown) => {
const url = String(input);
if (url.endsWith("/api/tags")) {
return tagsResponse(names);
}
return notFoundJsonResponse();
});
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
return fetchMock;
};
it("should not include ollama when no API key is configured", async () => {
const provider = await runOllamaCatalog({
env: { OLLAMA_API_KEY: undefined },
});
expect(provider).toBeUndefined();
});
it("should use native ollama api type", async () => {
const fetchMock = stubTagsFetch();
await withOllamaApiKey(async () => {
const provider = await runOllamaCatalog({});
if (!provider) {
throw new Error("expected injected Ollama provider");
}
expect(provider.apiKey).toBe(OLLAMA_LOCAL_AUTH_MARKER);
expect(provider.api).toBe("ollama");
expect(provider.baseUrl).toBe("http://127.0.0.1:11434");
expectDiscoveryCallCounts(fetchMock, { tags: 1, show: 0 });
});
});
it("should preserve explicit ollama baseUrl and api on implicit provider injection", async () => {
const fetchMock = stubTagsFetch();
await withOllamaApiKey(async () => {
const provider = await runOllamaCatalog({
config: {
models: {
providers: {
ollama: {
baseUrl: "http://192.168.20.14:11434/v1",
api: "openai-completions",
models: [],
},
},
},
},
env: { OLLAMA_API_KEY: "test-key" },
});
expect(countFetchCallUrls(fetchMock, "/api/tags")).toBe(1);
expect(provider?.baseUrl).toBe("http://192.168.20.14:11434/v1");
expect(provider?.api).toBe("openai-completions");
});
});
it("should normalize explicit native ollama baseUrl on implicit provider injection", async () => {
const fetchMock = stubTagsFetch();
await withOllamaApiKey(async () => {
const provider = await runOllamaCatalog({
config: {
models: {
providers: {
ollama: {
baseUrl: "http://192.168.20.14:11434/v1",
api: "ollama",
models: [],
},
},
},
},
env: { OLLAMA_API_KEY: "test-key" },
});
expect(countFetchCallUrls(fetchMock, "/api/tags")).toBe(1);
expect(provider?.baseUrl).toBe("http://192.168.20.14:11434");
expect(provider?.api).toBe("ollama");
});
});
it("discovers per-model context windows from /api/show", async () => {
enableDiscoveryEnv();
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/tags")) {
return tagsResponse(["qwen3:32b", "llama3.3:70b"]);
}
if (url.endsWith("/api/show")) {
const rawBody = init?.body;
const bodyText = typeof rawBody === "string" ? rawBody : "{}";
const parsed = JSON.parse(bodyText) as { name?: string };
if (parsed.name === "qwen3:32b") {
return jsonResponse({ model_info: { "qwen3.context_length": 131072 } });
}
if (parsed.name === "llama3.3:70b") {
return jsonResponse({ model_info: { "llama.context_length": 65536 } });
}
}
return notFoundJsonResponse();
});
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
const provider = await runOllamaCatalog({
env: { OLLAMA_API_KEY: "test-key", VITEST: "", NODE_ENV: "development" },
});
const models = provider?.models ?? [];
const qwen = models.find((model) => model.id === "qwen3:32b");
const llama = models.find((model) => model.id === "llama3.3:70b");
expect(qwen?.contextWindow).toBe(131072);
expect(llama?.contextWindow).toBe(65536);
expectDiscoveryCallCounts(fetchMock, { tags: 1, show: 2 });
});
it("auto-registers ollama provider when models are discovered locally", async () => {
await withoutAmbientOllamaEnv(async () => {
enableDiscoveryEnv();
const fetchMock = vi.fn(async (input: unknown) => {
const url = String(input);
if (url.endsWith("/api/tags")) {
return tagsResponse(["deepseek-r1:latest", "llama3.3:latest"]);
}
if (url.endsWith("/api/show")) {
return jsonResponse({ model_info: {} });
}
return notFoundJsonResponse();
});
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
const provider = await runOllamaCatalog({
env: { OLLAMA_API_KEY: OLLAMA_LOCAL_AUTH_MARKER, VITEST: "", NODE_ENV: "development" },
});
expect(provider?.apiKey).toBe(OLLAMA_LOCAL_AUTH_MARKER);
expect(provider?.api).toBe("ollama");
expect(provider?.baseUrl).toBe("http://127.0.0.1:11434");
expect(provider?.models).toHaveLength(2);
expect(provider?.models?.[0]?.id).toBe("deepseek-r1:latest");
expect(provider?.models?.[0]?.reasoning).toBe(true);
expect(provider?.models?.[1]?.reasoning).toBe(false);
expectDiscoveryCallCounts(fetchMock, { tags: 1, show: 2 });
});
});
it("does not warn when Ollama is unreachable and not explicitly configured", async () => {
await withoutAmbientOllamaEnv(async () => {
enableDiscoveryEnv();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const fetchMock = vi
.fn()
.mockRejectedValue(new Error("connect ECONNREFUSED 127.0.0.1:11434"));
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
const provider = await runOllamaCatalog({
env: { VITEST: "", NODE_ENV: "development" },
});
expect(provider).toBeUndefined();
expect(
warnSpy.mock.calls.filter(([message]) => String(message).includes("Ollama")),
).toHaveLength(0);
warnSpy.mockRestore();
});
});
it("warns when Ollama is unreachable and explicitly configured", async () => {
await withoutAmbientOllamaEnv(async () => {
enableDiscoveryEnv();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const fetchMock = vi
.fn()
.mockRejectedValue(new Error("connect ECONNREFUSED 127.0.0.1:11434"));
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
await runOllamaCatalog({
config: {
models: {
providers: {
ollama: {
baseUrl: "http://127.0.0.1:11435/v1",
api: "openai-completions",
models: [],
},
},
},
},
env: { VITEST: "", NODE_ENV: "development" },
});
expect(countWarnCallsIncluding(warnSpy, "Ollama")).toBeGreaterThan(0);
warnSpy.mockRestore();
});
});
it("falls back to default context window when /api/show fails", async () => {
enableDiscoveryEnv();
const fetchMock = vi.fn(async (input: unknown) => {
const url = String(input);
if (url.endsWith("/api/tags")) {
return tagsResponse(["qwen3:32b"]);
}
if (url.endsWith("/api/show")) {
return jsonResponse({}, 500);
}
return notFoundJsonResponse();
});
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
const provider = await runOllamaCatalog({
env: { OLLAMA_API_KEY: "test-key", VITEST: "", NODE_ENV: "development" },
});
const model = provider?.models?.find((entry) => entry.id === "qwen3:32b");
expect(model?.contextWindow).toBe(128000);
expectDiscoveryCallCounts(fetchMock, { tags: 1, show: 1 });
});
it("caps /api/show requests when /api/tags returns a very large model list", async () => {
enableDiscoveryEnv();
const manyModels = Array.from({ length: 250 }, (_, idx) => ({
name: `model-${idx}`,
modified_at: "",
size: 1,
digest: "",
}));
const fetchMock = vi.fn(async (input: unknown) => {
const url = String(input);
if (url.endsWith("/api/tags")) {
return jsonResponse({ models: manyModels });
}
return jsonResponse({ model_info: { "llama.context_length": 65536 } });
});
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
const provider = await runOllamaCatalog({
env: { OLLAMA_API_KEY: "test-key", VITEST: "", NODE_ENV: "development" },
});
const models = provider?.models ?? [];
// 1 call for /api/tags + 200 capped /api/show calls.
expectDiscoveryCallCounts(fetchMock, { tags: 1, show: 200 });
expect(models).toHaveLength(200);
});
it("should have correct model structure without streaming override", () => {
const mockOllamaModel = {
id: "llama3.3:latest",
name: "llama3.3:latest",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
};
// Native Ollama provider does not need streaming: false workaround
expect(mockOllamaModel).not.toHaveProperty("params");
});
it("should skip discovery fetch when explicit models are configured", async () => {
await withoutAmbientOllamaEnv(async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
const explicitModels: ModelDefinitionConfig[] = [
{
id: "gpt-oss:20b",
name: "GPT-OSS 20B",
reasoning: false,
input: ["text"] as Array<"text" | "image">,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 81920,
},
];
const provider = await runOllamaCatalog({
config: {
models: {
providers: {
ollama: {
baseUrl: "http://remote-ollama:11434/v1",
models: explicitModels,
apiKey: "config-ollama-key", // pragma: allowlist secret
},
},
},
},
env: { VITEST: "", NODE_ENV: "development" },
});
const ollamaCalls = fetchMock.mock.calls.filter(([input]) => {
const url = String(input);
return url.endsWith("/api/tags") || url.endsWith("/api/show");
});
expect(ollamaCalls).toHaveLength(0);
expect(provider?.models).toEqual(explicitModels);
expect(provider?.baseUrl).toBe("http://remote-ollama:11434");
expect(provider?.api).toBe("ollama");
expect(provider?.apiKey).toBe("config-ollama-key");
});
});
it("should use synthetic local auth for configured remote providers without apiKey", async () => {
await withoutAmbientOllamaEnv(async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
const provider = await runOllamaCatalog({
config: {
models: {
providers: {
ollama: {
baseUrl: "http://remote-ollama:11434/v1",
models: [
{
id: "gpt-oss:20b",
name: "GPT-OSS 20B",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 81920,
},
],
},
},
},
},
env: { VITEST: "", NODE_ENV: "development" },
});
expect(fetchMock).not.toHaveBeenCalled();
expect(provider?.baseUrl).toBe("http://remote-ollama:11434");
expect(provider?.api).toBe("ollama");
expect(provider?.apiKey).toBe(OLLAMA_LOCAL_AUTH_MARKER);
expect(provider?.models).toHaveLength(1);
});
});
it("should not use synthetic local auth for configured cloud providers without apiKey", async () => {
await withoutAmbientOllamaEnv(async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
const provider = await runOllamaCatalog({
config: {
models: {
providers: {
ollama: {
baseUrl: "https://ollama.com/v1",
models: [
{
id: "gpt-oss:20b",
name: "GPT-OSS 20B",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 81920,
},
],
},
},
},
},
env: { VITEST: "", NODE_ENV: "development" },
});
expect(fetchMock).not.toHaveBeenCalled();
expect(provider?.baseUrl).toBe("https://ollama.com");
expect(provider?.api).toBe("ollama");
expect(provider?.apiKey).toBeUndefined();
expect(provider?.models).toHaveLength(1);
});
});
it("uses resolved discovery api key when configured cloud apiKey is an env marker", async () => {
await withoutAmbientOllamaEnv(async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
const provider = await runOllamaCatalog({
config: {
models: {
providers: {
ollama: {
baseUrl: "https://ollama.com/v1",
models: [
{
id: "gpt-oss:20b",
name: "GPT-OSS 20B",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 81920,
},
],
apiKey: "OLLAMA_API_KEY",
},
},
},
},
env: { OLLAMA_API_KEY: "real-secret", VITEST: "", NODE_ENV: "development" },
resolveProviderApiKey: () => ({
apiKey: "OLLAMA_API_KEY",
discoveryApiKey: "real-secret",
}),
});
expect(fetchMock).not.toHaveBeenCalled();
expect(provider?.baseUrl).toBe("https://ollama.com");
expect(provider?.api).toBe("ollama");
expect(provider?.apiKey).toBe("real-secret");
expect(provider?.models).toHaveLength(1);
});
});
it("uses resolved discovery api key for configured cloud providers without apiKey", async () => {
await withoutAmbientOllamaEnv(async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
const provider = await runOllamaCatalog({
config: {
models: {
providers: {
ollama: {
baseUrl: "https://ollama.com/v1",
models: [
{
id: "gpt-oss:20b",
name: "GPT-OSS 20B",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 81920,
},
],
},
},
},
},
env: { OLLAMA_API_KEY: "real-secret", VITEST: "", NODE_ENV: "development" },
resolveProviderApiKey: () => ({
apiKey: "OLLAMA_API_KEY",
discoveryApiKey: "real-secret",
}),
});
expect(fetchMock).not.toHaveBeenCalled();
expect(provider?.baseUrl).toBe("https://ollama.com");
expect(provider?.api).toBe("ollama");
expect(provider?.apiKey).toBe("real-secret");
expect(provider?.models).toHaveLength(1);
});
});
it("keeps synthetic local auth when a local provider also has a discovery key", async () => {
await withoutAmbientOllamaEnv(async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
const provider = await runOllamaCatalog({
config: {
models: {
providers: {
ollama: {
baseUrl: "http://127.0.0.1:11434/v1",
models: [
{
id: "gpt-oss:20b",
name: "GPT-OSS 20B",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 81920,
},
],
apiKey: "OLLAMA_API_KEY",
},
},
},
},
env: { OLLAMA_API_KEY: "real-secret", VITEST: "", NODE_ENV: "development" },
resolveProviderApiKey: () => ({
apiKey: "OLLAMA_API_KEY",
discoveryApiKey: "real-secret",
}),
});
expect(fetchMock).not.toHaveBeenCalled();
expect(provider?.baseUrl).toBe("http://127.0.0.1:11434");
expect(provider?.api).toBe("ollama");
expect(provider?.apiKey).toBe(OLLAMA_LOCAL_AUTH_MARKER);
expect(provider?.models).toHaveLength(1);
});
});
it("should preserve explicit apiKey from configured remote providers", async () => {
await withoutAmbientOllamaEnv(async () => {
const fetchMock = vi.fn(async (input: unknown) => {
const url = String(input);
if (url.endsWith("/api/tags")) {
return tagsResponse([]);
}
return notFoundJsonResponse();
});
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
const provider = await runOllamaCatalog({
config: {
models: {
providers: {
ollama: {
baseUrl: "http://remote-ollama:11434/v1",
api: "openai-completions",
models: [
{
id: "configured-remote-model",
name: "Configured Remote Model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 8192,
},
],
apiKey: "config-ollama-key", // pragma: allowlist secret
},
},
},
},
env: { VITEST: "", NODE_ENV: "development" },
});
expect(provider?.apiKey).toBe("config-ollama-key");
expect(provider?.baseUrl).toBe("http://remote-ollama:11434/v1");
expect(provider?.api).toBe("openai-completions");
expect(fetchMock).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,70 @@
// Ollama provider module implements model/runtime integration.
import type { ProviderCatalogContext } from "openclaw/plugin-sdk/provider-catalog-shared";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import {
OLLAMA_DEFAULT_API_KEY,
OLLAMA_PROVIDER_ID,
resolveOllamaDiscoveryResult,
shouldUseSyntheticOllamaAuth,
type OllamaPluginConfig,
} from "./src/discovery-shared.js";
import { buildOllamaProvider } from "./src/provider-models.js";
type OllamaProviderPlugin = {
id: string;
label: string;
docsPath: string;
envVars: string[];
auth: [];
resolveSyntheticAuth: (ctx: { provider?: string; providerConfig?: ModelProviderConfig }) =>
| {
apiKey: string;
source: string;
mode: "api-key";
}
| undefined;
catalog: {
order: "late";
run: (ctx: ProviderCatalogContext) => ReturnType<typeof runOllamaDiscovery>;
};
};
function resolveOllamaPluginConfig(ctx: ProviderCatalogContext): OllamaPluginConfig {
const entries = (ctx.config.plugins?.entries ?? {}) as Record<
string,
{ config?: OllamaPluginConfig }
>;
return entries.ollama?.config ?? {};
}
async function runOllamaDiscovery(ctx: ProviderCatalogContext) {
return await resolveOllamaDiscoveryResult({
ctx,
pluginConfig: resolveOllamaPluginConfig(ctx),
buildProvider: buildOllamaProvider,
});
}
export const ollamaProviderDiscovery: OllamaProviderPlugin = {
id: OLLAMA_PROVIDER_ID,
label: "Ollama",
docsPath: "/providers/ollama",
envVars: ["OLLAMA_API_KEY"],
auth: [],
resolveSyntheticAuth: ({ provider, providerConfig }) => {
if (!shouldUseSyntheticOllamaAuth(providerConfig)) {
return undefined;
}
return {
apiKey: OLLAMA_DEFAULT_API_KEY,
source: `models.providers.${provider ?? OLLAMA_PROVIDER_ID} (synthetic local key)`,
mode: "api-key",
};
},
catalog: {
order: "late",
run: runOllamaDiscovery,
},
};
export default ollamaProviderDiscovery;

View File

@@ -0,0 +1,73 @@
// Ollama tests cover provider policy api plugin behavior.
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-types";
import { describe, expect, it } from "vitest";
import { normalizeConfig, resolveThinkingProfile } from "./provider-policy-api.js";
import { OLLAMA_DEFAULT_BASE_URL } from "./src/defaults.js";
function createModel(id: string, name: string): ModelDefinitionConfig {
return {
id,
name,
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128_000,
maxTokens: 8_192,
};
}
describe("ollama provider policy public artifact", () => {
it("injects defaults so implicit discovery can run before validation", () => {
expect(
normalizeConfig({
provider: "ollama",
providerConfig: {},
}),
).toStrictEqual({
baseUrl: OLLAMA_DEFAULT_BASE_URL,
models: [],
});
});
it("preserves explicit Ollama config values", () => {
const models = [createModel("llama3.2", "Llama 3.2")];
expect(
normalizeConfig({
provider: "ollama",
providerConfig: {
baseUrl: "http://ollama.internal:11434",
models,
},
}),
).toStrictEqual({
baseUrl: "http://ollama.internal:11434",
models,
});
});
it("ignores other providers", () => {
expect(
normalizeConfig({
provider: "openai",
providerConfig: {},
}),
).toStrictEqual({});
});
it("exposes max thinking for reasoning-capable models without full plugin activation", () => {
expect(resolveThinkingProfile({ reasoning: true })).toEqual({
levels: [{ id: "off" }, { id: "low" }, { id: "medium" }, { id: "high" }, { id: "max" }],
defaultLevel: "off",
});
expect(resolveThinkingProfile({ reasoning: false })).toEqual({
levels: [{ id: "off" }],
defaultLevel: "off",
});
});
});

View File

@@ -0,0 +1,60 @@
// Ollama API module exposes the plugin public contract.
import type { ProviderThinkingProfile } from "openclaw/plugin-sdk/plugin-entry";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-types";
import { OLLAMA_DEFAULT_BASE_URL } from "./src/defaults.js";
type OllamaProviderConfigDraft = Partial<ModelProviderConfig>;
const OLLAMA_REASONING_THINKING_PROFILE = {
levels: [{ id: "off" }, { id: "low" }, { id: "medium" }, { id: "high" }, { id: "max" }],
defaultLevel: "off",
} satisfies ProviderThinkingProfile;
const OLLAMA_NON_REASONING_THINKING_PROFILE = {
levels: [{ id: "off" }],
defaultLevel: "off",
} satisfies ProviderThinkingProfile;
/**
* Provider policy surface for Ollama: normalize provider configs used by
* core defaults/normalizers. This runs during config defaults application and
* normalization paths (not Zod validation).
*/
export function normalizeConfig({
provider,
providerConfig,
}: {
provider: string;
providerConfig: OllamaProviderConfigDraft;
}): OllamaProviderConfigDraft {
if (!providerConfig || typeof providerConfig !== "object") {
return providerConfig;
}
const normalizedProviderId = (provider ?? "").trim().toLowerCase();
if (normalizedProviderId !== "ollama") {
return providerConfig;
}
const next: OllamaProviderConfigDraft = { ...providerConfig };
// If baseUrl is missing, empty, or whitespace-only, default to local Ollama host.
if (typeof next.baseUrl !== "string" || !next.baseUrl.trim()) {
next.baseUrl = OLLAMA_DEFAULT_BASE_URL;
}
// If models is missing/not an array, default to empty array to signal discovery.
if (!Array.isArray(next.models)) {
next.models = [];
}
return next;
}
export function resolveThinkingProfile({
reasoning,
}: {
reasoning?: boolean;
}): ProviderThinkingProfile {
return reasoning ? OLLAMA_REASONING_THINKING_PROFILE : OLLAMA_NON_REASONING_THINKING_PROFILE;
}

View File

@@ -0,0 +1,23 @@
// Ollama API module exposes the plugin public contract.
export {
buildAssistantMessage,
buildOllamaChatRequest,
createConfiguredOllamaCompatStreamWrapper,
convertToOllamaMessages,
createConfiguredOllamaCompatNumCtxWrapper,
createConfiguredOllamaStreamFn,
createOllamaStreamFn,
isOllamaCompatProvider,
OLLAMA_NATIVE_BASE_URL,
parseNdjsonStream,
resolveOllamaBaseUrlForRun,
resolveOllamaCompatNumCtxEnabled,
shouldInjectOllamaCompatNumCtx,
wrapOllamaCompatNumCtx,
} from "./src/stream.js";
export {
createOllamaEmbeddingProvider,
DEFAULT_OLLAMA_EMBEDDING_MODEL,
type OllamaEmbeddingClient,
type OllamaEmbeddingProvider,
} from "./src/embedding-provider.js";

View File

@@ -0,0 +1,103 @@
// Ollama helper module supports config compat behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_PROVIDER_ID } from "./defaults.js";
type LegacyConfigRule = {
path: Array<string | number>;
message: string;
match: (value: unknown) => boolean;
};
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function isRetiredOllamaCloudBaseUrl(value: unknown): value is string {
if (typeof value !== "string" || !value.trim()) {
return false;
}
try {
return new URL(value.trim()).hostname.toLowerCase() === "ai.ollama.com";
} catch {
return false;
}
}
function findRetiredOllamaCloudBaseUrl(provider: unknown): { key: "baseUrl" | "baseURL" } | null {
const record = asRecord(provider);
if (!record) {
return null;
}
if (isRetiredOllamaCloudBaseUrl(record.baseUrl)) {
return { key: "baseUrl" };
}
if (isRetiredOllamaCloudBaseUrl(record.baseURL)) {
return { key: "baseURL" };
}
return null;
}
export const legacyConfigRules: LegacyConfigRule[] = [
{
path: ["models", "providers", OLLAMA_CLOUD_PROVIDER_ID],
message:
'models.providers.ollama-cloud.baseUrl="https://ai.ollama.com" is retired; use "https://ollama.com". Run "openclaw doctor --fix".',
match: (value) => findRetiredOllamaCloudBaseUrl(value) !== null,
},
];
export function migrateOllamaCloudRetiredBaseUrl(config: OpenClawConfig): {
config: OpenClawConfig;
changes: string[];
} | null {
const provider = config.models?.providers?.[OLLAMA_CLOUD_PROVIDER_ID];
const retired = findRetiredOllamaCloudBaseUrl(provider);
if (!retired) {
return null;
}
const nextConfig = structuredClone(config);
const nextModels = asRecord(nextConfig.models) ?? {};
nextConfig.models = nextModels as OpenClawConfig["models"];
const nextProviders = asRecord(nextModels.providers) ?? {};
nextModels.providers = nextProviders;
const nextProvider = asRecord(nextProviders[OLLAMA_CLOUD_PROVIDER_ID]) ?? {};
nextProviders[OLLAMA_CLOUD_PROVIDER_ID] = nextProvider;
const canonicalBaseUrl = nextProvider.baseUrl;
if (
retired.key === "baseURL" &&
typeof canonicalBaseUrl === "string" &&
canonicalBaseUrl.trim() &&
!isRetiredOllamaCloudBaseUrl(canonicalBaseUrl)
) {
delete nextProvider.baseURL;
return {
config: nextConfig,
changes: [
"Removed retired models.providers.ollama-cloud.baseURL while preserving models.providers.ollama-cloud.baseUrl.",
],
};
}
nextProvider.baseUrl = OLLAMA_CLOUD_BASE_URL;
if (retired.key === "baseURL") {
delete nextProvider.baseURL;
}
return {
config: nextConfig,
changes: [
`Updated models.providers.ollama-cloud.${retired.key} from the retired Ollama Cloud endpoint to ${OLLAMA_CLOUD_BASE_URL}.`,
],
};
}
export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): {
config: OpenClawConfig;
changes: string[];
} {
return migrateOllamaCloudRetiredBaseUrl(cfg) ?? { config: cfg, changes: [] };
}

View File

@@ -0,0 +1,24 @@
// Ollama plugin module implements defaults behavior.
export const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434";
export const OLLAMA_DOCKER_HOST_BASE_URL = "http://host.docker.internal:11434";
export const OLLAMA_CLOUD_BASE_URL = "https://ollama.com";
export const OLLAMA_CLOUD_PROVIDER_ID = "ollama-cloud";
export const OLLAMA_GLM52_CLOUD_MODEL_ID = "glm-5.2:cloud";
export const OLLAMA_GLM52_CONTEXT_WINDOW = 1_000_000;
export const OLLAMA_CLOUD_DEFAULT_MODELS = [
"kimi-k2.5:cloud",
"minimax-m2.7:cloud",
"glm-5.1:cloud",
OLLAMA_GLM52_CLOUD_MODEL_ID,
] as const;
export const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000;
export const OLLAMA_DEFAULT_MAX_TOKENS = 8192;
export const OLLAMA_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
export const OLLAMA_DEFAULT_MODEL = "gemma4";

View File

@@ -0,0 +1,236 @@
// Ollama tests cover discovery shared plugin behavior.
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { describe, expect, it } from "vitest";
import {
isHostedOllamaCloud,
isLocalOllamaBaseUrl,
resolveOllamaDiscoveryResult,
} from "./discovery-shared.js";
describe("isLocalOllamaBaseUrl", () => {
it.each([
undefined,
"",
"http://localhost:11434",
"http://127.0.0.1:11434",
"http://0.0.0.0:11434",
"http://[::1]:11434",
"http://10.0.0.5:11434",
"http://172.16.0.10:11434",
"http://172.31.255.254:11434",
"http://192.168.1.100:11434",
"http://gpu-node-1:11434",
"http://mac-studio.local:11434",
"http://docker.orb.internal:11434",
"http://host.docker.internal:11434",
"http://host.orb.internal:11434",
"http://[fd00::1]:11434",
"http://[fe90::1]:11434",
])("classifies %s as local", (baseUrl) => {
expect(isLocalOllamaBaseUrl(baseUrl)).toBe(true);
});
it.each([
"https://ollama.com",
"https://api.ollama.com/v1",
"https://ollama.example.com:11434",
"http://8.8.8.8:11434",
"http://172.15.255.254:11434",
"http://172.32.0.1:11434",
"http://193.168.1.1:11434",
"http://[2001:4860:4860::8888]:11434",
"http://10.example.com:11434",
"not a url",
])("classifies %s as remote", (baseUrl) => {
expect(isLocalOllamaBaseUrl(baseUrl)).toBe(false);
});
});
describe("isHostedOllamaCloud", () => {
it.each([
"https://ollama.com",
"https://ollama.com:11434",
"https://api.ollama.com",
"https://api.ollama.com/v1",
"https://sub.ollama.com",
])("classifies %s as hosted cloud", (baseUrl) => {
expect(isHostedOllamaCloud(baseUrl)).toBe(true);
});
it.each([
undefined,
"",
"http://localhost:11434",
"http://127.0.0.1:11434",
"https://ollama.mycompany.com",
"https://ollama.example.com",
"http://10.0.0.5:11434",
"not a url",
])("classifies %s as not hosted cloud", (baseUrl) => {
expect(isHostedOllamaCloud(baseUrl)).toBe(false);
});
});
describe("resolveOllamaDiscoveryResult — hosted Ollama Cloud guard", () => {
const discoveredModel = {
id: "discovered-model",
name: "discovered-model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
compat: { supportsTools: true, supportsUsageInStreaming: true },
params: { num_ctx: 128000 },
} satisfies ModelProviderConfig["models"][number];
const cloudModel = {
id: "minimax-m3:cloud",
name: "minimax-m3:cloud",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
compat: { supportsTools: true, supportsUsageInStreaming: true },
params: { num_ctx: 128000 },
} satisfies ModelProviderConfig["models"][number];
const buildMockProvider = async (
_configuredBaseUrl?: string,
_opts?: { quiet?: boolean },
): Promise<ModelProviderConfig> => ({
baseUrl: "https://ollama.com",
api: "ollama",
models: [discoveredModel],
});
it("returns null for remote base URL without explicit models", async () => {
const result = await resolveOllamaDiscoveryResult({
ctx: {
config: {
models: {
providers: {
ollama: {
baseUrl: "https://ollama.com",
apiKey: "test-key",
api: "ollama",
},
},
},
},
env: {},
resolveProviderApiKey: () => ({ apiKey: "test-key" }),
},
pluginConfig: {},
buildProvider: buildMockProvider,
});
expect(result).toBeNull();
});
it("returns explicit models for remote base URL when models are configured", async () => {
const result = await resolveOllamaDiscoveryResult({
ctx: {
config: {
models: {
providers: {
ollama: {
baseUrl: "https://ollama.com",
apiKey: "test-key",
api: "ollama",
models: [cloudModel],
},
},
},
},
env: {},
resolveProviderApiKey: () => ({ apiKey: "test-key" }),
},
pluginConfig: {},
buildProvider: buildMockProvider,
});
expect(result).not.toBeNull();
expect(result!.provider.models).toHaveLength(1);
expect(result!.provider.models[0].id).toBe("minimax-m3:cloud");
});
it("does not call buildProvider for remote base URL without explicit models", async () => {
let providerCalled = false;
const trackingBuildProvider = async (
_configuredBaseUrl?: string,
_opts?: { quiet?: boolean },
): Promise<ModelProviderConfig> => {
providerCalled = true;
return buildMockProvider();
};
const result = await resolveOllamaDiscoveryResult({
ctx: {
config: {
models: {
providers: {
ollama: {
baseUrl: "https://ollama.com",
apiKey: "test-key",
api: "ollama",
},
},
},
},
env: {},
resolveProviderApiKey: () => ({ apiKey: "test-key" }),
},
pluginConfig: {},
buildProvider: trackingBuildProvider,
});
expect(result).toBeNull();
expect(providerCalled).toBe(false);
});
it("still auto-discovers for remote self-hosted base URL when no explicit models", async () => {
const result = await resolveOllamaDiscoveryResult({
ctx: {
config: {
models: {
providers: {
ollama: {
baseUrl: "https://ollama.mycompany.com",
apiKey: "test-key",
api: "ollama",
},
},
},
},
env: {},
resolveProviderApiKey: () => ({ apiKey: "test-key" }),
},
pluginConfig: {},
buildProvider: buildMockProvider,
});
// Remote self-hosted base URL should still reach the discovery path
expect(result).not.toBeNull();
});
it("still auto-discovers for local base URL when no explicit models", async () => {
const result = await resolveOllamaDiscoveryResult({
ctx: {
config: {
models: {
providers: {
ollama: {
baseUrl: "http://localhost:11434",
api: "ollama",
},
},
},
},
env: { OLLAMA_API_KEY: "ollama-local" },
resolveProviderApiKey: () => ({ apiKey: "ollama-local" }),
},
pluginConfig: {},
buildProvider: buildMockProvider,
});
// Local base URL should still reach the discovery path
expect(result).not.toBeNull();
});
});

View File

@@ -0,0 +1,373 @@
// Ollama plugin module implements discovery shared behavior.
import { getCachedLiveCatalogValue } from "openclaw/plugin-sdk/provider-catalog-shared";
import type {
ModelProviderConfig,
ModelDefinitionConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
/**
* Provider config input type — partial config without required `models`.
* Replaces the deprecated `openclaw/plugin-sdk/config-types` import.
*/
type OllamaProviderConfigInput = Omit<Partial<ModelProviderConfig>, "models"> & {
models?: ModelDefinitionConfig[];
};
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js";
import { readProviderBaseUrl } from "./provider-base-url.js";
import { resolveOllamaApiBase } from "./provider-models.js";
export const OLLAMA_PROVIDER_ID = "ollama";
export const OLLAMA_DEFAULT_API_KEY = "ollama-local";
export type OllamaPluginConfig = {
discovery?: {
enabled?: boolean;
};
nodeInference?: {
enabled?: boolean;
};
};
type OllamaDiscoveryContext = {
config: {
models?: {
providers?: Record<string, OllamaProviderConfigInput | undefined>;
};
};
env: NodeJS.ProcessEnv;
resolveProviderApiKey: (providerId: string) => {
apiKey?: unknown;
discoveryApiKey?: unknown;
};
};
function readStringValue(value: unknown): string | undefined {
if (typeof value === "string") {
return normalizeOptionalString(value);
}
if (value && typeof value === "object" && "value" in value) {
return normalizeOptionalString((value as { value?: unknown }).value);
}
return undefined;
}
function isOllamaApiKeyMarker(value: string): boolean {
return value === "OLLAMA_API_KEY" || value === OLLAMA_DEFAULT_API_KEY;
}
export function resolveOllamaRuntimeBaseUrl(params: {
api?: ModelProviderConfig["api"];
configuredBaseUrl?: string;
discoveredBaseUrl: string;
}): string {
if (params.configuredBaseUrl && params.api && params.api !== "ollama") {
return params.configuredBaseUrl;
}
return params.discoveredBaseUrl;
}
function resolveOllamaDiscoveryApiKey(params: {
env: NodeJS.ProcessEnv;
baseUrl?: string;
explicitApiKey?: string;
resolvedApiKey?: unknown;
resolvedDiscoveryApiKey?: unknown;
}): string | undefined {
const envValue = normalizeOptionalString(params.env.OLLAMA_API_KEY);
const resolvedApiKey = normalizeOptionalString(params.resolvedApiKey);
const resolvedDiscoveryApiKey = normalizeOptionalString(params.resolvedDiscoveryApiKey);
const explicitApiKey = normalizeOptionalString(params.explicitApiKey);
if (explicitApiKey && !isOllamaApiKeyMarker(explicitApiKey)) {
return explicitApiKey;
}
if (!isLocalOllamaBaseUrl(params.baseUrl)) {
if (resolvedDiscoveryApiKey) {
return resolvedDiscoveryApiKey;
}
if (resolvedApiKey && !isOllamaApiKeyMarker(resolvedApiKey)) {
return resolvedApiKey;
}
return envValue && envValue !== OLLAMA_DEFAULT_API_KEY ? envValue : undefined;
}
if (resolvedApiKey && resolvedApiKey !== envValue && !isOllamaApiKeyMarker(resolvedApiKey)) {
return resolvedApiKey;
}
return OLLAMA_DEFAULT_API_KEY;
}
function shouldSkipAmbientOllamaDiscovery(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.VITEST) || env.NODE_ENV === "test";
}
const LOCAL_OLLAMA_HOSTNAMES = new Set([
"localhost",
"127.0.0.1",
"0.0.0.0",
"::1",
"::",
"docker.orb.internal",
"host.docker.internal",
"host.orb.internal",
]);
const LOOPBACK_OLLAMA_HOSTNAMES = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "::"]);
function isIpv4Loopback(host: string): boolean {
if (!/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
return false;
}
const octets = host.split(".").map((part) => Number.parseInt(part, 10));
if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return false;
}
return octets[0] === 127;
}
function isIpv4PrivateRange(host: string): boolean {
if (!/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
return false;
}
const octets = host.split(".").map((part) => Number.parseInt(part, 10));
if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return false;
}
const [a, b] = octets;
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
}
function isIpv6LocalRange(host: string): boolean {
const lower = host.toLowerCase();
return /^fe[89ab][0-9a-f]:/.test(lower) || /^f[cd][0-9a-f]{2}:/.test(lower);
}
export function isLocalOllamaBaseUrl(baseUrl: string | undefined | null): boolean {
if (!baseUrl) {
return true;
}
let parsed: URL;
try {
parsed = new URL(baseUrl);
} catch {
return false;
}
let host = parsed.hostname.toLowerCase();
if (host.startsWith("[") && host.endsWith("]")) {
host = host.slice(1, -1);
}
return (
LOCAL_OLLAMA_HOSTNAMES.has(host) ||
host.endsWith(".local") ||
isIpv4PrivateRange(host) ||
isIpv6LocalRange(host) ||
(!host.includes(".") && !host.includes(":"))
);
}
const HOSTED_OLLAMA_CLOUD_HOSTNAMES = new Set(["ollama.com", "api.ollama.com"]);
export function isHostedOllamaCloud(baseUrl: string | undefined | null): boolean {
if (!baseUrl) {
return false;
}
let parsed: URL;
try {
parsed = new URL(baseUrl);
} catch {
return false;
}
const host = parsed.hostname.toLowerCase();
return HOSTED_OLLAMA_CLOUD_HOSTNAMES.has(host) || host.endsWith(".ollama.com");
}
function isLoopbackOllamaBaseUrl(baseUrl: string | undefined | null): boolean {
if (!baseUrl) {
return true;
}
let parsed: URL;
try {
parsed = new URL(baseUrl);
} catch {
return false;
}
let host = parsed.hostname.toLowerCase();
if (host.startsWith("[") && host.endsWith("]")) {
host = host.slice(1, -1);
}
return LOOPBACK_OLLAMA_HOSTNAMES.has(host) || isIpv4Loopback(host);
}
function hasExplicitRemoteOllamaApiProvider(
providers: Record<string, OllamaProviderConfigInput | undefined> | undefined,
): boolean {
if (!providers) {
return false;
}
for (const [providerId, provider] of Object.entries(providers)) {
if (providerId === OLLAMA_PROVIDER_ID || !provider) {
continue;
}
if (normalizeOptionalString(provider.api)?.toLowerCase() !== "ollama") {
continue;
}
const baseUrl = readProviderBaseUrl(provider);
if (baseUrl && !isLoopbackOllamaBaseUrl(baseUrl)) {
return true;
}
}
return false;
}
export function shouldUseSyntheticOllamaAuth(
providerConfig: OllamaProviderConfigInput | undefined,
): boolean {
if (!hasMeaningfulExplicitOllamaConfig(providerConfig)) {
return false;
}
return isLocalOllamaBaseUrl(readProviderBaseUrl(providerConfig));
}
function hasMeaningfulExplicitOllamaConfig(
providerConfig: OllamaProviderConfigInput | undefined,
): boolean {
if (!providerConfig) {
return false;
}
if (Array.isArray(providerConfig.models) && providerConfig.models.length > 0) {
return true;
}
const baseUrl = readProviderBaseUrl(providerConfig);
if (baseUrl) {
return resolveOllamaApiBase(baseUrl) !== OLLAMA_DEFAULT_BASE_URL;
}
if (readStringValue(providerConfig.apiKey)) {
return true;
}
if (providerConfig.auth) {
return true;
}
if (typeof providerConfig.authHeader === "boolean") {
return true;
}
if (
providerConfig.headers &&
typeof providerConfig.headers === "object" &&
Object.keys(providerConfig.headers).length > 0
) {
return true;
}
if (providerConfig.request) {
return true;
}
if (typeof providerConfig.injectNumCtxForOpenAICompat === "boolean") {
return true;
}
return false;
}
export async function resolveOllamaDiscoveryResult(params: {
ctx: OllamaDiscoveryContext;
pluginConfig: OllamaPluginConfig;
buildProvider: (
configuredBaseUrl?: string,
opts?: { quiet?: boolean },
) => Promise<ModelProviderConfig>;
}): Promise<{ provider: ModelProviderConfig } | null> {
const explicit = params.ctx.config.models?.providers?.ollama;
const hasExplicitModels = Array.isArray(explicit?.models) && explicit.models.length > 0;
const hasMeaningfulExplicitConfig = hasMeaningfulExplicitOllamaConfig(explicit);
const hasRemoteOllamaApiProvider = hasExplicitRemoteOllamaApiProvider(
params.ctx.config.models?.providers,
);
const discoveryEnabled = params.pluginConfig.discovery?.enabled;
if (!hasExplicitModels && discoveryEnabled === false) {
return null;
}
// When the base URL points to hosted Ollama Cloud, skip auto-discovery.
// Cloud instances are shared tenants where available models are managed
// by the provider; only use explicitly configured models.
// Remote self-hosted Ollama endpoints still auto-discover as before.
const configuredBaseUrl = readProviderBaseUrl(explicit);
if (!hasExplicitModels && configuredBaseUrl && isHostedOllamaCloud(configuredBaseUrl)) {
return null;
}
const resolvedOllamaAuth = params.ctx.resolveProviderApiKey(OLLAMA_PROVIDER_ID);
const ollamaKey = resolvedOllamaAuth.apiKey;
const ollamaDiscoveryKey = resolvedOllamaAuth.discoveryApiKey;
const hasOllamaDiscoveryOptIn = typeof ollamaKey === "string" && ollamaKey.trim().length > 0;
const hasRealOllamaKey =
typeof ollamaKey === "string" &&
ollamaKey.trim().length > 0 &&
ollamaKey.trim() !== OLLAMA_DEFAULT_API_KEY;
const explicitApiKey = readStringValue(explicit?.apiKey);
if (hasExplicitModels && explicit) {
const discoveredBaseUrl = resolveOllamaApiBase(configuredBaseUrl);
const api = explicit.api ?? "ollama";
const apiKey = resolveOllamaDiscoveryApiKey({
env: params.ctx.env,
baseUrl: discoveredBaseUrl,
explicitApiKey,
resolvedApiKey: ollamaKey,
resolvedDiscoveryApiKey: ollamaDiscoveryKey,
});
return {
provider: {
...explicit,
models: explicit.models ?? [],
baseUrl: resolveOllamaRuntimeBaseUrl({ api, configuredBaseUrl, discoveredBaseUrl }),
api,
...(apiKey ? { apiKey } : {}),
},
};
}
if (!hasMeaningfulExplicitConfig && hasRemoteOllamaApiProvider) {
return null;
}
if (!hasOllamaDiscoveryOptIn && !hasMeaningfulExplicitConfig) {
return null;
}
if (
!hasRealOllamaKey &&
!hasMeaningfulExplicitConfig &&
shouldSkipAmbientOllamaDiscovery(params.ctx.env)
) {
return null;
}
const quiet = !hasRealOllamaKey && !hasMeaningfulExplicitConfig;
const provider = await getCachedLiveCatalogValue({
keyParts: [
OLLAMA_PROVIDER_ID,
"models",
configuredBaseUrl ?? OLLAMA_DEFAULT_BASE_URL,
ollamaKey,
quiet,
],
load: async () =>
await params.buildProvider(configuredBaseUrl, {
quiet,
}),
});
if (provider.models?.length === 0 && !ollamaKey && !explicit?.apiKey) {
return null;
}
const apiKey = resolveOllamaDiscoveryApiKey({
env: params.ctx.env,
baseUrl: provider.baseUrl ?? configuredBaseUrl,
explicitApiKey,
resolvedApiKey: ollamaKey,
resolvedDiscoveryApiKey: ollamaDiscoveryKey,
});
const api = explicit?.api ?? provider.api;
return {
provider: {
...provider,
baseUrl: resolveOllamaRuntimeBaseUrl({
api,
configuredBaseUrl,
discoveredBaseUrl: provider.baseUrl,
}),
api,
...(apiKey ? { apiKey } : {}),
},
};
}

View File

@@ -0,0 +1,745 @@
// Ollama tests cover embedding provider plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
const { fetchConfiguredLocalOriginWithSsrFGuardMock } = vi.hoisted(() => ({
fetchConfiguredLocalOriginWithSsrFGuardMock: vi.fn(
async ({ init, url }: { init?: RequestInit; url: string }) => ({
response: await fetch(url, init),
release: async () => {},
}),
),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: vi.fn(),
formatErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)),
ssrfPolicyFromHttpBaseUrlAllowedOrigin: (baseUrl: string) => {
const parsed = new URL(baseUrl);
return { allowedOrigins: [parsed.origin] };
},
}));
// Import-resolution gating for this private helper is covered in sdk-alias.test.ts.
vi.mock("openclaw/plugin-sdk/ssrf-runtime-internal", () => ({
fetchConfiguredLocalOriginWithSsrFGuard: fetchConfiguredLocalOriginWithSsrFGuardMock,
}));
let createOllamaEmbeddingProvider: typeof import("./embedding-provider.js").createOllamaEmbeddingProvider;
let ollamaMemoryEmbeddingProviderAdapter: typeof import("./memory-embedding-adapter.js").ollamaMemoryEmbeddingProviderAdapter;
beforeAll(async () => {
({ createOllamaEmbeddingProvider } = await import("./embedding-provider.js"));
({ ollamaMemoryEmbeddingProviderAdapter } = await import("./memory-embedding-adapter.js"));
});
beforeEach(() => {
fetchConfiguredLocalOriginWithSsrFGuardMock.mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
function mockEmbeddingFetch(embedding: number[]) {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ embeddings: [embedding] }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function firstFetchInit(fetchMock: ReturnType<typeof mockEmbeddingFetch>): RequestInit | undefined {
const call = fetchMock.mock.calls[0] as unknown[] | undefined;
if (!call) {
throw new Error("expected embedding fetch call");
}
return call[1] as RequestInit | undefined;
}
function readEmbeddingRequestBody(init: RequestInit | undefined): { input?: unknown } {
if (typeof init?.body !== "string") {
throw new Error("expected JSON string request body");
}
return JSON.parse(init.body) as { input?: unknown };
}
function readFirstEmbeddingInput(fetchMock: ReturnType<typeof mockEmbeddingFetch>): unknown {
const init = firstFetchInit(fetchMock);
const body = readEmbeddingRequestBody(init);
return body.input;
}
function firstGuardedFetchCall(): Record<string, unknown> {
const call = fetchConfiguredLocalOriginWithSsrFGuardMock.mock.calls[0]?.[0];
if (!call || typeof call !== "object") {
throw new Error("expected guarded fetch call");
}
return call as Record<string, unknown>;
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
function expectEmbeddingFetch(
fetchMock: ReturnType<typeof mockEmbeddingFetch>,
url: string,
params: {
model?: string;
input?: unknown;
headers?: Record<string, string>;
} = {},
) {
expect(fetchMock).toHaveBeenCalledWith(url, {
method: "POST",
headers: params.headers ?? { "Content-Type": "application/json" },
body: JSON.stringify({
model: params.model ?? "nomic-embed-text",
input: params.input ?? "hello",
}),
});
}
describe("ollama embedding provider", () => {
it("calls /api/embed and returns normalized vectors", async () => {
const fetchMock = mockEmbeddingFetch([3, 4]);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "unknown-embedder",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
const vector = await provider.embedQuery("hi");
expect(fetchMock).toHaveBeenCalledTimes(1);
expectEmbeddingFetch(fetchMock, "http://127.0.0.1:11434/api/embed", {
model: "unknown-embedder",
input: "hi",
});
expect(vector[0]).toBeCloseTo(0.6, 5);
expect(vector[1]).toBeCloseTo(0.8, 5);
});
it("applies outputDimensionality before normalizing vectors", async () => {
mockEmbeddingFetch([3, 4, 12]);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "unknown-embedder",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
outputDimensionality: 2,
});
const vector = await provider.embedQuery("hi");
expect(vector).toHaveLength(2);
expect(vector[0]).toBeCloseTo(0.6, 5);
expect(vector[1]).toBeCloseTo(0.8, 5);
});
it("marks the configured Ollama origin for managed-proxy direct routing", async () => {
const fetchMock = mockEmbeddingFetch([1, 0]);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434/v1" },
});
await provider.embedQuery("hello");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(firstGuardedFetchCall()).toMatchObject({
url: "http://127.0.0.1:11434/api/embed",
policy: { allowedOrigins: ["http://127.0.0.1:11434"] },
configuredLocalOriginBaseUrl: "http://127.0.0.1:11434",
auditContext: "ollama-memory-embedding",
});
});
it("passes cloud Ollama origins through the guarded fetch contract", async () => {
const fetchMock = mockEmbeddingFetch([1, 0]);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "https://ollama.com" },
});
await provider.embedQuery("hello");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(firstGuardedFetchCall()).toMatchObject({
url: "https://ollama.com/api/embed",
policy: { allowedOrigins: ["https://ollama.com"] },
configuredLocalOriginBaseUrl: "https://ollama.com",
auditContext: "ollama-memory-embedding",
});
});
it("resolves configured base URL and headers without sending local marker auth", async () => {
const fetchMock = mockEmbeddingFetch([1, 0]);
const { provider } = await createOllamaEmbeddingProvider({
config: {
models: {
providers: {
ollama: {
baseUrl: "http://127.0.0.1:11434/v1",
apiKey: "ollama-\nlocal\r\n", // pragma: allowlist secret
headers: {
"X-Provider-Header": "provider",
},
},
},
},
} as unknown as OpenClawConfig,
provider: "ollama",
model: "",
fallback: "none",
});
await provider.embedQuery("hello");
expectEmbeddingFetch(fetchMock, "http://127.0.0.1:11434/api/embed", {
input: "search_query: hello",
headers: {
"Content-Type": "application/json",
"X-Provider-Header": "provider",
},
});
});
it("resolves configured baseURL alias", async () => {
const fetchMock = mockEmbeddingFetch([1, 0]);
const { provider } = await createOllamaEmbeddingProvider({
config: {
models: {
providers: {
ollama: {
baseURL: "http://remote-ollama:11434/v1",
models: [],
},
},
},
} as unknown as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
});
await provider.embedQuery("hello");
expectEmbeddingFetch(fetchMock, "http://remote-ollama:11434/api/embed", {
model: "nomic-embed-text",
input: "search_query: hello",
});
});
it("fails fast when memory-search remote apiKey is an unresolved SecretRef", async () => {
await expect(
createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: {
baseUrl: "http://127.0.0.1:11434",
apiKey: { source: "env", provider: "default", id: "OLLAMA_API_KEY" },
},
}),
).rejects.toThrow(/agents\.\*\.memorySearch\.remote\.apiKey: unresolved SecretRef/i);
});
it("falls back to env key when provider apiKey is an unresolved SecretRef", async () => {
const fetchMock = mockEmbeddingFetch([1, 0]);
vi.stubEnv("OLLAMA_API_KEY", "ollama-env");
const { provider } = await createOllamaEmbeddingProvider({
config: {
models: {
providers: {
ollama: {
baseUrl: "http://127.0.0.1:11434/v1",
apiKey: { source: "env", provider: "default", id: "OLLAMA_API_KEY" },
models: [],
},
},
},
} as unknown as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
});
await provider.embedQuery("hello");
expectEmbeddingFetch(fetchMock, "http://127.0.0.1:11434/api/embed", {
input: "search_query: hello",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer ollama-env",
},
});
});
it("sends batch embeddings in one Ollama request", async () => {
const inputs: unknown[] = [];
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
const rawBody = typeof init?.body === "string" ? init.body : "{}";
const body = JSON.parse(rawBody) as { input?: unknown };
inputs.push(body.input);
return new Response(
JSON.stringify({
embeddings: [
[1, 0],
[1, 0],
[1, 0],
],
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
);
});
vi.stubGlobal("fetch", fetchMock);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
await expect(provider.embedBatch(["a", "bb", "ccc"])).resolves.toHaveLength(3);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(inputs).toEqual([["a", "bb", "ccc"]]);
expect(firstGuardedFetchCall()).toMatchObject({
url: "http://127.0.0.1:11434/api/embed",
policy: { allowedOrigins: ["http://127.0.0.1:11434"] },
configuredLocalOriginBaseUrl: "http://127.0.0.1:11434",
auditContext: "ollama-memory-embedding",
});
});
it("bounds embed error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"ollama embed unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
vi.stubGlobal(
"fetch",
vi.fn(async () => tracked.response),
);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
let error: unknown;
try {
await provider.embedQuery("hello");
} catch (err) {
error = err;
}
expect(String(error)).toContain("Ollama embed HTTP 503");
expect(String(error)).toContain("ollama embed unavailable");
expect(String(error)).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("reports malformed embed JSON with a provider-owned error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response("{not json", {
status: 200,
headers: { "content-type": "application/json" },
}),
),
);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
await expect(provider.embedQuery("hello")).rejects.toThrow(
"Ollama embed response: malformed JSON response",
);
});
it("bounds successful embed JSON bodies before parsing", async () => {
const streamed = createStreamingResponse({
chunkCount: 32,
chunkSize: 1024 * 1024,
text: "x",
headers: { "content-type": "application/json" },
});
const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded"));
vi.stubGlobal(
"fetch",
vi.fn(async () => streamed.response),
);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
await expect(provider.embedQuery("hello")).rejects.toThrow(
"Ollama embed response: JSON response exceeds 16777216 bytes",
);
expect(streamed.getReadCount()).toBeLessThan(32);
expect(streamed.wasCanceled()).toBe(true);
expect(jsonSpy).not.toHaveBeenCalled();
});
it("rejects non-number embedding values instead of zeroing them", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(JSON.stringify({ embeddings: [["0.1", 0.2]] }), {
status: 200,
headers: { "content-type": "application/json" },
}),
),
);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
await expect(provider.embedQuery("hello")).rejects.toThrow(
"Ollama embed response contains a non-number embedding value",
);
});
it("uses a retrieval query prefix for qwen3 embedding queries", async () => {
const fetchMock = mockEmbeddingFetch([1, 0]);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "qwen3-embedding:0.6b",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
await provider.embedQuery("怀孕");
expect(readFirstEmbeddingInput(fetchMock)).toBe(
"Instruct: Given a user query, retrieve relevant memory notes and documents\nQuery:怀孕",
);
});
it("uses the nomic search_query prefix for query embeddings", async () => {
const fetchMock = mockEmbeddingFetch([1, 0]);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
await provider.embedQuery("What does $& mean?");
expect(readFirstEmbeddingInput(fetchMock)).toBe("search_query: What does $& mean?");
});
it("uses the mixedbread retrieval prompt for query embeddings", async () => {
const fetchMock = mockEmbeddingFetch([1, 0]);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "mxbai-embed-large:latest",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
await provider.embedQuery("capital of Australia");
expect(readFirstEmbeddingInput(fetchMock)).toBe(
"Represent this sentence for searching relevant passages: capital of Australia",
);
});
it("keeps document batch embeddings raw", async () => {
const inputs: unknown[] = [];
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
const body = readEmbeddingRequestBody(init);
inputs.push(body.input);
return new Response(
JSON.stringify({
embeddings: [
[1, 0],
[1, 0],
],
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
);
});
vi.stubGlobal("fetch", fetchMock);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "qwen3-embedding:0.6b",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
await expect(provider.embedBatch(["doc one", "doc two"])).resolves.toHaveLength(2);
expect(inputs).toEqual([["doc one", "doc two"]]);
});
it("uses custom Ollama provider config and strips that provider prefix", async () => {
const fetchMock = mockEmbeddingFetch([1, 0]);
const { provider } = await createOllamaEmbeddingProvider({
config: {
models: {
providers: {
"ollama-spark": {
baseUrl: "http://spark.local:11434/v1",
apiKey: "spark-key",
headers: {
"X-Custom-Ollama": "spark",
},
models: [],
},
},
},
} as unknown as OpenClawConfig,
provider: "ollama-spark",
model: "ollama-spark/qwen3-embedding:4b",
fallback: "none",
});
await provider.embedQuery("hello");
expect(provider.model).toBe("qwen3-embedding:4b");
expectEmbeddingFetch(fetchMock, "http://spark.local:11434/api/embed", {
model: "qwen3-embedding:4b",
input:
"Instruct: Given a user query, retrieve relevant memory notes and documents\nQuery:hello",
headers: {
"Content-Type": "application/json",
"X-Custom-Ollama": "spark",
Authorization: "Bearer spark-key",
},
});
});
it("does not attach pure env OLLAMA_API_KEY to a local host", async () => {
const fetchMock = mockEmbeddingFetch([1, 0]);
vi.stubEnv("OLLAMA_API_KEY", "ollama-cloud-key");
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
await provider.embedQuery("hello");
const init = firstFetchInit(fetchMock);
const headers = init?.headers as Record<string, string> | undefined;
expect(headers?.Authorization).toBeUndefined();
});
it("attaches pure env OLLAMA_API_KEY to Ollama Cloud", async () => {
const fetchMock = mockEmbeddingFetch([1, 0]);
vi.stubEnv("OLLAMA_API_KEY", "ollama-cloud-key");
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "https://ollama.com" },
});
await provider.embedQuery("hello");
expectEmbeddingFetch(fetchMock, "https://ollama.com/api/embed", {
input: "search_query: hello",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer ollama-cloud-key",
},
});
});
it("does not attach provider apiKey to a different remote embedding host", async () => {
const fetchMock = mockEmbeddingFetch([1, 0]);
const { provider } = await createOllamaEmbeddingProvider({
config: {
models: {
providers: {
ollama: {
baseUrl: "http://127.0.0.1:11434",
apiKey: "provider-host-key",
models: [],
},
},
},
} as unknown as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "https://memory.example.com" },
});
await provider.embedQuery("hello");
const init = firstFetchInit(fetchMock);
const headers = init?.headers as Record<string, string> | undefined;
expect(headers?.Authorization).toBeUndefined();
});
it("attaches remote apiKey to a remote embedding host", async () => {
const fetchMock = mockEmbeddingFetch([1, 0]);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "https://memory.example.com", apiKey: "remote-host-key" },
});
await provider.embedQuery("hello");
expectEmbeddingFetch(fetchMock, "https://memory.example.com/api/embed", {
input: "search_query: hello",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer remote-host-key",
},
});
});
it("honors remote local marker as an explicit no-auth opt-out", async () => {
const fetchMock = mockEmbeddingFetch([1, 0]);
const { provider } = await createOllamaEmbeddingProvider({
config: {
models: {
providers: {
ollama: {
baseUrl: "http://127.0.0.1:11434",
apiKey: "provider-host-key",
models: [],
},
},
},
} as unknown as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { apiKey: "ollama-local" }, // pragma: allowlist secret
});
await provider.embedQuery("hello");
const init = firstFetchInit(fetchMock);
const headers = init?.headers as Record<string, string> | undefined;
expect(headers?.Authorization).toBeUndefined();
});
it("includes outputDimensionality in the memory embedding cache identity", async () => {
const result = await ollamaMemoryEmbeddingProviderAdapter.create({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
outputDimensionality: 2,
});
expect(result.runtime?.cacheKeyData).toMatchObject({
provider: "ollama",
model: "nomic-embed-text",
outputDimensionality: 2,
});
});
it("marks inline memory batches as local-server timeout work", async () => {
const result = await ollamaMemoryEmbeddingProviderAdapter.create({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
expect(result.runtime?.inlineBatchTimeoutMs).toBe(600_000);
});
});

View File

@@ -0,0 +1,408 @@
// Ollama provider module implements model/runtime integration.
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
import {
isKnownEnvApiKeyMarker,
isNonSecretApiKeyMarker,
normalizeOptionalSecretInput,
} from "openclaw/plugin-sdk/provider-auth";
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
} from "openclaw/plugin-sdk/secret-input";
import {
formatErrorMessage,
ssrfPolicyFromHttpBaseUrlAllowedOrigin,
type SsrFPolicy,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { fetchConfiguredLocalOriginWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime-internal";
import { OLLAMA_CLOUD_BASE_URL } from "./defaults.js";
import { normalizeOllamaWireModelId } from "./model-id.js";
import { readProviderBaseUrl } from "./provider-base-url.js";
import { resolveOllamaApiBase } from "./provider-models.js";
export type OllamaEmbeddingProvider = {
id: string;
model: string;
maxInputTokens?: number;
embedQuery: (text: string, options?: { signal?: AbortSignal }) => Promise<number[]>;
embedBatch: (texts: string[], options?: { signal?: AbortSignal }) => Promise<number[][]>;
};
type OllamaEmbeddingOptions = {
config: OpenClawConfig;
agentDir?: string;
provider?: string;
remote?: {
baseUrl?: string;
apiKey?: unknown;
headers?: Record<string, string>;
};
model: string;
fallback?: string;
local?: unknown;
outputDimensionality?: number;
taskType?: unknown;
};
export type OllamaEmbeddingClient = {
baseUrl: string;
headers: Record<string, string>;
ssrfPolicy?: SsrFPolicy;
model: string;
outputDimensionality?: number;
embedBatch: (texts: string[]) => Promise<number[][]>;
};
type OllamaEmbeddingClientConfig = Omit<OllamaEmbeddingClient, "embedBatch">;
export const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text";
const OLLAMA_EMBED_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const QUERY_INSTRUCTION_TEMPLATES = [
{
prefix: "qwen3-embedding",
template:
"Instruct: Given a user query, retrieve relevant memory notes and documents\nQuery:{query}",
},
{
prefix: "nomic-embed-text",
template: "search_query: {query}",
},
{
prefix: "mxbai-embed-large",
template: "Represent this sentence for searching relevant passages: {query}",
},
] as const;
function sanitizeAndNormalizeEmbedding(vec: unknown[], outputDimensionality?: number): number[] {
const selected =
typeof outputDimensionality === "number" ? vec.slice(0, outputDimensionality) : vec;
const sanitized = selected.map((value) => {
if (typeof value !== "number") {
throw new Error("Ollama embed response contains a non-number embedding value");
}
return Number.isFinite(value) ? value : 0;
});
const magnitude = Math.sqrt(sanitized.reduce((sum, value) => sum + value * value, 0));
if (magnitude < 1e-10) {
return sanitized;
}
return sanitized.map((value) => value / magnitude);
}
async function withRemoteHttpResponse<T>(params: {
url: string;
init?: RequestInit;
signal?: AbortSignal;
ssrfPolicy?: SsrFPolicy;
configuredLocalOriginBaseUrl: string;
onResponse: (response: Response) => Promise<T>;
}): Promise<T> {
const { response, release } = await fetchConfiguredLocalOriginWithSsrFGuard({
url: params.url,
init: params.init,
signal: params.signal,
policy: params.ssrfPolicy,
configuredLocalOriginBaseUrl: params.configuredLocalOriginBaseUrl,
auditContext: "ollama-memory-embedding",
});
try {
return await params.onResponse(response);
} finally {
await release();
}
}
async function readOllamaEmbeddingJsonResponse(
response: Response,
): Promise<{ embeddings?: unknown }> {
const payload = await readProviderJsonResponse<unknown>(response, "Ollama embed response");
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
throw new Error("Ollama embed response returned a non-object JSON payload");
}
return payload as { embeddings?: unknown };
}
function normalizeEmbeddingModel(model: string, providerId?: string): string {
const trimmed = model.trim();
if (!trimmed) {
return DEFAULT_OLLAMA_EMBEDDING_MODEL;
}
return normalizeOllamaWireModelId(trimmed, providerId);
}
function applyQueryInstructionTemplate(model: string, queryText: string): string {
const normalizedModel = model.trim().toLowerCase();
const match = QUERY_INSTRUCTION_TEMPLATES.find(({ prefix }) =>
normalizedModel.startsWith(prefix),
);
return match ? match.template.replace("{query}", () => queryText) : queryText;
}
function resolveConfiguredProvider(options: OllamaEmbeddingOptions) {
const providers = options.config.models?.providers;
if (!providers) {
return undefined;
}
const providerId = options.provider?.trim() || "ollama";
const direct = providers[providerId];
if (direct) {
return direct;
}
const normalized = normalizeProviderId(providerId);
for (const [candidateId, candidate] of Object.entries(providers)) {
if (normalizeProviderId(candidateId) === normalized) {
return candidate;
}
}
return providers.ollama;
}
function resolveMemorySecretInputString(params: {
value: unknown;
path: string;
}): string | undefined {
if (!hasConfiguredSecretInput(params.value)) {
return undefined;
}
return normalizeResolvedSecretInputString({
value: params.value,
path: params.path,
});
}
type OllamaEmbeddingBaseUrlOrigin = "remote-config" | "provider-config" | "default";
type OllamaEmbeddingSourceResolution = "unset" | "opt-out" | { apiKey: string };
type OllamaEmbeddingResolvedKeys = {
remote: OllamaEmbeddingSourceResolution;
provider: OllamaEmbeddingSourceResolution;
env: string | undefined;
};
function resolveSourcedOllamaEmbeddingKey(params: {
configString: string | undefined;
declared: boolean;
}): OllamaEmbeddingSourceResolution {
if (params.configString !== undefined) {
if (!isNonSecretApiKeyMarker(params.configString)) {
return { apiKey: params.configString };
}
if (!isKnownEnvApiKeyMarker(params.configString)) {
return "opt-out";
}
const envKey = resolveEnvApiKey("ollama")?.apiKey;
return envKey && !isNonSecretApiKeyMarker(envKey) ? { apiKey: envKey } : "opt-out";
}
if (params.declared) {
const envKey = resolveEnvApiKey("ollama")?.apiKey;
return envKey && !isNonSecretApiKeyMarker(envKey) ? { apiKey: envKey } : "opt-out";
}
return "unset";
}
function resolveOllamaEmbeddingResolvedKeys(
options: OllamaEmbeddingOptions,
providerConfig: ReturnType<typeof resolveConfiguredProvider>,
): OllamaEmbeddingResolvedKeys {
const remoteValue = options.remote?.apiKey;
const remote = resolveSourcedOllamaEmbeddingKey({
configString: resolveMemorySecretInputString({
value: remoteValue,
path: "agents.*.memorySearch.remote.apiKey",
}),
declared: hasConfiguredSecretInput(remoteValue),
});
const providerValue = providerConfig?.apiKey;
const provider = resolveSourcedOllamaEmbeddingKey({
configString: normalizeOptionalSecretInput(providerValue),
declared: hasConfiguredSecretInput(providerValue),
});
const envKey = resolveEnvApiKey("ollama")?.apiKey;
const env = envKey && !isNonSecretApiKeyMarker(envKey) ? envKey : undefined;
return { remote, provider, env };
}
function resolveOllamaEmbeddingBaseUrl(params: {
remoteBaseUrl?: string;
providerConfig: ReturnType<typeof resolveConfiguredProvider>;
}): { baseUrl: string; origin: OllamaEmbeddingBaseUrlOrigin } {
const remoteBaseUrl = params.remoteBaseUrl?.trim();
if (remoteBaseUrl) {
return { baseUrl: resolveOllamaApiBase(remoteBaseUrl), origin: "remote-config" };
}
const providerBaseUrl = readProviderBaseUrl(params.providerConfig);
if (providerBaseUrl) {
return { baseUrl: resolveOllamaApiBase(providerBaseUrl), origin: "provider-config" };
}
return { baseUrl: resolveOllamaApiBase(undefined), origin: "default" };
}
function normalizeOllamaHostKey(baseUrl: string): string | undefined {
try {
const parsed = new URL(baseUrl);
let hostname = parsed.hostname.toLowerCase();
if (hostname === "localhost" || hostname === "::1" || hostname === "[::1]") {
hostname = "127.0.0.1";
}
const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
const path = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/$/, "");
return `${parsed.protocol}//${hostname}:${port}${path}`;
} catch {
return undefined;
}
}
function areOllamaHostsEquivalent(a: string, b: string): boolean {
const aKey = normalizeOllamaHostKey(a);
const bKey = normalizeOllamaHostKey(b);
return aKey !== undefined && bKey !== undefined && aKey === bKey;
}
function isOllamaCloudBaseUrl(baseUrl: string): boolean {
return areOllamaHostsEquivalent(baseUrl, OLLAMA_CLOUD_BASE_URL);
}
function selectOllamaEmbeddingApiKey(params: {
resolved: OllamaEmbeddingResolvedKeys;
baseUrl: string;
baseUrlOrigin: OllamaEmbeddingBaseUrlOrigin;
providerOwnedHost: string;
}): string | undefined {
if (params.resolved.remote !== "unset") {
return typeof params.resolved.remote === "object" ? params.resolved.remote.apiKey : undefined;
}
const reachesProviderHost =
params.baseUrlOrigin === "provider-config" ||
params.baseUrlOrigin === "default" ||
areOllamaHostsEquivalent(params.baseUrl, params.providerOwnedHost);
if (params.resolved.provider !== "unset" && reachesProviderHost) {
return typeof params.resolved.provider === "object"
? params.resolved.provider.apiKey
: undefined;
}
if (params.resolved.env && isOllamaCloudBaseUrl(params.baseUrl)) {
return params.resolved.env;
}
return undefined;
}
function resolveOllamaEmbeddingClient(
options: OllamaEmbeddingOptions,
): OllamaEmbeddingClientConfig {
const providerConfig = resolveConfiguredProvider(options);
const { baseUrl, origin: baseUrlOrigin } = resolveOllamaEmbeddingBaseUrl({
remoteBaseUrl: options.remote?.baseUrl,
providerConfig,
});
const model = normalizeEmbeddingModel(options.model, options.provider);
const headerOverrides = Object.assign({}, providerConfig?.headers, options.remote?.headers);
const headers: Record<string, string> = {
"Content-Type": "application/json",
...headerOverrides,
};
const apiKey = selectOllamaEmbeddingApiKey({
resolved: resolveOllamaEmbeddingResolvedKeys(options, providerConfig),
baseUrl,
baseUrlOrigin,
providerOwnedHost: resolveOllamaApiBase(readProviderBaseUrl(providerConfig)),
});
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
return {
baseUrl,
headers,
ssrfPolicy: ssrfPolicyFromHttpBaseUrlAllowedOrigin(baseUrl),
model,
outputDimensionality: options.outputDimensionality,
};
}
export async function createOllamaEmbeddingProvider(
options: OllamaEmbeddingOptions,
): Promise<{ provider: OllamaEmbeddingProvider; client: OllamaEmbeddingClient }> {
const client = resolveOllamaEmbeddingClient(options);
const embedUrl = `${client.baseUrl.replace(/\/$/, "")}/api/embed`;
const embedMany = async (input: string | string[], signal?: AbortSignal): Promise<number[][]> => {
const json = await withRemoteHttpResponse({
url: embedUrl,
ssrfPolicy: client.ssrfPolicy,
configuredLocalOriginBaseUrl: client.baseUrl,
signal,
init: {
method: "POST",
headers: client.headers,
body: JSON.stringify({ model: client.model, input }),
},
onResponse: async (response) => {
if (!response.ok) {
const detail = await readResponseTextLimited(
response,
OLLAMA_EMBED_ERROR_BODY_LIMIT_BYTES,
).catch(() => "unknown error");
throw new Error(`Ollama embed HTTP ${response.status}: ${detail}`);
}
return await readOllamaEmbeddingJsonResponse(response);
},
});
if (!Array.isArray(json.embeddings)) {
throw new Error("Ollama embed response missing embeddings[]");
}
const expectedCount = Array.isArray(input) ? input.length : 1;
if (json.embeddings.length !== expectedCount) {
throw new Error(
`Ollama embed response returned ${json.embeddings.length} embeddings for ${expectedCount} inputs`,
);
}
return json.embeddings.map((embedding) => {
if (!Array.isArray(embedding)) {
throw new Error("Ollama embed response contains a non-array embedding");
}
return sanitizeAndNormalizeEmbedding(embedding, client.outputDimensionality);
});
};
const embedOne = async (text: string, signal?: AbortSignal): Promise<number[]> => {
const [embedding] = await embedMany(text, signal);
if (!embedding) {
throw new Error("Ollama embed response returned no embedding");
}
return embedding;
};
const embedQuery = async (
text: string,
optionsValue?: { signal?: AbortSignal },
): Promise<number[]> =>
await embedOne(applyQueryInstructionTemplate(client.model, text), optionsValue?.signal);
const provider: OllamaEmbeddingProvider = {
id: "ollama",
model: client.model,
embedQuery,
embedBatch: async (texts, optionsLocal) =>
texts.length === 0 ? [] : await embedMany(texts, optionsLocal?.signal),
};
return {
provider,
client: {
...client,
embedBatch: async (texts) => {
try {
return await provider.embedBatch(texts);
} catch (err) {
throw new Error(formatErrorMessage(err), { cause: err });
}
},
},
};
}

View File

@@ -0,0 +1,19 @@
// Ollama provider module implements model/runtime integration.
import {
describeImageWithModel,
describeImagesWithModel,
type MediaUnderstandingProvider,
} from "openclaw/plugin-sdk/media-understanding";
import { OLLAMA_PROVIDER_ID } from "./discovery-shared.js";
// Ollama vision support depends on which models the user has pulled (llava,
// qwen2.5vl, llama3.2-vision, …) — there is no single canonical default. We
// register the provider so the image tool can route `ollama/<vision-model>`
// requests, but leave `defaultModels` and `autoPriority` unset so Ollama
// only participates when the user explicitly configures an image model.
export const ollamaMediaUnderstandingProvider: MediaUnderstandingProvider = {
id: OLLAMA_PROVIDER_ID,
capabilities: ["image"],
describeImage: describeImageWithModel,
describeImages: describeImagesWithModel,
};

View File

@@ -0,0 +1,32 @@
// Ollama plugin module implements memory embedding adapter behavior.
import type { MemoryEmbeddingProviderAdapter } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import {
DEFAULT_OLLAMA_EMBEDDING_MODEL,
createOllamaEmbeddingProvider,
} from "./embedding-provider.js";
export const ollamaMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = {
id: "ollama",
defaultModel: DEFAULT_OLLAMA_EMBEDDING_MODEL,
transport: "remote",
authProviderId: "ollama",
create: async (options) => {
const { provider, client } = await createOllamaEmbeddingProvider({
...options,
provider: "ollama",
fallback: "none",
});
return {
provider,
runtime: {
id: "ollama",
inlineBatchTimeoutMs: 10 * 60_000,
cacheKeyData: {
provider: "ollama",
model: client.model,
outputDimensionality: client.outputDimensionality,
},
},
};
},
};

View File

@@ -0,0 +1,6 @@
// Ollama plugin module implements model behavior behavior.
import { isOllamaCloudKimiModelRef } from "./sanitizers/kimi-inline-reasoning.js";
export function shouldWrapOllamaCompatMoonshotThinking(modelId: string): boolean {
return isOllamaCloudKimiModelRef(modelId);
}

View File

@@ -0,0 +1,26 @@
// Ollama plugin module implements model id behavior.
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
const OLLAMA_PROVIDER_ID = "ollama";
function uniqueModelPrefixCandidates(providerId?: string): string[] {
const candidates = [providerId, normalizeProviderId(providerId ?? ""), OLLAMA_PROVIDER_ID]
.map((candidate) => candidate?.trim())
.filter((candidate): candidate is string => Boolean(candidate));
return uniqueStrings(candidates);
}
export function normalizeOllamaWireModelId(modelId: string, providerId?: string): string {
const trimmed = modelId.trim();
if (!trimmed) {
return trimmed;
}
for (const candidate of uniqueModelPrefixCandidates(providerId)) {
const prefix = `${candidate}/`;
if (trimmed.startsWith(prefix)) {
return trimmed.slice(prefix.length);
}
}
return trimmed;
}

View File

@@ -0,0 +1,330 @@
// Ollama node inference tests cover local discovery, chat, and agent tool routing.
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { describe, expect, it, vi } from "vitest";
import {
createOllamaNodeHostCommands,
createOllamaNodeInferenceTool,
createOllamaNodeInvokePolicy,
OLLAMA_CHAT_COMMAND,
OLLAMA_MODELS_COMMAND,
} from "./node-inference.js";
async function readBody(request: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.from(chunk));
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
async function withOllamaServer<T>(
run: (
baseUrl: string,
chatRequests: Record<string, unknown>[],
showRequests: string[],
) => Promise<T>,
): Promise<T> {
const chatRequests: Record<string, unknown>[] = [];
const showRequests: string[] = [];
const handleRequest = async (request: IncomingMessage, response: ServerResponse) => {
response.setHeader("Content-Type", "application/json");
if (request.url === "/api/tags") {
response.end(
JSON.stringify({
models: [
{
name: "remote:cloud",
size: 1,
remote_host: "https://ollama.com",
details: {},
},
{
name: "chat:small",
size: 500,
modified_at: "2026-07-01T00:00:00Z",
details: {
family: "small",
parameter_size: "0.5B",
quantization_level: "Q4_K_M",
},
},
{ name: "chat:large", size: 5000, details: { family: "large" } },
{ name: "embedding:latest", size: 100, details: { family: "embed" } },
{ name: "unknown:latest", size: 50, details: { family: "unknown" } },
],
}),
);
return;
}
if (request.url === "/api/ps") {
response.end(JSON.stringify({ models: [{ name: "chat:large" }] }));
return;
}
if (request.url === "/api/show") {
const body = (await readBody(request)) as { name?: string };
if (body.name) {
showRequests.push(body.name);
}
if (body.name === "unknown:latest") {
response.statusCode = 500;
response.end(JSON.stringify({ error: "show failed" }));
return;
}
const embedding = body.name === "embedding:latest";
response.end(
JSON.stringify({
capabilities: embedding ? ["embedding"] : ["completion", "tools"],
model_info: embedding ? {} : { "test.context_length": 32768 },
}),
);
return;
}
if (request.url === "/api/chat") {
const body = (await readBody(request)) as Record<string, unknown>;
chatRequests.push(body);
response.end(
JSON.stringify({
model: body.model,
message: { content: "local answer" },
done_reason:
(body.options as { num_predict?: unknown } | undefined)?.num_predict === 1
? "length"
: "stop",
prompt_eval_count: 8,
eval_count: 3,
load_duration: 2_500_000,
total_duration: 12_750_000,
}),
);
return;
}
response.statusCode = 404;
response.end(JSON.stringify({ error: "not found" }));
};
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
void handleRequest(request, response);
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("test server did not expose a TCP address");
}
try {
return await run(`http://127.0.0.1:${address.port}`, chatRequests, showRequests);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
}
function commandByName(baseUrl: string, command: string) {
const entry = createOllamaNodeHostCommands({ baseUrl }).find(
(candidate) => candidate.command === command,
);
if (!entry) {
throw new Error(`missing ${command} test command`);
}
return entry;
}
describe("Ollama node host inference", () => {
it("discovers local chat models and ranks loaded models first", async () => {
await withOllamaServer(async (baseUrl) => {
const result = JSON.parse(await commandByName(baseUrl, OLLAMA_MODELS_COMMAND).handle()) as {
provider: string;
models: Array<Record<string, unknown>>;
};
expect(result.provider).toBe("ollama");
expect(result.models.map((model) => model.name)).toEqual(["chat:large", "chat:small"]);
expect(result.models[0]).toMatchObject({ loaded: true, contextWindow: 32768 });
expect(result.models[1]).toMatchObject({
loaded: false,
family: "small",
parameterSize: "0.5B",
quantization: "Q4_K_M",
});
});
});
it("runs bounded chat and returns compact usage", async () => {
await withOllamaServer(async (baseUrl, chatRequests, showRequests) => {
const result = JSON.parse(
await commandByName(baseUrl, OLLAMA_CHAT_COMMAND).handle(
JSON.stringify({
model: "chat:small",
prompt: "Summarize this",
system: "Be concise",
maxTokens: 64,
temperature: 0.2,
}),
),
);
expect(chatRequests).toEqual([
{
model: "chat:small",
messages: [
{ role: "system", content: "Be concise" },
{ role: "user", content: "Summarize this" },
],
stream: false,
think: false,
options: { num_predict: 64, temperature: 0.2 },
},
]);
expect(showRequests).toEqual(["chat:small"]);
expect(result).toEqual({
provider: "ollama",
model: "chat:small",
response: "local answer",
usage: { promptTokens: 8, completionTokens: 3 },
timings: { loadMs: 2.5, totalMs: 12.75 },
});
});
});
it("rejects remote and non-chat models before inference", async () => {
await withOllamaServer(async (baseUrl, chatRequests) => {
await expect(
commandByName(baseUrl, OLLAMA_CHAT_COMMAND).handle(
JSON.stringify({ model: "remote:cloud", prompt: "hello" }),
),
).rejects.toThrow("is not a local chat model");
await expect(
commandByName(baseUrl, OLLAMA_CHAT_COMMAND).handle(
JSON.stringify({ model: "embedding:latest", prompt: "hello" }),
),
).rejects.toThrow("is not a local chat model");
expect(chatRequests).toHaveLength(0);
});
});
it("rejects a token-limited partial answer", async () => {
await withOllamaServer(async (baseUrl) => {
await expect(
commandByName(baseUrl, OLLAMA_CHAT_COMMAND).handle(
JSON.stringify({ model: "chat:small", prompt: "long answer", maxTokens: 1 }),
),
).rejects.toThrow("reaching maxTokens (1)");
});
});
it("registers a desktop and server pass-through policy", async () => {
const policy = createOllamaNodeInvokePolicy();
const invokeNode = vi.fn(async () => ({ ok: true as const, payload: { ok: true } }));
expect(policy.commands).toEqual([OLLAMA_MODELS_COMMAND, OLLAMA_CHAT_COMMAND]);
expect(policy.defaultPlatforms).toEqual(["macos", "linux", "windows"]);
await expect(policy.handle({ invokeNode } as never)).resolves.toEqual({
ok: true,
payload: { ok: true },
});
});
});
describe("node_inference agent tool", () => {
it("discovers models through the connected node runtime", async () => {
const invoke = vi.fn(async () => ({
payload: { provider: "ollama", models: [{ name: "chat:small", loaded: true }] },
}));
const api = createTestPluginApi({
runtime: {
nodes: {
list: async () => ({
nodes: [
{
nodeId: "node-1",
displayName: "Desk",
connected: true,
commands: [OLLAMA_MODELS_COMMAND, OLLAMA_CHAT_COMMAND],
},
],
}),
invoke,
},
} as never,
});
const result = await createOllamaNodeInferenceTool(api).execute("call-1", {
action: "discover",
});
expect(invoke).toHaveBeenCalledWith({
nodeId: "node-1",
command: OLLAMA_MODELS_COMMAND,
params: {},
timeoutMs: 90_000,
scopes: ["operator.write"],
});
expect(result.details).toEqual({
nodes: [
{
nodeId: "node-1",
displayName: "Desk",
ok: true,
provider: "ollama",
models: [{ name: "chat:small", loaded: true }],
},
],
});
});
it("routes a run to the sole capable node", async () => {
const invoke = vi.fn(async () => ({
payload: { provider: "ollama", model: "chat:small", response: "done" },
}));
const api = createTestPluginApi({
runtime: {
nodes: {
list: async () => ({
nodes: [
{
nodeId: "node-1",
connected: true,
commands: [OLLAMA_MODELS_COMMAND, OLLAMA_CHAT_COMMAND],
},
],
}),
invoke,
},
} as never,
});
const result = await createOllamaNodeInferenceTool(api).execute("call-2", {
action: "run",
model: "chat:small",
prompt: "answer fast",
maxTokens: 32,
});
expect(invoke).toHaveBeenCalledWith({
nodeId: "node-1",
command: OLLAMA_CHAT_COMMAND,
params: {
model: "chat:small",
prompt: "answer fast",
maxTokens: 32,
timeoutMs: 120_000,
},
timeoutMs: 130_000,
scopes: ["operator.write"],
});
expect(result.details).toMatchObject({
nodeId: "node-1",
provider: "ollama",
model: "chat:small",
response: "done",
});
});
});

View File

@@ -0,0 +1,550 @@
// Ollama node inference exposes local models to agents through paired node hosts.
import { jsonResult } from "openclaw/plugin-sdk/channel-actions";
import {
readFiniteNumberParam,
readPositiveIntegerParam,
readStringParam,
} from "openclaw/plugin-sdk/param-readers";
import type {
AnyAgentTool,
OpenClawPluginApi,
OpenClawPluginNodeHostCommand,
OpenClawPluginNodeInvokePolicy,
} from "openclaw/plugin-sdk/plugin-entry";
import {
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { Type } from "typebox";
import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js";
import {
buildOllamaBaseUrlSsrFPolicy,
enrichOllamaModelsWithContext,
fetchOllamaModels,
resolveOllamaApiBase,
} from "./provider-models.js";
export const OLLAMA_NODE_INFERENCE_CAPABILITY = "local-inference";
export const OLLAMA_MODELS_COMMAND = "ollama.models";
export const OLLAMA_CHAT_COMMAND = "ollama.chat";
export const OLLAMA_NODE_INFERENCE_COMMANDS = [OLLAMA_MODELS_COMMAND, OLLAMA_CHAT_COMMAND] as const;
const DEFAULT_INFERENCE_TIMEOUT_MS = 120_000;
const DEFAULT_MAX_TOKENS = 512;
const DISCOVERY_TRANSPORT_TIMEOUT_MS = 90_000;
const INFERENCE_TRANSPORT_GRACE_MS = 10_000;
const MAX_INFERENCE_TIMEOUT_MS = 10 * 60_000;
const MAX_TOKENS = 8192;
const MAX_PROMPT_CHARS = 128_000;
const MAX_SYSTEM_PROMPT_CHARS = 32_000;
const MAX_DISCOVERED_MODELS = 200;
const MAX_ERROR_BODY_BYTES = 500;
type NodeModel = {
name: string;
size?: number;
modifiedAt?: string;
family?: string;
parameterSize?: string;
quantization?: string;
contextWindow?: number;
capabilities?: string[];
loaded: boolean;
};
type OllamaModelsPayload = {
provider: "ollama";
models: NodeModel[];
};
type OllamaChatPayload = {
provider: "ollama";
model: string;
response: string;
usage?: {
promptTokens?: number;
completionTokens?: number;
};
timings?: {
loadMs?: number;
totalMs?: number;
};
};
type NodeSummary = Awaited<
ReturnType<OpenClawPluginApi["runtime"]["nodes"]["list"]>
>["nodes"][number];
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function readNodeCommandParams(paramsJSON?: string | null): Record<string, unknown> {
if (!paramsJSON) {
return {};
}
const parsed = asRecord(JSON.parse(paramsJSON));
if (!parsed) {
throw new Error("node inference params must be a JSON object");
}
return parsed;
}
function errorMessage(error: unknown): string {
return error instanceof Error && error.message ? error.message : String(error);
}
function durationMs(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return undefined;
}
return Math.round((value / 1_000_000) * 100) / 100;
}
function optionalNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
async function requestOllamaJson<T>(params: {
baseUrl: string;
path: string;
timeoutMs: number;
init?: RequestInit;
}): Promise<T> {
const apiBase = resolveOllamaApiBase(params.baseUrl);
let response: Response;
let release: (() => Promise<void>) | undefined;
try {
const guarded = await fetchWithSsrFGuard({
url: `${apiBase}${params.path}`,
init: {
...params.init,
signal: AbortSignal.timeout(params.timeoutMs),
},
policy: buildOllamaBaseUrlSsrFPolicy(apiBase),
auditContext: `ollama-node-inference${params.path}`,
});
response = guarded.response;
release = guarded.release;
} catch (error) {
throw new Error(`Ollama is unavailable at ${apiBase}: ${errorMessage(error)}`, {
cause: error,
});
}
try {
if (!response.ok) {
const body = (await readResponseTextLimited(response, MAX_ERROR_BODY_BYTES)).trim();
let detail = body;
try {
const parsed = asRecord(JSON.parse(body));
detail = typeof parsed?.error === "string" ? parsed.error : body;
} catch {
// Keep the bounded response text when Ollama returns a non-JSON error.
}
throw new Error(
`Ollama ${params.path} failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`,
);
}
return await readProviderJsonResponse<T>(response, `ollama-node-inference${params.path}`);
} finally {
await release();
}
}
async function fetchLoadedModelNames(baseUrl: string): Promise<Set<string>> {
try {
const data = await requestOllamaJson<{ models?: Array<{ name?: unknown; model?: unknown }> }>({
baseUrl,
path: "/api/ps",
timeoutMs: 5000,
});
return new Set(
(data.models ?? [])
.map((model) =>
typeof model.name === "string"
? model.name.trim()
: typeof model.model === "string"
? model.model.trim()
: "",
)
.filter(Boolean),
);
} catch {
// Model discovery still works against Ollama versions without /api/ps.
return new Set();
}
}
export async function discoverOllamaNodeModels(
baseUrl = OLLAMA_DEFAULT_BASE_URL,
): Promise<OllamaModelsPayload> {
const apiBase = resolveOllamaApiBase(baseUrl);
const discovered = await fetchOllamaModels(apiBase);
if (!discovered.reachable) {
throw new Error(`Ollama is not running at ${apiBase}`);
}
const localModels = discovered.models
.filter((model) => !model.remote_host?.trim())
.slice(0, MAX_DISCOVERED_MODELS);
const [models, loadedNames] = await Promise.all([
enrichOllamaModelsWithContext(apiBase, localModels),
fetchLoadedModelNames(apiBase),
]);
const rows = models
// Nodes advertise only models Ollama positively identifies as chat-capable.
// Failed /api/show probes must not turn embedding models into runnable choices.
.filter((model) => model.capabilities?.includes("completion") === true)
.map((model): NodeModel => {
const details = model.details;
const row: NodeModel = {
name: model.name,
loaded: loadedNames.has(model.name),
};
if (typeof model.size === "number") {
row.size = model.size;
}
if (typeof model.modified_at === "string") {
row.modifiedAt = model.modified_at;
}
if (details?.family) {
row.family = details.family;
}
if (details?.parameter_size) {
row.parameterSize = details.parameter_size;
}
if (details?.quantization_level) {
row.quantization = details.quantization_level;
}
if (typeof model.contextWindow === "number") {
row.contextWindow = model.contextWindow;
}
if (model.capabilities) {
row.capabilities = model.capabilities;
}
return row;
})
.toSorted((left, right) => {
if (left.loaded !== right.loaded) {
return left.loaded ? -1 : 1;
}
const sizeDelta =
(left.size ?? Number.MAX_SAFE_INTEGER) - (right.size ?? Number.MAX_SAFE_INTEGER);
return sizeDelta || left.name.localeCompare(right.name);
});
return { provider: "ollama", models: rows };
}
async function runOllamaNodeChat(params: {
baseUrl: string;
model: string;
prompt: string;
system?: string;
temperature?: number;
maxTokens: number;
timeoutMs: number;
}): Promise<OllamaChatPayload> {
const apiBase = resolveOllamaApiBase(params.baseUrl);
const discovered = await fetchOllamaModels(apiBase);
const localModel = discovered.models.find(
(model) => model.name === params.model && !model.remote_host?.trim(),
);
const [model] = localModel ? await enrichOllamaModelsWithContext(apiBase, [localModel]) : [];
if (!discovered.reachable || model?.capabilities?.includes("completion") !== true) {
throw new Error(
`Ollama model ${JSON.stringify(params.model)} is not a local chat model; discover models first`,
);
}
const messages = [
...(params.system ? [{ role: "system", content: params.system }] : []),
{ role: "user", content: params.prompt },
];
const data = await requestOllamaJson<{
model?: unknown;
message?: { content?: unknown };
done_reason?: unknown;
prompt_eval_count?: unknown;
eval_count?: unknown;
load_duration?: unknown;
total_duration?: unknown;
}>({
baseUrl: params.baseUrl,
path: "/api/chat",
timeoutMs: params.timeoutMs,
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: params.model,
messages,
stream: false,
think: false,
options: {
num_predict: params.maxTokens,
...(params.temperature !== undefined && { temperature: params.temperature }),
},
}),
},
});
const response = typeof data.message?.content === "string" ? data.message.content : undefined;
if (response === undefined) {
throw new Error("Ollama /api/chat response did not contain message.content");
}
if (data.done_reason === "length") {
throw new Error(
`Ollama stopped after reaching maxTokens (${params.maxTokens}); retry with a larger maxTokens value`,
);
}
const promptTokens = optionalNumber(data.prompt_eval_count);
const completionTokens = optionalNumber(data.eval_count);
const loadMs = durationMs(data.load_duration);
const totalMs = durationMs(data.total_duration);
return {
provider: "ollama",
model: typeof data.model === "string" && data.model.trim() ? data.model : params.model,
response,
...(promptTokens !== undefined || completionTokens !== undefined
? { usage: { promptTokens, completionTokens } }
: {}),
...(loadMs !== undefined || totalMs !== undefined ? { timings: { loadMs, totalMs } } : {}),
};
}
export function createOllamaNodeHostCommands(options?: {
baseUrl?: string;
}): OpenClawPluginNodeHostCommand[] {
const baseUrl = options?.baseUrl ?? OLLAMA_DEFAULT_BASE_URL;
return [
{
command: OLLAMA_MODELS_COMMAND,
cap: OLLAMA_NODE_INFERENCE_CAPABILITY,
handle: async () => JSON.stringify(await discoverOllamaNodeModels(baseUrl)),
},
{
command: OLLAMA_CHAT_COMMAND,
cap: OLLAMA_NODE_INFERENCE_CAPABILITY,
handle: async (paramsJSON) => {
const params = readNodeCommandParams(paramsJSON);
const model = readStringParam(params, "model", { required: true });
const prompt = readStringParam(params, "prompt", { required: true, trim: false });
const system = readStringParam(params, "system", { trim: false });
const maxTokens =
readPositiveIntegerParam(params, "maxTokens", {
max: MAX_TOKENS,
message: `maxTokens must be an integer between 1 and ${MAX_TOKENS}`,
}) ?? DEFAULT_MAX_TOKENS;
const timeoutMs =
readPositiveIntegerParam(params, "timeoutMs", {
max: MAX_INFERENCE_TIMEOUT_MS,
message: `timeoutMs must be an integer between 1 and ${MAX_INFERENCE_TIMEOUT_MS}`,
}) ?? DEFAULT_INFERENCE_TIMEOUT_MS;
const temperature = readFiniteNumberParam(params, "temperature", {
min: 0,
max: 2,
message: "temperature must be between 0 and 2",
});
if (prompt.length > MAX_PROMPT_CHARS) {
throw new Error(`prompt exceeds ${MAX_PROMPT_CHARS} characters`);
}
if (system && system.length > MAX_SYSTEM_PROMPT_CHARS) {
throw new Error(`system exceeds ${MAX_SYSTEM_PROMPT_CHARS} characters`);
}
return JSON.stringify(
await runOllamaNodeChat({
baseUrl,
model,
prompt,
system,
temperature,
maxTokens,
timeoutMs,
}),
);
},
},
];
}
export function createOllamaNodeInvokePolicy(): OpenClawPluginNodeInvokePolicy {
return {
commands: [...OLLAMA_NODE_INFERENCE_COMMANDS],
defaultPlatforms: ["macos", "linux", "windows"],
handle: async (ctx) => await ctx.invokeNode(),
};
}
function findNode(nodes: NodeSummary[], query: string): NodeSummary {
const normalized = query.trim().toLowerCase();
const matches = nodes.filter(
(node) =>
node.nodeId.toLowerCase() === normalized || node.displayName?.toLowerCase() === normalized,
);
if (matches.length === 0) {
throw new Error(`node ${JSON.stringify(query)} is not connected with Ollama inference support`);
}
if (matches.length > 1) {
throw new Error(`node ${JSON.stringify(query)} is ambiguous; use its nodeId`);
}
return matches[0];
}
function parseInvokePayload(raw: unknown): Record<string, unknown> {
const result = asRecord(raw);
let payload = asRecord(result?.payload);
if (!payload && typeof result?.payloadJSON === "string") {
payload = asRecord(JSON.parse(result.payloadJSON));
}
if (!payload) {
throw new Error("node returned an invalid Ollama inference payload");
}
return payload;
}
async function invokeNode(
api: OpenClawPluginApi,
nodeId: string,
command: string,
params: Record<string, unknown>,
timeoutMs: number,
): Promise<Record<string, unknown>> {
const raw = await api.runtime.nodes.invoke({
nodeId,
command,
params,
timeoutMs,
scopes: ["operator.write"],
});
return parseInvokePayload(raw);
}
export const ollamaNodeInferenceToolDefinition = {
name: "node_inference",
label: "Node Inference",
description:
"Discover and run chat-capable Ollama models installed on paired desktop/server nodes. Use action=discover first, then action=run with a node and model from that result. Inference stays on the selected node.",
parameters: Type.Object(
{
action: Type.Union([Type.Literal("discover"), Type.Literal("run")]),
node: Type.Optional(
Type.String({ description: "Connected node id or display name. Required when ambiguous." }),
),
model: Type.Optional(
Type.String({ description: "Exact local model name returned by discover." }),
),
prompt: Type.Optional(Type.String({ description: "Prompt for action=run." })),
system: Type.Optional(Type.String({ description: "Optional system prompt for action=run." })),
temperature: Type.Optional(Type.Number({ minimum: 0, maximum: 2 })),
maxTokens: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_TOKENS })),
timeoutMs: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_INFERENCE_TIMEOUT_MS })),
},
{ additionalProperties: false },
),
} as const;
export function createOllamaNodeInferenceTool(api: OpenClawPluginApi): AnyAgentTool {
return {
...ollamaNodeInferenceToolDefinition,
execute: async (_toolCallId, args) => {
const params = asRecord(args) ?? {};
const action = readStringParam(params, "action", { required: true });
const nodeQuery = readStringParam(params, "node");
const listed = await api.runtime.nodes.list({ connected: true });
const modelNodes = listed.nodes.filter((node) =>
node.commands?.includes(OLLAMA_MODELS_COMMAND),
);
if (action === "discover") {
const targets = nodeQuery ? [findNode(modelNodes, nodeQuery)] : modelNodes;
const nodes = await Promise.all(
targets.map(async (node) => {
try {
const payload = await invokeNode(
api,
node.nodeId,
OLLAMA_MODELS_COMMAND,
{},
DISCOVERY_TRANSPORT_TIMEOUT_MS,
);
const result: Record<string, unknown> = { nodeId: node.nodeId, ok: true };
if (node.displayName) {
result.displayName = node.displayName;
}
return Object.assign(result, payload);
} catch (error) {
const result: Record<string, unknown> = {
nodeId: node.nodeId,
ok: false,
error: errorMessage(error),
};
if (node.displayName) {
result.displayName = node.displayName;
}
return result;
}
}),
);
return jsonResult({
nodes,
...(modelNodes.length === 0 && {
hint: "No connected node advertises Ollama inference. Start Ollama and `openclaw node run` on the target machine, then approve any request shown by `openclaw nodes pending`.",
}),
});
}
if (action !== "run") {
throw new Error("action must be discover or run");
}
const chatNodes = modelNodes.filter((node) => node.commands?.includes(OLLAMA_CHAT_COMMAND));
const node = nodeQuery
? findNode(chatNodes, nodeQuery)
: chatNodes.length === 1
? chatNodes[0]
: undefined;
if (!node) {
throw new Error(
chatNodes.length === 0
? "no connected node advertises Ollama inference"
: "multiple nodes advertise Ollama inference; specify node",
);
}
const model = readStringParam(params, "model", { required: true });
const prompt = readStringParam(params, "prompt", { required: true, trim: false });
const maxTokens =
readPositiveIntegerParam(params, "maxTokens", { max: MAX_TOKENS }) ?? DEFAULT_MAX_TOKENS;
const timeoutMs =
readPositiveIntegerParam(params, "timeoutMs", { max: MAX_INFERENCE_TIMEOUT_MS }) ??
DEFAULT_INFERENCE_TIMEOUT_MS;
const system = readStringParam(params, "system", { trim: false });
const temperature = readFiniteNumberParam(params, "temperature", { min: 0, max: 2 });
const commandParams: Record<string, unknown> = {
model,
prompt,
maxTokens,
timeoutMs,
};
if (system !== undefined) {
commandParams.system = system;
}
if (temperature !== undefined) {
commandParams.temperature = temperature;
}
const result = await invokeNode(
api,
node.nodeId,
OLLAMA_CHAT_COMMAND,
commandParams,
// The command validates the selected model before starting its chat timeout.
// Keep that bounded preflight outside the inference budget seen by users.
timeoutMs + INFERENCE_TRANSPORT_GRACE_MS,
);
return jsonResult({
nodeId: node.nodeId,
...(node.displayName && { displayName: node.displayName }),
...result,
});
},
};
}

View File

@@ -0,0 +1,5 @@
// Ollama plugin module implements ollama json behavior.
export {
parseJsonObjectPreservingUnsafeIntegers,
parseJsonPreservingUnsafeIntegers,
} from "openclaw/plugin-sdk/json-unsafe-integers";

View File

@@ -0,0 +1,45 @@
// Ollama tests cover provider base url plugin behavior.
import { describe, expect, it } from "vitest";
import { readProviderBaseUrl } from "./provider-base-url.js";
describe("readProviderBaseUrl", () => {
it("reads canonical baseUrl and trims whitespace", () => {
expect(readProviderBaseUrl({ baseUrl: " http://host:11434/v1 ", models: [] })).toBe(
"http://host:11434/v1",
);
});
it("falls back to OpenAI SDK-style baseURL", () => {
const provider = {
baseURL: " http://remote-ollama:11434 ",
models: [],
} as unknown as Parameters<typeof readProviderBaseUrl>[0];
expect(readProviderBaseUrl(provider)).toBe("http://remote-ollama:11434");
});
it("prefers canonical baseUrl over baseURL", () => {
const provider = {
baseUrl: "http://canonical:11434",
baseURL: "http://alternate:11434",
models: [],
} as unknown as Parameters<typeof readProviderBaseUrl>[0];
expect(readProviderBaseUrl(provider)).toBe("http://canonical:11434");
});
it("ignores inherited baseUrl aliases", () => {
const provider = { models: [] } as unknown as Parameters<typeof readProviderBaseUrl>[0];
Object.setPrototypeOf(provider, { baseUrl: "http://inherited:11434" });
expect(readProviderBaseUrl(provider)).toBeUndefined();
});
it("returns undefined for empty or missing values", () => {
expect(readProviderBaseUrl(undefined)).toBeUndefined();
expect(
readProviderBaseUrl({ models: [] } as unknown as Parameters<typeof readProviderBaseUrl>[0]),
).toBeUndefined();
expect(readProviderBaseUrl({ baseUrl: " ", models: [] })).toBeUndefined();
});
});

View File

@@ -0,0 +1,37 @@
// Ollama provider module implements model/runtime integration.
import type {
ModelProviderConfig,
ModelDefinitionConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
/**
* Provider config input type — partial config without required `models`.
* Replaces the deprecated `openclaw/plugin-sdk/config-types` import.
*/
type OllamaProviderConfigInput = Omit<Partial<ModelProviderConfig>, "models"> & {
models?: ModelDefinitionConfig[];
};
export function readProviderBaseUrl(
provider: OllamaProviderConfigInput | undefined,
): string | undefined {
if (!provider) {
return undefined;
}
if (
Object.hasOwn(provider, "baseUrl") &&
typeof provider.baseUrl === "string" &&
provider.baseUrl.trim()
) {
return provider.baseUrl.trim();
}
const alternate = provider as OllamaProviderConfigInput & { baseURL?: unknown };
if (
Object.hasOwn(alternate, "baseURL") &&
typeof alternate.baseURL === "string" &&
alternate.baseURL.trim()
) {
return alternate.baseURL.trim();
}
return undefined;
}

View File

@@ -0,0 +1,42 @@
// Ollama tests cover provider models.ssrf plugin behavior.
import { describe, expect, it } from "vitest";
import { buildOllamaBaseUrlSsrFPolicy } from "./provider-models.js";
describe("buildOllamaBaseUrlSsrFPolicy", () => {
it("pins requests to the configured Ollama hostname for HTTP(S) URLs", () => {
expect(buildOllamaBaseUrlSsrFPolicy("http://127.0.0.1:11434")).toEqual({
hostnameAllowlist: ["127.0.0.1"],
allowPrivateNetwork: true,
});
expect(buildOllamaBaseUrlSsrFPolicy("http://192.168.1.10:11434")).toEqual({
hostnameAllowlist: ["192.168.1.10"],
allowPrivateNetwork: true,
});
expect(buildOllamaBaseUrlSsrFPolicy("https://ollama.example.com/v1")).toEqual({
hostnameAllowlist: ["ollama.example.com"],
allowPrivateNetwork: true,
});
});
it("opts into private-network access for explicit Ollama hosts", () => {
expect(buildOllamaBaseUrlSsrFPolicy("http://localhost:11434")).toEqual({
hostnameAllowlist: ["localhost"],
allowPrivateNetwork: true,
});
expect(buildOllamaBaseUrlSsrFPolicy("http://[fd00::1]:11434")).toEqual({
hostnameAllowlist: ["[fd00::1]"],
allowPrivateNetwork: true,
});
expect(buildOllamaBaseUrlSsrFPolicy("https://ollama.local:11434")).toEqual({
hostnameAllowlist: ["ollama.local"],
allowPrivateNetwork: true,
});
});
it("returns no allowlist for empty or invalid base URLs", () => {
expect(buildOllamaBaseUrlSsrFPolicy("")).toBeUndefined();
expect(buildOllamaBaseUrlSsrFPolicy("ftp://ollama.example.com")).toBeUndefined();
expect(buildOllamaBaseUrlSsrFPolicy("not-a-url")).toBeUndefined();
expect(buildOllamaBaseUrlSsrFPolicy("http://metadata.google.internal")).toBeUndefined();
});
});

View File

@@ -0,0 +1,438 @@
// Ollama tests cover provider models plugin behavior.
import { jsonResponse, requestBodyText, requestUrl } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildOllamaProvider,
buildOllamaModelDefinition,
enrichOllamaModelsWithContext,
fetchOllamaModels,
parseOllamaNumCtxParameter,
queryOllamaModelShowInfo,
resetOllamaModelShowInfoCacheForTest,
resolveOllamaApiBase,
type OllamaTagModel,
} from "./provider-models.js";
describe("ollama provider models", () => {
afterEach(() => {
resetOllamaModelShowInfoCacheForTest();
vi.unstubAllGlobals();
});
it("strips /v1 when resolving the Ollama API base", () => {
expect(resolveOllamaApiBase("http://127.0.0.1:11434/v1")).toBe("http://127.0.0.1:11434");
expect(resolveOllamaApiBase("http://127.0.0.1:11434///")).toBe("http://127.0.0.1:11434");
});
it("sets discovered models with context windows from /api/show", async () => {
const models: OllamaTagModel[] = [{ name: "llama3:8b" }, { name: "deepseek-r1:14b" }];
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = requestUrl(input);
if (!url.endsWith("/api/show")) {
throw new Error(`Unexpected fetch: ${url}`);
}
const body = JSON.parse(requestBodyText(init?.body)) as { name?: string };
if (body.name === "llama3:8b") {
return jsonResponse({ model_info: { "llama.context_length": 65536 } });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
const enriched = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", models);
expect(enriched).toEqual([
{ name: "llama3:8b", contextWindow: 65536, capabilities: undefined },
{ name: "deepseek-r1:14b", contextWindow: undefined, capabilities: undefined },
]);
expect(
buildOllamaModelDefinition(
enriched[1].name,
enriched[1].contextWindow,
enriched[1].capabilities,
).compat?.supportsTools,
).toBe(true);
});
it("forwards remote auth to model listing and show probes", async () => {
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer cloud-key");
const url = requestUrl(input);
if (url.endsWith("/api/tags")) {
return jsonResponse({ models: [{ name: "glm-5.2:cloud" }] });
}
if (url.endsWith("/api/show")) {
return jsonResponse({
model_info: { "glm5.2.context_length": 1_000_000 },
capabilities: ["completion", "thinking", "tools"],
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const provider = await buildOllamaProvider("https://ollama.com", {
apiKey: "cloud-key",
});
expect(provider.models).toEqual([
expect.objectContaining({
id: "glm-5.2:cloud",
contextWindow: 1_000_000,
maxTokens: 8_192,
reasoning: true,
}),
]);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("scopes cached show metadata by credential", async () => {
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = requestUrl(input);
if (url.endsWith("/api/tags")) {
return jsonResponse({ models: [{ name: "private-model", digest: "stable" }] });
}
const apiKey = new Headers(init?.headers).get("Authorization");
return jsonResponse({
model_info: {
"private.context_length": apiKey === "Bearer account-a" ? 16_000 : 32_000,
},
});
});
vi.stubGlobal("fetch", fetchMock);
const first = await buildOllamaProvider("https://ollama.example.com", {
apiKey: "account-a",
});
const second = await buildOllamaProvider("https://ollama.example.com", {
apiKey: "account-b",
});
expect(first.models?.[0]?.contextWindow).toBe(16_000);
expect(second.models?.[0]?.contextWindow).toBe(32_000);
expect(fetchMock).toHaveBeenCalledTimes(4);
});
it("recognizes the static Ollama Cloud GLM-5.2 model as reasoning-capable", () => {
expect(buildOllamaModelDefinition("glm-5.2:cloud")).toEqual(
expect.objectContaining({
reasoning: true,
contextWindow: 1_000_000,
maxTokens: 8192,
}),
);
});
it("uses Modelfile num_ctx when it expands the discovered context window", async () => {
const models: OllamaTagModel[] = [{ name: "llama3-32k:latest" }];
const fetchMock = vi.fn(async () =>
jsonResponse({
model_info: { "llama.context_length": 8192 },
parameters: 'stop "<|eot_id|>"\nnum_ctx 32768\nnum_keep 5',
capabilities: ["completion"],
}),
);
vi.stubGlobal("fetch", fetchMock);
const enriched = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", models);
expect(enriched).toEqual([
{
name: "llama3-32k:latest",
contextWindow: 32768,
capabilities: ["completion"],
},
]);
});
it("keeps the larger native context window when Modelfile num_ctx is smaller", async () => {
const models: OllamaTagModel[] = [{ name: "llama3.2:latest" }];
const fetchMock = vi.fn(async () =>
jsonResponse({
model_info: { "llama.context_length": 131072 },
parameters: "num_ctx 4096",
}),
);
vi.stubGlobal("fetch", fetchMock);
const enriched = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", models);
expect(enriched[0]?.contextWindow).toBe(131072);
});
it("uses positive num_ctx when /api/show omits model context metadata", async () => {
const models: OllamaTagModel[] = [{ name: "custom-model:latest" }];
const fetchMock = vi.fn(async () =>
jsonResponse({
model_info: {},
parameters: "num_ctx 16384",
}),
);
vi.stubGlobal("fetch", fetchMock);
const enriched = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", models);
expect(enriched[0]?.contextWindow).toBe(16384);
});
it("sets models with vision capability from /api/show capabilities", async () => {
const models: OllamaTagModel[] = [{ name: "kimi-k2.5:cloud" }, { name: "glm-5.1:cloud" }];
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = requestUrl(input);
if (!url.endsWith("/api/show")) {
throw new Error(`Unexpected fetch: ${url}`);
}
const body = JSON.parse(requestBodyText(init?.body)) as { name?: string };
if (body.name === "kimi-k2.5:cloud") {
return jsonResponse({
model_info: { "kimi-k2.context_length": 262144 },
capabilities: ["vision", "thinking", "completion", "tools"],
});
}
if (body.name === "glm-5.1:cloud") {
return jsonResponse({
model_info: { "glm5.context_length": 202752 },
capabilities: ["thinking", "completion", "tools"],
});
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
const enriched = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", models);
expect(enriched).toEqual([
{
name: "kimi-k2.5:cloud",
contextWindow: 262144,
capabilities: ["vision", "thinking", "completion", "tools"],
},
{
name: "glm-5.1:cloud",
contextWindow: 202752,
capabilities: ["thinking", "completion", "tools"],
},
]);
});
it("reuses cached /api/show metadata when the model digest is unchanged", async () => {
const models: OllamaTagModel[] = [
{ name: "qwen3:32b", digest: "sha256:abc123", modified_at: "2026-04-11T00:00:00Z" },
];
const fetchMock = vi.fn(async () =>
jsonResponse({
model_info: { "qwen3.context_length": 131072 },
capabilities: ["thinking", "tools"],
}),
);
vi.stubGlobal("fetch", fetchMock);
const first = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", models);
const second = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", models);
expect(first).toEqual(second);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("refreshes cached /api/show metadata when the model digest changes", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
jsonResponse({
model_info: { "qwen3.context_length": 131072 },
capabilities: ["thinking", "tools"],
}),
)
.mockResolvedValueOnce(
jsonResponse({
model_info: { "qwen3.context_length": 262144 },
capabilities: ["vision", "thinking", "tools"],
}),
);
vi.stubGlobal("fetch", fetchMock);
const first = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", [
{ name: "qwen3:32b", digest: "sha256:abc123" },
]);
const second = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", [
{ name: "qwen3:32b", digest: "sha256:def456" },
]);
expect(first).toEqual([
{
name: "qwen3:32b",
digest: "sha256:abc123",
contextWindow: 131072,
capabilities: ["thinking", "tools"],
},
]);
expect(second).toEqual([
{
name: "qwen3:32b",
digest: "sha256:def456",
contextWindow: 262144,
capabilities: ["vision", "thinking", "tools"],
},
]);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("retries /api/show after an empty result for the same digest", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({}))
.mockResolvedValueOnce(
jsonResponse({
model_info: { "qwen3.context_length": 131072 },
capabilities: ["thinking", "tools"],
}),
);
vi.stubGlobal("fetch", fetchMock);
const model: OllamaTagModel = { name: "qwen3:32b", digest: "sha256:abc123" };
const first = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", [model]);
const second = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", [model]);
expect(first).toEqual([
{
name: "qwen3:32b",
digest: "sha256:abc123",
contextWindow: undefined,
capabilities: undefined,
},
]);
expect(second).toEqual([
{
name: "qwen3:32b",
digest: "sha256:abc123",
contextWindow: 131072,
capabilities: ["thinking", "tools"],
},
]);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("normalizes /v1 base URLs before fetching and reuses the same cache entry", async () => {
const model: OllamaTagModel = { name: "qwen3:32b", digest: "sha256:abc123" };
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
expect(requestUrl(input)).toBe("http://127.0.0.1:11434/api/show");
expect(JSON.parse(requestBodyText(init?.body))).toEqual({ name: "qwen3:32b" });
return jsonResponse({
model_info: { "qwen3.context_length": 131072 },
capabilities: ["thinking", "tools"],
});
});
vi.stubGlobal("fetch", fetchMock);
const first = await enrichOllamaModelsWithContext("http://127.0.0.1:11434/v1/", [model]);
const second = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", [model]);
expect(first).toEqual(second);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("buildOllamaModelDefinition sets input to text+image when vision capability is present", () => {
const visionModel = buildOllamaModelDefinition("kimi-k2.5:cloud", 262144, [
"vision",
"completion",
"tools",
"thinking",
]);
expect(visionModel.input).toEqual(["text", "image"]);
expect(visionModel.reasoning).toBe(true);
expect(visionModel.compat?.supportsTools).toBe(true);
expect(visionModel.compat?.supportsUsageInStreaming).toBe(true);
const textModel = buildOllamaModelDefinition("glm-5.1:cloud", 202752, ["completion", "tools"]);
expect(textModel.input).toEqual(["text"]);
expect(textModel.reasoning).toBe(false);
expect(textModel.compat?.supportsTools).toBe(true);
expect(textModel.compat?.supportsUsageInStreaming).toBe(true);
const deepseekCloudModel = buildOllamaModelDefinition("deepseek-v4-pro:cloud", 1048576, [
"completion",
"tools",
]);
expect(deepseekCloudModel.reasoning).toBe(true);
expect(deepseekCloudModel.compat?.supportsTools).toBe(true);
const deepseekCloudModelWithoutCapabilities = buildOllamaModelDefinition(
"deepseek-v4-flash:cloud",
1048576,
);
expect(deepseekCloudModelWithoutCapabilities.reasoning).toBe(true);
const noCapabilities = buildOllamaModelDefinition("unknown-model", 65536);
expect(noCapabilities.input).toEqual(["text"]);
expect(noCapabilities.compat?.supportsTools).toBe(true);
expect(noCapabilities.compat?.supportsUsageInStreaming).toBe(true);
});
it("disables tool support when Ollama capabilities omit tools", () => {
const model = buildOllamaModelDefinition("embeddinggemma:latest", 2048, ["embedding"]);
expect(model.reasoning).toBe(false);
expect(model.compat?.supportsTools).toBe(false);
expect(model.compat?.supportsUsageInStreaming).toBe(true);
});
it("parses the last positive Modelfile num_ctx value", () => {
expect(parseOllamaNumCtxParameter("num_ctx 8192\nnum_ctx 32768")).toBe(32768);
expect(parseOllamaNumCtxParameter("temperature 0.8\nnum_ctx -1\nnum_ctx 0")).toBeUndefined();
expect(parseOllamaNumCtxParameter('stop "<|eot_id|>"')).toBeUndefined();
expect(parseOllamaNumCtxParameter({ num_ctx: 8192 })).toBeUndefined();
});
it("fails soft and stops reading when discovery streams exceed the JSON byte cap", async () => {
// Larger than the shared 16 MiB readProviderJsonResponse cap so the bounded reader cancels
// the stream mid-flight; if the cap were removed the reader would buffer the whole payload.
const ONE_MIB = 1024 * 1024;
const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap.
const chunk = new Uint8Array(ONE_MIB);
let bytesPulled = 0;
let canceled = false;
const makeOversizedJsonResponse = (): Response => {
bytesPulled = 0;
canceled = false;
let pulled = 0;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (pulled >= TOTAL_CHUNKS) {
controller.close();
return;
}
pulled += 1;
bytesPulled += chunk.length;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
});
return new Response(body, {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
vi.stubGlobal(
"fetch",
vi.fn(async () => makeOversizedJsonResponse()),
);
const tags = await fetchOllamaModels("http://127.0.0.1:11434");
expect(tags).toEqual({ reachable: false, models: [] });
expect(canceled).toBe(true);
// Only the bounded prefix is pulled, never the full advertised 32 MiB stream.
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
vi.stubGlobal(
"fetch",
vi.fn(async () => makeOversizedJsonResponse()),
);
const showInfo = await queryOllamaModelShowInfo("http://127.0.0.1:11434", "evil-model:latest");
expect(showInfo).toEqual({});
expect(canceled).toBe(true);
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
});
});

View File

@@ -0,0 +1,359 @@
// Ollama provider module implements model/runtime integration.
import { createHash } from "node:crypto";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-onboard";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import {
OLLAMA_DEFAULT_BASE_URL,
OLLAMA_DEFAULT_CONTEXT_WINDOW,
OLLAMA_DEFAULT_COST,
OLLAMA_DEFAULT_MAX_TOKENS,
OLLAMA_GLM52_CLOUD_MODEL_ID,
OLLAMA_GLM52_CONTEXT_WINDOW,
} from "./defaults.js";
export type OllamaTagModel = {
name: string;
modified_at?: string;
size?: number;
digest?: string;
remote_host?: string;
details?: {
family?: string;
parameter_size?: string;
quantization_level?: string;
};
};
export type OllamaTagsResponse = {
models?: OllamaTagModel[];
};
export type OllamaModelWithContext = OllamaTagModel & {
contextWindow?: number;
capabilities?: string[];
};
const OLLAMA_SHOW_CONCURRENCY = 8;
const OLLAMA_CONTEXT_ENRICH_LIMIT = 200;
const MAX_OLLAMA_SHOW_CACHE_ENTRIES = 256;
const ollamaModelShowInfoCache = new Map<string, Promise<OllamaModelShowInfo>>();
const OLLAMA_ALWAYS_BLOCKED_HOSTNAMES = new Set(["metadata.google.internal"]);
export function buildOllamaBaseUrlSsrFPolicy(baseUrl: string) {
const trimmed = baseUrl.trim();
if (!trimmed) {
return undefined;
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return undefined;
}
if (OLLAMA_ALWAYS_BLOCKED_HOSTNAMES.has(parsed.hostname)) {
return undefined;
}
return {
hostnameAllowlist: [parsed.hostname],
allowPrivateNetwork: true,
};
} catch {
return undefined;
}
}
export function resolveOllamaApiBase(configuredBaseUrl?: string): string {
if (!configuredBaseUrl) {
return OLLAMA_DEFAULT_BASE_URL;
}
const trimmed = configuredBaseUrl.replace(/\/+$/, "");
return trimmed.replace(/\/v1$/i, "");
}
export type OllamaModelShowInfo = {
contextWindow?: number;
capabilities?: string[];
};
function buildOllamaModelShowCacheKey(
apiBase: string,
model: Pick<OllamaTagModel, "name" | "digest" | "modified_at">,
apiKey?: string,
): string | undefined {
const version = model.digest?.trim() || model.modified_at?.trim();
if (!version) {
return undefined;
}
const authScope = apiKey ? createHash("sha256").update(apiKey).digest("hex") : "anonymous";
return `${resolveOllamaApiBase(apiBase)}|${model.name}|${version}|${authScope}`;
}
function setOllamaModelShowCacheEntry(key: string, value: Promise<OllamaModelShowInfo>): void {
if (ollamaModelShowInfoCache.size >= MAX_OLLAMA_SHOW_CACHE_ENTRIES) {
const oldestKey = ollamaModelShowInfoCache.keys().next().value;
if (typeof oldestKey === "string") {
ollamaModelShowInfoCache.delete(oldestKey);
}
}
ollamaModelShowInfoCache.set(key, value);
}
function hasCachedOllamaModelShowInfo(info: OllamaModelShowInfo): boolean {
return typeof info.contextWindow === "number" || (info.capabilities?.length ?? 0) > 0;
}
export function parseOllamaNumCtxParameter(parameters: unknown): number | undefined {
if (typeof parameters !== "string" || !parameters.trim()) {
return undefined;
}
let lastValue: number | undefined;
for (const rawLine of parameters.split(/\r?\n/)) {
const match = rawLine.trim().match(/^num_ctx\s+(-?\d+)\b/);
if (!match) {
continue;
}
const parsed = Number.parseInt(match[1], 10);
if (Number.isFinite(parsed) && parsed > 0) {
lastValue = parsed;
}
}
return lastValue;
}
export async function queryOllamaModelShowInfo(
apiBase: string,
modelName: string,
opts?: { apiKey?: string },
): Promise<OllamaModelShowInfo> {
const normalizedApiBase = resolveOllamaApiBase(apiBase);
try {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (opts?.apiKey) {
headers.Authorization = `Bearer ${opts.apiKey}`;
}
const { response, release } = await fetchWithSsrFGuard({
url: `${normalizedApiBase}/api/show`,
init: {
method: "POST",
headers,
body: JSON.stringify({ name: modelName }),
signal: AbortSignal.timeout(3000),
},
policy: buildOllamaBaseUrlSsrFPolicy(normalizedApiBase),
auditContext: "ollama-provider-models.show",
});
try {
if (!response.ok) {
return {};
}
const data = await readProviderJsonResponse<{
model_info?: Record<string, unknown>;
capabilities?: unknown;
parameters?: unknown;
}>(response, "ollama-provider-models.show");
let contextWindow: number | undefined;
if (data.model_info) {
for (const [key, value] of Object.entries(data.model_info)) {
if (
key.endsWith(".context_length") &&
typeof value === "number" &&
Number.isFinite(value)
) {
const ctx = Math.floor(value);
if (ctx > 0) {
contextWindow = ctx;
break;
}
}
}
}
const paramCtx = parseOllamaNumCtxParameter(data.parameters);
if (paramCtx !== undefined && (contextWindow === undefined || paramCtx > contextWindow)) {
contextWindow = paramCtx;
}
const capabilities = Array.isArray(data.capabilities)
? (data.capabilities as unknown[]).filter((c): c is string => typeof c === "string")
: undefined;
return { contextWindow, capabilities };
} finally {
await release();
}
} catch {
return {};
}
}
async function queryOllamaModelShowInfoCached(
apiBase: string,
model: Pick<OllamaTagModel, "name" | "digest" | "modified_at">,
opts?: { apiKey?: string },
): Promise<OllamaModelShowInfo> {
const normalizedApiBase = resolveOllamaApiBase(apiBase);
const cacheKey = buildOllamaModelShowCacheKey(normalizedApiBase, model, opts?.apiKey);
if (!cacheKey) {
return await queryOllamaModelShowInfo(normalizedApiBase, model.name, opts);
}
const cached = ollamaModelShowInfoCache.get(cacheKey);
if (cached) {
return await cached;
}
const pending = queryOllamaModelShowInfo(normalizedApiBase, model.name, opts).then((result) => {
if (!hasCachedOllamaModelShowInfo(result)) {
ollamaModelShowInfoCache.delete(cacheKey);
}
return result;
});
setOllamaModelShowCacheEntry(cacheKey, pending);
return await pending;
}
/** @deprecated Use queryOllamaModelShowInfo instead. */
export async function queryOllamaContextWindow(
apiBase: string,
modelName: string,
): Promise<number | undefined> {
return (await queryOllamaModelShowInfo(apiBase, modelName)).contextWindow;
}
export async function enrichOllamaModelsWithContext(
apiBase: string,
models: OllamaTagModel[],
opts?: { apiKey?: string; concurrency?: number },
): Promise<OllamaModelWithContext[]> {
const concurrency = Math.max(1, Math.floor(opts?.concurrency ?? OLLAMA_SHOW_CONCURRENCY));
const enriched: OllamaModelWithContext[] = [];
for (let index = 0; index < models.length; index += concurrency) {
const batch = models.slice(index, index + concurrency);
const batchResults = await Promise.all(
batch.map(async (model) => {
const showInfo = await queryOllamaModelShowInfoCached(
apiBase,
model,
opts?.apiKey ? { apiKey: opts.apiKey } : undefined,
);
return Object.assign({}, model, {
contextWindow: showInfo.contextWindow,
capabilities: showInfo.capabilities,
});
}),
);
enriched.push(...batchResults);
}
return enriched;
}
export function isReasoningModelHeuristic(modelId: string): boolean {
return /r1|reasoning|think|reason/i.test(modelId);
}
function isKnownOllamaCloudReasoningModel(modelId: string): boolean {
const normalized = modelId.trim().toLowerCase();
return (
normalized === OLLAMA_GLM52_CLOUD_MODEL_ID ||
/^deepseek-v4-(?:flash|pro):cloud$/.test(normalized)
);
}
export function buildOllamaModelDefinition(
modelId: string,
contextWindow?: number,
capabilities?: string[],
): ModelDefinitionConfig {
const hasVision = capabilities?.includes("vision") ?? false;
const input: ("text" | "image")[] = hasVision ? ["text", "image"] : ["text"];
const reasoning =
isKnownOllamaCloudReasoningModel(modelId) ||
(capabilities === undefined
? isReasoningModelHeuristic(modelId)
: capabilities.includes("thinking"));
const compat =
capabilities === undefined
? { supportsTools: true, supportsUsageInStreaming: true }
: {
supportsTools: capabilities.includes("tools"),
supportsUsageInStreaming: true,
};
return {
id: modelId,
name: modelId,
reasoning,
input,
cost: OLLAMA_DEFAULT_COST,
contextWindow:
contextWindow ??
(modelId.trim().toLowerCase() === OLLAMA_GLM52_CLOUD_MODEL_ID
? OLLAMA_GLM52_CONTEXT_WINDOW
: OLLAMA_DEFAULT_CONTEXT_WINDOW),
maxTokens: OLLAMA_DEFAULT_MAX_TOKENS,
compat,
};
}
export async function fetchOllamaModels(
baseUrl: string,
opts?: { apiKey?: string },
): Promise<{ reachable: boolean; models: OllamaTagModel[] }> {
try {
const apiBase = resolveOllamaApiBase(baseUrl);
const { response, release } = await fetchWithSsrFGuard({
url: `${apiBase}/api/tags`,
init: {
headers: opts?.apiKey ? { Authorization: `Bearer ${opts.apiKey}` } : undefined,
signal: AbortSignal.timeout(5000),
},
policy: buildOllamaBaseUrlSsrFPolicy(apiBase),
auditContext: "ollama-provider-models.tags",
});
try {
if (!response.ok) {
return { reachable: true, models: [] };
}
const data = await readProviderJsonResponse<OllamaTagsResponse>(
response,
"ollama-provider-models.tags",
);
const models = (data.models ?? []).filter((m) => m.name);
return { reachable: true, models };
} finally {
await release();
}
} catch {
return { reachable: false, models: [] };
}
}
export async function buildOllamaProvider(
configuredBaseUrl?: string,
opts?: { apiKey?: string; quiet?: boolean },
): Promise<ModelProviderConfig> {
const apiBase = resolveOllamaApiBase(configuredBaseUrl);
const auth = opts?.apiKey ? { apiKey: opts.apiKey } : undefined;
const { reachable, models } = await fetchOllamaModels(apiBase, auth);
if (!reachable && !opts?.quiet) {
console.warn(`Ollama could not be reached at ${apiBase}.`);
}
const discovered = await enrichOllamaModelsWithContext(
apiBase,
models.slice(0, OLLAMA_CONTEXT_ENRICH_LIMIT),
auth,
);
return {
baseUrl: apiBase,
api: "ollama",
models: discovered.map((model) =>
buildOllamaModelDefinition(model.name, model.contextWindow, model.capabilities),
),
};
}
export function resetOllamaModelShowInfoCacheForTest(): void {
ollamaModelShowInfoCache.clear();
}

View File

@@ -0,0 +1,75 @@
// Ollama plugin module implements kimi inline reasoning behavior.
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import type {
OllamaVisibleContentSanitizer,
OllamaVisibleContentStreamResolution,
} from "./visible-content-contract.js";
const INLINE_REASONING_MIN_PREFIX_CHARS = 80;
const INLINE_REASONING_MAX_PENDING_CHARS = 512;
const INLINE_REASONING_BOUNDARY_RE = /(^|\s)\uFE0F\s*/u;
type InlineReasoningVisibleTextResolution =
| { kind: "visible"; text: string; bypassInlineReasoning?: boolean }
| { kind: "pending" };
export function isOllamaCloudKimiModelRef(modelId: string): boolean {
const normalizedModelId = normalizeLowercaseStringOrEmpty(modelId);
const slashIndex = normalizedModelId.indexOf("/");
const normalizedWireModelId =
slashIndex === -1 ? normalizedModelId : normalizedModelId.slice(slashIndex + 1);
return normalizedWireModelId.startsWith("kimi-k") && normalizedWireModelId.includes(":cloud");
}
function resolveInlineReasoningVisibleText(params: {
text: string;
final: boolean;
}): InlineReasoningVisibleTextResolution {
const match = INLINE_REASONING_BOUNDARY_RE.exec(params.text);
if (!match) {
if (!params.final && params.text.length <= INLINE_REASONING_MAX_PENDING_CHARS) {
return { kind: "pending" };
}
return {
kind: "visible",
text: params.text,
bypassInlineReasoning:
!params.final && params.text.length > INLINE_REASONING_MAX_PENDING_CHARS,
};
}
const boundaryStartIndex = match.index + match[1].length;
const boundaryEndIndex = match.index + match[0].length;
const prefix = params.text.slice(0, boundaryStartIndex).trim();
const answer = params.text.slice(boundaryEndIndex).trim();
if (prefix.length >= INLINE_REASONING_MIN_PREFIX_CHARS) {
return { kind: "visible", text: answer };
}
return params.final ? { kind: "visible", text: params.text } : { kind: "pending" };
}
export function createKimiInlineReasoningSanitizer(): OllamaVisibleContentSanitizer {
let bypassInlineReasoning = false;
return {
resolveStreamText(params): OllamaVisibleContentStreamResolution {
if (bypassInlineReasoning) {
return { kind: "visible", text: params.text };
}
const resolution = resolveInlineReasoningVisibleText(params);
if (resolution.kind === "pending") {
return resolution;
}
if (resolution.bypassInlineReasoning) {
bypassInlineReasoning = true;
}
return { kind: "visible", text: resolution.text };
},
sanitizeFinalText(text) {
const resolution = resolveInlineReasoningVisibleText({ text, final: true });
return resolution.kind === "visible" ? resolution.text : text;
},
};
}

View File

@@ -0,0 +1,9 @@
// Ollama plugin module implements visible content contract behavior.
export type OllamaVisibleContentStreamResolution =
| { kind: "visible"; text: string }
| { kind: "pending" };
export type OllamaVisibleContentSanitizer = {
resolveStreamText(params: { text: string; final: boolean }): OllamaVisibleContentStreamResolution;
sanitizeFinalText(text: string): string;
};

View File

@@ -0,0 +1,31 @@
// Ollama plugin module implements visible content behavior.
import {
createKimiInlineReasoningSanitizer,
isOllamaCloudKimiModelRef,
} from "./kimi-inline-reasoning.js";
import type { OllamaVisibleContentSanitizer } from "./visible-content-contract.js";
const noopVisibleContentSanitizer: OllamaVisibleContentSanitizer = {
resolveStreamText(params) {
return { kind: "visible", text: params.text };
},
sanitizeFinalText(text) {
return text;
},
};
export function createOllamaVisibleContentSanitizer(
modelId: string,
): OllamaVisibleContentSanitizer {
if (isOllamaCloudKimiModelRef(modelId)) {
return createKimiInlineReasoningSanitizer();
}
return noopVisibleContentSanitizer;
}
export function sanitizeOllamaFinalVisibleContent(params: {
modelId: string;
text: string;
}): string {
return createOllamaVisibleContentSanitizer(params.modelId).sanitizeFinalText(params.text);
}

View File

@@ -0,0 +1,827 @@
// Ollama tests cover setup plugin behavior.
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { WizardPrompter } from "openclaw/plugin-sdk/setup";
import { jsonResponse, requestBodyText, requestUrl } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resetOllamaModelShowInfoCacheForTest } from "./provider-models.js";
import {
checkOllamaCloudAuth,
configureOllamaNonInteractive,
ensureOllamaModelPulled,
promptAndConfigureOllama,
} from "./setup.js";
const upsertAuthProfileWithLock = vi.hoisted(() => vi.fn(async () => {}));
const fetchWithSsrFGuardMock = vi.hoisted(() =>
vi.fn(async (params: { url: string; init?: RequestInit; signal?: AbortSignal }) => ({
response: await globalThis.fetch(params.url, {
...params.init,
...(params.signal ? { signal: params.signal } : {}),
}),
finalUrl: params.url,
release: async () => {},
})),
);
vi.mock("openclaw/plugin-sdk/provider-auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/provider-auth")>();
return {
...actual,
upsertAuthProfileWithLock,
};
});
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
return {
...actual,
fetchWithSsrFGuard: (...args: Parameters<typeof actual.fetchWithSsrFGuard>) =>
fetchWithSsrFGuardMock(...args),
};
});
function createOllamaFetchMock(params: {
tags?: string[];
show?: Record<string, number | undefined>;
pullResponse?: Response;
tagsError?: Error;
meResponse?: Response;
}) {
return vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = requestUrl(input);
if (url.endsWith("/api/tags")) {
if (params.tagsError) {
throw params.tagsError;
}
return jsonResponse({ models: (params.tags ?? []).map((name) => ({ name })) });
}
if (url.endsWith("/api/show")) {
const body = JSON.parse(requestBodyText(init?.body)) as { name?: string };
const contextWindow = body.name ? params.show?.[body.name] : undefined;
return contextWindow
? jsonResponse({ model_info: { "llama.context_length": contextWindow } })
: jsonResponse({});
}
if (url.endsWith("/api/me")) {
return params.meResponse ?? jsonResponse({});
}
if (url.endsWith("/api/pull")) {
return params.pullResponse ?? new Response('{"status":"success"}\n', { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
});
}
function mockCall(mock: { mock: { calls: unknown[][] } }, index = 0) {
return mock.mock.calls.at(index);
}
function mockCallArg(mock: { mock: { calls: unknown[][] } }, index = 0, argIndex = 0) {
return mockCall(mock, index)?.at(argIndex);
}
function createLocalPrompter(): WizardPrompter {
return {
select: vi.fn().mockResolvedValueOnce("local-only"),
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
}
function createCloudPrompter(): WizardPrompter {
return {
select: vi.fn().mockResolvedValueOnce("cloud-only"),
confirm: vi.fn().mockResolvedValueOnce(false),
text: vi.fn().mockResolvedValueOnce("test-ollama-key"),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
}
function createCloudLocalPrompter(): WizardPrompter {
return {
select: vi.fn().mockResolvedValueOnce("cloud-local"),
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
}
function createDefaultOllamaConfig(primary: string) {
return {
agents: { defaults: { model: { primary } } },
models: { providers: { ollama: { baseUrl: "http://127.0.0.1:11434", models: [] } } },
};
}
function createRuntime() {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as unknown as RuntimeEnv;
}
describe("ollama setup", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
upsertAuthProfileWithLock.mockClear();
fetchWithSsrFGuardMock.mockClear();
resetOllamaModelShowInfoCacheForTest();
});
it("puts suggested local model first in local mode", async () => {
const prompter = createLocalPrompter();
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] });
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
});
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
expect(modelIds?.[0]).toBe("gemma4");
});
it("Docker setup defaults to the host Ollama endpoint", async () => {
vi.stubEnv("OPENCLAW_DOCKER_SETUP", "1");
const text = vi.fn().mockResolvedValueOnce("http://host.docker.internal:11434");
const prompter = {
select: vi.fn().mockResolvedValueOnce("local-only"),
text,
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] });
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
});
const baseUrlPrompt = mockCallArg(text) as {
message?: string;
initialValue?: string;
placeholder?: string;
validate?: unknown;
};
expect(baseUrlPrompt).toEqual({
message: "Ollama base URL",
initialValue: "http://host.docker.internal:11434",
placeholder: "http://host.docker.internal:11434",
validate: baseUrlPrompt.validate,
});
expect(typeof baseUrlPrompt.validate).toBe("function");
expect(mockCallArg(fetchMock)).toBe("http://host.docker.internal:11434/api/tags");
expect(result.config.models?.providers?.ollama?.baseUrl).toBe(
"http://host.docker.internal:11434",
);
});
it("puts suggested cloud model first in cloud mode", async () => {
const prompter = createCloudPrompter();
vi.stubGlobal("fetch", createOllamaFetchMock({ tags: [] }));
const result = await promptAndConfigureOllama({
cfg: {},
env: {},
prompter,
allowSecretRefPrompt: false,
});
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
expect(modelIds?.[0]).toBe("kimi-k2.5:cloud");
expect(result.config.models?.providers?.ollama?.baseUrl).toBe("https://ollama.com");
expect(result.config.models?.providers?.ollama?.apiKey).toBe("test-ollama-key");
expect(result.credential).toBe("test-ollama-key");
});
it("uses generic token flags for cloud-only setup", async () => {
const prompter = createCloudPrompter();
vi.stubGlobal("fetch", createOllamaFetchMock({ tags: [] }));
const result = await promptAndConfigureOllama({
cfg: {},
env: {},
opts: {
token: "generic-ollama-key",
tokenProvider: "ollama",
},
prompter,
allowSecretRefPrompt: false,
});
expect(result.credential).toBe("generic-ollama-key");
expect(prompter.text).not.toHaveBeenCalled();
});
it("puts hybrid cloud model suggestions after the local default when signed in", async () => {
const prompter = createCloudLocalPrompter();
const fetchMock = createOllamaFetchMock({
tags: ["llama3:8b"],
meResponse: jsonResponse({ user: "signed-in" }),
});
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
});
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
expect(modelIds).toEqual([
"gemma4",
"kimi-k2.5:cloud",
"minimax-m2.7:cloud",
"glm-5.1:cloud",
"glm-5.2:cloud",
"llama3:8b",
]);
expect(result.config.models?.providers?.ollama?.baseUrl).toBe("http://127.0.0.1:11434");
expect(result.credential).toBe("ollama-local");
});
it("mode selection affects model ordering (local)", async () => {
const prompter = createLocalPrompter();
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b", "gemma4"] });
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
});
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
expect(modelIds?.[0]).toBe("gemma4");
expect(modelIds).toContain("llama3:8b");
});
it("dedupes the suggested local model against a discovered latest tag", async () => {
const prompter = createLocalPrompter();
const fetchMock = createOllamaFetchMock({ tags: ["gemma4:latest", "llama3:8b"] });
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
});
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
expect(modelIds).toEqual(["gemma4:latest", "llama3:8b"]);
});
it("cloud mode does not hit local Ollama endpoints", async () => {
const prompter = createCloudPrompter();
const fetchMock = createOllamaFetchMock({ tags: [] });
vi.stubGlobal("fetch", fetchMock);
await promptAndConfigureOllama({
cfg: {},
env: {},
prompter,
allowSecretRefPrompt: false,
});
const requestUrls = fetchMock.mock.calls.map((call) => requestUrl(call[0]));
expect(requestUrls).toEqual(["https://ollama.com/api/tags"]);
expect(new Headers(fetchMock.mock.calls[0]?.[1]?.headers).get("Authorization")).toBe(
"Bearer test-ollama-key",
);
});
it("rejects the local marker during cloud-only setup", async () => {
const prompter = createCloudPrompter();
await expect(
promptAndConfigureOllama({
cfg: {},
env: {},
opts: {
ollamaApiKey: "ollama-local",
},
prompter,
allowSecretRefPrompt: false,
}),
).rejects.toThrow("Cloud-only Ollama setup requires a real OLLAMA_API_KEY.");
});
it("local mode only hits local model discovery endpoints", async () => {
const prompter = createLocalPrompter();
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] });
vi.stubGlobal("fetch", fetchMock);
await promptAndConfigureOllama({
cfg: {},
prompter,
});
expect(fetchMock.mock.calls.map((call) => requestUrl(call[0]))).toEqual([
"http://127.0.0.1:11434/api/tags",
"http://127.0.0.1:11434/api/show",
]);
});
it("asks for Ollama mode before cloud api key", async () => {
const events: string[] = [];
const prompter = {
select: vi.fn(async () => {
events.push("select");
return "cloud-only";
}),
confirm: vi.fn(async () => false),
text: vi.fn(async () => {
events.push("text");
return "test-ollama-key";
}),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
vi.stubGlobal("fetch", createOllamaFetchMock({ tags: [] }));
await promptAndConfigureOllama({
cfg: {},
env: {},
prompter,
allowSecretRefPrompt: false,
});
expect(events).toEqual(["select", "text"]);
});
it("shows cloud-mode unreachable guidance when the host is down", async () => {
const prompter = createLocalPrompter();
const fetchMock = createOllamaFetchMock({ tagsError: new Error("down") });
vi.stubGlobal("fetch", fetchMock);
await expect(
promptAndConfigureOllama({
cfg: {},
prompter,
}),
).rejects.toThrow("Ollama not reachable");
expect(prompter.note).toHaveBeenCalledWith(
[
"Ollama could not be reached at http://127.0.0.1:11434.",
"Download it at https://ollama.com/download",
"",
"Start Ollama and re-run setup.",
].join("\n"),
"Ollama",
);
});
it("cloud + local mode falls back to local models when ollama signin is missing", async () => {
const prompter = createCloudLocalPrompter();
const fetchMock = createOllamaFetchMock({
tags: ["llama3:8b"],
meResponse: new Response(JSON.stringify({ signin_url: "https://ollama.com/signin" }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
});
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
});
expect(result.config.models?.providers?.ollama?.models?.map((m) => m.id)).toEqual([
"gemma4",
"llama3:8b",
]);
expect(prompter.note).toHaveBeenCalledWith(
[
"Cloud models on this Ollama host need `ollama signin`.",
"https://ollama.com/signin",
"",
"Continuing with local models only for now.",
].join("\n"),
"Ollama Cloud + Local",
);
});
it("cloud mode falls back to the hardcoded cloud model list when /api/tags is empty", async () => {
const prompter = createCloudPrompter();
vi.stubGlobal("fetch", createOllamaFetchMock({ tags: [] }));
const result = await promptAndConfigureOllama({
cfg: {},
env: {},
prompter,
allowSecretRefPrompt: false,
});
const models = result.config.models?.providers?.ollama?.models;
const modelIds = models?.map((m) => m.id);
expect(modelIds).toEqual([
"kimi-k2.5:cloud",
"minimax-m2.7:cloud",
"glm-5.1:cloud",
"glm-5.2:cloud",
]);
expect(models?.find((model) => model.id === "kimi-k2.5:cloud")?.input).toEqual([
"text",
"image",
]);
});
it("cloud mode populates models from ollama.com /api/tags when reachable", async () => {
const prompter = createCloudPrompter();
const fetchMock = createOllamaFetchMock({
tags: ["qwen3-coder:480b-cloud", "gpt-oss:120b-cloud"],
show: { "qwen3-coder:480b-cloud": 262144 },
});
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
env: {},
prompter,
allowSecretRefPrompt: false,
});
const models = result.config.models?.providers?.ollama?.models;
const modelIds = models?.map((m) => m.id);
expect(modelIds).toEqual([
"kimi-k2.5:cloud",
"minimax-m2.7:cloud",
"glm-5.1:cloud",
"glm-5.2:cloud",
"qwen3-coder:480b-cloud",
"gpt-oss:120b-cloud",
]);
const requestUrls = fetchMock.mock.calls.map((call) => requestUrl(call[0]));
expect(requestUrls.filter((url) => url.endsWith("/api/show"))).toEqual([]);
expect(requestUrls).toContain("https://ollama.com/api/tags");
});
it("uses /api/show context windows when building Ollama model configs", async () => {
const prompter = {
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
select: vi.fn().mockResolvedValueOnce("local-only"),
note: vi.fn(async () => undefined),
} as unknown as WizardPrompter;
const fetchMock = createOllamaFetchMock({
tags: ["llama3:8b"],
show: { "llama3:8b": 65536 },
});
vi.stubGlobal("fetch", fetchMock);
const result = await promptAndConfigureOllama({
cfg: {},
prompter,
});
const model = result.config.models?.providers?.ollama?.models?.find(
(m) => m.id === "llama3:8b",
);
expect(model?.contextWindow).toBe(65536);
});
describe("ensureOllamaModelPulled", () => {
it("pulls model when not available locally", async () => {
vi.useFakeTimers();
try {
const progress = { update: vi.fn(), stop: vi.fn() };
const prompter = {
progress: vi.fn(() => progress),
} as unknown as WizardPrompter;
const fetchMock = createOllamaFetchMock({
tags: ["llama3:8b"],
pullResponse: new Response('{"status":"success"}\n', { status: 200 }),
});
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
config: createDefaultOllamaConfig("ollama/gemma4"),
model: "ollama/gemma4",
prompter,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(mockCallArg(fetchMock, 1)).toContain("/api/pull");
const pullInit = mockCallArg(fetchMock, 1, 1) as RequestInit | undefined;
expect(pullInit?.signal).toBeInstanceOf(AbortSignal);
expect(pullInit?.signal?.aborted).toBe(false);
await vi.advanceTimersByTimeAsync(30_000);
expect(pullInit?.signal?.aborted).toBe(false);
} finally {
vi.useRealTimers();
}
});
it("fails stalled model pull streams after an idle timeout", async () => {
vi.useFakeTimers();
try {
const progress = { update: vi.fn(), stop: vi.fn() };
const prompter = {
progress: vi.fn(() => progress),
} as unknown as WizardPrompter;
const fetchMock = vi.fn(async (input: string | URL | Request) => {
const url = requestUrl(input);
if (url.endsWith("/api/tags")) {
return jsonResponse({ models: [] });
}
if (url.endsWith("/api/pull")) {
return new Response(new ReadableStream<Uint8Array>(), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const pullPromise = ensureOllamaModelPulled({
config: createDefaultOllamaConfig("ollama/gemma4"),
model: "ollama/gemma4",
prompter,
}).catch((err: unknown) => err);
await vi.waitFor(() => expect(mockCallArg(fetchMock, 1)).toContain("/api/pull"));
await vi.advanceTimersByTimeAsync(300_000);
const pullError = await pullPromise;
expect(pullError).toBeInstanceOf(Error);
expect((pullError as Error).name).toBe("WizardCancelledError");
expect((pullError as Error).message).toBe("Failed to download selected Ollama model");
expect(progress.stop).toHaveBeenCalledWith(
"Failed to download gemma4: Ollama pull stalled: no data received for 300s",
);
} finally {
vi.useRealTimers();
}
});
it("skips pull when model is already available", async () => {
const prompter = {} as unknown as WizardPrompter;
const fetchMock = createOllamaFetchMock({ tags: ["gemma4"] });
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
config: createDefaultOllamaConfig("ollama/gemma4"),
model: "ollama/gemma4",
prompter,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("skips pull when an untagged model is available as latest", async () => {
const prompter = {} as unknown as WizardPrompter;
const fetchMock = createOllamaFetchMock({ tags: ["gemma4:latest"] });
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
config: createDefaultOllamaConfig("ollama/gemma4"),
model: "ollama/gemma4",
prompter,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("uses baseURL alias when checking and pulling models", async () => {
const progress = { update: vi.fn(), stop: vi.fn() };
const prompter = {
progress: vi.fn(() => progress),
} as unknown as WizardPrompter;
const fetchMock = createOllamaFetchMock({
tags: [],
pullResponse: new Response('{"status":"success"}\n', { status: 200 }),
});
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
config: {
agents: { defaults: { model: { primary: "ollama/gemma4" } } },
models: {
providers: {
ollama: {
baseURL: "http://127.0.0.1:11435",
models: [],
} as never,
},
},
},
model: "ollama/gemma4",
prompter,
});
expect(mockCallArg(fetchMock)).toBe("http://127.0.0.1:11435/api/tags");
expect(mockCallArg(fetchMock, 1)).toBe("http://127.0.0.1:11435/api/pull");
});
it("skips pull for cloud models", async () => {
const prompter = {} as unknown as WizardPrompter;
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
config: createDefaultOllamaConfig("ollama/kimi-k2.5:cloud"),
model: "ollama/kimi-k2.5:cloud",
prompter,
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("skips when model is not an ollama model", async () => {
const prompter = {} as unknown as WizardPrompter;
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await ensureOllamaModelPulled({
config: {
agents: { defaults: { model: { primary: "openai/gpt-4o" } } },
},
model: "openai/gpt-4o",
prompter,
});
expect(fetchMock).not.toHaveBeenCalled();
});
});
it("uses discovered model when requested non-interactive download fails", async () => {
const fetchMock = createOllamaFetchMock({
tags: ["qwen2.5-coder:7b"],
pullResponse: new Response('{"error":"disk full"}\n', { status: 200 }),
});
vi.stubGlobal("fetch", fetchMock);
const runtime = createRuntime();
const result = await configureOllamaNonInteractive({
nextConfig: {
agents: {
defaults: {
model: {
primary: "openai/gpt-4o-mini",
fallbacks: ["anthropic/claude-sonnet-4-5"],
},
},
},
},
opts: {
customBaseUrl: "http://127.0.0.1:11434",
customModelId: "missing-model",
},
runtime,
});
expect(runtime.error).toHaveBeenCalledWith("Download failed: disk full");
expect(result.agents?.defaults?.model).toEqual({
primary: "ollama/qwen2.5-coder:7b",
fallbacks: ["anthropic/claude-sonnet-4-5"],
});
});
it("normalizes ollama/ prefix in non-interactive custom model download", async () => {
const fetchMock = createOllamaFetchMock({
tags: [],
pullResponse: new Response('{"status":"success"}\n', { status: 200 }),
});
vi.stubGlobal("fetch", fetchMock);
const runtime = createRuntime();
const result = await configureOllamaNonInteractive({
nextConfig: {},
opts: {
customBaseUrl: "http://127.0.0.1:11434",
customModelId: "ollama/llama3.2:latest",
},
runtime,
});
const pullRequest = mockCallArg(fetchMock, 1, 1) as RequestInit | undefined;
expect(JSON.parse(requestBodyText(pullRequest?.body))).toEqual({ name: "llama3.2:latest" });
expect(result.agents?.defaults?.model).toEqual({ primary: "ollama/llama3.2:latest" });
});
it("uses the discovered latest tag as the non-interactive default without pulling", async () => {
const fetchMock = createOllamaFetchMock({ tags: ["gemma4:latest"] });
vi.stubGlobal("fetch", fetchMock);
const runtime = createRuntime();
const result = await configureOllamaNonInteractive({
nextConfig: {},
opts: {
customBaseUrl: "http://127.0.0.1:11434",
},
runtime,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
const requestUrls = fetchMock.mock.calls.map((call) => requestUrl(call[0]));
expect(requestUrls.filter((url) => url.endsWith("/api/pull"))).toEqual([]);
expect(result.models?.providers?.ollama?.models?.map((model) => model.id)).toEqual([
"gemma4:latest",
]);
expect(result.agents?.defaults?.model).toEqual({ primary: "ollama/gemma4:latest" });
expect(runtime.log).toHaveBeenCalledWith("Default Ollama model: gemma4:latest");
});
it("accepts cloud models in non-interactive mode without pulling", async () => {
const fetchMock = createOllamaFetchMock({ tags: [] });
vi.stubGlobal("fetch", fetchMock);
const runtime = createRuntime();
const result = await configureOllamaNonInteractive({
nextConfig: {},
opts: {
customBaseUrl: "http://127.0.0.1:11434",
customModelId: "kimi-k2.5:cloud",
},
runtime,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.models?.providers?.ollama?.models?.map((model) => model.id)).toContain(
"kimi-k2.5:cloud",
);
expect(result.agents?.defaults?.model).toEqual({ primary: "ollama/kimi-k2.5:cloud" });
});
it("exits when Ollama is unreachable", async () => {
const fetchMock = createOllamaFetchMock({
tagsError: new Error("connect ECONNREFUSED"),
});
vi.stubGlobal("fetch", fetchMock);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as unknown as RuntimeEnv;
const nextConfig = {};
const result = await configureOllamaNonInteractive({
nextConfig,
opts: {
customBaseUrl: "http://127.0.0.1:11435",
customModelId: "llama3.2:latest",
},
runtime,
});
expect(runtime.error).toHaveBeenCalledWith(
[
"Ollama could not be reached at http://127.0.0.1:11435.",
"Download it at https://ollama.com/download",
].join("\n"),
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(result).toBe(nextConfig);
});
});
describe("checkOllamaCloudAuth", () => {
afterEach(() => {
fetchWithSsrFGuardMock.mockClear();
});
it("bounds oversized 401 body and cancels the stream", async () => {
const chunk = new Uint8Array(1024 * 1024); // 1 MiB chunk
let readCount = 0;
let canceled = false;
// 64 chunks × 1 MiB = 64 MiB — exceeds the 16 MiB cap
const oversizedBody = new ReadableStream<Uint8Array>({
pull(controller) {
if (readCount >= 64) {
controller.close();
return;
}
readCount += 1;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(oversizedBody, {
status: 401,
headers: { "Content-Type": "application/json" },
}),
finalUrl: "https://ollama.com/api/me",
release: async () => {},
});
await expect(checkOllamaCloudAuth("https://ollama.com")).resolves.toEqual({
signedIn: false,
signinUrl: undefined,
});
// Stream must be cancelled before all 64 MiB are consumed
expect(readCount).toBeLessThan(64);
expect(canceled).toBe(true);
});
});

View File

@@ -0,0 +1,773 @@
// Ollama setup module handles plugin onboarding behavior.
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type {
OpenClawConfig,
SecretInput,
SecretInputMode,
} from "openclaw/plugin-sdk/provider-auth";
import {
ensureApiKeyFromOptionEnvOrPrompt,
isNonSecretApiKeyMarker,
normalizeApiKeyInput,
normalizeOptionalSecretInput,
upsertAuthProfileWithLock,
validateApiKeyInput,
} from "openclaw/plugin-sdk/provider-auth";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { applyAgentDefaultModelPrimary } from "openclaw/plugin-sdk/provider-onboard";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
import { WizardCancelledError, type WizardPrompter } from "openclaw/plugin-sdk/setup";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import {
OLLAMA_CLOUD_BASE_URL,
OLLAMA_CLOUD_DEFAULT_MODELS,
OLLAMA_DEFAULT_BASE_URL,
OLLAMA_DOCKER_HOST_BASE_URL,
OLLAMA_DEFAULT_MODEL,
} from "./defaults.js";
import { readProviderBaseUrl } from "./provider-base-url.js";
import {
buildOllamaBaseUrlSsrFPolicy,
buildOllamaProvider,
buildOllamaModelDefinition,
enrichOllamaModelsWithContext,
fetchOllamaModels,
resolveOllamaApiBase,
type OllamaModelWithContext,
} from "./provider-models.js";
export { buildOllamaProvider };
const OLLAMA_SUGGESTED_MODELS_LOCAL = [OLLAMA_DEFAULT_MODEL];
const OLLAMA_SUGGESTED_MODELS_CLOUD = [...OLLAMA_CLOUD_DEFAULT_MODELS];
const OLLAMA_CONTEXT_ENRICH_LIMIT = 200;
const OLLAMA_CLOUD_MAX_DISCOVERED_MODELS = 500;
const OLLAMA_PULL_RESPONSE_TIMEOUT_MS = 30_000;
const OLLAMA_PULL_STREAM_IDLE_TIMEOUT_MS = 300_000;
type OllamaSetupOptions = {
customBaseUrl?: string;
customModelId?: string;
};
type OllamaSetupResult = {
config: OpenClawConfig;
credential: SecretInput;
credentialMode?: SecretInputMode;
};
function isTruthyEnvValue(value: string | undefined): boolean {
return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? "");
}
function resolveOllamaSetupDefaultBaseUrl(env: NodeJS.ProcessEnv = process.env): string {
return isTruthyEnvValue(env.OPENCLAW_DOCKER_SETUP)
? OLLAMA_DOCKER_HOST_BASE_URL
: OLLAMA_DEFAULT_BASE_URL;
}
type OllamaInteractiveMode = "cloud-local" | "cloud-only" | "local-only";
type HostBackedOllamaInteractiveMode = Exclude<OllamaInteractiveMode, "cloud-only">;
const HOST_BACKED_OLLAMA_MODE_CONFIG: Record<
HostBackedOllamaInteractiveMode,
{ includeCloudModels: boolean; noteTitle: string }
> = {
"cloud-local": {
includeCloudModels: true,
noteTitle: "Ollama Cloud + Local",
},
"local-only": {
includeCloudModels: false,
noteTitle: "Ollama",
},
};
function buildOllamaUnreachableLines(baseUrl: string): string[] {
return [
`Ollama could not be reached at ${baseUrl}.`,
"Download it at https://ollama.com/download",
"",
"Start Ollama and re-run setup.",
];
}
function buildOllamaCloudSigninLines(signinUrl?: string): string[] {
return [
"Cloud models on this Ollama host need `ollama signin`.",
signinUrl ?? "Run `ollama signin` on the configured Ollama host.",
"",
"Continuing with local models only for now.",
];
}
function normalizeOllamaModelName(value: string | undefined): string | undefined {
const trimmed = value?.trim();
if (!trimmed) {
return undefined;
}
if (normalizeLowercaseStringOrEmpty(trimmed).startsWith("ollama/")) {
const normalized = trimmed.slice("ollama/".length).trim();
return normalized || undefined;
}
return trimmed;
}
function isOllamaCloudModel(modelName: string | undefined): boolean {
return normalizeOptionalLowercaseString(modelName)?.endsWith(":cloud") === true;
}
function formatOllamaPullStatus(status: string): { text: string; hidePercent: boolean } {
const trimmed = status.trim();
const partStatusMatch = trimmed.match(/^([a-z-]+)\s+(?:sha256:)?[a-f0-9]{8,}$/i);
if (partStatusMatch) {
return { text: `${partStatusMatch[1]} part`, hidePercent: false };
}
if (/^verifying\b.*\bdigest\b/i.test(trimmed)) {
return { text: "verifying digest", hidePercent: true };
}
return { text: trimmed, hidePercent: false };
}
export async function checkOllamaCloudAuth(
baseUrl: string,
): Promise<{ signedIn: boolean; signinUrl?: string }> {
try {
const apiBase = resolveOllamaApiBase(baseUrl);
const { response, release } = await fetchWithSsrFGuard({
url: `${apiBase}/api/me`,
init: {
method: "POST",
signal: AbortSignal.timeout(5000),
},
policy: buildOllamaBaseUrlSsrFPolicy(apiBase),
auditContext: "ollama-setup.me",
});
try {
if (response.status === 401) {
const data = await readProviderJsonResponse<{ signin_url?: string }>(
response,
"ollama.cloud-auth",
);
return { signedIn: false, signinUrl: data.signin_url };
}
if (!response.ok) {
return { signedIn: false };
}
return { signedIn: true };
} finally {
await release();
}
} catch {
return { signedIn: false };
}
}
type OllamaPullChunk = {
status?: string;
total?: number;
completed?: number;
error?: string;
};
type OllamaPullResult = { ok: true } | { ok: false; message: string };
async function readOllamaPullChunkWithIdleTimeout(
reader: ReadableStreamDefaultReader<Uint8Array>,
): Promise<ReadableStreamReadResult<Uint8Array>> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let timedOut = false;
return await new Promise((resolve, reject) => {
const clear = () => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
};
timeoutId = setTimeout(() => {
timedOut = true;
clear();
void reader.cancel().catch(() => undefined);
reject(
new Error(
`Ollama pull stalled: no data received for ${Math.round(OLLAMA_PULL_STREAM_IDLE_TIMEOUT_MS / 1000)}s`,
),
);
}, OLLAMA_PULL_STREAM_IDLE_TIMEOUT_MS);
void reader.read().then(
(result) => {
clear();
if (!timedOut) {
resolve(result);
}
},
(err: unknown) => {
clear();
if (!timedOut) {
reject(toLintErrorObject(err, "Non-Error rejection"));
}
},
);
});
}
async function pullOllamaModelCore(params: {
baseUrl: string;
modelName: string;
onStatus?: (status: string, percent: number | null) => void;
}): Promise<OllamaPullResult> {
const baseUrl = resolveOllamaApiBase(params.baseUrl);
const modelName = normalizeOllamaModelName(params.modelName) ?? params.modelName.trim();
const responseController = new AbortController();
const responseTimeout = setTimeout(
responseController.abort.bind(responseController),
OLLAMA_PULL_RESPONSE_TIMEOUT_MS,
);
try {
const { response, release } = await fetchWithSsrFGuard({
url: `${baseUrl}/api/pull`,
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: modelName }),
},
signal: responseController.signal,
policy: buildOllamaBaseUrlSsrFPolicy(baseUrl),
auditContext: "ollama-setup.pull",
});
clearTimeout(responseTimeout);
try {
if (!response.ok) {
return { ok: false, message: `Failed to download ${modelName} (HTTP ${response.status})` };
}
if (!response.body) {
return { ok: false, message: `Failed to download ${modelName} (no response body)` };
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const layers = new Map<string, { total: number; completed: number }>();
const parseLine = (line: string): OllamaPullResult => {
const trimmed = line.trim();
if (!trimmed) {
return { ok: true };
}
try {
const chunk = JSON.parse(trimmed) as OllamaPullChunk;
if (chunk.error) {
return { ok: false, message: `Download failed: ${chunk.error}` };
}
if (!chunk.status) {
return { ok: true };
}
if (chunk.total && chunk.completed !== undefined) {
layers.set(chunk.status, { total: chunk.total, completed: chunk.completed });
let totalSum = 0;
let completedSum = 0;
for (const layer of layers.values()) {
totalSum += layer.total;
completedSum += layer.completed;
}
params.onStatus?.(
chunk.status,
totalSum > 0 ? Math.round((completedSum / totalSum) * 100) : null,
);
} else {
params.onStatus?.(chunk.status, null);
}
} catch {
// Ignore malformed streaming lines from Ollama.
}
return { ok: true };
};
for (;;) {
const { done, value } = await readOllamaPullChunkWithIdleTimeout(reader);
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const parsed = parseLine(line);
if (!parsed.ok) {
return parsed;
}
}
}
const trailing = buffer.trim();
if (trailing) {
const parsed = parseLine(trailing);
if (!parsed.ok) {
return parsed;
}
}
return { ok: true };
} finally {
await release();
}
} catch (err) {
const reason = formatErrorMessage(err);
return { ok: false, message: `Failed to download ${modelName}: ${reason}` };
} finally {
clearTimeout(responseTimeout);
}
}
async function pullOllamaModel(
baseUrl: string,
modelName: string,
prompter: WizardPrompter,
): Promise<boolean> {
const spinner = prompter.progress(`Downloading ${modelName}...`);
const result = await pullOllamaModelCore({
baseUrl,
modelName,
onStatus: (status, percent) => {
const displayStatus = formatOllamaPullStatus(status);
if (displayStatus.hidePercent) {
spinner.update(`Downloading ${modelName} - ${displayStatus.text}`);
} else {
spinner.update(`Downloading ${modelName} - ${displayStatus.text} - ${percent ?? 0}%`);
}
},
});
if (!result.ok) {
spinner.stop(result.message);
return false;
}
spinner.stop(`Downloaded ${modelName}`);
return true;
}
async function pullOllamaModelNonInteractive(
baseUrl: string,
modelName: string,
runtime: RuntimeEnv,
): Promise<boolean> {
runtime.log(`Downloading ${modelName}...`);
const result = await pullOllamaModelCore({ baseUrl, modelName });
if (!result.ok) {
runtime.error(result.message);
return false;
}
runtime.log(`Downloaded ${modelName}`);
return true;
}
async function promptForOllamaCloudCredential(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
opts?: Record<string, unknown>;
prompter: WizardPrompter;
secretInputMode?: SecretInputMode;
allowSecretRefPrompt?: boolean;
}): Promise<{
credential: SecretInput;
credentialMode?: SecretInputMode;
discoveryApiKey: string;
}> {
const captured: { credential?: SecretInput; credentialMode?: SecretInputMode } = {};
const optionToken = normalizeOptionalSecretInput(params.opts?.ollamaApiKey);
const discoveryApiKey = await ensureApiKeyFromOptionEnvOrPrompt({
token: optionToken ?? normalizeOptionalSecretInput(params.opts?.token),
tokenProvider: optionToken
? "ollama"
: normalizeOptionalSecretInput(params.opts?.tokenProvider),
secretInputMode:
params.allowSecretRefPrompt === false
? (params.secretInputMode ?? "plaintext")
: params.secretInputMode,
config: params.cfg,
env: params.env,
expectedProviders: ["ollama"],
provider: "ollama",
envLabel: "OLLAMA_API_KEY",
promptMessage: "Ollama API key",
normalize: normalizeApiKeyInput,
validate: validateApiKeyInput,
prompter: params.prompter,
setCredential: async (apiKey, mode) => {
captured.credential = apiKey;
captured.credentialMode = mode;
},
});
if (!captured.credential) {
throw new Error("Missing Ollama API key input.");
}
if (
typeof captured.credential === "string" &&
isNonSecretApiKeyMarker(captured.credential, { includeEnvVarName: false })
) {
throw new Error("Cloud-only Ollama setup requires a real OLLAMA_API_KEY.");
}
return {
credential: captured.credential,
credentialMode: captured.credentialMode,
discoveryApiKey,
};
}
function buildOllamaModelsConfig(
modelNames: string[],
discoveredModelsByName?: Map<string, OllamaModelWithContext>,
) {
return modelNames.map((name) => {
const discovered = discoveredModelsByName?.get(name);
// Suggested cloud models may be injected before `/api/tags` exposes them,
// so keep Kimi vision-capable during setup even without discovered metadata.
const capabilities =
discovered?.capabilities ?? (name === "kimi-k2.5:cloud" ? ["vision"] : undefined);
return buildOllamaModelDefinition(name, discovered?.contextWindow, capabilities);
});
}
function getOllamaLatestDedupeKey(name: string): string {
const normalized = normalizeLowercaseStringOrEmpty(name);
return normalized.endsWith(":latest") ? normalized.slice(0, -":latest".length) : normalized;
}
function isExplicitLatestOllamaModel(name: string): boolean {
return normalizeLowercaseStringOrEmpty(name).endsWith(":latest");
}
function shouldReplaceOllamaModelName(existing: string, candidate: string): boolean {
return !isExplicitLatestOllamaModel(existing) && isExplicitLatestOllamaModel(candidate);
}
function mergeUniqueModelNames(...groups: string[][]): string[] {
const indexByKey = new Map<string, number>();
const merged: string[] = [];
for (const group of groups) {
for (const name of group) {
const key = getOllamaLatestDedupeKey(name);
const existingIndex = indexByKey.get(key);
if (existingIndex !== undefined) {
if (shouldReplaceOllamaModelName(merged[existingIndex], name)) {
merged[existingIndex] = name;
}
continue;
}
indexByKey.set(key, merged.length);
merged.push(name);
}
}
return merged;
}
function findAvailableOllamaModelName(modelName: string, availableModelNames: Iterable<string>) {
const wantedKey = getOllamaLatestDedupeKey(modelName);
for (const available of availableModelNames) {
if (getOllamaLatestDedupeKey(available) === wantedKey) {
return available;
}
}
return undefined;
}
function applyOllamaProviderConfig(
cfg: OpenClawConfig,
baseUrl: string,
modelNames: string[],
discoveredModelsByName?: Map<string, OllamaModelWithContext>,
apiKey: SecretInput = "OLLAMA_API_KEY",
): OpenClawConfig {
return {
...cfg,
models: {
...cfg.models,
mode: cfg.models?.mode ?? "merge",
providers: {
...cfg.models?.providers,
ollama: {
baseUrl,
api: "ollama",
apiKey,
models: buildOllamaModelsConfig(modelNames, discoveredModelsByName),
},
},
},
};
}
async function storeOllamaCredential(agentDir?: string): Promise<void> {
await upsertAuthProfileWithLock({
profileId: "ollama:default",
credential: { type: "api_key", provider: "ollama", key: "ollama-local" },
agentDir,
});
}
async function promptForOllamaBaseUrl(
prompter: WizardPrompter,
env: NodeJS.ProcessEnv = process.env,
): Promise<string> {
const defaultBaseUrl = resolveOllamaSetupDefaultBaseUrl(env);
const baseUrlRaw = await prompter.text({
message: "Ollama base URL",
initialValue: defaultBaseUrl,
placeholder: defaultBaseUrl,
validate: (value) => (value?.trim() ? undefined : "Required"),
});
return resolveOllamaApiBase((baseUrlRaw ?? defaultBaseUrl).trim().replace(/\/+$/, ""));
}
async function resolveHostBackedSuggestedModelNames(params: {
mode: HostBackedOllamaInteractiveMode;
baseUrl: string;
prompter: WizardPrompter;
}): Promise<string[]> {
const modeConfig = HOST_BACKED_OLLAMA_MODE_CONFIG[params.mode];
if (!modeConfig.includeCloudModels) {
return OLLAMA_SUGGESTED_MODELS_LOCAL;
}
const auth = await checkOllamaCloudAuth(params.baseUrl);
if (auth.signedIn) {
return mergeUniqueModelNames(OLLAMA_SUGGESTED_MODELS_LOCAL, OLLAMA_SUGGESTED_MODELS_CLOUD);
}
await params.prompter.note(
buildOllamaCloudSigninLines(auth.signinUrl).join("\n"),
modeConfig.noteTitle,
);
return OLLAMA_SUGGESTED_MODELS_LOCAL;
}
async function promptAndConfigureHostBackedOllama(params: {
cfg: OpenClawConfig;
mode: HostBackedOllamaInteractiveMode;
prompter: WizardPrompter;
env?: NodeJS.ProcessEnv;
}): Promise<OllamaSetupResult> {
const baseUrl = await promptForOllamaBaseUrl(params.prompter, params.env);
const { reachable, models } = await fetchOllamaModels(baseUrl);
if (!reachable) {
await params.prompter.note(buildOllamaUnreachableLines(baseUrl).join("\n"), "Ollama");
throw new WizardCancelledError("Ollama not reachable");
}
const enrichedModels = await enrichOllamaModelsWithContext(
baseUrl,
models.slice(0, OLLAMA_CONTEXT_ENRICH_LIMIT),
);
const discoveredModelsByName = new Map(enrichedModels.map((model) => [model.name, model]));
const discoveredModelNames = models.map((model) => model.name);
const suggestedModelNames = await resolveHostBackedSuggestedModelNames({
mode: params.mode,
baseUrl,
prompter: params.prompter,
});
return {
credential: "ollama-local",
config: applyOllamaProviderConfig(
params.cfg,
baseUrl,
mergeUniqueModelNames(suggestedModelNames, discoveredModelNames),
discoveredModelsByName,
),
};
}
export async function promptAndConfigureOllama(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
opts?: Record<string, unknown>;
prompter: WizardPrompter;
secretInputMode?: SecretInputMode;
allowSecretRefPrompt?: boolean;
}): Promise<OllamaSetupResult> {
const mode = (await params.prompter.select({
message: "Ollama mode",
options: [
{
value: "cloud-local",
label: "Cloud + Local",
hint: "Route cloud and local models through your Ollama host",
},
{ value: "cloud-only", label: "Cloud only", hint: "Hosted Ollama models via ollama.com" },
{ value: "local-only", label: "Local only", hint: "Local models only" },
],
})) as OllamaInteractiveMode;
if (mode === "cloud-only") {
const { credential, credentialMode, discoveryApiKey } = await promptForOllamaCloudCredential({
cfg: params.cfg,
env: params.env,
opts: params.opts,
prompter: params.prompter,
secretInputMode: params.secretInputMode,
allowSecretRefPrompt: params.allowSecretRefPrompt,
});
const { models: rawDiscoveredModels } = await fetchOllamaModels(OLLAMA_CLOUD_BASE_URL, {
apiKey: discoveryApiKey,
});
const discoveredModels = rawDiscoveredModels.slice(0, OLLAMA_CLOUD_MAX_DISCOVERED_MODELS);
const discoveredModelNames = discoveredModels.map((model) => model.name);
const modelNames =
discoveredModelNames.length > 0
? mergeUniqueModelNames(OLLAMA_SUGGESTED_MODELS_CLOUD, discoveredModelNames)
: OLLAMA_SUGGESTED_MODELS_CLOUD;
return {
credential,
credentialMode,
config: applyOllamaProviderConfig(
params.cfg,
OLLAMA_CLOUD_BASE_URL,
modelNames,
undefined,
credential,
),
};
}
return await promptAndConfigureHostBackedOllama({
cfg: params.cfg,
mode,
prompter: params.prompter,
env: params.env,
});
}
export async function configureOllamaNonInteractive(params: {
nextConfig: OpenClawConfig;
opts: OllamaSetupOptions;
runtime: RuntimeEnv;
agentDir?: string;
}): Promise<OpenClawConfig> {
const baseUrl = resolveOllamaApiBase(
(params.opts.customBaseUrl?.trim() || resolveOllamaSetupDefaultBaseUrl()).replace(/\/+$/, ""),
);
const { reachable, models } = await fetchOllamaModels(baseUrl);
const explicitModel = normalizeOllamaModelName(params.opts.customModelId);
if (!reachable) {
params.runtime.error(buildOllamaUnreachableLines(baseUrl).slice(0, 2).join("\n"));
params.runtime.exit(1);
return params.nextConfig;
}
await storeOllamaCredential(params.agentDir);
const enrichedModels = await enrichOllamaModelsWithContext(
baseUrl,
models.slice(0, OLLAMA_CONTEXT_ENRICH_LIMIT),
);
const discoveredModelsByName = new Map(enrichedModels.map((model) => [model.name, model]));
const modelNames = models.map((model) => model.name);
const orderedModelNames = mergeUniqueModelNames(OLLAMA_SUGGESTED_MODELS_LOCAL, modelNames);
const requestedDefaultModelId = explicitModel ?? OLLAMA_SUGGESTED_MODELS_LOCAL[0];
const availableModelNames = new Set(modelNames);
const availableDefaultModelId = findAvailableOllamaModelName(
requestedDefaultModelId,
availableModelNames,
);
const requestedCloudModel = isOllamaCloudModel(requestedDefaultModelId);
let pulledRequestedModel = false;
if (requestedCloudModel) {
availableModelNames.add(requestedDefaultModelId);
} else if (!availableDefaultModelId) {
pulledRequestedModel = await pullOllamaModelNonInteractive(
baseUrl,
requestedDefaultModelId,
params.runtime,
);
if (pulledRequestedModel) {
availableModelNames.add(requestedDefaultModelId);
}
}
let allModelNames = orderedModelNames;
let defaultModelId = availableDefaultModelId ?? requestedDefaultModelId;
if (
(pulledRequestedModel || requestedCloudModel) &&
!allModelNames.includes(requestedDefaultModelId)
) {
allModelNames = [...allModelNames, requestedDefaultModelId];
}
if (!findAvailableOllamaModelName(defaultModelId, availableModelNames)) {
if (availableModelNames.size === 0) {
params.runtime.error(
[
`No Ollama models are available at ${baseUrl}.`,
"Pull a model first, then re-run setup.",
].join("\n"),
);
params.runtime.exit(1);
return params.nextConfig;
}
defaultModelId =
allModelNames.find((name) => findAvailableOllamaModelName(name, availableModelNames)) ??
Array.from(availableModelNames)[0];
params.runtime.log(
`Ollama model ${requestedDefaultModelId} was not available; using ${defaultModelId} instead.`,
);
}
const config = applyOllamaProviderConfig(
params.nextConfig,
baseUrl,
allModelNames,
discoveredModelsByName,
);
params.runtime.log(`Default Ollama model: ${defaultModelId}`);
return applyAgentDefaultModelPrimary(config, `ollama/${defaultModelId}`);
}
export async function ensureOllamaModelPulled(params: {
config: OpenClawConfig;
model: string;
prompter: WizardPrompter;
}): Promise<void> {
if (!params.model.startsWith("ollama/")) {
return;
}
const baseUrl =
readProviderBaseUrl(params.config.models?.providers?.ollama) ?? OLLAMA_DEFAULT_BASE_URL;
const modelName = params.model.slice("ollama/".length);
if (isOllamaCloudModel(modelName)) {
return;
}
const { models } = await fetchOllamaModels(baseUrl);
if (
findAvailableOllamaModelName(
modelName,
models.map((model) => model.name),
)
) {
return;
}
if (!(await pullOllamaModel(baseUrl, modelName, params.prompter))) {
throw new WizardCancelledError("Failed to download selected Ollama model");
}
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,524 @@
// Ollama tests cover stream plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
import { buildAssistantMessage, createOllamaStreamFn } from "./stream.js";
function makeOllamaResponse(params: {
content?: string;
thinking?: string;
reasoning?: string;
done_reason?: string;
tool_calls?: Array<{ function: { name: string; arguments: Record<string, unknown> } }>;
}) {
return {
model: "qwen3.5",
created_at: new Date().toISOString(),
message: {
role: "assistant" as const,
content: params.content ?? "",
...(params.thinking != null ? { thinking: params.thinking } : {}),
...(params.reasoning != null ? { reasoning: params.reasoning } : {}),
...(params.tool_calls ? { tool_calls: params.tool_calls } : {}),
},
done: true,
...(params.done_reason ? { done_reason: params.done_reason } : {}),
prompt_eval_count: 100,
eval_count: 50,
};
}
const MODEL_INFO = { api: "ollama", provider: "ollama", id: "qwen3.5" };
describe("buildAssistantMessage", () => {
it("includes thinking block when response has thinking field", () => {
const response = makeOllamaResponse({
thinking: "Let me think about this",
content: "The answer is 42",
});
const msg = buildAssistantMessage(response, MODEL_INFO);
expect(msg.content).toHaveLength(2);
expect(msg.content[0]).toEqual({ type: "thinking", thinking: "Let me think about this" });
expect(msg.content[1]).toEqual({ type: "text", text: "The answer is 42" });
});
it("includes thinking block when response has reasoning field", () => {
const response = makeOllamaResponse({
reasoning: "Step by step analysis",
content: "Result is 7",
});
const msg = buildAssistantMessage(response, MODEL_INFO);
expect(msg.content).toHaveLength(2);
expect(msg.content[0]).toEqual({ type: "thinking", thinking: "Step by step analysis" });
expect(msg.content[1]).toEqual({ type: "text", text: "Result is 7" });
});
it("prefers thinking over reasoning when both are present", () => {
const response = makeOllamaResponse({
thinking: "From thinking field",
reasoning: "From reasoning field",
content: "Answer",
});
const msg = buildAssistantMessage(response, MODEL_INFO);
expect(msg.content[0]).toEqual({ type: "thinking", thinking: "From thinking field" });
});
it("omits thinking block when no thinking or reasoning field", () => {
const response = makeOllamaResponse({
content: "Just text",
});
const msg = buildAssistantMessage(response, MODEL_INFO);
expect(msg.content).toHaveLength(1);
expect(msg.content[0]).toEqual({ type: "text", text: "Just text" });
});
it("omits thinking block when thinking field is empty", () => {
const response = makeOllamaResponse({
thinking: "",
content: "Just text",
});
const msg = buildAssistantMessage(response, MODEL_INFO);
expect(msg.content).toHaveLength(1);
expect(msg.content[0]).toEqual({ type: "text", text: "Just text" });
});
it("preserves output-budget length stops", () => {
const response = makeOllamaResponse({
content: "Partial answer",
done_reason: "length",
});
const msg = buildAssistantMessage(response, MODEL_INFO);
expect(msg.stopReason).toBe("length");
});
it("keeps a length stop authoritative over complete-looking tool calls", () => {
const response = makeOllamaResponse({
done_reason: "length",
tool_calls: [{ function: { name: "read", arguments: { path: "README.md" } } }],
});
const msg = buildAssistantMessage(response, MODEL_INFO);
expect(msg.stopReason).toBe("length");
});
});
describe("createOllamaStreamFn thinking events", () => {
beforeEach(() => {
vi.useRealTimers();
});
afterEach(() => {
fetchWithSsrFGuardMock.mockReset();
vi.useRealTimers();
});
function makeNdjsonBody(chunks: Array<Record<string, unknown>>): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
const lines = chunks.map((c) => JSON.stringify(c) + "\n").join("");
return new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(lines));
controller.close();
},
});
}
async function streamOllamaEvents(
chunks: Array<Record<string, unknown>>,
options: Parameters<ReturnType<typeof createOllamaStreamFn>>[2] = {},
context: Parameters<ReturnType<typeof createOllamaStreamFn>>[1] = {
messages: [{ role: "user", content: "test" }],
} as never,
): Promise<Array<{ type: string; [key: string]: unknown }>> {
const body = makeNdjsonBody(chunks);
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(body, { status: 200 }),
release: vi.fn(async () => undefined),
});
const streamFn = createOllamaStreamFn("http://localhost:11434");
const stream = streamFn(
{ api: "ollama", provider: "ollama", id: "qwen3.5", contextWindow: 65536 } as never,
context,
options,
);
const events: Array<{ type: string; [key: string]: unknown }> = [];
for await (const event of stream as AsyncIterable<{
type: string;
[key: string]: unknown;
}>) {
events.push(event);
}
return events;
}
it("emits thinking_start, thinking_delta, and thinking_end events for thinking content", async () => {
const thinkingChunks = [
{
model: "qwen3.5",
created_at: "2026-01-01T00:00:00Z",
message: { role: "assistant", content: "", thinking: "Step 1" },
done: false,
},
{
model: "qwen3.5",
created_at: "2026-01-01T00:00:01Z",
message: { role: "assistant", content: "", thinking: " and step 2" },
done: false,
},
{
model: "qwen3.5",
created_at: "2026-01-01T00:00:02Z",
message: { role: "assistant", content: "The answer", thinking: "" },
done: false,
},
{
model: "qwen3.5",
created_at: "2026-01-01T00:00:03Z",
message: { role: "assistant", content: "" },
done: true,
done_reason: "stop",
prompt_eval_count: 10,
eval_count: 5,
},
];
const events = await streamOllamaEvents(thinkingChunks);
const eventTypes = events.map((e) => e.type);
expect(eventTypes).toContain("thinking_start");
expect(eventTypes).toContain("thinking_delta");
expect(eventTypes).toContain("thinking_end");
expect(eventTypes).toContain("text_start");
expect(eventTypes).toContain("text_delta");
expect(eventTypes).toContain("done");
const thinkingStartIndex = eventTypes.indexOf("thinking_start");
const textStartIndex = eventTypes.indexOf("text_start");
expect(thinkingStartIndex).toBeLessThan(textStartIndex);
const thinkingEndIndex = eventTypes.indexOf("thinking_end");
expect(thinkingEndIndex).toBeLessThan(textStartIndex);
const thinkingDeltas = events.filter((e) => e.type === "thinking_delta");
expect(thinkingDeltas).toHaveLength(2);
expect(thinkingDeltas[0].delta).toBe("Step 1");
expect(thinkingDeltas[1].delta).toBe(" and step 2");
const thinkingStart = events.find((e) => e.type === "thinking_start");
expect(thinkingStart?.contentIndex).toBe(0);
const textStart = events.find((e) => e.type === "text_start");
expect(textStart?.contentIndex).toBe(1);
const done = events.find((e) => e.type === "done") as { message?: { content: unknown[] } };
const content = done?.message?.content ?? [];
expect(content[0]).toEqual({ type: "thinking", thinking: "Step 1 and step 2" });
expect(content[1]).toEqual({ type: "text", text: "The answer" });
});
it("streams without thinking events when no thinking content is present", async () => {
const chunks = [
{
model: "qwen3.5",
created_at: "2026-01-01T00:00:00Z",
message: { role: "assistant", content: "Hello" },
done: false,
},
{
model: "qwen3.5",
created_at: "2026-01-01T00:00:01Z",
message: { role: "assistant", content: "" },
done: true,
done_reason: "stop",
prompt_eval_count: 10,
eval_count: 5,
},
];
const events = await streamOllamaEvents(chunks);
const eventTypes = events.map((e) => e.type);
expect(eventTypes).not.toContain("thinking_start");
expect(eventTypes).not.toContain("thinking_delta");
expect(eventTypes).not.toContain("thinking_end");
expect(eventTypes).toContain("text_start");
expect(eventTypes).toContain("text_delta");
expect(eventTypes).toContain("done");
const textStart = events.find((e) => e.type === "text_start") as { contentIndex?: number };
expect(textStart?.contentIndex).toBe(0);
});
it("emits length for a token-limited native stream", async () => {
const events = await streamOllamaEvents([
{
model: "qwen3.5",
created_at: "2026-01-01T00:00:00Z",
message: { role: "assistant", content: "Partial answer" },
done: false,
},
{
model: "qwen3.5",
created_at: "2026-01-01T00:00:01Z",
message: { role: "assistant", content: "" },
done: true,
done_reason: "length",
prompt_eval_count: 10,
eval_count: 5,
},
]);
const done = events.find((event) => event.type === "done") as {
reason?: string;
message?: { stopReason?: string };
};
expect(done.reason).toBe("length");
expect(done.message?.stopReason).toBe("length");
});
it("preserves a native length stop when the partial response contains tool calls", async () => {
const events = await streamOllamaEvents(
[
makeOllamaResponse({
done_reason: "length",
tool_calls: [{ function: { name: "read", arguments: { path: "README.md" } } }],
}),
],
{},
{
messages: [{ role: "user", content: "test" }],
tools: [{ name: "read", description: "Read files", parameters: { type: "object" } }],
} as never,
);
const done = events.find((event) => event.type === "done") as {
reason?: string;
message?: { content?: Array<Record<string, unknown>>; stopReason?: string };
};
expect(done.reason).toBe("length");
expect(done.message?.stopReason).toBe("length");
expect(done.message?.content).toEqual([
expect.objectContaining({ type: "toolCall", name: "read" }),
]);
});
it("uses generic stream timeout for Ollama request timeout", async () => {
await streamOllamaEvents([makeOllamaResponse({ content: "ok" })], { timeoutMs: 2500 });
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({
url: "http://localhost:11434/api/chat",
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "qwen3.5",
messages: [{ role: "user", content: "test" }],
stream: true,
options: {},
}),
},
policy: {
allowPrivateNetwork: true,
hostnameAllowlist: ["localhost"],
},
timeoutMs: 2500,
auditContext: "ollama-stream.chat",
});
});
it("promotes standalone bracketed local-model tool text to a structured tool call", async () => {
const rawToolText = [
"[mempalace_mempalace_search]",
'{"query":"codename","wing":"personal","room":"identities"}',
"[END_TOOL_REQUEST]",
].join("\n");
const events = await streamOllamaEvents(
[
{
model: "qwen3.5",
created_at: "2026-01-01T00:00:00Z",
message: { role: "assistant", content: rawToolText },
done: false,
},
{
model: "qwen3.5",
created_at: "2026-01-01T00:00:01Z",
message: { role: "assistant", content: "" },
done: true,
done_reason: "stop",
prompt_eval_count: 10,
eval_count: 5,
},
],
{},
{
messages: [{ role: "user", content: "test" }],
tools: [
{
name: "mempalace_mempalace_search",
description: "Search MemPalace",
parameters: { type: "object", properties: {} },
},
],
} as never,
);
expect(events.map((event) => event.type)).toEqual([
"start",
"toolcall_start",
"toolcall_delta",
"done",
]);
const done = events.find((event) => event.type === "done") as {
message?: { content?: Array<Record<string, unknown>>; stopReason?: string };
reason?: string;
};
expect(done.reason).toBe("toolUse");
expect(done.message?.stopReason).toBe("toolUse");
expect(done.message?.content?.[0]).toMatchObject({
type: "toolCall",
name: "mempalace_mempalace_search",
arguments: { query: "codename", wing: "personal", room: "identities" },
});
});
it("promotes standalone Harmony local-model tool text to a structured tool call", async () => {
const rawToolText =
'commentary to=read code {"path":"/path/to/file","line_start":1,"line_end":400}';
const events = await streamOllamaEvents(
[
{
model: "qwen3.5",
created_at: "2026-01-01T00:00:00Z",
message: { role: "assistant", content: rawToolText },
done: false,
},
{
model: "qwen3.5",
created_at: "2026-01-01T00:00:01Z",
message: { role: "assistant", content: "" },
done: true,
done_reason: "stop",
prompt_eval_count: 10,
eval_count: 5,
},
],
{},
{
messages: [{ role: "user", content: "test" }],
tools: [{ name: "read", description: "Read files", parameters: { type: "object" } }],
} as never,
);
expect(events.map((event) => event.type)).toEqual([
"start",
"toolcall_start",
"toolcall_delta",
"done",
]);
const done = events.find((event) => event.type === "done") as {
message?: { content?: Array<Record<string, unknown>>; stopReason?: string };
reason?: string;
};
expect(done.reason).toBe("toolUse");
expect(done.message?.content?.[0]).toMatchObject({
type: "toolCall",
name: "read",
arguments: { path: "/path/to/file", line_start: 1, line_end: 400 },
});
});
it("yields to the event loop while processing dense native stream chunks", async () => {
const chunks = [
...Array.from({ length: 65 }, (_value, index) => ({
model: "qwen3.5",
created_at: `2026-01-01T00:00:${String(index % 60).padStart(2, "0")}Z`,
message: { role: "assistant" as const, content: "x" },
done: false,
})),
makeOllamaResponse({ content: "" }),
];
const body = makeNdjsonBody(chunks);
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(body, { status: 200 }),
release: vi.fn(async () => undefined),
});
const streamFn = createOllamaStreamFn("http://localhost:11434");
const stream = streamFn(
{ api: "ollama", provider: "ollama", id: "qwen3.5", contextWindow: 65536 } as never,
{ messages: [{ role: "user", content: "test" }] } as never,
{},
);
let timerFired = false;
const timerPromise = new Promise<void>((resolve) => {
setTimeout(() => {
timerFired = true;
resolve();
}, 0);
});
let yieldedBeforeDone = false;
for await (const event of stream as AsyncIterable<{ type: string }>) {
if (timerFired && event.type !== "done") {
yieldedBeforeDone = true;
}
}
await timerPromise;
expect(yieldedBeforeDone).toBe(true);
});
it("reports caller aborts during dense native stream processing as aborted", async () => {
const chunks = [
...Array.from({ length: 65 }, (_value, index) => ({
model: "qwen3.5",
created_at: `2026-01-01T00:00:${String(index % 60).padStart(2, "0")}Z`,
message: { role: "assistant" as const, content: "x" },
done: false,
})),
makeOllamaResponse({ content: "" }),
];
const body = makeNdjsonBody(chunks);
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(body, { status: 200 }),
release: vi.fn(async () => undefined),
});
const controller = new AbortController();
const streamFn = createOllamaStreamFn("http://localhost:11434");
const stream = streamFn(
{ api: "ollama", provider: "ollama", id: "qwen3.5", contextWindow: 65536 } as never,
{ messages: [{ role: "user", content: "test" }] } as never,
{ signal: controller.signal },
);
setTimeout(() => {
controller.abort();
}, 0);
const events: Array<{ type: string; reason?: string; error?: { stopReason?: string } }> = [];
for await (const event of stream as AsyncIterable<{
type: string;
reason?: string;
error?: { stopReason?: string };
}>) {
events.push(event);
}
const lastEvent = events.at(-1);
expect(lastEvent).toMatchObject({
type: "error",
reason: "aborted",
error: { stopReason: "aborted" },
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,515 @@
// Ollama tests cover web search provider plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
import { createOllamaWebSearchProvider as createContractOllamaWebSearchProvider } from "../web-search-contract-api.js";
import {
testing,
createOllamaWebSearchProvider,
runOllamaWebSearch,
} from "./web-search-provider.js";
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
type OllamaProviderConfigOverride = Partial<{
api: "ollama";
apiKey: string;
baseUrl: string;
baseURL: string;
models: NonNullable<
NonNullable<NonNullable<OpenClawConfig["models"]>["providers"]>[string]
>["models"];
}>;
function createOllamaConfig(provider: OllamaProviderConfigOverride = {}): OpenClawConfig {
return {
models: {
providers: {
ollama: {
baseUrl: "http://ollama.local:11434/v1",
api: "ollama",
models: [],
...provider,
},
},
},
};
}
function createOllamaConfigWithWebSearchBaseUrl(baseUrl: string): OpenClawConfig {
return {
...createOllamaConfig(),
plugins: {
entries: {
ollama: {
config: {
webSearch: {
baseUrl,
},
},
},
},
},
};
}
function createSetupNotes() {
const notes: Array<{ title?: string; message: string }> = [];
return {
notes,
prompter: {
note: async (message: string, title?: string) => {
notes.push({ title, message });
},
},
};
}
function expectOllamaWebSearchRequest(
call: unknown[] | undefined,
params: {
url: string;
query?: string;
maxResults?: number;
headers?: Record<string, string>;
policy: Record<string, unknown>;
},
) {
if (!call?.[0] || typeof call[0] !== "object") {
throw new Error("Expected fetchWithSsrFGuard call");
}
const request = call[0] as {
url: string;
init: {
method: string;
headers: Record<string, string>;
body: string;
signal: AbortSignal;
};
policy: Record<string, unknown>;
auditContext: string;
};
expect(request).toEqual({
url: params.url,
init: {
method: "POST",
headers: params.headers ?? { "Content-Type": "application/json" },
body: JSON.stringify({
query: params.query ?? "openclaw",
max_results: params.maxResults ?? 5,
}),
signal: request.init.signal,
},
policy: params.policy,
auditContext: "ollama-web-search.search",
});
expect(request.init.signal).toBeInstanceOf(AbortSignal);
}
function fetchCall(index = 0): unknown[] {
const call = fetchWithSsrFGuardMock.mock.calls.at(index);
if (!call) {
throw new Error(`expected guarded fetch call ${index}`);
}
return call;
}
function fetchRequest(index = 0): {
init?: { headers?: Record<string, string> };
url?: string;
} {
const request = fetchCall(index).at(0);
if (!request || typeof request !== "object") {
throw new Error(`expected guarded fetch request ${index}`);
}
return request as {
init?: { headers?: Record<string, string> };
url?: string;
};
}
function expectSingleSearchResultUrl(results: unknown, url: string) {
if (!Array.isArray(results)) {
throw new Error("Expected search results array");
}
expect(results).toHaveLength(1);
const [result] = results;
if (!result || typeof result !== "object") {
throw new Error("Expected search result object");
}
expect((result as { url?: unknown }).url).toBe(url);
}
describe("ollama web search provider", () => {
beforeEach(() => {
fetchWithSsrFGuardMock.mockReset();
});
it("registers a keyless web search provider", () => {
const provider = createContractOllamaWebSearchProvider();
expect(provider.id).toBe("ollama");
expect(provider.label).toBe("Ollama Web Search");
expect(provider.requiresCredential).toBe(false);
expect(provider.envVars).toEqual([]);
});
it("uses the configured Ollama host and enables the plugin in config", () => {
const provider = createOllamaWebSearchProvider();
if (!provider.applySelectionConfig) {
throw new Error("Expected applySelectionConfig to be defined");
}
const applied = provider.applySelectionConfig({});
expect(provider.credentialPath).toBe("");
expect(applied.plugins?.entries?.ollama?.enabled).toBe(true);
expect(
testing.resolveOllamaWebSearchBaseUrl({
models: {
providers: {
ollama: {
baseUrl: "http://ollama.local:11434/v1",
api: "ollama",
models: [],
},
},
},
}),
).toBe("http://ollama.local:11434");
});
it("prefers the plugin web search base URL over the model provider host", () => {
expect(
testing.resolveOllamaWebSearchBaseUrl(
createOllamaConfigWithWebSearchBaseUrl("http://localhost:11434/v1"),
),
).toBe("http://localhost:11434");
});
it("uses the configured Ollama Cloud host for web search", () => {
expect(
testing.resolveOllamaWebSearchBaseUrl(
createOllamaConfig({
baseUrl: "https://ollama.com",
}),
),
).toBe("https://ollama.com");
});
it("uses the model provider baseURL alias for web search", () => {
expect(
testing.resolveOllamaWebSearchBaseUrl(
createOllamaConfig({
baseUrl: undefined,
baseURL: "http://remote-ollama:11434/v1",
} as OllamaProviderConfigOverride),
),
).toBe("http://remote-ollama:11434");
});
it("maps generic search args into the local Ollama proxy endpoint", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response(
JSON.stringify({
results: [
{
title: "OpenClaw",
url: "https://openclaw.ai/docs",
content: "Gateway docs and setup details",
},
],
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
release,
});
const provider = createOllamaWebSearchProvider();
const tool = provider.createTool({
config: createOllamaConfig(),
} as never);
if (!tool) {
throw new Error("Expected tool definition");
}
const result = await tool.execute({ query: "openclaw docs", count: 3 });
expectOllamaWebSearchRequest(fetchCall(), {
url: "http://ollama.local:11434/api/experimental/web_search",
query: "openclaw docs",
maxResults: 3,
policy: {
allowPrivateNetwork: true,
hostnameAllowlist: ["ollama.local"],
},
});
expect(result.query).toBe("openclaw docs");
expect(result.provider).toBe("ollama");
expect(result.count).toBe(1);
expectSingleSearchResultUrl(result.results, "https://openclaw.ai/docs");
expect(release).toHaveBeenCalledTimes(1);
});
it("tries the future local direct endpoint when the local proxy endpoint is missing", async () => {
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response("not found", { status: 404 }),
release: vi.fn(async () => {}),
})
.mockResolvedValueOnce({
response: new Response(
JSON.stringify({
results: [{ title: "Legacy", url: "https://example.com", content: "result" }],
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
release: vi.fn(async () => {}),
});
const result = await runOllamaWebSearch({
config: createOllamaConfig(),
query: "openclaw",
});
expect(result.count).toBe(1);
expectSingleSearchResultUrl(result.results, "https://example.com");
expect(fetchWithSsrFGuardMock.mock.calls.map((call) => call[0].url)).toEqual([
"http://ollama.local:11434/api/experimental/web_search",
"http://ollama.local:11434/api/web_search",
]);
});
it("uses only the hosted endpoint for Ollama Cloud base URLs", async () => {
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(
JSON.stringify({
results: [{ title: "Cloud", url: "https://example.com", content: "result" }],
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
release: vi.fn(async () => {}),
});
const result = await runOllamaWebSearch({
config: createOllamaConfig({
baseUrl: "https://ollama.com",
apiKey: "cloud-config-secret",
}),
query: "openclaw",
});
expect(result.count).toBe(1);
expect(fetchWithSsrFGuardMock.mock.calls).toHaveLength(1);
expect(fetchRequest().url).toBe("https://ollama.com/api/web_search");
expectOllamaWebSearchRequest(fetchCall(), {
url: "https://ollama.com/api/web_search",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer cloud-config-secret",
},
policy: {
allowPrivateNetwork: true,
hostnameAllowlist: ["ollama.com"],
},
});
});
it("uses an env Ollama key only for the cloud fallback from a local host", async () => {
const original = process.env.OLLAMA_API_KEY;
try {
process.env.OLLAMA_API_KEY = "cloud-secret";
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response("not found", { status: 404 }),
release: vi.fn(async () => {}),
})
.mockResolvedValueOnce({
response: new Response("not found", { status: 404 }),
release: vi.fn(async () => {}),
})
.mockResolvedValueOnce({
response: new Response(
JSON.stringify({
results: [{ title: "Cloud", url: "https://example.com", content: "result" }],
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
release: vi.fn(async () => {}),
});
const result = await runOllamaWebSearch({
config: createOllamaConfig(),
query: "openclaw",
});
expect(result.count).toBe(1);
const firstHeaders = fetchRequest().init?.headers;
const cloudHeaders = fetchRequest(2).init?.headers;
expect(firstHeaders?.Authorization).toBeUndefined();
expect(cloudHeaders?.Authorization).toBe("Bearer cloud-secret");
expect(fetchWithSsrFGuardMock.mock.calls.map((call) => call[0].url)).toEqual([
"http://ollama.local:11434/api/experimental/web_search",
"http://ollama.local:11434/api/web_search",
"https://ollama.com/api/web_search",
]);
expect(fetchRequest(2).url).toBe("https://ollama.com/api/web_search");
} finally {
if (original === undefined) {
delete process.env.OLLAMA_API_KEY;
} else {
process.env.OLLAMA_API_KEY = original;
}
}
});
it("surfaces Ollama signin guidance for 401 responses", async () => {
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response("", { status: 401 }),
release: vi.fn(async () => {}),
});
await expect(runOllamaWebSearch({ query: "latest openclaw release" })).rejects.toThrow(
"ollama signin",
);
});
it("reports malformed Ollama web search JSON with a stable provider error", async () => {
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response("{ nope", { status: 200 }),
release: vi.fn(async () => {}),
});
await expect(
runOllamaWebSearch({
config: createOllamaConfig(),
query: "openclaw",
}),
).rejects.toThrow("Ollama web search: malformed JSON response");
});
it("bounds successful Ollama web search JSON bodies before parsing", async () => {
const streamed = createStreamingResponse({
chunkCount: 32,
chunkSize: 1024 * 1024,
text: "x",
headers: { "content-type": "application/json" },
});
const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded"));
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: streamed.response,
release: vi.fn(async () => {}),
});
await expect(
runOllamaWebSearch({
config: createOllamaConfig(),
query: "openclaw",
}),
).rejects.toThrow("Ollama web search: JSON response exceeds 16777216 bytes");
expect(streamed.getReadCount()).toBeLessThan(32);
expect(streamed.wasCanceled()).toBe(true);
expect(jsonSpy).not.toHaveBeenCalled();
});
it("warns when Ollama is not reachable during setup without cancelling", async () => {
fetchWithSsrFGuardMock.mockRejectedValueOnce(new Error("connect failed"));
const config = createOllamaConfig();
const { notes, prompter } = createSetupNotes();
const next = await testing.warnOllamaWebSearchPrereqs({
config,
prompter,
});
expect(next).toBe(config);
expect(notes).toEqual([
{
title: "Ollama Web Search",
message: [
"Ollama Web Search requires Ollama to be running.",
"Expected host: http://ollama.local:11434",
"Start Ollama before using this provider.",
].join("\n"),
},
]);
});
it("resolves env var when config apiKey is a marker string", () => {
const original = process.env.OLLAMA_API_KEY;
try {
process.env.OLLAMA_API_KEY = "real-secret-from-env";
const key = testing.resolveOllamaWebSearchApiKey(
createOllamaConfig({
apiKey: "OLLAMA_API_KEY",
baseUrl: "http://localhost:11434",
}),
);
expect(key).toBe("real-secret-from-env");
} finally {
if (original === undefined) {
delete process.env.OLLAMA_API_KEY;
} else {
process.env.OLLAMA_API_KEY = original;
}
}
});
it("warns when ollama signin is missing during setup without cancelling", async () => {
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response(JSON.stringify({ models: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
release: vi.fn(async () => {}),
})
.mockResolvedValueOnce({
response: new Response(
JSON.stringify({ error: "not signed in", signin_url: "https://ollama.com/signin" }),
{
status: 401,
headers: { "Content-Type": "application/json" },
},
),
release: vi.fn(async () => {}),
});
const config = createOllamaConfig();
const { notes, prompter } = createSetupNotes();
const next = await testing.warnOllamaWebSearchPrereqs({
config,
prompter,
});
expect(next).toBe(config);
expect(notes).toEqual([
{
title: "Ollama Web Search",
message: "Ollama Web Search requires `ollama signin`.\nhttps://ollama.com/signin",
},
]);
});
});

View File

@@ -0,0 +1,351 @@
// Ollama provider module implements model/runtime integration.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
isNonSecretApiKeyMarker,
normalizeOptionalSecretInput,
} from "openclaw/plugin-sdk/provider-auth";
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import {
enablePluginInConfig,
readPositiveIntegerParam,
readResponseText,
readStringParam,
resolveProviderWebSearchPluginConfig,
resolveSearchCount,
resolveSiteName,
truncateText,
wrapWebContent,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { Type } from "typebox";
import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js";
import { readProviderBaseUrl } from "./provider-base-url.js";
import {
buildOllamaBaseUrlSsrFPolicy,
fetchOllamaModels,
resolveOllamaApiBase,
} from "./provider-models.js";
import { checkOllamaCloudAuth } from "./setup.js";
const OLLAMA_WEB_SEARCH_SCHEMA = Type.Object(
{
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Integer({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
}),
),
},
{ additionalProperties: false },
);
const OLLAMA_HOSTED_WEB_SEARCH_PATH = "/api/web_search";
const OLLAMA_LOCAL_WEB_SEARCH_PROXY_PATH = "/api/experimental/web_search";
const OLLAMA_CLOUD_BASE_URL = "https://ollama.com";
const DEFAULT_OLLAMA_WEB_SEARCH_COUNT = 5;
const DEFAULT_OLLAMA_WEB_SEARCH_TIMEOUT_MS = 15_000;
const OLLAMA_WEB_SEARCH_SNIPPET_MAX_CHARS = 300;
type OllamaWebSearchResult = {
title?: string;
url?: string;
content?: string;
};
type OllamaWebSearchResponse = {
results?: OllamaWebSearchResult[];
};
type OllamaWebSearchAttempt = {
baseUrl: string;
path: string;
apiKey?: string;
};
async function readOllamaWebSearchResponse(response: Response): Promise<OllamaWebSearchResponse> {
return await readProviderJsonResponse<OllamaWebSearchResponse>(response, "Ollama web search");
}
function isOllamaCloudBaseUrl(baseUrl: string): boolean {
try {
const parsed = new URL(baseUrl);
return parsed.protocol === "https:" && parsed.hostname === "ollama.com";
} catch {
return false;
}
}
function resolveConfiguredOllamaWebSearchApiKey(config?: OpenClawConfig): string | undefined {
const providerApiKey = normalizeOptionalSecretInput(config?.models?.providers?.ollama?.apiKey);
if (providerApiKey && !isNonSecretApiKeyMarker(providerApiKey)) {
return providerApiKey;
}
return undefined;
}
function resolveEnvOllamaWebSearchApiKey(): string | undefined {
return resolveEnvApiKey("ollama")?.apiKey;
}
function resolveOllamaWebSearchApiKey(config?: OpenClawConfig): string | undefined {
return resolveConfiguredOllamaWebSearchApiKey(config) ?? resolveEnvOllamaWebSearchApiKey();
}
function resolveOllamaWebSearchBaseUrl(config?: OpenClawConfig): string {
const pluginBaseUrl = normalizeOptionalString(
resolveProviderWebSearchPluginConfig(config, "ollama")?.baseUrl,
);
if (pluginBaseUrl) {
return resolveOllamaApiBase(pluginBaseUrl);
}
const configuredBaseUrl = readProviderBaseUrl(config?.models?.providers?.ollama);
if (configuredBaseUrl) {
return resolveOllamaApiBase(configuredBaseUrl);
}
return OLLAMA_DEFAULT_BASE_URL;
}
function normalizeOllamaWebSearchResult(
result: OllamaWebSearchResult,
): { title: string; url: string; content: string } | null {
const url = normalizeOptionalString(result.url) ?? "";
if (!url) {
return null;
}
return {
title: normalizeOptionalString(result.title) ?? "",
url,
content: normalizeOptionalString(result.content) ?? "",
};
}
function buildOllamaWebSearchAttempts(params: {
baseUrl: string;
configuredApiKey?: string;
envApiKey?: string;
}): OllamaWebSearchAttempt[] {
if (isOllamaCloudBaseUrl(params.baseUrl)) {
return [
{
baseUrl: params.baseUrl,
path: OLLAMA_HOSTED_WEB_SEARCH_PATH,
apiKey: params.configuredApiKey ?? params.envApiKey,
},
];
}
const attempts: OllamaWebSearchAttempt[] = [
{
baseUrl: params.baseUrl,
path: OLLAMA_LOCAL_WEB_SEARCH_PROXY_PATH,
apiKey: params.configuredApiKey,
},
{
baseUrl: params.baseUrl,
path: OLLAMA_HOSTED_WEB_SEARCH_PATH,
apiKey: params.configuredApiKey,
},
];
if (params.envApiKey) {
attempts.push({
baseUrl: OLLAMA_CLOUD_BASE_URL,
path: OLLAMA_HOSTED_WEB_SEARCH_PATH,
apiKey: params.envApiKey,
});
}
return attempts;
}
export async function runOllamaWebSearch(params: {
config?: OpenClawConfig;
query: string;
count?: number;
}): Promise<Record<string, unknown>> {
const query = params.query.trim();
if (!query) {
throw new Error("query parameter is required");
}
const baseUrl = resolveOllamaWebSearchBaseUrl(params.config);
const configuredApiKey = resolveConfiguredOllamaWebSearchApiKey(params.config);
const envApiKey = resolveEnvOllamaWebSearchApiKey();
const count = resolveSearchCount(params.count, DEFAULT_OLLAMA_WEB_SEARCH_COUNT);
const startedAt = Date.now();
const body = JSON.stringify({ query, max_results: count });
const attempts = buildOllamaWebSearchAttempts({ baseUrl, configuredApiKey, envApiKey });
let payload: OllamaWebSearchResponse | undefined;
let lastError: Error | undefined;
for (const attempt of attempts) {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (attempt.apiKey) {
headers.Authorization = `Bearer ${attempt.apiKey}`;
}
const { response, release } = await fetchWithSsrFGuard({
url: `${attempt.baseUrl}${attempt.path}`,
init: {
method: "POST",
headers,
body,
signal: AbortSignal.timeout(DEFAULT_OLLAMA_WEB_SEARCH_TIMEOUT_MS),
},
policy: buildOllamaBaseUrlSsrFPolicy(attempt.baseUrl),
auditContext: "ollama-web-search.search",
});
try {
if (response.status === 401) {
throw new Error("Ollama web search authentication failed. Run `ollama signin`.");
}
if (response.status === 403) {
throw new Error(
"Ollama web search is unavailable. Ensure cloud-backed web search is enabled on the Ollama host.",
);
}
if (!response.ok) {
const detail = await readResponseText(response, { maxBytes: 64_000 });
const message =
`Ollama web search failed (${response.status}): ${detail.text || ""}`.trim();
if (response.status === 404) {
lastError = new Error(message);
continue;
}
throw new Error(message);
}
payload = await readOllamaWebSearchResponse(response);
break;
} catch (error) {
if (error instanceof Error) {
lastError = error;
} else {
lastError = new Error(String(error));
}
throw lastError;
} finally {
await release();
}
}
if (!payload) {
throw lastError ?? new Error("Ollama web search failed");
}
const results = Array.isArray(payload.results)
? payload.results
.map(normalizeOllamaWebSearchResult)
.filter((result): result is NonNullable<typeof result> => result !== null)
.slice(0, count)
: [];
return {
query,
provider: "ollama",
count: results.length,
tookMs: Date.now() - startedAt,
externalContent: {
untrusted: true,
source: "web_search",
provider: "ollama",
wrapped: true,
},
results: results.map((result) => {
const snippet = truncateText(result.content, OLLAMA_WEB_SEARCH_SNIPPET_MAX_CHARS).text;
return {
title: result.title ? wrapWebContent(result.title, "web_search") : "",
url: result.url,
snippet: snippet ? wrapWebContent(snippet, "web_search") : "",
siteName: resolveSiteName(result.url) || undefined,
};
}),
};
}
async function warnOllamaWebSearchPrereqs(params: {
config: OpenClawConfig;
prompter: {
note: (message: string, title?: string) => Promise<void>;
};
}): Promise<OpenClawConfig> {
const baseUrl = resolveOllamaWebSearchBaseUrl(params.config);
const { reachable } = await fetchOllamaModels(baseUrl);
if (!reachable) {
await params.prompter.note(
[
"Ollama Web Search requires Ollama to be running.",
`Expected host: ${baseUrl}`,
"Start Ollama before using this provider.",
].join("\n"),
"Ollama Web Search",
);
return params.config;
}
const auth = await checkOllamaCloudAuth(baseUrl);
if (!auth.signedIn) {
await params.prompter.note(
[
"Ollama Web Search requires `ollama signin`.",
...(auth.signinUrl ? [auth.signinUrl] : ["Run `ollama signin`."]),
].join("\n"),
"Ollama Web Search",
);
}
return params.config;
}
export function createOllamaWebSearchProvider(): WebSearchProviderPlugin {
return {
id: "ollama",
label: "Ollama Web Search",
hint: "Local Ollama host · requires ollama signin",
onboardingScopes: ["text-inference"],
requiresCredential: false,
envVars: [],
placeholder: "(run ollama signin)",
signupUrl: "https://ollama.com/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 110,
credentialPath: "",
getCredentialValue: () => undefined,
setCredentialValue: () => {},
applySelectionConfig: (config) => enablePluginInConfig(config, "ollama").config,
runSetup: async (ctx) =>
await warnOllamaWebSearchPrereqs({
config: ctx.config,
prompter: ctx.prompter,
}),
createTool: (ctx) => ({
description:
"Search the web using Ollama's web search API. Returns titles, URLs, and snippets from the configured Ollama host.",
parameters: OLLAMA_WEB_SEARCH_SCHEMA,
execute: async (args) =>
await runOllamaWebSearch({
config: ctx.config,
query: readStringParam(args, "query", { required: true }),
count: readPositiveIntegerParam(args, "count", {
max: 10,
message: "count must be an integer from 1 to 10.",
}),
}),
}),
};
}
export const testing = {
buildOllamaWebSearchAttempts,
normalizeOllamaWebSearchResult,
resolveConfiguredOllamaWebSearchApiKey,
resolveEnvOllamaWebSearchApiKey,
resolveOllamaWebSearchApiKey,
resolveOllamaWebSearchBaseUrl,
isOllamaCloudBaseUrl,
readOllamaWebSearchResponse,
warnOllamaWebSearchPrereqs,
};
export { testing as __testing };

View File

@@ -0,0 +1,158 @@
// Ollama tests cover wsl2 crash loop check plugin behavior.
import { promisify } from "node:util";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { isWSL2SyncMock } = vi.hoisted(() => ({
isWSL2SyncMock: vi.fn(() => false),
}));
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
isWSL2Sync: isWSL2SyncMock,
}));
vi.mock("node:fs/promises", () => ({
access: vi.fn(),
}));
vi.mock("node:child_process", async () => {
const { promisify: realPromisify } = await import("node:util");
const mockExecFile = vi.fn();
const execFilePromise = vi.fn();
(mockExecFile as unknown as Record<symbol, unknown>)[realPromisify.custom] = execFilePromise;
return { execFile: mockExecFile };
});
import { execFile } from "node:child_process";
import { access } from "node:fs/promises";
import {
checkWsl2CrashLoopRisk,
hasWslCuda,
isOllamaEnabledWithRestartAlways,
parseSystemctlShowProperties,
} from "./wsl2-crash-loop-check.js";
const accessMock = vi.mocked(access);
const execFileMock = execFile as unknown as ReturnType<typeof vi.fn> & {
[key: symbol]: ReturnType<typeof vi.fn>;
};
const execFilePromiseMock = vi.mocked(execFileMock[promisify.custom]);
function createLogger() {
return {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
};
}
function mockSystemctl(stdout: string): void {
execFilePromiseMock.mockResolvedValue({ stdout, stderr: "" });
}
describe("wsl2 crash-loop check", () => {
beforeEach(() => {
vi.clearAllMocks();
isWSL2SyncMock.mockReturnValue(false);
});
it("parses systemctl show properties", () => {
expect(
parseSystemctlShowProperties("UnitFileState=enabled\nRestart=always\nIgnoredLine\n"),
).toEqual(
new Map([
["UnitFileState", "enabled"],
["Restart", "always"],
]),
);
});
it("detects enabled Restart=always ollama service", async () => {
mockSystemctl("UnitFileState=enabled\nRestart=always\n");
await expect(isOllamaEnabledWithRestartAlways()).resolves.toBe(true);
expect(execFilePromiseMock).toHaveBeenCalledWith(
"systemctl",
["show", "ollama.service", "--property=UnitFileState,Restart", "--no-pager"],
{ timeout: 5000 },
);
});
it("does not treat enabled-runtime as persistent autostart", async () => {
mockSystemctl("UnitFileState=enabled-runtime\nRestart=always\n");
await expect(isOllamaEnabledWithRestartAlways()).resolves.toBe(false);
});
it("requires Restart=always", async () => {
mockSystemctl("UnitFileState=enabled\nRestart=on-failure\n");
await expect(isOllamaEnabledWithRestartAlways()).resolves.toBe(false);
});
it("returns false when systemctl is unavailable", async () => {
execFilePromiseMock.mockRejectedValue(new Error("systemd unavailable"));
await expect(isOllamaEnabledWithRestartAlways()).resolves.toBe(false);
});
it("detects CUDA from the first available WSL marker", async () => {
accessMock.mockResolvedValueOnce(undefined);
await expect(hasWslCuda()).resolves.toBe(true);
expect(accessMock).toHaveBeenCalledWith("/dev/dxg");
});
it("checks the remaining CUDA markers before returning false", async () => {
accessMock.mockRejectedValue(new Error("missing"));
await expect(hasWslCuda()).resolves.toBe(false);
expect(accessMock).toHaveBeenCalledTimes(4);
});
it("warns for WSL2 plus Ollama autostart plus CUDA", async () => {
isWSL2SyncMock.mockReturnValue(true);
mockSystemctl("UnitFileState=enabled\nRestart=always\n");
accessMock.mockResolvedValueOnce(undefined);
const logger = createLogger();
await checkWsl2CrashLoopRisk(logger);
expect(logger.warn).toHaveBeenCalledTimes(1);
const message = String(logger.warn.mock.calls.at(0)?.[0]);
expect(message).toContain("WSL2 crash-loop risk");
expect(message).toContain("sudo systemctl disable ollama");
expect(message).toContain("autoMemoryReclaim=disabled");
expect(message).toContain("OLLAMA_KEEP_ALIVE=5m");
});
it("does not probe systemd outside WSL2", async () => {
const logger = createLogger();
await checkWsl2CrashLoopRisk(logger);
expect(execFilePromiseMock).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
it("does not warn when CUDA is not visible", async () => {
isWSL2SyncMock.mockReturnValue(true);
mockSystemctl("UnitFileState=enabled\nRestart=always\n");
accessMock.mockRejectedValue(new Error("missing"));
const logger = createLogger();
await checkWsl2CrashLoopRisk(logger);
expect(logger.warn).not.toHaveBeenCalled();
});
it("never throws from advisory checks", async () => {
isWSL2SyncMock.mockReturnValue(true);
execFilePromiseMock.mockRejectedValue(new Error("boom"));
const logger = createLogger();
await expect(checkWsl2CrashLoopRisk(logger)).resolves.toBeUndefined();
expect(logger.warn).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,85 @@
// Ollama plugin module implements wsl2 crash loop check behavior.
import { execFile } from "node:child_process";
import { access } from "node:fs/promises";
import { promisify } from "node:util";
import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry";
import { isWSL2Sync } from "openclaw/plugin-sdk/runtime-env";
const execFileAsync = promisify(execFile);
const SYSTEMCTL_TIMEOUT_MS = 5_000;
const WSL_CUDA_MARKERS = [
"/dev/dxg",
"/usr/lib/wsl/lib/nvidia-smi",
"/usr/lib/wsl/lib/libcuda.so.1",
"/usr/local/cuda",
];
export function parseSystemctlShowProperties(stdout: string): Map<string, string> {
const properties = new Map<string, string>();
for (const line of stdout.split(/\r?\n/u)) {
const separator = line.indexOf("=");
if (separator <= 0) {
continue;
}
properties.set(line.slice(0, separator), line.slice(separator + 1));
}
return properties;
}
export async function isOllamaEnabledWithRestartAlways(): Promise<boolean> {
try {
const { stdout } = await execFileAsync(
"systemctl",
["show", "ollama.service", "--property=UnitFileState,Restart", "--no-pager"],
{ timeout: SYSTEMCTL_TIMEOUT_MS },
);
const properties = parseSystemctlShowProperties(stdout);
return properties.get("UnitFileState") === "enabled" && properties.get("Restart") === "always";
} catch {
return false;
}
}
export async function hasWslCuda(): Promise<boolean> {
for (const marker of WSL_CUDA_MARKERS) {
try {
await access(marker);
return true;
} catch {
// Try the next cheap marker.
}
}
return false;
}
export async function checkWsl2CrashLoopRisk(logger: PluginLogger): Promise<void> {
try {
if (!isWSL2Sync()) {
return;
}
if (!(await isOllamaEnabledWithRestartAlways())) {
return;
}
if (!(await hasWslCuda())) {
return;
}
logger.warn(
[
"[ollama] WSL2 crash-loop risk: ollama.service is enabled with Restart=always and CUDA is visible.",
"On WSL2, GPU-backed Ollama can pin host memory while loading a model.",
"Hyper-V memory reclaim cannot always reclaim those pinned pages, so Windows can terminate and restart the WSL2 VM.",
"",
"Common evidence: repeated WSL2 reboots, high CPU in app.slice at startup, and SIGTERM from systemd rather than the Linux OOM killer.",
"See: https://github.com/ollama/ollama/issues/11317",
"",
"Mitigation:",
" 1. Disable autostart: sudo systemctl disable ollama",
" 2. Add [experimental] autoMemoryReclaim=disabled to %USERPROFILE%\\.wslconfig on Windows, then run wsl --shutdown",
" 3. Set OLLAMA_KEEP_ALIVE=5m in the Ollama service environment or start ollama serve manually when needed",
].join("\n"),
);
} catch {
// Advisory only: never break provider registration or model discovery.
}
}

View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -0,0 +1,27 @@
// Ollama API module exposes the plugin public contract.
import {
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-contract";
export function createOllamaWebSearchProvider(): WebSearchProviderPlugin {
return {
id: "ollama",
label: "Ollama Web Search",
hint: "Local Ollama host · requires ollama signin",
onboardingScopes: ["text-inference"],
requiresCredential: false,
envVars: [],
placeholder: "(run ollama signin)",
signupUrl: "https://ollama.com/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 110,
credentialPath: "",
...createWebSearchProviderContractFields({
credentialPath: "",
searchCredential: { type: "none" },
selectionPluginId: "ollama",
}),
createTool: () => null,
};
}

View File

@@ -0,0 +1,2 @@
// Ollama provider module implements model/runtime integration.
export { createOllamaWebSearchProvider } from "./src/web-search-provider.js";