From a14b0d53b356772d304dd5d6cabec01ca1163f42 Mon Sep 17 00:00:00 2001 From: Timur Iskhakov Date: Tue, 31 Mar 2026 10:52:56 +0100 Subject: [PATCH 1/3] fix: decode base64-encoded embeddings in recorder --- src/recorder.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/recorder.ts b/src/recorder.ts index ef34c00..2a99e83 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -301,6 +301,12 @@ function buildFixtureResponse(parsed: unknown, status: number): FixtureResponse if (Array.isArray(first.embedding)) { return { embedding: first.embedding as number[] }; } + if (typeof first.embedding === "string") { + // Base64-encoded embedding (e.g. OpenAI with encoding_format: "base64") + const buf = Buffer.from(first.embedding, "base64"); + const floats = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4); + return { embedding: Array.from(floats) }; + } } // Direct embedding: { embedding: [...] } From bf0217d4f76499261b1c3caa2e781a3885c4b821 Mon Sep 17 00:00:00 2001 From: Timur Iskhakov Date: Tue, 31 Mar 2026 11:09:10 +0100 Subject: [PATCH 2/3] fix: use encoding_format to detect base64 embeddings --- src/recorder.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/recorder.ts b/src/recorder.ts index 2a99e83..05608f4 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -143,7 +143,13 @@ export async function proxyAndRecord( // Not JSON — could be an unknown format defaults.logger.warn("Upstream response is not valid JSON — saving as error fixture"); } - fixtureResponse = buildFixtureResponse(parsedResponse, upstreamStatus); + let encodingFormat: string | undefined; + try { + encodingFormat = rawBody ? JSON.parse(rawBody).encoding_format : undefined; + } catch { + /* not JSON */ + } + fixtureResponse = buildFixtureResponse(parsedResponse, upstreamStatus, encodingFormat); } // Build the match criteria from the original request @@ -271,7 +277,11 @@ function makeUpstreamRequest( * Detect the response format from the parsed upstream JSON and convert * it into an llmock FixtureResponse. */ -function buildFixtureResponse(parsed: unknown, status: number): FixtureResponse { +function buildFixtureResponse( + parsed: unknown, + status: number, + encodingFormat?: string, +): FixtureResponse { if (parsed === null || parsed === undefined) { // Raw / unparseable response — save as error return { @@ -301,8 +311,7 @@ function buildFixtureResponse(parsed: unknown, status: number): FixtureResponse if (Array.isArray(first.embedding)) { return { embedding: first.embedding as number[] }; } - if (typeof first.embedding === "string") { - // Base64-encoded embedding (e.g. OpenAI with encoding_format: "base64") + if (typeof first.embedding === "string" && encodingFormat === "base64") { const buf = Buffer.from(first.embedding, "base64"); const floats = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4); return { embedding: Array.from(floats) }; From 5602fb536e843e341f757eab5c78610803b057f2 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Tue, 31 Mar 2026 14:39:42 -0700 Subject: [PATCH 3/3] fix: guard base64 decode against corrupted data + add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard the Float32Array decode with try/catch so corrupted base64 data falls through to the error path instead of crashing. Add 4 tests covering the encoding_format × embedding-type matrix: - base64 string + encoding_format:base64 → decodes to float array - base64 string + no encoding_format → proxy_error (original bug path) - array embedding + encoding_format:base64 → array passthrough - truncated base64 (odd byte count) → empty embedding, no crash --- src/__tests__/recorder.test.ts | 155 +++++++++++++++++++++++++++++++++ src/recorder.ts | 10 ++- 2 files changed, 162 insertions(+), 3 deletions(-) diff --git a/src/__tests__/recorder.test.ts b/src/__tests__/recorder.test.ts index f2ac2c0..3e796b3 100644 --- a/src/__tests__/recorder.test.ts +++ b/src/__tests__/recorder.test.ts @@ -2368,6 +2368,161 @@ describe("buildFixtureResponse format detection", () => { expect(fixtureContent.fixtures[0].response.embedding).toEqual([0.1, 0.2, 0.3]); }); + it("decodes base64-encoded embeddings when encoding_format is base64", async () => { + // Float32Array([0.5, 1.0, -0.25]) encoded as base64 + const base64Embedding = "AAAAPwAAgD8AAIC+"; + const { url: upstreamUrl } = await createRawUpstreamWithStatus({ + object: "list", + data: [{ object: "embedding", index: 0, embedding: base64Embedding }], + model: "text-embedding-3-small", + usage: { prompt_tokens: 5, total_tokens: 5 }, + }); + + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "llmock-record-")); + recorder = await createServer([], { + port: 0, + record: { providers: { openai: upstreamUrl }, fixturePath: tmpDir }, + }); + + const resp = await post(`${recorder.url}/v1/embeddings`, { + model: "text-embedding-3-small", + input: "base64 embedding test", + encoding_format: "base64", + }); + + expect(resp.status).toBe(200); + + const files = fs.readdirSync(tmpDir); + const fixtureFiles = files.filter((f) => f.endsWith(".json")); + expect(fixtureFiles).toHaveLength(1); + + const fixtureContent = JSON.parse( + fs.readFileSync(path.join(tmpDir, fixtureFiles[0]), "utf-8"), + ) as { + fixtures: Array<{ + response: { embedding?: number[] }; + }>; + }; + // Should decode base64 → Float32Array → number[] + expect(fixtureContent.fixtures[0].response.embedding).toEqual([0.5, 1, -0.25]); + }); + + it("does not decode base64 embedding when encoding_format is not set", async () => { + // Same base64 string but no encoding_format in request — should NOT decode + const base64Embedding = "AAAAPwAAgD8AAIC+"; + const { url: upstreamUrl } = await createRawUpstreamWithStatus({ + object: "list", + data: [{ object: "embedding", index: 0, embedding: base64Embedding }], + model: "text-embedding-3-small", + usage: { prompt_tokens: 5, total_tokens: 5 }, + }); + + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "llmock-record-")); + recorder = await createServer([], { + port: 0, + record: { providers: { openai: upstreamUrl }, fixturePath: tmpDir }, + }); + + const resp = await post(`${recorder.url}/v1/embeddings`, { + model: "text-embedding-3-small", + input: "base64 no format test", + }); + + expect(resp.status).toBe(200); + + const files = fs.readdirSync(tmpDir); + const fixtureFiles = files.filter((f) => f.endsWith(".json")); + expect(fixtureFiles).toHaveLength(1); + + const fixtureContent = JSON.parse( + fs.readFileSync(path.join(tmpDir, fixtureFiles[0]), "utf-8"), + ) as { + fixtures: Array<{ + response: { error?: { type: string } }; + }>; + }; + // Without encoding_format, base64 string embedding is not an array → + // falls through to proxy_error + expect(fixtureContent.fixtures[0].response.error?.type).toBe("proxy_error"); + }); + + it("still detects array embeddings when encoding_format is base64", async () => { + // Some upstream responses return array format even when base64 was requested + const { url: upstreamUrl } = await createRawUpstreamWithStatus({ + object: "list", + data: [{ object: "embedding", index: 0, embedding: [0.5, 1.0, -0.25] }], + model: "text-embedding-3-small", + usage: { prompt_tokens: 5, total_tokens: 5 }, + }); + + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "llmock-record-")); + recorder = await createServer([], { + port: 0, + record: { providers: { openai: upstreamUrl }, fixturePath: tmpDir }, + }); + + const resp = await post(`${recorder.url}/v1/embeddings`, { + model: "text-embedding-3-small", + input: "array with base64 format test", + encoding_format: "base64", + }); + + expect(resp.status).toBe(200); + + const files = fs.readdirSync(tmpDir); + const fixtureFiles = files.filter((f) => f.endsWith(".json")); + expect(fixtureFiles).toHaveLength(1); + + const fixtureContent = JSON.parse( + fs.readFileSync(path.join(tmpDir, fixtureFiles[0]), "utf-8"), + ) as { + fixtures: Array<{ + response: { embedding?: number[] }; + }>; + }; + // Array.isArray check comes first, so array embeddings work regardless of encoding_format + expect(fixtureContent.fixtures[0].response.embedding).toEqual([0.5, 1, -0.25]); + }); + + it("handles truncated base64 embedding gracefully (odd byte count)", async () => { + // 2 bytes decodes to 0 float32 elements — produces empty embedding, not a crash + const shortBase64 = Buffer.from([0x00, 0x01]).toString("base64"); + const { url: upstreamUrl } = await createRawUpstreamWithStatus({ + object: "list", + data: [{ object: "embedding", index: 0, embedding: shortBase64 }], + model: "text-embedding-3-small", + usage: { prompt_tokens: 5, total_tokens: 5 }, + }); + + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "llmock-record-")); + recorder = await createServer([], { + port: 0, + record: { providers: { openai: upstreamUrl }, fixturePath: tmpDir }, + }); + + const resp = await post(`${recorder.url}/v1/embeddings`, { + model: "text-embedding-3-small", + input: "truncated base64 test", + encoding_format: "base64", + }); + + expect(resp.status).toBe(200); + + const files = fs.readdirSync(tmpDir); + const fixtureFiles = files.filter((f) => f.endsWith(".json")); + expect(fixtureFiles).toHaveLength(1); + + const fixtureContent = JSON.parse( + fs.readFileSync(path.join(tmpDir, fixtureFiles[0]), "utf-8"), + ) as { + fixtures: Array<{ + response: { embedding?: number[] }; + }>; + }; + // Truncated base64 decodes to empty array rather than crashing + expect(fixtureContent.fixtures[0].response.embedding).toEqual([]); + }); + it("preserves error code field from upstream error response", async () => { const { url: upstreamUrl } = await createRawUpstreamWithStatus( { diff --git a/src/recorder.ts b/src/recorder.ts index 05608f4..a70f6ce 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -312,9 +312,13 @@ function buildFixtureResponse( return { embedding: first.embedding as number[] }; } if (typeof first.embedding === "string" && encodingFormat === "base64") { - const buf = Buffer.from(first.embedding, "base64"); - const floats = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4); - return { embedding: Array.from(floats) }; + try { + const buf = Buffer.from(first.embedding, "base64"); + const floats = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4); + return { embedding: Array.from(floats) }; + } catch { + // Corrupted base64 or non-float32 data — fall through to error + } } }