|
1 | 1 | // Openai tests cover embedding batch plugin behavior. |
| 2 | +import { createServer } from "node:http"; |
2 | 3 | import { describe, expect, it, vi } from "vitest"; |
3 | 4 | import { parseOpenAiBatchOutput, runOpenAiEmbeddingBatches } from "./embedding-batch.js"; |
4 | 5 |
|
@@ -54,6 +55,33 @@ function parseStringBody(init: RequestInit | undefined): unknown { |
54 | 55 | return JSON.parse(init.body) as unknown; |
55 | 56 | } |
56 | 57 |
|
| 58 | +async function listenLoopbackServer(server: ReturnType<typeof createServer>): Promise<number> { |
| 59 | + return await new Promise((resolve, reject) => { |
| 60 | + server.once("error", reject); |
| 61 | + server.listen(0, "127.0.0.1", () => { |
| 62 | + server.off("error", reject); |
| 63 | + const address = server.address(); |
| 64 | + if (!address || typeof address === "string") { |
| 65 | + reject(new Error("expected loopback TCP address")); |
| 66 | + return; |
| 67 | + } |
| 68 | + resolve(address.port); |
| 69 | + }); |
| 70 | + }); |
| 71 | +} |
| 72 | + |
| 73 | +async function closeServer(server: ReturnType<typeof createServer>): Promise<void> { |
| 74 | + await new Promise<void>((resolve, reject) => { |
| 75 | + server.close((err) => { |
| 76 | + if (err) { |
| 77 | + reject(err); |
| 78 | + return; |
| 79 | + } |
| 80 | + resolve(); |
| 81 | + }); |
| 82 | + }); |
| 83 | +} |
| 84 | + |
57 | 85 | describe("OpenAI embedding batch output", () => { |
58 | 86 | it("wraps malformed JSONL output", () => { |
59 | 87 | expect(() => parseOpenAiBatchOutput('{"custom_id":"ok"}\n{not json')).toThrow( |
@@ -330,6 +358,234 @@ describe("OpenAI embedding batch output", () => { |
330 | 358 | expect(readCount).toBeLessThan(chunkCount); |
331 | 359 | }); |
332 | 360 |
|
| 361 | + it("streams valid batch output files larger than the provider text cap", async () => { |
| 362 | + const outputLineCount = 18; |
| 363 | + const padding = "x".repeat(1024 * 1024); |
| 364 | + const requests: Parameters<typeof runOpenAiEmbeddingBatches>[0]["requests"] = Array.from( |
| 365 | + { length: outputLineCount }, |
| 366 | + (_, index) => ({ |
| 367 | + custom_id: String(index), |
| 368 | + method: "POST" as const, |
| 369 | + url: "/v1/embeddings", |
| 370 | + body: { model: "text-embedding-3-small", input: `payload-${index}` }, |
| 371 | + }), |
| 372 | + ); |
| 373 | + let outputLinesSent = 0; |
| 374 | + const outputResponse = new Response( |
| 375 | + new ReadableStream<Uint8Array>({ |
| 376 | + pull(controller) { |
| 377 | + if (outputLinesSent >= outputLineCount) { |
| 378 | + controller.close(); |
| 379 | + return; |
| 380 | + } |
| 381 | + const customId = String(outputLinesSent); |
| 382 | + controller.enqueue( |
| 383 | + jsonlEncoder.encode( |
| 384 | + `${JSON.stringify({ |
| 385 | + custom_id: customId, |
| 386 | + response: { |
| 387 | + status_code: 200, |
| 388 | + body: { data: [{ embedding: [outputLinesSent + 1] }] }, |
| 389 | + }, |
| 390 | + padding, |
| 391 | + })}\n`, |
| 392 | + ), |
| 393 | + ); |
| 394 | + outputLinesSent += 1; |
| 395 | + }, |
| 396 | + }), |
| 397 | + { status: 200, headers: { "Content-Type": "application/jsonl" } }, |
| 398 | + ); |
| 399 | + const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { |
| 400 | + const url = fetchInputUrl(input); |
| 401 | + if (url.endsWith("/files") && init?.method === "POST") { |
| 402 | + return jsonResponse({ id: "file-0" }); |
| 403 | + } |
| 404 | + if (url.endsWith("/batches") && init?.method === "POST") { |
| 405 | + return jsonResponse({ id: "batch-0", status: "completed", output_file_id: "output-0" }); |
| 406 | + } |
| 407 | + if (url.endsWith("/files/output-0/content")) { |
| 408 | + return outputResponse; |
| 409 | + } |
| 410 | + return new Response("unexpected request", { status: 500 }); |
| 411 | + }); |
| 412 | + |
| 413 | + const byCustomId = await runOpenAiEmbeddingBatches({ |
| 414 | + openAi: { |
| 415 | + baseUrl: "https://openai-compatible.example/v1", |
| 416 | + headers: { Authorization: "Bearer test" }, |
| 417 | + model: "text-embedding-3-small", |
| 418 | + fetchImpl, |
| 419 | + }, |
| 420 | + agentId: "main", |
| 421 | + requests, |
| 422 | + wait: true, |
| 423 | + concurrency: 1, |
| 424 | + pollIntervalMs: 1000, |
| 425 | + timeoutMs: 60_000, |
| 426 | + }); |
| 427 | + |
| 428 | + expect(outputLinesSent).toBe(outputLineCount); |
| 429 | + expect([...byCustomId.entries()]).toEqual( |
| 430 | + requests.map((request, index) => [request.custom_id, [index + 1]]), |
| 431 | + ); |
| 432 | + }); |
| 433 | + |
| 434 | + it("stops reading batch output after all requested custom IDs are accounted for", async () => { |
| 435 | + const outputLineCount = 1024; |
| 436 | + let outputLinesSent = 0; |
| 437 | + let canceled = false; |
| 438 | + const outputResponse = new Response( |
| 439 | + new ReadableStream<Uint8Array>({ |
| 440 | + pull(controller) { |
| 441 | + if (outputLinesSent >= outputLineCount) { |
| 442 | + controller.close(); |
| 443 | + return; |
| 444 | + } |
| 445 | + const line = |
| 446 | + outputLinesSent === 0 |
| 447 | + ? { |
| 448 | + custom_id: "0", |
| 449 | + response: { |
| 450 | + status_code: 200, |
| 451 | + body: { data: [{ embedding: [1] }] }, |
| 452 | + }, |
| 453 | + } |
| 454 | + : { |
| 455 | + custom_id: `extra-${outputLinesSent}`, |
| 456 | + response: { |
| 457 | + status_code: 200, |
| 458 | + body: { data: [{ embedding: [outputLinesSent] }] }, |
| 459 | + }, |
| 460 | + }; |
| 461 | + controller.enqueue(jsonlEncoder.encode(`${JSON.stringify(line)}\n`)); |
| 462 | + outputLinesSent += 1; |
| 463 | + }, |
| 464 | + cancel() { |
| 465 | + canceled = true; |
| 466 | + }, |
| 467 | + }), |
| 468 | + { status: 200, headers: { "Content-Type": "application/jsonl" } }, |
| 469 | + ); |
| 470 | + const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { |
| 471 | + const url = fetchInputUrl(input); |
| 472 | + if (url.endsWith("/files") && init?.method === "POST") { |
| 473 | + return jsonResponse({ id: "file-0" }); |
| 474 | + } |
| 475 | + if (url.endsWith("/batches") && init?.method === "POST") { |
| 476 | + return jsonResponse({ id: "batch-0", status: "completed", output_file_id: "output-0" }); |
| 477 | + } |
| 478 | + if (url.endsWith("/files/output-0/content")) { |
| 479 | + return outputResponse; |
| 480 | + } |
| 481 | + return new Response("unexpected request", { status: 500 }); |
| 482 | + }); |
| 483 | + |
| 484 | + const byCustomId = await runOpenAiEmbeddingBatches({ |
| 485 | + openAi: { |
| 486 | + baseUrl: "https://openai-compatible.example/v1", |
| 487 | + headers: { Authorization: "Bearer test" }, |
| 488 | + model: "text-embedding-3-small", |
| 489 | + fetchImpl, |
| 490 | + }, |
| 491 | + agentId: "main", |
| 492 | + requests: [ |
| 493 | + { |
| 494 | + custom_id: "0", |
| 495 | + method: "POST", |
| 496 | + url: "/v1/embeddings", |
| 497 | + body: { model: "text-embedding-3-small", input: "payload" }, |
| 498 | + }, |
| 499 | + ], |
| 500 | + wait: true, |
| 501 | + concurrency: 1, |
| 502 | + pollIntervalMs: 1000, |
| 503 | + timeoutMs: 60_000, |
| 504 | + }); |
| 505 | + |
| 506 | + expect([...byCustomId.entries()]).toEqual([["0", [1]]]); |
| 507 | + expect(canceled).toBe(true); |
| 508 | + expect(outputLinesSent).toBeLessThan(outputLineCount); |
| 509 | + }); |
| 510 | + |
| 511 | + it("bounds batch output file content without buffering the whole response", async () => { |
| 512 | + const outputChunkCount = 1024; |
| 513 | + let outputChunksSent = 0; |
| 514 | + const server = createServer((req, res) => { |
| 515 | + const url = req.url ?? ""; |
| 516 | + if (url === "/v1/files") { |
| 517 | + res.writeHead(200, { "Content-Type": "application/json" }); |
| 518 | + res.end(JSON.stringify({ id: "file-0" })); |
| 519 | + return; |
| 520 | + } |
| 521 | + if (url === "/v1/batches") { |
| 522 | + res.writeHead(200, { "Content-Type": "application/json" }); |
| 523 | + res.end(JSON.stringify({ id: "batch-0", status: "completed", output_file_id: "output-0" })); |
| 524 | + return; |
| 525 | + } |
| 526 | + if (url === "/v1/files/output-0/content") { |
| 527 | + res.writeHead(200, { "Content-Type": "application/jsonl" }); |
| 528 | + const chunkSize = 1024 * 1024; |
| 529 | + const writeNext = () => { |
| 530 | + if (outputChunksSent >= outputChunkCount) { |
| 531 | + res.end(); |
| 532 | + return; |
| 533 | + } |
| 534 | + outputChunksSent += 1; |
| 535 | + if (res.write(Buffer.alloc(chunkSize))) { |
| 536 | + setImmediate(writeNext); |
| 537 | + } else { |
| 538 | + res.once("drain", writeNext); |
| 539 | + } |
| 540 | + }; |
| 541 | + writeNext(); |
| 542 | + return; |
| 543 | + } |
| 544 | + res.writeHead(500); |
| 545 | + res.end("unexpected request"); |
| 546 | + }); |
| 547 | + |
| 548 | + const port = await listenLoopbackServer(server); |
| 549 | + const realFetch = globalThis.fetch.bind(globalThis); |
| 550 | + const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { |
| 551 | + const originalUrl = new URL(fetchInputUrl(input)); |
| 552 | + const loopbackUrl = new URL( |
| 553 | + `${originalUrl.pathname}${originalUrl.search}`, |
| 554 | + `http://127.0.0.1:${port}`, |
| 555 | + ); |
| 556 | + return await realFetch(loopbackUrl, init); |
| 557 | + }); |
| 558 | + |
| 559 | + try { |
| 560 | + await expect( |
| 561 | + runOpenAiEmbeddingBatches({ |
| 562 | + openAi: { |
| 563 | + baseUrl: "https://openai-compatible.example/v1", |
| 564 | + headers: { Authorization: "Bearer test" }, |
| 565 | + model: "text-embedding-3-small", |
| 566 | + fetchImpl, |
| 567 | + }, |
| 568 | + agentId: "main", |
| 569 | + requests: [ |
| 570 | + { |
| 571 | + custom_id: "0", |
| 572 | + method: "POST", |
| 573 | + url: "/v1/embeddings", |
| 574 | + body: { model: "text-embedding-3-small", input: "payload" }, |
| 575 | + }, |
| 576 | + ], |
| 577 | + wait: true, |
| 578 | + concurrency: 1, |
| 579 | + pollIntervalMs: 1000, |
| 580 | + timeoutMs: 60_000, |
| 581 | + }), |
| 582 | + ).rejects.toThrow(/openai\.batch-file-content/); |
| 583 | + } finally { |
| 584 | + await closeServer(server); |
| 585 | + } |
| 586 | + expect(outputChunksSent).toBeLessThan(outputChunkCount); |
| 587 | + }); |
| 588 | + |
333 | 589 | it("bounds batch resource error bodies without using response.text()", async () => { |
334 | 590 | const tracked = cancelTrackedResponse(`${"batch status unavailable ".repeat(1024)}tail`, { |
335 | 591 | status: 400, |
|
0 commit comments