重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

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

View file

@ -0,0 +1,34 @@
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,587 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
import plugin from "./index.js";
const promptAndConfigureOllamaMock = vi.hoisted(() =>
vi.fn(async () => ({
credential: "ollama-local",
config: {
models: {
providers: {
ollama: {
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
},
},
},
},
})),
);
const ensureOllamaModelPulledMock = vi.hoisted(() => vi.fn(async () => {}));
const buildOllamaProviderMock = vi.hoisted(() => vi.fn());
const createConfiguredOllamaStreamFnMock = vi.hoisted(() =>
vi.fn((_params: { model: unknown; providerBaseUrl?: string }) => ({}) as never),
);
vi.mock("./api.js", () => ({
promptAndConfigureOllama: promptAndConfigureOllamaMock,
ensureOllamaModelPulled: ensureOllamaModelPulledMock,
configureOllamaNonInteractive: vi.fn(),
buildOllamaProvider: buildOllamaProviderMock,
}));
vi.mock("./src/stream.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./src/stream.js")>();
return {
...actual,
createConfiguredOllamaStreamFn: createConfiguredOllamaStreamFnMock,
};
});
beforeEach(() => {
promptAndConfigureOllamaMock.mockClear();
ensureOllamaModelPulledMock.mockClear();
buildOllamaProviderMock.mockReset();
createConfiguredOllamaStreamFnMock.mockClear();
});
function registerProvider() {
return registerProviderWithPluginConfig({});
}
function registerProviderWithPluginConfig(pluginConfig: Record<string, unknown>) {
const registerProviderMock = vi.fn();
plugin.register(
createTestPluginApi({
id: "ollama",
name: "Ollama",
source: "test",
config: {},
pluginConfig,
runtime: {} as never,
registerProvider: registerProviderMock,
}),
);
expect(registerProviderMock).toHaveBeenCalledTimes(1);
return registerProviderMock.mock.calls[0]?.[0];
}
describe("ollama plugin", () => {
it("does not preselect a default model during provider auth setup", async () => {
const provider = registerProvider();
const result = await provider.auth[0].run({
config: {},
prompter: {} as never,
isRemote: false,
openUrl: vi.fn(async () => undefined),
});
expect(promptAndConfigureOllamaMock).toHaveBeenCalledWith({
cfg: {},
env: undefined,
opts: undefined,
prompter: {},
secretInputMode: undefined,
allowSecretRefPrompt: undefined,
});
expect(result.configPatch).toEqual({
models: {
providers: {
ollama: {
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
},
},
},
});
expect(result.defaultModel).toBeUndefined();
});
it("pulls the model the user actually selected", async () => {
const provider = registerProvider();
const config = {
models: {
providers: {
ollama: {
baseUrl: "http://127.0.0.1:11434",
models: [],
},
},
},
};
const prompter = {} as never;
await provider.onModelSelected?.({
config,
model: "ollama/gemma4",
prompter,
});
expect(ensureOllamaModelPulledMock).toHaveBeenCalledWith({
config,
model: "ollama/gemma4",
prompter,
});
});
it("skips ambient discovery when plugin discovery is disabled", async () => {
const provider = registerProviderWithPluginConfig({ discovery: { enabled: false } });
const result = await provider.discovery.run({
config: {},
env: {},
resolveProviderApiKey: () => ({ apiKey: "", discoveryApiKey: "" }),
} as never);
expect(result).toBeNull();
expect(buildOllamaProviderMock).not.toHaveBeenCalled();
});
it("keeps empty default-ish provider stubs quiet", async () => {
const provider = registerProvider();
buildOllamaProviderMock.mockResolvedValueOnce({
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
});
const result = await provider.discovery.run({
config: {
models: {
providers: {
ollama: {
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
},
},
},
},
env: { NODE_ENV: "development" },
resolveProviderApiKey: () => ({ apiKey: "" }),
} as never);
expect(result).toBeNull();
expect(buildOllamaProviderMock).toHaveBeenCalledWith("http://127.0.0.1:11434", {
quiet: true,
});
});
it("treats non-default baseUrl as explicit discovery config", async () => {
const provider = registerProvider();
buildOllamaProviderMock.mockResolvedValueOnce({
baseUrl: "http://remote-ollama:11434",
api: "ollama",
models: [],
});
const result = await provider.discovery.run({
config: {
models: {
providers: {
ollama: {
baseUrl: "http://remote-ollama:11434",
api: "ollama",
models: [],
},
},
},
},
env: { NODE_ENV: "development" },
resolveProviderApiKey: () => ({ apiKey: "" }),
} as never);
expect(result).toBeNull();
expect(buildOllamaProviderMock).toHaveBeenCalledWith("http://remote-ollama:11434", {
quiet: false,
});
});
it("keeps stored ollama-local marker auth on the quiet ambient path", async () => {
const provider = registerProvider();
buildOllamaProviderMock.mockResolvedValueOnce({
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
});
const result = await provider.discovery.run({
config: {},
env: { NODE_ENV: "development" },
resolveProviderApiKey: () => ({ apiKey: "ollama-local" }),
} as never);
expect(result).toMatchObject({
provider: {
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
apiKey: "ollama-local",
models: [],
},
});
expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, {
quiet: true,
});
});
it("does not mint synthetic auth for empty default-ish provider stubs", () => {
const provider = registerProvider();
const auth = provider.resolveSyntheticAuth?.({
providerConfig: {
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
},
});
expect(auth).toBeUndefined();
});
it("mints synthetic auth for non-default explicit ollama config", () => {
const provider = registerProvider();
const auth = provider.resolveSyntheticAuth?.({
providerConfig: {
baseUrl: "http://remote-ollama:11434",
api: "ollama",
models: [],
},
});
expect(auth).toEqual({
apiKey: "ollama-local",
source: "models.providers.ollama (synthetic local key)",
mode: "api-key",
});
});
it("wraps OpenAI-compatible payloads with num_ctx for Ollama compat routes", () => {
const provider = registerProvider();
let payloadSeen: Record<string, unknown> | undefined;
const baseStreamFn = vi.fn((_model, _context, options) => {
const payload: Record<string, unknown> = { options: { temperature: 0.1 } };
options?.onPayload?.(payload, _model);
payloadSeen = payload;
return {} as never;
});
const wrapped = provider.wrapStreamFn?.({
config: {
models: {
providers: {
ollama: {
api: "openai-completions",
baseUrl: "http://127.0.0.1:11434/v1",
models: [],
},
},
},
},
provider: "ollama",
modelId: "qwen3:32b",
model: {
api: "openai-completions",
provider: "ollama",
id: "qwen3:32b",
baseUrl: "http://127.0.0.1:11434/v1",
contextWindow: 202_752,
},
streamFn: baseStreamFn,
});
expect(typeof wrapped).toBe("function");
void wrapped?.({} as never, {} as never, {});
expect(baseStreamFn).toHaveBeenCalledTimes(1);
expect((payloadSeen?.options as Record<string, unknown> | undefined)?.num_ctx).toBe(202752);
});
it("declares streaming usage support for OpenAI-compatible Ollama routes", () => {
const provider = registerProvider();
expect(
provider.contributeResolvedModelCompat?.({
modelId: "qwen3:32b",
provider: "ollama",
model: {
api: "openai-completions",
provider: "ollama",
id: "qwen3:32b",
baseUrl: "http://127.0.0.1:11434/v1",
},
} as never),
).toEqual({ supportsUsageInStreaming: true });
expect(
provider.contributeResolvedModelCompat?.({
modelId: "qwen3:32b",
provider: "custom",
model: {
api: "openai-completions",
provider: "custom",
id: "qwen3:32b",
baseUrl: "https://proxy.example.com/v1",
},
} as never),
).toBeUndefined();
});
it("owns replay policy for OpenAI-compatible Ollama routes only", () => {
const provider = registerProvider();
expect(
provider.buildReplayPolicy?.({
provider: "ollama",
modelApi: "openai-completions",
modelId: "qwen3:32b",
} as never),
).toMatchObject({
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
});
expect(
provider.buildReplayPolicy?.({
provider: "ollama",
modelApi: "openai-responses",
modelId: "qwen3:32b",
} as never),
).toMatchObject({
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
applyAssistantFirstOrderingFix: false,
validateGeminiTurns: false,
validateAnthropicTurns: false,
});
expect(
provider.buildReplayPolicy?.({
provider: "ollama",
modelApi: "ollama",
modelId: "qwen3.5:9b",
} as never),
).toBeUndefined();
});
it("routes createStreamFn to the correct provider baseUrl for ollama2", () => {
const provider = registerProvider();
const config = {
models: {
providers: {
ollama: {
api: "ollama",
baseUrl: "http://127.0.0.1:11434",
models: [],
},
ollama2: {
api: "ollama",
baseUrl: "http://127.0.0.1:11435",
models: [],
},
},
},
};
const model = { id: "llama3.2", provider: "ollama2", baseUrl: undefined };
provider.createStreamFn?.({ config, model, provider: "ollama2" } as never);
expect(createConfiguredOllamaStreamFnMock).toHaveBeenCalledWith(
expect.objectContaining({ providerBaseUrl: "http://127.0.0.1:11435" }),
);
});
it("uses ollama provider baseUrl when provider is ollama (backward compat)", () => {
const provider = registerProvider();
const config = {
models: {
providers: {
ollama: {
api: "ollama",
baseUrl: "http://127.0.0.1:11434",
models: [],
},
ollama2: {
api: "ollama",
baseUrl: "http://127.0.0.1:11435",
models: [],
},
},
},
};
const model = { id: "llama3.2", provider: "ollama", baseUrl: undefined };
provider.createStreamFn?.({ config, model, provider: "ollama" } as never);
expect(createConfiguredOllamaStreamFnMock).toHaveBeenCalledWith(
expect.objectContaining({ providerBaseUrl: "http://127.0.0.1:11434" }),
);
});
it("wraps native Ollama payloads with top-level think=false when thinking is off", () => {
const provider = registerProvider();
let payloadSeen: Record<string, unknown> | undefined;
const baseStreamFn = vi.fn((_model, _context, options) => {
const payload: Record<string, unknown> = {
messages: [],
options: { num_ctx: 65536 },
stream: true,
};
options?.onPayload?.(payload, _model);
payloadSeen = payload;
return {} as never;
});
const wrapped = provider.wrapStreamFn?.({
config: {
models: {
providers: {
ollama: {
api: "ollama",
baseUrl: "http://127.0.0.1:11434",
models: [],
},
},
},
},
provider: "ollama",
modelId: "qwen3.5:9b",
thinkingLevel: "off",
model: {
api: "ollama",
provider: "ollama",
id: "qwen3.5:9b",
baseUrl: "http://127.0.0.1:11434",
contextWindow: 131_072,
},
streamFn: baseStreamFn,
});
expect(typeof wrapped).toBe("function");
void wrapped?.(
{
api: "ollama",
provider: "ollama",
id: "qwen3.5:9b",
} as never,
{} as never,
{},
);
expect(baseStreamFn).toHaveBeenCalledTimes(1);
expect(payloadSeen?.think).toBe(false);
expect((payloadSeen?.options as Record<string, unknown> | undefined)?.think).toBeUndefined();
});
it("wraps native Ollama payloads with top-level think=true when thinking is enabled", () => {
const provider = registerProvider();
let payloadSeen: Record<string, unknown> | undefined;
const baseStreamFn = vi.fn((_model, _context, options) => {
const payload: Record<string, unknown> = {
messages: [],
options: { num_ctx: 65536 },
stream: true,
};
options?.onPayload?.(payload, _model);
payloadSeen = payload;
return {} as never;
});
const wrapped = provider.wrapStreamFn?.({
config: {
models: {
providers: {
ollama: {
api: "ollama",
baseUrl: "http://127.0.0.1:11434",
models: [],
},
},
},
},
provider: "ollama",
modelId: "qwen3.5:9b",
thinkingLevel: "low",
model: {
api: "ollama",
provider: "ollama",
id: "qwen3.5:9b",
baseUrl: "http://127.0.0.1:11434",
contextWindow: 131_072,
},
streamFn: baseStreamFn,
});
expect(typeof wrapped).toBe("function");
void wrapped?.(
{
api: "ollama",
provider: "ollama",
id: "qwen3.5:9b",
} as never,
{} as never,
{},
);
expect(baseStreamFn).toHaveBeenCalledTimes(1);
expect(payloadSeen?.think).toBe(true);
expect((payloadSeen?.options as Record<string, unknown> | undefined)?.think).toBeUndefined();
});
it("does not set think param when thinkingLevel is undefined", () => {
const provider = registerProvider();
let payloadSeen: Record<string, unknown> | undefined;
const baseStreamFn = vi.fn((_model, _context, options) => {
const payload: Record<string, unknown> = {
messages: [],
options: { num_ctx: 65536 },
stream: true,
};
options?.onPayload?.(payload, _model);
payloadSeen = payload;
return {} as never;
});
const wrapped = provider.wrapStreamFn?.({
config: {
models: {
providers: {
ollama: {
api: "ollama",
baseUrl: "http://127.0.0.1:11434",
models: [],
},
},
},
},
provider: "ollama",
modelId: "qwen3.5:9b",
thinkingLevel: undefined,
model: {
api: "ollama",
provider: "ollama",
id: "qwen3.5:9b",
baseUrl: "http://127.0.0.1:11434",
contextWindow: 131_072,
},
streamFn: baseStreamFn,
});
expect(typeof wrapped).toBe("function");
void wrapped?.(
{
api: "ollama",
provider: "ollama",
id: "qwen3.5:9b",
} as never,
{} as never,
{},
);
expect(baseStreamFn).toHaveBeenCalledTimes(1);
expect(payloadSeen?.think).toBeUndefined();
});
});

