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

@@ -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,
};