fix(embeddings): reject empty vectors from compatible providers (#128480)

* fix(embeddings): reject empty provider vectors

* chore(embeddings): tighten assertion safety baseline
This commit is contained in:
Peter Steinberger
2026-08-23 21:23:01 -07:00
committed by GitHub
parent a6a765cd44
commit 8635794d4d
3 changed files with 32 additions and 27 deletions
+1 -1
View File
@@ -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
@@ -77,7 +77,7 @@ async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknow
async function startEmbeddingServer(params?: {
token?: string;
respond?: (request: CapturedRequest) => FixtureResponse | Record<string, unknown>;
respond?: (request: CapturedRequest) => FixtureResponse | Record<string, unknown> | 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",
);
});
@@ -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<unknown> {
return await readProviderJsonResponse(response, "openai-compatible embeddings failed");
}
async function readEmbeddingErrorBodySnippet(response: Response): Promise<string | undefined> {
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 {