diff --git a/packages/memory-host-sdk/src/host/embedding-vectors.ts b/packages/memory-host-sdk/src/host/embedding-vectors.ts index 8e9ebbc51bb4..448f22fa53bc 100644 --- a/packages/memory-host-sdk/src/host/embedding-vectors.ts +++ b/packages/memory-host-sdk/src/host/embedding-vectors.ts @@ -1,4 +1,50 @@ // Vector normalization helpers used before embedding similarity search. +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; + +/** Validate provider embeddings and restore their original request order. */ +export function readEmbeddingVectors( + data: unknown, + expectedCount: number | undefined, + errorPrefix: string, +): number[][] { + const malformedResponse = () => new Error(`${errorPrefix}: malformed JSON response`); + if (!Array.isArray(data) || (expectedCount !== undefined && data.length !== expectedCount)) { + throw malformedResponse(); + } + const vectors: number[][] = []; + let indexed: boolean | undefined; + for (let position = 0; position < data.length; position += 1) { + const entry = asOptionalRecord(data[position]); + const embedding = entry?.embedding; + const usesIndex = entry?.index !== undefined; + if ( + !entry || + !Array.isArray(embedding) || + embedding.length === 0 || + (indexed !== undefined && indexed !== usesIndex) + ) { + throw malformedResponse(); + } + for (const coordinate of embedding) { + if (typeof coordinate !== "number" || !Number.isFinite(coordinate)) { + throw malformedResponse(); + } + } + indexed = usesIndex; + const index = usesIndex ? entry.index : position; + if ( + typeof index !== "number" || + !Number.isInteger(index) || + index < 0 || + index >= data.length || + vectors[index] !== undefined + ) { + throw malformedResponse(); + } + vectors[index] = embedding; + } + return vectors; +} /** Replace invalid coordinates and L2-normalize non-empty vectors. */ export function sanitizeAndNormalizeEmbedding(vec: number[]): number[] { diff --git a/packages/memory-host-sdk/src/host/embeddings-remote-fetch.test.ts b/packages/memory-host-sdk/src/host/embeddings-remote-fetch.test.ts index 77411793446c..34df975cffe7 100644 --- a/packages/memory-host-sdk/src/host/embeddings-remote-fetch.test.ts +++ b/packages/memory-host-sdk/src/host/embeddings-remote-fetch.test.ts @@ -70,6 +70,54 @@ describe("fetchRemoteEmbeddingVectors", () => { expect(requirePostJsonParams().signal).toBe(controller.signal); }); + it("returns indexed response vectors in their original request order", async () => { + postJsonMock.mockImplementationOnce(async (params) => + params.parse({ + data: [ + { index: 1, embedding: [0.2] }, + { index: 0, embedding: [0.1] }, + ], + }), + ); + + await expect( + fetchRemoteEmbeddingVectors({ + url: "https://memory.example/v1/embeddings", + headers: {}, + body: { input: ["first", "second"] }, + errorPrefix: "embedding fetch failed", + }), + ).resolves.toEqual([[0.1], [0.2]]); + }); + + it.each([ + { name: "mixed indexed and positional", indexes: [0, undefined] }, + { name: "duplicate", indexes: [0, 0] }, + { name: "out-of-range", indexes: [0, 2] }, + { name: "negative", indexes: [0, -1] }, + { name: "fractional", indexes: [0, 0.5] }, + { name: "non-numeric", indexes: [0, "1"] }, + { name: "null", indexes: [0, null] }, + ])("rejects $name embedding indexes", async ({ indexes }) => { + postJsonMock.mockImplementationOnce(async (params) => + params.parse({ + data: indexes.map((index) => ({ + ...(index === undefined ? {} : { index }), + embedding: [0.1], + })), + }), + ); + + await expect( + fetchRemoteEmbeddingVectors({ + url: "https://memory.example/v1/embeddings", + headers: {}, + body: { input: ["first", "second"] }, + errorPrefix: "embedding fetch failed", + }), + ).rejects.toThrow("embedding fetch failed: malformed JSON response"); + }); + it("throws a status-rich error on non-ok responses", async () => { postJsonMock.mockRejectedValueOnce(new Error("embedding fetch failed: 403 forbidden")); @@ -157,9 +205,33 @@ describe("fetchRemoteEmbeddingVectors", () => { ).resolves.toEqual([]); }); - it("rejects wrong nested embedding vector types", async () => { + it("accepts response-sized vectors when request input is not an array", async () => { + postJsonMock.mockImplementationOnce(async (params) => + params.parse({ + data: [ + { index: 1, embedding: [0.2] }, + { index: 0, embedding: [0.1] }, + ], + }), + ); + + await expect( + fetchRemoteEmbeddingVectors({ + url: "https://memory.example/v1/embeddings", + headers: {}, + body: { input: "query" }, + errorPrefix: "embedding fetch failed", + }), + ).resolves.toEqual([[0.1], [0.2]]); + }); + + it.each([ + { name: "non-numeric", embedding: [0.1, "bad"] }, + { name: "sparse", embedding: Object.assign(Array.of(), { 1: 0.1 }) }, + { name: "non-finite", embedding: [Number.POSITIVE_INFINITY] }, + ])("rejects $name embedding coordinates", async ({ embedding }) => { postJsonMock.mockImplementationOnce(async (params) => { - return await params.parse({ data: [{ embedding: [0.1, "bad"] }] }); + return await params.parse({ data: [{ embedding }] }); }); await expect( diff --git a/packages/memory-host-sdk/src/host/embeddings-remote-fetch.ts b/packages/memory-host-sdk/src/host/embeddings-remote-fetch.ts index d111dbbc4b8e..943a3c741816 100644 --- a/packages/memory-host-sdk/src/host/embeddings-remote-fetch.ts +++ b/packages/memory-host-sdk/src/host/embeddings-remote-fetch.ts @@ -1,35 +1,12 @@ // Memory Host SDK module implements embeddings remote fetch behavior. import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; +import { readEmbeddingVectors } from "./embedding-vectors.js"; import type { SsrFPolicy } from "./openclaw-runtime-network.js"; import { postJson } from "./post-json.js"; // Fetches and validates OpenAI-compatible embedding responses. -/** Build the common malformed embedding response error. */ -function malformedEmbeddingResponse(errorPrefix: string): Error { - return new Error(`${errorPrefix}: malformed JSON response`); -} - -/** Validate and return one finite embedding vector. */ -function readEmbeddingVector(value: unknown, errorPrefix: string): number[] { - if (!Array.isArray(value) || value.length === 0) { - throw malformedEmbeddingResponse(errorPrefix); - } - for (const entry of value) { - if (typeof entry !== "number" || !Number.isFinite(entry)) { - throw malformedEmbeddingResponse(errorPrefix); - } - } - return value; -} - -/** Resolve expected response count from the request body when input is an array. */ -function resolveExpectedEmbeddingCount(body: unknown): number | undefined { - const input = asOptionalRecord(body)?.input; - return Array.isArray(input) ? input.length : undefined; -} - -/** POST an embedding request and return validated vectors in provider response order. */ +/** POST an embedding request and return validated vectors in request order. */ export async function fetchRemoteEmbeddingVectors(params: { url: string; headers: Record; @@ -48,21 +25,12 @@ export async function fetchRemoteEmbeddingVectors(params: { body: params.body, errorPrefix: params.errorPrefix, parse: (payload) => { - const root = asOptionalRecord(payload); - if (!root || !Array.isArray(root.data)) { - throw malformedEmbeddingResponse(params.errorPrefix); - } - const expectedCount = resolveExpectedEmbeddingCount(params.body); - if (expectedCount !== undefined && root.data.length !== expectedCount) { - throw malformedEmbeddingResponse(params.errorPrefix); - } - return root.data.map((entry) => { - const record = asOptionalRecord(entry); - if (!record) { - throw malformedEmbeddingResponse(params.errorPrefix); - } - return readEmbeddingVector(record.embedding, params.errorPrefix); - }); + const input = asOptionalRecord(params.body)?.input; + return readEmbeddingVectors( + asOptionalRecord(payload)?.data, + Array.isArray(input) ? input.length : undefined, + params.errorPrefix, + ); }, }); } diff --git a/src/plugins/openai-compatible-embedding-provider.test.ts b/src/plugins/openai-compatible-embedding-provider.test.ts index e3dcf7260dcf..7c055a3d3647 100644 --- a/src/plugins/openai-compatible-embedding-provider.test.ts +++ b/src/plugins/openai-compatible-embedding-provider.test.ts @@ -910,6 +910,27 @@ describe("openai-compatible generic embedding provider", () => { ).rejects.toThrow("missing model"); }); + it.each([ + { + name: "out-of-order indexed", + data: [ + { index: 1, embedding: [0.2] }, + { index: 0, embedding: [0.1] }, + ], + }, + { + name: "fully positional compatible-provider", + data: [{ embedding: [0.1] }, { embedding: [0.2] }], + }, + ])("returns $name responses in original document order", async ({ data }) => { + const server = await startEmbeddingServer({ respond: () => ({ data }) }); + const { provider } = await createOpenAICompatibleEmbeddingProvider( + createOptions({ remote: { baseUrl: server.baseUrl } }), + ); + + await expect(provider.embedBatch(["first", "second"])).resolves.toEqual([[0.1], [0.2]]); + }); + it.each([ { name: "missing vectors", input: "hello", response: { data: [] } }, { name: "empty direct vector", input: "hello", response: { data: [{ embedding: [] }] } }, @@ -918,6 +939,21 @@ describe("openai-compatible generic embedding provider", () => { input: ["hello", "world"], response: { data: [{ embedding: [1] }, { embedding: [] }] }, }, + { + name: "mixed indexed and positional vectors", + input: ["hello", "world"], + response: { data: [{ index: 0, embedding: [1] }, { embedding: [2] }] }, + }, + { + name: "duplicate vector indexes", + input: ["hello", "world"], + response: { + data: [ + { index: 0, embedding: [1] }, + { index: 0, embedding: [2] }, + ], + }, + }, { name: "null response root", input: "hello", response: null }, ])("rejects malformed $name with the provider-specific error", async ({ input, response }) => { const server = await startEmbeddingServer({ respond: () => response }); diff --git a/src/plugins/openai-compatible-embedding-provider.ts b/src/plugins/openai-compatible-embedding-provider.ts index 31ce2d6be19f..1fc2dd19e977 100644 --- a/src/plugins/openai-compatible-embedding-provider.ts +++ b/src/plugins/openai-compatible-embedding-provider.ts @@ -1,8 +1,8 @@ // Builds OpenAI-compatible embedding provider entries for plugins. import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; -import { asOptionalRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { readEmbeddingVectors } from "../../packages/memory-host-sdk/src/host/embedding-vectors.js"; import { readProviderJsonArrayFieldResponse } from "../agents/provider-http-errors.js"; import type { AcquireConfiguredProviderLocalService, @@ -247,31 +247,6 @@ function malformedEmbeddingResponse(): Error { return new Error("openai-compatible embeddings failed: malformed JSON response"); } -function readEmbeddingVector(value: unknown): number[] { - if (!Array.isArray(value) || value.length === 0) { - throw malformedEmbeddingResponse(); - } - for (const entry of value) { - if (typeof entry !== "number" || !Number.isFinite(entry)) { - throw malformedEmbeddingResponse(); - } - } - return value; -} - -function readEmbeddingVectors(data: unknown[], expectedCount: number): number[][] { - if (data.length !== expectedCount) { - throw malformedEmbeddingResponse(); - } - return data.map((entry) => { - const record = asRecord(entry); - if (!record) { - throw malformedEmbeddingResponse(); - } - return readEmbeddingVector(record.embedding); - }); -} - async function readEmbeddingErrorBodySnippet(response: Response): Promise { if (!response.body || response.bodyUsed) { return undefined; @@ -337,6 +312,7 @@ async function postEmbeddingRequest(params: { "data", ), input.length, + "openai-compatible embeddings failed", ); } finally { await release();