mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:50:43 +00:00
fix(memory): resolve custom embedding provider ids
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"`.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user