mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:50:43 +00:00
fix(ollama): harden native provider routing
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
149
extensions/ollama/ollama.live.test.ts
Normal file
149
extensions/ollama/ollama.live.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
24
extensions/ollama/src/model-id.ts
Normal file
24
extensions/ollama/src/model-id.ts
Normal 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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user