Skip to content

Commit 77e6e4c

Browse files
committed
refactor: move memory embeddings into provider plugins
1 parent 7e9ff0f commit 77e6e4c

94 files changed

Lines changed: 1035 additions & 7121 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/plugins/sdk-migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ Current bundled provider examples:
318318
| `plugin-sdk/memory-core` | Bundled memory-core helpers | Memory manager/config/file/CLI helper surface |
319319
| `plugin-sdk/memory-core-engine-runtime` | Memory engine runtime facade | Memory index/search runtime facade |
320320
| `plugin-sdk/memory-core-host-engine-foundation` | Memory host foundation engine | Memory host foundation engine exports |
321-
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding engine | Memory host embedding engine exports |
321+
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding engine | Memory embedding contracts, registry access, local provider, and generic batch/remote helpers; concrete remote providers live in their owning plugins |
322322
| `plugin-sdk/memory-core-host-engine-qmd` | Memory host QMD engine | Memory host QMD engine exports |
323323
| `plugin-sdk/memory-core-host-engine-storage` | Memory host storage engine | Memory host storage engine exports |
324324
| `plugin-sdk/memory-core-host-multimodal` | Memory host multimodal helpers | Memory host multimodal helpers |

docs/plugins/sdk-overview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ explicitly promotes one as public.
264264
| `plugin-sdk/memory-core` | Bundled memory-core helper surface for manager/config/file/CLI helpers |
265265
| `plugin-sdk/memory-core-engine-runtime` | Memory index/search runtime facade |
266266
| `plugin-sdk/memory-core-host-engine-foundation` | Memory host foundation engine exports |
267-
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding engine exports |
267+
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding contracts, registry access, local provider, and generic batch/remote helpers |
268268
| `plugin-sdk/memory-core-host-engine-qmd` | Memory host QMD engine exports |
269269
| `plugin-sdk/memory-core-host-engine-storage` | Memory host storage engine exports |
270270
| `plugin-sdk/memory-core-host-multimodal` | Memory host multimodal helpers |

