From 2fc2fc930b24ec579efd9d00826a280805924b7d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 6 Aug 2026 07:40:22 +0800 Subject: [PATCH] improve(ollama): reduce inactive startup cost (#119745) * improve(ollama): defer optional runtime imports Punchcard-Session: coral-workshop-workshop-3f * improve(ollama): isolate lazy runtime modules * improve(ollama): preserve lazy runtime boundaries --- config/max-lines-baseline.txt | 2 +- extensions/ollama/api.ts | 2 +- extensions/ollama/index.test.ts | 47 +- extensions/ollama/index.ts | 116 +++- extensions/ollama/lazy-import.test.ts | 271 ++++++++ extensions/ollama/ollama.live.test.ts | 2 +- extensions/ollama/runtime-api.ts | 2 +- extensions/ollama/src/defaults.ts | 9 +- .../ollama/src/embedding-provider.runtime.ts | 501 ++++++++++++++ extensions/ollama/src/embedding-provider.ts | 510 +-------------- .../ollama/src/node-inference-contract.ts | 41 ++ .../ollama/src/node-inference-registration.ts | 71 ++ extensions/ollama/src/node-inference.ts | 56 +- .../ollama/src/setup-body-release.test.ts | 2 +- extensions/ollama/src/setup.runtime.ts | 596 +++++++++++++++++ extensions/ollama/src/setup.test.ts | 2 +- extensions/ollama/src/setup.ts | 613 +----------------- extensions/ollama/src/stream-api.ts | 31 + extensions/ollama/src/stream-compat.ts | 243 +++++++ extensions/ollama/src/stream-contract.ts | 2 + extensions/ollama/src/stream-registration.ts | 17 + extensions/ollama/src/stream-runtime.test.ts | 4 +- .../src/{stream.ts => stream.runtime.ts} | 270 +------- extensions/ollama/src/stream.test.ts | 6 +- extensions/ollama/src/web-search-contract.ts | 19 + .../src/web-search-provider-registration.ts | 42 ++ .../ollama/src/web-search-provider.runtime.ts | 367 +++++++++++ extensions/ollama/src/web-search-provider.ts | 380 +---------- 28 files changed, 2387 insertions(+), 1837 deletions(-) create mode 100644 extensions/ollama/lazy-import.test.ts create mode 100644 extensions/ollama/src/embedding-provider.runtime.ts create mode 100644 extensions/ollama/src/node-inference-contract.ts create mode 100644 extensions/ollama/src/node-inference-registration.ts create mode 100644 extensions/ollama/src/setup.runtime.ts create mode 100644 extensions/ollama/src/stream-api.ts create mode 100644 extensions/ollama/src/stream-compat.ts create mode 100644 extensions/ollama/src/stream-contract.ts create mode 100644 extensions/ollama/src/stream-registration.ts rename extensions/ollama/src/{stream.ts => stream.runtime.ts} (84%) create mode 100644 extensions/ollama/src/web-search-contract.ts create mode 100644 extensions/ollama/src/web-search-provider-registration.ts create mode 100644 extensions/ollama/src/web-search-provider.runtime.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 7cbb8992364c..d01a800029c9 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -180,7 +180,7 @@ extensions/oc-path/src/oc-path/universal.ts extensions/ollama/index.test.ts extensions/ollama/index.ts extensions/ollama/src/stream-runtime.test.ts -extensions/ollama/src/stream.ts +extensions/ollama/src/stream.runtime.ts extensions/openai/image-generation-provider.test.ts extensions/openai/image-generation-provider.ts extensions/openai/openai-provider.test.ts diff --git a/extensions/ollama/api.ts b/extensions/ollama/api.ts index 765966755c3e..9ad73910d111 100644 --- a/extensions/ollama/api.ts +++ b/extensions/ollama/api.ts @@ -33,4 +33,4 @@ export { resolveOllamaCompatNumCtxEnabled, shouldInjectOllamaCompatNumCtx, wrapOllamaCompatNumCtx, -} from "./src/stream.js"; +} from "./src/stream-api.js"; diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index 75efc387e7d1..80737710623f 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -1,9 +1,5 @@ // Ollama tests cover index plugin behavior. import { expectDefined } from "@openclaw/normalization-core"; -import { - describeImageWithModel, - describeImagesWithModel, -} from "openclaw/plugin-sdk/media-understanding"; import type { ProviderAuthMethod } from "openclaw/plugin-sdk/plugin-entry"; import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; import { clearLiveCatalogCacheForTests } from "openclaw/plugin-sdk/provider-catalog-shared"; @@ -56,28 +52,16 @@ const buildOllamaModelDefinitionMock = vi.hoisted(() => }), ); const createConfiguredOllamaStreamFnMock = vi.hoisted(() => - vi.fn((_params: { model: unknown; providerBaseUrl?: string }) => ({}) as never), + vi.fn((_params: { model: unknown; providerBaseUrl?: string }) => (() => ({})) as never), ); -vi.mock("./api.js", () => ({ - promptAndConfigureOllama: promptAndConfigureOllamaMock, - ensureOllamaModelPulled: ensureOllamaModelPulledMock, - configureOllamaNonInteractive: configureOllamaNonInteractiveMock, - fetchOllamaModels: fetchOllamaModelsMock, - resolveOllamaApiBase: (baseUrl?: string) => - (baseUrl ?? "http://127.0.0.1:11434").replace(/\/+$/, "").replace(/\/v1$/i, ""), - resolveOllamaSetupDefaultBaseUrl: (env: NodeJS.ProcessEnv = process.env) => - ["1", "true", "yes", "on"].includes(env.OPENCLAW_DOCKER_SETUP?.trim().toLowerCase() ?? "") - ? "http://host.docker.internal:11434" - : "http://127.0.0.1:11434", - buildOllamaProvider: buildOllamaProviderMock, - queryOllamaModelShowInfo: queryOllamaModelShowInfoMock, - buildOllamaModelDefinition: buildOllamaModelDefinitionMock, -})); - vi.mock("./src/provider-models.js", async (importOriginal) => ({ ...(await importOriginal()), + buildOllamaModelDefinition: buildOllamaModelDefinitionMock, + buildOllamaProvider: buildOllamaProviderMock, + fetchOllamaModels: fetchOllamaModelsMock, fetchLoadedOllamaModelNames: fetchLoadedOllamaModelNamesMock, + queryOllamaModelShowInfo: queryOllamaModelShowInfoMock, })); vi.mock("openclaw/plugin-sdk/secret-input-runtime", async (importOriginal) => { @@ -90,18 +74,17 @@ vi.mock("openclaw/plugin-sdk/secret-input-runtime", async (importOriginal) => { }; }); -vi.mock("./src/setup.js", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("./src/setup.runtime.js", async (importOriginal) => ({ + ...(await importOriginal()), checkOllamaCloudAuth: checkOllamaCloudAuthMock, + configureOllamaNonInteractive: configureOllamaNonInteractiveMock, + ensureOllamaModelPulled: ensureOllamaModelPulledMock, + promptAndConfigureOllama: promptAndConfigureOllamaMock, })); -vi.mock("./src/stream.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - createConfiguredOllamaStreamFn: createConfiguredOllamaStreamFnMock, - }; -}); +vi.mock("./src/stream-registration.js", () => ({ + createLazyConfiguredOllamaStreamFn: createConfiguredOllamaStreamFnMock, +})); beforeEach(() => { clearLiveCatalogCacheForTests(); @@ -2520,8 +2503,8 @@ describe("ollama plugin", () => { const ollamaMedia = expectDefined(mediaProviders[0], "Ollama media provider"); expect(ollamaMedia.id).toBe("ollama"); expect(ollamaMedia.capabilities).toEqual(["image"]); - expect(ollamaMedia.describeImage).toBe(describeImageWithModel); - expect(ollamaMedia.describeImages).toBe(describeImagesWithModel); + expect(ollamaMedia.describeImage).toBeTypeOf("function"); + expect(ollamaMedia.describeImages).toBeTypeOf("function"); // Intentional: no defaultModels or autoPriority. Ollama vision models are // user-installed (llava, qwen2.5vl, …) with no universal default, and we // don't want Ollama to auto-steal image duty from configured providers. diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index d2365613564a..49c790a787d5 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -2,6 +2,9 @@ import { createHash } from "node:crypto"; import { collectConfiguredModelRefValues } from "@openclaw/model-catalog-core/configured-model-refs"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; +import type { MediaUnderstandingProvider } from "openclaw/plugin-sdk/media-understanding"; +import type { MemoryEmbeddingProviderAdapter } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime"; import { definePluginEntry, @@ -30,25 +33,16 @@ import type { import { buildOpenAICompatibleReplayPolicy } from "openclaw/plugin-sdk/provider-model-shared"; import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools"; import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime"; -import { - buildOllamaModelDefinition, - buildOllamaProvider, - configureOllamaNonInteractive, - ensureOllamaModelPulled, - fetchOllamaModels, - promptAndConfigureOllama, - queryOllamaModelShowInfo, - resolveOllamaApiBase, - resolveOllamaSetupDefaultBaseUrl, -} from "./api.js"; import { resolveThinkingProfile as resolveOllamaThinkingProfile } from "./provider-policy-api.js"; import { + DEFAULT_OLLAMA_EMBEDDING_MODEL, OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_DEFAULT_MODELS, OLLAMA_CLOUD_PROVIDER_ID, OLLAMA_DEFAULT_BASE_URL, OLLAMA_DEFAULT_MODEL, OLLAMA_GLM52_CLOUD_MODEL_ID, + resolveOllamaSetupDefaultBaseUrl, } from "./src/defaults.js"; import { OLLAMA_DEFAULT_API_KEY, @@ -60,23 +54,22 @@ import { type OllamaPluginConfig, } from "./src/discovery-shared.js"; import { - DEFAULT_OLLAMA_EMBEDDING_MODEL, - createOllamaEmbeddingProvider, -} from "./src/embedding-provider.js"; -import { ollamaMediaUnderstandingProvider } from "./src/media-understanding-provider.js"; -import { ollamaMemoryEmbeddingProviderAdapter } from "./src/memory-embedding-adapter.js"; -import { - createOllamaNodeHostCommands, - createOllamaNodeInferenceTool, + createLazyOllamaNodeHostCommands, + createLazyOllamaNodeInferenceTool, createOllamaNodeInvokePolicy, -} from "./src/node-inference.js"; +} from "./src/node-inference-registration.js"; import { readProviderBaseUrl } from "./src/provider-base-url.js"; import { + buildOllamaModelDefinition, + buildOllamaProvider, buildDefaultOllamaCloudModelDefinition, capLocalOllamaModelContext, capLocalOllamaProviderContext, + fetchOllamaModels, fetchLoadedOllamaModelNames, isOllamaCloudModel, + queryOllamaModelShowInfo, + resolveOllamaApiBase, } from "./src/provider-models.js"; import { findAvailableOllamaModelName, @@ -84,13 +77,66 @@ import { orderPreferredOllamaModelIds, } from "./src/setup-model-selection.js"; import { - OLLAMA_INCOMPLETE_STREAM_ERROR, createConfiguredOllamaCompatStreamWrapper, - createConfiguredOllamaStreamFn, resolveConfiguredOllamaProviderConfig, -} from "./src/stream.js"; -import { createOllamaWebSearchProvider } from "./src/web-search-provider.js"; -import { checkWsl2CrashLoopRisk } from "./src/wsl2-crash-loop-check.js"; +} from "./src/stream-compat.js"; +import { OLLAMA_INCOMPLETE_STREAM_ERROR } from "./src/stream-contract.js"; +import { createLazyConfiguredOllamaStreamFn } from "./src/stream-registration.js"; +import { createLazyOllamaWebSearchProvider } from "./src/web-search-provider-registration.js"; + +const loadOllamaSetup = createLazyRuntimeModule(() => import("./src/setup.runtime.js")); +const loadOllamaEmbeddingProvider = createLazyRuntimeModule( + () => import("./src/embedding-provider.runtime.js"), +); +const loadOllamaMemoryEmbeddingProviderAdapter = createLazyRuntimeModule( + async () => + (await import("./src/memory-embedding-adapter.js")).ollamaMemoryEmbeddingProviderAdapter, +); +const loadOllamaMediaUnderstandingProvider = createLazyRuntimeModule( + async () => + (await import("./src/media-understanding-provider.js")).ollamaMediaUnderstandingProvider, +); + +const lazyOllamaMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = { + id: OLLAMA_PROVIDER_ID, + defaultModel: DEFAULT_OLLAMA_EMBEDDING_MODEL, + transport: "remote", + authProviderId: OLLAMA_PROVIDER_ID, + create: async (options) => + await (await loadOllamaMemoryEmbeddingProviderAdapter()).create(options), +}; + +const lazyOllamaMediaUnderstandingProvider: MediaUnderstandingProvider = { + id: OLLAMA_PROVIDER_ID, + capabilities: ["image"], + describeImage: async (request) => { + const provider = await loadOllamaMediaUnderstandingProvider(); + if (!provider.describeImage) { + throw new Error("Ollama media understanding provider missing describeImage"); + } + return await provider.describeImage(request); + }, + describeImages: async (request) => { + const provider = await loadOllamaMediaUnderstandingProvider(); + if (!provider.describeImages) { + throw new Error("Ollama media understanding provider missing describeImages"); + } + return await provider.describeImages(request); + }, +}; + +async function checkWsl2CrashLoopRiskLazily(api: OpenClawPluginApi): Promise { + try { + const { isWSL2Sync } = await import("openclaw/plugin-sdk/runtime-env"); + if (!isWSL2Sync()) { + return; + } + const { checkWsl2CrashLoopRisk } = await import("./src/wsl2-crash-loop-check.js"); + await checkWsl2CrashLoopRisk(api.logger); + } catch { + // Advisory-only startup checks must not break provider registration. + } +} function buildNativeOllamaReplayPolicy(): ProviderReplayPolicy { return { @@ -156,7 +202,7 @@ async function validateOllamaNonInteractive( ); if (requestedModel && isOllamaCloudModel(requestedModel)) { - const { checkOllamaCloudAuth } = await import("./src/setup.js"); + const { checkOllamaCloudAuth } = await loadOllamaSetup(); const cloudAuth = await checkOllamaCloudAuth(baseUrl); if (!cloudAuth.signedIn) { ctx.runtime.error( @@ -809,7 +855,7 @@ const OLLAMA_SHARED_PROVIDER_HOOKS = { if (model.api !== "ollama") { return undefined; } - return createConfiguredOllamaStreamFn({ + return createLazyConfiguredOllamaStreamFn({ model, providerBaseUrl: readProviderBaseUrl( @@ -847,17 +893,17 @@ export default definePluginEntry({ register(api: OpenClawPluginApi) { const startupPluginConfig = (api.pluginConfig ?? {}) as OllamaPluginConfig; if (api.registrationMode === "full") { - void checkWsl2CrashLoopRisk(api.logger); + void checkWsl2CrashLoopRiskLazily(api); } - api.registerMemoryEmbeddingProvider(ollamaMemoryEmbeddingProviderAdapter); - api.registerMediaUnderstandingProvider(ollamaMediaUnderstandingProvider); + api.registerMemoryEmbeddingProvider(lazyOllamaMemoryEmbeddingProviderAdapter); + api.registerMediaUnderstandingProvider(lazyOllamaMediaUnderstandingProvider); if (startupPluginConfig.nodeInference?.enabled !== false) { - for (const command of createOllamaNodeHostCommands()) { + for (const command of createLazyOllamaNodeHostCommands()) { api.registerNodeHostCommand(command); } } api.registerNodeInvokePolicy(createOllamaNodeInvokePolicy()); - api.registerTool(createOllamaNodeInferenceTool(api)); + api.registerTool(createLazyOllamaNodeInferenceTool(api)); const resolveCurrentPluginConfig = (config?: OpenClawConfig): OllamaPluginConfig => { const runtimePluginConfig = resolvePluginConfigObject(config, "ollama"); if (runtimePluginConfig) { @@ -865,7 +911,7 @@ export default definePluginEntry({ } return config ? {} : startupPluginConfig; }; - api.registerWebSearchProvider(createOllamaWebSearchProvider()); + api.registerWebSearchProvider(createLazyOllamaWebSearchProvider()); api.registerProvider({ id: OLLAMA_CLOUD_PROVIDER_ID, label: "Ollama Cloud", @@ -1002,6 +1048,7 @@ export default definePluginEntry({ }, }, run: async (ctx: ProviderAuthContext): Promise => { + const { promptAndConfigureOllama } = await loadOllamaSetup(); const result = await promptAndConfigureOllama({ cfg: ctx.config, env: ctx.env, @@ -1034,6 +1081,7 @@ export default definePluginEntry({ }, validateNonInteractive: validateOllamaNonInteractive, runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => { + const { configureOllamaNonInteractive } = await loadOllamaSetup(); return await configureOllamaNonInteractive({ nextConfig: ctx.config, opts: { @@ -1079,6 +1127,7 @@ export default definePluginEntry({ if (!model.startsWith("ollama/")) { return; } + const { ensureOllamaModelPulled } = await loadOllamaSetup(); await ensureOllamaModelPulled({ config, model, prompter }); }, ...OLLAMA_SHARED_PROVIDER_HOOKS, @@ -1093,6 +1142,7 @@ export default definePluginEntry({ capContextTokens: true, }), createEmbeddingProvider: async ({ config, model, provider: embeddingProvider, remote }) => { + const { createOllamaEmbeddingProvider } = await loadOllamaEmbeddingProvider(); const { provider, client } = await createOllamaEmbeddingProvider({ config, remote, diff --git a/extensions/ollama/lazy-import.test.ts b/extensions/ollama/lazy-import.test.ts new file mode 100644 index 000000000000..a7a8e6b6dbbf --- /dev/null +++ b/extensions/ollama/lazy-import.test.ts @@ -0,0 +1,271 @@ +import type { MediaUnderstandingProvider } from "openclaw/plugin-sdk/media-understanding"; +import type { MemoryEmbeddingProviderAdapter } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; +import type { + AnyAgentTool, + OpenClawPluginNodeHostCommand, + ProviderPlugin, +} from "openclaw/plugin-sdk/plugin-entry"; +import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; +import { createPluginRuntimeMock } from "openclaw/plugin-sdk/plugin-test-runtime"; +import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("ollama lazy imports", () => { + afterEach(() => { + for (const moduleId of [ + "./src/embedding-provider.runtime.js", + "./src/media-understanding-provider.js", + "./src/memory-embedding-adapter.js", + "./src/node-inference.js", + "./src/setup.runtime.js", + "./src/stream.runtime.js", + "./src/web-search-provider.runtime.js", + "./src/wsl2-crash-loop-check.js", + "openclaw/plugin-sdk/runtime-env", + ]) { + vi.doUnmock(moduleId); + } + vi.resetModules(); + }); + + it("loads optional runtime owners only on first use", async () => { + let embeddingImports = 0; + let mediaImports = 0; + let memoryImports = 0; + let nodeInferenceImports = 0; + let setupImports = 0; + let streamImports = 0; + let webSearchImports = 0; + let wslImports = 0; + let wslChecks = 0; + + vi.doMock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => ({ + ...(await importOriginal()), + isWSL2Sync: () => { + wslChecks += 1; + return false; + }, + })); + vi.doMock("./src/embedding-provider.runtime.js", () => { + embeddingImports += 1; + return { + createOllamaEmbeddingProvider: async () => ({ + provider: { id: "ollama", model: "nomic-embed-text" }, + client: { baseUrl: "http://127.0.0.1:11434" }, + }), + }; + }); + vi.doMock("./src/memory-embedding-adapter.js", () => { + memoryImports += 1; + return { + ollamaMemoryEmbeddingProviderAdapter: { + id: "ollama", + defaultModel: "nomic-embed-text", + transport: "remote", + authProviderId: "ollama", + create: async () => ({ provider: null }), + }, + }; + }); + vi.doMock("./src/media-understanding-provider.js", () => { + mediaImports += 1; + return { + ollamaMediaUnderstandingProvider: { + id: "ollama", + capabilities: ["image"], + describeImage: async () => ({ text: "image" }), + describeImages: async () => ({ text: "images" }), + }, + }; + }); + vi.doMock("./src/node-inference.js", () => { + nodeInferenceImports += 1; + return { + createOllamaNodeHostCommands: () => [ + { + command: "ollama.models", + cap: "local-inference", + handle: async () => JSON.stringify({ provider: "ollama", models: [] }), + }, + { + command: "ollama.chat", + cap: "local-inference", + handle: async () => JSON.stringify({ provider: "ollama", response: "ok" }), + }, + ], + createOllamaNodeInferenceTool: () => ({ + name: "node_inference", + label: "Node Inference", + description: "test", + parameters: { type: "object" }, + execute: async () => ({ content: [] }), + }), + }; + }); + vi.doMock("./src/setup.runtime.js", () => { + setupImports += 1; + return { + promptAndConfigureOllama: async () => ({ + credential: "ollama-local", + config: {}, + }), + }; + }); + vi.doMock("./src/stream.runtime.js", () => { + streamImports += 1; + return { + createConfiguredOllamaStreamFn: () => async () => ({ transport: "ollama" }), + }; + }); + vi.doMock("./src/web-search-provider.runtime.js", () => { + webSearchImports += 1; + return { + createOllamaWebSearchProvider: () => ({ + id: "ollama", + label: "Ollama Web Search", + runSetup: async ({ config }: { config: unknown }) => config, + createTool: () => ({ + description: "test", + parameters: { type: "object" }, + execute: async ({ query }: { query: string }) => ({ query, results: [] }), + }), + }), + }; + }); + vi.doMock("./src/wsl2-crash-loop-check.js", () => { + wslImports += 1; + return { checkWsl2CrashLoopRisk: async () => {} }; + }); + + const { default: ollamaPlugin } = await import("./index.js"); + let embeddingAdapter: MemoryEmbeddingProviderAdapter | undefined; + let mediaProvider: MediaUnderstandingProvider | undefined; + const nodeCommands: OpenClawPluginNodeHostCommand[] = []; + const providers: ProviderPlugin[] = []; + let nodeInferenceTool: AnyAgentTool | undefined; + let webSearchProvider: WebSearchProviderPlugin | undefined; + ollamaPlugin.register( + createTestPluginApi({ + id: "ollama", + name: "Ollama", + source: "test", + config: {}, + runtime: createPluginRuntimeMock(), + registerMemoryEmbeddingProvider: (adapter) => { + embeddingAdapter = adapter; + }, + registerMediaUnderstandingProvider: (provider) => { + mediaProvider = provider; + }, + registerNodeHostCommand: (command) => nodeCommands.push(command), + registerTool: (tool) => { + if (typeof tool !== "function" && tool.name === "node_inference") { + nodeInferenceTool = tool; + } + }, + registerProvider: (provider) => providers.push(provider), + registerWebSearchProvider: (provider) => { + webSearchProvider = provider; + }, + }), + ); + await vi.waitFor(() => expect(wslChecks).toBe(1)); + + expect({ + embeddingImports, + mediaImports, + memoryImports, + nodeInferenceImports, + setupImports, + streamImports, + webSearchImports, + wslImports, + }).toEqual({ + embeddingImports: 0, + mediaImports: 0, + memoryImports: 0, + nodeInferenceImports: 0, + setupImports: 0, + streamImports: 0, + webSearchImports: 0, + wslImports: 0, + }); + + await expect(embeddingAdapter?.create({} as never)).resolves.toEqual({ provider: null }); + await expect(mediaProvider?.describeImage?.({} as never)).resolves.toEqual({ text: "image" }); + await expect(nodeCommands[0]?.handle()).resolves.toBe( + JSON.stringify({ provider: "ollama", models: [] }), + ); + await expect(nodeInferenceTool?.execute("call-1", {}, undefined)).resolves.toEqual({ + content: [], + }); + await expect( + webSearchProvider?.createTool({ config: {} } as never)?.execute({ query: "openclaw" }), + ).resolves.toEqual({ + query: "openclaw", + results: [], + }); + + const localProvider = providers.find((provider) => provider.id === "ollama"); + await expect( + localProvider?.createEmbeddingProvider?.({ + config: {}, + model: "", + provider: "ollama", + } as never), + ).resolves.toMatchObject({ + id: "ollama", + client: { baseUrl: "http://127.0.0.1:11434" }, + }); + await expect( + localProvider?.auth[0]?.run({ + config: {}, + prompter: {}, + } as never), + ).resolves.toMatchObject({ + configPatch: {}, + }); + const streamFn = localProvider?.createStreamFn?.({ + config: { + models: { + providers: { + ollama: { + api: "ollama", + baseUrl: "http://127.0.0.1:11434", + models: [], + }, + }, + }, + }, + model: { + api: "ollama", + id: "qwen3:32b", + provider: "ollama", + }, + provider: "ollama", + } as never); + await expect(streamFn?.({} as never, {} as never, {})).resolves.toEqual({ + transport: "ollama", + }); + + expect({ + embeddingImports, + mediaImports, + memoryImports, + nodeInferenceImports, + setupImports, + streamImports, + webSearchImports, + wslImports, + }).toEqual({ + embeddingImports: 1, + mediaImports: 1, + memoryImports: 1, + nodeInferenceImports: 1, + setupImports: 1, + streamImports: 1, + webSearchImports: 1, + wslImports: 0, + }); + }); +}); diff --git a/extensions/ollama/ollama.live.test.ts b/extensions/ollama/ollama.live.test.ts index 942b2ed30d95..11f858fcafc9 100644 --- a/extensions/ollama/ollama.live.test.ts +++ b/extensions/ollama/ollama.live.test.ts @@ -8,7 +8,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it } from "vitest"; import { isLocalOllamaBaseUrl } from "./src/discovery-shared.js"; import { createOllamaEmbeddingProvider } from "./src/embedding-provider.js"; -import { createOllamaStreamFn } from "./src/stream.js"; +import { createOllamaStreamFn } from "./src/stream.runtime.js"; import { createOllamaWebSearchProvider } from "./src/web-search-provider.js"; const LIVE = process.env.OPENCLAW_LIVE_TEST === "1" && process.env.OPENCLAW_LIVE_OLLAMA === "1"; diff --git a/extensions/ollama/runtime-api.ts b/extensions/ollama/runtime-api.ts index f49f8254b3d6..7959cdee94e6 100644 --- a/extensions/ollama/runtime-api.ts +++ b/extensions/ollama/runtime-api.ts @@ -13,7 +13,7 @@ export { resolveOllamaCompatNumCtxEnabled, shouldInjectOllamaCompatNumCtx, wrapOllamaCompatNumCtx, -} from "./src/stream.js"; +} from "./src/stream-api.js"; export { createOllamaEmbeddingProvider, DEFAULT_OLLAMA_EMBEDDING_MODEL, diff --git a/extensions/ollama/src/defaults.ts b/extensions/ollama/src/defaults.ts index f3fc84b98c0b..12dfaa5fb2ab 100644 --- a/extensions/ollama/src/defaults.ts +++ b/extensions/ollama/src/defaults.ts @@ -1,6 +1,6 @@ // Ollama plugin module implements defaults behavior. export const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434"; -export const OLLAMA_DOCKER_HOST_BASE_URL = "http://host.docker.internal:11434"; +const OLLAMA_DOCKER_HOST_BASE_URL = "http://host.docker.internal:11434"; export const OLLAMA_CLOUD_BASE_URL = "https://ollama.com"; export const OLLAMA_CLOUD_PROVIDER_ID = "ollama-cloud"; export const OLLAMA_GLM52_CLOUD_MODEL_ID = "glm-5.2"; @@ -33,3 +33,10 @@ export const OLLAMA_DEFAULT_COST = { }; export const OLLAMA_DEFAULT_MODEL = "gemma4"; +export const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text"; + +export function resolveOllamaSetupDefaultBaseUrl(env: NodeJS.ProcessEnv = process.env): string { + return ["1", "true", "yes", "on"].includes(env.OPENCLAW_DOCKER_SETUP?.trim().toLowerCase() ?? "") + ? OLLAMA_DOCKER_HOST_BASE_URL + : OLLAMA_DEFAULT_BASE_URL; +} diff --git a/extensions/ollama/src/embedding-provider.runtime.ts b/extensions/ollama/src/embedding-provider.runtime.ts new file mode 100644 index 000000000000..478cb3ccf9fd --- /dev/null +++ b/extensions/ollama/src/embedding-provider.runtime.ts @@ -0,0 +1,501 @@ +// Ollama embedding runtime implements provider integration. +import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth"; +import { + isKnownEnvApiKeyMarker, + isNonSecretApiKeyMarker, + normalizeOptionalSecretInput, +} from "openclaw/plugin-sdk/provider-auth"; +import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime"; +import { + readProviderJsonResponse, + readResponseTextLimited, +} from "openclaw/plugin-sdk/provider-http"; +import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; +import { + coerceSecretRef, + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + resolveConfiguredSecretInputString, +} from "openclaw/plugin-sdk/secret-input-runtime"; +import { + formatErrorMessage, + ssrfPolicyFromHttpBaseUrlAllowedOrigin, + type SsrFPolicy, +} from "openclaw/plugin-sdk/ssrf-runtime"; +import { fetchConfiguredLocalOriginWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime-internal"; +import { DEFAULT_OLLAMA_EMBEDDING_MODEL, OLLAMA_CLOUD_BASE_URL } from "./defaults.js"; +import { normalizeOllamaWireModelId } from "./model-id.js"; +import { readProviderBaseUrl } from "./provider-base-url.js"; +import { resolveOllamaApiBase } from "./provider-models.js"; + +export type OllamaEmbeddingProvider = { + id: string; + model: string; + maxInputTokens?: number; + embedQuery: (text: string, options?: { signal?: AbortSignal }) => Promise; + embedBatch: (texts: string[], options?: { signal?: AbortSignal }) => Promise; +}; + +type MemoryCoreAcquireLocalService = ( + target: { + providerId: string; + baseUrl: string; + headers?: HeadersInit; + }, + signal?: AbortSignal | null, +) => Promise<{ release: () => void } | undefined>; + +type OllamaEmbeddingOptions = { + config: OpenClawConfig; + agentDir?: string; + provider?: string; + remote?: { + baseUrl?: string; + apiKey?: unknown; + headers?: Record; + }; + model: string; + fallback?: string; + local?: unknown; + outputDimensionality?: number; + taskType?: unknown; + acquireLocalService?: MemoryCoreAcquireLocalService; +}; + +export type OllamaEmbeddingClient = { + baseUrl: string; + headers: Record; + ssrfPolicy?: SsrFPolicy; + model: string; + outputDimensionality?: number; + localServiceTarget?: Parameters[0]; + acquireLocalService?: MemoryCoreAcquireLocalService; + embedBatch: (texts: string[]) => Promise; +}; + +type OllamaEmbeddingClientConfig = Omit; + +export { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./defaults.js"; +const OLLAMA_EMBED_ERROR_BODY_LIMIT_BYTES = 8 * 1024; + +const QUERY_INSTRUCTION_TEMPLATES = [ + { + prefix: "qwen3-embedding", + template: + "Instruct: Given a user query, retrieve relevant memory notes and documents\nQuery:{query}", + }, + { + prefix: "nomic-embed-text", + template: "search_query: {query}", + }, + { + prefix: "mxbai-embed-large", + template: "Represent this sentence for searching relevant passages: {query}", + }, +] as const; + +function sanitizeAndNormalizeEmbedding(vec: unknown[], outputDimensionality?: number): number[] { + const selected = + typeof outputDimensionality === "number" ? vec.slice(0, outputDimensionality) : vec; + const sanitized = selected.map((value) => { + if (typeof value !== "number") { + throw new Error("Ollama embed response contains a non-number embedding value"); + } + return Number.isFinite(value) ? value : 0; + }); + const magnitude = Math.sqrt(sanitized.reduce((sum, value) => sum + value * value, 0)); + if (magnitude < 1e-10) { + return sanitized; + } + return sanitized.map((value) => value / magnitude); +} + +async function withRemoteHttpResponse(params: { + url: string; + init?: RequestInit; + signal?: AbortSignal; + ssrfPolicy?: SsrFPolicy; + configuredLocalOriginBaseUrl: string; + onResponse: (response: Response) => Promise; +}): Promise { + const { response, release } = await fetchConfiguredLocalOriginWithSsrFGuard({ + url: params.url, + init: params.init, + signal: params.signal, + policy: params.ssrfPolicy, + configuredLocalOriginBaseUrl: params.configuredLocalOriginBaseUrl, + auditContext: "ollama-memory-embedding", + }); + try { + return await params.onResponse(response); + } finally { + await release(); + } +} + +async function readOllamaEmbeddingJsonResponse( + response: Response, +): Promise<{ embeddings?: unknown }> { + const payload = await readProviderJsonResponse(response, "Ollama embed response"); + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + throw new Error("Ollama embed response returned a non-object JSON payload"); + } + return payload as { embeddings?: unknown }; +} + +function normalizeEmbeddingModel(model: string, providerId?: string): string { + const trimmed = model.trim(); + if (!trimmed) { + return DEFAULT_OLLAMA_EMBEDDING_MODEL; + } + return normalizeOllamaWireModelId(trimmed, providerId); +} + +function applyQueryInstructionTemplate(model: string, queryText: string): string { + const normalizedModel = model.trim().toLowerCase(); + const match = QUERY_INSTRUCTION_TEMPLATES.find(({ prefix }) => + normalizedModel.startsWith(prefix), + ); + return match ? match.template.replace("{query}", () => queryText) : queryText; +} + +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 { providerId, config: direct }; + } + const normalized = normalizeProviderId(providerId); + for (const [candidateId, candidate] of Object.entries(providers)) { + if (normalizeProviderId(candidateId) === normalized) { + return { providerId: candidateId, config: candidate }; + } + } + const fallback = providers.ollama; + return fallback ? { providerId: "ollama", config: fallback } : undefined; +} + +function resolveMemorySecretInputString(params: { + value: unknown; + path: string; +}): string | undefined { + if (!hasConfiguredSecretInput(params.value)) { + return undefined; + } + return normalizeResolvedSecretInputString({ + value: params.value, + path: params.path, + }); +} + +type OllamaEmbeddingBaseUrlOrigin = "remote-config" | "provider-config" | "default"; +type OllamaEmbeddingSourceResolution = "unset" | "opt-out" | { apiKey: string }; + +type OllamaEmbeddingResolvedKeys = { + remote: OllamaEmbeddingSourceResolution; + provider: OllamaEmbeddingSourceResolution; + env: string | undefined; +}; + +function resolveSourcedOllamaEmbeddingKey(params: { + configString: string | undefined; + declared: boolean; + resolvedSecretRef?: boolean; +}): OllamaEmbeddingSourceResolution { + if (params.configString !== undefined) { + // Resolved SecretRefs are opaque credentials, even when their values happen + // to match an ambient env marker or the synthetic local-auth placeholder. + if (params.resolvedSecretRef || !isNonSecretApiKeyMarker(params.configString)) { + return { apiKey: params.configString }; + } + if (!isKnownEnvApiKeyMarker(params.configString)) { + return "opt-out"; + } + const envKey = resolveEnvApiKey("ollama")?.apiKey; + return envKey && !isNonSecretApiKeyMarker(envKey) ? { apiKey: envKey } : "opt-out"; + } + return params.declared ? "opt-out" : "unset"; +} + +async function resolveConfiguredOllamaEmbeddingSecret(params: { + config: OpenClawConfig; + value: unknown; + path: string; +}): Promise { + if (!coerceSecretRef(params.value, params.config.secrets?.defaults)) { + return normalizeOptionalSecretInput(params.value); + } + const resolved = await resolveConfiguredSecretInputString({ + config: params.config, + env: process.env, + value: params.value, + path: params.path, + unresolvedReasonStyle: "detailed", + }); + if (resolved.unresolvedRefReason) { + throw new Error(resolved.unresolvedRefReason); + } + return normalizeOptionalSecretInput(resolved.value); +} + +async function resolveOllamaEmbeddingResolvedKeys( + options: OllamaEmbeddingOptions, + providerConfig: ReturnType, + providerOwnsHost: boolean, +): Promise { + const remoteValue = options.remote?.apiKey; + const remote = resolveSourcedOllamaEmbeddingKey({ + configString: resolveMemorySecretInputString({ + value: remoteValue, + path: "memory.search.remote.apiKey", + }), + declared: hasConfiguredSecretInput(remoteValue), + }); + const providerValue = providerConfig?.config.apiKey; + let provider: OllamaEmbeddingSourceResolution = "unset"; + if (remote === "unset" && providerOwnsHost && providerConfig) { + provider = resolveSourcedOllamaEmbeddingKey({ + configString: await resolveConfiguredOllamaEmbeddingSecret({ + config: options.config, + value: providerValue, + path: `models.providers.${providerConfig.providerId}.apiKey`, + }), + declared: hasConfiguredSecretInput(providerValue), + resolvedSecretRef: Boolean(coerceSecretRef(providerValue, options.config.secrets?.defaults)), + }); + } + const envKey = resolveEnvApiKey("ollama")?.apiKey; + const env = envKey && !isNonSecretApiKeyMarker(envKey) ? envKey : undefined; + return { remote, provider, env }; +} + +function resolveOllamaEmbeddingBaseUrl(params: { + remoteBaseUrl?: string; + providerConfig: ReturnType; +}): { baseUrl: string; origin: OllamaEmbeddingBaseUrlOrigin } { + const remoteBaseUrl = params.remoteBaseUrl?.trim(); + if (remoteBaseUrl) { + return { baseUrl: resolveOllamaApiBase(remoteBaseUrl), origin: "remote-config" }; + } + const providerBaseUrl = readProviderBaseUrl(params.providerConfig?.config); + if (providerBaseUrl) { + return { baseUrl: resolveOllamaApiBase(providerBaseUrl), origin: "provider-config" }; + } + return { baseUrl: resolveOllamaApiBase(undefined), origin: "default" }; +} + +function normalizeOllamaHostKey(baseUrl: string): string | undefined { + try { + const parsed = new URL(baseUrl); + let hostname = parsed.hostname.toLowerCase(); + if (hostname === "localhost" || hostname === "::1" || hostname === "[::1]") { + hostname = "127.0.0.1"; + } + const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80"); + const path = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/$/, ""); + return `${parsed.protocol}//${hostname}:${port}${path}`; + } catch { + return undefined; + } +} + +function areOllamaHostsEquivalent(a: string, b: string): boolean { + const aKey = normalizeOllamaHostKey(a); + const bKey = normalizeOllamaHostKey(b); + return aKey !== undefined && bKey !== undefined && aKey === bKey; +} + +function isOllamaCloudBaseUrl(baseUrl: string): boolean { + return areOllamaHostsEquivalent(baseUrl, OLLAMA_CLOUD_BASE_URL); +} + +function selectOllamaEmbeddingApiKey(params: { + resolved: OllamaEmbeddingResolvedKeys; + baseUrl: string; + providerOwnsHost: boolean; +}): string | undefined { + if (params.resolved.remote !== "unset") { + return typeof params.resolved.remote === "object" ? params.resolved.remote.apiKey : undefined; + } + if (params.resolved.provider !== "unset" && params.providerOwnsHost) { + return typeof params.resolved.provider === "object" + ? params.resolved.provider.apiKey + : undefined; + } + if (params.resolved.env && isOllamaCloudBaseUrl(params.baseUrl)) { + return params.resolved.env; + } + return undefined; +} + +async function resolveOllamaEmbeddingClient( + options: OllamaEmbeddingOptions, +): Promise { + const providerConfig = resolveConfiguredProvider(options); + const { baseUrl, origin: baseUrlOrigin } = resolveOllamaEmbeddingBaseUrl({ + remoteBaseUrl: options.remote?.baseUrl, + providerConfig, + }); + const model = normalizeEmbeddingModel(options.model, options.provider); + const providerOwnedHost = resolveOllamaApiBase(readProviderBaseUrl(providerConfig?.config)); + // Provider keys and headers belong to this origin only; a remote override + // must neither resolve nor inherit another host's configured credentials. + const providerOwnsHost = + baseUrlOrigin !== "remote-config" || areOllamaHostsEquivalent(baseUrl, providerOwnedHost); + const remoteHeaderNames = new Set( + Object.keys(options.remote?.headers ?? {}).map((headerName) => headerName.toLowerCase()), + ); + const headerOverrides: Record = {}; + if (providerOwnsHost && providerConfig?.config.headers) { + for (const [headerName, headerValue] of Object.entries(providerConfig.config.headers)) { + if (remoteHeaderNames.has(headerName.toLowerCase())) { + continue; + } + const resolvedValue = await resolveConfiguredOllamaEmbeddingSecret({ + config: options.config, + value: headerValue, + path: `models.providers.${providerConfig.providerId}.headers.${headerName}`, + }); + if (resolvedValue) { + headerOverrides[headerName] = resolvedValue; + } + } + } + Object.assign(headerOverrides, options.remote?.headers); + const headers: Record = { + "Content-Type": "application/json", + ...headerOverrides, + }; + // Explicit HTTP auth owns its request; resolving a competing bearer can leak + // another tenant's key or fail on a SecretRef that is already inactive. + const hasAuthorizationHeader = Object.entries(headers).some( + ([name, value]) => name.toLowerCase() === "authorization" && value.trim().length > 0, + ); + const apiKey = hasAuthorizationHeader + ? undefined + : selectOllamaEmbeddingApiKey({ + resolved: await resolveOllamaEmbeddingResolvedKeys( + options, + providerConfig, + providerOwnsHost, + ), + baseUrl, + providerOwnsHost, + }); + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + const localService = providerConfig?.config.localService; + return { + baseUrl, + headers, + ssrfPolicy: ssrfPolicyFromHttpBaseUrlAllowedOrigin(baseUrl), + model, + outputDimensionality: options.outputDimensionality, + ...(localService && baseUrlOrigin !== "remote-config" + ? { + localServiceTarget: { + providerId: providerConfig.providerId, + baseUrl: `${baseUrl.replace(/\/+$/, "")}/v1`, + headers, + }, + acquireLocalService: options.acquireLocalService, + } + : {}), + }; +} + +export async function createOllamaEmbeddingProvider( + options: OllamaEmbeddingOptions, +): Promise<{ provider: OllamaEmbeddingProvider; client: OllamaEmbeddingClient }> { + const client = await resolveOllamaEmbeddingClient(options); + const embedUrl = `${client.baseUrl.replace(/\/$/, "")}/api/embed`; + + const embedMany = async (input: string | string[], signal?: AbortSignal): Promise => { + const localServiceLease = + client.localServiceTarget && client.acquireLocalService + ? await client.acquireLocalService(client.localServiceTarget, signal) + : undefined; + let json: Awaited>; + try { + json = await withRemoteHttpResponse({ + url: embedUrl, + ssrfPolicy: client.ssrfPolicy, + configuredLocalOriginBaseUrl: client.baseUrl, + signal, + init: { + method: "POST", + headers: client.headers, + body: JSON.stringify({ model: client.model, input }), + }, + onResponse: async (response) => { + if (!response.ok) { + const detail = await readResponseTextLimited( + response, + OLLAMA_EMBED_ERROR_BODY_LIMIT_BYTES, + ).catch(() => "unknown error"); + throw new Error(`Ollama embed HTTP ${response.status}: ${detail}`); + } + return await readOllamaEmbeddingJsonResponse(response); + }, + }); + } finally { + localServiceLease?.release(); + } + if (!Array.isArray(json.embeddings)) { + throw new Error("Ollama embed response missing embeddings[]"); + } + 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, client.outputDimensionality); + }); + }; + + const embedOne = async (text: string, signal?: AbortSignal): Promise => { + const [embedding] = await embedMany(text, signal); + if (!embedding) { + throw new Error("Ollama embed response returned no embedding"); + } + return embedding; + }; + + const embedQuery = async ( + text: string, + optionsValue?: { signal?: AbortSignal }, + ): Promise => + await embedOne(applyQueryInstructionTemplate(client.model, text), optionsValue?.signal); + + const provider: OllamaEmbeddingProvider = { + id: "ollama", + model: client.model, + embedQuery, + embedBatch: async (texts, optionsLocal) => + texts.length === 0 ? [] : await embedMany(texts, optionsLocal?.signal), + }; + + return { + provider, + client: { + ...client, + embedBatch: async (texts) => { + try { + return await provider.embedBatch(texts); + } catch (err) { + throw new Error(formatErrorMessage(err), { cause: err }); + } + }, + }, + }; +} diff --git a/extensions/ollama/src/embedding-provider.ts b/extensions/ollama/src/embedding-provider.ts index 095fe28c90b8..9ba7d4d14524 100644 --- a/extensions/ollama/src/embedding-provider.ts +++ b/extensions/ollama/src/embedding-provider.ts @@ -1,501 +1,17 @@ -// Ollama provider module implements model/runtime integration. -import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth"; -import { - isKnownEnvApiKeyMarker, - isNonSecretApiKeyMarker, - normalizeOptionalSecretInput, -} from "openclaw/plugin-sdk/provider-auth"; -import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime"; -import { - readProviderJsonResponse, - readResponseTextLimited, -} from "openclaw/plugin-sdk/provider-http"; -import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; -import { - coerceSecretRef, - hasConfiguredSecretInput, - normalizeResolvedSecretInputString, - resolveConfiguredSecretInputString, -} from "openclaw/plugin-sdk/secret-input-runtime"; -import { - formatErrorMessage, - ssrfPolicyFromHttpBaseUrlAllowedOrigin, - type SsrFPolicy, -} from "openclaw/plugin-sdk/ssrf-runtime"; -import { fetchConfiguredLocalOriginWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime-internal"; -import { OLLAMA_CLOUD_BASE_URL } from "./defaults.js"; -import { normalizeOllamaWireModelId } from "./model-id.js"; -import { readProviderBaseUrl } from "./provider-base-url.js"; -import { resolveOllamaApiBase } from "./provider-models.js"; +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; -export type OllamaEmbeddingProvider = { - id: string; - model: string; - maxInputTokens?: number; - embedQuery: (text: string, options?: { signal?: AbortSignal }) => Promise; - embedBatch: (texts: string[], options?: { signal?: AbortSignal }) => Promise; -}; +export { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./defaults.js"; +export type { + OllamaEmbeddingClient, + OllamaEmbeddingProvider, +} from "./embedding-provider.runtime.js"; -type MemoryCoreAcquireLocalService = ( - target: { - providerId: string; - baseUrl: string; - headers?: HeadersInit; - }, - signal?: AbortSignal | null, -) => Promise<{ release: () => void } | undefined>; +type OllamaEmbeddingRuntime = typeof import("./embedding-provider.runtime.js"); -type OllamaEmbeddingOptions = { - config: OpenClawConfig; - agentDir?: string; - provider?: string; - remote?: { - baseUrl?: string; - apiKey?: unknown; - headers?: Record; - }; - model: string; - fallback?: string; - local?: unknown; - outputDimensionality?: number; - taskType?: unknown; - acquireLocalService?: MemoryCoreAcquireLocalService; -}; +const loadOllamaEmbeddingRuntime = createLazyRuntimeModule( + () => import("./embedding-provider.runtime.js"), +); -export type OllamaEmbeddingClient = { - baseUrl: string; - headers: Record; - ssrfPolicy?: SsrFPolicy; - model: string; - outputDimensionality?: number; - localServiceTarget?: Parameters[0]; - acquireLocalService?: MemoryCoreAcquireLocalService; - embedBatch: (texts: string[]) => Promise; -}; - -type OllamaEmbeddingClientConfig = Omit; - -export const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text"; -const OLLAMA_EMBED_ERROR_BODY_LIMIT_BYTES = 8 * 1024; - -const QUERY_INSTRUCTION_TEMPLATES = [ - { - prefix: "qwen3-embedding", - template: - "Instruct: Given a user query, retrieve relevant memory notes and documents\nQuery:{query}", - }, - { - prefix: "nomic-embed-text", - template: "search_query: {query}", - }, - { - prefix: "mxbai-embed-large", - template: "Represent this sentence for searching relevant passages: {query}", - }, -] as const; - -function sanitizeAndNormalizeEmbedding(vec: unknown[], outputDimensionality?: number): number[] { - const selected = - typeof outputDimensionality === "number" ? vec.slice(0, outputDimensionality) : vec; - const sanitized = selected.map((value) => { - if (typeof value !== "number") { - throw new Error("Ollama embed response contains a non-number embedding value"); - } - return Number.isFinite(value) ? value : 0; - }); - const magnitude = Math.sqrt(sanitized.reduce((sum, value) => sum + value * value, 0)); - if (magnitude < 1e-10) { - return sanitized; - } - return sanitized.map((value) => value / magnitude); -} - -async function withRemoteHttpResponse(params: { - url: string; - init?: RequestInit; - signal?: AbortSignal; - ssrfPolicy?: SsrFPolicy; - configuredLocalOriginBaseUrl: string; - onResponse: (response: Response) => Promise; -}): Promise { - const { response, release } = await fetchConfiguredLocalOriginWithSsrFGuard({ - url: params.url, - init: params.init, - signal: params.signal, - policy: params.ssrfPolicy, - configuredLocalOriginBaseUrl: params.configuredLocalOriginBaseUrl, - auditContext: "ollama-memory-embedding", - }); - try { - return await params.onResponse(response); - } finally { - await release(); - } -} - -async function readOllamaEmbeddingJsonResponse( - response: Response, -): Promise<{ embeddings?: unknown }> { - const payload = await readProviderJsonResponse(response, "Ollama embed response"); - if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { - throw new Error("Ollama embed response returned a non-object JSON payload"); - } - return payload as { embeddings?: unknown }; -} - -function normalizeEmbeddingModel(model: string, providerId?: string): string { - const trimmed = model.trim(); - if (!trimmed) { - return DEFAULT_OLLAMA_EMBEDDING_MODEL; - } - return normalizeOllamaWireModelId(trimmed, providerId); -} - -function applyQueryInstructionTemplate(model: string, queryText: string): string { - const normalizedModel = model.trim().toLowerCase(); - const match = QUERY_INSTRUCTION_TEMPLATES.find(({ prefix }) => - normalizedModel.startsWith(prefix), - ); - return match ? match.template.replace("{query}", () => queryText) : queryText; -} - -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 { providerId, config: direct }; - } - const normalized = normalizeProviderId(providerId); - for (const [candidateId, candidate] of Object.entries(providers)) { - if (normalizeProviderId(candidateId) === normalized) { - return { providerId: candidateId, config: candidate }; - } - } - const fallback = providers.ollama; - return fallback ? { providerId: "ollama", config: fallback } : undefined; -} - -function resolveMemorySecretInputString(params: { - value: unknown; - path: string; -}): string | undefined { - if (!hasConfiguredSecretInput(params.value)) { - return undefined; - } - return normalizeResolvedSecretInputString({ - value: params.value, - path: params.path, - }); -} - -type OllamaEmbeddingBaseUrlOrigin = "remote-config" | "provider-config" | "default"; -type OllamaEmbeddingSourceResolution = "unset" | "opt-out" | { apiKey: string }; - -type OllamaEmbeddingResolvedKeys = { - remote: OllamaEmbeddingSourceResolution; - provider: OllamaEmbeddingSourceResolution; - env: string | undefined; -}; - -function resolveSourcedOllamaEmbeddingKey(params: { - configString: string | undefined; - declared: boolean; - resolvedSecretRef?: boolean; -}): OllamaEmbeddingSourceResolution { - if (params.configString !== undefined) { - // Resolved SecretRefs are opaque credentials, even when their values happen - // to match an ambient env marker or the synthetic local-auth placeholder. - if (params.resolvedSecretRef || !isNonSecretApiKeyMarker(params.configString)) { - return { apiKey: params.configString }; - } - if (!isKnownEnvApiKeyMarker(params.configString)) { - return "opt-out"; - } - const envKey = resolveEnvApiKey("ollama")?.apiKey; - return envKey && !isNonSecretApiKeyMarker(envKey) ? { apiKey: envKey } : "opt-out"; - } - return params.declared ? "opt-out" : "unset"; -} - -async function resolveConfiguredOllamaEmbeddingSecret(params: { - config: OpenClawConfig; - value: unknown; - path: string; -}): Promise { - if (!coerceSecretRef(params.value, params.config.secrets?.defaults)) { - return normalizeOptionalSecretInput(params.value); - } - const resolved = await resolveConfiguredSecretInputString({ - config: params.config, - env: process.env, - value: params.value, - path: params.path, - unresolvedReasonStyle: "detailed", - }); - if (resolved.unresolvedRefReason) { - throw new Error(resolved.unresolvedRefReason); - } - return normalizeOptionalSecretInput(resolved.value); -} - -async function resolveOllamaEmbeddingResolvedKeys( - options: OllamaEmbeddingOptions, - providerConfig: ReturnType, - providerOwnsHost: boolean, -): Promise { - const remoteValue = options.remote?.apiKey; - const remote = resolveSourcedOllamaEmbeddingKey({ - configString: resolveMemorySecretInputString({ - value: remoteValue, - path: "memory.search.remote.apiKey", - }), - declared: hasConfiguredSecretInput(remoteValue), - }); - const providerValue = providerConfig?.config.apiKey; - let provider: OllamaEmbeddingSourceResolution = "unset"; - if (remote === "unset" && providerOwnsHost && providerConfig) { - provider = resolveSourcedOllamaEmbeddingKey({ - configString: await resolveConfiguredOllamaEmbeddingSecret({ - config: options.config, - value: providerValue, - path: `models.providers.${providerConfig.providerId}.apiKey`, - }), - declared: hasConfiguredSecretInput(providerValue), - resolvedSecretRef: Boolean(coerceSecretRef(providerValue, options.config.secrets?.defaults)), - }); - } - const envKey = resolveEnvApiKey("ollama")?.apiKey; - const env = envKey && !isNonSecretApiKeyMarker(envKey) ? envKey : undefined; - return { remote, provider, env }; -} - -function resolveOllamaEmbeddingBaseUrl(params: { - remoteBaseUrl?: string; - providerConfig: ReturnType; -}): { baseUrl: string; origin: OllamaEmbeddingBaseUrlOrigin } { - const remoteBaseUrl = params.remoteBaseUrl?.trim(); - if (remoteBaseUrl) { - return { baseUrl: resolveOllamaApiBase(remoteBaseUrl), origin: "remote-config" }; - } - const providerBaseUrl = readProviderBaseUrl(params.providerConfig?.config); - if (providerBaseUrl) { - return { baseUrl: resolveOllamaApiBase(providerBaseUrl), origin: "provider-config" }; - } - return { baseUrl: resolveOllamaApiBase(undefined), origin: "default" }; -} - -function normalizeOllamaHostKey(baseUrl: string): string | undefined { - try { - const parsed = new URL(baseUrl); - let hostname = parsed.hostname.toLowerCase(); - if (hostname === "localhost" || hostname === "::1" || hostname === "[::1]") { - hostname = "127.0.0.1"; - } - const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80"); - const path = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/$/, ""); - return `${parsed.protocol}//${hostname}:${port}${path}`; - } catch { - return undefined; - } -} - -function areOllamaHostsEquivalent(a: string, b: string): boolean { - const aKey = normalizeOllamaHostKey(a); - const bKey = normalizeOllamaHostKey(b); - return aKey !== undefined && bKey !== undefined && aKey === bKey; -} - -function isOllamaCloudBaseUrl(baseUrl: string): boolean { - return areOllamaHostsEquivalent(baseUrl, OLLAMA_CLOUD_BASE_URL); -} - -function selectOllamaEmbeddingApiKey(params: { - resolved: OllamaEmbeddingResolvedKeys; - baseUrl: string; - providerOwnsHost: boolean; -}): string | undefined { - if (params.resolved.remote !== "unset") { - return typeof params.resolved.remote === "object" ? params.resolved.remote.apiKey : undefined; - } - if (params.resolved.provider !== "unset" && params.providerOwnsHost) { - return typeof params.resolved.provider === "object" - ? params.resolved.provider.apiKey - : undefined; - } - if (params.resolved.env && isOllamaCloudBaseUrl(params.baseUrl)) { - return params.resolved.env; - } - return undefined; -} - -async function resolveOllamaEmbeddingClient( - options: OllamaEmbeddingOptions, -): Promise { - const providerConfig = resolveConfiguredProvider(options); - const { baseUrl, origin: baseUrlOrigin } = resolveOllamaEmbeddingBaseUrl({ - remoteBaseUrl: options.remote?.baseUrl, - providerConfig, - }); - const model = normalizeEmbeddingModel(options.model, options.provider); - const providerOwnedHost = resolveOllamaApiBase(readProviderBaseUrl(providerConfig?.config)); - // Provider keys and headers belong to this origin only; a remote override - // must neither resolve nor inherit another host's configured credentials. - const providerOwnsHost = - baseUrlOrigin !== "remote-config" || areOllamaHostsEquivalent(baseUrl, providerOwnedHost); - const remoteHeaderNames = new Set( - Object.keys(options.remote?.headers ?? {}).map((headerName) => headerName.toLowerCase()), - ); - const headerOverrides: Record = {}; - if (providerOwnsHost && providerConfig?.config.headers) { - for (const [headerName, headerValue] of Object.entries(providerConfig.config.headers)) { - if (remoteHeaderNames.has(headerName.toLowerCase())) { - continue; - } - const resolvedValue = await resolveConfiguredOllamaEmbeddingSecret({ - config: options.config, - value: headerValue, - path: `models.providers.${providerConfig.providerId}.headers.${headerName}`, - }); - if (resolvedValue) { - headerOverrides[headerName] = resolvedValue; - } - } - } - Object.assign(headerOverrides, options.remote?.headers); - const headers: Record = { - "Content-Type": "application/json", - ...headerOverrides, - }; - // Explicit HTTP auth owns its request; resolving a competing bearer can leak - // another tenant's key or fail on a SecretRef that is already inactive. - const hasAuthorizationHeader = Object.entries(headers).some( - ([name, value]) => name.toLowerCase() === "authorization" && value.trim().length > 0, - ); - const apiKey = hasAuthorizationHeader - ? undefined - : selectOllamaEmbeddingApiKey({ - resolved: await resolveOllamaEmbeddingResolvedKeys( - options, - providerConfig, - providerOwnsHost, - ), - baseUrl, - providerOwnsHost, - }); - if (apiKey) { - headers.Authorization = `Bearer ${apiKey}`; - } - const localService = providerConfig?.config.localService; - return { - baseUrl, - headers, - ssrfPolicy: ssrfPolicyFromHttpBaseUrlAllowedOrigin(baseUrl), - model, - outputDimensionality: options.outputDimensionality, - ...(localService && baseUrlOrigin !== "remote-config" - ? { - localServiceTarget: { - providerId: providerConfig.providerId, - baseUrl: `${baseUrl.replace(/\/+$/, "")}/v1`, - headers, - }, - acquireLocalService: options.acquireLocalService, - } - : {}), - }; -} - -export async function createOllamaEmbeddingProvider( - options: OllamaEmbeddingOptions, -): Promise<{ provider: OllamaEmbeddingProvider; client: OllamaEmbeddingClient }> { - const client = await resolveOllamaEmbeddingClient(options); - const embedUrl = `${client.baseUrl.replace(/\/$/, "")}/api/embed`; - - const embedMany = async (input: string | string[], signal?: AbortSignal): Promise => { - const localServiceLease = - client.localServiceTarget && client.acquireLocalService - ? await client.acquireLocalService(client.localServiceTarget, signal) - : undefined; - let json: Awaited>; - try { - json = await withRemoteHttpResponse({ - url: embedUrl, - ssrfPolicy: client.ssrfPolicy, - configuredLocalOriginBaseUrl: client.baseUrl, - signal, - init: { - method: "POST", - headers: client.headers, - body: JSON.stringify({ model: client.model, input }), - }, - onResponse: async (response) => { - if (!response.ok) { - const detail = await readResponseTextLimited( - response, - OLLAMA_EMBED_ERROR_BODY_LIMIT_BYTES, - ).catch(() => "unknown error"); - throw new Error(`Ollama embed HTTP ${response.status}: ${detail}`); - } - return await readOllamaEmbeddingJsonResponse(response); - }, - }); - } finally { - localServiceLease?.release(); - } - if (!Array.isArray(json.embeddings)) { - throw new Error("Ollama embed response missing embeddings[]"); - } - 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, client.outputDimensionality); - }); - }; - - const embedOne = async (text: string, signal?: AbortSignal): Promise => { - const [embedding] = await embedMany(text, signal); - if (!embedding) { - throw new Error("Ollama embed response returned no embedding"); - } - return embedding; - }; - - const embedQuery = async ( - text: string, - optionsValue?: { signal?: AbortSignal }, - ): Promise => - await embedOne(applyQueryInstructionTemplate(client.model, text), optionsValue?.signal); - - const provider: OllamaEmbeddingProvider = { - id: "ollama", - model: client.model, - embedQuery, - embedBatch: async (texts, optionsLocal) => - texts.length === 0 ? [] : await embedMany(texts, optionsLocal?.signal), - }; - - return { - provider, - client: { - ...client, - embedBatch: async (texts) => { - try { - return await provider.embedBatch(texts); - } catch (err) { - throw new Error(formatErrorMessage(err), { cause: err }); - } - }, - }, - }; -} +export const createOllamaEmbeddingProvider: OllamaEmbeddingRuntime["createOllamaEmbeddingProvider"] = + async (...args) => + await (await loadOllamaEmbeddingRuntime()).createOllamaEmbeddingProvider(...args); diff --git a/extensions/ollama/src/node-inference-contract.ts b/extensions/ollama/src/node-inference-contract.ts new file mode 100644 index 000000000000..2d1b4a761188 --- /dev/null +++ b/extensions/ollama/src/node-inference-contract.ts @@ -0,0 +1,41 @@ +import { stringEnum } from "openclaw/plugin-sdk/channel-actions"; +import type { AnyAgentTool } from "openclaw/plugin-sdk/plugin-entry"; +import { Type } from "typebox"; + +export const OLLAMA_NODE_INFERENCE_CAPABILITY = "local-inference"; +export const OLLAMA_MODELS_COMMAND = "ollama.models"; +export const OLLAMA_CHAT_COMMAND = "ollama.chat"; +export const OLLAMA_NODE_INFERENCE_COMMANDS = [OLLAMA_MODELS_COMMAND, OLLAMA_CHAT_COMMAND] as const; +export const OLLAMA_NODE_INFERENCE_DEFAULT_PLATFORMS = ["macos", "linux", "windows"] as const; + +export const DEFAULT_INFERENCE_TIMEOUT_MS = 120_000; +export const DEFAULT_MAX_TOKENS = 512; +export const DISCOVERY_TRANSPORT_TIMEOUT_MS = 90_000; +export const MAX_INFERENCE_TIMEOUT_MS = 10 * 60_000; +export const MAX_TOKENS = 8192; +export const MAX_PROMPT_CHARS = 128_000; +export const MAX_SYSTEM_PROMPT_CHARS = 32_000; + +export const ollamaNodeInferenceToolDefinition = { + name: "node_inference", + label: "Node Inference", + description: + "Discover and run chat-capable Ollama models installed on paired desktop/server nodes. Use action=discover first, then action=run with a node and model from that result. Inference stays on the selected node.", + parameters: Type.Object( + { + action: stringEnum(["discover", "run"] as const), + node: Type.Optional( + Type.String({ description: "Connected node id or display name. Required when ambiguous." }), + ), + model: Type.Optional( + Type.String({ description: "Exact local model name returned by discover." }), + ), + prompt: Type.Optional(Type.String({ description: "Prompt for action=run." })), + system: Type.Optional(Type.String({ description: "Optional system prompt for action=run." })), + temperature: Type.Optional(Type.Number({ minimum: 0, maximum: 2 })), + maxTokens: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_TOKENS })), + timeoutMs: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_INFERENCE_TIMEOUT_MS })), + }, + { additionalProperties: false }, + ), +} as const satisfies Pick; diff --git a/extensions/ollama/src/node-inference-registration.ts b/extensions/ollama/src/node-inference-registration.ts new file mode 100644 index 000000000000..e1b563891a62 --- /dev/null +++ b/extensions/ollama/src/node-inference-registration.ts @@ -0,0 +1,71 @@ +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; +import type { + AnyAgentTool, + OpenClawPluginApi, + OpenClawPluginNodeHostCommand, + OpenClawPluginNodeInvokePolicy, +} from "openclaw/plugin-sdk/plugin-entry"; +import { + OLLAMA_CHAT_COMMAND, + OLLAMA_MODELS_COMMAND, + OLLAMA_NODE_INFERENCE_CAPABILITY, + OLLAMA_NODE_INFERENCE_COMMANDS, + OLLAMA_NODE_INFERENCE_DEFAULT_PLATFORMS, + ollamaNodeInferenceToolDefinition, +} from "./node-inference-contract.js"; + +const loadOllamaNodeInference = createLazyRuntimeModule(() => import("./node-inference.js")); + +function createLazyNodeHostCommand( + command: (typeof OLLAMA_NODE_INFERENCE_COMMANDS)[number], +): OpenClawPluginNodeHostCommand { + let runtimeCommandPromise: Promise | undefined; + const loadRuntimeCommand = () => + (runtimeCommandPromise ??= loadOllamaNodeInference().then((runtime) => { + const runtimeCommand = runtime + .createOllamaNodeHostCommands() + .find((candidate) => candidate.command === command); + if (!runtimeCommand) { + throw new Error(`Ollama node inference runtime missing ${command}`); + } + return runtimeCommand; + })); + return { + command, + cap: OLLAMA_NODE_INFERENCE_CAPABILITY, + handle: async (paramsJSON, io, context) => { + const runtimeCommand = await loadRuntimeCommand(); + return await runtimeCommand.handle(paramsJSON, io, context); + }, + }; +} + +export function createLazyOllamaNodeHostCommands(): OpenClawPluginNodeHostCommand[] { + return [ + createLazyNodeHostCommand(OLLAMA_MODELS_COMMAND), + createLazyNodeHostCommand(OLLAMA_CHAT_COMMAND), + ]; +} + +export function createOllamaNodeInvokePolicy(): OpenClawPluginNodeInvokePolicy { + return { + commands: [...OLLAMA_NODE_INFERENCE_COMMANDS], + defaultPlatforms: [...OLLAMA_NODE_INFERENCE_DEFAULT_PLATFORMS], + handle: async (ctx) => await ctx.invokeNode(), + }; +} + +export function createLazyOllamaNodeInferenceTool(api: OpenClawPluginApi): AnyAgentTool { + let toolPromise: Promise | undefined; + const loadTool = () => + (toolPromise ??= loadOllamaNodeInference().then((runtime) => + runtime.createOllamaNodeInferenceTool(api), + )); + return { + ...ollamaNodeInferenceToolDefinition, + execute: async (...args) => { + const tool = await loadTool(); + return await tool.execute(...args); + }, + }; +} diff --git a/extensions/ollama/src/node-inference.ts b/extensions/ollama/src/node-inference.ts index 6d64d1559de7..1f6cc9a41cec 100644 --- a/extensions/ollama/src/node-inference.ts +++ b/extensions/ollama/src/node-inference.ts @@ -1,4 +1,4 @@ -import { jsonResult, stringEnum } from "openclaw/plugin-sdk/channel-actions"; +import { jsonResult } from "openclaw/plugin-sdk/channel-actions"; import { formatErrorMessage as errorMessage } from "openclaw/plugin-sdk/error-runtime"; // Ollama node inference exposes local models to agents through paired node hosts. import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; @@ -18,8 +18,22 @@ import { readResponseTextLimited, } from "openclaw/plugin-sdk/provider-http"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -import { Type } from "typebox"; import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js"; +import { + DEFAULT_INFERENCE_TIMEOUT_MS, + DEFAULT_MAX_TOKENS, + DISCOVERY_TRANSPORT_TIMEOUT_MS, + MAX_INFERENCE_TIMEOUT_MS, + MAX_PROMPT_CHARS, + MAX_SYSTEM_PROMPT_CHARS, + MAX_TOKENS, + OLLAMA_CHAT_COMMAND, + OLLAMA_MODELS_COMMAND, + OLLAMA_NODE_INFERENCE_CAPABILITY, + OLLAMA_NODE_INFERENCE_COMMANDS, + OLLAMA_NODE_INFERENCE_DEFAULT_PLATFORMS, + ollamaNodeInferenceToolDefinition, +} from "./node-inference-contract.js"; import { buildOllamaBaseUrlSsrFPolicy, enrichOllamaCompletionModels, @@ -31,18 +45,6 @@ import { throwIfOllamaRequestAborted, } from "./provider-models.js"; -const OLLAMA_NODE_INFERENCE_CAPABILITY = "local-inference"; -const OLLAMA_MODELS_COMMAND = "ollama.models"; -const OLLAMA_CHAT_COMMAND = "ollama.chat"; -const OLLAMA_NODE_INFERENCE_COMMANDS = [OLLAMA_MODELS_COMMAND, OLLAMA_CHAT_COMMAND] as const; - -const DEFAULT_INFERENCE_TIMEOUT_MS = 120_000; -const DEFAULT_MAX_TOKENS = 512; -const DISCOVERY_TRANSPORT_TIMEOUT_MS = 90_000; -const MAX_INFERENCE_TIMEOUT_MS = 10 * 60_000; -const MAX_TOKENS = 8192; -const MAX_PROMPT_CHARS = 128_000; -const MAX_SYSTEM_PROMPT_CHARS = 32_000; const MAX_ERROR_BODY_BYTES = 500; type NodeModel = { @@ -378,7 +380,7 @@ export function createOllamaNodeHostCommands(options?: { export function createOllamaNodeInvokePolicy(): OpenClawPluginNodeInvokePolicy { return { commands: [...OLLAMA_NODE_INFERENCE_COMMANDS], - defaultPlatforms: ["macos", "linux", "windows"], + defaultPlatforms: [...OLLAMA_NODE_INFERENCE_DEFAULT_PLATFORMS], handle: async (ctx) => await ctx.invokeNode(), }; } @@ -430,30 +432,6 @@ async function invokeNode( return parseInvokePayload(raw); } -const ollamaNodeInferenceToolDefinition = { - name: "node_inference", - label: "Node Inference", - description: - "Discover and run chat-capable Ollama models installed on paired desktop/server nodes. Use action=discover first, then action=run with a node and model from that result. Inference stays on the selected node.", - parameters: Type.Object( - { - action: stringEnum(["discover", "run"] as const), - node: Type.Optional( - Type.String({ description: "Connected node id or display name. Required when ambiguous." }), - ), - model: Type.Optional( - Type.String({ description: "Exact local model name returned by discover." }), - ), - prompt: Type.Optional(Type.String({ description: "Prompt for action=run." })), - system: Type.Optional(Type.String({ description: "Optional system prompt for action=run." })), - temperature: Type.Optional(Type.Number({ minimum: 0, maximum: 2 })), - maxTokens: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_TOKENS })), - timeoutMs: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_INFERENCE_TIMEOUT_MS })), - }, - { additionalProperties: false }, - ), -} as const; - export function createOllamaNodeInferenceTool(api: OpenClawPluginApi): AnyAgentTool { return { ...ollamaNodeInferenceToolDefinition, diff --git a/extensions/ollama/src/setup-body-release.test.ts b/extensions/ollama/src/setup-body-release.test.ts index 964a9b87652c..7909ee2b9e19 100644 --- a/extensions/ollama/src/setup-body-release.test.ts +++ b/extensions/ollama/src/setup-body-release.test.ts @@ -5,7 +5,7 @@ import type { WizardPrompter } from "openclaw/plugin-sdk/setup"; import { afterEach, describe, expect, it, vi } from "vitest"; import { fetchOllamaModels, readOllamaModelShowInfo } from "./provider-models.js"; import { pullOllamaModel } from "./setup-pull.js"; -import { checkOllamaCloudAuth } from "./setup.js"; +import { checkOllamaCloudAuth } from "./setup.runtime.js"; const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); diff --git a/extensions/ollama/src/setup.runtime.ts b/extensions/ollama/src/setup.runtime.ts new file mode 100644 index 000000000000..dd92ea1b1ac1 --- /dev/null +++ b/extensions/ollama/src/setup.runtime.ts @@ -0,0 +1,596 @@ +// Ollama setup runtime handles plugin onboarding behavior. +import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; +import type { + OpenClawConfig, + SecretInput, + SecretInputMode, +} from "openclaw/plugin-sdk/provider-auth"; +import { + ensureApiKeyFromOptionEnvOrPrompt, + isNonSecretApiKeyMarker, + normalizeApiKeyInput, + normalizeOptionalSecretInput, + upsertAuthProfileWithLock, + validateApiKeyInput, +} from "openclaw/plugin-sdk/provider-auth"; +import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; +import { applyAgentDefaultModelPrimary } from "openclaw/plugin-sdk/provider-onboard"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; +import { WizardCancelledError, type WizardPrompter } from "openclaw/plugin-sdk/setup"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { + OLLAMA_CLOUD_BASE_URL, + OLLAMA_CLOUD_DEFAULT_MODELS, + OLLAMA_DEFAULT_BASE_URL, + OLLAMA_DEFAULT_MODEL, + resolveOllamaSetupDefaultBaseUrl, +} from "./defaults.js"; +import { readProviderBaseUrl } from "./provider-base-url.js"; +import { + buildOllamaBaseUrlSsrFPolicy, + buildOllamaProvider, + enrichOllamaModelsWithContext, + fetchOllamaModels, + isOllamaCloudModel, + resolveOllamaApiBase, + type OllamaModelWithContext, +} from "./provider-models.js"; +import { + buildOllamaModelsConfig, + discoverOllamaModelsForSetup, + findAvailableOllamaModelName, + inspectOllamaModelsForSetup, + mergeUniqueModelNames, + normalizeOllamaModelName, + selectAppGuidedOllamaModelId, +} from "./setup-model-selection.js"; +import { pullOllamaModel, pullOllamaModelNonInteractive } from "./setup-pull.js"; + +export { buildOllamaProvider, resolveOllamaSetupDefaultBaseUrl }; + +const OLLAMA_SUGGESTED_MODELS_LOCAL = [OLLAMA_DEFAULT_MODEL]; +const OLLAMA_SUGGESTED_MODELS_CLOUD = OLLAMA_CLOUD_DEFAULT_MODELS.map((model) => model.id); +const OLLAMA_SUGGESTED_MODELS_LOCAL_CLOUD = OLLAMA_CLOUD_DEFAULT_MODELS.map( + (model) => `${model.id}:cloud`, +); +const OLLAMA_CLOUD_MAX_DISCOVERED_MODELS = 500; +const OLLAMA_RECOMMENDED_TOOLS_MODEL = "gemma4:e4b"; +const OLLAMA_RECOMMENDED_TOOLS_MODEL_SIZE = "about 9.6 GB"; + +type OllamaCloudDefaultModel = (typeof OLLAMA_CLOUD_DEFAULT_MODELS)[number]; + +type OllamaSetupOptions = { + customBaseUrl?: string; + customModelId?: string; +}; + +type OllamaSetupResult = { + config: OpenClawConfig; + credential: SecretInput; + credentialMode?: SecretInputMode; + defaultModel?: string; +}; + +type OllamaInteractiveMode = "cloud-local" | "cloud-only" | "local-only"; +type HostBackedOllamaInteractiveMode = Exclude; + +const HOST_BACKED_OLLAMA_MODE_CONFIG: Record< + HostBackedOllamaInteractiveMode, + { includeCloudModels: boolean; noteTitle: string } +> = { + "cloud-local": { + includeCloudModels: true, + noteTitle: "Ollama Cloud + Local", + }, + "local-only": { + includeCloudModels: false, + noteTitle: "Ollama", + }, +}; + +function buildOllamaUnreachableLines(baseUrl: string, retry: boolean): string[] { + return [ + `Ollama could not be reached at ${baseUrl}.`, + "Start or restart the Ollama server for this address.", + "If Ollama is not installed on that machine, download it at https://ollama.com/download", + ...(retry ? ["", "Continue when it is running. OpenClaw will retry this address."] : []), + ]; +} + +function buildOllamaCloudSigninLines(signinUrl?: string): string[] { + return [ + "Cloud models on this Ollama host need `ollama signin`.", + signinUrl ?? "Run `ollama signin` on the configured Ollama host.", + "", + "Continuing with local models only for now.", + ]; +} + +export async function checkOllamaCloudAuth( + baseUrl: string, +): Promise<{ signedIn: boolean; signinUrl?: string }> { + try { + const apiBase = resolveOllamaApiBase(baseUrl); + const { response, release } = await fetchWithSsrFGuard({ + url: `${apiBase}/api/me`, + init: { + method: "POST", + }, + // Guard-owned timeoutMs also bounds DNS/proxy preflight; init.signal does not. + timeoutMs: 5000, + policy: buildOllamaBaseUrlSsrFPolicy(apiBase), + auditContext: "ollama-setup.me", + }); + try { + if (response.status === 401) { + const data = await readProviderJsonResponse<{ signin_url?: string }>( + response, + "ollama.cloud-auth", + ); + return { signedIn: false, signinUrl: data.signin_url }; + } + if (!response.ok) { + return { signedIn: false }; + } + return { signedIn: true }; + } finally { + // Capture can retain a cloned tee branch, so cancellation must not delay + // the guard's bounded dispatcher release. + void response.body?.cancel().catch(() => undefined); + await release(); + } + } catch { + return { signedIn: false }; + } +} + +async function promptForOllamaCloudCredential(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + opts?: Record; + prompter: WizardPrompter; + secretInputMode?: SecretInputMode; + allowSecretRefPrompt?: boolean; +}): Promise<{ + credential: SecretInput; + credentialMode?: SecretInputMode; + discoveryApiKey: string; +}> { + const captured: { credential?: SecretInput; credentialMode?: SecretInputMode } = {}; + const optionToken = normalizeOptionalSecretInput(params.opts?.ollamaApiKey); + const discoveryApiKey = await ensureApiKeyFromOptionEnvOrPrompt({ + token: optionToken ?? normalizeOptionalSecretInput(params.opts?.token), + tokenProvider: optionToken + ? "ollama" + : normalizeOptionalSecretInput(params.opts?.tokenProvider), + secretInputMode: + params.allowSecretRefPrompt === false + ? (params.secretInputMode ?? "plaintext") + : params.secretInputMode, + config: params.cfg, + env: params.env, + expectedProviders: ["ollama"], + provider: "ollama", + envLabel: "OLLAMA_API_KEY", + promptMessage: "Ollama API key", + normalize: normalizeApiKeyInput, + validate: validateApiKeyInput, + prompter: params.prompter, + setCredential: async (apiKey, mode) => { + captured.credential = apiKey; + captured.credentialMode = mode; + }, + }); + if (!captured.credential) { + throw new Error("Missing Ollama API key input."); + } + if ( + typeof captured.credential === "string" && + isNonSecretApiKeyMarker(captured.credential, { includeEnvVarName: false }) + ) { + throw new Error("Cloud-only Ollama setup requires a real OLLAMA_API_KEY."); + } + return { + credential: captured.credential, + credentialMode: captured.credentialMode, + discoveryApiKey, + }; +} + +function applyOllamaProviderConfig( + cfg: OpenClawConfig, + baseUrl: string, + modelNames: string[], + discoveredModelsByName?: Map, + apiKey: SecretInput = "OLLAMA_API_KEY", + defaultModels: readonly OllamaCloudDefaultModel[] = [], +): OpenClawConfig { + return { + ...cfg, + models: { + ...cfg.models, + mode: cfg.models?.mode ?? "merge", + providers: { + ...cfg.models?.providers, + ollama: { + baseUrl, + api: "ollama", + apiKey, + models: buildOllamaModelsConfig(modelNames, discoveredModelsByName, defaultModels), + }, + }, + }, + }; +} + +async function storeOllamaCredential(agentDir?: string): Promise { + await upsertAuthProfileWithLock({ + profileId: "ollama:default", + credential: { type: "api_key", provider: "ollama", key: "ollama-local" }, + agentDir, + }); +} + +async function promptForOllamaBaseUrl( + prompter: WizardPrompter, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const defaultBaseUrl = resolveOllamaSetupDefaultBaseUrl(env); + const baseUrlRaw = await prompter.text({ + message: "Ollama base URL", + initialValue: defaultBaseUrl, + placeholder: defaultBaseUrl, + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + return resolveOllamaApiBase((baseUrlRaw ?? defaultBaseUrl).trim().replace(/\/+$/, "")); +} + +async function resolveHostBackedSuggestedModelNames(params: { + mode: HostBackedOllamaInteractiveMode; + baseUrl: string; + prompter: WizardPrompter; +}): Promise { + const modeConfig = HOST_BACKED_OLLAMA_MODE_CONFIG[params.mode]; + if (!modeConfig.includeCloudModels) { + return OLLAMA_SUGGESTED_MODELS_LOCAL; + } + + const auth = await checkOllamaCloudAuth(params.baseUrl); + if (auth.signedIn) { + return mergeUniqueModelNames( + OLLAMA_SUGGESTED_MODELS_LOCAL, + OLLAMA_SUGGESTED_MODELS_LOCAL_CLOUD, + ); + } + + await params.prompter.note( + buildOllamaCloudSigninLines(auth.signinUrl).join("\n"), + modeConfig.noteTitle, + ); + return OLLAMA_SUGGESTED_MODELS_LOCAL; +} + +async function promptAndConfigureHostBackedOllama(params: { + cfg: OpenClawConfig; + mode: HostBackedOllamaInteractiveMode; + prompter: WizardPrompter; + env?: NodeJS.ProcessEnv; + signal?: AbortSignal; +}): Promise { + const baseUrl = await promptForOllamaBaseUrl(params.prompter, params.env); + let discovery = await discoverOllamaModelsForSetup({ + baseUrl, + inspectTools: true, + ...(params.signal ? { signal: params.signal } : {}), + }); + + if (!discovery.reachable) { + await params.prompter.note(buildOllamaUnreachableLines(baseUrl, true).join("\n"), "Ollama"); + const shouldRetry = await params.prompter.confirm({ + message: "Retry this Ollama address now?", + initialValue: true, + }); + if (!shouldRetry) { + throw new WizardCancelledError("Ollama setup cancelled"); + } + params.signal?.throwIfAborted(); + discovery = await discoverOllamaModelsForSetup({ + baseUrl, + inspectTools: true, + ...(params.signal ? { signal: params.signal } : {}), + }); + } + + if (!discovery.reachable) { + throw new WizardCancelledError(`Ollama is still not reachable at ${baseUrl}`); + } + + const { + models, + inspectedModels, + discoveredModelsByName, + inspectionFailures, + hasToolsCapableModel, + } = discovery; + + if (inspectionFailures.length > 0) { + await params.prompter.note( + [ + "Some installed models could not be inspected and were skipped:", + ...inspectionFailures.slice(0, 5).map((line) => `- ${line}`), + ...(inspectionFailures.length > 5 ? [`…and ${inspectionFailures.length - 5} more`] : []), + ].join("\n"), + "Ollama", + ); + } + let discoveredModelNames = models.map((model) => model.name); + // A pull offer is only meaningful when inspection actually worked: if every + // scan failed we cannot know what is installed, so recommending a multi-GB + // download would be guesswork against a misbehaving server. + const inspectionUsable = + inspectedModels.length === 0 || inspectionFailures.length < inspectedModels.length; + if (!hasToolsCapableModel && inspectionUsable) { + const shouldPullRecommended = await params.prompter.confirm({ + message: `No tools-capable Ollama model is installed. Pull ${OLLAMA_RECOMMENDED_TOOLS_MODEL} (${OLLAMA_RECOMMENDED_TOOLS_MODEL_SIZE})?`, + initialValue: false, + }); + if (shouldPullRecommended) { + if ( + !(await pullOllamaModel( + baseUrl, + OLLAMA_RECOMMENDED_TOOLS_MODEL, + params.prompter, + params.signal, + )) + ) { + throw new WizardCancelledError("Failed to download recommended Ollama model"); + } + params.signal?.throwIfAborted(); + const recommendedScan = await inspectOllamaModelsForSetup( + baseUrl, + [{ name: OLLAMA_RECOMMENDED_TOOLS_MODEL }], + params.signal, + ); + // Unlike the pre-existing-model scan, a just-pulled model that cannot be + // verified is a hard failure: configuring it unenriched would silently + // drop the tools capability the pull was for. + if (recommendedScan.inspectionFailures.length > 0) { + throw new WizardCancelledError( + `Failed to verify pulled Ollama model: ${recommendedScan.inspectionFailures[0]}`, + ); + } + const [recommendedModel] = recommendedScan.inspected; + if (recommendedModel) { + discoveredModelsByName.set(recommendedModel.name, recommendedModel); + } + discoveredModelNames = mergeUniqueModelNames(discoveredModelNames, [ + OLLAMA_RECOMMENDED_TOOLS_MODEL, + ]); + } + } + const suggestedModelNames = await resolveHostBackedSuggestedModelNames({ + mode: params.mode, + baseUrl, + prompter: params.prompter, + }); + const localDefaultModelId = selectAppGuidedOllamaModelId( + [...discoveredModelsByName.values()].map((model) => ({ + id: model.name, + contextWindow: model.contextWindow, + supportsTools: model.capabilities?.includes("tools") === true, + })), + ); + const cloudDefaultModelId = suggestedModelNames.find(isOllamaCloudModel); + const defaultModelId = localDefaultModelId ?? cloudDefaultModelId; + + return { + credential: "ollama-local", + ...(defaultModelId ? { defaultModel: `ollama/${defaultModelId}` } : {}), + config: applyOllamaProviderConfig( + params.cfg, + baseUrl, + mergeUniqueModelNames(suggestedModelNames, discoveredModelNames), + discoveredModelsByName, + ), + }; +} + +export async function promptAndConfigureOllama(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + opts?: Record; + prompter: WizardPrompter; + secretInputMode?: SecretInputMode; + allowSecretRefPrompt?: boolean; + signal?: AbortSignal; +}): Promise { + const mode = (await params.prompter.select({ + message: "Ollama mode", + options: [ + { + value: "cloud-local", + label: "Cloud + Local", + hint: "Route cloud and local models through your Ollama host", + }, + { value: "cloud-only", label: "Cloud only", hint: "Hosted Ollama models via ollama.com" }, + { value: "local-only", label: "Local only", hint: "Local models only" }, + ], + })) as OllamaInteractiveMode; + if (mode === "cloud-only") { + const { credential, credentialMode, discoveryApiKey } = await promptForOllamaCloudCredential({ + cfg: params.cfg, + env: params.env, + opts: params.opts, + prompter: params.prompter, + secretInputMode: params.secretInputMode, + allowSecretRefPrompt: params.allowSecretRefPrompt, + }); + const { models: rawDiscoveredModels } = await fetchOllamaModels(OLLAMA_CLOUD_BASE_URL, { + apiKey: discoveryApiKey, + }); + const discoveredModels = rawDiscoveredModels.slice(0, OLLAMA_CLOUD_MAX_DISCOVERED_MODELS); + const discoveredModelNames = discoveredModels.map((model) => model.name); + const modelNames = + discoveredModelNames.length > 0 + ? mergeUniqueModelNames(OLLAMA_SUGGESTED_MODELS_CLOUD, discoveredModelNames) + : OLLAMA_SUGGESTED_MODELS_CLOUD; + const defaultModelId = modelNames[0]; + return { + credential, + credentialMode, + ...(defaultModelId ? { defaultModel: `ollama/${defaultModelId}` } : {}), + config: applyOllamaProviderConfig( + params.cfg, + OLLAMA_CLOUD_BASE_URL, + modelNames, + undefined, + credential, + OLLAMA_CLOUD_DEFAULT_MODELS, + ), + }; + } + return await promptAndConfigureHostBackedOllama({ + cfg: params.cfg, + mode, + prompter: params.prompter, + env: params.env, + ...(params.signal ? { signal: params.signal } : {}), + }); +} + +export async function configureOllamaNonInteractive(params: { + nextConfig: OpenClawConfig; + opts: OllamaSetupOptions; + runtime: RuntimeEnv; + agentDir?: string; +}): Promise { + const baseUrl = resolveOllamaApiBase( + (params.opts.customBaseUrl?.trim() || resolveOllamaSetupDefaultBaseUrl()).replace(/\/+$/, ""), + ); + const { reachable, models, discoveredModelsByName } = await discoverOllamaModelsForSetup({ + baseUrl, + }); + const explicitModel = normalizeOllamaModelName(params.opts.customModelId); + + if (!reachable) { + params.runtime.error(buildOllamaUnreachableLines(baseUrl, false).join("\n")); + params.runtime.exit(1); + return params.nextConfig; + } + + const modelNames = models.map((model) => model.name); + // Configured local models are advertised as available, so suggested models + // belong in the inventory only when Ollama actually reports them as installed. + const orderedModelNames = mergeUniqueModelNames( + OLLAMA_SUGGESTED_MODELS_LOCAL.filter( + (modelName) => findAvailableOllamaModelName(modelName, modelNames) !== undefined, + ), + modelNames, + ); + + const requestedDefaultModelId = + explicitModel ?? + expectDefined(OLLAMA_SUGGESTED_MODELS_LOCAL[0], "default suggested Ollama model"); + const availableModelNames = new Set(modelNames); + const availableDefaultModelId = findAvailableOllamaModelName( + requestedDefaultModelId, + availableModelNames, + ); + const requestedCloudModel = isOllamaCloudModel(requestedDefaultModelId); + let pulledRequestedModel = false; + + if (requestedCloudModel) { + availableModelNames.add(requestedDefaultModelId); + } else if (!availableDefaultModelId) { + pulledRequestedModel = await pullOllamaModelNonInteractive( + baseUrl, + requestedDefaultModelId, + params.runtime, + ); + if (pulledRequestedModel) { + availableModelNames.add(requestedDefaultModelId); + } + } + + let allModelNames = orderedModelNames; + let defaultModelId = availableDefaultModelId ?? requestedDefaultModelId; + if ( + (pulledRequestedModel || requestedCloudModel) && + !allModelNames.includes(requestedDefaultModelId) + ) { + allModelNames = [...allModelNames, requestedDefaultModelId]; + } + + if (!findAvailableOllamaModelName(defaultModelId, availableModelNames)) { + if (availableModelNames.size === 0) { + params.runtime.error( + [ + `No Ollama models are available at ${baseUrl}.`, + "Pull a model first, then re-run setup.", + ].join("\n"), + ); + params.runtime.exit(1); + return params.nextConfig; + } + + defaultModelId = + allModelNames.find((name) => findAvailableOllamaModelName(name, availableModelNames)) ?? + expectDefined(availableModelNames.values().next().value, "available Ollama setup model"); + params.runtime.log( + `Ollama model ${requestedDefaultModelId} was not available; using ${defaultModelId} instead.`, + ); + } + + if (!requestedCloudModel && !discoveredModelsByName.has(defaultModelId)) { + // Explicit and newly pulled models can fall outside the bounded catalog scan. + const selectedModel = expectDefined( + ( + await enrichOllamaModelsWithContext(baseUrl, [ + models.find((model) => model.name === defaultModelId) ?? { name: defaultModelId }, + ]) + )[0], + "selected Ollama setup model", + ); + discoveredModelsByName.set(defaultModelId, selectedModel); + } + + // Failed setup must not leave a durable local profile behind. + await storeOllamaCredential(params.agentDir); + + const config = applyOllamaProviderConfig( + params.nextConfig, + baseUrl, + allModelNames, + discoveredModelsByName, + ); + params.runtime.log(`Default Ollama model: ${defaultModelId}`); + return applyAgentDefaultModelPrimary(config, `ollama/${defaultModelId}`); +} + +export async function ensureOllamaModelPulled(params: { + config: OpenClawConfig; + model: string; + prompter: WizardPrompter; +}): Promise { + if (!params.model.startsWith("ollama/")) { + return; + } + const baseUrl = + readProviderBaseUrl(params.config.models?.providers?.ollama) ?? OLLAMA_DEFAULT_BASE_URL; + const modelName = params.model.slice("ollama/".length); + if (isOllamaCloudModel(modelName)) { + return; + } + const { models } = await fetchOllamaModels(baseUrl); + if ( + findAvailableOllamaModelName( + modelName, + models.map((model) => model.name), + ) + ) { + return; + } + if (!(await pullOllamaModel(baseUrl, modelName, params.prompter))) { + throw new WizardCancelledError("Failed to download selected Ollama model"); + } +} diff --git a/extensions/ollama/src/setup.test.ts b/extensions/ollama/src/setup.test.ts index e24f01ad0236..a15961b425eb 100644 --- a/extensions/ollama/src/setup.test.ts +++ b/extensions/ollama/src/setup.test.ts @@ -4,11 +4,11 @@ import type { WizardPrompter } from "openclaw/plugin-sdk/setup"; import { jsonResponse, requestBodyText, requestUrl } from "openclaw/plugin-sdk/test-env"; import { afterEach, describe, expect, it, vi } from "vitest"; import { - checkOllamaCloudAuth, configureOllamaNonInteractive, ensureOllamaModelPulled, promptAndConfigureOllama, } from "./setup.js"; +import { checkOllamaCloudAuth } from "./setup.runtime.js"; const upsertAuthProfileWithLock = vi.hoisted(() => vi.fn(async () => {})); const fetchWithSsrFGuardMock = vi.hoisted(() => diff --git a/extensions/ollama/src/setup.ts b/extensions/ollama/src/setup.ts index f056748bfe2a..de717bf10114 100644 --- a/extensions/ollama/src/setup.ts +++ b/extensions/ollama/src/setup.ts @@ -1,606 +1,19 @@ -// Ollama setup module handles plugin onboarding behavior. -import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; -import type { - OpenClawConfig, - SecretInput, - SecretInputMode, -} from "openclaw/plugin-sdk/provider-auth"; -import { - ensureApiKeyFromOptionEnvOrPrompt, - isNonSecretApiKeyMarker, - normalizeApiKeyInput, - normalizeOptionalSecretInput, - upsertAuthProfileWithLock, - validateApiKeyInput, -} from "openclaw/plugin-sdk/provider-auth"; -import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; -import { applyAgentDefaultModelPrimary } from "openclaw/plugin-sdk/provider-onboard"; -import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; -import { WizardCancelledError, type WizardPrompter } from "openclaw/plugin-sdk/setup"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -import { - OLLAMA_CLOUD_BASE_URL, - OLLAMA_CLOUD_DEFAULT_MODELS, - OLLAMA_DEFAULT_BASE_URL, - OLLAMA_DOCKER_HOST_BASE_URL, - OLLAMA_DEFAULT_MODEL, -} from "./defaults.js"; -import { readProviderBaseUrl } from "./provider-base-url.js"; -import { - buildOllamaBaseUrlSsrFPolicy, - buildOllamaProvider, - enrichOllamaModelsWithContext, - fetchOllamaModels, - isOllamaCloudModel, - resolveOllamaApiBase, - type OllamaModelWithContext, -} from "./provider-models.js"; -import { - buildOllamaModelsConfig, - discoverOllamaModelsForSetup, - findAvailableOllamaModelName, - inspectOllamaModelsForSetup, - mergeUniqueModelNames, - normalizeOllamaModelName, - selectAppGuidedOllamaModelId, -} from "./setup-model-selection.js"; -import { pullOllamaModel, pullOllamaModelNonInteractive } from "./setup-pull.js"; +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; -export { buildOllamaProvider }; +export { resolveOllamaSetupDefaultBaseUrl } from "./defaults.js"; +export { buildOllamaProvider } from "./provider-models.js"; -const OLLAMA_SUGGESTED_MODELS_LOCAL = [OLLAMA_DEFAULT_MODEL]; -const OLLAMA_SUGGESTED_MODELS_CLOUD = OLLAMA_CLOUD_DEFAULT_MODELS.map((model) => model.id); -const OLLAMA_SUGGESTED_MODELS_LOCAL_CLOUD = OLLAMA_CLOUD_DEFAULT_MODELS.map( - (model) => `${model.id}:cloud`, -); -const OLLAMA_CLOUD_MAX_DISCOVERED_MODELS = 500; -const OLLAMA_RECOMMENDED_TOOLS_MODEL = "gemma4:e4b"; -const OLLAMA_RECOMMENDED_TOOLS_MODEL_SIZE = "about 9.6 GB"; +type OllamaSetupRuntime = typeof import("./setup.runtime.js"); -type OllamaCloudDefaultModel = (typeof OLLAMA_CLOUD_DEFAULT_MODELS)[number]; +const loadOllamaSetupRuntime = createLazyRuntimeModule(() => import("./setup.runtime.js")); -type OllamaSetupOptions = { - customBaseUrl?: string; - customModelId?: string; -}; +export const promptAndConfigureOllama: OllamaSetupRuntime["promptAndConfigureOllama"] = async ( + ...args +) => await (await loadOllamaSetupRuntime()).promptAndConfigureOllama(...args); -type OllamaSetupResult = { - config: OpenClawConfig; - credential: SecretInput; - credentialMode?: SecretInputMode; - defaultModel?: string; -}; +export const configureOllamaNonInteractive: OllamaSetupRuntime["configureOllamaNonInteractive"] = + async (...args) => await (await loadOllamaSetupRuntime()).configureOllamaNonInteractive(...args); -function isTruthyEnvValue(value: string | undefined): boolean { - return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? ""); -} - -export function resolveOllamaSetupDefaultBaseUrl(env: NodeJS.ProcessEnv = process.env): string { - return isTruthyEnvValue(env.OPENCLAW_DOCKER_SETUP) - ? OLLAMA_DOCKER_HOST_BASE_URL - : OLLAMA_DEFAULT_BASE_URL; -} - -type OllamaInteractiveMode = "cloud-local" | "cloud-only" | "local-only"; -type HostBackedOllamaInteractiveMode = Exclude; - -const HOST_BACKED_OLLAMA_MODE_CONFIG: Record< - HostBackedOllamaInteractiveMode, - { includeCloudModels: boolean; noteTitle: string } -> = { - "cloud-local": { - includeCloudModels: true, - noteTitle: "Ollama Cloud + Local", - }, - "local-only": { - includeCloudModels: false, - noteTitle: "Ollama", - }, -}; - -function buildOllamaUnreachableLines(baseUrl: string, retry: boolean): string[] { - return [ - `Ollama could not be reached at ${baseUrl}.`, - "Start or restart the Ollama server for this address.", - "If Ollama is not installed on that machine, download it at https://ollama.com/download", - ...(retry ? ["", "Continue when it is running. OpenClaw will retry this address."] : []), - ]; -} - -function buildOllamaCloudSigninLines(signinUrl?: string): string[] { - return [ - "Cloud models on this Ollama host need `ollama signin`.", - signinUrl ?? "Run `ollama signin` on the configured Ollama host.", - "", - "Continuing with local models only for now.", - ]; -} - -export async function checkOllamaCloudAuth( - baseUrl: string, -): Promise<{ signedIn: boolean; signinUrl?: string }> { - try { - const apiBase = resolveOllamaApiBase(baseUrl); - const { response, release } = await fetchWithSsrFGuard({ - url: `${apiBase}/api/me`, - init: { - method: "POST", - }, - // Guard-owned timeoutMs also bounds DNS/proxy preflight; init.signal does not. - timeoutMs: 5000, - policy: buildOllamaBaseUrlSsrFPolicy(apiBase), - auditContext: "ollama-setup.me", - }); - try { - if (response.status === 401) { - const data = await readProviderJsonResponse<{ signin_url?: string }>( - response, - "ollama.cloud-auth", - ); - return { signedIn: false, signinUrl: data.signin_url }; - } - if (!response.ok) { - return { signedIn: false }; - } - return { signedIn: true }; - } finally { - // Capture can retain a cloned tee branch, so cancellation must not delay - // the guard's bounded dispatcher release. - void response.body?.cancel().catch(() => undefined); - await release(); - } - } catch { - return { signedIn: false }; - } -} - -async function promptForOllamaCloudCredential(params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; - opts?: Record; - prompter: WizardPrompter; - secretInputMode?: SecretInputMode; - allowSecretRefPrompt?: boolean; -}): Promise<{ - credential: SecretInput; - credentialMode?: SecretInputMode; - discoveryApiKey: string; -}> { - const captured: { credential?: SecretInput; credentialMode?: SecretInputMode } = {}; - const optionToken = normalizeOptionalSecretInput(params.opts?.ollamaApiKey); - const discoveryApiKey = await ensureApiKeyFromOptionEnvOrPrompt({ - token: optionToken ?? normalizeOptionalSecretInput(params.opts?.token), - tokenProvider: optionToken - ? "ollama" - : normalizeOptionalSecretInput(params.opts?.tokenProvider), - secretInputMode: - params.allowSecretRefPrompt === false - ? (params.secretInputMode ?? "plaintext") - : params.secretInputMode, - config: params.cfg, - env: params.env, - expectedProviders: ["ollama"], - provider: "ollama", - envLabel: "OLLAMA_API_KEY", - promptMessage: "Ollama API key", - normalize: normalizeApiKeyInput, - validate: validateApiKeyInput, - prompter: params.prompter, - setCredential: async (apiKey, mode) => { - captured.credential = apiKey; - captured.credentialMode = mode; - }, - }); - if (!captured.credential) { - throw new Error("Missing Ollama API key input."); - } - if ( - typeof captured.credential === "string" && - isNonSecretApiKeyMarker(captured.credential, { includeEnvVarName: false }) - ) { - throw new Error("Cloud-only Ollama setup requires a real OLLAMA_API_KEY."); - } - return { - credential: captured.credential, - credentialMode: captured.credentialMode, - discoveryApiKey, - }; -} - -function applyOllamaProviderConfig( - cfg: OpenClawConfig, - baseUrl: string, - modelNames: string[], - discoveredModelsByName?: Map, - apiKey: SecretInput = "OLLAMA_API_KEY", - defaultModels: readonly OllamaCloudDefaultModel[] = [], -): OpenClawConfig { - return { - ...cfg, - models: { - ...cfg.models, - mode: cfg.models?.mode ?? "merge", - providers: { - ...cfg.models?.providers, - ollama: { - baseUrl, - api: "ollama", - apiKey, - models: buildOllamaModelsConfig(modelNames, discoveredModelsByName, defaultModels), - }, - }, - }, - }; -} - -async function storeOllamaCredential(agentDir?: string): Promise { - await upsertAuthProfileWithLock({ - profileId: "ollama:default", - credential: { type: "api_key", provider: "ollama", key: "ollama-local" }, - agentDir, - }); -} - -async function promptForOllamaBaseUrl( - prompter: WizardPrompter, - env: NodeJS.ProcessEnv = process.env, -): Promise { - const defaultBaseUrl = resolveOllamaSetupDefaultBaseUrl(env); - const baseUrlRaw = await prompter.text({ - message: "Ollama base URL", - initialValue: defaultBaseUrl, - placeholder: defaultBaseUrl, - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - return resolveOllamaApiBase((baseUrlRaw ?? defaultBaseUrl).trim().replace(/\/+$/, "")); -} - -async function resolveHostBackedSuggestedModelNames(params: { - mode: HostBackedOllamaInteractiveMode; - baseUrl: string; - prompter: WizardPrompter; -}): Promise { - const modeConfig = HOST_BACKED_OLLAMA_MODE_CONFIG[params.mode]; - if (!modeConfig.includeCloudModels) { - return OLLAMA_SUGGESTED_MODELS_LOCAL; - } - - const auth = await checkOllamaCloudAuth(params.baseUrl); - if (auth.signedIn) { - return mergeUniqueModelNames( - OLLAMA_SUGGESTED_MODELS_LOCAL, - OLLAMA_SUGGESTED_MODELS_LOCAL_CLOUD, - ); - } - - await params.prompter.note( - buildOllamaCloudSigninLines(auth.signinUrl).join("\n"), - modeConfig.noteTitle, - ); - return OLLAMA_SUGGESTED_MODELS_LOCAL; -} - -async function promptAndConfigureHostBackedOllama(params: { - cfg: OpenClawConfig; - mode: HostBackedOllamaInteractiveMode; - prompter: WizardPrompter; - env?: NodeJS.ProcessEnv; - signal?: AbortSignal; -}): Promise { - const baseUrl = await promptForOllamaBaseUrl(params.prompter, params.env); - let discovery = await discoverOllamaModelsForSetup({ - baseUrl, - inspectTools: true, - ...(params.signal ? { signal: params.signal } : {}), - }); - - if (!discovery.reachable) { - await params.prompter.note(buildOllamaUnreachableLines(baseUrl, true).join("\n"), "Ollama"); - const shouldRetry = await params.prompter.confirm({ - message: "Retry this Ollama address now?", - initialValue: true, - }); - if (!shouldRetry) { - throw new WizardCancelledError("Ollama setup cancelled"); - } - params.signal?.throwIfAborted(); - discovery = await discoverOllamaModelsForSetup({ - baseUrl, - inspectTools: true, - ...(params.signal ? { signal: params.signal } : {}), - }); - } - - if (!discovery.reachable) { - throw new WizardCancelledError(`Ollama is still not reachable at ${baseUrl}`); - } - - const { - models, - inspectedModels, - discoveredModelsByName, - inspectionFailures, - hasToolsCapableModel, - } = discovery; - - if (inspectionFailures.length > 0) { - await params.prompter.note( - [ - "Some installed models could not be inspected and were skipped:", - ...inspectionFailures.slice(0, 5).map((line) => `- ${line}`), - ...(inspectionFailures.length > 5 ? [`…and ${inspectionFailures.length - 5} more`] : []), - ].join("\n"), - "Ollama", - ); - } - let discoveredModelNames = models.map((model) => model.name); - // A pull offer is only meaningful when inspection actually worked: if every - // scan failed we cannot know what is installed, so recommending a multi-GB - // download would be guesswork against a misbehaving server. - const inspectionUsable = - inspectedModels.length === 0 || inspectionFailures.length < inspectedModels.length; - if (!hasToolsCapableModel && inspectionUsable) { - const shouldPullRecommended = await params.prompter.confirm({ - message: `No tools-capable Ollama model is installed. Pull ${OLLAMA_RECOMMENDED_TOOLS_MODEL} (${OLLAMA_RECOMMENDED_TOOLS_MODEL_SIZE})?`, - initialValue: false, - }); - if (shouldPullRecommended) { - if ( - !(await pullOllamaModel( - baseUrl, - OLLAMA_RECOMMENDED_TOOLS_MODEL, - params.prompter, - params.signal, - )) - ) { - throw new WizardCancelledError("Failed to download recommended Ollama model"); - } - params.signal?.throwIfAborted(); - const recommendedScan = await inspectOllamaModelsForSetup( - baseUrl, - [{ name: OLLAMA_RECOMMENDED_TOOLS_MODEL }], - params.signal, - ); - // Unlike the pre-existing-model scan, a just-pulled model that cannot be - // verified is a hard failure: configuring it unenriched would silently - // drop the tools capability the pull was for. - if (recommendedScan.inspectionFailures.length > 0) { - throw new WizardCancelledError( - `Failed to verify pulled Ollama model: ${recommendedScan.inspectionFailures[0]}`, - ); - } - const [recommendedModel] = recommendedScan.inspected; - if (recommendedModel) { - discoveredModelsByName.set(recommendedModel.name, recommendedModel); - } - discoveredModelNames = mergeUniqueModelNames(discoveredModelNames, [ - OLLAMA_RECOMMENDED_TOOLS_MODEL, - ]); - } - } - const suggestedModelNames = await resolveHostBackedSuggestedModelNames({ - mode: params.mode, - baseUrl, - prompter: params.prompter, - }); - const localDefaultModelId = selectAppGuidedOllamaModelId( - [...discoveredModelsByName.values()].map((model) => ({ - id: model.name, - contextWindow: model.contextWindow, - supportsTools: model.capabilities?.includes("tools") === true, - })), - ); - const cloudDefaultModelId = suggestedModelNames.find(isOllamaCloudModel); - const defaultModelId = localDefaultModelId ?? cloudDefaultModelId; - - return { - credential: "ollama-local", - ...(defaultModelId ? { defaultModel: `ollama/${defaultModelId}` } : {}), - config: applyOllamaProviderConfig( - params.cfg, - baseUrl, - mergeUniqueModelNames(suggestedModelNames, discoveredModelNames), - discoveredModelsByName, - ), - }; -} - -export async function promptAndConfigureOllama(params: { - cfg: OpenClawConfig; - env?: NodeJS.ProcessEnv; - opts?: Record; - prompter: WizardPrompter; - secretInputMode?: SecretInputMode; - allowSecretRefPrompt?: boolean; - signal?: AbortSignal; -}): Promise { - const mode = (await params.prompter.select({ - message: "Ollama mode", - options: [ - { - value: "cloud-local", - label: "Cloud + Local", - hint: "Route cloud and local models through your Ollama host", - }, - { value: "cloud-only", label: "Cloud only", hint: "Hosted Ollama models via ollama.com" }, - { value: "local-only", label: "Local only", hint: "Local models only" }, - ], - })) as OllamaInteractiveMode; - if (mode === "cloud-only") { - const { credential, credentialMode, discoveryApiKey } = await promptForOllamaCloudCredential({ - cfg: params.cfg, - env: params.env, - opts: params.opts, - prompter: params.prompter, - secretInputMode: params.secretInputMode, - allowSecretRefPrompt: params.allowSecretRefPrompt, - }); - const { models: rawDiscoveredModels } = await fetchOllamaModels(OLLAMA_CLOUD_BASE_URL, { - apiKey: discoveryApiKey, - }); - const discoveredModels = rawDiscoveredModels.slice(0, OLLAMA_CLOUD_MAX_DISCOVERED_MODELS); - const discoveredModelNames = discoveredModels.map((model) => model.name); - const modelNames = - discoveredModelNames.length > 0 - ? mergeUniqueModelNames(OLLAMA_SUGGESTED_MODELS_CLOUD, discoveredModelNames) - : OLLAMA_SUGGESTED_MODELS_CLOUD; - const defaultModelId = modelNames[0]; - return { - credential, - credentialMode, - ...(defaultModelId ? { defaultModel: `ollama/${defaultModelId}` } : {}), - config: applyOllamaProviderConfig( - params.cfg, - OLLAMA_CLOUD_BASE_URL, - modelNames, - undefined, - credential, - OLLAMA_CLOUD_DEFAULT_MODELS, - ), - }; - } - return await promptAndConfigureHostBackedOllama({ - cfg: params.cfg, - mode, - prompter: params.prompter, - env: params.env, - ...(params.signal ? { signal: params.signal } : {}), - }); -} - -export async function configureOllamaNonInteractive(params: { - nextConfig: OpenClawConfig; - opts: OllamaSetupOptions; - runtime: RuntimeEnv; - agentDir?: string; -}): Promise { - const baseUrl = resolveOllamaApiBase( - (params.opts.customBaseUrl?.trim() || resolveOllamaSetupDefaultBaseUrl()).replace(/\/+$/, ""), - ); - const { reachable, models, discoveredModelsByName } = await discoverOllamaModelsForSetup({ - baseUrl, - }); - const explicitModel = normalizeOllamaModelName(params.opts.customModelId); - - if (!reachable) { - params.runtime.error(buildOllamaUnreachableLines(baseUrl, false).join("\n")); - params.runtime.exit(1); - return params.nextConfig; - } - - const modelNames = models.map((model) => model.name); - // Configured local models are advertised as available, so suggested models - // belong in the inventory only when Ollama actually reports them as installed. - const orderedModelNames = mergeUniqueModelNames( - OLLAMA_SUGGESTED_MODELS_LOCAL.filter( - (modelName) => findAvailableOllamaModelName(modelName, modelNames) !== undefined, - ), - modelNames, - ); - - const requestedDefaultModelId = - explicitModel ?? - expectDefined(OLLAMA_SUGGESTED_MODELS_LOCAL[0], "default suggested Ollama model"); - const availableModelNames = new Set(modelNames); - const availableDefaultModelId = findAvailableOllamaModelName( - requestedDefaultModelId, - availableModelNames, - ); - const requestedCloudModel = isOllamaCloudModel(requestedDefaultModelId); - let pulledRequestedModel = false; - - if (requestedCloudModel) { - availableModelNames.add(requestedDefaultModelId); - } else if (!availableDefaultModelId) { - pulledRequestedModel = await pullOllamaModelNonInteractive( - baseUrl, - requestedDefaultModelId, - params.runtime, - ); - if (pulledRequestedModel) { - availableModelNames.add(requestedDefaultModelId); - } - } - - let allModelNames = orderedModelNames; - let defaultModelId = availableDefaultModelId ?? requestedDefaultModelId; - if ( - (pulledRequestedModel || requestedCloudModel) && - !allModelNames.includes(requestedDefaultModelId) - ) { - allModelNames = [...allModelNames, requestedDefaultModelId]; - } - - if (!findAvailableOllamaModelName(defaultModelId, availableModelNames)) { - if (availableModelNames.size === 0) { - params.runtime.error( - [ - `No Ollama models are available at ${baseUrl}.`, - "Pull a model first, then re-run setup.", - ].join("\n"), - ); - params.runtime.exit(1); - return params.nextConfig; - } - - defaultModelId = - allModelNames.find((name) => findAvailableOllamaModelName(name, availableModelNames)) ?? - expectDefined(availableModelNames.values().next().value, "available Ollama setup model"); - params.runtime.log( - `Ollama model ${requestedDefaultModelId} was not available; using ${defaultModelId} instead.`, - ); - } - - if (!requestedCloudModel && !discoveredModelsByName.has(defaultModelId)) { - // Explicit and newly pulled models can fall outside the bounded catalog scan. - const selectedModel = expectDefined( - ( - await enrichOllamaModelsWithContext(baseUrl, [ - models.find((model) => model.name === defaultModelId) ?? { name: defaultModelId }, - ]) - )[0], - "selected Ollama setup model", - ); - discoveredModelsByName.set(defaultModelId, selectedModel); - } - - // Failed setup must not leave a durable local profile behind. - await storeOllamaCredential(params.agentDir); - - const config = applyOllamaProviderConfig( - params.nextConfig, - baseUrl, - allModelNames, - discoveredModelsByName, - ); - params.runtime.log(`Default Ollama model: ${defaultModelId}`); - return applyAgentDefaultModelPrimary(config, `ollama/${defaultModelId}`); -} - -export async function ensureOllamaModelPulled(params: { - config: OpenClawConfig; - model: string; - prompter: WizardPrompter; -}): Promise { - if (!params.model.startsWith("ollama/")) { - return; - } - const baseUrl = - readProviderBaseUrl(params.config.models?.providers?.ollama) ?? OLLAMA_DEFAULT_BASE_URL; - const modelName = params.model.slice("ollama/".length); - if (isOllamaCloudModel(modelName)) { - return; - } - const { models } = await fetchOllamaModels(baseUrl); - if ( - findAvailableOllamaModelName( - modelName, - models.map((model) => model.name), - ) - ) { - return; - } - if (!(await pullOllamaModel(baseUrl, modelName, params.prompter))) { - throw new WizardCancelledError("Failed to download selected Ollama model"); - } -} +export const ensureOllamaModelPulled: OllamaSetupRuntime["ensureOllamaModelPulled"] = async ( + ...args +) => await (await loadOllamaSetupRuntime()).ensureOllamaModelPulled(...args); diff --git a/extensions/ollama/src/stream-api.ts b/extensions/ollama/src/stream-api.ts new file mode 100644 index 000000000000..dc4c771ffa3f --- /dev/null +++ b/extensions/ollama/src/stream-api.ts @@ -0,0 +1,31 @@ +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; + +export { + createConfiguredOllamaCompatStreamWrapper, + isOllamaCompatProvider, + resolveOllamaCompatNumCtxEnabled, + shouldInjectOllamaCompatNumCtx, + wrapOllamaCompatNumCtx, +} from "./stream-compat.js"; + +type OllamaStreamRuntime = typeof import("./stream.runtime.js"); + +const loadOllamaStreamRuntime = createLazyRuntimeModule(() => import("./stream.runtime.js")); +const ollamaStreamRuntime = await loadOllamaStreamRuntime(); + +export const OLLAMA_NATIVE_BASE_URL: OllamaStreamRuntime["OLLAMA_NATIVE_BASE_URL"] = + ollamaStreamRuntime.OLLAMA_NATIVE_BASE_URL; +export const resolveOllamaBaseUrlForRun: OllamaStreamRuntime["resolveOllamaBaseUrlForRun"] = + ollamaStreamRuntime.resolveOllamaBaseUrlForRun; +export const buildOllamaChatRequest: OllamaStreamRuntime["buildOllamaChatRequest"] = + ollamaStreamRuntime.buildOllamaChatRequest; +export const convertToOllamaMessages: OllamaStreamRuntime["convertToOllamaMessages"] = + ollamaStreamRuntime.convertToOllamaMessages; +export const buildAssistantMessage: OllamaStreamRuntime["buildAssistantMessage"] = + ollamaStreamRuntime.buildAssistantMessage; +export const parseNdjsonStream: OllamaStreamRuntime["parseNdjsonStream"] = + ollamaStreamRuntime.parseNdjsonStream; +export const createOllamaStreamFn: OllamaStreamRuntime["createOllamaStreamFn"] = + ollamaStreamRuntime.createOllamaStreamFn; +export const createConfiguredOllamaStreamFn: OllamaStreamRuntime["createConfiguredOllamaStreamFn"] = + ollamaStreamRuntime.createConfiguredOllamaStreamFn; diff --git a/extensions/ollama/src/stream-compat.ts b/extensions/ollama/src/stream-compat.ts new file mode 100644 index 000000000000..45b27ec7984b --- /dev/null +++ b/extensions/ollama/src/stream-compat.ts @@ -0,0 +1,243 @@ +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import { streamSimple } from "openclaw/plugin-sdk/llm"; +import type { + OpenClawConfig, + ProviderRuntimeModel, + ProviderWrapStreamFnContext, +} from "openclaw/plugin-sdk/plugin-entry"; +import { + DEFAULT_CONTEXT_TOKENS, + normalizeProviderId, +} from "openclaw/plugin-sdk/provider-model-shared"; +import { + createMoonshotThinkingWrapper, + resolveMoonshotThinkingType, + streamWithPayloadPatch, +} from "openclaw/plugin-sdk/provider-stream-shared"; +import { isLoopbackHost } from "openclaw/plugin-sdk/ssrf-runtime"; +import { shouldWrapOllamaCompatMoonshotThinking } from "./model-behavior.js"; + +export type OllamaThinkValue = boolean | "low" | "medium" | "high"; + +export function resolveConfiguredOllamaProviderConfig(params: { + config?: OpenClawConfig; + providerId?: string; +}) { + const providerId = params.providerId?.trim(); + if (!providerId) { + return undefined; + } + const providers = params.config?.models?.providers; + if (!providers) { + return undefined; + } + const direct = providers[providerId]; + if (direct) { + return direct; + } + const normalized = normalizeProviderId(providerId); + for (const [candidateId, candidate] of Object.entries(providers)) { + if (normalizeProviderId(candidateId) === normalized) { + return candidate; + } + } + return undefined; +} + +export function isOllamaCompatProvider(model: { + provider?: string; + baseUrl?: string; + api?: string; +}): boolean { + const providerId = normalizeProviderId(model.provider ?? ""); + if (providerId === "ollama") { + return true; + } + if (!model.baseUrl) { + return false; + } + try { + const parsed = new URL(model.baseUrl); + if (isLoopbackHost(parsed.hostname) && parsed.port === "11434") { + return true; + } + + // Allow remote/LAN Ollama OpenAI-compatible endpoints when the provider id + // itself indicates Ollama usage (for example "my-ollama"). + const providerHintsOllama = providerId.includes("ollama"); + const isOllamaPort = parsed.port === "11434"; + const isOllamaCompatPath = parsed.pathname === "/" || /^\/v1\/?$/i.test(parsed.pathname); + return providerHintsOllama && isOllamaPort && isOllamaCompatPath; + } catch { + return false; + } +} + +export function resolveOllamaCompatNumCtxEnabled(params: { + config?: OpenClawConfig; + providerId?: string; +}): boolean { + return resolveConfiguredOllamaProviderConfig(params)?.injectNumCtxForOpenAICompat ?? true; +} + +export function shouldInjectOllamaCompatNumCtx(params: { + model: { api?: string; provider?: string; baseUrl?: string }; + config?: OpenClawConfig; + providerId?: string; +}): boolean { + if (params.model.api !== "openai-completions") { + return false; + } + if (!isOllamaCompatProvider(params.model)) { + return false; + } + return resolveOllamaCompatNumCtxEnabled({ + config: params.config, + providerId: params.providerId, + }); +} + +export function wrapOllamaCompatNumCtx(baseFn: StreamFn | undefined, numCtx: number): StreamFn { + const streamFn = baseFn ?? streamSimple; + return (model, context, options) => + streamWithPayloadPatch(streamFn, model, context, options, (payloadRecord) => { + if (!payloadRecord.options || typeof payloadRecord.options !== "object") { + payloadRecord.options = {}; + } + (payloadRecord.options as Record).num_ctx = numCtx; + }); +} + +function createOllamaThinkingWrapper( + baseFn: StreamFn | undefined, + think: OllamaThinkValue, +): StreamFn { + const streamFn = baseFn ?? streamSimple; + return (model, context, options) => + streamWithPayloadPatch(streamFn, model, context, options, (payloadRecord) => { + payloadRecord.think = think; + }); +} + +function resolveOllamaThinkValue(thinkingLevel: unknown): OllamaThinkValue | undefined { + if (thinkingLevel === "off") { + return false; + } + if (thinkingLevel === "low" || thinkingLevel === "medium" || thinkingLevel === "high") { + return thinkingLevel; + } + if (thinkingLevel === "minimal") { + return "low"; + } + if (thinkingLevel === "xhigh" || thinkingLevel === "adaptive" || thinkingLevel === "max") { + return "high"; + } + return undefined; +} + +export function resolveOllamaThinkParamValue( + params: Record | undefined, +): OllamaThinkValue | undefined { + const raw = params?.think ?? params?.thinking; + if (typeof raw === "boolean") { + return raw; + } + if (raw === "off") { + return false; + } + if (raw === "low" || raw === "medium" || raw === "high") { + return raw; + } + if (raw === "minimal") { + return "low"; + } + if (raw === "xhigh" || raw === "adaptive" || raw === "max") { + return "high"; + } + return undefined; +} + +export function shouldForwardNativeOllamaThink( + model: ProviderRuntimeModel | undefined, + think: OllamaThinkValue, +): boolean { + // Ollama accepts top-level `think` as the native chat contract, but rejects + // truthy values for models known not to expose thinking support. + return think === false || model?.reasoning !== false; +} + +export function resolveOllamaConfiguredNumCtx(model: ProviderRuntimeModel): number | undefined { + const raw = model.params?.num_ctx; + if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) { + return undefined; + } + return Math.floor(raw); +} + +function resolveOllamaNumCtx(model: ProviderRuntimeModel): number { + return ( + resolveOllamaConfiguredNumCtx(model) ?? + Math.max( + 1, + Math.floor( + model.contextTokens ?? model.contextWindow ?? model.maxTokens ?? DEFAULT_CONTEXT_TOKENS, + ), + ) + ); +} + +export function createConfiguredOllamaCompatStreamWrapper( + ctx: ProviderWrapStreamFnContext, +): StreamFn | undefined { + let streamFn = ctx.streamFn; + const model = ctx.model; + let injectNumCtx = false; + const isNativeOllamaTransport = model?.api === "ollama"; + + if (model) { + const providerId = + typeof model.provider === "string" && model.provider.trim().length > 0 + ? model.provider + : ctx.provider; + if ( + shouldInjectOllamaCompatNumCtx({ + model, + config: ctx.config, + providerId, + }) + ) { + injectNumCtx = true; + } + } + + if (injectNumCtx && model) { + streamFn = wrapOllamaCompatNumCtx(streamFn, resolveOllamaNumCtx(model)); + } + + const configuredThinkValue = model ? resolveOllamaThinkParamValue(model.params) : undefined; + const runtimeThinkValue = isNativeOllamaTransport + ? resolveOllamaThinkValue(ctx.thinkingLevel) + : undefined; + // "off" is also the implicit agent default. Preserve explicit native Ollama + // model config unless the active run requests a non-off thinking level. + const ollamaThinkValue = + runtimeThinkValue === false && configuredThinkValue !== undefined + ? undefined + : runtimeThinkValue; + if (ollamaThinkValue !== undefined && shouldForwardNativeOllamaThink(model, ollamaThinkValue)) { + streamFn = createOllamaThinkingWrapper(streamFn, ollamaThinkValue); + } + + if ( + normalizeProviderId(ctx.provider) === "ollama" && + shouldWrapOllamaCompatMoonshotThinking(ctx.modelId) + ) { + const thinkingType = resolveMoonshotThinkingType({ + configuredThinking: ctx.extraParams?.thinking, + thinkingLevel: ctx.thinkingLevel, + }); + streamFn = createMoonshotThinkingWrapper(streamFn, thinkingType); + } + + return streamFn; +} diff --git a/extensions/ollama/src/stream-contract.ts b/extensions/ollama/src/stream-contract.ts new file mode 100644 index 000000000000..643392654e49 --- /dev/null +++ b/extensions/ollama/src/stream-contract.ts @@ -0,0 +1,2 @@ +// Lightweight stream constants used before the Ollama transport is activated. +export const OLLAMA_INCOMPLETE_STREAM_ERROR = "Ollama API stream ended without a final response"; diff --git a/extensions/ollama/src/stream-registration.ts b/extensions/ollama/src/stream-registration.ts new file mode 100644 index 000000000000..a4ab8028e42f --- /dev/null +++ b/extensions/ollama/src/stream-registration.ts @@ -0,0 +1,17 @@ +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; + +const loadOllamaStreamRuntime = createLazyRuntimeModule(() => import("./stream.runtime.js")); + +export function createLazyConfiguredOllamaStreamFn(params: { + model: { baseUrl?: string; headers?: unknown }; + providerBaseUrl?: string; +}): StreamFn { + const streamFnPromise = loadOllamaStreamRuntime().then((runtime) => + runtime.createConfiguredOllamaStreamFn(params), + ); + return async (...args) => { + const streamFn = await streamFnPromise; + return streamFn(...args); + }; +} diff --git a/extensions/ollama/src/stream-runtime.test.ts b/extensions/ollama/src/stream-runtime.test.ts index 357fff2b0065..56ffab843ffe 100644 --- a/extensions/ollama/src/stream-runtime.test.ts +++ b/extensions/ollama/src/stream-runtime.test.ts @@ -19,8 +19,8 @@ vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => { }; }); +import { OLLAMA_INCOMPLETE_STREAM_ERROR } from "./stream-contract.js"; import { - OLLAMA_INCOMPLETE_STREAM_ERROR, buildOllamaChatRequest, createConfiguredOllamaCompatStreamWrapper, createConfiguredOllamaStreamFn, @@ -29,7 +29,7 @@ import { buildAssistantMessage, parseNdjsonStream, resolveOllamaBaseUrlForRun, -} from "./stream.js"; +} from "./stream.runtime.js"; type GuardedFetchCall = { url: string; diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.runtime.ts similarity index 84% rename from extensions/ollama/src/stream.ts rename to extensions/ollama/src/stream.runtime.ts index 22482d1a9554..bae228a7efd4 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.runtime.ts @@ -1,4 +1,4 @@ -// Ollama plugin module implements stream behavior. +// Ollama stream runtime implements native transport behavior. import { randomUUID } from "node:crypto"; import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; @@ -11,30 +11,16 @@ import type { Tool, Usage, } from "openclaw/plugin-sdk/llm"; -import { createAssistantMessageEventStream, streamSimple } from "openclaw/plugin-sdk/llm"; -import type { - OpenClawConfig, - ProviderRuntimeModel, - ProviderWrapStreamFnContext, -} from "openclaw/plugin-sdk/plugin-entry"; +import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm"; +import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry"; import { isNonSecretApiKeyMarker } from "openclaw/plugin-sdk/provider-auth"; import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; -import { - DEFAULT_CONTEXT_TOKENS, - normalizeProviderId, -} from "openclaw/plugin-sdk/provider-model-shared"; -import { - createMoonshotThinkingWrapper, - createPlainTextToolCallCompatWrapper, - resolveMoonshotThinkingType, - streamWithPayloadPatch, -} from "openclaw/plugin-sdk/provider-stream-shared"; +import { createPlainTextToolCallCompatWrapper } from "openclaw/plugin-sdk/provider-stream-shared"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; -import { fetchWithSsrFGuard, isLoopbackHost } from "openclaw/plugin-sdk/ssrf-runtime"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { isRecord, readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime"; import { estimateStringChars, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { OLLAMA_CLOUD_BASE_URL, OLLAMA_DEFAULT_BASE_URL } from "./defaults.js"; -import { shouldWrapOllamaCompatMoonshotThinking } from "./model-behavior.js"; import { normalizeOllamaWireModelId } from "./model-id.js"; import { parseJsonObjectPreservingUnsafeIntegers, @@ -45,11 +31,26 @@ import { createOllamaVisibleContentSanitizer, sanitizeOllamaFinalVisibleContent, } from "./sanitizers/visible-content.js"; +import { + type OllamaThinkValue, + resolveOllamaConfiguredNumCtx, + resolveOllamaThinkParamValue, + shouldForwardNativeOllamaThink, +} from "./stream-compat.js"; +import { OLLAMA_INCOMPLETE_STREAM_ERROR } from "./stream-contract.js"; import { checkNdjsonRecordCap } from "./stream-ndjson-cap.js"; + +export { + createConfiguredOllamaCompatStreamWrapper, + isOllamaCompatProvider, + resolveOllamaCompatNumCtxEnabled, + shouldInjectOllamaCompatNumCtx, + wrapOllamaCompatNumCtx, +} from "./stream-compat.js"; + const log = createSubsystemLogger("ollama-stream"); export const OLLAMA_NATIVE_BASE_URL = OLLAMA_DEFAULT_BASE_URL; -export const OLLAMA_INCOMPLETE_STREAM_ERROR = "Ollama API stream ended without a final response"; const OLLAMA_STREAM_COOPERATIVE_YIELD_INTERVAL_MS = 12; const OLLAMA_STREAM_COOPERATIVE_YIELD_MAX_EVENTS = 64; @@ -153,97 +154,6 @@ export function resolveOllamaBaseUrlForRun(params: { return OLLAMA_NATIVE_BASE_URL; } -export function resolveConfiguredOllamaProviderConfig(params: { - config?: OpenClawConfig; - providerId?: string; -}) { - const providerId = params.providerId?.trim(); - if (!providerId) { - return undefined; - } - const providers = params.config?.models?.providers; - if (!providers) { - return undefined; - } - const direct = providers[providerId]; - if (direct) { - return direct; - } - const normalized = normalizeProviderId(providerId); - for (const [candidateId, candidate] of Object.entries(providers)) { - if (normalizeProviderId(candidateId) === normalized) { - return candidate; - } - } - return undefined; -} - -export function isOllamaCompatProvider(model: { - provider?: string; - baseUrl?: string; - api?: string; -}): boolean { - const providerId = normalizeProviderId(model.provider ?? ""); - if (providerId === "ollama") { - return true; - } - if (!model.baseUrl) { - return false; - } - try { - const parsed = new URL(model.baseUrl); - if (isLoopbackHost(parsed.hostname) && parsed.port === "11434") { - return true; - } - - // Allow remote/LAN Ollama OpenAI-compatible endpoints when the provider id - // itself indicates Ollama usage (for example "my-ollama"). - const providerHintsOllama = providerId.includes("ollama"); - const isOllamaPort = parsed.port === "11434"; - const isOllamaCompatPath = parsed.pathname === "/" || /^\/v1\/?$/i.test(parsed.pathname); - return providerHintsOllama && isOllamaPort && isOllamaCompatPath; - } catch { - return false; - } -} - -export function resolveOllamaCompatNumCtxEnabled(params: { - config?: OpenClawConfig; - providerId?: string; -}): boolean { - return resolveConfiguredOllamaProviderConfig(params)?.injectNumCtxForOpenAICompat ?? true; -} - -export function shouldInjectOllamaCompatNumCtx(params: { - model: { api?: string; provider?: string; baseUrl?: string }; - config?: OpenClawConfig; - providerId?: string; -}): boolean { - if (params.model.api !== "openai-completions") { - return false; - } - if (!isOllamaCompatProvider(params.model)) { - return false; - } - return resolveOllamaCompatNumCtxEnabled({ - config: params.config, - providerId: params.providerId, - }); -} - -export function wrapOllamaCompatNumCtx(baseFn: StreamFn | undefined, numCtx: number): StreamFn { - const streamFn = baseFn ?? streamSimple; - return (model, context, options) => - streamWithPayloadPatch(streamFn, model, context, options, (payloadRecord) => { - if (!payloadRecord.options || typeof payloadRecord.options !== "object") { - payloadRecord.options = {}; - } - (payloadRecord.options as Record).num_ctx = numCtx; - }); -} - -type OllamaThinkValue = boolean | "low" | "medium" | "high"; - const OLLAMA_OPTION_PARAM_KEYS = new Set([ "num_keep", "seed", @@ -268,92 +178,14 @@ const OLLAMA_OPTION_PARAM_KEYS = new Set([ const OLLAMA_TOP_LEVEL_PARAM_KEYS = new Set(["format", "keep_alive", "truncate", "shift"]); -function createOllamaThinkingWrapper( - baseFn: StreamFn | undefined, - think: OllamaThinkValue, -): StreamFn { - const streamFn = baseFn ?? streamSimple; - return (model, context, options) => - streamWithPayloadPatch(streamFn, model, context, options, (payloadRecord) => { - payloadRecord.think = think; - }); -} - -function resolveOllamaThinkValue(thinkingLevel: unknown): OllamaThinkValue | undefined { - if (thinkingLevel === "off") { - return false; - } - if (thinkingLevel === "low" || thinkingLevel === "medium" || thinkingLevel === "high") { - return thinkingLevel; - } - if (thinkingLevel === "minimal") { - return "low"; - } - if (thinkingLevel === "xhigh" || thinkingLevel === "adaptive" || thinkingLevel === "max") { - return "high"; - } - return undefined; -} - -function resolveOllamaThinkParamValue( - params: Record | undefined, -): OllamaThinkValue | undefined { - const raw = params?.think ?? params?.thinking; - if (typeof raw === "boolean") { - return raw; - } - if (raw === "off") { - return false; - } - if (raw === "low" || raw === "medium" || raw === "high") { - return raw; - } - if (raw === "minimal") { - return "low"; - } - if (raw === "xhigh" || raw === "adaptive" || raw === "max") { - return "high"; - } - return undefined; -} - -function shouldForwardNativeOllamaThink( - model: ProviderRuntimeModel | undefined, - think: OllamaThinkValue, -): boolean { - // Ollama accepts top-level `think` as the native chat contract, but rejects - // truthy values for models known not to expose thinking support. - return think === false || model?.reasoning !== false; -} - -function resolveOllamaConfiguredNumCtx(model: ProviderRuntimeModel): number | undefined { - const raw = model.params?.num_ctx; - if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) { - return undefined; - } - return Math.floor(raw); -} - -function resolveOllamaNumCtx(model: ProviderRuntimeModel): number { - return ( - resolveOllamaConfiguredNumCtx(model) ?? - Math.max( - 1, - Math.floor( - model.contextTokens ?? model.contextWindow ?? model.maxTokens ?? DEFAULT_CONTEXT_TOKENS, - ), - ) - ); -} - /** * Resolves num_ctx for native /api/chat requests: * 1. explicit `params.num_ctx` set on the model wins, * 2. the effective `contextTokens` runtime cap is forwarded when present, * 3. otherwise Ollama's model, OLLAMA_CONTEXT_LENGTH, VRAM, or Modelfile policy decides. * - * This intentionally differs from `resolveOllamaNumCtx` by not falling back - * to `DEFAULT_CONTEXT_TOKENS`: that constant is a sane wrapper-side guess for + * This intentionally differs from the OpenAI-compat resolver by not falling back + * to a default context size: that fallback is a sane wrapper-side guess for * the OpenAI-compat path, but native `/api/chat` should not force the full * advertised `contextWindow`; only an explicit runtime cap or operator override is forwarded. */ @@ -435,62 +267,6 @@ function resolveStreamingTextDelta(previousText: string, nextText: string): stri return nextText; } -export function createConfiguredOllamaCompatStreamWrapper( - ctx: ProviderWrapStreamFnContext, -): StreamFn | undefined { - let streamFn = ctx.streamFn; - const model = ctx.model; - let injectNumCtx = false; - const isNativeOllamaTransport = model?.api === "ollama"; - - if (model) { - const providerId = - typeof model.provider === "string" && model.provider.trim().length > 0 - ? model.provider - : ctx.provider; - if ( - shouldInjectOllamaCompatNumCtx({ - model, - config: ctx.config, - providerId, - }) - ) { - injectNumCtx = true; - } - } - - if (injectNumCtx && model) { - streamFn = wrapOllamaCompatNumCtx(streamFn, resolveOllamaNumCtx(model)); - } - - const configuredThinkValue = model ? resolveOllamaThinkParamValue(model.params) : undefined; - const runtimeThinkValue = isNativeOllamaTransport - ? resolveOllamaThinkValue(ctx.thinkingLevel) - : undefined; - // "off" is also the implicit agent default. Preserve explicit native Ollama - // model config unless the active run requests a non-off thinking level. - const ollamaThinkValue = - runtimeThinkValue === false && configuredThinkValue !== undefined - ? undefined - : runtimeThinkValue; - if (ollamaThinkValue !== undefined && shouldForwardNativeOllamaThink(model, ollamaThinkValue)) { - streamFn = createOllamaThinkingWrapper(streamFn, ollamaThinkValue); - } - - if ( - normalizeProviderId(ctx.provider) === "ollama" && - shouldWrapOllamaCompatMoonshotThinking(ctx.modelId) - ) { - const thinkingType = resolveMoonshotThinkingType({ - configuredThinking: ctx.extraParams?.thinking, - thinkingLevel: ctx.thinkingLevel, - }); - streamFn = createMoonshotThinkingWrapper(streamFn, thinkingType); - } - - return streamFn; -} - export function buildOllamaChatRequest(params: { modelId: string; providerId?: string; diff --git a/extensions/ollama/src/stream.test.ts b/extensions/ollama/src/stream.test.ts index ebe011d5393f..cf8e7e23a915 100644 --- a/extensions/ollama/src/stream.test.ts +++ b/extensions/ollama/src/stream.test.ts @@ -13,7 +13,11 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => ({ fetchWithSsrFGuard: fetchWithSsrFGuardMock, })); -import { buildAssistantMessage, createOllamaStreamFn, isOllamaCompatProvider } from "./stream.js"; +import { + buildAssistantMessage, + createOllamaStreamFn, + isOllamaCompatProvider, +} from "./stream-api.js"; function makeOllamaResponse(params: { content?: string; diff --git a/extensions/ollama/src/web-search-contract.ts b/extensions/ollama/src/web-search-contract.ts new file mode 100644 index 000000000000..38264a01ec43 --- /dev/null +++ b/extensions/ollama/src/web-search-contract.ts @@ -0,0 +1,19 @@ +import type { WebSearchProviderToolDefinition } from "openclaw/plugin-sdk/provider-web-search-contract"; + +export const OLLAMA_WEB_SEARCH_TOOL_DESCRIPTION = + "Search the web using Ollama's web search API. Returns titles, URLs, and snippets from the configured Ollama host."; + +export const OLLAMA_WEB_SEARCH_TOOL_PARAMETERS = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "integer", + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, + }, + }, + required: ["query"], + additionalProperties: false, +} as const satisfies WebSearchProviderToolDefinition["parameters"]; diff --git a/extensions/ollama/src/web-search-provider-registration.ts b/extensions/ollama/src/web-search-provider-registration.ts new file mode 100644 index 000000000000..aeeb11e0df71 --- /dev/null +++ b/extensions/ollama/src/web-search-provider-registration.ts @@ -0,0 +1,42 @@ +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; +import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract"; +import { createOllamaWebSearchProvider as createOllamaWebSearchProviderContract } from "../web-search-contract-api.js"; +import { + OLLAMA_WEB_SEARCH_TOOL_DESCRIPTION, + OLLAMA_WEB_SEARCH_TOOL_PARAMETERS, +} from "./web-search-contract.js"; + +const loadOllamaWebSearchProvider = createLazyRuntimeModule( + () => import("./web-search-provider.runtime.js"), +); + +export function createLazyOllamaWebSearchProvider(): WebSearchProviderPlugin { + let providerPromise: Promise | undefined; + const loadProvider = () => + (providerPromise ??= loadOllamaWebSearchProvider().then((runtime) => + runtime.createOllamaWebSearchProvider(), + )); + return { + ...createOllamaWebSearchProviderContract(), + runSetup: async (ctx) => { + const provider = await loadProvider(); + return provider.runSetup ? await provider.runSetup(ctx) : ctx.config; + }, + createTool: (ctx) => { + let toolPromise: Promise> | undefined; + const loadTool = () => + (toolPromise ??= loadProvider().then((provider) => provider.createTool(ctx))); + return { + description: OLLAMA_WEB_SEARCH_TOOL_DESCRIPTION, + parameters: OLLAMA_WEB_SEARCH_TOOL_PARAMETERS, + execute: async (args, executionContext) => { + const tool = await loadTool(); + if (!tool) { + throw new Error("Ollama web search runtime did not create a tool"); + } + return await tool.execute(args, executionContext); + }, + }; + }, + }; +} diff --git a/extensions/ollama/src/web-search-provider.runtime.ts b/extensions/ollama/src/web-search-provider.runtime.ts new file mode 100644 index 000000000000..c7c85264067f --- /dev/null +++ b/extensions/ollama/src/web-search-provider.runtime.ts @@ -0,0 +1,367 @@ +// Ollama web-search runtime implements provider integration. +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { + isNonSecretApiKeyMarker, + normalizeOptionalSecretInput, +} from "openclaw/plugin-sdk/provider-auth"; +import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime"; +import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; +import { + enablePluginInConfig, + readPositiveIntegerParam, + readResponseText, + readStringParam, + resolveProviderWebSearchPluginConfig, + resolveSearchCount, + resolveSiteName, + resolveWebSearchProviderCredential, + truncateText, + wrapWebContent, + type WebSearchProviderPlugin, +} from "openclaw/plugin-sdk/provider-web-search"; +import { coerceSecretRef } from "openclaw/plugin-sdk/secret-input"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js"; +import { readProviderBaseUrl } from "./provider-base-url.js"; +import { + buildOllamaBaseUrlSsrFPolicy, + fetchOllamaModels, + resolveOllamaApiBase, +} from "./provider-models.js"; +import { + OLLAMA_WEB_SEARCH_TOOL_DESCRIPTION, + OLLAMA_WEB_SEARCH_TOOL_PARAMETERS, +} from "./web-search-contract.js"; + +const OLLAMA_HOSTED_WEB_SEARCH_PATH = "/api/web_search"; +const OLLAMA_LOCAL_WEB_SEARCH_PROXY_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; + +type OllamaWebSearchResult = { + title?: string; + url?: string; + content?: string; +}; + +type OllamaWebSearchResponse = { + results?: OllamaWebSearchResult[]; +}; + +type OllamaWebSearchAttempt = { + baseUrl: string; + path: string; + apiKey?: string; +}; + +async function readOllamaWebSearchResponse(response: Response): Promise { + return await readProviderJsonResponse(response, "Ollama web search"); +} + +function isOllamaCloudBaseUrl(baseUrl: string): boolean { + try { + const parsed = new URL(baseUrl); + return parsed.protocol === "https:" && parsed.hostname === "ollama.com"; + } catch { + return false; + } +} + +function normalizeOllamaWebSearchApiKey(value: unknown): string | undefined { + const apiKey = normalizeOptionalSecretInput(value); + return apiKey && !isNonSecretApiKeyMarker(apiKey) ? apiKey : undefined; +} + +function resolveEnvOllamaWebSearchApiKey(): string | undefined { + return normalizeOllamaWebSearchApiKey(resolveEnvApiKey("ollama")?.apiKey); +} + +function createOllamaWebSearchCredentialError(ref: { source: string; id: string }): Error { + return new Error( + ref.source === "env" + ? `models.providers.ollama.apiKey env SecretRef ${ref.id} is not available for Ollama web search.` + : "models.providers.ollama.apiKey SecretRef cannot be resolved by Ollama web search. Use an env SecretRef for this path.", + ); +} + +// Delegate configured-key resolution (literal value or env-backed SecretRef) to the shared +// web-search resolver, then apply Ollama's marker filter so persisted non-secret placeholders +// (e.g. the OAuth/signin marker) fall through to the ambient OLLAMA_API_KEY instead of being sent. +function resolveConfiguredOllamaWebSearchApiKey(config?: OpenClawConfig): string | undefined { + const credentialValue = config?.models?.providers?.ollama?.apiKey; + const credentialRef = coerceSecretRef(credentialValue); + const resolvedValue = normalizeOllamaWebSearchApiKey( + resolveWebSearchProviderCredential({ + credentialValue, + path: "models.providers.ollama.apiKey", + envVars: [], + }), + ); + // An explicit ref selects one credential. Do not reinterpret an unavailable ref as no config, + // which would permit an unrelated ambient key and potentially route the query to Ollama Cloud. + if (credentialRef && !resolvedValue) { + throw createOllamaWebSearchCredentialError(credentialRef); + } + return resolvedValue; +} + +function resolveOllamaWebSearchBaseUrl(config?: OpenClawConfig): string { + const pluginBaseUrl = normalizeOptionalString( + resolveProviderWebSearchPluginConfig(config, "ollama")?.baseUrl, + ); + if (pluginBaseUrl) { + return resolveOllamaApiBase(pluginBaseUrl); + } + const configuredBaseUrl = readProviderBaseUrl(config?.models?.providers?.ollama); + if (configuredBaseUrl) { + return resolveOllamaApiBase(configuredBaseUrl); + } + return OLLAMA_DEFAULT_BASE_URL; +} + +function normalizeOllamaWebSearchResult( + result: OllamaWebSearchResult, +): { title: string; url: string; content: string } | null { + const url = normalizeOptionalString(result.url) ?? ""; + if (!url) { + return null; + } + return { + title: normalizeOptionalString(result.title) ?? "", + url, + content: normalizeOptionalString(result.content) ?? "", + }; +} + +function buildOllamaWebSearchAttempts(params: { + baseUrl: string; + configuredApiKey?: string; + envApiKey?: string; +}): OllamaWebSearchAttempt[] { + if (isOllamaCloudBaseUrl(params.baseUrl)) { + return [ + { + baseUrl: params.baseUrl, + path: OLLAMA_HOSTED_WEB_SEARCH_PATH, + apiKey: params.configuredApiKey ?? params.envApiKey, + }, + ]; + } + + const attempts: OllamaWebSearchAttempt[] = [ + { + baseUrl: params.baseUrl, + path: OLLAMA_LOCAL_WEB_SEARCH_PROXY_PATH, + apiKey: params.configuredApiKey, + }, + { + baseUrl: params.baseUrl, + path: OLLAMA_HOSTED_WEB_SEARCH_PATH, + apiKey: params.configuredApiKey, + }, + ]; + if (params.envApiKey) { + attempts.push({ + baseUrl: OLLAMA_CLOUD_BASE_URL, + path: OLLAMA_HOSTED_WEB_SEARCH_PATH, + apiKey: params.envApiKey, + }); + } + return attempts; +} + +async function runOllamaWebSearch(params: { + config?: OpenClawConfig; + query: string; + count?: number; + signal?: AbortSignal; +}): Promise> { + const query = params.query.trim(); + if (!query) { + throw new Error("query parameter is required"); + } + + const baseUrl = resolveOllamaWebSearchBaseUrl(params.config); + const configuredApiKey = resolveConfiguredOllamaWebSearchApiKey(params.config); + // Resolve the ambient cloud key independently of the configured selected-host key so a mixed + // setup still reaches the Ollama Cloud fallback with OLLAMA_API_KEY after the selected-host + // attempts fail. Gating this on configuredApiKey would drop that final authenticated attempt. + const envApiKey = resolveEnvOllamaWebSearchApiKey(); + const count = resolveSearchCount(params.count, DEFAULT_OLLAMA_WEB_SEARCH_COUNT); + const startedAt = Date.now(); + const body = JSON.stringify({ query, max_results: count }); + const attempts = buildOllamaWebSearchAttempts({ baseUrl, configuredApiKey, envApiKey }); + + let payload: OllamaWebSearchResponse | undefined; + let lastError: Error | undefined; + for (const attempt of attempts) { + params.signal?.throwIfAborted(); + const headers: Record = { "Content-Type": "application/json" }; + if (attempt.apiKey) { + headers.Authorization = `Bearer ${attempt.apiKey}`; + } + const { response, release } = await fetchWithSsrFGuard({ + url: `${attempt.baseUrl}${attempt.path}`, + init: { + method: "POST", + headers, + body, + }, + // Guard-owned timeoutMs also bounds DNS/proxy preflight; init.signal does not. + timeoutMs: DEFAULT_OLLAMA_WEB_SEARCH_TIMEOUT_MS, + ...(params.signal ? { signal: params.signal } : {}), + 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 readOllamaWebSearchResponse(response); + params.signal?.throwIfAborted(); + break; + } catch (error) { + if (error instanceof Error) { + lastError = error; + } else { + lastError = new Error(String(error)); + } + throw lastError; + } finally { + // The 401/403 branches throw before the stream is touched, leaving release + // to force-close the active dispatcher. Start cancellation first; awaiting + // it can deadlock when capture tees the stream. + if (!response.bodyUsed) { + void response.body?.cancel().catch(() => undefined); + } + 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: { + config: OpenClawConfig; + prompter: { + note: (message: string, title?: string) => Promise; + }; +}): Promise { + const baseUrl = resolveOllamaWebSearchBaseUrl(params.config); + const { reachable } = await fetchOllamaModels(baseUrl); + if (!reachable) { + await params.prompter.note( + [ + "Ollama Web Search requires Ollama to be running.", + `Expected host: ${baseUrl}`, + "Start Ollama before using this provider.", + ].join("\n"), + "Ollama Web Search", + ); + return params.config; + } + + const { checkOllamaCloudAuth } = await import("./setup.runtime.js"); + const auth = await checkOllamaCloudAuth(baseUrl); + if (!auth.signedIn) { + await params.prompter.note( + [ + "Ollama Web Search requires `ollama signin`.", + ...(auth.signinUrl ? [auth.signinUrl] : ["Run `ollama signin`."]), + ].join("\n"), + "Ollama Web Search", + ); + } + + return params.config; +} + +export function createOllamaWebSearchProvider(): WebSearchProviderPlugin { + return { + id: "ollama", + label: "Ollama Web Search", + hint: "Local Ollama host · requires ollama signin", + onboardingScopes: ["text-inference"], + requiresCredential: false, + envVars: [], + placeholder: "(run ollama signin)", + signupUrl: "https://ollama.com/", + docsUrl: "https://docs.openclaw.ai/tools/web", + autoDetectOrder: 110, + credentialPath: "", + getCredentialValue: () => undefined, + setCredentialValue: () => {}, + applySelectionConfig: (config) => enablePluginInConfig(config, "ollama").config, + runSetup: async (ctx) => + await warnOllamaWebSearchPrereqs({ + config: ctx.config, + prompter: ctx.prompter, + }), + createTool: (ctx) => ({ + description: OLLAMA_WEB_SEARCH_TOOL_DESCRIPTION, + parameters: OLLAMA_WEB_SEARCH_TOOL_PARAMETERS, + execute: async (args, context) => { + context?.signal?.throwIfAborted(); + return await runOllamaWebSearch({ + config: ctx.config, + query: readStringParam(args, "query", { required: true }), + count: readPositiveIntegerParam(args, "count", { + max: 10, + message: "count must be an integer from 1 to 10.", + }), + signal: context?.signal, + }); + }, + }), + }; +} diff --git a/extensions/ollama/src/web-search-provider.ts b/extensions/ollama/src/web-search-provider.ts index 5b81dd2ca95e..b0142ba6c5d1 100644 --- a/extensions/ollama/src/web-search-provider.ts +++ b/extensions/ollama/src/web-search-provider.ts @@ -1,379 +1 @@ -// Ollama provider module implements model/runtime integration. -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { - isNonSecretApiKeyMarker, - normalizeOptionalSecretInput, -} from "openclaw/plugin-sdk/provider-auth"; -import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime"; -import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; -import { - enablePluginInConfig, - readPositiveIntegerParam, - readResponseText, - readStringParam, - resolveProviderWebSearchPluginConfig, - resolveSearchCount, - resolveSiteName, - resolveWebSearchProviderCredential, - truncateText, - wrapWebContent, - type WebSearchProviderPlugin, -} from "openclaw/plugin-sdk/provider-web-search"; -import { coerceSecretRef } from "openclaw/plugin-sdk/secret-input"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { Type } from "typebox"; -import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js"; -import { readProviderBaseUrl } from "./provider-base-url.js"; -import { - buildOllamaBaseUrlSsrFPolicy, - fetchOllamaModels, - resolveOllamaApiBase, -} from "./provider-models.js"; -import { checkOllamaCloudAuth } from "./setup.js"; - -const OLLAMA_WEB_SEARCH_SCHEMA = Type.Object( - { - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Integer({ - description: "Number of results to return (1-10).", - minimum: 1, - maximum: 10, - }), - ), - }, - { additionalProperties: false }, -); - -const OLLAMA_HOSTED_WEB_SEARCH_PATH = "/api/web_search"; -const OLLAMA_LOCAL_WEB_SEARCH_PROXY_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; - -type OllamaWebSearchResult = { - title?: string; - url?: string; - content?: string; -}; - -type OllamaWebSearchResponse = { - results?: OllamaWebSearchResult[]; -}; - -type OllamaWebSearchAttempt = { - baseUrl: string; - path: string; - apiKey?: string; -}; - -async function readOllamaWebSearchResponse(response: Response): Promise { - return await readProviderJsonResponse(response, "Ollama web search"); -} - -function isOllamaCloudBaseUrl(baseUrl: string): boolean { - try { - const parsed = new URL(baseUrl); - return parsed.protocol === "https:" && parsed.hostname === "ollama.com"; - } catch { - return false; - } -} - -function normalizeOllamaWebSearchApiKey(value: unknown): string | undefined { - const apiKey = normalizeOptionalSecretInput(value); - return apiKey && !isNonSecretApiKeyMarker(apiKey) ? apiKey : undefined; -} - -function resolveEnvOllamaWebSearchApiKey(): string | undefined { - return normalizeOllamaWebSearchApiKey(resolveEnvApiKey("ollama")?.apiKey); -} - -function createOllamaWebSearchCredentialError(ref: { source: string; id: string }): Error { - return new Error( - ref.source === "env" - ? `models.providers.ollama.apiKey env SecretRef ${ref.id} is not available for Ollama web search.` - : "models.providers.ollama.apiKey SecretRef cannot be resolved by Ollama web search. Use an env SecretRef for this path.", - ); -} - -// Delegate configured-key resolution (literal value or env-backed SecretRef) to the shared -// web-search resolver, then apply Ollama's marker filter so persisted non-secret placeholders -// (e.g. the OAuth/signin marker) fall through to the ambient OLLAMA_API_KEY instead of being sent. -function resolveConfiguredOllamaWebSearchApiKey(config?: OpenClawConfig): string | undefined { - const credentialValue = config?.models?.providers?.ollama?.apiKey; - const credentialRef = coerceSecretRef(credentialValue); - const resolvedValue = normalizeOllamaWebSearchApiKey( - resolveWebSearchProviderCredential({ - credentialValue, - path: "models.providers.ollama.apiKey", - envVars: [], - }), - ); - // An explicit ref selects one credential. Do not reinterpret an unavailable ref as no config, - // which would permit an unrelated ambient key and potentially route the query to Ollama Cloud. - if (credentialRef && !resolvedValue) { - throw createOllamaWebSearchCredentialError(credentialRef); - } - return resolvedValue; -} - -function resolveOllamaWebSearchBaseUrl(config?: OpenClawConfig): string { - const pluginBaseUrl = normalizeOptionalString( - resolveProviderWebSearchPluginConfig(config, "ollama")?.baseUrl, - ); - if (pluginBaseUrl) { - return resolveOllamaApiBase(pluginBaseUrl); - } - const configuredBaseUrl = readProviderBaseUrl(config?.models?.providers?.ollama); - if (configuredBaseUrl) { - return resolveOllamaApiBase(configuredBaseUrl); - } - return OLLAMA_DEFAULT_BASE_URL; -} - -function normalizeOllamaWebSearchResult( - result: OllamaWebSearchResult, -): { title: string; url: string; content: string } | null { - const url = normalizeOptionalString(result.url) ?? ""; - if (!url) { - return null; - } - return { - title: normalizeOptionalString(result.title) ?? "", - url, - content: normalizeOptionalString(result.content) ?? "", - }; -} - -function buildOllamaWebSearchAttempts(params: { - baseUrl: string; - configuredApiKey?: string; - envApiKey?: string; -}): OllamaWebSearchAttempt[] { - if (isOllamaCloudBaseUrl(params.baseUrl)) { - return [ - { - baseUrl: params.baseUrl, - path: OLLAMA_HOSTED_WEB_SEARCH_PATH, - apiKey: params.configuredApiKey ?? params.envApiKey, - }, - ]; - } - - const attempts: OllamaWebSearchAttempt[] = [ - { - baseUrl: params.baseUrl, - path: OLLAMA_LOCAL_WEB_SEARCH_PROXY_PATH, - apiKey: params.configuredApiKey, - }, - { - baseUrl: params.baseUrl, - path: OLLAMA_HOSTED_WEB_SEARCH_PATH, - apiKey: params.configuredApiKey, - }, - ]; - if (params.envApiKey) { - attempts.push({ - baseUrl: OLLAMA_CLOUD_BASE_URL, - path: OLLAMA_HOSTED_WEB_SEARCH_PATH, - apiKey: params.envApiKey, - }); - } - return attempts; -} - -async function runOllamaWebSearch(params: { - config?: OpenClawConfig; - query: string; - count?: number; - signal?: AbortSignal; -}): Promise> { - const query = params.query.trim(); - if (!query) { - throw new Error("query parameter is required"); - } - - const baseUrl = resolveOllamaWebSearchBaseUrl(params.config); - const configuredApiKey = resolveConfiguredOllamaWebSearchApiKey(params.config); - // Resolve the ambient cloud key independently of the configured selected-host key so a mixed - // setup still reaches the Ollama Cloud fallback with OLLAMA_API_KEY after the selected-host - // attempts fail. Gating this on configuredApiKey would drop that final authenticated attempt. - const envApiKey = resolveEnvOllamaWebSearchApiKey(); - const count = resolveSearchCount(params.count, DEFAULT_OLLAMA_WEB_SEARCH_COUNT); - const startedAt = Date.now(); - const body = JSON.stringify({ query, max_results: count }); - const attempts = buildOllamaWebSearchAttempts({ baseUrl, configuredApiKey, envApiKey }); - - let payload: OllamaWebSearchResponse | undefined; - let lastError: Error | undefined; - for (const attempt of attempts) { - params.signal?.throwIfAborted(); - const headers: Record = { "Content-Type": "application/json" }; - if (attempt.apiKey) { - headers.Authorization = `Bearer ${attempt.apiKey}`; - } - const { response, release } = await fetchWithSsrFGuard({ - url: `${attempt.baseUrl}${attempt.path}`, - init: { - method: "POST", - headers, - body, - }, - // Guard-owned timeoutMs also bounds DNS/proxy preflight; init.signal does not. - timeoutMs: DEFAULT_OLLAMA_WEB_SEARCH_TIMEOUT_MS, - ...(params.signal ? { signal: params.signal } : {}), - 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 readOllamaWebSearchResponse(response); - params.signal?.throwIfAborted(); - break; - } catch (error) { - if (error instanceof Error) { - lastError = error; - } else { - lastError = new Error(String(error)); - } - throw lastError; - } finally { - // The 401/403 branches throw before the stream is touched, leaving release - // to force-close the active dispatcher. Start cancellation first; awaiting - // it can deadlock when capture tees the stream. - if (!response.bodyUsed) { - void response.body?.cancel().catch(() => undefined); - } - 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: { - config: OpenClawConfig; - prompter: { - note: (message: string, title?: string) => Promise; - }; -}): Promise { - const baseUrl = resolveOllamaWebSearchBaseUrl(params.config); - const { reachable } = await fetchOllamaModels(baseUrl); - if (!reachable) { - await params.prompter.note( - [ - "Ollama Web Search requires Ollama to be running.", - `Expected host: ${baseUrl}`, - "Start Ollama before using this provider.", - ].join("\n"), - "Ollama Web Search", - ); - return params.config; - } - - const auth = await checkOllamaCloudAuth(baseUrl); - if (!auth.signedIn) { - await params.prompter.note( - [ - "Ollama Web Search requires `ollama signin`.", - ...(auth.signinUrl ? [auth.signinUrl] : ["Run `ollama signin`."]), - ].join("\n"), - "Ollama Web Search", - ); - } - - return params.config; -} - -export function createOllamaWebSearchProvider(): WebSearchProviderPlugin { - return { - id: "ollama", - label: "Ollama Web Search", - hint: "Local Ollama host · requires ollama signin", - onboardingScopes: ["text-inference"], - requiresCredential: false, - envVars: [], - placeholder: "(run ollama signin)", - signupUrl: "https://ollama.com/", - docsUrl: "https://docs.openclaw.ai/tools/web", - autoDetectOrder: 110, - credentialPath: "", - getCredentialValue: () => undefined, - setCredentialValue: () => {}, - applySelectionConfig: (config) => enablePluginInConfig(config, "ollama").config, - runSetup: async (ctx) => - await warnOllamaWebSearchPrereqs({ - config: ctx.config, - prompter: ctx.prompter, - }), - createTool: (ctx) => ({ - description: - "Search the web using Ollama's web search API. Returns titles, URLs, and snippets from the configured Ollama host.", - parameters: OLLAMA_WEB_SEARCH_SCHEMA, - execute: async (args, context) => { - context?.signal?.throwIfAborted(); - return await runOllamaWebSearch({ - config: ctx.config, - query: readStringParam(args, "query", { required: true }), - count: readPositiveIntegerParam(args, "count", { - max: 10, - message: "count must be an integer from 1 to 10.", - }), - signal: context?.signal, - }); - }, - }), - }; -} +export { createLazyOllamaWebSearchProvider as createOllamaWebSearchProvider } from "./web-search-provider-registration.js";