View file

@ -0,0 +1,303 @@
import {
definePluginEntry,
type OpenClawPluginApi,
type ProviderAuthContext,
type ProviderAuthMethodNonInteractiveContext,
type ProviderAuthResult,
type ProviderDiscoveryContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { buildApiKeyCredential } from "openclaw/plugin-sdk/provider-auth";
import {
OPENAI_COMPATIBLE_REPLAY_HOOKS,
type ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeOptionalString, readStringValue } from "openclaw/plugin-sdk/text-runtime";
import {
buildOllamaProvider,
configureOllamaNonInteractive,
ensureOllamaModelPulled,
promptAndConfigureOllama,
} from "./api.js";
import { OLLAMA_DEFAULT_BASE_URL } from "./src/defaults.js";
import {
DEFAULT_OLLAMA_EMBEDDING_MODEL,
createOllamaEmbeddingProvider,
} from "./src/embedding-provider.js";
import { ollamaMemoryEmbeddingProviderAdapter } from "./src/memory-embedding-adapter.js";
import { resolveOllamaApiBase } from "./src/provider-models.js";
import {
createConfiguredOllamaCompatStreamWrapper,
createConfiguredOllamaStreamFn,
isOllamaCompatProvider,
resolveConfiguredOllamaProviderConfig,
} from "./src/stream.js";
import { createOllamaWebSearchProvider } from "./src/web-search-provider.js";
const PROVIDER_ID = "ollama";
const DEFAULT_API_KEY = "ollama-local";
type OllamaPluginConfig = {
discovery?: {
enabled?: boolean;
};
};
type OllamaProviderLikeConfig = ModelProviderConfig;
function resolveOllamaDiscoveryApiKey(params: {
env: NodeJS.ProcessEnv;
explicitApiKey?: string;
resolvedApiKey?: string;
}): string {
const envApiKey = params.env.OLLAMA_API_KEY?.trim() ? "OLLAMA_API_KEY" : undefined;
const explicitApiKey = normalizeOptionalString(params.explicitApiKey);
const resolvedApiKey = normalizeOptionalString(params.resolvedApiKey);
return envApiKey ?? explicitApiKey ?? resolvedApiKey ?? DEFAULT_API_KEY;
}
function shouldSkipAmbientOllamaDiscovery(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.VITEST) || env.NODE_ENV === "test";
}
function hasMeaningfulExplicitOllamaConfig(providerConfig?: OllamaProviderLikeConfig): boolean {
if (!providerConfig) {
return false;
}
if (Array.isArray(providerConfig.models) && providerConfig.models.length > 0) {
return true;
}
if (typeof providerConfig.baseUrl === "string" && providerConfig.baseUrl.trim()) {
return resolveOllamaApiBase(providerConfig.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;
}
function usesOllamaOpenAICompatTransport(model: {
api?: unknown;
provider?: unknown;
baseUrl?: unknown;
}): boolean {
return (
model.api === "openai-completions" &&
isOllamaCompatProvider({
provider: typeof model.provider === "string" ? model.provider : undefined,
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
api: "openai-completions",
})
);
}
export default definePluginEntry({
id: "ollama",
name: "Ollama Provider",
description: "Bundled Ollama provider plugin",
register(api: OpenClawPluginApi) {
api.registerMemoryEmbeddingProvider(ollamaMemoryEmbeddingProviderAdapter);
const pluginConfig = (api.pluginConfig ?? {}) as OllamaPluginConfig;
api.registerWebSearchProvider(createOllamaWebSearchProvider());
api.registerProvider({
id: 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(
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,
});
},
},
],
discovery: {
order: "late",
run: async (ctx: ProviderDiscoveryContext) => {
const explicit = ctx.config.models?.providers?.ollama;
const hasExplicitModels = Array.isArray(explicit?.models) && explicit.models.length > 0;
const hasMeaningfulExplicitConfig = hasMeaningfulExplicitOllamaConfig(explicit);
const discoveryEnabled =
pluginConfig.discovery?.enabled ?? ctx.config.models?.ollamaDiscovery?.enabled;
if (!hasExplicitModels && discoveryEnabled === false) {
return null;
}
const ollamaKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
const hasRealOllamaKey =
typeof ollamaKey === "string" &&
ollamaKey.trim().length > 0 &&
ollamaKey.trim() !== DEFAULT_API_KEY;
const explicitApiKey = readStringValue(explicit?.apiKey);
if (hasExplicitModels && explicit) {
return {
provider: {
...explicit,
baseUrl:
typeof explicit.baseUrl === "string" && explicit.baseUrl.trim()
? resolveOllamaApiBase(explicit.baseUrl)
: OLLAMA_DEFAULT_BASE_URL,
api: explicit.api ?? "ollama",
apiKey: resolveOllamaDiscoveryApiKey({
env: ctx.env,
explicitApiKey,
resolvedApiKey: ollamaKey,
}),
},
};
}
if (
!hasRealOllamaKey &&
!hasMeaningfulExplicitConfig &&
shouldSkipAmbientOllamaDiscovery(ctx.env)
) {
return null;
}
const provider = await buildOllamaProvider(explicit?.baseUrl, {
quiet: !hasRealOllamaKey && !hasMeaningfulExplicitConfig,
});
if (provider.models.length === 0 && !ollamaKey && !explicit?.apiKey) {
return null;
}
return {
provider: {
...provider,
apiKey: resolveOllamaDiscoveryApiKey({
env: ctx.env,
explicitApiKey,
resolvedApiKey: ollamaKey,
}),
},
};
},
},
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 }) => {
return createConfiguredOllamaStreamFn({
model,
providerBaseUrl: resolveConfiguredOllamaProviderConfig({ config, providerId: provider })
?.baseUrl,
});
},
...OPENAI_COMPATIBLE_REPLAY_HOOKS,
contributeResolvedModelCompat: ({ model }) =>
usesOllamaOpenAICompatTransport(model) ? { supportsUsageInStreaming: true } : undefined,
resolveReasoningOutputMode: () => "native",
wrapStreamFn: createConfiguredOllamaCompatStreamWrapper,
createEmbeddingProvider: async ({ config, model, remote }) => {
const { provider, client } = await createOllamaEmbeddingProvider({
config,
remote,
model: model || DEFAULT_OLLAMA_EMBEDDING_MODEL,
});
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: ({ providerConfig }) => {
if (!hasMeaningfulExplicitOllamaConfig(providerConfig)) {
return undefined;
}
return {
apiKey: DEFAULT_API_KEY,
source: "models.providers.ollama (synthetic local key)",
mode: "api-key",
};
},
shouldDeferSyntheticProfileAuth: ({ resolvedApiKey }) =>
resolvedApiKey?.trim() === DEFAULT_API_KEY,
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,50 @@
{
"id": "ollama",
"enabledByDefault": true,
"providers": ["ollama"],
"providerDiscoveryEntry": "./provider-discovery.ts",
"syntheticAuthRefs": ["ollama"],
"nonSecretAuthMarkers": ["ollama-local"],
"providerAuthEnvVars": {
"ollama": ["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"
}
],
"contracts": {
"memoryEmbeddingProviders": ["ollama"],
"webSearchProviders": ["ollama"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"discovery": {
"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."
}
}
}

View file

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

View file

@ -0,0 +1,7 @@
import { describePluginRegistrationContract } from "../../test/helpers/plugins/plugin-registration-contract.js";
describePluginRegistrationContract({
pluginId: "ollama",
providerIds: ["ollama"],
webSearchProviderIds: ["ollama"],
});

View file

@ -0,0 +1,30 @@
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/text-runtime",
"openclaw/plugin-sdk/plugin-entry",
]) {
expect(source, `provider discovery must not import ${forbidden}`).not.toContain(forbidden);
}
});
});

