fix(memory): resolve custom embedding provider ids

This commit is contained in:
Peter Steinberger
2026-04-28 03:10:57 +01:00
parent 632b0fd580
commit a0a0ab4d9e
9 changed files with 231 additions and 21 deletions

View File

@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Memory/Ollama: resolve `memorySearch.provider` custom provider ids through their configured `models.providers.<id>.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.

View File

@@ -29,6 +29,10 @@ explicitly:
}
```
For multi-endpoint setups, `provider` can also be a custom
`models.providers.<id>` 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"`.

View File

@@ -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`.
</Accordion>
<Accordion title="Custom provider ids">
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.
</Accordion>
<Accordion title="Memory embedding scope">
When Ollama is used for memory embeddings, bearer auth is scoped to the host where it was declared:

View File

@@ -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.<id>` 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.<id>` 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).

View File

@@ -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({

View File

@@ -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<typeof getMemoryEmbeddingProvider> {
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.<id>.api.
// Keep multimodal validation on that config-aware adapter, not the raw id.
if (
multimodalActive &&
multimodalProvider &&

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;
}