From 4241e8f7d856e3bb7fd16ddd99cd51e2ce06158c Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 2 Mar 2026 20:04:56 -0500 Subject: [PATCH] fix(memory): honor ollama provider config for embeddings --- src/agents/memory-search.test.ts | 9 +- src/agents/memory-search.ts | 6 +- src/memory/embeddings-ollama.test.ts | 44 +++++++++ src/memory/embeddings-ollama.ts | 128 ++++++++++++++++++++------- 4 files changed, 152 insertions(+), 35 deletions(-) diff --git a/src/agents/memory-search.test.ts b/src/agents/memory-search.test.ts index a49aefa4634..5fe1120cf58 100644 --- a/src/agents/memory-search.test.ts +++ b/src/agents/memory-search.test.ts @@ -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: { diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts index 53fce45de84..7b4e40b1df6 100644 --- a/src/agents/memory-search.ts +++ b/src/agents/memory-search.ts @@ -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, diff --git a/src/memory/embeddings-ollama.test.ts b/src/memory/embeddings-ollama.test.ts index d6f6420e11e..9fe1b27cdfe 100644 --- a/src/memory/embeddings-ollama.test.ts +++ b/src/memory/embeddings-ollama.test.ts @@ -27,4 +27,48 @@ describe("embeddings-ollama", () => { 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 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", + }), + }), + ); + }); }); diff --git a/src/memory/embeddings-ollama.ts b/src/memory/embeddings-ollama.ts index d755f840d10..80660a6522e 100644 --- a/src/memory/embeddings-ollama.ts +++ b/src/memory/embeddings-ollama.ts @@ -1,9 +1,20 @@ +import { resolveEnvApiKey } from "../agents/model-auth.js"; import { formatErrorMessage } from "../infra/errors.js"; +import type { SsrFPolicy } from "../infra/net/ssrf.js"; import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.js"; +import { buildRemoteBaseUrlPolicy, withRemoteHttpResponse } from "./remote-http.js"; export type OllamaEmbeddingClient = { + baseUrl: string; + headers: Record; + ssrfPolicy?: SsrFPolicy; + model: string; embedBatch: (texts: string[]) => Promise; }; +type OllamaEmbeddingClientConfig = Omit; + +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)); @@ -14,33 +25,83 @@ function sanitizeAndNormalizeEmbedding(vec: number[]): number[] { 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 = options.config.models?.providers?.ollama?.apiKey?.trim(); + 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 = { + "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 baseUrl = options.remote?.baseUrl?.trim() || "http://127.0.0.1:11434"; - const model = options.model || "nomic-embed-text"; - - const headers: Record = { - "content-type": "application/json", - ...options.remote?.headers, - }; - - // Ollama doesn't require an API key by default. If users set one (proxy), allow it. - const apiKey = options.remote?.apiKey; - if (apiKey) { - headers.authorization = `Bearer ${apiKey}`; - } + const client = resolveOllamaEmbeddingClient(options); + const embedUrl = `${client.baseUrl.replace(/\/$/, "")}/api/embeddings`; const embedOne = async (text: string): Promise => { - const res = await fetch(`${baseUrl.replace(/\/$/, "")}/api/embeddings`, { - method: "POST", - headers, - body: JSON.stringify({ model, prompt: text }), + 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 (!res.ok) { - throw new Error(`Ollama embeddings HTTP ${res.status}: ${await res.text()}`); - } - const json = (await res.json()) as { embedding?: number[] }; if (!Array.isArray(json.embedding)) { throw new Error(`Ollama embeddings response missing embedding[]`); } @@ -49,24 +110,25 @@ export async function createOllamaEmbeddingProvider( const provider: EmbeddingProvider = { id: "ollama", - model, + model: client.model, embedQuery: embedOne, embedBatch: async (texts: string[]) => { - // Ollama /api/embeddings is single-prompt; parallelize with a small fanout. - // Keep it simple and let caller batch size control overall load. + // Ollama /api/embeddings accepts one prompt per request. return await Promise.all(texts.map(embedOne)); }, }; - const client: OllamaEmbeddingClient = { - embedBatch: async (texts) => { - try { - return await provider.embedBatch(texts); - } catch (err) { - throw new Error(formatErrorMessage(err), { cause: err }); - } + return { + provider, + client: { + ...client, + embedBatch: async (texts) => { + try { + return await provider.embedBatch(texts); + } catch (err) { + throw new Error(formatErrorMessage(err), { cause: err }); + } + }, }, }; - - return { provider, client }; }