View file

@ -0,0 +1,476 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-onboard";
import { type OpenClawConfig, withFetchPreconnect } from "openclaw/plugin-sdk/testing";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ollamaProviderDiscovery } from "./provider-discovery.js";
const OLLAMA_LOCAL_AUTH_MARKER = "ollama-local";
afterEach(() => {
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 expectDiscoveryCallCounts = (
fetchMock: ReturnType<typeof vi.fn>,
params: { tags: number; show: number },
) => {
const urls = fetchCallUrls(fetchMock);
expect(urls.filter((url) => url.endsWith("/api/tags"))).toHaveLength(params.tags);
expect(urls.filter((url) => url.endsWith("/api/show"))).toHaveLength(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 }) {
const env: NodeJS.ProcessEnv = {
...process.env,
VITEST: "1",
NODE_ENV: "test",
...params.env,
};
const result = await ollamaProviderDiscovery.discovery.run({
config: params.config ?? {},
agentDir: createAgentDir(),
env,
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 tagsResponse = (names: string[]) => ({
ok: true,
json: async () => ({ models: names.map((name) => createTagModel(name)) }),
});
const notFoundJsonResponse = () => ({
ok: false,
status: 404,
json: async () => ({}),
});
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 = vi.fn(async (input: unknown) => {
const url = String(input);
if (url.endsWith("/api/tags")) {
return tagsResponse([]);
}
return notFoundJsonResponse();
});
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
await withOllamaApiKey(async () => {
const provider = await runOllamaCatalog({});
expect(provider).toBeDefined();
expect(provider?.apiKey).toBe("OLLAMA_API_KEY");
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 on implicit provider injection", 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));
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(fetchCallUrls(fetchMock).filter((url) => url.endsWith("/api/tags"))).toHaveLength(1);
// Native API strips /v1 suffix via resolveOllamaApiBase()
expect(provider?.baseUrl).toBe("http://192.168.20.14:11434");
});
});
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 {
ok: true,
json: async () => ({ model_info: { "qwen3.context_length": 131072 } }),
};
}
if (parsed.name === "llama3.3:70b") {
return {
ok: true,
json: async () => ({ 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 {
ok: true,
json: async () => ({ model_info: {} }),
};
}
return notFoundJsonResponse();
});
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
const provider = await runOllamaCatalog({
env: { 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(
warnSpy.mock.calls.filter(([message]) => String(message).includes("Ollama")).length,
).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 {
ok: false,
status: 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 {
ok: true,
json: async () => ({ models: manyModels }),
};
}
return {
ok: true,
json: async () => ({ 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 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");
expect(provider?.api).toBe("openai-completions");
expect(fetchMock).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,199 @@
import type { ProviderCatalogContext } from "openclaw/plugin-sdk/provider-catalog-shared";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { OLLAMA_DEFAULT_BASE_URL } from "./src/defaults.js";
import {
buildOllamaModelDefinition,
enrichOllamaModelsWithContext,
fetchOllamaModels,
resolveOllamaApiBase,
} from "./src/provider-models.js";
const PROVIDER_ID = "ollama";
const DEFAULT_API_KEY = "ollama-local";
const OLLAMA_CONTEXT_ENRICH_LIMIT = 200;
type OllamaPluginConfig = {
discovery?: {
enabled?: boolean;
};
};
type OllamaProviderLikeConfig = ModelProviderConfig;
type OllamaProviderPlugin = {
id: string;
label: string;
docsPath: string;
envVars: string[];
auth: [];
discovery: {
order: "late";
run: (ctx: ProviderCatalogContext) => ReturnType<typeof runOllamaDiscovery>;
};
};
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
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 resolveOllamaDiscoveryApiKey(params: {
env: NodeJS.ProcessEnv;
explicitApiKey?: string;
resolvedApiKey?: string;
}): string {
const envApiKey = params.env.OLLAMA_API_KEY?.trim() ? "OLLAMA_API_KEY" : undefined;
return envApiKey ?? params.explicitApiKey ?? params.resolvedApiKey ?? DEFAULT_API_KEY;
}
function shouldSkipAmbientOllamaDiscovery(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.VITEST) || env.NODE_ENV === "test";
}
function hasMeaningfulExplicitOllamaConfig(
providerConfig: OllamaProviderLikeConfig | undefined,
): boolean {
if (!providerConfig) {
return false;
}
if (Array.isArray(providerConfig.models) && providerConfig.models.length > 0) {
return true;
}
if (typeof providerConfig.baseUrl === "string" && providerConfig.baseUrl.trim()) {
return resolveOllamaApiBase(providerConfig.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;
}
async function buildOllamaProvider(
configuredBaseUrl?: string,
opts?: { quiet?: boolean },
): Promise<ModelProviderConfig> {
const apiBase = resolveOllamaApiBase(configuredBaseUrl);
const { reachable, models } = await fetchOllamaModels(apiBase);
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),
);
return {
baseUrl: apiBase,
api: "ollama",
models: discovered.map((model) =>
buildOllamaModelDefinition(model.name, model.contextWindow, model.capabilities),
),
};
}
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) {
const pluginConfig = resolveOllamaPluginConfig(ctx);
const explicit = ctx.config.models?.providers?.ollama;
const hasExplicitModels = Array.isArray(explicit?.models) && explicit.models.length > 0;
const hasMeaningfulExplicitConfig = hasMeaningfulExplicitOllamaConfig(explicit);
const discoveryEnabled =
pluginConfig.discovery?.enabled ?? ctx.config.models?.ollamaDiscovery?.enabled;
if (!hasExplicitModels && discoveryEnabled === false) {
return null;
}
const ollamaKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
const hasRealOllamaKey =
typeof ollamaKey === "string" &&
ollamaKey.trim().length > 0 &&
ollamaKey.trim() !== DEFAULT_API_KEY;
const explicitApiKey = readStringValue(explicit?.apiKey);
if (hasExplicitModels && explicit) {
return {
provider: {
...explicit,
baseUrl:
typeof explicit.baseUrl === "string" && explicit.baseUrl.trim()
? resolveOllamaApiBase(explicit.baseUrl)
: OLLAMA_DEFAULT_BASE_URL,
api: explicit.api ?? "ollama",
apiKey: resolveOllamaDiscoveryApiKey({
env: ctx.env,
explicitApiKey,
resolvedApiKey: ollamaKey,
}),
},
};
}
if (
!hasRealOllamaKey &&
!hasMeaningfulExplicitConfig &&
shouldSkipAmbientOllamaDiscovery(ctx.env)
) {
return null;
}
const provider = await buildOllamaProvider(explicit?.baseUrl, {
quiet: !hasRealOllamaKey && !hasMeaningfulExplicitConfig,
});
if (provider.models?.length === 0 && !ollamaKey && !explicit?.apiKey) {
return null;
}
return {
provider: {
...provider,
apiKey: resolveOllamaDiscoveryApiKey({
env: ctx.env,
explicitApiKey,
resolvedApiKey: ollamaKey,
}),
},
};
}
export const ollamaProviderDiscovery: OllamaProviderPlugin = {
id: PROVIDER_ID,
label: "Ollama",
docsPath: "/providers/ollama",
envVars: ["OLLAMA_API_KEY"],
auth: [],
discovery: {
order: "late",
run: runOllamaDiscovery,
},
};
export default ollamaProviderDiscovery;

View file

@ -0,0 +1,22 @@
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,14 @@
export const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434";
export const OLLAMA_CLOUD_BASE_URL = "https://ollama.com";
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";
export const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text";

View file

@ -0,0 +1,146 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(async ({ init, url }: { init?: RequestInit; url: string }) => ({
response: await fetch(url, init),
release: async () => {},
})),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
formatErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)),
}));
let createOllamaEmbeddingProvider: typeof import("./embedding-provider.js").createOllamaEmbeddingProvider;
beforeAll(async () => {
({ createOllamaEmbeddingProvider } = await import("./embedding-provider.js"));
});
beforeEach(() => {
fetchWithSsrFGuardMock.mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
function mockEmbeddingFetch(embedding: number[]) {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ embedding }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
describe("ollama embedding provider", () => {
it("calls /api/embeddings and returns normalized vectors", async () => {
const fetchMock = mockEmbeddingFetch([3, 4]);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
const vector = await provider.embedQuery("hi");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(vector[0]).toBeCloseTo(0.6, 5);
expect(vector[1]).toBeCloseTo(0.8, 5);
});
it("resolves configured base URL, API key, and headers", 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");
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:11434/api/embeddings",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Content-Type": "application/json",
Authorization: "Bearer ollama-local",
"X-Provider-Header": "provider",
}),
}),
);
});
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");
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:11434/api/embeddings",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer ollama-env",
}),
}),
);
});
});

