From a0a0ab4d9e2ad2289062b3ed4c24e29b10470c54 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 03:10:57 +0100 Subject: [PATCH] fix(memory): resolve custom embedding provider ids --- CHANGELOG.md | 1 + docs/concepts/memory-search.md | 4 ++ docs/providers/ollama.md | 2 +- docs/reference/memory-config.md | 39 +++++++++++--- src/agents/memory-search.test.ts | 27 ++++++++++ src/agents/memory-search.ts | 40 +++++++++++--- src/agents/model-auth.test.ts | 34 ++++++++++++ .../memory-embedding-provider-runtime.test.ts | 51 ++++++++++++++++++ .../memory-embedding-provider-runtime.ts | 54 ++++++++++++++++--- 9 files changed, 231 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0a97521697..574322d7c3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Memory/Ollama: resolve `memorySearch.provider` custom provider ids through their configured `models.providers..api` owner, so multi-GPU Ollama setups can dedicate embeddings to providers such as `ollama-5080` without losing the Ollama adapter or local auth semantics. Fixes #73150. Thanks @oneandrewwang. - CLI/memory: skip eager context-window warmup for `openclaw memory` commands so memory search does not race unrelated model metadata discovery. Fixes #73123. Thanks @oalansilva and @neeravmakwana. - CLI/Telegram: route Telegram `message send` and poll actions through the running Gateway when available, so packaged installs use the staged `grammy` runtime deps and CLI sends return instead of hanging after the Telegram channel is active. Fixes #73140. Thanks @oalansilva. - Cron/providers: preflight local Ollama and OpenAI-compatible provider endpoints before isolated cron agent turns, record unreachable local providers as skipped runs, and cache dead-endpoint probes so many jobs do not hammer the same stopped local server. Fixes #58584. Thanks @jpeghead. diff --git a/docs/concepts/memory-search.md b/docs/concepts/memory-search.md index 30a403802f7..c3733b6a060 100644 --- a/docs/concepts/memory-search.md +++ b/docs/concepts/memory-search.md @@ -29,6 +29,10 @@ explicitly: } ``` +For multi-endpoint setups, `provider` can also be a custom +`models.providers.` entry, such as `ollama-5080`, when that provider sets +`api: "ollama"` or another embedding adapter owner. + For local embeddings with no API key, install the optional `node-llama-cpp` runtime package next to OpenClaw and use `provider: "local"`. diff --git a/docs/providers/ollama.md b/docs/providers/ollama.md index a152d68c46a..3ee76cadd23 100644 --- a/docs/providers/ollama.md +++ b/docs/providers/ollama.md @@ -25,7 +25,7 @@ Ollama provider config uses `baseUrl` as the canonical key. OpenClaw also accept 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"` follow the same 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. + Custom provider ids that set `api: "ollama"` follow the same 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. Memory search can also set `agents.defaults.memorySearch.provider` to that custom provider id so embeddings use the matching Ollama endpoint. When Ollama is used for memory embeddings, bearer auth is scoped to the host where it was declared: diff --git a/docs/reference/memory-config.md b/docs/reference/memory-config.md index 017dab64875..c213dbbc089 100644 --- a/docs/reference/memory-config.md +++ b/docs/reference/memory-config.md @@ -46,12 +46,12 @@ See [Active Memory](/concepts/active-memory) for the activation model, plugin-ow ## Provider selection -| Key | Type | Default | Description | -| ---------- | --------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `provider` | `string` | auto-detected | Embedding adapter ID: `bedrock`, `deepinfra`, `gemini`, `github-copilot`, `local`, `mistral`, `ollama`, `openai`, `voyage` | -| `model` | `string` | provider default | Embedding model name | -| `fallback` | `string` | `"none"` | Fallback adapter ID when the primary fails | -| `enabled` | `boolean` | `true` | Enable or disable memory search | +| Key | Type | Default | Description | +| ---------- | --------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `provider` | `string` | auto-detected | Embedding adapter ID such as `bedrock`, `deepinfra`, `gemini`, `github-copilot`, `local`, `mistral`, `ollama`, `openai`, or `voyage`; may also be a configured `models.providers.` whose `api` points at one of those adapters | +| `model` | `string` | provider default | Embedding model name | +| `fallback` | `string` | `"none"` | Fallback adapter ID when the primary fails | +| `enabled` | `boolean` | `true` | Enable or disable memory search | ### Auto-detection order @@ -86,6 +86,33 @@ When `provider` is not set, OpenClaw selects the first available: `ollama` is supported but not auto-detected (set it explicitly). +### Custom provider ids + +`memorySearch.provider` can point at a custom `models.providers.` entry. OpenClaw resolves that provider's `api` owner for the embedding adapter while preserving the custom provider id for endpoint, auth, and model-prefix handling. This lets multi-GPU or multi-host setups dedicate memory embeddings to a specific local endpoint: + +```json5 +{ + models: { + providers: { + "ollama-5080": { + api: "ollama", + baseUrl: "http://gpu-box.local:11435", + apiKey: "ollama-local", + models: [{ id: "qwen3-embedding:0.6b" }], + }, + }, + }, + agents: { + defaults: { + memorySearch: { + provider: "ollama-5080", + model: "qwen3-embedding:0.6b", + }, + }, + }, +} +``` + ### API key resolution Remote embeddings require an API key. Bedrock uses the AWS SDK default credential chain instead (instance roles, SSO, access keys). diff --git a/src/agents/memory-search.test.ts b/src/agents/memory-search.test.ts index ede692d8fc9..6e7cf635152 100644 --- a/src/agents/memory-search.test.ts +++ b/src/agents/memory-search.test.ts @@ -199,6 +199,33 @@ describe("memory search config", () => { expect(resolved?.fallback).toBe("none"); }); + it("resolves custom provider ids through their configured api owner", () => { + const cfg = asConfig({ + models: { + providers: { + "ollama-5080": { + api: "ollama", + baseUrl: "http://10.0.0.8:11435", + models: [], + }, + }, + }, + agents: { + defaults: { + memorySearch: { + provider: "ollama-5080", + }, + }, + }, + }); + + const resolved = resolveMemorySearchConfig(cfg, "main"); + + expect(resolved?.provider).toBe("ollama-5080"); + expect(resolved?.model).toBe("nomic-embed-text"); + expectDefaultRemoteBatch(resolved); + }); + it("resolves sync config without consulting embedding providers", () => { clearMemoryEmbeddingProviders(); const cfg = asConfig({ diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts index d3faed881ee..42adc2ace68 100644 --- a/src/agents/memory-search.ts +++ b/src/agents/memory-search.ts @@ -11,6 +11,7 @@ import { import { getMemoryEmbeddingProvider } from "../plugins/memory-embedding-providers.js"; import { clampInt, clampNumber, resolveUserPath } from "../utils.js"; import { resolveAgentConfig } from "./agent-scope.js"; +import { findNormalizedProviderValue, normalizeProviderId } from "./provider-id.js"; export type ResolvedMemorySearchConfig = { enabled: boolean; @@ -147,7 +148,29 @@ function resolveStorePath(agentId: string, raw?: string): string { return resolveUserPath(withToken); } +function getConfiguredMemoryEmbeddingProvider( + providerId: string, + cfg: OpenClawConfig, +): ReturnType { + const directAdapter = getMemoryEmbeddingProvider(providerId); + if (directAdapter) { + return directAdapter; + } + const providerConfig = findNormalizedProviderValue(cfg.models?.providers, providerId); + const ownerApi = providerConfig?.api?.trim(); + if (!ownerApi) { + return undefined; + } + const normalizedProvider = normalizeProviderId(providerId); + const normalizedOwner = normalizeProviderId(ownerApi); + if (!normalizedOwner || normalizedOwner === normalizedProvider) { + return undefined; + } + return getMemoryEmbeddingProvider(normalizedOwner); +} + function mergeConfig( + cfg: OpenClawConfig, defaults: MemorySearchConfig | undefined, overrides: MemorySearchConfig | undefined, agentId: string, @@ -156,12 +179,15 @@ function mergeConfig( const sessionMemory = overrides?.experimental?.sessionMemory ?? defaults?.experimental?.sessionMemory ?? false; const provider = overrides?.provider ?? defaults?.provider ?? "auto"; - const primaryAdapter = provider === "auto" ? undefined : getMemoryEmbeddingProvider(provider); + const primaryAdapter = + provider === "auto" ? undefined : getConfiguredMemoryEmbeddingProvider(provider, cfg); const defaultRemote = defaults?.remote; const overrideRemote = overrides?.remote; const fallback = overrides?.fallback ?? defaults?.fallback ?? "none"; const fallbackAdapter = - fallback && fallback !== "none" ? getMemoryEmbeddingProvider(fallback) : undefined; + fallback && fallback !== "none" + ? getConfiguredMemoryEmbeddingProvider(fallback, cfg) + : undefined; const hasRemoteConfig = Boolean( overrideRemote?.baseUrl || overrideRemote?.apiKey || @@ -402,15 +428,17 @@ export function resolveMemorySearchConfig( ): ResolvedMemorySearchConfig | null { const defaults = cfg.agents?.defaults?.memorySearch; const overrides = resolveAgentConfig(cfg, agentId)?.memorySearch; - const resolved = mergeConfig(defaults, overrides, agentId); + const resolved = mergeConfig(cfg, defaults, overrides, agentId); if (!resolved.enabled) { return null; } const multimodalActive = isMemoryMultimodalEnabled(resolved.multimodal); const multimodalProvider = - resolved.provider === "auto" ? undefined : getMemoryEmbeddingProvider(resolved.provider); - // Config resolution is a startup/doctor hot path; only validate adapters - // already registered by the active runtime instead of cold-loading plugins. + resolved.provider === "auto" + ? undefined + : getConfiguredMemoryEmbeddingProvider(resolved.provider, cfg); + // Custom provider ids can map to a memory adapter through models.providers..api. + // Keep multimodal validation on that config-aware adapter, not the raw id. if ( multimodalActive && multimodalProvider && diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index a1812cacf40..df4dfeb6f82 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -964,6 +964,40 @@ describe("resolveApiKeyForProvider – synthetic local auth for custom providers }); }); + it("uses Ollama plugin synthetic auth for custom private provider ids without apiKey", async () => { + const auth = await resolveApiKeyForProvider({ + provider: "ollama-gpu1", + cfg: { + models: { + providers: { + "ollama-gpu1": { + baseUrl: "http://192.168.178.122:11435", + api: "ollama", + models: [ + { + id: "qwen3:14b", + name: "Qwen 3 14B", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 16384, + maxTokens: 4096, + }, + ], + }, + }, + }, + }, + store: { version: 1, profiles: {} }, + }); + + expect(auth).toMatchObject({ + apiKey: "ollama-local", + source: "models.providers.ollama-gpu1 (synthetic local key)", + mode: "api-key", + }); + }); + it("accepts non-secret local markers for private LAN custom OpenAI-compatible providers", async () => { const auth = await resolveApiKeyForProvider({ provider: "custom-192-168-0-222-11434", diff --git a/src/plugins/memory-embedding-provider-runtime.test.ts b/src/plugins/memory-embedding-provider-runtime.test.ts index 3a337735716..96ca02cbecb 100644 --- a/src/plugins/memory-embedding-provider-runtime.test.ts +++ b/src/plugins/memory-embedding-provider-runtime.test.ts @@ -73,6 +73,57 @@ describe("memory embedding provider runtime resolution", () => { }); }); + it("uses a configured provider api as the memory adapter owner", () => { + const ollamaAdapter = createCapabilityAdapter("ollama"); + mocks.resolvePluginCapabilityProvider.mockImplementation(({ providerId }) => + providerId === "ollama" ? ollamaAdapter : undefined, + ); + + expect( + runtimeModule.getMemoryEmbeddingProvider("ollama-5080", { + models: { + providers: { + "ollama-5080": { + api: "ollama", + baseUrl: "http://10.0.0.8:11435", + models: [], + }, + }, + }, + } as never), + ).toBe(ollamaAdapter); + expect(mocks.resolvePluginCapabilityProvider).toHaveBeenCalledWith({ + key: "memoryEmbeddingProviders", + providerId: "ollama-5080", + cfg: expect.any(Object), + }); + expect(mocks.resolvePluginCapabilityProvider).toHaveBeenCalledWith({ + key: "memoryEmbeddingProviders", + providerId: "ollama", + cfg: expect.any(Object), + }); + }); + + it("uses registered adapters through a configured provider api", () => { + const ollamaAdapter = createCapabilityAdapter("ollama"); + registerMemoryEmbeddingProvider(ollamaAdapter); + + expect( + runtimeModule.getMemoryEmbeddingProvider("ollama-gpu1", { + models: { + providers: { + "ollama-gpu1": { + api: "ollama", + baseUrl: "http://ollama-host:11435", + models: [], + }, + }, + }, + } as never), + ).toBe(ollamaAdapter); + expect(mocks.resolvePluginCapabilityProvider).not.toHaveBeenCalled(); + }); + it("prefers registered adapters over declared capability fallback adapters with the same id", () => { const registered = { id: "openai", diff --git a/src/plugins/memory-embedding-provider-runtime.ts b/src/plugins/memory-embedding-provider-runtime.ts index 5a5c34751e4..dc1d4292608 100644 --- a/src/plugins/memory-embedding-provider-runtime.ts +++ b/src/plugins/memory-embedding-provider-runtime.ts @@ -1,3 +1,4 @@ +import { normalizeProviderId } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolvePluginCapabilityProvider, @@ -30,17 +31,54 @@ export function listMemoryEmbeddingProviders( return [...merged.values()]; } +function readConfiguredProviderApiId(providerId: string, cfg?: OpenClawConfig): string | undefined { + const providers = cfg?.models?.providers; + if (!providers) { + return undefined; + } + const normalized = normalizeProviderId(providerId); + const providerConfig = + providers[providerId] ?? + Object.entries(providers).find( + ([candidateId]) => normalizeProviderId(candidateId) === normalized, + )?.[1]; + const api = providerConfig?.api?.trim(); + if (!api) { + return undefined; + } + const normalizedApi = normalizeProviderId(api); + return normalizedApi && normalizedApi !== normalized ? normalizedApi : undefined; +} + +function resolveMemoryEmbeddingProviderLookupIds(id: string, cfg?: OpenClawConfig): string[] { + const ids = [id]; + const apiId = readConfiguredProviderApiId(id, cfg); + if (apiId && !ids.some((candidate) => normalizeProviderId(candidate) === apiId)) { + ids.push(apiId); + } + return ids; +} + export function getMemoryEmbeddingProvider( id: string, cfg?: OpenClawConfig, ): MemoryEmbeddingProviderAdapter | undefined { - const registered = getRegisteredMemoryEmbeddingProvider(id); - if (registered) { - return registered.adapter; + const ids = resolveMemoryEmbeddingProviderLookupIds(id, cfg); + for (const candidateId of ids) { + const registered = getRegisteredMemoryEmbeddingProvider(candidateId); + if (registered) { + return registered.adapter; + } } - return resolvePluginCapabilityProvider({ - key: "memoryEmbeddingProviders", - providerId: id, - cfg, - }); + for (const candidateId of ids) { + const provider = resolvePluginCapabilityProvider({ + key: "memoryEmbeddingProviders", + providerId: candidateId, + cfg, + }); + if (provider) { + return provider; + } + } + return undefined; }