Skip to content

Commit abb6b1d

Browse files
authored
fix: decode base64-encoded embeddings in recorder (#64)
## Problem When recording fixtures, the recorder fails to save OpenAI embedding responses that use base64 encoding. The OpenAI API returns base64-encoded embeddings when `encoding_format: "base64"` is set (the default in many SDKs, including Python's `openai` package). `buildFixtureResponse` checks `Array.isArray(first.embedding)` but the embedding is a base64 string, not an array. The fixture is saved with a `proxy_error`: ```json { "response": { "error": { "message": "Could not detect response format from upstream", "type": "proxy_error" }, "status": 200 } } ``` ## Fix Extract `encoding_format` from the raw request body and pass it to `buildFixtureResponse`. When `encoding_format === "base64"` and the embedding is a string, decode it via `Buffer.from(embedding, "base64")` → `Float32Array` → `Array.from(floats)`. This is forward-safe — only decodes when `encoding_format` explicitly says `"base64"`, so future encoding formats (e.g. `"int8"`) won't be misinterpreted. ## Changes - `recorder.ts`: Extract `encoding_format` from raw request, pass to `buildFixtureResponse`, decode base64 when detected
2 parents ff3f2c8 + 5602fb5 commit abb6b1d

2 files changed

Lines changed: 176 additions & 2 deletions

File tree

src/__tests__/recorder.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2368,6 +2368,161 @@ describe("buildFixtureResponse format detection", () => {
23682368
expect(fixtureContent.fixtures[0].response.embedding).toEqual([0.1, 0.2, 0.3]);
23692369
});
23702370

2371+
it("decodes base64-encoded embeddings when encoding_format is base64", async () => {
2372+
// Float32Array([0.5, 1.0, -0.25]) encoded as base64
2373+
const base64Embedding = "AAAAPwAAgD8AAIC+";
2374+
const { url: upstreamUrl } = await createRawUpstreamWithStatus({
2375+
object: "list",
2376+
data: [{ object: "embedding", index: 0, embedding: base64Embedding }],
2377+
model: "text-embedding-3-small",
2378+
usage: { prompt_tokens: 5, total_tokens: 5 },
2379+
});
2380+
2381+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "llmock-record-"));
2382+
recorder = await createServer([], {
2383+
port: 0,
2384+
record: { providers: { openai: upstreamUrl }, fixturePath: tmpDir },
2385+
});
2386+
2387+
const resp = await post(`${recorder.url}/v1/embeddings`, {
2388+
model: "text-embedding-3-small",
2389+
input: "base64 embedding test",
2390+
encoding_format: "base64",
2391+
});
2392+
2393+
expect(resp.status).toBe(200);
2394+
2395+
const files = fs.readdirSync(tmpDir);
2396+
const fixtureFiles = files.filter((f) => f.endsWith(".json"));
2397+
expect(fixtureFiles).toHaveLength(1);
2398+
2399+
const fixtureContent = JSON.parse(
2400+
fs.readFileSync(path.join(tmpDir, fixtureFiles[0]), "utf-8"),
2401+
) as {
2402+
fixtures: Array<{
2403+
response: { embedding?: number[] };
2404+
}>;
2405+
};
2406+
// Should decode base64 → Float32Array → number[]
2407+
expect(fixtureContent.fixtures[0].response.embedding).toEqual([0.5, 1, -0.25]);
2408+
});
2409+
2410+
it("does not decode base64 embedding when encoding_format is not set", async () => {
2411+
// Same base64 string but no encoding_format in request — should NOT decode
2412+
const base64Embedding = "AAAAPwAAgD8AAIC+";
2413+
const { url: upstreamUrl } = await createRawUpstreamWithStatus({
2414+
object: "list",
2415+
data: [{ object: "embedding", index: 0, embedding: base64Embedding }],
2416+
model: "text-embedding-3-small",
2417+
usage: { prompt_tokens: 5, total_tokens: 5 },
2418+
});
2419+
2420+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "llmock-record-"));
2421+
recorder = await createServer([], {
2422+
port: 0,
2423+
record: { providers: { openai: upstreamUrl }, fixturePath: tmpDir },
2424+
});
2425+
2426+
const resp = await post(`${recorder.url}/v1/embeddings`, {
2427+
model: "text-embedding-3-small",
2428+
input: "base64 no format test",
2429+
});
2430+
2431+
expect(resp.status).toBe(200);
2432+
2433+
const files = fs.readdirSync(tmpDir);
2434+
const fixtureFiles = files.filter((f) => f.endsWith(".json"));
2435+
expect(fixtureFiles).toHaveLength(1);
2436+
2437+
const fixtureContent = JSON.parse(
2438+
fs.readFileSync(path.join(tmpDir, fixtureFiles[0]), "utf-8"),
2439+
) as {
2440+
fixtures: Array<{
2441+
response: { error?: { type: string } };
2442+
}>;
2443+
};
2444+
// Without encoding_format, base64 string embedding is not an array →
2445+
// falls through to proxy_error
2446+
expect(fixtureContent.fixtures[0].response.error?.type).toBe("proxy_error");
2447+
});
2448+
2449+
it("still detects array embeddings when encoding_format is base64", async () => {
2450+
// Some upstream responses return array format even when base64 was requested
2451+
const { url: upstreamUrl } = await createRawUpstreamWithStatus({
2452+
object: "list",
2453+
data: [{ object: "embedding", index: 0, embedding: [0.5, 1.0, -0.25] }],
2454+
model: "text-embedding-3-small",
2455+
usage: { prompt_tokens: 5, total_tokens: 5 },
2456+
});
2457+
2458+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "llmock-record-"));
2459+
recorder = await createServer([], {
2460+
port: 0,
2461+
record: { providers: { openai: upstreamUrl }, fixturePath: tmpDir },
2462+
});
2463+
2464+
const resp = await post(`${recorder.url}/v1/embeddings`, {
2465+
model: "text-embedding-3-small",
2466+
input: "array with base64 format test",
2467+
encoding_format: "base64",
2468+
});
2469+
2470+
expect(resp.status).toBe(200);
2471+
2472+
const files = fs.readdirSync(tmpDir);
2473+
const fixtureFiles = files.filter((f) => f.endsWith(".json"));
2474+
expect(fixtureFiles).toHaveLength(1);
2475+
2476+
const fixtureContent = JSON.parse(
2477+
fs.readFileSync(path.join(tmpDir, fixtureFiles[0]), "utf-8"),
2478+
) as {
2479+
fixtures: Array<{
2480+
response: { embedding?: number[] };
2481+
}>;
2482+
};
2483+
// Array.isArray check comes first, so array embeddings work regardless of encoding_format
2484+
expect(fixtureContent.fixtures[0].response.embedding).toEqual([0.5, 1, -0.25]);
2485+
});
2486+
2487+
it("handles truncated base64 embedding gracefully (odd byte count)", async () => {
2488+
// 2 bytes decodes to 0 float32 elements — produces empty embedding, not a crash
2489+
const shortBase64 = Buffer.from([0x00, 0x01]).toString("base64");
2490+
const { url: upstreamUrl } = await createRawUpstreamWithStatus({
2491+
object: "list",
2492+
data: [{ object: "embedding", index: 0, embedding: shortBase64 }],
2493+
model: "text-embedding-3-small",
2494+
usage: { prompt_tokens: 5, total_tokens: 5 },
2495+
});
2496+
2497+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "llmock-record-"));
2498+
recorder = await createServer([], {
2499+
port: 0,
2500+
record: { providers: { openai: upstreamUrl }, fixturePath: tmpDir },
2501+
});
2502+
2503+
const resp = await post(`${recorder.url}/v1/embeddings`, {
2504+
model: "text-embedding-3-small",
2505+
input: "truncated base64 test",
2506+
encoding_format: "base64",
2507+
});
2508+
2509+
expect(resp.status).toBe(200);
2510+
2511+
const files = fs.readdirSync(tmpDir);
2512+
const fixtureFiles = files.filter((f) => f.endsWith(".json"));
2513+
expect(fixtureFiles).toHaveLength(1);
2514+
2515+
const fixtureContent = JSON.parse(
2516+
fs.readFileSync(path.join(tmpDir, fixtureFiles[0]), "utf-8"),
2517+
) as {
2518+
fixtures: Array<{
2519+
response: { embedding?: number[] };
2520+
}>;
2521+
};
2522+
// Truncated base64 decodes to empty array rather than crashing
2523+
expect(fixtureContent.fixtures[0].response.embedding).toEqual([]);
2524+
});
2525+
23712526
it("preserves error code field from upstream error response", async () => {
23722527
const { url: upstreamUrl } = await createRawUpstreamWithStatus(
23732528
{

src/recorder.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,13 @@ export async function proxyAndRecord(
161161
// Not JSON — could be an unknown format
162162
defaults.logger.warn("Upstream response is not valid JSON — saving as error fixture");
163163
}
164-
fixtureResponse = buildFixtureResponse(parsedResponse, upstreamStatus);
164+
let encodingFormat: string | undefined;
165+
try {
166+
encodingFormat = rawBody ? JSON.parse(rawBody).encoding_format : undefined;
167+
} catch {
168+
/* not JSON */
169+
}
170+
fixtureResponse = buildFixtureResponse(parsedResponse, upstreamStatus, encodingFormat);
165171
}
166172

167173
// Build the match criteria from the original request
@@ -289,7 +295,11 @@ function makeUpstreamRequest(
289295
* Detect the response format from the parsed upstream JSON and convert
290296
* it into an llmock FixtureResponse.
291297
*/
292-
function buildFixtureResponse(parsed: unknown, status: number): FixtureResponse {
298+
function buildFixtureResponse(
299+
parsed: unknown,
300+
status: number,
301+
encodingFormat?: string,
302+
): FixtureResponse {
293303
if (parsed === null || parsed === undefined) {
294304
// Raw / unparseable response — save as error
295305
return {
@@ -319,6 +329,15 @@ function buildFixtureResponse(parsed: unknown, status: number): FixtureResponse
319329
if (Array.isArray(first.embedding)) {
320330
return { embedding: first.embedding as number[] };
321331
}
332+
if (typeof first.embedding === "string" && encodingFormat === "base64") {
333+
try {
334+
const buf = Buffer.from(first.embedding, "base64");
335+
const floats = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
336+
return { embedding: Array.from(floats) };
337+
} catch {
338+
// Corrupted base64 or non-float32 data — fall through to error
339+
}
340+
}
322341
}
323342

324343
// Direct embedding: { embedding: [...] }

0 commit comments

Comments
 (0)