From 8635794d4d2a2faebeb2a0824628b683c2cdf768 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 23 Aug 2026 21:23:01 -0700 Subject: [PATCH] fix(embeddings): reject empty vectors from compatible providers (#128480) * fix(embeddings): reject empty provider vectors * chore(embeddings): tighten assertion safety baseline --- config/assertion-safety-baseline.txt | 2 +- ...enai-compatible-embedding-provider.test.ts | 30 +++++++++++++------ .../openai-compatible-embedding-provider.ts | 27 +++++++---------- 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 5fe1662e2550..04915dec1626 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -3624,7 +3624,7 @@ src/plugins/native-module-require.ts 3 src/plugins/official-external-plugin-catalog-snapshot-store.ts 4 src/plugins/official-external-plugin-targets.ts 1 src/plugins/official-external-provider-endpoints.ts 1 -src/plugins/openai-compatible-embedding-provider.ts 3 +src/plugins/openai-compatible-embedding-provider.ts 2 src/plugins/plugin-cache-primitives.ts 2 src/plugins/plugin-command-dispatch-contract.ts 1 src/plugins/plugin-command-execution.ts 3 diff --git a/src/plugins/openai-compatible-embedding-provider.test.ts b/src/plugins/openai-compatible-embedding-provider.test.ts index 2093d4518a28..e3dcf7260dcf 100644 --- a/src/plugins/openai-compatible-embedding-provider.test.ts +++ b/src/plugins/openai-compatible-embedding-provider.test.ts @@ -77,7 +77,7 @@ async function readJsonBody(req: IncomingMessage): Promise FixtureResponse | Record; + respond?: (request: CapturedRequest) => FixtureResponse | Record | null; status?: number; }): Promise<{ baseUrl: string; requests: CapturedRequest[] }> { const requests: CapturedRequest[] = []; @@ -102,11 +102,13 @@ async function startEmbeddingServer(params?: { res.writeHead(params?.status ?? 200, { "content-type": "application/json" }); res.end( JSON.stringify( - params?.respond?.(captured) ?? { - object: "list", - data: [{ object: "embedding", embedding: [0.1, 0.2, 0.3], index: 0 }], - model: body.model, - }, + params?.respond + ? params.respond(captured) + : { + object: "list", + data: [{ object: "embedding", embedding: [0.1, 0.2, 0.3], index: 0 }], + model: body.model, + }, ), ); } catch (error) { @@ -908,16 +910,26 @@ describe("openai-compatible generic embedding provider", () => { ).rejects.toThrow("missing model"); }); - it("keeps remote parser failures behind the provider-specific error prefix", async () => { - const server = await startEmbeddingServer({ respond: () => ({ data: [] }) }); + it.each([ + { name: "missing vectors", input: "hello", response: { data: [] } }, + { name: "empty direct vector", input: "hello", response: { data: [{ embedding: [] }] } }, + { + name: "empty batch vector", + input: ["hello", "world"], + response: { data: [{ embedding: [1] }, { embedding: [] }] }, + }, + { 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 }); const { provider } = await createOpenAICompatibleEmbeddingProvider( createOptions({ model: "text-embedding-bge-m3", remote: { baseUrl: server.baseUrl }, }), ); + const request = typeof input === "string" ? provider.embed(input) : provider.embedBatch(input); - await expect(provider.embed("hello")).rejects.toThrow( + await expect(request).rejects.toThrow( "openai-compatible embeddings failed: malformed JSON response", ); }); diff --git a/src/plugins/openai-compatible-embedding-provider.ts b/src/plugins/openai-compatible-embedding-provider.ts index 73a48e23ed22..31ce2d6be19f 100644 --- a/src/plugins/openai-compatible-embedding-provider.ts +++ b/src/plugins/openai-compatible-embedding-provider.ts @@ -3,7 +3,7 @@ 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 { readProviderJsonResponse } from "../agents/provider-http-errors.js"; +import { readProviderJsonArrayFieldResponse } from "../agents/provider-http-errors.js"; import type { AcquireConfiguredProviderLocalService, ConfiguredProviderLocalServiceTarget, @@ -44,10 +44,6 @@ type OpenAICompatibleEmbeddingClient = { acquireLocalService?: AcquireConfiguredProviderLocalService; }; -type OpenAICompatibleEmbeddingResponse = { - data?: unknown; -}; - type ConfiguredEmbeddingProvider = { api?: string; baseUrl?: string; @@ -252,7 +248,7 @@ function malformedEmbeddingResponse(): Error { } function readEmbeddingVector(value: unknown): number[] { - if (!Array.isArray(value)) { + if (!Array.isArray(value) || value.length === 0) { throw malformedEmbeddingResponse(); } for (const entry of value) { @@ -263,14 +259,11 @@ function readEmbeddingVector(value: unknown): number[] { return value; } -function readEmbeddingVectors( - payload: OpenAICompatibleEmbeddingResponse, - expectedCount: number, -): number[][] { - if (!Array.isArray(payload.data) || payload.data.length !== expectedCount) { +function readEmbeddingVectors(data: unknown[], expectedCount: number): number[][] { + if (data.length !== expectedCount) { throw malformedEmbeddingResponse(); } - return payload.data.map((entry) => { + return data.map((entry) => { const record = asRecord(entry); if (!record) { throw malformedEmbeddingResponse(); @@ -279,10 +272,6 @@ function readEmbeddingVectors( }); } -async function readJsonResponse(response: Response): Promise { - return await readProviderJsonResponse(response, "openai-compatible embeddings failed"); -} - async function readEmbeddingErrorBodySnippet(response: Response): Promise { if (!response.body || response.bodyUsed) { return undefined; @@ -342,7 +331,11 @@ async function postEmbeddingRequest(params: { throw await createEmbeddingHttpError(response); } return readEmbeddingVectors( - (await readJsonResponse(response)) as OpenAICompatibleEmbeddingResponse, + await readProviderJsonArrayFieldResponse( + response, + "openai-compatible embeddings failed", + "data", + ), input.length, ); } finally {