fix(memory): reject malformed direct and batch embedding vectors (#117200)

Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
Peter Steinberger
2026-07-31 21:38:31 -07:00
committed by GitHub
parent 1b3572d08b
commit 618f3298c7
4 changed files with 69 additions and 3 deletions

View File

@@ -173,6 +173,39 @@ describe("applyEmbeddingBatchOutputLine", () => {
expect(byCustomId.get("req-1")).toEqual([0.1, 0.2]);
});
it.each([
{ name: "non-array", embedding: "poison" },
{ name: "array-like object", embedding: { length: 1 } },
{ name: "boolean", embedding: false },
{ name: "string coordinate", embedding: ["poison"] },
{ name: "null coordinate", embedding: [null] },
{ name: "NaN coordinate", embedding: [Number.NaN] },
{ name: "positive infinity coordinate", embedding: [Number.POSITIVE_INFINITY] },
{ name: "negative infinity coordinate", embedding: [Number.NEGATIVE_INFINITY] },
])("rejects a $name instead of storing it as a valid vector", ({ embedding }) => {
const remaining = new Set(["req-invalid"]);
const errors: string[] = [];
const byCustomId = new Map<string, number[]>();
applyEmbeddingBatchOutputLine({
line: {
custom_id: "req-invalid",
response: {
status_code: 200,
body: { data: [{ embedding: embedding as unknown as number[] }] },
},
},
remaining,
errors,
byCustomId,
});
expect(remaining.has("req-invalid")).toBe(false);
expect(errors).toHaveLength(1);
expect(errors[0]).toMatch(/^req-invalid: (?:empty|invalid) embedding$/);
expect(byCustomId.size).toBe(0);
});
it("records provider error from line.error", () => {
const remaining = new Set(["req-2"]);
const errors: string[] = [];

View File

@@ -174,8 +174,8 @@ export function applyEmbeddingBatchOutputLine(params: {
const data =
response?.body && typeof response.body === "object" ? (response.body.data ?? []) : [];
const embedding = data[0]?.embedding ?? [];
if (embedding.length === 0) {
params.errors.push(`${customId}: empty embedding`);
if (!Array.isArray(embedding) || embedding.length === 0 || !embedding.every(Number.isFinite)) {
params.errors.push(`${customId}: ${embedding?.length ? "invalid" : "empty"} embedding`);
return;
}
params.byCustomId.set(customId, embedding);

View File

@@ -124,6 +124,39 @@ describe("fetchRemoteEmbeddingVectors", () => {
).rejects.toThrow("embedding fetch failed: malformed JSON response");
});
it.each([
{ name: "query", input: ["query"], data: [{ embedding: [] }] },
{
name: "document batch",
input: ["first", "second"],
data: [{ embedding: [0.1] }, { embedding: [] }],
},
])("rejects empty vectors in a $name response", async ({ input, data }) => {
postJsonMock.mockImplementationOnce(async (params) => await params.parse({ data }));
await expect(
fetchRemoteEmbeddingVectors({
url: "https://memory.example/v1/embeddings",
headers: {},
body: { input },
errorPrefix: "embedding fetch failed",
}),
).rejects.toThrow("embedding fetch failed: malformed JSON response");
});
it("preserves an empty response for an empty submitted input batch", async () => {
postJsonMock.mockImplementationOnce(async (params) => await params.parse({ data: [] }));
await expect(
fetchRemoteEmbeddingVectors({
url: "https://memory.example/v1/embeddings",
headers: {},
body: { input: [] },
errorPrefix: "embedding fetch failed",
}),
).resolves.toEqual([]);
});
it("rejects wrong nested embedding vector types", async () => {
postJsonMock.mockImplementationOnce(async (params) => {
return await params.parse({ data: [{ embedding: [0.1, "bad"] }] });

View File

@@ -18,7 +18,7 @@ function malformedEmbeddingResponse(errorPrefix: string): Error {
/** Validate and return one finite embedding vector. */
function readEmbeddingVector(value: unknown, errorPrefix: string): number[] {
if (!Array.isArray(value)) {
if (!Array.isArray(value) || value.length === 0) {
throw malformedEmbeddingResponse(errorPrefix);
}
for (const entry of value) {