From a3e0674261633234aba1bdb855c4bb4a511608a7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 01:01:51 +0100 Subject: [PATCH] fix(ollama): harden native provider routing --- CHANGELOG.md | 4 + docs/providers/ollama.md | 7 +- docs/tools/ollama-search.md | 8 +- extensions/ollama/index.test.ts | 10 +- extensions/ollama/index.ts | 12 +- extensions/ollama/ollama.live.test.ts | 149 ++++++++++++++ .../ollama/src/embedding-provider.test.ts | 88 +++++++-- extensions/ollama/src/embedding-provider.ts | 78 +++++--- extensions/ollama/src/model-id.ts | 24 +++ extensions/ollama/src/stream-runtime.test.ts | 127 ++++++++++++ extensions/ollama/src/stream.ts | 111 ++++++++++- .../ollama/src/web-search-provider.test.ts | 84 ++++++++ extensions/ollama/src/web-search-provider.ts | 186 ++++++++++++------ src/plugins/provider-config-owner.ts | 27 +++ src/plugins/provider-hook-runtime.ts | 13 +- src/plugins/provider-runtime.test.ts | 32 +++ src/plugins/providers.runtime.ts | 28 +++ src/plugins/providers.test.ts | 41 ++++ 18 files changed, 909 insertions(+), 120 deletions(-) create mode 100644 extensions/ollama/ollama.live.test.ts create mode 100644 extensions/ollama/src/model-id.ts create mode 100644 src/plugins/provider-config-owner.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index eb9d23cfe2b..6081dc1759a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/providers/ollama.md b/docs/providers/ollama.md index 68ea42d8dec..339dd1d7fe3 100644 --- a/docs/providers/ollama.md +++ b/docs/providers/ollama.md @@ -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 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 | | ------------- | ------------------- | diff --git a/docs/tools/ollama-search.md b/docs/tools/ollama-search.md index 96036f62a05..073cb39d7c1 100644 --- a/docs/tools/ollama-search.md +++ b/docs/tools/ollama-search.md @@ -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 diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index f7792b97d76..e9ee52de232 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -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", () => { diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 956359f7dda..4ca916d7d64 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -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, diff --git a/extensions/ollama/ollama.live.test.ts b/extensions/ollama/ollama.live.test.ts new file mode 100644 index 00000000000..c4d4666dd1c --- /dev/null +++ b/extensions/ollama/ollama.live.test.ts @@ -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(stream: AsyncIterable): Promise { + 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; + }; + }; + }>; + } + | 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; + }, + } 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, + ); +}); diff --git a/extensions/ollama/src/embedding-provider.test.ts b/extensions/ollama/src/embedding-provider.test.ts index a6f7ad02078..533ecd3e8e8 100644 --- a/extensions/ollama/src/embedding-provider.test.ts +++ b/extensions/ollama/src/embedding-provider.test.ts @@ -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 () => { diff --git a/extensions/ollama/src/embedding-provider.ts b/extensions/ollama/src/embedding-provider.ts index c1e1421b79d..68753fc1f07 100644 --- a/extensions/ollama/src/embedding-provider.ts +++ b/extensions/ollama/src/embedding-provider.ts @@ -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; 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(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 = { "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 => { + const embedMany = async (input: string | string[]): Promise => { 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 => { + 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 { diff --git a/extensions/ollama/src/model-id.ts b/extensions/ollama/src/model-id.ts new file mode 100644 index 00000000000..df0bcae7e73 --- /dev/null +++ b/extensions/ollama/src/model-id.ts @@ -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; +} diff --git a/extensions/ollama/src/stream-runtime.test.ts b/extensions/ollama/src/stream-runtime.test.ts index 2e502be5d15..d6598dd04eb 100644 --- a/extensions/ollama/src/stream-runtime.test.ts +++ b/extensions/ollama/src/stream-runtime.test.ts @@ -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; + }; + }; + }>; + }; + 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", () => { diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.ts index 2a1093ddb5b..c1f45a2070e 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.ts @@ -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; 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 { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function inferOllamaSchemaType(schema: Record): 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 { + if (!isRecord(schema)) { + return { + type: "object", + properties: {}, + }; + } + + const normalized: Record = {}; + 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, + 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, diff --git a/extensions/ollama/src/web-search-provider.test.ts b/extensions/ollama/src/web-search-provider.test.ts index c336c591ca4..4d70d28f51c 100644 --- a/extensions/ollama/src/web-search-provider.test.ts +++ b/extensions/ollama/src/web-search-provider.test.ts @@ -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 + | undefined; + const cloudHeaders = fetchWithSsrFGuardMock.mock.calls[2]?.[0].init?.headers as + | Record + | 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 }), diff --git a/extensions/ollama/src/web-search-provider.ts b/extensions/ollama/src/web-search-provider.ts index 61279ef2c95..c4ed075ff2f 100644 --- a/extensions/ollama/src/web-search-provider.ts +++ b/extensions/ollama/src/web-search-provider.ts @@ -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 = { "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 = { "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 => 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 => 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, }; diff --git a/src/plugins/provider-config-owner.ts b/src/plugins/provider-config-owner.ts new file mode 100644 index 00000000000..e863082df93 --- /dev/null +++ b/src/plugins/provider-config-owner.ts @@ -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; +} diff --git a/src/plugins/provider-hook-runtime.ts b/src/plugins/provider-hook-runtime.ts index ef9c2961939..4d7c705c7fe 100644 --- a/src/plugins/provider-hook-runtime.ts +++ b/src/plugins/provider-hook-runtime.ts @@ -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: { diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 86d2db022ea..741a7c2404a 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -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 ?? []; diff --git a/src/plugins/providers.runtime.ts b/src/plugins/providers.runtime.ts index 7b3cfbbb087..d723a1808bb 100644 --- a/src/plugins/providers.runtime.ts +++ b/src/plugins/providers.runtime.ts @@ -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 ( diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index a34057ba930..ed20ed7cec5 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -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({