Skip to content

Commit da0a062

Browse files
committed
fix: memory search remote overrides (#819) (thanks @mukhtharcm)
1 parent ba316a1 commit da0a062

7 files changed

Lines changed: 171 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Changes
66
- Models/Moonshot: add Kimi K2 turbo + thinking variants to the preset + docs. (#818 — thanks @mickahouan)
7+
- Memory: allow custom OpenAI-compatible embedding endpoints for memory search (remote baseUrl/apiKey/headers). (#819 — thanks @mukhtharcm)
78

89
### Fixes
910
- Onboarding/Configure: refuse to proceed with invalid configs; run `clawdbot doctor` first to avoid wiping custom fields. (#764 — thanks @mukhtharcm)

docs/concepts/memory.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,11 @@ Defaults:
7979
- Uses remote embeddings (OpenAI) unless configured for local.
8080
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
8181

82-
Remote embeddings **require** an OpenAI API key (`OPENAI_API_KEY` or
83-
`models.providers.openai.apiKey`). Codex OAuth only covers chat/completions and
84-
does **not** satisfy embeddings for memory search.
82+
Remote embeddings **require** an API key for the embedding provider. By default
83+
this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex
84+
OAuth only covers chat/completions and does **not** satisfy embeddings for
85+
memory search. When using a custom OpenAI-compatible endpoint, set
86+
`memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
8587

8688
If you want to use a **custom OpenAI-compatible endpoint** (like Gemini, OpenRouter, or a proxy),
8789
you can use the `remote` configuration:

src/agents/memory-search.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,37 @@ describe("memory search config", () => {
5353
expect(resolved?.query.maxResults).toBe(8);
5454
expect(resolved?.query.minScore).toBe(0.2);
5555
});
56+
57+
it("merges remote defaults with agent overrides", () => {
58+
const cfg = {
59+
agents: {
60+
defaults: {
61+
memorySearch: {
62+
remote: {
63+
baseUrl: "https://default.example/v1",
64+
apiKey: "default-key",
65+
headers: { "X-Default": "on" },
66+
},
67+
},
68+
},
69+
list: [
70+
{
71+
id: "main",
72+
default: true,
73+
memorySearch: {
74+
remote: {
75+
baseUrl: "https://agent.example/v1",
76+
},
77+
},
78+
},
79+
],
80+
},
81+
};
82+
const resolved = resolveMemorySearchConfig(cfg, "main");
83+
expect(resolved?.remote).toEqual({
84+
baseUrl: "https://agent.example/v1",
85+
apiKey: "default-key",
86+
headers: { "X-Default": "on" },
87+
});
88+
});
5689
});

src/agents/memory-search.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,14 @@ function mergeConfig(
6565
): ResolvedMemorySearchConfig {
6666
const enabled = overrides?.enabled ?? defaults?.enabled ?? true;
6767
const provider = overrides?.provider ?? defaults?.provider ?? "openai";
68-
const remote = overrides?.remote ?? defaults?.remote;
68+
const hasRemote = Boolean(defaults?.remote || overrides?.remote);
69+
const remote = hasRemote
70+
? {
71+
baseUrl: overrides?.remote?.baseUrl ?? defaults?.remote?.baseUrl,
72+
apiKey: overrides?.remote?.apiKey ?? defaults?.remote?.apiKey,
73+
headers: overrides?.remote?.headers ?? defaults?.remote?.headers,
74+
}
75+
: undefined;
6976
const fallback = overrides?.fallback ?? defaults?.fallback ?? "openai";
7077
const model = overrides?.model ?? defaults?.model ?? DEFAULT_MODEL;
7178
const local = {

src/config/schema.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ const FIELD_LABELS: Record<string, string> = {
120120
"agents.defaults.memorySearch.provider": "Memory Search Provider",
121121
"agents.defaults.memorySearch.remote.baseUrl": "Remote Embedding Base URL",
122122
"agents.defaults.memorySearch.remote.apiKey": "Remote Embedding API Key",
123+
"agents.defaults.memorySearch.remote.headers": "Remote Embedding Headers",
123124
"agents.defaults.memorySearch.model": "Memory Search Model",
124125
"agents.defaults.memorySearch.fallback": "Memory Search Fallback",
125126
"agents.defaults.memorySearch.local.modelPath": "Local Embedding Model Path",
@@ -242,6 +243,8 @@ const FIELD_HELP: Record<string, string> = {
242243
"Custom OpenAI-compatible base URL (e.g. for Gemini/OpenRouter proxies).",
243244
"agents.defaults.memorySearch.remote.apiKey":
244245
"Custom API key for the remote embedding provider.",
246+
"agents.defaults.memorySearch.remote.headers":
247+
"Extra headers for remote embeddings (merged; remote overrides OpenAI headers).",
245248
"agents.defaults.memorySearch.local.modelPath":
246249
"Local GGUF model path or hf: URI (node-llama-cpp).",
247250
"agents.defaults.memorySearch.fallback":

src/memory/embeddings.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("../agents/model-auth.js", () => ({
4+
resolveApiKeyForProvider: vi.fn(),
5+
}));
6+
7+
const createFetchMock = () =>
8+
vi.fn(async () => ({
9+
ok: true,
10+
status: 200,
11+
json: async () => ({ data: [{ embedding: [1, 2, 3] }] }),
12+
})) as unknown as typeof fetch;
13+
14+
describe("embedding provider remote overrides", () => {
15+
afterEach(() => {
16+
vi.resetAllMocks();
17+
vi.unstubAllGlobals();
18+
});
19+
20+
it("uses remote baseUrl/apiKey and merges headers", async () => {
21+
const fetchMock = createFetchMock();
22+
vi.stubGlobal("fetch", fetchMock);
23+
24+
const { createEmbeddingProvider } = await import("./embeddings.js");
25+
const authModule = await import("../agents/model-auth.js");
26+
vi.mocked(authModule.resolveApiKeyForProvider).mockResolvedValue({
27+
apiKey: "provider-key",
28+
});
29+
30+
const cfg = {
31+
models: {
32+
providers: {
33+
openai: {
34+
baseUrl: "https://provider.example/v1",
35+
headers: {
36+
"X-Provider": "p",
37+
"X-Shared": "provider",
38+
},
39+
},
40+
},
41+
},
42+
};
43+
44+
const result = await createEmbeddingProvider({
45+
config: cfg as never,
46+
provider: "openai",
47+
remote: {
48+
baseUrl: "https://remote.example/v1",
49+
apiKey: " remote-key ",
50+
headers: {
51+
"X-Shared": "remote",
52+
"X-Remote": "r",
53+
},
54+
},
55+
model: "text-embedding-3-small",
56+
fallback: "openai",
57+
});
58+
59+
await result.provider.embedQuery("hello");
60+
61+
expect(authModule.resolveApiKeyForProvider).not.toHaveBeenCalled();
62+
const [url, init] = fetchMock.mock.calls[0] ?? [];
63+
expect(url).toBe("https://remote.example/v1/embeddings");
64+
const headers = (init?.headers ?? {}) as Record<string, string>;
65+
expect(headers.Authorization).toBe("Bearer remote-key");
66+
expect(headers["Content-Type"]).toBe("application/json");
67+
expect(headers["X-Provider"]).toBe("p");
68+
expect(headers["X-Shared"]).toBe("remote");
69+
expect(headers["X-Remote"]).toBe("r");
70+
});
71+
72+
it("falls back to resolved api key when remote apiKey is blank", async () => {
73+
const fetchMock = createFetchMock();
74+
vi.stubGlobal("fetch", fetchMock);
75+
76+
const { createEmbeddingProvider } = await import("./embeddings.js");
77+
const authModule = await import("../agents/model-auth.js");
78+
vi.mocked(authModule.resolveApiKeyForProvider).mockResolvedValue({
79+
apiKey: "provider-key",
80+
});
81+
82+
const cfg = {
83+
models: {
84+
providers: {
85+
openai: {
86+
baseUrl: "https://provider.example/v1",
87+
},
88+
},
89+
},
90+
};
91+
92+
const result = await createEmbeddingProvider({
93+
config: cfg as never,
94+
provider: "openai",
95+
remote: {
96+
baseUrl: "https://remote.example/v1",
97+
apiKey: " ",
98+
},
99+
model: "text-embedding-3-small",
100+
fallback: "openai",
101+
});
102+
103+
await result.provider.embedQuery("hello");
104+
105+
expect(authModule.resolveApiKeyForProvider).toHaveBeenCalledTimes(1);
106+
const headers =
107+
(fetchMock.mock.calls[0]?.[1]?.headers as Record<string, string>) ?? {};
108+
expect(headers.Authorization).toBe("Bearer provider-key");
109+
});
110+
});

src/memory/embeddings.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,12 @@ function normalizeOpenAiModel(model: string): string {
4747
async function createOpenAiEmbeddingProvider(
4848
options: EmbeddingProviderOptions,
4949
): Promise<EmbeddingProvider> {
50-
const remote = options.config.agents?.defaults?.memorySearch?.remote;
50+
const remote = options.remote;
51+
const remoteApiKey = remote?.apiKey?.trim();
52+
const remoteBaseUrl = remote?.baseUrl?.trim();
5153

52-
const { apiKey } = remote?.apiKey
53-
? { apiKey: remote.apiKey }
54+
const { apiKey } = remoteApiKey
55+
? { apiKey: remoteApiKey }
5456
: await resolveApiKeyForProvider({
5557
provider: "openai",
5658
cfg: options.config,
@@ -59,11 +61,13 @@ async function createOpenAiEmbeddingProvider(
5961

6062
const providerConfig = options.config.models?.providers?.openai;
6163
const baseUrl =
62-
remote?.baseUrl?.trim() ||
63-
providerConfig?.baseUrl?.trim() ||
64-
DEFAULT_OPENAI_BASE_URL;
64+
remoteBaseUrl || providerConfig?.baseUrl?.trim() || DEFAULT_OPENAI_BASE_URL;
6565
const url = `${baseUrl.replace(/\/$/, "")}/embeddings`;
66-
const headerOverrides = remote?.headers ?? providerConfig?.headers ?? {};
66+
const headerOverrides = Object.assign(
67+
{},
68+
providerConfig?.headers,
69+
remote?.headers,
70+
);
6771
const headers: Record<string, string> = {
6872
"Content-Type": "application/json",
6973
Authorization: `Bearer ${apiKey}`,

0 commit comments

Comments
 (0)