fix(ollama): harden native provider routing

This commit is contained in:
Peter Steinberger
2026-04-27 01:01:51 +01:00
parent be56f172ab
commit a3e0674261
18 changed files with 909 additions and 120 deletions

View File

@@ -19,6 +19,10 @@ Docs: https://docs.openclaw.ai
- Logging/sessions: apply configured redaction patterns to persisted session transcript text and accept escaped character classes in safe custom redaction regexes, so transcript JSONL no longer keeps matching sensitive text in the clear. Fixes #42982. Thanks @panpan0000.
- Providers/Ollama: honor `/api/show` capabilities when registering local models so non-tool Ollama models no longer receive the agent tool surface, and keep native Ollama thinking opt-in instead of enabling it by default. Fixes #64710 and duplicate #65343. Thanks @yuan-b, @netherby, @xilopaint, and @Diyforfun2026.
- Providers/Ollama: expose native Ollama thinking effort levels so `/think max` is accepted for reasoning-capable Ollama models and maps to Ollama's highest supported `think` effort. Fixes #71584. Thanks @g0st1n.
- Providers/Ollama: strip the active custom Ollama provider prefix before native chat and embedding requests, so custom provider ids like `ollama-spark/qwen3:32b` reach Ollama as the real model name. Fixes #72353. Thanks @maximus-dss and @hclsys.
- Providers/Ollama: move memory embeddings to Ollama's current `/api/embed` endpoint with batched `input` requests while preserving vector normalization and custom provider auth/header overrides. Fixes #39983. Thanks @sskkcc and @LiudengZhang.
- Providers/Ollama: try both current and legacy Ollama web-search endpoints and use `OLLAMA_API_KEY` only for the `ollama.com` cloud fallback, keeping local signed-in hosts keyless. Fixes #69132. Thanks @yoon1012 and @hyspacex.
- Agents/Ollama: apply provider-owned replay turn normalization to native Ollama chat so Cloud models no longer reject non-alternating replay history in agent/Gateway runs. Fixes #71697. Thanks @ismael-81.
- Agents/Ollama: validate explicit `--thinking max` against catalog-discovered Ollama reasoning metadata so local agent runs accept the same native thinking levels shown in the model catalog. Fixes #71584. Thanks @g0st1n.
- Docker/QA: add observability coverage to the normal Docker aggregate so QA-lab OTEL and Prometheus diagnostics run inside Docker. Thanks @vincentkoc.
- Auto-reply: poison inbound message dedupe after replay-unsafe provider/runtime failures so retries stay safe before visible progress but cannot duplicate messages after block output, tool side effects, or session progress. Fixes #69303; keeps #58549 and #64606 as duplicate validation. Thanks @martingarramon, @NikolaFC, and @zeroth-blip.

View File

@@ -318,6 +318,10 @@ Once configured, all your Ollama models are available:
}
```
Custom Ollama provider ids are also supported. When a model ref uses the active
provider prefix, such as `ollama-spark/qwen3:32b`, OpenClaw strips only that
prefix before calling Ollama so the server receives `qwen3:32b`.
## Ollama Web Search
OpenClaw supports **Ollama Web Search** as a bundled `web_search` provider.
@@ -437,7 +441,8 @@ For the full setup and behavior details, see [Ollama Web Search](/tools/ollama-s
<Accordion title="Memory embeddings">
The bundled Ollama plugin registers a memory embedding provider for
[memory search](/concepts/memory). It uses the configured Ollama base URL
and API key.
and API key, calls Ollama's current `/api/embed` endpoint, and batches
multiple memory chunks into one `input` request when possible.
| Property | Value |
| ------------- | ------------------- |

View File

@@ -78,18 +78,22 @@ If no explicit Ollama base URL is set, OpenClaw uses `http://127.0.0.1:11434`.
If your Ollama host expects bearer auth, OpenClaw reuses
`models.providers.ollama.apiKey` (or the matching env-backed provider auth)
for web-search requests too.
for requests to that configured host.
## Notes
- No web-search-specific API key field is required for this provider.
- If the Ollama host is auth-protected, OpenClaw reuses the normal Ollama
provider API key when present.
- If the configured host does not expose web search and `OLLAMA_API_KEY` is set,
OpenClaw can fall back to `https://ollama.com/api/web_search` without sending
that env key to the local host.
- OpenClaw warns during setup if Ollama is unreachable or not signed in, but
it does not block selection.
- Runtime auto-detect can fall back to Ollama Web Search when no higher-priority
credentialed provider is configured.
- The provider uses Ollama's `/api/web_search` endpoint.
- The provider tries Ollama's `/api/web_search` endpoint first, then the legacy
`/api/experimental/web_search` endpoint for older hosts.
## Related

View File