View file

@ -0,0 +1,207 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
import { normalizeOptionalSecretInput } from "openclaw/plugin-sdk/provider-auth";
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
} from "openclaw/plugin-sdk/secret-input";
import {
fetchWithSsrFGuard,
formatErrorMessage,
type SsrFPolicy,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { resolveOllamaApiBase } from "./provider-models.js";
export type OllamaEmbeddingProvider = {
id: string;
model: string;
maxInputTokens?: number;
embedQuery: (text: string) => Promise<number[]>;
embedBatch: (texts: string[]) => 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;
embedBatch: (texts: string[]) => Promise<number[][]>;
};
type OllamaEmbeddingClientConfig = Omit<OllamaEmbeddingClient, "embedBatch">;
export const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text";
function sanitizeAndNormalizeEmbedding(vec: number[]): number[] {
const sanitized = vec.map((value) => (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);
}
function buildRemoteBaseUrlPolicy(baseUrl: string): SsrFPolicy | undefined {
const trimmed = baseUrl.trim();
if (!trimmed) {
return undefined;
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return undefined;
}
return { allowedHostnames: [parsed.hostname] };
} catch {
return undefined;
}
}
async function withRemoteHttpResponse<T>(params: {
url: string;
init?: RequestInit;
ssrfPolicy?: SsrFPolicy;
onResponse: (response: Response) => Promise<T>;
}): Promise<T> {
const { response, release } = await fetchWithSsrFGuard({
url: params.url,
init: params.init,
policy: params.ssrfPolicy,
auditContext: "memory-remote",
});
try {
return await params.onResponse(response);
} finally {
await release();
}
}
function normalizeEmbeddingModel(model: string): string {
const trimmed = model.trim();
if (!trimmed) {
return DEFAULT_OLLAMA_EMBEDDING_MODEL;
}
return trimmed.startsWith("ollama/") ? trimmed.slice("ollama/".length) : trimmed;
}
function resolveMemorySecretInputString(params: {
value: unknown;
path: string;
}): string | undefined {
if (!hasConfiguredSecretInput(params.value)) {
return undefined;
}
return normalizeResolvedSecretInputString({
value: params.value,
path: params.path,
});
}
function resolveOllamaApiKey(options: OllamaEmbeddingOptions): string | undefined {
const remoteApiKey = resolveMemorySecretInputString({
value: options.remote?.apiKey,
path: "agents.*.memorySearch.remote.apiKey",
});
if (remoteApiKey) {
return remoteApiKey;
}
const providerApiKey = normalizeOptionalSecretInput(
options.config.models?.providers?.ollama?.apiKey,
);
if (providerApiKey) {
return providerApiKey;
}
return resolveEnvApiKey("ollama")?.apiKey;
}
function resolveOllamaEmbeddingClient(
options: OllamaEmbeddingOptions,
): OllamaEmbeddingClientConfig {
const providerConfig = options.config.models?.providers?.ollama;
const rawBaseUrl = options.remote?.baseUrl?.trim() || providerConfig?.baseUrl?.trim();
const baseUrl = resolveOllamaApiBase(rawBaseUrl);
const model = normalizeEmbeddingModel(options.model);
const headerOverrides = Object.assign({}, providerConfig?.headers, options.remote?.headers);
const headers: Record<string, string> = {
"Content-Type": "application/json",
...headerOverrides,
};
const apiKey = resolveOllamaApiKey(options);
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
return {
baseUrl,
headers,
ssrfPolicy: buildRemoteBaseUrlPolicy(baseUrl),
model,
};
}
export async function createOllamaEmbeddingProvider(
options: OllamaEmbeddingOptions,
): Promise<{ provider: OllamaEmbeddingProvider; client: OllamaEmbeddingClient }> {
const client = resolveOllamaEmbeddingClient(options);
const embedUrl = `${client.baseUrl.replace(/\/$/, "")}/api/embeddings`;
const embedOne = async (text: string): Promise<number[]> => {
const json = await withRemoteHttpResponse({
url: embedUrl,
ssrfPolicy: client.ssrfPolicy,
init: {
method: "POST",
headers: client.headers,
body: JSON.stringify({ model: client.model, prompt: text }),
},
onResponse: async (response) => {
if (!response.ok) {
throw new Error(`Ollama embeddings HTTP ${response.status}: ${await response.text()}`);
}
return (await response.json()) as { embedding?: number[] };
},
});
if (!Array.isArray(json.embedding)) {
throw new Error("Ollama embeddings response missing embedding[]");
}
return sanitizeAndNormalizeEmbedding(json.embedding);
};
const provider: OllamaEmbeddingProvider = {
id: "ollama",
model: client.model,
embedQuery: embedOne,
embedBatch: async (texts) => {
return await Promise.all(texts.map(embedOne));
},
};
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,29 @@
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",
cacheKeyData: {
provider: "ollama",
model: client.model,
},
},
};
},
};

View file

@ -0,0 +1,143 @@
const MAX_SAFE_INTEGER_ABS_STR = String(Number.MAX_SAFE_INTEGER);
function isAsciiDigit(ch: string | undefined): boolean {
return ch !== undefined && ch >= "0" && ch <= "9";
}
function parseJsonNumberToken(
input: string,
start: number,
): { token: string; end: number; isInteger: boolean } | null {
let idx = start;
if (input[idx] === "-") {
idx += 1;
}
if (idx >= input.length) {
return null;
}
if (input[idx] === "0") {
idx += 1;
} else if (isAsciiDigit(input[idx]) && input[idx] !== "0") {
while (isAsciiDigit(input[idx])) {
idx += 1;
}
} else {
return null;
}
let isInteger = true;
if (input[idx] === ".") {
isInteger = false;
idx += 1;
if (!isAsciiDigit(input[idx])) {
return null;
}
while (isAsciiDigit(input[idx])) {
idx += 1;
}
}
if (input[idx] === "e" || input[idx] === "E") {
isInteger = false;
idx += 1;
if (input[idx] === "+" || input[idx] === "-") {
idx += 1;
}
if (!isAsciiDigit(input[idx])) {
return null;
}
while (isAsciiDigit(input[idx])) {
idx += 1;
}
}
return {
token: input.slice(start, idx),
end: idx,
isInteger,
};
}
function isUnsafeIntegerLiteral(token: string): boolean {
const digits = token[0] === "-" ? token.slice(1) : token;
if (digits.length < MAX_SAFE_INTEGER_ABS_STR.length) {
return false;
}
if (digits.length > MAX_SAFE_INTEGER_ABS_STR.length) {
return true;
}
return digits > MAX_SAFE_INTEGER_ABS_STR;
}
function quoteUnsafeIntegerLiterals(input: string): string {
let out = "";
let inString = false;
let escaped = false;
let idx = 0;
while (idx < input.length) {
const ch = input[idx] ?? "";
if (inString) {
out += ch;
if (escaped) {
escaped = false;
} else if (ch === "\\") {
escaped = true;
} else if (ch === '"') {
inString = false;
}
idx += 1;
continue;
}
if (ch === '"') {
inString = true;
out += ch;
idx += 1;
continue;
}
if (ch === "-" || isAsciiDigit(ch)) {
const parsed = parseJsonNumberToken(input, idx);
if (parsed) {
if (parsed.isInteger && isUnsafeIntegerLiteral(parsed.token)) {
out += `"${parsed.token}"`;
} else {
out += parsed.token;
}
idx = parsed.end;
continue;
}
}
out += ch;
idx += 1;
}
return out;
}
export function parseJsonPreservingUnsafeIntegers(input: string): unknown {
return JSON.parse(quoteUnsafeIntegerLiterals(input)) as unknown;
}
export function parseJsonObjectPreservingUnsafeIntegers(
value: unknown,
): Record<string, unknown> | null {
if (typeof value === "string") {
try {
const parsed = parseJsonPreservingUnsafeIntegers(value);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
return null;
}
return null;
}
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return null;
}

View file

@ -0,0 +1,41 @@
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,215 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { jsonResponse, requestBodyText, requestUrl } from "../../../src/test-helpers/http.js";
import {
buildOllamaModelDefinition,
enrichOllamaModelsWithContext,
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 },
]);
});
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",
]);
expect(visionModel.input).toEqual(["text", "image"]);
const textModel = buildOllamaModelDefinition("glm-5.1:cloud", 202752, ["completion", "tools"]);
expect(textModel.input).toEqual(["text"]);
const noCapabilities = buildOllamaModelDefinition("unknown-model", 65536);
expect(noCapabilities.input).toEqual(["text"]);
});
});

View file

