From dc78d584482c18e25ef29925d1d86f323df9b95e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 03:28:14 +0100 Subject: [PATCH] fix(ollama): honor baseURL provider aliases --- CHANGELOG.md | 1 + docs/providers/ollama.md | 2 + docs/tools/ollama-search.md | 2 + extensions/ollama/index.test.ts | 70 +++++++++++++++++++ extensions/ollama/index.ts | 6 +- extensions/ollama/src/discovery-shared.ts | 13 ++-- .../ollama/src/embedding-provider.test.ts | 27 +++++++ extensions/ollama/src/embedding-provider.ts | 3 +- .../ollama/src/provider-base-url.test.ts | 44 ++++++++++++ extensions/ollama/src/provider-base-url.ts | 23 ++++++ extensions/ollama/src/setup.test.ts | 32 +++++++++ extensions/ollama/src/setup.ts | 4 +- .../ollama/src/web-search-provider.test.ts | 12 ++++ extensions/ollama/src/web-search-provider.ts | 5 +- 14 files changed, 231 insertions(+), 13 deletions(-) create mode 100644 extensions/ollama/src/provider-base-url.test.ts create mode 100644 extensions/ollama/src/provider-base-url.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 123944b43a7..72ed22a1c91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - 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: 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. - Memory/doctor: treat Ollama memory embeddings as key-optional so `openclaw doctor` no longer warns about a missing API key when the gateway reports embeddings are ready. Fixes #46584. Thanks @fengly78. - Agents/Ollama: apply provider-owned replay turn normalization to native Ollama chat so Cloud models no longer reject non-alternating replay history in agent/Gateway runs. Fixes #71697. Thanks @ismael-81. - Control UI/Ollama: show the resolved configured thinking default in chat and session thinking dropdowns so inherited `adaptive`/per-model thinking config no longer appears as `Default (off)` or a generic inherit value. Fixes #72407. Thanks @NotecAG. diff --git a/docs/providers/ollama.md b/docs/providers/ollama.md index acd8c1a5e8e..d7e66573eb0 100644 --- a/docs/providers/ollama.md +++ b/docs/providers/ollama.md @@ -13,6 +13,8 @@ OpenClaw integrates with Ollama's native API (`/api/chat`) for hosted cloud mode **Remote Ollama users**: Do not use the `/v1` OpenAI-compatible URL (`http://host:11434/v1`) with OpenClaw. This breaks tool calling and models may output raw tool JSON as plain text. Use the native Ollama API URL instead: `baseUrl: "http://host:11434"` (no `/v1`). +Ollama provider config uses `baseUrl` as the canonical key. OpenClaw also accepts `baseURL` for compatibility with OpenAI SDK-style examples, but new config should prefer `baseUrl`. + ## Getting started Choose your preferred setup method and mode. diff --git a/docs/tools/ollama-search.md b/docs/tools/ollama-search.md index 2b159e148c6..3086863ce14 100644 --- a/docs/tools/ollama-search.md +++ b/docs/tools/ollama-search.md @@ -97,6 +97,8 @@ reuse that host instead: } ``` +The Ollama model provider uses `baseUrl` as the canonical key. The web-search provider also honors `baseURL` on `models.providers.ollama` for compatibility with OpenAI SDK-style config examples. + If no explicit Ollama base URL is set, OpenClaw uses `http://127.0.0.1:11434`. If your Ollama host expects bearer auth, OpenClaw reuses diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index 4a7f5931a9b..5463650e689 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -312,6 +312,36 @@ describe("ollama plugin", () => { }); }); + it("accepts baseURL alias as explicit discovery config", async () => { + const provider = registerProvider(); + buildOllamaProviderMock.mockResolvedValueOnce({ + baseUrl: "http://remote-ollama:11434", + api: "ollama", + models: [], + }); + + const result = await provider.discovery.run({ + config: { + models: { + providers: { + ollama: { + baseURL: "http://remote-ollama:11434", + api: "ollama", + models: [], + }, + }, + }, + }, + env: { NODE_ENV: "development" }, + resolveProviderApiKey: () => ({ apiKey: "" }), + } as never); + + expect(result).toBeNull(); + expect(buildOllamaProviderMock).toHaveBeenCalledWith("http://remote-ollama:11434", { + quiet: false, + }); + }); + it("keeps stored ollama-local marker auth on the quiet ambient path", async () => { const provider = registerProvider(); buildOllamaProviderMock.mockResolvedValueOnce({ @@ -371,6 +401,24 @@ describe("ollama plugin", () => { }); }); + it("mints synthetic auth for non-default baseURL alias config", () => { + const provider = registerProvider(); + + const auth = provider.resolveSyntheticAuth?.({ + providerConfig: { + baseURL: "http://remote-ollama:11434", + api: "ollama", + models: [], + } as never, + }); + + expect(auth).toEqual({ + apiKey: "ollama-local", + source: "models.providers.ollama (synthetic local key)", + mode: "api-key", + }); + }); + it("wraps OpenAI-compatible payloads with num_ctx for Ollama compat routes", () => { const provider = registerProvider(); let payloadSeen: Record | undefined; @@ -513,6 +561,28 @@ describe("ollama plugin", () => { ); }); + it("routes createStreamFn through baseURL alias for custom Ollama providers", () => { + const provider = registerProvider(); + const config = { + models: { + providers: { + ollama2: { + api: "ollama", + baseURL: "http://127.0.0.1:11435", + models: [], + }, + }, + }, + }; + const model = { id: "llama3.2", provider: "ollama2", baseUrl: undefined }; + + provider.createStreamFn?.({ config, model, provider: "ollama2" } as never); + + expect(createConfiguredOllamaStreamFnMock).toHaveBeenCalledWith( + expect.objectContaining({ providerBaseUrl: "http://127.0.0.1:11435" }), + ); + }); + it("uses ollama provider baseUrl when provider is ollama (backward compat)", () => { const provider = registerProvider(); const config = { diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 4ca916d7d64..24f186b9af5 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -31,6 +31,7 @@ import { } from "./src/embedding-provider.js"; import { ollamaMediaUnderstandingProvider } from "./src/media-understanding-provider.js"; import { ollamaMemoryEmbeddingProviderAdapter } from "./src/memory-embedding-adapter.js"; +import { readProviderBaseUrl } from "./src/provider-base-url.js"; import { createConfiguredOllamaCompatStreamWrapper, createConfiguredOllamaStreamFn, @@ -161,8 +162,9 @@ export default definePluginEntry({ createStreamFn: ({ config, model, provider }) => { return createConfiguredOllamaStreamFn({ model, - providerBaseUrl: resolveConfiguredOllamaProviderConfig({ config, providerId: provider }) - ?.baseUrl, + providerBaseUrl: readProviderBaseUrl( + resolveConfiguredOllamaProviderConfig({ config, providerId: provider }), + ), }); }, ...OPENAI_COMPATIBLE_REPLAY_HOOKS, diff --git a/extensions/ollama/src/discovery-shared.ts b/extensions/ollama/src/discovery-shared.ts index b45dee32ba9..23108ad6e08 100644 --- a/extensions/ollama/src/discovery-shared.ts +++ b/extensions/ollama/src/discovery-shared.ts @@ -1,5 +1,6 @@ import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js"; +import { readProviderBaseUrl } from "./provider-base-url.js"; import { resolveOllamaApiBase } from "./provider-models.js"; export const OLLAMA_PROVIDER_ID = "ollama"; @@ -63,8 +64,9 @@ export function hasMeaningfulExplicitOllamaConfig( if (Array.isArray(providerConfig.models) && providerConfig.models.length > 0) { return true; } - if (typeof providerConfig.baseUrl === "string" && providerConfig.baseUrl.trim()) { - return resolveOllamaApiBase(providerConfig.baseUrl) !== OLLAMA_DEFAULT_BASE_URL; + const baseUrl = readProviderBaseUrl(providerConfig); + if (baseUrl) { + return resolveOllamaApiBase(baseUrl) !== OLLAMA_DEFAULT_BASE_URL; } if (readStringValue(providerConfig.apiKey)) { return true; @@ -118,10 +120,7 @@ export async function resolveOllamaDiscoveryResult(params: { return { provider: { ...explicit, - baseUrl: - typeof explicit.baseUrl === "string" && explicit.baseUrl.trim() - ? resolveOllamaApiBase(explicit.baseUrl) - : OLLAMA_DEFAULT_BASE_URL, + baseUrl: resolveOllamaApiBase(readProviderBaseUrl(explicit) ?? OLLAMA_DEFAULT_BASE_URL), api: explicit.api ?? "ollama", apiKey: resolveOllamaDiscoveryApiKey({ env: params.ctx.env, @@ -142,7 +141,7 @@ export async function resolveOllamaDiscoveryResult(params: { return null; } - const provider = await params.buildProvider(explicit?.baseUrl, { + const provider = await params.buildProvider(readProviderBaseUrl(explicit), { quiet: !hasRealOllamaKey && !hasMeaningfulExplicitConfig, }); if (provider.models?.length === 0 && !ollamaKey && !explicit?.apiKey) { diff --git a/extensions/ollama/src/embedding-provider.test.ts b/extensions/ollama/src/embedding-provider.test.ts index 533ecd3e8e8..e0b9441661e 100644 --- a/extensions/ollama/src/embedding-provider.test.ts +++ b/extensions/ollama/src/embedding-provider.test.ts @@ -109,6 +109,33 @@ describe("ollama embedding provider", () => { ); }); + it("resolves configured baseURL alias", async () => { + const fetchMock = mockEmbeddingFetch([1, 0]); + + const { provider } = await createOllamaEmbeddingProvider({ + config: { + models: { + providers: { + ollama: { + baseURL: "http://remote-ollama:11434/v1", + models: [], + }, + }, + }, + } as unknown as OpenClawConfig, + provider: "ollama", + model: "nomic-embed-text", + fallback: "none", + }); + + await provider.embedQuery("hello"); + + expect(fetchMock).toHaveBeenCalledWith( + "http://remote-ollama:11434/api/embed", + expect.objectContaining({ method: "POST" }), + ); + }); + it("fails fast when memory-search remote apiKey is an unresolved SecretRef", async () => { await expect( createOllamaEmbeddingProvider({ diff --git a/extensions/ollama/src/embedding-provider.ts b/extensions/ollama/src/embedding-provider.ts index 68753fc1f07..b9351d3097d 100644 --- a/extensions/ollama/src/embedding-provider.ts +++ b/extensions/ollama/src/embedding-provider.ts @@ -13,6 +13,7 @@ import { type SsrFPolicy, } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeOllamaWireModelId } from "./model-id.js"; +import { readProviderBaseUrl } from "./provider-base-url.js"; import { resolveOllamaApiBase } from "./provider-models.js"; export type OllamaEmbeddingProvider = { @@ -138,7 +139,7 @@ function resolveOllamaEmbeddingClient( options: OllamaEmbeddingOptions, ): OllamaEmbeddingClientConfig { const providerConfig = resolveConfiguredProvider(options); - const rawBaseUrl = options.remote?.baseUrl?.trim() || providerConfig?.baseUrl?.trim(); + const rawBaseUrl = options.remote?.baseUrl?.trim() || readProviderBaseUrl(providerConfig); const baseUrl = resolveOllamaApiBase(rawBaseUrl); const model = normalizeEmbeddingModel(options.model, options.provider); const headerOverrides = Object.assign({}, providerConfig?.headers, options.remote?.headers); diff --git a/extensions/ollama/src/provider-base-url.test.ts b/extensions/ollama/src/provider-base-url.test.ts new file mode 100644 index 00000000000..51c812bfa8c --- /dev/null +++ b/extensions/ollama/src/provider-base-url.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { readProviderBaseUrl } from "./provider-base-url.js"; + +describe("readProviderBaseUrl", () => { + it("reads canonical baseUrl and trims whitespace", () => { + expect(readProviderBaseUrl({ baseUrl: " http://host:11434/v1 ", models: [] })).toBe( + "http://host:11434/v1", + ); + }); + + it("falls back to OpenAI SDK-style baseURL", () => { + const provider = { + baseURL: " http://remote-ollama:11434 ", + models: [], + } as unknown as Parameters[0]; + + expect(readProviderBaseUrl(provider)).toBe("http://remote-ollama:11434"); + }); + + it("prefers canonical baseUrl over baseURL", () => { + const provider = { + baseUrl: "http://canonical:11434", + baseURL: "http://alternate:11434", + models: [], + } as unknown as Parameters[0]; + + expect(readProviderBaseUrl(provider)).toBe("http://canonical:11434"); + }); + + it("ignores inherited baseUrl aliases", () => { + const provider = { models: [] } as unknown as Parameters[0]; + Object.setPrototypeOf(provider, { baseUrl: "http://inherited:11434" }); + + expect(readProviderBaseUrl(provider)).toBeUndefined(); + }); + + it("returns undefined for empty or missing values", () => { + expect(readProviderBaseUrl(undefined)).toBeUndefined(); + expect( + readProviderBaseUrl({ models: [] } as unknown as Parameters[0]), + ).toBeUndefined(); + expect(readProviderBaseUrl({ baseUrl: " ", models: [] })).toBeUndefined(); + }); +}); diff --git a/extensions/ollama/src/provider-base-url.ts b/extensions/ollama/src/provider-base-url.ts new file mode 100644 index 00000000000..0d250cf05af --- /dev/null +++ b/extensions/ollama/src/provider-base-url.ts @@ -0,0 +1,23 @@ +import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; + +export function readProviderBaseUrl(provider: ModelProviderConfig | undefined): string | undefined { + if (!provider) { + return undefined; + } + if ( + Object.hasOwn(provider, "baseUrl") && + typeof provider.baseUrl === "string" && + provider.baseUrl.trim() + ) { + return provider.baseUrl.trim(); + } + const alternate = provider as ModelProviderConfig & { baseURL?: unknown }; + if ( + Object.hasOwn(alternate, "baseURL") && + typeof alternate.baseURL === "string" && + alternate.baseURL.trim() + ) { + return alternate.baseURL.trim(); + } + return undefined; +} diff --git a/extensions/ollama/src/setup.test.ts b/extensions/ollama/src/setup.test.ts index 46c5d95679a..926f2b690b0 100644 --- a/extensions/ollama/src/setup.test.ts +++ b/extensions/ollama/src/setup.test.ts @@ -434,6 +434,38 @@ describe("ollama setup", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("uses baseURL alias when checking and pulling models", async () => { + const progress = { update: vi.fn(), stop: vi.fn() }; + const prompter = { + progress: vi.fn(() => progress), + } as unknown as WizardPrompter; + + const fetchMock = createOllamaFetchMock({ + tags: [], + pullResponse: new Response('{"status":"success"}\n', { status: 200 }), + }); + vi.stubGlobal("fetch", fetchMock); + + await ensureOllamaModelPulled({ + config: { + agents: { defaults: { model: { primary: "ollama/gemma4" } } }, + models: { + providers: { + ollama: { + baseURL: "http://127.0.0.1:11435", + models: [], + } as never, + }, + }, + }, + model: "ollama/gemma4", + prompter, + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe("http://127.0.0.1:11435/api/tags"); + expect(fetchMock.mock.calls[1]?.[0]).toBe("http://127.0.0.1:11435/api/pull"); + }); + it("skips pull for cloud models", async () => { const prompter = {} as unknown as WizardPrompter; const fetchMock = vi.fn(); diff --git a/extensions/ollama/src/setup.ts b/extensions/ollama/src/setup.ts index e1a866f8459..4a36e327ecf 100644 --- a/extensions/ollama/src/setup.ts +++ b/extensions/ollama/src/setup.ts @@ -25,6 +25,7 @@ import { OLLAMA_DEFAULT_BASE_URL, OLLAMA_DEFAULT_MODEL, } from "./defaults.js"; +import { readProviderBaseUrl } from "./provider-base-url.js"; import { buildOllamaBaseUrlSsrFPolicy, buildOllamaProvider, @@ -631,7 +632,8 @@ export async function ensureOllamaModelPulled(params: { if (!params.model.startsWith("ollama/")) { return; } - const baseUrl = params.config.models?.providers?.ollama?.baseUrl ?? OLLAMA_DEFAULT_BASE_URL; + const baseUrl = + readProviderBaseUrl(params.config.models?.providers?.ollama) ?? OLLAMA_DEFAULT_BASE_URL; const modelName = params.model.slice("ollama/".length); if (isOllamaCloudModel(modelName)) { return; diff --git a/extensions/ollama/src/web-search-provider.test.ts b/extensions/ollama/src/web-search-provider.test.ts index 2b82bc49752..350bdef27fa 100644 --- a/extensions/ollama/src/web-search-provider.test.ts +++ b/extensions/ollama/src/web-search-provider.test.ts @@ -19,6 +19,7 @@ type OllamaProviderConfigOverride = Partial<{ api: "ollama"; apiKey: string; baseUrl: string; + baseURL: string; models: NonNullable< NonNullable["providers"]>[string] >["models"]; @@ -125,6 +126,17 @@ describe("ollama web search provider", () => { ).toBe("https://ollama.com"); }); + it("uses the model provider baseURL alias for web search", () => { + expect( + testing.resolveOllamaWebSearchBaseUrl( + createOllamaConfig({ + baseUrl: undefined, + baseURL: "http://remote-ollama:11434/v1", + } as OllamaProviderConfigOverride), + ), + ).toBe("http://remote-ollama:11434"); + }); + it("maps generic search args into the local Ollama proxy endpoint", async () => { const release = vi.fn(async () => {}); fetchWithSsrFGuardMock.mockResolvedValue({ diff --git a/extensions/ollama/src/web-search-provider.ts b/extensions/ollama/src/web-search-provider.ts index 79399ca8b21..712c0b42a46 100644 --- a/extensions/ollama/src/web-search-provider.ts +++ b/extensions/ollama/src/web-search-provider.ts @@ -20,6 +20,7 @@ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { Type } from "typebox"; import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js"; +import { readProviderBaseUrl } from "./provider-base-url.js"; import { buildOllamaBaseUrlSsrFPolicy, fetchOllamaModels, @@ -96,8 +97,8 @@ function resolveOllamaWebSearchBaseUrl(config?: OpenClawConfig): string { if (pluginBaseUrl) { return resolveOllamaApiBase(pluginBaseUrl); } - const configuredBaseUrl = config?.models?.providers?.ollama?.baseUrl; - if (normalizeOptionalString(configuredBaseUrl)) { + const configuredBaseUrl = readProviderBaseUrl(config?.models?.providers?.ollama); + if (configuredBaseUrl) { return resolveOllamaApiBase(configuredBaseUrl); } return OLLAMA_DEFAULT_BASE_URL;