mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-09 15:35:17 +00:00
feat(memory): add Ollama embedding provider (#26349)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: ac41386543
Co-authored-by: nico-hoff <43175972+nico-hoff@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Models/MiniMax: add first-class `MiniMax-M2.5-highspeed` support across built-in provider catalogs, onboarding flows, and MiniMax OAuth plugin defaults, while keeping legacy `MiniMax-M2.5-Lightning` compatibility for existing configs.
|
||||
- Docs/Models: refresh MiniMax, Moonshot (Kimi), GLM/Z.AI model docs to align with latest defaults (`MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `moonshot/kimi-k2.5`, `zai/glm-5`) and keep Moonshot model lists synced from shared source data.
|
||||
- Memory/Ollama embeddings: add `memorySearch.provider = "ollama"` and `memorySearch.fallback = "ollama"` support, honor `models.providers.ollama` settings for memory embedding requests, and document Ollama embedding usage. (#26349) Thanks @nico-hoff.
|
||||
- Outbound adapters/plugins: add shared `sendPayload` support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat.
|
||||
- Media understanding/audio echo: add optional `tools.media.audio.echoTranscript` + `echoFormat` to send a pre-agent transcript confirmation message to the originating chat, with echo disabled by default. (#32150) Thanks @AytuncYildizli.
|
||||
- Plugin runtime/STT: add `api.runtime.stt.transcribeAudioFile(...)` so extensions can transcribe local audio files through OpenClaw's configured media-understanding audio providers. (#22402) Thanks @benthecarman.
|
||||
|
||||
@@ -109,6 +109,8 @@ Defaults:
|
||||
6. Otherwise memory search stays disabled until configured.
|
||||
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
|
||||
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
|
||||
- `memorySearch.provider = "ollama"` is also supported for local/self-hosted
|
||||
Ollama embeddings (`/api/embeddings`), but it is not auto-selected.
|
||||
|
||||
Remote embeddings **require** an API key for the embedding provider. OpenClaw
|
||||
resolves keys from auth profiles, `models.providers.*.apiKey`, or environment
|
||||
@@ -116,7 +118,9 @@ variables. Codex OAuth only covers chat/completions and does **not** satisfy
|
||||
embeddings for memory search. For Gemini, use `GEMINI_API_KEY` or
|
||||
`models.providers.google.apiKey`. For Voyage, use `VOYAGE_API_KEY` or
|
||||
`models.providers.voyage.apiKey`. For Mistral, use `MISTRAL_API_KEY` or
|
||||
`models.providers.mistral.apiKey`.
|
||||
`models.providers.mistral.apiKey`. Ollama typically does not require a real API
|
||||
key (a placeholder like `OLLAMA_API_KEY=ollama-local` is enough when needed by
|
||||
local policy).
|
||||
When using a custom OpenAI-compatible endpoint,
|
||||
set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
||||
|
||||
@@ -331,7 +335,7 @@ If you don't want to set an API key, use `memorySearch.provider = "local"` or se
|
||||
|
||||
Fallbacks:
|
||||
|
||||
- `memorySearch.fallback` can be `openai`, `gemini`, `voyage`, `mistral`, `local`, or `none`.
|
||||
- `memorySearch.fallback` can be `openai`, `gemini`, `voyage`, `mistral`, `ollama`, `local`, or `none`.
|
||||
- The fallback provider is only used when the primary embedding provider fails.
|
||||
|
||||
Batch indexing (OpenAI + Gemini + Voyage):
|
||||
|
||||
@@ -1299,12 +1299,13 @@ It prefers OpenAI if an OpenAI key resolves, otherwise Gemini if a Gemini key
|
||||
resolves, then Voyage, then Mistral. If no remote key is available, memory
|
||||
search stays disabled until you configure it. If you have a local model path
|
||||
configured and present, OpenClaw
|
||||
prefers `local`.
|
||||
prefers `local`. Ollama is supported when you explicitly set
|
||||
`memorySearch.provider = "ollama"`.
|
||||
|
||||
If you'd rather stay local, set `memorySearch.provider = "local"` (and optionally
|
||||
`memorySearch.fallback = "none"`). If you want Gemini embeddings, set
|
||||
`memorySearch.provider = "gemini"` and provide `GEMINI_API_KEY` (or
|
||||
`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, Voyage, Mistral, or local** embedding
|
||||
`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, Voyage, Mistral, Ollama, or local** embedding
|
||||
models - see [Memory](/concepts/memory) for the setup details.
|
||||
|
||||
### Does memory persist forever What are the limits
|
||||
|
||||
@@ -68,6 +68,7 @@ Semantic memory search uses **embedding APIs** when configured for remote provid
|
||||
- `memorySearch.provider = "gemini"` → Gemini embeddings
|
||||
- `memorySearch.provider = "voyage"` → Voyage embeddings
|
||||
- `memorySearch.provider = "mistral"` → Mistral embeddings
|
||||
- `memorySearch.provider = "ollama"` → Ollama embeddings (local/self-hosted; typically no hosted API billing)
|
||||
- Optional fallback to a remote provider if local embeddings fail
|
||||
|
||||
You can keep it local with `memorySearch.provider = "local"` (no API usage).
|
||||
|
||||
@@ -6,7 +6,7 @@ const asConfig = (cfg: OpenClawConfig): OpenClawConfig => cfg;
|
||||
|
||||
describe("memory search config", () => {
|
||||
function configWithDefaultProvider(
|
||||
provider: "openai" | "local" | "gemini" | "mistral",
|
||||
provider: "openai" | "local" | "gemini" | "mistral" | "ollama",
|
||||
): OpenClawConfig {
|
||||
return asConfig({
|
||||
agents: {
|
||||
@@ -156,6 +156,13 @@ describe("memory search config", () => {
|
||||
expect(resolved?.model).toBe("mistral-embed");
|
||||
});
|
||||
|
||||
it("includes remote defaults and model default for ollama without overrides", () => {
|
||||
const cfg = configWithDefaultProvider("ollama");
|
||||
const resolved = resolveMemorySearchConfig(cfg, "main");
|
||||
expectDefaultRemoteBatch(resolved);
|
||||
expect(resolved?.model).toBe("nomic-embed-text");
|
||||
});
|
||||
|
||||
it("defaults session delta thresholds", () => {
|
||||
const cfg = asConfig({
|
||||
agents: {
|
||||
|
||||
@@ -9,7 +9,7 @@ export type ResolvedMemorySearchConfig = {
|
||||
enabled: boolean;
|
||||
sources: Array<"memory" | "sessions">;
|
||||
extraPaths: string[];
|
||||
provider: "openai" | "local" | "gemini" | "voyage" | "mistral" | "auto";
|
||||
provider: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama" | "auto";
|
||||
remote?: {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
@@ -25,7 +25,7 @@ export type ResolvedMemorySearchConfig = {
|
||||
experimental: {
|
||||
sessionMemory: boolean;
|
||||
};
|
||||
fallback: "openai" | "gemini" | "local" | "voyage" | "mistral" | "none";
|
||||
fallback: "openai" | "gemini" | "local" | "voyage" | "mistral" | "ollama" | "none";
|
||||
model: string;
|
||||
local: {
|
||||
modelPath?: string;
|
||||
@@ -82,6 +82,7 @@ const DEFAULT_OPENAI_MODEL = "text-embedding-3-small";
|
||||
const DEFAULT_GEMINI_MODEL = "gemini-embedding-001";
|
||||
const DEFAULT_VOYAGE_MODEL = "voyage-4-large";
|
||||
const DEFAULT_MISTRAL_MODEL = "mistral-embed";
|
||||
const DEFAULT_OLLAMA_MODEL = "nomic-embed-text";
|
||||
const DEFAULT_CHUNK_TOKENS = 400;
|
||||
const DEFAULT_CHUNK_OVERLAP = 80;
|
||||
const DEFAULT_WATCH_DEBOUNCE_MS = 1500;
|
||||
@@ -155,6 +156,7 @@ function mergeConfig(
|
||||
provider === "gemini" ||
|
||||
provider === "voyage" ||
|
||||
provider === "mistral" ||
|
||||
provider === "ollama" ||
|
||||
provider === "auto";
|
||||
const batch = {
|
||||
enabled: overrideRemote?.batch?.enabled ?? defaultRemote?.batch?.enabled ?? false,
|
||||
@@ -186,7 +188,9 @@ function mergeConfig(
|
||||
? DEFAULT_VOYAGE_MODEL
|
||||
: provider === "mistral"
|
||||
? DEFAULT_MISTRAL_MODEL
|
||||
: undefined;
|
||||
: provider === "ollama"
|
||||
? DEFAULT_OLLAMA_MODEL
|
||||
: undefined;
|
||||
const model = overrides?.model ?? defaults?.model ?? modelDefault ?? "";
|
||||
const local = {
|
||||
modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath,
|
||||
|
||||
@@ -235,6 +235,31 @@ describe("noteMemorySearchHealth", () => {
|
||||
const message = String(note.mock.calls[0]?.[0] ?? "");
|
||||
expect(message).toContain("openclaw configure --section model");
|
||||
});
|
||||
|
||||
it("still warns in auto mode when only ollama credentials exist", async () => {
|
||||
resolveMemorySearchConfig.mockReturnValue({
|
||||
provider: "auto",
|
||||
local: {},
|
||||
remote: {},
|
||||
});
|
||||
resolveApiKeyForProvider.mockImplementation(async ({ provider }: { provider: string }) => {
|
||||
if (provider === "ollama") {
|
||||
return {
|
||||
apiKey: "ollama-local",
|
||||
source: "env: OLLAMA_API_KEY",
|
||||
mode: "api-key",
|
||||
};
|
||||
}
|
||||
throw new Error("missing key");
|
||||
});
|
||||
|
||||
await noteMemorySearchHealth(cfg);
|
||||
|
||||
expect(note).toHaveBeenCalledTimes(1);
|
||||
const providerCalls = resolveApiKeyForProvider.mock.calls as Array<[{ provider: string }]>;
|
||||
const providersChecked = providerCalls.map(([arg]) => arg.provider);
|
||||
expect(providersChecked).toEqual(["openai", "google", "voyage", "mistral"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectLegacyWorkspaceDirs", () => {
|
||||
|
||||
@@ -186,7 +186,7 @@ function hasLocalEmbeddings(local: { modelPath?: string }, useDefaultFallback =
|
||||
}
|
||||
|
||||
async function hasApiKeyForProvider(
|
||||
provider: "openai" | "gemini" | "voyage" | "mistral",
|
||||
provider: "openai" | "gemini" | "voyage" | "mistral" | "ollama",
|
||||
cfg: OpenClawConfig,
|
||||
agentDir: string,
|
||||
): Promise<boolean> {
|
||||
|
||||
@@ -724,7 +724,7 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"agents.defaults.memorySearch.experimental.sessionMemory":
|
||||
"Indexes session transcripts into memory search so responses can reference prior chat turns. Keep this off unless transcript recall is needed, because indexing cost and storage usage both increase.",
|
||||
"agents.defaults.memorySearch.provider":
|
||||
'Selects the embedding backend used to build/query memory vectors: "openai", "gemini", "voyage", "mistral", or "local". Keep your most reliable provider here and configure fallback for resilience.',
|
||||
'Selects the embedding backend used to build/query memory vectors: "openai", "gemini", "voyage", "mistral", "ollama", or "local". Keep your most reliable provider here and configure fallback for resilience.',
|
||||
"agents.defaults.memorySearch.model":
|
||||
"Embedding model override used by the selected memory provider when a non-default model is required. Set this only when you need explicit recall quality/cost tuning beyond provider defaults.",
|
||||
"agents.defaults.memorySearch.remote.baseUrl":
|
||||
@@ -746,7 +746,7 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"agents.defaults.memorySearch.local.modelPath":
|
||||
"Specifies the local embedding model source for local memory search, such as a GGUF file path or `hf:` URI. Use this only when provider is `local`, and verify model compatibility before large index rebuilds.",
|
||||
"agents.defaults.memorySearch.fallback":
|
||||
'Backup provider used when primary embeddings fail: "openai", "gemini", "voyage", "mistral", "local", or "none". Set a real fallback for production reliability; use "none" only if you prefer explicit failures.',
|
||||
'Backup provider used when primary embeddings fail: "openai", "gemini", "voyage", "mistral", "ollama", "local", or "none". Set a real fallback for production reliability; use "none" only if you prefer explicit failures.',
|
||||
"agents.defaults.memorySearch.store.path":
|
||||
"Sets where the SQLite memory index is stored on disk for each agent. Keep the default `~/.openclaw/memory/{agentId}.sqlite` unless you need custom storage placement or backup policy alignment.",
|
||||
"agents.defaults.memorySearch.store.vector.enabled":
|
||||
|
||||
@@ -324,7 +324,7 @@ export type MemorySearchConfig = {
|
||||
sessionMemory?: boolean;
|
||||
};
|
||||
/** Embedding provider mode. */
|
||||
provider?: "openai" | "gemini" | "local" | "voyage" | "mistral";
|
||||
provider?: "openai" | "gemini" | "local" | "voyage" | "mistral" | "ollama";
|
||||
remote?: {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
@@ -343,7 +343,7 @@ export type MemorySearchConfig = {
|
||||
};
|
||||
};
|
||||
/** Fallback behavior when embeddings fail. */
|
||||
fallback?: "openai" | "gemini" | "local" | "voyage" | "mistral" | "none";
|
||||
fallback?: "openai" | "gemini" | "local" | "voyage" | "mistral" | "ollama" | "none";
|
||||
/** Embedding model id (remote) or alias (local). */
|
||||
model?: string;
|
||||
/** Local embedding settings (node-llama-cpp). */
|
||||
|
||||
@@ -557,6 +557,7 @@ export const MemorySearchSchema = z
|
||||
z.literal("gemini"),
|
||||
z.literal("voyage"),
|
||||
z.literal("mistral"),
|
||||
z.literal("ollama"),
|
||||
])
|
||||
.optional(),
|
||||
remote: z
|
||||
@@ -584,6 +585,7 @@ export const MemorySearchSchema = z
|
||||
z.literal("local"),
|
||||
z.literal("voyage"),
|
||||
z.literal("mistral"),
|
||||
z.literal("ollama"),
|
||||
z.literal("none"),
|
||||
])
|
||||
.optional(),
|
||||
|
||||
74
src/memory/embeddings-ollama.test.ts
Normal file
74
src/memory/embeddings-ollama.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { createOllamaEmbeddingProvider } from "./embeddings-ollama.js";
|
||||
|
||||
describe("embeddings-ollama", () => {
|
||||
it("calls /api/embeddings and returns normalized vectors", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ embedding: [3, 4] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "http://127.0.0.1:11434" },
|
||||
});
|
||||
|
||||
const v = await provider.embedQuery("hi");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
// normalized [3,4] => [0.6,0.8]
|
||||
expect(v[0]).toBeCloseTo(0.6, 5);
|
||||
expect(v[1]).toBeCloseTo(0.8, 5);
|
||||
});
|
||||
|
||||
it("resolves baseUrl/apiKey/headers from models.providers.ollama and strips /v1", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ embedding: [1, 0] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
apiKey: "ollama-local",
|
||||
headers: {
|
||||
"X-Provider-Header": "provider",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "",
|
||||
fallback: "none",
|
||||
});
|
||||
|
||||
await provider.embedQuery("hello");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:11434/api/embeddings",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer ollama-local",
|
||||
"X-Provider-Header": "provider",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
137
src/memory/embeddings-ollama.ts
Normal file
137
src/memory/embeddings-ollama.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { resolveEnvApiKey } from "../agents/model-auth.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
|
||||
import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.js";
|
||||
import { buildRemoteBaseUrlPolicy, withRemoteHttpResponse } from "./remote-http.js";
|
||||
|
||||
export type OllamaEmbeddingClient = {
|
||||
baseUrl: string;
|
||||
headers: Record<string, string>;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
model: string;
|
||||
embedBatch: (texts: string[]) => Promise<number[][]>;
|
||||
};
|
||||
type OllamaEmbeddingClientConfig = Omit<OllamaEmbeddingClient, "embedBatch">;
|
||||
|
||||
export const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text";
|
||||
const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434";
|
||||
|
||||
function sanitizeAndNormalizeEmbedding(vec: number[]): number[] {
|
||||
const sanitized = vec.map((value) => (Number.isFinite(value) ? value : 0));
|
||||
const magnitude = Math.sqrt(sanitized.reduce((sum, value) => sum + value * value, 0));
|
||||
if (magnitude < 1e-10) {
|
||||
return sanitized;
|
||||
}
|
||||
return sanitized.map((value) => value / magnitude);
|
||||
}
|
||||
|
||||
function normalizeOllamaModel(model: string): string {
|
||||
const trimmed = model.trim();
|
||||
if (!trimmed) {
|
||||
return DEFAULT_OLLAMA_EMBEDDING_MODEL;
|
||||
}
|
||||
if (trimmed.startsWith("ollama/")) {
|
||||
return trimmed.slice("ollama/".length);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function resolveOllamaApiBase(configuredBaseUrl?: string): string {
|
||||
if (!configuredBaseUrl) {
|
||||
return DEFAULT_OLLAMA_BASE_URL;
|
||||
}
|
||||
const trimmed = configuredBaseUrl.replace(/\/+$/, "");
|
||||
return trimmed.replace(/\/v1$/i, "");
|
||||
}
|
||||
|
||||
function resolveOllamaApiKey(options: EmbeddingProviderOptions): string | undefined {
|
||||
const remoteApiKey = options.remote?.apiKey?.trim();
|
||||
if (remoteApiKey) {
|
||||
return remoteApiKey;
|
||||
}
|
||||
const providerApiKey = normalizeOptionalSecretInput(
|
||||
options.config.models?.providers?.ollama?.apiKey,
|
||||
);
|
||||
if (providerApiKey) {
|
||||
return providerApiKey;
|
||||
}
|
||||
return resolveEnvApiKey("ollama")?.apiKey;
|
||||
}
|
||||
|
||||
function resolveOllamaEmbeddingClient(
|
||||
options: EmbeddingProviderOptions,
|
||||
): OllamaEmbeddingClientConfig {
|
||||
const providerConfig = options.config.models?.providers?.ollama;
|
||||
const rawBaseUrl = options.remote?.baseUrl?.trim() || providerConfig?.baseUrl?.trim();
|
||||
const baseUrl = resolveOllamaApiBase(rawBaseUrl);
|
||||
const model = normalizeOllamaModel(options.model);
|
||||
const headerOverrides = Object.assign({}, providerConfig?.headers, options.remote?.headers);
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...headerOverrides,
|
||||
};
|
||||
const apiKey = resolveOllamaApiKey(options);
|
||||
if (apiKey) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
return {
|
||||
baseUrl,
|
||||
headers,
|
||||
ssrfPolicy: buildRemoteBaseUrlPolicy(baseUrl),
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createOllamaEmbeddingProvider(
|
||||
options: EmbeddingProviderOptions,
|
||||
): Promise<{ provider: EmbeddingProvider; client: OllamaEmbeddingClient }> {
|
||||
const client = resolveOllamaEmbeddingClient(options);
|
||||
const embedUrl = `${client.baseUrl.replace(/\/$/, "")}/api/embeddings`;
|
||||
|
||||
const embedOne = async (text: string): Promise<number[]> => {
|
||||
const json = await withRemoteHttpResponse({
|
||||
url: embedUrl,
|
||||
ssrfPolicy: client.ssrfPolicy,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: client.headers,
|
||||
body: JSON.stringify({ model: client.model, prompt: text }),
|
||||
},
|
||||
onResponse: async (res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`Ollama embeddings HTTP ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
return (await res.json()) as { embedding?: number[] };
|
||||
},
|
||||
});
|
||||
if (!Array.isArray(json.embedding)) {
|
||||
throw new Error(`Ollama embeddings response missing embedding[]`);
|
||||
}
|
||||
return sanitizeAndNormalizeEmbedding(json.embedding);
|
||||
};
|
||||
|
||||
const provider: EmbeddingProvider = {
|
||||
id: "ollama",
|
||||
model: client.model,
|
||||
embedQuery: embedOne,
|
||||
embedBatch: async (texts: string[]) => {
|
||||
// Ollama /api/embeddings accepts one prompt per request.
|
||||
return await Promise.all(texts.map(embedOne));
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
provider,
|
||||
client: {
|
||||
...client,
|
||||
embedBatch: async (texts) => {
|
||||
try {
|
||||
return await provider.embedBatch(texts);
|
||||
} catch (err) {
|
||||
throw new Error(formatErrorMessage(err), { cause: err });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
createMistralEmbeddingProvider,
|
||||
type MistralEmbeddingClient,
|
||||
} from "./embeddings-mistral.js";
|
||||
import { createOllamaEmbeddingProvider, type OllamaEmbeddingClient } from "./embeddings-ollama.js";
|
||||
import { createOpenAiEmbeddingProvider, type OpenAiEmbeddingClient } from "./embeddings-openai.js";
|
||||
import { createVoyageEmbeddingProvider, type VoyageEmbeddingClient } from "./embeddings-voyage.js";
|
||||
import { importNodeLlamaCpp } from "./node-llama.js";
|
||||
@@ -25,6 +26,7 @@ export type { GeminiEmbeddingClient } from "./embeddings-gemini.js";
|
||||
export type { MistralEmbeddingClient } from "./embeddings-mistral.js";
|
||||
export type { OpenAiEmbeddingClient } from "./embeddings-openai.js";
|
||||
export type { VoyageEmbeddingClient } from "./embeddings-voyage.js";
|
||||
export type { OllamaEmbeddingClient } from "./embeddings-ollama.js";
|
||||
|
||||
export type EmbeddingProvider = {
|
||||
id: string;
|
||||
@@ -34,10 +36,13 @@ export type EmbeddingProvider = {
|
||||
embedBatch: (texts: string[]) => Promise<number[][]>;
|
||||
};
|
||||
|
||||
export type EmbeddingProviderId = "openai" | "local" | "gemini" | "voyage" | "mistral";
|
||||
export type EmbeddingProviderId = "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama";
|
||||
export type EmbeddingProviderRequest = EmbeddingProviderId | "auto";
|
||||
export type EmbeddingProviderFallback = EmbeddingProviderId | "none";
|
||||
|
||||
// Remote providers considered for auto-selection when provider === "auto".
|
||||
// Ollama is intentionally excluded here so that "auto" mode does not
|
||||
// implicitly assume a local Ollama instance is available.
|
||||
const REMOTE_EMBEDDING_PROVIDER_IDS = ["openai", "gemini", "voyage", "mistral"] as const;
|
||||
|
||||
export type EmbeddingProviderResult = {
|
||||
@@ -50,6 +55,7 @@ export type EmbeddingProviderResult = {
|
||||
gemini?: GeminiEmbeddingClient;
|
||||
voyage?: VoyageEmbeddingClient;
|
||||
mistral?: MistralEmbeddingClient;
|
||||
ollama?: OllamaEmbeddingClient;
|
||||
};
|
||||
|
||||
export type EmbeddingProviderOptions = {
|
||||
@@ -152,6 +158,10 @@ export async function createEmbeddingProvider(
|
||||
const provider = await createLocalEmbeddingProvider(options);
|
||||
return { provider };
|
||||
}
|
||||
if (id === "ollama") {
|
||||
const { provider, client } = await createOllamaEmbeddingProvider(options);
|
||||
return { provider, ollama: client };
|
||||
}
|
||||
if (id === "gemini") {
|
||||
const { provider, client } = await createGeminiEmbeddingProvider(options);
|
||||
return { provider, gemini: client };
|
||||
|
||||
@@ -13,6 +13,7 @@ import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { DEFAULT_GEMINI_EMBEDDING_MODEL } from "./embeddings-gemini.js";
|
||||
import { DEFAULT_MISTRAL_EMBEDDING_MODEL } from "./embeddings-mistral.js";
|
||||
import { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./embeddings-ollama.js";
|
||||
import { DEFAULT_OPENAI_EMBEDDING_MODEL } from "./embeddings-openai.js";
|
||||
import { DEFAULT_VOYAGE_EMBEDDING_MODEL } from "./embeddings-voyage.js";
|
||||
import {
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
type EmbeddingProvider,
|
||||
type GeminiEmbeddingClient,
|
||||
type MistralEmbeddingClient,
|
||||
type OllamaEmbeddingClient,
|
||||
type OpenAiEmbeddingClient,
|
||||
type VoyageEmbeddingClient,
|
||||
} from "./embeddings.js";
|
||||
@@ -91,11 +93,12 @@ export abstract class MemoryManagerSyncOps {
|
||||
protected abstract readonly workspaceDir: string;
|
||||
protected abstract readonly settings: ResolvedMemorySearchConfig;
|
||||
protected provider: EmbeddingProvider | null = null;
|
||||
protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral";
|
||||
protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama";
|
||||
protected openAi?: OpenAiEmbeddingClient;
|
||||
protected gemini?: GeminiEmbeddingClient;
|
||||
protected voyage?: VoyageEmbeddingClient;
|
||||
protected mistral?: MistralEmbeddingClient;
|
||||
protected ollama?: OllamaEmbeddingClient;
|
||||
protected abstract batch: {
|
||||
enabled: boolean;
|
||||
wait: boolean;
|
||||
@@ -350,7 +353,10 @@ export abstract class MemoryManagerSyncOps {
|
||||
this.fts.available = result.ftsAvailable;
|
||||
if (result.ftsError) {
|
||||
this.fts.loadError = result.ftsError;
|
||||
log.warn(`fts unavailable: ${result.ftsError}`);
|
||||
// Only warn when hybrid search is enabled; otherwise this is expected noise.
|
||||
if (this.fts.enabled) {
|
||||
log.warn(`fts unavailable: ${result.ftsError}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -958,7 +964,13 @@ export abstract class MemoryManagerSyncOps {
|
||||
if (this.fallbackFrom) {
|
||||
return false;
|
||||
}
|
||||
const fallbackFrom = this.provider.id as "openai" | "gemini" | "local" | "voyage" | "mistral";
|
||||
const fallbackFrom = this.provider.id as
|
||||
| "openai"
|
||||
| "gemini"
|
||||
| "local"
|
||||
| "voyage"
|
||||
| "mistral"
|
||||
| "ollama";
|
||||
|
||||
const fallbackModel =
|
||||
fallback === "gemini"
|
||||
@@ -969,7 +981,9 @@ export abstract class MemoryManagerSyncOps {
|
||||
? DEFAULT_VOYAGE_EMBEDDING_MODEL
|
||||
: fallback === "mistral"
|
||||
? DEFAULT_MISTRAL_EMBEDDING_MODEL
|
||||
: this.settings.model;
|
||||
: fallback === "ollama"
|
||||
? DEFAULT_OLLAMA_EMBEDDING_MODEL
|
||||
: this.settings.model;
|
||||
|
||||
const fallbackResult = await createEmbeddingProvider({
|
||||
config: this.cfg,
|
||||
@@ -988,6 +1002,7 @@ export abstract class MemoryManagerSyncOps {
|
||||
this.gemini = fallbackResult.gemini;
|
||||
this.voyage = fallbackResult.voyage;
|
||||
this.mistral = fallbackResult.mistral;
|
||||
this.ollama = fallbackResult.ollama;
|
||||
this.providerKey = this.computeProviderKey();
|
||||
this.batch = this.resolveBatchConfig();
|
||||
log.warn(`memory embeddings: switched to fallback provider (${fallback})`, { reason });
|
||||
|
||||
@@ -3,10 +3,12 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./embeddings-ollama.js";
|
||||
import type {
|
||||
EmbeddingProvider,
|
||||
EmbeddingProviderResult,
|
||||
MistralEmbeddingClient,
|
||||
OllamaEmbeddingClient,
|
||||
OpenAiEmbeddingClient,
|
||||
} from "./embeddings.js";
|
||||
import { getMemorySearchManager, type MemoryIndexManager } from "./index.js";
|
||||
@@ -36,7 +38,7 @@ function buildConfig(params: {
|
||||
workspaceDir: string;
|
||||
indexPath: string;
|
||||
provider: "openai" | "mistral";
|
||||
fallback?: "none" | "mistral";
|
||||
fallback?: "none" | "mistral" | "ollama";
|
||||
}): OpenClawConfig {
|
||||
return {
|
||||
agents: {
|
||||
@@ -144,4 +146,51 @@ describe("memory manager mistral provider wiring", () => {
|
||||
expect(internal.openAi).toBeUndefined();
|
||||
expect(internal.mistral).toBe(mistralClient);
|
||||
});
|
||||
|
||||
it("uses default ollama model when activating ollama fallback", async () => {
|
||||
const openAiClient: OpenAiEmbeddingClient = {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
headers: { authorization: "Bearer openai-key" },
|
||||
model: "text-embedding-3-small",
|
||||
};
|
||||
const ollamaClient: OllamaEmbeddingClient = {
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
headers: {},
|
||||
model: DEFAULT_OLLAMA_EMBEDDING_MODEL,
|
||||
embedBatch: async (texts: string[]) => texts.map(() => [0.1, 0.2, 0.3]),
|
||||
};
|
||||
createEmbeddingProviderMock.mockResolvedValueOnce({
|
||||
requestedProvider: "openai",
|
||||
provider: createProvider("openai"),
|
||||
openAi: openAiClient,
|
||||
} as EmbeddingProviderResult);
|
||||
createEmbeddingProviderMock.mockResolvedValueOnce({
|
||||
requestedProvider: "ollama",
|
||||
provider: createProvider("ollama"),
|
||||
ollama: ollamaClient,
|
||||
} as EmbeddingProviderResult);
|
||||
|
||||
const cfg = buildConfig({ workspaceDir, indexPath, provider: "openai", fallback: "ollama" });
|
||||
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||
if (!result.manager) {
|
||||
throw new Error(`manager missing: ${result.error ?? "no error provided"}`);
|
||||
}
|
||||
manager = result.manager as unknown as MemoryIndexManager;
|
||||
const internal = manager as unknown as {
|
||||
activateFallbackProvider: (reason: string) => Promise<boolean>;
|
||||
openAi?: OpenAiEmbeddingClient;
|
||||
ollama?: OllamaEmbeddingClient;
|
||||
};
|
||||
|
||||
const activated = await internal.activateFallbackProvider("forced ollama fallback");
|
||||
expect(activated).toBe(true);
|
||||
expect(internal.openAi).toBeUndefined();
|
||||
expect(internal.ollama).toBe(ollamaClient);
|
||||
|
||||
const fallbackCall = createEmbeddingProviderMock.mock.calls[1]?.[0] as
|
||||
| { provider?: string; model?: string }
|
||||
| undefined;
|
||||
expect(fallbackCall?.provider).toBe("ollama");
|
||||
expect(fallbackCall?.model).toBe(DEFAULT_OLLAMA_EMBEDDING_MODEL);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type EmbeddingProviderResult,
|
||||
type GeminiEmbeddingClient,
|
||||
type MistralEmbeddingClient,
|
||||
type OllamaEmbeddingClient,
|
||||
type OpenAiEmbeddingClient,
|
||||
type VoyageEmbeddingClient,
|
||||
} from "./embeddings.js";
|
||||
@@ -48,14 +49,22 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
protected readonly workspaceDir: string;
|
||||
protected readonly settings: ResolvedMemorySearchConfig;
|
||||
protected provider: EmbeddingProvider | null;
|
||||
private readonly requestedProvider: "openai" | "local" | "gemini" | "voyage" | "mistral" | "auto";
|
||||
protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral";
|
||||
private readonly requestedProvider:
|
||||
| "openai"
|
||||
| "local"
|
||||
| "gemini"
|
||||
| "voyage"
|
||||
| "mistral"
|
||||
| "ollama"
|
||||
| "auto";
|
||||
protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama";
|
||||
protected fallbackReason?: string;
|
||||
private readonly providerUnavailableReason?: string;
|
||||
protected openAi?: OpenAiEmbeddingClient;
|
||||
protected gemini?: GeminiEmbeddingClient;
|
||||
protected voyage?: VoyageEmbeddingClient;
|
||||
protected mistral?: MistralEmbeddingClient;
|
||||
protected ollama?: OllamaEmbeddingClient;
|
||||
protected batch: {
|
||||
enabled: boolean;
|
||||
wait: boolean;
|
||||
@@ -185,6 +194,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
this.gemini = params.providerResult.gemini;
|
||||
this.voyage = params.providerResult.voyage;
|
||||
this.mistral = params.providerResult.mistral;
|
||||
this.ollama = params.providerResult.ollama;
|
||||
this.sources = new Set(params.settings.sources);
|
||||
this.db = this.openDatabase();
|
||||
this.providerKey = this.computeProviderKey();
|
||||
@@ -289,9 +299,11 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
return merged;
|
||||
}
|
||||
|
||||
const keywordResults = hybrid.enabled
|
||||
? await this.searchKeyword(cleaned, candidates).catch(() => [])
|
||||
: [];
|
||||
// If FTS isn't available, hybrid mode cannot use keyword search; degrade to vector-only.
|
||||
const keywordResults =
|
||||
hybrid.enabled && this.fts.enabled && this.fts.available
|
||||
? await this.searchKeyword(cleaned, candidates).catch(() => [])
|
||||
: [];
|
||||
|
||||
const queryVec = await this.embedQueryWithTimeout(cleaned);
|
||||
const hasVector = queryVec.some((v) => v !== 0);
|
||||
@@ -299,7 +311,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
? await this.searchVector(queryVec, candidates).catch(() => [])
|
||||
: [];
|
||||
|
||||
if (!hybrid.enabled) {
|
||||
if (!hybrid.enabled || !this.fts.enabled || !this.fts.available) {
|
||||
return vectorResults.filter((entry) => entry.score >= minScore).slice(0, maxResults);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user