@ -0,0 +1,260 @@
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,
} from "./defaults.js";
export type OllamaTagModel = {
name: string;
modified_at?: string;
size?: number;
digest?: string;
remote_host?: string;
details?: {
family?: string;
parameter_size?: string;
};
};
export type OllamaTagsResponse = {
models?: OllamaTagModel[];
};
export type OllamaModelWithContext = OllamaTagModel & {
contextWindow?: number;
capabilities?: string[];
};
const OLLAMA_SHOW_CONCURRENCY = 8;
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">,
): string | undefined {
const version = model.digest?.trim() || model.modified_at?.trim();
if (!version) {
return undefined;
}
return `${resolveOllamaApiBase(apiBase)}|${model.name}|${version}`;
}
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 async function queryOllamaModelShowInfo(
apiBase: string,
modelName: string,
): Promise<OllamaModelShowInfo> {
const normalizedApiBase = resolveOllamaApiBase(apiBase);
try {
const { response, release } = await fetchWithSsrFGuard({
url: `${normalizedApiBase}/api/show`,
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
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 response.json()) as {
model_info?: Record<string, unknown>;
capabilities?: unknown;
};
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 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">,
): Promise<OllamaModelShowInfo> {
const normalizedApiBase = resolveOllamaApiBase(apiBase);
const cacheKey = buildOllamaModelShowCacheKey(normalizedApiBase, model);
if (!cacheKey) {
return await queryOllamaModelShowInfo(normalizedApiBase, model.name);
}
const cached = ollamaModelShowInfoCache.get(cacheKey);
if (cached) {
return await cached;
}
const pending = queryOllamaModelShowInfo(normalizedApiBase, model.name).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?: { 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);
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);
}
export function buildOllamaModelDefinition(
modelId: string,
contextWindow?: number,
capabilities?: string[],
): ModelDefinitionConfig {
const hasVision = capabilities?.includes("vision") ?? false;
const input: ("text" | "image")[] = hasVision ? ["text", "image"] : ["text"];
return {
id: modelId,
name: modelId,
reasoning: isReasoningModelHeuristic(modelId),
input,
cost: OLLAMA_DEFAULT_COST,
contextWindow: contextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW,
maxTokens: OLLAMA_DEFAULT_MAX_TOKENS,
};
}
export async function fetchOllamaModels(
baseUrl: string,
): Promise<{ reachable: boolean; models: OllamaTagModel[] }> {
try {
const apiBase = resolveOllamaApiBase(baseUrl);
const { response, release } = await fetchWithSsrFGuard({
url: `${apiBase}/api/tags`,
init: {
signal: AbortSignal.timeout(5000),
},
policy: buildOllamaBaseUrlSsrFPolicy(apiBase),
auditContext: "ollama-provider-models.tags",
});
try {
if (!response.ok) {
return { reachable: true, models: [] };
}
const data = (await response.json()) as OllamaTagsResponse;
const models = (data.models ?? []).filter((m) => m.name);
return { reachable: true, models };
} finally {
await release();
}
} catch {
return { reachable: false, models: [] };
}
}
export function resetOllamaModelShowInfoCacheForTest(): void {
ollamaModelShowInfoCache.clear();
}

View file

@ -0,0 +1,537 @@
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { WizardPrompter } from "openclaw/plugin-sdk/setup";
import { afterEach, describe, expect, it, vi } from "vitest";
import { jsonResponse, requestBodyText, requestUrl } from "../../../src/test-helpers/http.js";
import { resetOllamaModelShowInfoCacheForTest } from "./provider-models.js";
import {
configureOllamaNonInteractive,
ensureOllamaModelPulled,
promptAndConfigureOllama,
} from "./setup.js";
const upsertAuthProfileWithLock = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("openclaw/plugin-sdk/provider-auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/provider-auth")>();
return {
...actual,
upsertAuthProfileWithLock,
};
});
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 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();
upsertAuthProfileWithLock.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("puts suggested cloud model first in cloud mode", async () => {
const prompter = createCloudPrompter();
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();
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",
"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("cloud mode does not hit local Ollama endpoints", async () => {
const prompter = createCloudPrompter();
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await promptAndConfigureOllama({
cfg: {},
env: {},
prompter,
allowSecretRefPrompt: false,
});
expect(fetchMock).not.toHaveBeenCalled();
});
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).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0]?.[0]).toContain("/api/tags");
expect(fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("/api/me"))).toBe(
false,
);
});
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;
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 seeds the hosted cloud model list", async () => {
const prompter = createCloudPrompter();
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"]);
expect(models?.find((model) => model.id === "kimi-k2.5:cloud")?.input).toEqual([
"text",
"image",
]);
});
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 () => {
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(fetchMock.mock.calls[1][0]).toContain("/api/pull");
});
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 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 = fetchMock.mock.calls[1]?.[1];
expect(JSON.parse(requestBodyText(pullRequest?.body))).toEqual({ name: "llama3.2:latest" });
expect(result.agents?.defaults?.model).toEqual(
expect.objectContaining({ primary: "ollama/llama3.2: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(
expect.objectContaining({ 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(
expect.stringContaining("Ollama could not be reached at http://127.0.0.1:11435."),
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(result).toBe(nextConfig);
});
});

View file

@ -0,0 +1,654 @@
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 { 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/text-runtime";
import {
OLLAMA_CLOUD_BASE_URL,
OLLAMA_DEFAULT_BASE_URL,
OLLAMA_DEFAULT_MODEL,
} from "./defaults.js";
import {
buildOllamaBaseUrlSsrFPolicy,
buildOllamaModelDefinition,
enrichOllamaModelsWithContext,
fetchOllamaModels,
resolveOllamaApiBase,
type OllamaModelWithContext,
} from "./provider-models.js";
const OLLAMA_SUGGESTED_MODELS_LOCAL = [OLLAMA_DEFAULT_MODEL];
const OLLAMA_SUGGESTED_MODELS_CLOUD = ["kimi-k2.5:cloud", "minimax-m2.7:cloud", "glm-5.1:cloud"];
const OLLAMA_CONTEXT_ENRICH_LIMIT = 200;
type OllamaSetupOptions = {
customBaseUrl?: string;
customModelId?: string;
};
type OllamaSetupResult = {
config: OpenClawConfig;
credential: SecretInput;
credentialMode?: SecretInputMode;
};
type ProviderConfig = {
baseUrl: string;
api: "ollama";
models: ReturnType<typeof buildOllamaModelDefinition>[];
};
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 response.json()) as { signin_url?: string };
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 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();
try {
const { response, release } = await fetchWithSsrFGuard({
url: `${baseUrl}/api/pull`,
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: modelName }),
},
policy: buildOllamaBaseUrlSsrFPolicy(baseUrl),
auditContext: "ollama-setup.pull",
});
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 reader.read();
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}` };
}
}
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 }> {
const captured: { credential?: SecretInput; credentialMode?: SecretInputMode } = {};
const optionToken = normalizeOptionalSecretInput(params.opts?.ollamaApiKey);
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 };
}
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 mergeUniqueModelNames(...groups: string[][]): string[] {
const seen = new Set<string>();
const merged: string[] = [];
for (const group of groups) {
for (const name of group) {
if (seen.has(name)) {
continue;
}
seen.add(name);
merged.push(name);
}
}
return merged;
}
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): Promise<string> {
const baseUrlRaw = await prompter.text({
message: "Ollama base URL",
initialValue: OLLAMA_DEFAULT_BASE_URL,
placeholder: OLLAMA_DEFAULT_BASE_URL,
validate: (value) => (value?.trim() ? undefined : "Required"),
});
return resolveOllamaApiBase((baseUrlRaw ?? "").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;
}): Promise<OllamaSetupResult> {
const baseUrl = await promptForOllamaBaseUrl(params.prompter);
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 buildOllamaProvider(
configuredBaseUrl?: string,
opts?: { quiet?: boolean },
): Promise<ProviderConfig> {
const apiBase = resolveOllamaApiBase(configuredBaseUrl);
const { reachable, models } = await fetchOllamaModels(apiBase);
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),
);
return {
baseUrl: apiBase,
api: "ollama",
models: discovered.map((model) =>
buildOllamaModelDefinition(model.name, model.contextWindow, model.capabilities),
),
};
}
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 } = await promptForOllamaCloudCredential({
cfg: params.cfg,
env: params.env,
opts: params.opts,
prompter: params.prompter,
secretInputMode: params.secretInputMode,
allowSecretRefPrompt: params.allowSecretRefPrompt,
});
return {
credential,
credentialMode,
config: applyOllamaProviderConfig(
params.cfg,
OLLAMA_CLOUD_BASE_URL,
OLLAMA_SUGGESTED_MODELS_CLOUD,
undefined,
credential,
),
};
}
return await promptAndConfigureHostBackedOllama({
cfg: params.cfg,
mode,
prompter: params.prompter,
});
}
export async function configureOllamaNonInteractive(params: {
nextConfig: OpenClawConfig;
opts: OllamaSetupOptions;
runtime: RuntimeEnv;
agentDir?: string;
}): Promise<OpenClawConfig> {
const baseUrl = resolveOllamaApiBase(
(params.opts.customBaseUrl?.trim() || OLLAMA_DEFAULT_BASE_URL).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 = [
...OLLAMA_SUGGESTED_MODELS_LOCAL,
...modelNames.filter((name) => !OLLAMA_SUGGESTED_MODELS_LOCAL.includes(name)),
];
const requestedDefaultModelId = explicitModel ?? OLLAMA_SUGGESTED_MODELS_LOCAL[0];
const availableModelNames = new Set(modelNames);
const requestedCloudModel = isOllamaCloudModel(requestedDefaultModelId);
let pulledRequestedModel = false;
if (requestedCloudModel) {
availableModelNames.add(requestedDefaultModelId);
} else if (!modelNames.includes(requestedDefaultModelId)) {
pulledRequestedModel = await pullOllamaModelNonInteractive(
baseUrl,
requestedDefaultModelId,
params.runtime,
);
if (pulledRequestedModel) {
availableModelNames.add(requestedDefaultModelId);
}
}
let allModelNames = orderedModelNames;
let defaultModelId = requestedDefaultModelId;
if (
(pulledRequestedModel || requestedCloudModel) &&
!allModelNames.includes(requestedDefaultModelId)
) {
allModelNames = [...allModelNames, requestedDefaultModelId];
}
if (!availableModelNames.has(requestedDefaultModelId)) {
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) => availableModelNames.has(name)) ??
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 = params.config.models?.providers?.ollama?.baseUrl ?? OLLAMA_DEFAULT_BASE_URL;
const modelName = params.model.slice("ollama/".length);
if (isOllamaCloudModel(modelName)) {
return;
}
const { models } = await fetchOllamaModels(baseUrl);
if (models.some((model) => model.name === modelName)) {
return;
}
if (!(await pullOllamaModel(baseUrl, modelName, params.prompter))) {
throw new WizardCancelledError("Failed to download selected Ollama model");
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,235 @@
import { afterEach, 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;
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,
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" });
});
});
describe("createOllamaStreamFn thinking events", () => {
afterEach(() => {
fetchWithSsrFGuardMock.mockReset();
});
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();
},
});
}
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 body = makeNdjsonBody(thinkingChunks);
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,
{},
);
const events: Array<{ type: string; [key: string]: unknown }> = [];
for await (const event of stream as AsyncIterable<{ type: string; [key: string]: unknown }>) {
events.push(event);
}
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]).toMatchObject({ type: "thinking", thinking: "Step 1 and step 2" });
expect(content[1]).toMatchObject({ 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 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,
{},
);
const events: Array<{ type: string }> = [];
for await (const event of stream as AsyncIterable<{ type: string }>) {
events.push(event);
}
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);
});
});

View file

@ -0,0 +1,887 @@
import { randomUUID } from "node:crypto";
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type {
AssistantMessage,
StopReason,
TextContent,
ThinkingContent,
ToolCall,
Tool,
Usage,
} from "@mariozechner/pi-ai";
import { createAssistantMessageEventStream, streamSimple } from "@mariozechner/pi-ai";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type {
OpenClawConfig,
ProviderRuntimeModel,
ProviderWrapStreamFnContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { isNonSecretApiKeyMarker } from "openclaw/plugin-sdk/provider-auth";
import {
DEFAULT_CONTEXT_TOKENS,
normalizeProviderId,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
createMoonshotThinkingWrapper,
resolveMoonshotThinkingType,
streamWithPayloadPatch,
} from "openclaw/plugin-sdk/provider-stream-shared";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeLowercaseStringOrEmpty, readStringValue } from "openclaw/plugin-sdk/text-runtime";
import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js";
import {
parseJsonObjectPreservingUnsafeIntegers,
parseJsonPreservingUnsafeIntegers,
} from "./ollama-json.js";
import { buildOllamaBaseUrlSsrFPolicy } from "./provider-models.js";
const log = createSubsystemLogger("ollama-stream");
export const OLLAMA_NATIVE_BASE_URL = OLLAMA_DEFAULT_BASE_URL;
export function resolveOllamaBaseUrlForRun(params: {
modelBaseUrl?: string;
providerBaseUrl?: string;
}): string {
const providerBaseUrl = params.providerBaseUrl?.trim();
if (providerBaseUrl) {
return providerBaseUrl;
}
const modelBaseUrl = params.modelBaseUrl?.trim();
if (modelBaseUrl) {
return modelBaseUrl;
}
return OLLAMA_NATIVE_BASE_URL;
}
export function resolveConfiguredOllamaProviderConfig(params: {
config?: OpenClawConfig;
providerId?: string;
}) {
const providerId = params.providerId?.trim();
if (!providerId) {
return undefined;
}
const providers = params.config?.models?.providers;
if (!providers) {
return undefined;
}
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 undefined;
}
export function isOllamaCompatProvider(model: {
provider?: string;
baseUrl?: string;
api?: string;
}): boolean {
const providerId = normalizeProviderId(model.provider ?? "");
if (providerId === "ollama") {
return true;
}
if (!model.baseUrl) {
return false;
}
try {
const parsed = new URL(model.baseUrl);
const hostname = normalizeLowercaseStringOrEmpty(parsed.hostname);
const isLocalhost =
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname === "[::1]";
if (isLocalhost && parsed.port === "11434") {
return true;
}
// Allow remote/LAN Ollama OpenAI-compatible endpoints when the provider id
// itself indicates Ollama usage (for example "my-ollama").
const providerHintsOllama = providerId.includes("ollama");
const isOllamaPort = parsed.port === "11434";
const isOllamaCompatPath = parsed.pathname === "/" || /^\/v1\/?$/i.test(parsed.pathname);
return providerHintsOllama && isOllamaPort && isOllamaCompatPath;
} catch {
return false;
}
}
export function resolveOllamaCompatNumCtxEnabled(params: {
config?: OpenClawConfig;
providerId?: string;
}): boolean {
return resolveConfiguredOllamaProviderConfig(params)?.injectNumCtxForOpenAICompat ?? true;
}
export function shouldInjectOllamaCompatNumCtx(params: {
model: { api?: string; provider?: string; baseUrl?: string };
config?: OpenClawConfig;
providerId?: string;
}): boolean {
if (params.model.api !== "openai-completions") {
return false;
}
if (!isOllamaCompatProvider(params.model)) {
return false;
}
return resolveOllamaCompatNumCtxEnabled({
config: params.config,
providerId: params.providerId,
});
}
export function wrapOllamaCompatNumCtx(baseFn: StreamFn | undefined, numCtx: number): StreamFn {
const streamFn = baseFn ?? streamSimple;
return (model, context, options) =>
streamWithPayloadPatch(streamFn, model, context, options, (payloadRecord) => {
if (!payloadRecord.options || typeof payloadRecord.options !== "object") {
payloadRecord.options = {};
}
(payloadRecord.options as Record<string, unknown>).num_ctx = numCtx;
normalizeOllamaCompatMessageToolArgs(payloadRecord);
});
}
function createOllamaThinkingWrapper(baseFn: StreamFn | undefined, think: boolean): StreamFn {
const streamFn = baseFn ?? streamSimple;
return (model, context, options) => {
if (model.api !== "ollama") {
return streamFn(model, context, options);
}
return streamWithPayloadPatch(streamFn, model, context, options, (payloadRecord) => {
payloadRecord.think = think;
});
};
}
function resolveOllamaCompatNumCtx(model: ProviderRuntimeModel): number {
return Math.max(1, Math.floor(model.contextWindow ?? model.maxTokens ?? DEFAULT_CONTEXT_TOKENS));
}
function isOllamaCloudKimiModelRef(modelId: string): boolean {
const normalizedModelId = normalizeLowercaseStringOrEmpty(modelId);
return normalizedModelId.startsWith("kimi-k") && normalizedModelId.includes(":cloud");
}
export function createConfiguredOllamaCompatStreamWrapper(
ctx: ProviderWrapStreamFnContext,
): StreamFn | undefined {
let streamFn = ctx.streamFn;
const model = ctx.model;
let injectNumCtx = false;
if (model) {
const providerId =
typeof model.provider === "string" && model.provider.trim().length > 0
? model.provider
: ctx.provider;
if (
shouldInjectOllamaCompatNumCtx({
model,
config: ctx.config,
providerId,
})
) {
injectNumCtx = true;
}
}
if (injectNumCtx && model) {
streamFn = wrapOllamaCompatNumCtx(streamFn, resolveOllamaCompatNumCtx(model));
}
if (ctx.thinkingLevel === "off") {
streamFn = createOllamaThinkingWrapper(streamFn, false);
} else if (ctx.thinkingLevel) {
// Any non-off ThinkLevel (minimal, low, medium, high, xhigh, adaptive)
// should enable Ollama's native thinking mode.
streamFn = createOllamaThinkingWrapper(streamFn, true);
}
if (normalizeProviderId(ctx.provider) === "ollama" && isOllamaCloudKimiModelRef(ctx.modelId)) {
const thinkingType = resolveMoonshotThinkingType({
configuredThinking: ctx.extraParams?.thinking,
thinkingLevel: ctx.thinkingLevel,
});
streamFn = createMoonshotThinkingWrapper(streamFn, thinkingType);
}
return streamFn;
}
// Backward-compatible alias for existing imports/tests while the broader
// Ollama compat wrapper now owns more than num_ctx injection.
export const createConfiguredOllamaCompatNumCtxWrapper = createConfiguredOllamaCompatStreamWrapper;
function normalizeOllamaWireModelId(modelId: string): string {
const trimmed = modelId.trim();
return trimmed.startsWith("ollama/") ? trimmed.slice("ollama/".length) : trimmed;
}
export function buildOllamaChatRequest(params: {
modelId: string;
messages: OllamaChatMessage[];
tools?: OllamaTool[];
options?: Record<string, unknown>;
stream?: boolean;
}): OllamaChatRequest {
return {
model: normalizeOllamaWireModelId(params.modelId),
messages: params.messages,
stream: params.stream ?? true,
...(params.tools && params.tools.length > 0 ? { tools: params.tools } : {}),
...(params.options ? { options: params.options } : {}),
};
}
type StreamModelDescriptor = {
api: string;
provider: string;
id: string;
};
function buildUsageWithNoCost(params: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
totalTokens?: number;
}): Usage {
const input = params.input ?? 0;
const output = params.output ?? 0;
const cacheRead = params.cacheRead ?? 0;
const cacheWrite = params.cacheWrite ?? 0;
return {
input,
output,
cacheRead,
cacheWrite,
totalTokens: params.totalTokens ?? input + output,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
function buildStreamAssistantMessage(params: {
model: StreamModelDescriptor;
content: AssistantMessage["content"];
stopReason: StopReason;
usage: Usage;
timestamp?: number;
}): AssistantMessage {
return {
role: "assistant",
content: params.content,
stopReason: params.stopReason,
api: params.model.api,
provider: params.model.provider,
model: params.model.id,
usage: params.usage,
timestamp: params.timestamp ?? Date.now(),
};
}
function buildStreamErrorAssistantMessage(params: {
model: StreamModelDescriptor;
errorMessage: string;
timestamp?: number;
}): AssistantMessage & { stopReason: "error"; errorMessage: string } {
return {
...buildStreamAssistantMessage({
model: params.model,
content: [],
stopReason: "error",
usage: buildUsageWithNoCost({}),
timestamp: params.timestamp,
}),
stopReason: "error",
errorMessage: params.errorMessage,
};
}
interface OllamaChatRequest {
model: string;
messages: OllamaChatMessage[];
stream: boolean;
tools?: OllamaTool[];
options?: Record<string, unknown>;
think?: boolean;
}
interface OllamaChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: string;
images?: string[];
tool_calls?: OllamaToolCall[];
tool_name?: string;
}
interface OllamaTool {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
interface OllamaToolCall {
function: {
name: string;
arguments: Record<string, unknown>;
};
}
interface OllamaChatResponse {
model: string;
created_at: string;
message: {
role: "assistant";
content: string;
thinking?: string;
reasoning?: string;
tool_calls?: OllamaToolCall[];
};
done: boolean;
done_reason?: string;
total_duration?: number;
load_duration?: number;
prompt_eval_count?: number;
prompt_eval_duration?: number;
eval_count?: number;
eval_duration?: number;
}
type InputContentPart =
| { type: "text"; text: string }
| { type: "image"; data: string }
| { type: "toolCall"; id: string; name: string; arguments: unknown }
| { type: "tool_use"; id: string; name: string; input: unknown };
function extractTextContent(content: unknown): string {
if (typeof content === "string") {
return content;
}
if (!Array.isArray(content)) {
return "";
}
return (content as InputContentPart[])
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join("");
}
function extractOllamaImages(content: unknown): string[] {
if (!Array.isArray(content)) {
return [];
}
return (content as InputContentPart[])
.filter((part): part is { type: "image"; data: string } => part.type === "image")
.map((part) => part.data);
}
function ensureArgsObject(value: unknown): Record<string, unknown> {
return parseJsonObjectPreservingUnsafeIntegers(value) ?? {};
}
function normalizeOllamaCompatMessageToolArgs(payloadRecord: Record<string, unknown>): void {
const messages = payloadRecord.messages;
if (!Array.isArray(messages)) {
return;
}
for (const message of messages) {
if (!message || typeof message !== "object" || Array.isArray(message)) {
continue;
}
const messageRecord = message as Record<string, unknown>;
const functionCall = messageRecord.function_call;
if (functionCall && typeof functionCall === "object" && !Array.isArray(functionCall)) {
const functionCallRecord = functionCall as Record<string, unknown>;
if (Object.hasOwn(functionCallRecord, "arguments")) {
functionCallRecord.arguments = ensureArgsObject(functionCallRecord.arguments);
}
}
const toolCalls = messageRecord.tool_calls;
if (!Array.isArray(toolCalls)) {
continue;
}
for (const toolCall of toolCalls) {
if (!toolCall || typeof toolCall !== "object" || Array.isArray(toolCall)) {
continue;
}
const functionSpec = (toolCall as Record<string, unknown>).function;
if (!functionSpec || typeof functionSpec !== "object" || Array.isArray(functionSpec)) {
continue;
}
const functionRecord = functionSpec as Record<string, unknown>;
if (Object.hasOwn(functionRecord, "arguments")) {
functionRecord.arguments = ensureArgsObject(functionRecord.arguments);
}
}
}
}
function extractToolCalls(content: unknown): OllamaToolCall[] {
if (!Array.isArray(content)) {
return [];
}
const parts = content as InputContentPart[];
const result: OllamaToolCall[] = [];
for (const part of parts) {
if (part.type === "toolCall") {
result.push({ function: { name: part.name, arguments: ensureArgsObject(part.arguments) } });
} else if (part.type === "tool_use") {
result.push({ function: { name: part.name, arguments: ensureArgsObject(part.input) } });
}
}
return result;
}
export function convertToOllamaMessages(
messages: Array<{ role: string; content: unknown }>,
system?: string,
): OllamaChatMessage[] {
const result: OllamaChatMessage[] = [];
if (system) {
result.push({ role: "system", content: system });
}
for (const msg of messages) {
if (msg.role === "user") {
const text = extractTextContent(msg.content);
const images = extractOllamaImages(msg.content);
result.push({
role: "user",
content: text,
...(images.length > 0 ? { images } : {}),
});
continue;
}
if (msg.role === "assistant") {
const text = extractTextContent(msg.content);
const toolCalls = extractToolCalls(msg.content);
result.push({
role: "assistant",
content: text,
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
});
continue;
}
if (msg.role === "tool" || msg.role === "toolResult") {
const text = extractTextContent(msg.content);
const toolName =
typeof (msg as { toolName?: unknown }).toolName === "string"
? (msg as { toolName?: string }).toolName
: undefined;
result.push({
role: "tool",
content: text,
...(toolName ? { tool_name: toolName } : {}),
});
}
}
return result;
}
function extractOllamaTools(tools: Tool[] | undefined): OllamaTool[] {
if (!tools || !Array.isArray(tools)) {
return [];
}
const result: OllamaTool[] = [];
for (const tool of tools) {
if (typeof tool.name !== "string" || !tool.name) {
continue;
}
result.push({
type: "function",
function: {
name: tool.name,
description: typeof tool.description === "string" ? tool.description : "",
parameters: (tool.parameters ?? {}) as Record<string, unknown>,
},
});
}
return result;
}
export function buildAssistantMessage(
response: OllamaChatResponse,
modelInfo: StreamModelDescriptor,
): AssistantMessage {
const content: (TextContent | ThinkingContent | ToolCall)[] = [];
const thinking = response.message.thinking ?? response.message.reasoning ?? "";
if (thinking) {
content.push({ type: "thinking", thinking });
}
const text = response.message.content || "";
if (text) {
content.push({ type: "text", text });
}
const toolCalls = response.message.tool_calls;
if (toolCalls && toolCalls.length > 0) {
for (const toolCall of toolCalls) {
content.push({
type: "toolCall",
id: `ollama_call_${randomUUID()}`,
name: toolCall.function.name,
arguments: toolCall.function.arguments,
});
}
}
return buildStreamAssistantMessage({
model: modelInfo,
content,
stopReason: toolCalls && toolCalls.length > 0 ? "toolUse" : "stop",
usage: buildUsageWithNoCost({
input: response.prompt_eval_count ?? 0,
output: response.eval_count ?? 0,
}),
});
}
export async function* parseNdjsonStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
): AsyncGenerator<OllamaChatResponse> {
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
try {
yield parseJsonPreservingUnsafeIntegers(trimmed) as OllamaChatResponse;
} catch {
log.warn(`Skipping malformed NDJSON line: ${trimmed.slice(0, 120)}`);
}
}
}
if (buffer.trim()) {
try {
yield parseJsonPreservingUnsafeIntegers(buffer.trim()) as OllamaChatResponse;
} catch {
log.warn(`Skipping malformed trailing data: ${buffer.trim().slice(0, 120)}`);
}
}
}
function resolveOllamaChatUrl(baseUrl: string): string {
const trimmed = baseUrl.trim().replace(/\/+$/, "");
const normalizedBase = trimmed.replace(/\/v1$/i, "");
return `${normalizedBase || OLLAMA_NATIVE_BASE_URL}/api/chat`;
}
function resolveOllamaModelHeaders(model: {
headers?: unknown;
}): Record<string, string> | undefined {
if (!model.headers || typeof model.headers !== "object" || Array.isArray(model.headers)) {
return undefined;
}
return model.headers as Record<string, string>;
}
export function createOllamaStreamFn(
baseUrl: string,
defaultHeaders?: Record<string, string>,
): StreamFn {
const chatUrl = resolveOllamaChatUrl(baseUrl);
const ssrfPolicy = buildOllamaBaseUrlSsrFPolicy(chatUrl);
return (model, context, options) => {
const stream = createAssistantMessageEventStream();
const run = async () => {
try {
const ollamaMessages = convertToOllamaMessages(
context.messages ?? [],
context.systemPrompt,
);
const ollamaTools = extractOllamaTools(context.tools);
const ollamaOptions: Record<string, unknown> = { num_ctx: model.contextWindow ?? 65536 };
if (typeof options?.temperature === "number") {
ollamaOptions.temperature = options.temperature;
}
if (typeof options?.maxTokens === "number") {
ollamaOptions.num_predict = options.maxTokens;
}
const body = buildOllamaChatRequest({
modelId: model.id,
messages: ollamaMessages,
stream: true,
tools: ollamaTools,
options: ollamaOptions,
});
options?.onPayload?.(body, model);
const headers: Record<string, string> = {
"Content-Type": "application/json",
...defaultHeaders,
...options?.headers,
};
if (
options?.apiKey &&
(!headers.Authorization || !isNonSecretApiKeyMarker(options.apiKey))
) {
headers.Authorization = `Bearer ${options.apiKey}`;
}
const { response, release } = await fetchWithSsrFGuard({
url: chatUrl,
init: {
method: "POST",
headers,
body: JSON.stringify(body),
signal: options?.signal,
},
policy: ssrfPolicy,
auditContext: "ollama-stream.chat",
});
try {
if (!response.ok) {
const errorText = await response.text().catch(() => "unknown error");
throw new Error(`${response.status} ${errorText}`);
}
if (!response.body) {
throw new Error("Ollama API returned empty response body");
}
const reader = response.body.getReader();
let accumulatedContent = "";
let accumulatedThinking = "";
const accumulatedToolCalls: OllamaToolCall[] = [];
let finalResponse: OllamaChatResponse | undefined;
const modelInfo = { api: model.api, provider: model.provider, id: model.id };
let streamStarted = false;
let thinkingStarted = false;
let thinkingEnded = false;
let textBlockStarted = false;
let textBlockClosed = false;
const textContentIndex = () => (thinkingStarted ? 1 : 0);
const buildCurrentContent = (): (TextContent | ThinkingContent | ToolCall)[] => {
const parts: (TextContent | ThinkingContent | ToolCall)[] = [];
if (accumulatedThinking) {
parts.push({
type: "thinking",
thinking: accumulatedThinking,
});
}
if (accumulatedContent) {
parts.push({ type: "text", text: accumulatedContent });
}
return parts;
};
const closeThinkingBlock = () => {
if (!thinkingStarted || thinkingEnded) {
return;
}
thinkingEnded = true;
const partial = buildStreamAssistantMessage({
model: modelInfo,
content: buildCurrentContent(),
stopReason: "stop",
usage: buildUsageWithNoCost({}),
});
stream.push({
type: "thinking_end",
contentIndex: 0,
content: accumulatedThinking,
partial,
});
};
const closeTextBlock = () => {
if (!textBlockStarted || textBlockClosed) {
return;
}
textBlockClosed = true;
const partial = buildStreamAssistantMessage({
model: modelInfo,
content: buildCurrentContent(),
stopReason: "stop",
usage: buildUsageWithNoCost({}),
});
stream.push({
type: "text_end",
contentIndex: textContentIndex(),
content: accumulatedContent,
partial,
});
};
for await (const chunk of parseNdjsonStream(reader)) {
const thinkingDelta = chunk.message?.thinking ?? chunk.message?.reasoning;
if (thinkingDelta) {
if (!streamStarted) {
streamStarted = true;
const emptyPartial = buildStreamAssistantMessage({
model: modelInfo,
content: [],
stopReason: "stop",
usage: buildUsageWithNoCost({}),
});
stream.push({ type: "start", partial: emptyPartial });
}
if (!thinkingStarted) {
thinkingStarted = true;
const partial = buildStreamAssistantMessage({
model: modelInfo,
content: buildCurrentContent(),
stopReason: "stop",
usage: buildUsageWithNoCost({}),
});
stream.push({ type: "thinking_start", contentIndex: 0, partial });
}
accumulatedThinking += thinkingDelta;
const partial = buildStreamAssistantMessage({
model: modelInfo,
content: buildCurrentContent(),
stopReason: "stop",
usage: buildUsageWithNoCost({}),
});
stream.push({
type: "thinking_delta",
contentIndex: 0,
delta: thinkingDelta,
partial,
});
}
if (chunk.message?.content) {
const delta = chunk.message.content;
if (thinkingStarted && !thinkingEnded) {
closeThinkingBlock();
}
if (!streamStarted) {
streamStarted = true;
const emptyPartial = buildStreamAssistantMessage({
model: modelInfo,
content: [],
stopReason: "stop",
usage: buildUsageWithNoCost({}),
});
stream.push({ type: "start", partial: emptyPartial });
}
if (!textBlockStarted) {
textBlockStarted = true;
const partial = buildStreamAssistantMessage({
model: modelInfo,
content: buildCurrentContent(),
stopReason: "stop",
usage: buildUsageWithNoCost({}),
});
stream.push({ type: "text_start", contentIndex: textContentIndex(), partial });
}
accumulatedContent += delta;
const partial = buildStreamAssistantMessage({
model: modelInfo,
content: buildCurrentContent(),
stopReason: "stop",
usage: buildUsageWithNoCost({}),
});
stream.push({
type: "text_delta",
contentIndex: textContentIndex(),
delta,
partial,
});
}
if (chunk.message?.tool_calls) {
closeThinkingBlock();
closeTextBlock();
accumulatedToolCalls.push(...chunk.message.tool_calls);
}
if (chunk.done) {
finalResponse = chunk;
break;
}
}
if (!finalResponse) {
throw new Error("Ollama API stream ended without a final response");
}
finalResponse.message.content = accumulatedContent;
if (accumulatedThinking) {
finalResponse.message.thinking = accumulatedThinking;
}
if (accumulatedToolCalls.length > 0) {
finalResponse.message.tool_calls = accumulatedToolCalls;
}
const assistantMessage = buildAssistantMessage(finalResponse, modelInfo);
closeThinkingBlock();
closeTextBlock();
stream.push({
type: "done",
reason: assistantMessage.stopReason === "toolUse" ? "toolUse" : "stop",
message: assistantMessage,
});
} finally {
await release();
}
} catch (err) {
stream.push({
type: "error",
reason: "error",
error: buildStreamErrorAssistantMessage({
model,
errorMessage: formatErrorMessage(err),
}),
});
} finally {
stream.end();
}
};
queueMicrotask(() => void run());
return stream;
};
}
export function createConfiguredOllamaStreamFn(params: {
model: { baseUrl?: string; headers?: unknown };
providerBaseUrl?: string;
}): StreamFn {
return createOllamaStreamFn(
resolveOllamaBaseUrlForRun({
modelBaseUrl: readStringValue(params.model.baseUrl),
providerBaseUrl: params.providerBaseUrl,
}),
resolveOllamaModelHeaders(params.model),
);
}

View file

@ -0,0 +1,275 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createOllamaWebSearchProvider as createContractOllamaWebSearchProvider } from "../web-search-contract-api.js";
import {
__testing as 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;
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 });
},
},
};
}
describe("ollama web search provider", () => {
beforeEach(() => {
fetchWithSsrFGuardMock.mockReset();
});
it("registers a keyless web search provider", () => {
expect(createContractOllamaWebSearchProvider()).toMatchObject({
id: "ollama",
label: "Ollama Web Search",
requiresCredential: false,
envVars: [],
});
});
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("falls back to the local Ollama host when the model provider uses ollama cloud", () => {
expect(
testing.resolveOllamaWebSearchBaseUrl(
createOllamaConfig({
baseUrl: "https://ollama.com",
}),
),
).toBe("http://127.0.0.1:11434");
});
it("maps generic search args into the Ollama experimental search 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 });
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "http://ollama.local:11434/api/experimental/web_search",
auditContext: "ollama-web-search.search",
}),
);
expect(
JSON.parse(
String(
(
fetchWithSsrFGuardMock.mock.calls[0]?.[0] as {
init?: { body?: string };
}
).init?.body,
),
),
).toEqual({
query: "openclaw docs",
max_results: 3,
});
expect(result).toMatchObject({
query: "openclaw docs",
provider: "ollama",
count: 1,
results: [{ url: "https://openclaw.ai/docs" }],
});
expect(release).toHaveBeenCalledTimes(1);
});
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("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([
expect.objectContaining({
title: "Ollama Web Search",
message: expect.stringContaining("requires Ollama to be running"),
}),
]);
});
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([
expect.objectContaining({
title: "Ollama Web Search",
message: expect.stringContaining("Ollama Web Search requires `ollama signin`."),
}),
]);
expect(notes[0]?.message).toContain("https://ollama.com/signin");
});
});

View file

@ -0,0 +1,250 @@
import { Type } from "@sinclair/typebox";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
isNonSecretApiKeyMarker,
normalizeOptionalSecretInput,
} from "openclaw/plugin-sdk/provider-auth";
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
enablePluginInConfig,
readNumberParam,
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/text-runtime";
import { OLLAMA_CLOUD_BASE_URL, OLLAMA_DEFAULT_BASE_URL } from "./defaults.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.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
}),
),
},
{ additionalProperties: false },
);
const OLLAMA_WEB_SEARCH_PATH = "/api/experimental/web_search";
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[];
};
function resolveOllamaWebSearchApiKey(config?: OpenClawConfig): string | undefined {
const providerApiKey = normalizeOptionalSecretInput(config?.models?.providers?.ollama?.apiKey);
if (providerApiKey && !isNonSecretApiKeyMarker(providerApiKey)) {
return providerApiKey;
}
return resolveEnvApiKey("ollama")?.apiKey;
}
function resolveOllamaWebSearchBaseUrl(config?: OpenClawConfig): string {
const pluginBaseUrl = normalizeOptionalString(
resolveProviderWebSearchPluginConfig(config, "ollama")?.baseUrl,
);
if (pluginBaseUrl) {
return resolveOllamaApiBase(pluginBaseUrl);
}
const configuredBaseUrl = config?.models?.providers?.ollama?.baseUrl;
if (normalizeOptionalString(configuredBaseUrl)) {
const baseUrl = resolveOllamaApiBase(configuredBaseUrl);
if (baseUrl !== OLLAMA_CLOUD_BASE_URL) {
return baseUrl;
}
}
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) ?? "",
};
}
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 apiKey = resolveOllamaWebSearchApiKey(params.config);
const count = resolveSearchCount(params.count, DEFAULT_OLLAMA_WEB_SEARCH_COUNT);
const startedAt = Date.now();
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
const { response, release } = await fetchWithSsrFGuard({
url: `${baseUrl}${OLLAMA_WEB_SEARCH_PATH}`,
init: {
method: "POST",
headers,
body: JSON.stringify({ query, max_results: count }),
signal: AbortSignal.timeout(DEFAULT_OLLAMA_WEB_SEARCH_TIMEOUT_MS),
},
policy: buildOllamaBaseUrlSsrFPolicy(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 });
throw new Error(`Ollama web search failed (${response.status}): ${detail.text || ""}`.trim());
}
const payload = (await response.json()) as OllamaWebSearchResponse;
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,
};
}),
};
} finally {
await release();
}
}
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 experimental 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: readNumberParam(args, "count", { integer: true }),
}),
}),
};
}
export const __testing = {
normalizeOllamaWebSearchResult,
resolveOllamaWebSearchApiKey,
resolveOllamaWebSearchBaseUrl,
warnOllamaWebSearchPrereqs,
};

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,26 @@
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 @@
export { createOllamaWebSearchProvider } from "./src/web-search-provider.js";