src/memory-host-sdk/host/embeddings-bedrock.ts renamed to extensions/amazon-bedrock/embedding-provider.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
2-
import { sanitizeAndNormalizeEmbedding } from "./embedding-vectors.js";
3-
import { debugEmbeddingsLog } from "./embeddings-debug.js";
4-
import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.types.js";
1+
import {
2+
debugEmbeddingsLog,
3+
sanitizeAndNormalizeEmbedding,
4+
type MemoryEmbeddingProvider,
5+
type MemoryEmbeddingProviderCreateOptions,
6+
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
7+
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
58

69
// ---------------------------------------------------------------------------
710
// Types & constants
@@ -254,8 +257,8 @@ function parseCohereBatch(family: Family, raw: string): number[][] {
254257
// ---------------------------------------------------------------------------
255258

256259
export async function createBedrockEmbeddingProvider(
257-
options: EmbeddingProviderOptions,
258-
): Promise<{ provider: EmbeddingProvider; client: BedrockEmbeddingClient }> {
260+
options: MemoryEmbeddingProviderCreateOptions,
261+
): Promise<{ provider: MemoryEmbeddingProvider; client: BedrockEmbeddingClient }> {
259262
const client = resolveBedrockEmbeddingClient(options);
260263
const { BedrockRuntimeClient, InvokeModelCommand } = await loadSdk();
261264
const sdk = new BedrockRuntimeClient({ region: client.region });
@@ -333,7 +336,7 @@ export async function createBedrockEmbeddingProvider(
333336
// ---------------------------------------------------------------------------
334337

335338
export function resolveBedrockEmbeddingClient(
336-
options: EmbeddingProviderOptions,
339+
options: MemoryEmbeddingProviderCreateOptions,
337340
): BedrockEmbeddingClient {
338341
const model = normalizeBedrockEmbeddingModel(options.model);
339342
const spec = resolveSpec(model);
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {
2+
isMissingEmbeddingApiKeyError,
3+
type MemoryEmbeddingProviderAdapter,
4+
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
5+
import {
6+
createBedrockEmbeddingProvider,
7+
DEFAULT_BEDROCK_EMBEDDING_MODEL,
8+
} from "./embedding-provider.js";
9+
10+
export const bedrockMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = {
11+
id: "bedrock",
12+
defaultModel: DEFAULT_BEDROCK_EMBEDDING_MODEL,
13+
transport: "remote",
14+
authProviderId: "amazon-bedrock",
15+
autoSelectPriority: 60,
16+
allowExplicitWhenConfiguredAuto: true,
17+
shouldContinueAutoSelection: isMissingEmbeddingApiKeyError,
18+
create: async (options) => {
19+
const { provider, client } = await createBedrockEmbeddingProvider({
20+
...options,
21+
provider: "bedrock",
22+
fallback: "none",
23+
});
24+
return {
25+
provider,
26+
runtime: {
27+
id: "bedrock",
28+
cacheKeyData: {
29+
provider: "bedrock",
30+
region: client.region,
31+
model: client.model,
32+
dimensions: client.dimensions,
33+
},
34+
},
35+
};
36+
},
37+
};

extensions/amazon-bedrock/openclaw.plugin.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
"id": "amazon-bedrock",
33
"enabledByDefault": true,
44
"providers": ["amazon-bedrock"],
5+
"contracts": {
6+
"memoryEmbeddingProviders": ["bedrock"]
7+
},
58
"configSchema": {
69
"type": "object",
710
"additionalProperties": false,

extensions/amazon-bedrock/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
"description": "OpenClaw Amazon Bedrock provider plugin",
66
"type": "module",
77
"dependencies": {
8-
"@aws-sdk/client-bedrock": "3.1028.0"
8+
"@aws-sdk/client-bedrock": "3.1028.0",
9+
"@aws-sdk/client-bedrock-runtime": "3.1028.0",
10+
"@aws-sdk/credential-provider-node": "3.972.30"
911
},
1012
"devDependencies": {
1113
"@openclaw/plugin-sdk": "workspace:*"

extensions/amazon-bedrock/register.sync.runtime.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
resolveBedrockConfigApiKey,
1515
resolveImplicitBedrockProvider,
1616
} from "./api.js";
17+
import { bedrockMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
1718

1819
type GuardrailConfig = {
1920
guardrailIdentifier: string;
@@ -78,6 +79,8 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
7879
const pluginConfig = (api.pluginConfig ?? {}) as AmazonBedrockPluginConfig;
7980
const guardrail = pluginConfig.guardrail;
8081

82+
api.registerMemoryEmbeddingProvider(bedrockMemoryEmbeddingProviderAdapter);
83+
8184
const baseWrapStreamFn = ({ modelId, streamFn }: { modelId: string; streamFn?: StreamFn }) =>
8285
isAnthropicBedrockModel(modelId) ? streamFn : createBedrockNoCacheWrapper(streamFn);
8386

extensions/github-copilot/embeddings.test.ts

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ const resolveFirstGithubTokenMock = vi.hoisted(() => vi.fn());
44
const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn());
55
const resolveConfiguredSecretInputStringMock = vi.hoisted(() => vi.fn());
66
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
7-
const createGitHubCopilotEmbeddingProviderMock = vi.hoisted(() => vi.fn());
87

98
vi.mock("./auth.js", () => ({
109
resolveFirstGithubToken: resolveFirstGithubTokenMock,
@@ -19,10 +18,6 @@ vi.mock("openclaw/plugin-sdk/github-copilot-token", () => ({
1918
resolveCopilotApiToken: resolveCopilotApiTokenMock,
2019
}));
2120

22-
vi.mock("openclaw/plugin-sdk/memory-core-host-engine-embeddings", () => ({
23-
createGitHubCopilotEmbeddingProvider: createGitHubCopilotEmbeddingProviderMock,
24-
}));
25-
2621
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
2722
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
2823
}));
@@ -73,23 +68,13 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
7368
source: "test",
7469
baseUrl: TEST_BASE_URL,
7570
});
76-
createGitHubCopilotEmbeddingProviderMock.mockImplementation(async (client) => ({
77-
provider: {
78-
id: "github-copilot",
79-
model: client.model,
80-
embedQuery: async () => [0.1, 0.2, 0.3],
81-
embedBatch: async (texts: string[]) => texts.map(() => [0.1, 0.2, 0.3]),
82-
},
83-
client,
84-
}));
8571
});
8672

8773
afterEach(() => {
8874
vi.restoreAllMocks();
8975
resolveConfiguredSecretInputStringMock.mockReset();
9076
resolveFirstGithubTokenMock.mockReset();
9177
resolveCopilotApiTokenMock.mockReset();
92-
createGitHubCopilotEmbeddingProviderMock.mockReset();
9378
fetchWithSsrFGuardMock.mockReset();
9479
});
9580

@@ -113,12 +98,8 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
11398
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
11499

115100
expect(result.provider?.model).toBe("text-embedding-3-small");
116-
expect(createGitHubCopilotEmbeddingProviderMock).toHaveBeenCalledWith(
117-
expect.objectContaining({
118-
baseUrl: TEST_BASE_URL,
119-
githubToken: "gh_test_token_123",
120-
model: "text-embedding-3-small",
121-
}),
101+
expect(resolveCopilotApiTokenMock).toHaveBeenCalledWith(
102+
expect.objectContaining({ githubToken: "gh_test_token_123" }),
122103
);
123104
});
124105

@@ -217,14 +198,12 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => {
217198
} as never);
218199

219200
expect(resolveFirstGithubTokenMock).toHaveBeenCalled();
220-
expect(createGitHubCopilotEmbeddingProviderMock).toHaveBeenCalledWith({
221-
baseUrl: "https://proxy.example/v1",
222-
env: process.env,
223-
fetchImpl: fetch,
224-
githubToken: "gh_remote_token",
225-
headers: { "X-Proxy-Token": "proxy" },
226-
model: "text-embedding-3-small",
227-
});
201+
expect(resolveCopilotApiTokenMock).toHaveBeenCalledWith(
202+
expect.objectContaining({
203+
env: process.env,
204+
githubToken: "gh_remote_token",
205+
}),
206+
);
228207

229208
const discoveryCall = fetchWithSsrFGuardMock.mock.calls[0]?.[0] as {
230209
init: { headers: Record<string, string> };

extensions/github-copilot/embeddings.ts

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import {
44
resolveCopilotApiToken,
55
} from "openclaw/plugin-sdk/github-copilot-token";
66
import {
7-
createGitHubCopilotEmbeddingProvider,
7+
buildRemoteBaseUrlPolicy,
8+
sanitizeAndNormalizeEmbedding,
9+
withRemoteHttpResponse,
10+
type MemoryEmbeddingProvider,
811
type MemoryEmbeddingProviderAdapter,
912
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
1013
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -44,6 +47,15 @@ type CopilotModelEntry = {
4447
supported_endpoints?: unknown;
4548
};
4649

50+
type GitHubCopilotEmbeddingClient = {
51+
githubToken: string;
52+
model: string;
53+
baseUrl?: string;
54+
headers?: Record<string, string>;
55+
env?: NodeJS.ProcessEnv;
56+
fetchImpl?: typeof fetch;
57+
};
58+
4759
function isCopilotSetupError(err: unknown): boolean {
4860
if (!(err instanceof Error)) {
4961
return false;
@@ -147,9 +159,126 @@ function pickBestModel(available: string[], userModel?: string): string {
147159
throw new Error("No embedding models available from GitHub Copilot");
148160
}
149161

162+
function parseGitHubCopilotEmbeddingPayload(payload: unknown, expectedCount: number): number[][] {
163+
if (!payload || typeof payload !== "object") {
164+
throw new Error("GitHub Copilot embeddings response missing data[]");
165+
}
166+
const data = (payload as { data?: unknown }).data;
167+
if (!Array.isArray(data)) {
168+
throw new Error("GitHub Copilot embeddings response missing data[]");
169+
}
170+
171+
const vectors = Array.from<number[] | undefined>({ length: expectedCount });
172+
for (const entry of data) {
173+
if (!entry || typeof entry !== "object") {
174+
throw new Error("GitHub Copilot embeddings response contains an invalid entry");
175+
}
176+
const indexValue = (entry as { index?: unknown }).index;
177+
const embedding = (entry as { embedding?: unknown }).embedding;
178+
const index = typeof indexValue === "number" ? indexValue : Number.NaN;
179+
if (!Number.isInteger(index)) {
180+
throw new Error("GitHub Copilot embeddings response contains an invalid index");
181+
}
182+
if (index < 0 || index >= expectedCount) {
183+
throw new Error("GitHub Copilot embeddings response contains an out-of-range index");
184+
}
185+
if (vectors[index] !== undefined) {
186+
throw new Error("GitHub Copilot embeddings response contains duplicate indexes");
187+
}
188+
if (!Array.isArray(embedding) || !embedding.every((value) => typeof value === "number")) {
189+
throw new Error("GitHub Copilot embeddings response contains an invalid embedding");
190+
}
191+
vectors[index] = sanitizeAndNormalizeEmbedding(embedding);
192+
}
193+
194+
for (let index = 0; index < expectedCount; index += 1) {
195+
if (vectors[index] === undefined) {
196+
throw new Error("GitHub Copilot embeddings response missing vectors for some inputs");
197+
}
198+
}
199+
return vectors as number[][];
200+
}
201+
202+
async function resolveGitHubCopilotEmbeddingSession(client: GitHubCopilotEmbeddingClient): Promise<{
203+
baseUrl: string;
204+
headers: Record<string, string>;
205+
}> {
206+
const token = await resolveCopilotApiToken({
207+
githubToken: client.githubToken,
208+
env: client.env,
209+
fetchImpl: client.fetchImpl,
210+
});
211+
const baseUrl = client.baseUrl?.trim() || token.baseUrl || DEFAULT_COPILOT_API_BASE_URL;
212+
return {
213+
baseUrl,
214+
headers: {
215+
...COPILOT_HEADERS_STATIC,
216+
...client.headers,
217+
Authorization: `Bearer ${token.token}`,
218+
},
219+
};
220+
}
221+
222+
async function createGitHubCopilotEmbeddingProvider(
223+
client: GitHubCopilotEmbeddingClient,
224+
): Promise<{ provider: MemoryEmbeddingProvider; client: GitHubCopilotEmbeddingClient }> {
225+
const initialSession = await resolveGitHubCopilotEmbeddingSession(client);
226+
227+
const embed = async (input: string[]): Promise<number[][]> => {
228+
if (input.length === 0) {
229+
return [];
230+
}
231+
232+
const session = await resolveGitHubCopilotEmbeddingSession(client);
233+
const url = `${session.baseUrl.replace(/\/$/, "")}/embeddings`;
234+
return await withRemoteHttpResponse({
235+
url,
236+
fetchImpl: client.fetchImpl,
237+
ssrfPolicy: buildRemoteBaseUrlPolicy(session.baseUrl),
238+
init: {
239+
method: "POST",
240+
headers: session.headers,
241+
body: JSON.stringify({ model: client.model, input }),
242+
},
243+
onResponse: async (response) => {
244+
if (!response.ok) {
245+
throw new Error(
246+
`GitHub Copilot embeddings HTTP ${response.status}: ${await response.text()}`,
247+
);
248+
}
249+
250+
let payload: unknown;
251+
try {
252+
payload = await response.json();
253+
} catch {
254+
throw new Error("GitHub Copilot embeddings returned invalid JSON");
255+
}
256+
return parseGitHubCopilotEmbeddingPayload(payload, input.length);
257+
},
258+
});
259+
};
260+
261+
return {
262+
provider: {
263+
id: COPILOT_EMBEDDING_PROVIDER_ID,
264+
model: client.model,
265+
embedQuery: async (text) => {
266+
const [vector] = await embed([text]);
267+
return vector ?? [];
268+
},
269+
embedBatch: embed,
270+
},
271+
client: {
272+
...client,
273+
baseUrl: initialSession.baseUrl,
274+
},
275+
};
276+
}
277+
150278
export const githubCopilotMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = {
151279
id: COPILOT_EMBEDDING_PROVIDER_ID,
152280
transport: "remote",
281+
authProviderId: COPILOT_EMBEDDING_PROVIDER_ID,
153282
autoSelectPriority: 15,
154283
allowExplicitWhenConfiguredAuto: true,
155284
shouldContinueAutoSelection: (err: unknown) => isCopilotSetupError(err),

packages/memory-host-sdk/src/host/batch-gemini.ts renamed to extensions/google/embedding-batch.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1+
import crypto from "node:crypto";
12
import {
23
buildEmbeddingBatchGroupOptions,
34
runEmbeddingBatchGroups,
45
type EmbeddingBatchExecutionParams,
5-
} from "./batch-runner.js";
6-
import { buildBatchHeaders, normalizeBatchBaseUrl } from "./batch-utils.js";
7-
import { sanitizeAndNormalizeEmbedding } from "./embedding-vectors.js";
8-
import { debugEmbeddingsLog } from "./embeddings-debug.js";
9-
import type { GeminiEmbeddingClient, GeminiTextEmbeddingRequest } from "./embeddings-gemini.js";
10-
import { hashText } from "./internal.js";
11-
import { withRemoteHttpResponse } from "./remote-http.js";
6+
buildBatchHeaders,
7+
debugEmbeddingsLog,
8+
normalizeBatchBaseUrl,
9+
sanitizeAndNormalizeEmbedding,
10+
withRemoteHttpResponse,
11+
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
12+
import type { GeminiEmbeddingClient, GeminiTextEmbeddingRequest } from "./embedding-provider.js";
1213

1314
export type GeminiBatchRequest = {
1415
custom_id: string;
@@ -40,6 +41,10 @@ export type GeminiBatchOutputLine = {
4041
};
4142

4243
const GEMINI_BATCH_MAX_REQUESTS = 50000;
44+
function hashText(text: string): string {
45+
return crypto.createHash("sha256").update(text).digest("hex");
46+
}
47+
4348
function getGeminiUploadUrl(baseUrl: string): string {
4449
if (baseUrl.includes("/v1beta")) {
4550
return baseUrl.replace(/\/v1beta\/?$/, "/upload/v1beta");

0 commit comments

Comments
 (0)