@@ -429,7 +429,7 @@ describe("ollama plugin", () => {
).toBeUndefined();
});
it("owns replay policy for OpenAI-compatible Ollama routes only", () => {
it("owns replay policy for OpenAI-compatible and native Ollama routes", () => {
const provider = registerProvider();
expect(
@@ -466,7 +466,13 @@ describe("ollama plugin", () => {
modelApi: "ollama",
modelId: "qwen3.5:9b",
} as never),
).toBeUndefined();
).toMatchObject({
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
applyAssistantFirstOrderingFix: true,
validateGeminiTurns: true,
validateAnthropicTurns: true,
});
});
it("routes createStreamFn to the correct provider baseUrl for ollama2", () => {

View File

@@ -8,7 +8,10 @@ import {
type ProviderDiscoveryContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { buildApiKeyCredential } from "openclaw/plugin-sdk/provider-auth";
import { OPENAI_COMPATIBLE_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared";
import {
buildOpenAICompatibleReplayPolicy,
OPENAI_COMPATIBLE_REPLAY_HOOKS,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
buildOllamaProvider,
configureOllamaNonInteractive,
@@ -163,6 +166,10 @@ export default definePluginEntry({
});
},
...OPENAI_COMPATIBLE_REPLAY_HOOKS,
buildReplayPolicy: (ctx) =>
ctx.modelApi === "ollama"
? buildOpenAICompatibleReplayPolicy("openai-completions")
: buildOpenAICompatibleReplayPolicy(ctx.modelApi),
contributeResolvedModelCompat: ({ model }) =>
usesOllamaOpenAICompatTransport(model) ? { supportsUsageInStreaming: true } : undefined,
resolveReasoningOutputMode: () => "native",
@@ -174,11 +181,12 @@ export default definePluginEntry({
defaultLevel: "off",
}),
wrapStreamFn: createConfiguredOllamaCompatStreamWrapper,
createEmbeddingProvider: async ({ config, model, remote }) => {
createEmbeddingProvider: async ({ config, model, provider: embeddingProvider, remote }) => {
const { provider, client } = await createOllamaEmbeddingProvider({
config,
remote,
model: model || DEFAULT_OLLAMA_EMBEDDING_MODEL,
provider: embeddingProvider || OLLAMA_PROVIDER_ID,
});
return {
...provider,

View File

@@ -0,0 +1,149 @@
import { describe, expect, it } from "vitest";
import { createOllamaEmbeddingProvider } from "./src/embedding-provider.js";
import { createOllamaStreamFn } from "./src/stream.js";
import { createOllamaWebSearchProvider } from "./src/web-search-provider.js";
const LIVE = process.env.OPENCLAW_LIVE_TEST === "1" && process.env.OPENCLAW_LIVE_OLLAMA === "1";
const OLLAMA_BASE_URL =
process.env.OPENCLAW_LIVE_OLLAMA_BASE_URL?.trim() || "http://127.0.0.1:11434";
const CHAT_MODEL = process.env.OPENCLAW_LIVE_OLLAMA_MODEL?.trim() || "llama3.2:latest";
const EMBEDDING_MODEL =
process.env.OPENCLAW_LIVE_OLLAMA_EMBED_MODEL?.trim() || "embeddinggemma:latest";
const PROVIDER_ID = process.env.OPENCLAW_LIVE_OLLAMA_PROVIDER_ID?.trim() || "ollama-live-custom";
const RUN_WEB_SEARCH = process.env.OPENCLAW_LIVE_OLLAMA_WEB_SEARCH !== "0";
async function collectStreamEvents<T>(stream: AsyncIterable<T>): Promise<T[]> {
const events: T[] = [];
for await (const event of stream) {
events.push(event);
}
return events;
}
describe.skipIf(!LIVE)("ollama live", () => {
it("runs native chat with a custom provider prefix and normalized tool schemas", async () => {
const streamFn = createOllamaStreamFn(OLLAMA_BASE_URL);
let payload:
| {
model?: string;
tools?: Array<{
function?: {
parameters?: {
properties?: Record<string, { type?: string }>;
};
};
}>;
}
| undefined;
const stream = streamFn(
{
id: `${PROVIDER_ID}/${CHAT_MODEL}`,
api: "ollama",
provider: PROVIDER_ID,
contextWindow: 8192,
} as never,
{
messages: [{ role: "user", content: "Reply exactly OK." }],
tools: [
{
name: "lookup_weather",
description: "Lookup weather for a city.",
parameters: {
properties: {
city: { enum: ["London", "Vienna"] },
units: { enum: ["metric", "imperial"] },
options: {
properties: {
includeWind: { type: "boolean" },
},
},
},
required: ["city"],
},
},
],
} as never,
{
maxTokens: 32,
temperature: 0,
onPayload: (body: unknown) => {
payload = body as NonNullable<typeof payload>;
},
} as never,
);
const events = await collectStreamEvents(await Promise.resolve(stream));
const error = events.find((event) => (event as { type?: string }).type === "error");
expect(error).toBeUndefined();
expect(events.some((event) => (event as { type?: string }).type === "done")).toBe(true);
expect(payload?.model).toBe(CHAT_MODEL);
const properties = payload?.tools?.[0]?.function?.parameters?.properties;
expect(properties?.city?.type).toBe("string");
expect(properties?.units?.type).toBe("string");
expect(properties?.options?.type).toBe("object");
}, 60_000);
it("embeds a batch through the current Ollama endpoint for custom providers", async () => {
const { client } = await createOllamaEmbeddingProvider({
config: {
models: {
providers: {
[PROVIDER_ID]: {
api: "ollama",
baseUrl: OLLAMA_BASE_URL,
apiKey: "ollama-local",
},
},
},
},
provider: PROVIDER_ID,
model: `${PROVIDER_ID}/${EMBEDDING_MODEL}`,
} as never);
const embeddings = await client.embedBatch(["hello", "world"]);
expect(embeddings).toHaveLength(2);
expect(embeddings[0]?.length ?? 0).toBeGreaterThan(0);
expect(embeddings[1]?.length).toBe(embeddings[0]?.length);
expect(Math.hypot(...embeddings[0])).toBeGreaterThan(0.99);
expect(Math.hypot(...embeddings[0])).toBeLessThan(1.01);
}, 45_000);
it.skipIf(!RUN_WEB_SEARCH)(
"searches through Ollama web search fallback endpoints",
async () => {
const provider = createOllamaWebSearchProvider();
const tool = provider.createTool({
config: {
models: {
providers: {
ollama: {
api: "ollama",
baseUrl: OLLAMA_BASE_URL,
apiKey: "ollama-local",
},
},
},
},
} as never);
if (!tool) {
throw new Error("Ollama web-search provider did not create a tool");
}
const result = (await tool.execute({
query: "OpenClaw documentation",
count: 1,
})) as {
provider?: string;
results?: Array<{ url?: string }>;
};
expect(result.provider).toBe("ollama");
expect(result.results?.length ?? 0).toBeGreaterThan(0);
expect(result.results?.[0]?.url).toMatch(/^https?:\/\//);
},
45_000,
);
});

View File

@@ -37,7 +37,7 @@ afterEach(() => {
function mockEmbeddingFetch(embedding: number[]) {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ embedding }), {
new Response(JSON.stringify({ embeddings: [embedding] }), {
status: 200,
headers: { "content-type": "application/json" },
}),
@@ -47,7 +47,7 @@ function mockEmbeddingFetch(embedding: number[]) {
}
describe("ollama embedding provider", () => {
it("calls /api/embeddings and returns normalized vectors", async () => {
it("calls /api/embed and returns normalized vectors", async () => {
const fetchMock = mockEmbeddingFetch([3, 4]);
const { provider } = await createOllamaEmbeddingProvider({
@@ -61,6 +61,13 @@ describe("ollama embedding provider", () => {
const vector = await provider.embedQuery("hi");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:11434/api/embed",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ model: "nomic-embed-text", input: "hi" }),
}),
);
expect(vector[0]).toBeCloseTo(0.6, 5);
expect(vector[1]).toBeCloseTo(0.8, 5);
});
@@ -90,7 +97,7 @@ describe("ollama embedding provider", () => {
await provider.embedQuery("hello");
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:11434/api/embeddings",
"http://127.0.0.1:11434/api/embed",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
@@ -141,7 +148,7 @@ describe("ollama embedding provider", () => {
await provider.embedQuery("hello");
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:11434/api/embeddings",
"http://127.0.0.1:11434/api/embed",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer ollama-env",
@@ -150,22 +157,25 @@ describe("ollama embedding provider", () => {
);
});
it("serializes batch embeddings to avoid flooding local Ollama", async () => {
let inFlight = 0;
let maxInFlight = 0;
const prompts: string[] = [];
it("sends batch embeddings in one Ollama request", async () => {
const inputs: unknown[] = [];
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
const rawBody = typeof init?.body === "string" ? init.body : "{}";
const body = JSON.parse(rawBody) as { prompt?: string };
prompts.push(body.prompt ?? "");
await new Promise((resolve) => setTimeout(resolve, 0));
inFlight -= 1;
return new Response(JSON.stringify({ embedding: [1, 0] }), {
status: 200,
headers: { "content-type": "application/json" },
});
const body = JSON.parse(rawBody) as { input?: unknown };
inputs.push(body.input);
return new Response(
JSON.stringify({
embeddings: [
[1, 0],
[1, 0],
[1, 0],
],
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
);
});
vi.stubGlobal("fetch", fetchMock);
@@ -178,9 +188,45 @@ describe("ollama embedding provider", () => {
});
await expect(provider.embedBatch(["a", "bb", "ccc"])).resolves.toHaveLength(3);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(prompts).toEqual(["a", "bb", "ccc"]);
expect(maxInFlight).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(inputs).toEqual([["a", "bb", "ccc"]]);
});
it("uses custom Ollama provider config and strips that provider prefix", async () => {
const fetchMock = mockEmbeddingFetch([1, 0]);
const { provider } = await createOllamaEmbeddingProvider({
config: {
models: {
providers: {
"ollama-spark": {
baseUrl: "http://spark.local:11434/v1",
apiKey: "spark-key",
headers: {
"X-Custom-Ollama": "spark",
},
models: [],
},
},
},
} as unknown as OpenClawConfig,
provider: "ollama-spark",
model: "ollama-spark/qwen3-embedding:4b",
fallback: "none",
});
await provider.embedQuery("hello");
expect(provider.model).toBe("qwen3-embedding:4b");
expect(fetchMock).toHaveBeenCalledWith(
"http://spark.local:11434/api/embed",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer spark-key",
"X-Custom-Ollama": "spark",
}),
}),
);
});
it("marks inline memory batches as local-server timeout work", async () => {

View File

@@ -1,6 +1,7 @@
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 { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
@@ -11,6 +12,7 @@ import {
ssrfPolicyFromHttpBaseUrlAllowedHostname,
type SsrFPolicy,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeOllamaWireModelId } from "./model-id.js";
import { resolveOllamaApiBase } from "./provider-models.js";
export type OllamaEmbeddingProvider = {
@@ -48,7 +50,6 @@ export type OllamaEmbeddingClient = {
type OllamaEmbeddingClientConfig = Omit<OllamaEmbeddingClient, "embedBatch">;
export const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text";
const OLLAMA_EMBEDDING_BATCH_CONCURRENCY = 1;
function sanitizeAndNormalizeEmbedding(vec: number[]): number[] {
const sanitized = vec.map((value) => (Number.isFinite(value) ? value : 0));
@@ -78,12 +79,31 @@ async function withRemoteHttpResponse<T>(params: {
}
}
function normalizeEmbeddingModel(model: string): string {
function normalizeEmbeddingModel(model: string, providerId?: string): string {
const trimmed = model.trim();
if (!trimmed) {
return DEFAULT_OLLAMA_EMBEDDING_MODEL;
}
return trimmed.startsWith("ollama/") ? trimmed.slice("ollama/".length) : trimmed;
return normalizeOllamaWireModelId(trimmed, providerId);
}
function resolveConfiguredProvider(options: OllamaEmbeddingOptions) {
const providers = options.config.models?.providers;
if (!providers) {
return undefined;
}
const providerId = options.provider?.trim() || "ollama";
const direct = providers[providerId];
if (direct) {
return direct;
}
const normalized = normalizeProviderId(providerId);
for (const [candidateId, candidate] of Object.entries(providers)) {
if (normalizeProviderId(candidateId) === normalized) {
return candidate;
}
}
return providers.ollama;
}
function resolveMemorySecretInputString(params: {
@@ -107,9 +127,7 @@ function resolveOllamaApiKey(options: OllamaEmbeddingOptions): string | undefine
if (remoteApiKey) {
return remoteApiKey;
}
const providerApiKey = normalizeOptionalSecretInput(
options.config.models?.providers?.ollama?.apiKey,
);
const providerApiKey = normalizeOptionalSecretInput(resolveConfiguredProvider(options)?.apiKey);
if (providerApiKey) {
return providerApiKey;
}
@@ -119,10 +137,10 @@ function resolveOllamaApiKey(options: OllamaEmbeddingOptions): string | undefine
function resolveOllamaEmbeddingClient(
options: OllamaEmbeddingOptions,
): OllamaEmbeddingClientConfig {
const providerConfig = options.config.models?.providers?.ollama;
const providerConfig = resolveConfiguredProvider(options);
const rawBaseUrl = options.remote?.baseUrl?.trim() || providerConfig?.baseUrl?.trim();
const baseUrl = resolveOllamaApiBase(rawBaseUrl);
const model = normalizeEmbeddingModel(options.model);
const model = normalizeEmbeddingModel(options.model, options.provider);
const headerOverrides = Object.assign({}, providerConfig?.headers, options.remote?.headers);
const headers: Record<string, string> = {
"Content-Type": "application/json",
@@ -144,42 +162,54 @@ export async function createOllamaEmbeddingProvider(
options: OllamaEmbeddingOptions,
): Promise<{ provider: OllamaEmbeddingProvider; client: OllamaEmbeddingClient }> {
const client = resolveOllamaEmbeddingClient(options);
const embedUrl = `${client.baseUrl.replace(/\/$/, "")}/api/embeddings`;
const embedUrl = `${client.baseUrl.replace(/\/$/, "")}/api/embed`;
const embedOne = async (text: string): Promise<number[]> => {
const embedMany = async (input: string | 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 }),
body: JSON.stringify({ model: client.model, input }),
},
onResponse: async (response) => {
if (!response.ok) {
throw new Error(`Ollama embeddings HTTP ${response.status}: ${await response.text()}`);
throw new Error(`Ollama embed HTTP ${response.status}: ${await response.text()}`);
}
return (await response.json()) as { embedding?: number[] };
return (await response.json()) as { embeddings?: unknown };
},
});
if (!Array.isArray(json.embedding)) {
throw new Error("Ollama embeddings response missing embedding[]");
if (!Array.isArray(json.embeddings)) {
throw new Error("Ollama embed response missing embeddings[]");
}
return sanitizeAndNormalizeEmbedding(json.embedding);
const expectedCount = Array.isArray(input) ? input.length : 1;
if (json.embeddings.length !== expectedCount) {
throw new Error(
`Ollama embed response returned ${json.embeddings.length} embeddings for ${expectedCount} inputs`,
);
}
return json.embeddings.map((embedding) => {
if (!Array.isArray(embedding)) {
throw new Error("Ollama embed response contains a non-array embedding");
}
return sanitizeAndNormalizeEmbedding(embedding);
});
};
const embedOne = async (text: string): Promise<number[]> => {
const [embedding] = await embedMany(text);
if (!embedding) {
throw new Error("Ollama embed response returned no embedding");
}
return embedding;
};
const provider: OllamaEmbeddingProvider = {
id: "ollama",
model: client.model,
embedQuery: embedOne,
embedBatch: async (texts) => {
const embeddings: number[][] = [];
for (let index = 0; index < texts.length; index += OLLAMA_EMBEDDING_BATCH_CONCURRENCY) {
const batch = texts.slice(index, index + OLLAMA_EMBEDDING_BATCH_CONCURRENCY);
embeddings.push(...(await Promise.all(batch.map(embedOne))));
}
return embeddings;
},
embedBatch: async (texts) => (texts.length === 0 ? [] : await embedMany(texts)),
};
return {

View File

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

View File

@@ -56,6 +56,30 @@ describe("buildOllamaChatRequest", () => {
model: "qwen3:14b-q8_0",
});
});
it("strips the active custom provider prefix from chat model ids", () => {
expect(
buildOllamaChatRequest({
modelId: "ollama-spark/qwen3:32b",
providerId: "ollama-spark",
messages: [{ role: "user", content: "hello" }],
}),
).toMatchObject({
model: "qwen3:32b",
});
});
it("keeps unrelated slash-containing Ollama model ids intact", () => {
expect(
buildOllamaChatRequest({
modelId: "library/qwen3:32b",
providerId: "ollama-spark",
messages: [{ role: "user", content: "hello" }],
}),
).toMatchObject({
model: "library/qwen3:32b",
});
});
});
describe("createConfiguredOllamaCompatStreamWrapper", () => {
@@ -255,6 +279,109 @@ describe("createConfiguredOllamaCompatStreamWrapper", () => {
},
);
});
it("sends custom-provider Ollama chat requests with the bare Ollama model id", async () => {
await withMockNdjsonFetch(
[
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}',
'{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}',
],
async (fetchMock) => {
const streamFn = createOllamaStreamFn("http://ollama-host:11434");
const model = {
api: "ollama",
provider: "ollama-spark",
id: "ollama-spark/qwen3:32b",
contextWindow: 131072,
};
const stream = await Promise.resolve(
streamFn(
model as never,
{
messages: [{ role: "user", content: "hello" }],
} as never,
{} as never,
),
);
await collectStreamEvents(stream);
const requestInit = getGuardedFetchCall(fetchMock).init ?? {};
if (typeof requestInit.body !== "string") {
throw new Error("Expected string request body");
}
const requestBody = JSON.parse(requestInit.body) as { model?: string };
expect(requestBody.model).toBe("qwen3:32b");
},
);
});
it("adds direct type hints to native Ollama tool schemas before sending them", async () => {
await withMockNdjsonFetch(
[
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}',
'{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}',
],
async (fetchMock) => {
const streamFn = createOllamaStreamFn("http://ollama-host:11434");
const model = {
api: "ollama",
provider: "ollama",
id: "qwen3:32b",
contextWindow: 131072,
};
const stream = await Promise.resolve(
streamFn(
model as never,
{
messages: [{ role: "user", content: "hello" }],
tools: [
{
name: "search",
description: "search",
parameters: {
properties: {
query: {
anyOf: [{ type: "string" }, { type: "null" }],
},
tags: {
items: { type: "string" },
},
},
required: ["query"],
},
},
],
} as never,
{} as never,
),
);
await collectStreamEvents(stream);
const requestInit = getGuardedFetchCall(fetchMock).init ?? {};
if (typeof requestInit.body !== "string") {
throw new Error("Expected string request body");
}
const requestBody = JSON.parse(requestInit.body) as {
tools?: Array<{
function?: {
parameters?: {
type?: string;
properties?: Record<string, { type?: string }>;
};
};
}>;
};
const parameters = requestBody.tools?.[0]?.function?.parameters;
expect(parameters?.type).toBe("object");
expect(parameters?.properties?.query?.type).toBe("string");
expect(parameters?.properties?.tags?.type).toBe("array");
},
);
});
});
describe("convertToOllamaMessages", () => {

View File

@@ -30,6 +30,7 @@ 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 { normalizeOllamaWireModelId } from "./model-id.js";
import {
parseJsonObjectPreservingUnsafeIntegers,
parseJsonPreservingUnsafeIntegers,
@@ -239,20 +240,16 @@ export function createConfiguredOllamaCompatStreamWrapper(
// 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;
providerId?: string;
messages: OllamaChatMessage[];
tools?: OllamaTool[];
options?: Record<string, unknown>;
stream?: boolean;
}): OllamaChatRequest {
return {
model: normalizeOllamaWireModelId(params.modelId),
model: normalizeOllamaWireModelId(params.modelId, params.providerId),
messages: params.messages,
stream: params.stream ?? true,
...(params.tools && params.tools.length > 0 ? { tools: params.tools } : {}),
@@ -449,6 +446,105 @@ function normalizeOllamaCompatMessageToolArgs(payloadRecord: Record<string, unkn
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function inferOllamaSchemaType(schema: Record<string, unknown>): string | undefined {
if (schema.properties && isRecord(schema.properties)) {
return "object";
}
if (schema.items) {
return "array";
}
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
const values = schema.enum.filter((value) => value !== null);
if (values.length > 0 && values.every((value) => typeof value === "string")) {
return "string";
}
if (values.length > 0 && values.every((value) => typeof value === "number")) {
return "number";
}
if (values.length > 0 && values.every((value) => typeof value === "boolean")) {
return "boolean";
}
}
for (const unionKey of ["anyOf", "oneOf"] as const) {
const variants = schema[unionKey];
if (!Array.isArray(variants)) {
continue;
}
for (const variant of variants) {
if (!isRecord(variant)) {
continue;
}
const variantType = variant.type;
if (typeof variantType === "string" && variantType !== "null") {
return variantType;
}
if (Array.isArray(variantType)) {
const firstType = variantType.find(
(entry): entry is string => typeof entry === "string" && entry !== "null",
);
if (firstType) {
return firstType;
}
}
const inferred = inferOllamaSchemaType(variant);
if (inferred) {
return inferred;
}
}
}
return undefined;
}
function normalizeOllamaToolSchema(schema: unknown, isRoot = false): Record<string, unknown> {
if (!isRecord(schema)) {
return {
type: "object",
properties: {},
};
}
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schema)) {
if (key === "properties" && isRecord(value)) {
normalized.properties = Object.fromEntries(
Object.entries(value).map(([propertyName, propertySchema]) => [
propertyName,
normalizeOllamaToolSchema(propertySchema),
]),
);
continue;
}
if (key === "items") {
normalized.items = Array.isArray(value)
? value.map((entry) => normalizeOllamaToolSchema(entry))
: normalizeOllamaToolSchema(value);
continue;
}
if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) {
normalized[key] = value.map((entry) => normalizeOllamaToolSchema(entry));
continue;
}
normalized[key] = value;
}
const schemaType = normalized.type;
if (
typeof schemaType !== "string" &&
(!Array.isArray(schemaType) ||
!schemaType.some((entry) => typeof entry === "string" && entry !== "null"))
) {
normalized.type = inferOllamaSchemaType(normalized) ?? (isRoot ? "object" : "string");
}
if (normalized.type === "object" && !isRecord(normalized.properties)) {
normalized.properties = {};
}
return normalized;
}
function extractToolCalls(content: unknown): OllamaToolCall[] {
if (!Array.isArray(content)) {
return [];
@@ -529,7 +625,7 @@ function extractOllamaTools(tools: Tool[] | undefined): OllamaTool[] {
function: {
name: tool.name,
description: typeof tool.description === "string" ? tool.description : "",
parameters: (tool.parameters ?? {}) as Record<string, unknown>,
parameters: normalizeOllamaToolSchema(tool.parameters, true),
},
});
}
@@ -653,6 +749,7 @@ export function createOllamaStreamFn(
const body = buildOllamaChatRequest({
modelId: model.id,
providerId: model.provider,
messages: ollamaMessages,
stream: true,
tools: ollamaTools,

View File

@@ -184,6 +184,90 @@ describe("ollama web search provider", () => {
expect(release).toHaveBeenCalledTimes(1);
});
it("falls back to the legacy Ollama web search endpoint when /api/web_search is missing", async () => {
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response("not found", { status: 404 }),
release: vi.fn(async () => {}),
})
.mockResolvedValueOnce({
response: new Response(
JSON.stringify({
results: [{ title: "Legacy", url: "https://example.com", content: "result" }],
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
release: vi.fn(async () => {}),
});
await expect(
runOllamaWebSearch({ config: createOllamaConfig(), query: "openclaw" }),
).resolves.toMatchObject({
count: 1,
results: [{ url: "https://example.com" }],
});
expect(fetchWithSsrFGuardMock.mock.calls.map((call) => call[0].url)).toEqual([
"http://ollama.local:11434/api/web_search",
"http://ollama.local:11434/api/experimental/web_search",
]);
});
it("uses an env Ollama key only for the cloud fallback from a local host", async () => {
const original = process.env.OLLAMA_API_KEY;
try {
process.env.OLLAMA_API_KEY = "cloud-secret";
fetchWithSsrFGuardMock
.mockResolvedValueOnce({
response: new Response("not found", { status: 404 }),
release: vi.fn(async () => {}),
})
.mockResolvedValueOnce({
response: new Response("not found", { status: 404 }),
release: vi.fn(async () => {}),
})
.mockResolvedValueOnce({
response: new Response(
JSON.stringify({
results: [{ title: "Cloud", url: "https://example.com", content: "result" }],
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
release: vi.fn(async () => {}),
});
await expect(
runOllamaWebSearch({ config: createOllamaConfig(), query: "openclaw" }),
).resolves.toMatchObject({
count: 1,
});
const firstHeaders = fetchWithSsrFGuardMock.mock.calls[0]?.[0].init?.headers as
| Record<string, string>
| undefined;
const cloudHeaders = fetchWithSsrFGuardMock.mock.calls[2]?.[0].init?.headers as
| Record<string, string>
| undefined;
expect(firstHeaders?.Authorization).toBeUndefined();
expect(cloudHeaders?.Authorization).toBe("Bearer cloud-secret");
expect(fetchWithSsrFGuardMock.mock.calls[2]?.[0].url).toBe(
"https://ollama.com/api/web_search",
);
} finally {
if (original === undefined) {
delete process.env.OLLAMA_API_KEY;
} else {
process.env.OLLAMA_API_KEY = original;
}
}
});
it("surfaces Ollama signin guidance for 401 responses", async () => {
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response("", { status: 401 }),

View File

@@ -42,6 +42,8 @@ const OLLAMA_WEB_SEARCH_SCHEMA = Type.Object(
);
const OLLAMA_WEB_SEARCH_PATH = "/api/web_search";
const OLLAMA_LEGACY_WEB_SEARCH_PATH = "/api/experimental/web_search";
const OLLAMA_CLOUD_BASE_URL = "https://ollama.com";
const DEFAULT_OLLAMA_WEB_SEARCH_COUNT = 5;
const DEFAULT_OLLAMA_WEB_SEARCH_TIMEOUT_MS = 15_000;
const OLLAMA_WEB_SEARCH_SNIPPET_MAX_CHARS = 300;
@@ -56,14 +58,31 @@ type OllamaWebSearchResponse = {
results?: OllamaWebSearchResult[];
};
function resolveOllamaWebSearchApiKey(config?: OpenClawConfig): string | undefined {
function isOllamaCloudBaseUrl(baseUrl: string): boolean {
try {
const parsed = new URL(baseUrl);
return parsed.protocol === "https:" && parsed.hostname === "ollama.com";
} catch {
return false;
}
}
function resolveConfiguredOllamaWebSearchApiKey(config?: OpenClawConfig): string | undefined {
const providerApiKey = normalizeOptionalSecretInput(config?.models?.providers?.ollama?.apiKey);
if (providerApiKey && !isNonSecretApiKeyMarker(providerApiKey)) {
return providerApiKey;
}
return undefined;
}
function resolveEnvOllamaWebSearchApiKey(): string | undefined {
return resolveEnvApiKey("ollama")?.apiKey;
}
function resolveOllamaWebSearchApiKey(config?: OpenClawConfig): string | undefined {
return resolveConfiguredOllamaWebSearchApiKey(config) ?? resolveEnvOllamaWebSearchApiKey();
}
function resolveOllamaWebSearchBaseUrl(config?: OpenClawConfig): string {
const pluginBaseUrl = normalizeOptionalString(
resolveProviderWebSearchPluginConfig(config, "ollama")?.baseUrl,
@@ -103,71 +122,117 @@ export async function runOllamaWebSearch(params: {
}
const baseUrl = resolveOllamaWebSearchBaseUrl(params.config);
const apiKey = resolveOllamaWebSearchApiKey(params.config);
const configuredApiKey = resolveConfiguredOllamaWebSearchApiKey(params.config);
const envApiKey = resolveEnvOllamaWebSearchApiKey();
const count = resolveSearchCount(params.count, DEFAULT_OLLAMA_WEB_SEARCH_COUNT);
const startedAt = Date.now();
const 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),
const body = JSON.stringify({ query, max_results: count });
const attempts = [
{
baseUrl,
path: OLLAMA_WEB_SEARCH_PATH,
apiKey: isOllamaCloudBaseUrl(baseUrl) ? (configuredApiKey ?? envApiKey) : configuredApiKey,
},
policy: buildOllamaBaseUrlSsrFPolicy(baseUrl),
auditContext: "ollama-web-search.search",
});
{
baseUrl,
path: OLLAMA_LEGACY_WEB_SEARCH_PATH,
apiKey: isOllamaCloudBaseUrl(baseUrl) ? (configuredApiKey ?? envApiKey) : configuredApiKey,
},
...(!isOllamaCloudBaseUrl(baseUrl) && envApiKey
? [
{
baseUrl: OLLAMA_CLOUD_BASE_URL,
path: OLLAMA_WEB_SEARCH_PATH,
apiKey: envApiKey,
},
]
: []),
];
try {
if (response.status === 401) {
throw new Error("Ollama web search authentication failed. Run `ollama signin`.");
let payload: OllamaWebSearchResponse | undefined;
let lastError: Error | undefined;
for (const attempt of attempts) {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (attempt.apiKey) {
headers.Authorization = `Bearer ${attempt.apiKey}`;
}
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,
const { response, release } = await fetchWithSsrFGuard({
url: `${attempt.baseUrl}${attempt.path}`,
init: {
method: "POST",
headers,
body,
signal: AbortSignal.timeout(DEFAULT_OLLAMA_WEB_SEARCH_TIMEOUT_MS),
},
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();
policy: buildOllamaBaseUrlSsrFPolicy(attempt.baseUrl),
auditContext: "ollama-web-search.search",
});
try {
if (response.status === 401) {
throw new Error("Ollama web search authentication failed. Run `ollama signin`.");
}
if (response.status === 403) {
throw new Error(
"Ollama web search is unavailable. Ensure cloud-backed web search is enabled on the Ollama host.",
);
}
if (!response.ok) {
const detail = await readResponseText(response, { maxBytes: 64_000 });
const message =
`Ollama web search failed (${response.status}): ${detail.text || ""}`.trim();
if (response.status === 404) {
lastError = new Error(message);
continue;
}
throw new Error(message);
}
payload = (await response.json()) as OllamaWebSearchResponse;
break;
} catch (error) {
if (error instanceof Error) {
lastError = error;
} else {
lastError = new Error(String(error));
}
throw lastError;
} finally {
await release();
}
}
if (!payload) {
throw lastError ?? new Error("Ollama web search failed");
}
const results = Array.isArray(payload.results)
? payload.results
.map(normalizeOllamaWebSearchResult)
.filter((result): result is NonNullable<typeof result> => result !== null)
.slice(0, count)
: [];
return {
query,
provider: "ollama",
count: results.length,
tookMs: Date.now() - startedAt,
externalContent: {
untrusted: true,
source: "web_search",
provider: "ollama",
wrapped: true,
},
results: results.map((result) => {
const snippet = truncateText(result.content, OLLAMA_WEB_SEARCH_SNIPPET_MAX_CHARS).text;
return {
title: result.title ? wrapWebContent(result.title, "web_search") : "",
url: result.url,
snippet: snippet ? wrapWebContent(snippet, "web_search") : "",
siteName: resolveSiteName(result.url) || undefined,
};
}),
};
}
async function warnOllamaWebSearchPrereqs(params: {
@@ -241,7 +306,10 @@ export function createOllamaWebSearchProvider(): WebSearchProviderPlugin {
export const __testing = {
normalizeOllamaWebSearchResult,
resolveConfiguredOllamaWebSearchApiKey,
resolveEnvOllamaWebSearchApiKey,
resolveOllamaWebSearchApiKey,
resolveOllamaWebSearchBaseUrl,
isOllamaCloudBaseUrl,
warnOllamaWebSearchPrereqs,
};

View File

@@ -0,0 +1,27 @@
import { normalizeProviderId } from "../agents/provider-id.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
export function resolveProviderConfigApiOwnerHint(params: {
provider: string;
config?: OpenClawConfig;
}): string | undefined {
const providers = params.config?.models?.providers;
if (!providers) {
return undefined;
}
const normalizedProvider = normalizeProviderId(params.provider);
if (!normalizedProvider) {
return undefined;
}
const providerConfig =
providers[params.provider] ??
Object.entries(providers).find(
([candidateId]) => normalizeProviderId(candidateId) === normalizedProvider,
)?.[1];
const api =
typeof providerConfig?.api === "string" ? normalizeProviderId(providerConfig.api) : "";
if (!api || api === normalizedProvider) {
return undefined;
}
return api;
}

View File

@@ -1,6 +1,7 @@
import { normalizeProviderId } from "../agents/provider-id.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizePluginIdScope, serializePluginIdScope } from "./plugin-scope.js";
import { resolveProviderConfigApiOwnerHint } from "./provider-config-owner.js";
import { isPluginProvidersLoadInFlight, resolvePluginProviders } from "./providers.runtime.js";
import { resolvePluginCacheInputs } from "./roots.js";
import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js";
@@ -164,16 +165,24 @@ export function resolveProviderRuntimePlugin(params: {
bundledProviderVitestCompat?: boolean;
installBundledRuntimeDeps?: boolean;
}): ProviderPlugin | undefined {
const apiOwnerHint = resolveProviderConfigApiOwnerHint({
provider: params.provider,
config: params.config,
});
return resolveProviderPluginsForHooks({
config: params.config,
workspaceDir: params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState(),
env: params.env,
providerRefs: [params.provider],
providerRefs: apiOwnerHint ? [params.provider, apiOwnerHint] : [params.provider],
applyAutoEnable: params.applyAutoEnable,
bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat,
bundledProviderVitestCompat: params.bundledProviderVitestCompat,
installBundledRuntimeDeps: params.installBundledRuntimeDeps,
}).find((plugin) => matchesProviderId(plugin, params.provider));
}).find(
(plugin) =>
matchesProviderId(plugin, params.provider) ||
(apiOwnerHint ? matchesProviderId(plugin, apiOwnerHint) : false),
);
}
export function resolveProviderHookPlugin(params: {

View File

@@ -1630,6 +1630,38 @@ describe("provider-runtime", () => {
);
});
it("matches provider hooks through a custom provider's native api owner", () => {
const ollamaPlugin: ProviderPlugin = {
id: "ollama",
label: "Ollama",
auth: [],
createStreamFn: vi.fn(() => vi.fn()),
};
resolvePluginProvidersMock.mockReturnValue([ollamaPlugin]);
const plugin = resolveProviderRuntimePlugin({
provider: "ollama-spark",
config: {
models: {
providers: {
"ollama-spark": {
api: "ollama",
baseUrl: "http://127.0.0.1:11434",
models: [],
},
},
},
} as never,
});
expect(plugin).toBe(ollamaPlugin);
expect(resolvePluginProvidersMock).toHaveBeenCalledWith(
expect.objectContaining({
providerRefs: ["ollama-spark", "ollama"],
}),
);
});
it("merges compat contributions from owner and foreign provider plugins", () => {
resolvePluginProvidersMock.mockImplementation((params) => {
const onlyPluginIds = params.onlyPluginIds ?? [];

View File

@@ -8,6 +8,7 @@ import {
type PluginLoadOptions,
} from "./loader.js";
import { hasExplicitPluginIdScope } from "./plugin-scope.js";
import { resolveProviderConfigApiOwnerHint } from "./provider-config-owner.js";
import {
resolveActivatableProviderOwnerPluginIds,
resolveDiscoverableProviderOwnerPluginIds,
@@ -49,6 +50,33 @@ function resolveExplicitProviderOwnerPluginIds(params: {
if (plannedPluginIds.length > 0) {
return plannedPluginIds;
}
const apiOwnerHint = resolveProviderConfigApiOwnerHint({
provider,
config: params.config,
});
if (apiOwnerHint) {
const apiOwnerPluginIds = resolveManifestActivationPluginIds({
trigger: {
kind: "provider",
provider: apiOwnerHint,
},
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
if (apiOwnerPluginIds.length > 0) {
return apiOwnerPluginIds;
}
const legacyApiOwnerPluginIds = resolveOwningPluginIdsForProvider({
provider: apiOwnerHint,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
if (legacyApiOwnerPluginIds?.length) {
return legacyApiOwnerPluginIds;
}
}
// Keep legacy provider/CLI-backend ownership working until every owner is
// expressible through activation descriptors.
return (

View File

@@ -804,6 +804,47 @@ describe("resolvePluginProviders", () => {
);
});
it("activates the owner plugin for custom provider refs that use a native provider api", () => {
setManifestPlugins([
createManifestProviderPlugin({
id: "ollama",
providerIds: ["ollama"],
enabledByDefault: true,
}),
]);
resolvePluginProviders({
config: {
models: {
providers: {
"ollama-spark": {
api: "ollama",
baseUrl: "http://127.0.0.1:11434",
models: [],
},
},
},
} as OpenClawConfig,
providerRefs: ["ollama-spark"],
activate: true,
});
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["ollama"],
activate: true,
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: ["ollama"],
entries: {
ollama: { enabled: true },
},
}),
}),
}),
);
});
it("uses activation.onProviders to keep explicit provider owners on the runtime path", () => {
setManifestPlugins([
createManifestProviderPlugin({