diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f53282ec21..82b86c400cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - 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. - Providers/Ollama: scope synthetic local auth and embedding bearer headers to declared Ollama host boundaries so cloud keys are not sent to local/self-hosted embedding endpoints and remote/cloud Ollama endpoints no longer receive the `ollama-local` marker as if it were a real token. Supersedes #69261 and #69857; refs #43945. Thanks @hyspacex, @maxramsay, and @Meli73. +- Providers/Ollama: resolve custom-named local Ollama providers such as `ollama-remote` through the Ollama synthetic-auth hook so subagents no longer miss `ollama-local` auth and silently fall back to cloud models. Fixes #43945. Thanks @Meli73 and @maxramsay. - Providers/PDF/Ollama: add bounded network timeouts for Ollama model pulls and native Anthropic/Gemini PDF analysis requests so unresponsive provider endpoints no longer hang sessions indefinitely. Fixes #54142; supersedes #54144 and #54145. Thanks @jinduwang1001-max and @arkyu2077. - 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. diff --git a/docs/providers/ollama.md b/docs/providers/ollama.md index 49afb9affdc..cb88cfc2ffd 100644 --- a/docs/providers/ollama.md +++ b/docs/providers/ollama.md @@ -17,6 +17,8 @@ Ollama provider config uses `baseUrl` as the canonical key. OpenClaw also accept Local and LAN Ollama hosts do not need a real bearer token; OpenClaw uses the local `ollama-local` marker only for loopback, private-network, `.local`, and bare-hostname Ollama base URLs. Remote public hosts and Ollama Cloud (`https://ollama.com`) require a real credential through `OLLAMA_API_KEY`, an auth profile, or the provider's `apiKey`. +Custom provider ids that set `api: "ollama"` use the same auth rules. For example, an `ollama-remote` provider that points at a private LAN Ollama host can use `apiKey: "ollama-local"` and sub-agents will resolve that marker through the Ollama provider hook instead of treating it as a missing credential. + When Ollama is used for memory embeddings, bearer auth is scoped to the host where it was declared. A provider-level key is sent only to that provider's Ollama host; `agents.*.memorySearch.remote.apiKey` is sent only to its remote embedding host; and a pure `OLLAMA_API_KEY` env value is treated as the Ollama Cloud convention rather than being sent to local/self-hosted hosts by default. ## Getting started diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 4bc708d6269..c997f989196 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -198,13 +198,13 @@ export default definePluginEntry({ matchesContextOverflowError: ({ errorMessage }) => /\bollama\b.*(?:context length|too many tokens|context window)/i.test(errorMessage) || /\btruncating input\b.*\btoo long\b/i.test(errorMessage), - resolveSyntheticAuth: ({ providerConfig }) => { + resolveSyntheticAuth: ({ provider, providerConfig }) => { if (!shouldUseSyntheticOllamaAuth(providerConfig)) { return undefined; } return { apiKey: OLLAMA_DEFAULT_API_KEY, - source: "models.providers.ollama (synthetic local key)", + source: `models.providers.${provider ?? OLLAMA_PROVIDER_ID} (synthetic local key)`, mode: "api-key", }; }, diff --git a/extensions/ollama/provider-discovery.ts b/extensions/ollama/provider-discovery.ts index 6cb40cee779..485d7b813ba 100644 --- a/extensions/ollama/provider-discovery.ts +++ b/extensions/ollama/provider-discovery.ts @@ -15,7 +15,7 @@ type OllamaProviderPlugin = { docsPath: string; envVars: string[]; auth: []; - resolveSyntheticAuth: (ctx: { providerConfig?: ModelProviderConfig }) => + resolveSyntheticAuth: (ctx: { provider?: string; providerConfig?: ModelProviderConfig }) => | { apiKey: string; source: string; @@ -50,13 +50,13 @@ export const ollamaProviderDiscovery: OllamaProviderPlugin = { docsPath: "/providers/ollama", envVars: ["OLLAMA_API_KEY"], auth: [], - resolveSyntheticAuth: ({ providerConfig }) => { + resolveSyntheticAuth: ({ provider, providerConfig }) => { if (!shouldUseSyntheticOllamaAuth(providerConfig)) { return undefined; } return { apiKey: OLLAMA_DEFAULT_API_KEY, - source: "models.providers.ollama (synthetic local key)", + source: `models.providers.${provider ?? OLLAMA_PROVIDER_ID} (synthetic local key)`, mode: "api-key", }; }, diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index 5904c165ad4..2f9506f1e51 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -14,7 +14,7 @@ vi.mock("../plugins/plugin-registry.js", () => ({ plugins: [ { origin: "bundled", - nonSecretAuthMarkers: ["gcp-vertex-credentials"], + nonSecretAuthMarkers: ["gcp-vertex-credentials", "ollama-local"], }, ], }), @@ -98,6 +98,16 @@ vi.mock("../plugins/provider-runtime.js", async () => { mode: "oauth" as const, }; } + if ( + params.context.providerConfig?.api === "ollama" && + params.context.providerConfig.baseUrl?.startsWith("http://192.168.") + ) { + return { + apiKey: "ollama-local", + source: `models.providers.${params.provider} (synthetic local key)`, + mode: "api-key" as const, + }; + } return undefined; }, }; @@ -867,6 +877,41 @@ describe("resolveApiKeyForProvider – synthetic local auth for custom providers ).rejects.toThrow("No API key found"); }); + it("resolves custom named Ollama providers with explicit local marker auth", async () => { + const auth = await resolveApiKeyForProvider({ + provider: "ollama-remote", + cfg: { + models: { + providers: { + "ollama-remote": { + baseUrl: "http://192.168.178.122:11434", + api: "ollama", + apiKey: "ollama-local", + models: [ + { + id: "qwen3.5:27b", + name: "Qwen 3.5 27B", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 4096, + }, + ], + }, + }, + }, + }, + store: { version: 1, profiles: {} }, + }); + + expect(auth).toMatchObject({ + apiKey: "ollama-local", + source: "models.providers.ollama-remote (synthetic local key)", + mode: "api-key", + }); + }); + it("does not synthesize local auth when apiKey is explicitly configured but unresolved", async () => { const previous = process.env.OPENAI_API_KEY; delete process.env.OPENAI_API_KEY; diff --git a/src/plugins/provider-runtime.synthetic-auth-discovery.test.ts b/src/plugins/provider-runtime.synthetic-auth-discovery.test.ts index 7a0a0529e7b..c2cfe091c16 100644 --- a/src/plugins/provider-runtime.synthetic-auth-discovery.test.ts +++ b/src/plugins/provider-runtime.synthetic-auth-discovery.test.ts @@ -13,6 +13,25 @@ const resolvePluginDiscoveryProvidersRuntime = vi.hoisted(() => mode: "api-key" as const, }), }, + { + id: "ollama", + label: "Ollama", + auth: [], + resolveSyntheticAuth: ({ + provider, + providerConfig, + }: { + provider: string; + providerConfig?: { api?: string; baseUrl?: string }; + }) => + providerConfig?.api === "ollama" && providerConfig.baseUrl?.startsWith("http://10.") + ? { + apiKey: "ollama-local", + source: `models.providers.${provider} (synthetic local key)`, + mode: "api-key" as const, + } + : undefined, + }, ]), ); @@ -39,7 +58,13 @@ vi.mock("./providers.js", () => ({ resolveCatalogHookProviderPluginIds: vi.fn(() => []), resolveExternalAuthProfileCompatFallbackPluginIds: vi.fn(() => []), resolveExternalAuthProfileProviderPluginIds: vi.fn(() => []), - resolveOwningPluginIdsForProvider: vi.fn(() => ["anthropic-vertex"]), + resolveOwningPluginIdsForProvider: vi.fn(({ provider }: { provider: string }) => + provider === "ollama" + ? ["ollama"] + : provider === "anthropic-vertex" + ? ["anthropic-vertex"] + : [], + ), })); import { resolveProviderSyntheticAuthWithPlugin } from "./provider-runtime.js"; @@ -63,4 +88,26 @@ describe("resolveProviderSyntheticAuthWithPlugin", () => { expect(resolveProviderRuntimePlugin).not.toHaveBeenCalled(); expect(resolvePluginDiscoveryProvidersRuntime).toHaveBeenCalled(); }); + + it("uses the configured provider api as the synthetic-auth hook owner", () => { + expect( + resolveProviderSyntheticAuthWithPlugin({ + provider: "ollama-remote", + context: { + config: undefined, + provider: "ollama-remote", + providerConfig: { + api: "ollama", + baseUrl: "http://10.0.0.8:11434", + apiKey: "ollama-local", + models: [], + }, + }, + }), + ).toEqual({ + apiKey: "ollama-local", + source: "models.providers.ollama-remote (synthetic local key)", + mode: "api-key", + }); + }); }); diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 4c1ed4b5d4e..d7db821653c 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -108,6 +108,19 @@ function matchesProviderPluginRef(provider: ProviderPlugin, providerId: string): ); } +function resolveProviderHookRefs(provider: string, providerConfig?: ModelProviderConfig): string[] { + const refs = [provider]; + const apiRef = normalizeOptionalString(providerConfig?.api); + if (apiRef && normalizeProviderId(apiRef) !== normalizeProviderId(provider)) { + refs.push(apiRef); + } + return [...new Set(refs)]; +} + +function matchesAnyProviderPluginRef(provider: ProviderPlugin, providerRefs: readonly string[]) { + return providerRefs.some((providerRef) => matchesProviderPluginRef(provider, providerRef)); +} + function hasExplicitProviderRuntimePluginActivation(params: { provider: string; config?: OpenClawConfig; @@ -930,13 +943,20 @@ export function resolveProviderSyntheticAuthWithPlugin(params: { env?: NodeJS.ProcessEnv; context: ProviderResolveSyntheticAuthContext; }) { - const discoveryPluginIds = - resolveOwningPluginIdsForProvider({ - provider: params.provider, - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }) ?? []; + const providerRefs = resolveProviderHookRefs(params.provider, params.context.providerConfig); + const discoveryPluginIds = [ + ...new Set( + providerRefs.flatMap( + (provider) => + resolveOwningPluginIdsForProvider({ + provider, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }) ?? [], + ), + ), + ]; const discoveryProvider = ( discoveryPluginIds.length > 0 ? resolvePluginDiscoveryProvidersRuntime({ @@ -947,7 +967,7 @@ export function resolveProviderSyntheticAuthWithPlugin(params: { discoveryEntriesOnly: true, }) : [] - ).find((provider) => matchesProviderPluginRef(provider, params.provider)); + ).find((provider) => matchesAnyProviderPluginRef(provider, providerRefs)); if (typeof discoveryProvider?.resolveSyntheticAuth === "function") { return discoveryProvider.resolveSyntheticAuth(params.context) ?? undefined; } @@ -961,13 +981,32 @@ export function resolveProviderSyntheticAuthWithPlugin(params: { if (runtimeResolved) { return runtimeResolved; } - return resolvePluginDiscoveryProvidersRuntime({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }) - .find((provider) => provider.id === params.provider) - ?.resolveSyntheticAuth?.(params.context); + for (const providerRef of providerRefs) { + if (normalizeProviderId(providerRef) === normalizeProviderId(params.provider)) { + continue; + } + const runtimeProviderResolved = resolveProviderRuntimePlugin({ + ...params, + provider: providerRef, + applyAutoEnable: false, + bundledProviderAllowlistCompat: false, + bundledProviderVitestCompat: false, + installBundledRuntimeDeps: false, + })?.resolveSyntheticAuth?.(params.context); + if (runtimeProviderResolved) { + return runtimeProviderResolved; + } + } + if (providerRefs.length === 1) { + return resolvePluginDiscoveryProvidersRuntime({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }) + .find((provider) => matchesAnyProviderPluginRef(provider, providerRefs)) + ?.resolveSyntheticAuth?.(params.context); + } + return undefined; } export function resolveExternalAuthProfilesWithPlugins(params: { @@ -1040,10 +1079,17 @@ export function shouldDeferProviderSyntheticProfileAuthWithPlugin(params: { env?: NodeJS.ProcessEnv; context: ProviderDeferSyntheticProfileAuthContext; }) { - return ( - resolveProviderRuntimePlugin(params)?.shouldDeferSyntheticProfileAuth?.(params.context) ?? - undefined - ); + const providerRefs = resolveProviderHookRefs(params.provider, params.context.providerConfig); + for (const providerRef of providerRefs) { + const resolved = resolveProviderRuntimePlugin({ + ...params, + provider: providerRef, + })?.shouldDeferSyntheticProfileAuth?.(params.context); + if (resolved !== undefined) { + return resolved; + } + } + return undefined; } export function resolveProviderBuiltInModelSuppression(params: {