diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b4db1a2289..58d09a7ac79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai - Providers/Ollama: strip the active custom Ollama provider prefix before native chat and embedding requests, so custom provider ids like `ollama-spark/qwen3:32b` reach Ollama as the real model name. Fixes #72353. Thanks @maximus-dss and @hclsys. - Providers/Ollama: parse stringified native tool-call arguments before dispatch, preserving unsafe integer values so Ollama tool use receives structured parameters. Fixes #69735; supersedes #69910. Thanks @rongshuzhao and @yfge. - Providers/Ollama: skip ambient localhost discovery unless Ollama auth or meaningful config opts in, preventing unexpected probes to `127.0.0.1:11434` for users who are not using Ollama. Fixes #56939; supersedes #57116. Thanks @IanxDev and @tsukhani. +- Providers/Ollama: skip implicit localhost discovery when a custom remote `api: "ollama"` provider is configured, while still treating `127/8` loopback hosts as local. Carries forward #43224. Thanks @issacthekaylon. - Providers/Ollama: move memory embeddings to Ollama's current `/api/embed` endpoint with batched `input` requests while preserving vector normalization and custom provider auth/header overrides. Fixes #39983. Thanks @sskkcc and @LiudengZhang. - Providers/Ollama: route local web search through Ollama's signed `/api/experimental/web_search` daemon proxy, use hosted `/api/web_search` directly for `ollama.com`, and keep `OLLAMA_API_KEY` scoped to cloud fallback auth. Fixes #69132. Thanks @yoon1012 and @hyspacex. - Providers/Ollama: accept OpenAI SDK-style `baseURL` as an alias for `baseUrl` across discovery, streaming, setup pulls, embeddings, and web search so remote Ollama hosts are not silently ignored. Fixes #62533; supersedes #62549. Thanks @Julien-BKK and @Linux2010. diff --git a/docs/providers/ollama.md b/docs/providers/ollama.md index b5b6847f3cc..2f7b3e3bb19 100644 --- a/docs/providers/ollama.md +++ b/docs/providers/ollama.md @@ -174,7 +174,7 @@ Choose your preferred setup method and mode. ## Model discovery (implicit provider) -When you set `OLLAMA_API_KEY` (or an auth profile) and **do not** define `models.providers.ollama`, OpenClaw discovers models from the local Ollama instance at `http://127.0.0.1:11434`. +When you set `OLLAMA_API_KEY` (or an auth profile) and **do not** define `models.providers.ollama` or another custom remote provider with `api: "ollama"`, OpenClaw discovers models from the local Ollama instance at `http://127.0.0.1:11434`. | Behavior | Detail | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -202,7 +202,7 @@ ollama pull mistral The new model will be automatically discovered and available to use. -If you set `models.providers.ollama` explicitly, auto-discovery is skipped and you must define models manually. See the explicit config section below. +If you set `models.providers.ollama` explicitly, or configure a custom remote provider such as `models.providers.ollama-cloud` with `api: "ollama"`, auto-discovery is skipped and you must define models manually. Loopback custom providers such as `http://127.0.0.2:11434` are still treated as local. See the explicit config section below. ## Vision and image description diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index f6cb521699a..198965abb37 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -369,6 +369,64 @@ describe("ollama plugin", () => { }); }); + it("skips implicit localhost discovery when a custom remote Ollama provider is configured", async () => { + const provider = registerProvider(); + + const result = await provider.discovery.run({ + config: { + models: { + providers: { + "ollama-cloud": { + api: "ollama", + baseUrl: "https://ollama.com", + models: [{ id: "kimi-k2.5", name: "Kimi K2.5" }], + }, + }, + }, + }, + env: { NODE_ENV: "development", OLLAMA_API_KEY: "ollama-live" }, + resolveProviderApiKey: () => ({ apiKey: "ollama-live" }), + } as never); + + expect(result).toBeNull(); + expect(buildOllamaProviderMock).not.toHaveBeenCalled(); + }); + + it("treats custom 127/8 Ollama providers as loopback for implicit discovery", async () => { + const provider = registerProvider(); + buildOllamaProviderMock.mockResolvedValueOnce({ + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + models: [], + }); + + const result = await provider.discovery.run({ + config: { + models: { + providers: { + "ollama-alt-local": { + api: "ollama", + baseUrl: "http://127.0.0.2:11434", + models: [{ id: "llama3.2", name: "Llama 3.2" }], + }, + }, + }, + }, + env: { NODE_ENV: "development", OLLAMA_API_KEY: "ollama-live" }, + resolveProviderApiKey: () => ({ apiKey: "ollama-live" }), + } as never); + + expect(result).toMatchObject({ + provider: { + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + }, + }); + expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, { + quiet: false, + }); + }); + it("does not mint synthetic auth for empty default-ish provider stubs", () => { const provider = registerProvider(); diff --git a/extensions/ollama/src/discovery-shared.ts b/extensions/ollama/src/discovery-shared.ts index f12d736e6dd..fd001e2e479 100644 --- a/extensions/ollama/src/discovery-shared.ts +++ b/extensions/ollama/src/discovery-shared.ts @@ -15,9 +15,7 @@ export type OllamaPluginConfig = { type OllamaDiscoveryContext = { config: { models?: { - providers?: { - ollama?: ModelProviderConfig; - }; + providers?: Record; ollamaDiscovery?: { enabled?: boolean; }; @@ -73,6 +71,17 @@ function shouldSkipAmbientOllamaDiscovery(env: NodeJS.ProcessEnv): boolean { const LOCAL_OLLAMA_HOSTNAMES = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "::"]); +function isIpv4Loopback(host: string): boolean { + if (!/^\d+\.\d+\.\d+\.\d+$/.test(host)) { + return false; + } + const octets = host.split(".").map((part) => Number.parseInt(part, 10)); + if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + return false; + } + return octets[0] === 127; +} + function isIpv4PrivateRange(host: string): boolean { if (!/^\d+\.\d+\.\d+\.\d+$/.test(host)) { return false; @@ -113,6 +122,44 @@ export function isLocalOllamaBaseUrl(baseUrl: string | undefined | null): boolea ); } +function isLoopbackOllamaBaseUrl(baseUrl: string | undefined | null): boolean { + if (!baseUrl) { + return true; + } + let parsed: URL; + try { + parsed = new URL(baseUrl); + } catch { + return false; + } + let host = parsed.hostname.toLowerCase(); + if (host.startsWith("[") && host.endsWith("]")) { + host = host.slice(1, -1); + } + return LOCAL_OLLAMA_HOSTNAMES.has(host) || isIpv4Loopback(host); +} + +function hasExplicitRemoteOllamaApiProvider( + providers: Record | undefined, +): boolean { + if (!providers) { + return false; + } + for (const [providerId, provider] of Object.entries(providers)) { + if (providerId === OLLAMA_PROVIDER_ID || !provider) { + continue; + } + if (normalizeOptionalString(provider.api)?.toLowerCase() !== "ollama") { + continue; + } + const baseUrl = readProviderBaseUrl(provider); + if (baseUrl && !isLoopbackOllamaBaseUrl(baseUrl)) { + return true; + } + } + return false; +} + export function shouldUseSyntheticOllamaAuth( providerConfig: ModelProviderConfig | undefined, ): boolean { @@ -171,6 +218,9 @@ export async function resolveOllamaDiscoveryResult(params: { const explicit = params.ctx.config.models?.providers?.ollama; const hasExplicitModels = Array.isArray(explicit?.models) && explicit.models.length > 0; const hasMeaningfulExplicitConfig = hasMeaningfulExplicitOllamaConfig(explicit); + const hasRemoteOllamaApiProvider = hasExplicitRemoteOllamaApiProvider( + params.ctx.config.models?.providers, + ); const discoveryEnabled = params.pluginConfig.discovery?.enabled ?? params.ctx.config.models?.ollamaDiscovery?.enabled; if (!hasExplicitModels && discoveryEnabled === false) { @@ -202,6 +252,9 @@ export async function resolveOllamaDiscoveryResult(params: { }, }; } + if (!hasMeaningfulExplicitConfig && hasRemoteOllamaApiProvider) { + return null; + } if (!hasOllamaDiscoveryOptIn && !hasMeaningfulExplicitConfig) { return null; }