@cartanova/qgrid-cli 2.10.5 → 2.10.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/bundle/dist/application/monit/monit.frame.js +1 -1
  2. package/bundle/dist/application/qgrid/expired-token-reminder.js +1 -1
  3. package/bundle/dist/application/qgrid/oauth.js +1 -1
  4. package/bundle/dist/application/qgrid/qgrid-image-generation.js +7 -11
  5. package/bundle/dist/application/qgrid/qgrid-response-format.js +4 -4
  6. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +24 -7
  7. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +8 -3
  8. package/bundle/dist/application/qgrid/qgrid.frame.js +24 -10
  9. package/bundle/dist/application/qgrid/token-death.js +1 -1
  10. package/bundle/dist/application/qgrid/token-window-keepalive.js +1 -1
  11. package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
  12. package/bundle/dist/application/request-log/request-log.model.js +2 -2
  13. package/bundle/dist/i18n/sd.generated.js +3 -3
  14. package/bundle/dist/sonamu.config.js +2 -2
  15. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +1 -1
  16. package/bundle/dist/utils/providers/anthropic/anthropic-thinking.js +1 -1
  17. package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +13 -2
  18. package/bundle/dist/utils/providers/openai/openai-direct-client.js +5 -4
  19. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +111 -57
  20. package/bundle/dist/utils/providers/openai/openai-image-generation.js +25 -17
  21. package/bundle/dist/utils/providers/openai/openai-refresh.js +1 -1
  22. package/bundle/src/application/qgrid/qgrid-image-generation.test.ts +19 -8
  23. package/bundle/src/application/qgrid/qgrid-image-generation.ts +17 -24
  24. package/bundle/src/application/qgrid/qgrid-response-format.test.ts +4 -2
  25. package/bundle/src/application/qgrid/qgrid-response-format.ts +7 -10
  26. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +87 -9
  27. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +45 -5
  28. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +136 -2
  29. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +11 -1
  30. package/bundle/src/application/qgrid/qgrid.frame.test.ts +73 -0
  31. package/bundle/src/application/qgrid/qgrid.frame.ts +39 -4
  32. package/bundle/src/application/request-log/request-log.entity.json +2 -2
  33. package/bundle/src/application/sonamu.generated.http +2 -2
  34. package/bundle/src/i18n/sd.generated.ts +2 -2
  35. package/bundle/src/utils/providers/openai/openai-backend-protocol.test.ts +23 -1
  36. package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +24 -2
  37. package/bundle/src/utils/providers/openai/openai-direct-client.test.ts +15 -0
  38. package/bundle/src/utils/providers/openai/openai-direct-client.ts +14 -4
  39. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +223 -32
  40. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +174 -72
  41. package/bundle/src/utils/providers/openai/openai-image-generation.test.ts +89 -0
  42. package/bundle/src/utils/providers/openai/openai-image-generation.ts +26 -15
  43. package/bundle/web-dist/client/assets/cost-DH2jZbrw.js +1 -0
  44. package/bundle/web-dist/client/assets/{index-Dxw7DiXF.js → index-D9ryIyL-.js} +4 -4
  45. package/bundle/web-dist/client/assets/logs-CLf2VNfk.js +1 -0
  46. package/bundle/web-dist/client/assets/{monit-E8eGW1Sj.js → monit-DojxOLj6.js} +1 -1
  47. package/bundle/web-dist/client/assets/{routes-7spLfIGZ.js → routes-DXJhNvbI.js} +2 -2
  48. package/bundle/web-dist/client/assets/{services.generated-jnPHYMER.js → services.generated-DqFYgWsY.js} +1 -1
  49. package/bundle/web-dist/client/assets/{settings-BdosZFNC.js → settings-bopyz9pa.js} +1 -1
  50. package/bundle/web-dist/client/assets/{show-b2PFNI3D.js → show-DbLD3MzB.js} +1 -1
  51. package/bundle/web-dist/client/assets/{tokens-CBdHb2_U.js → tokens-2-AIi9sZ.js} +1 -1
  52. package/bundle/web-dist/client/assets/{use-oauth-login-flow-Cyn3YPqW.js → use-oauth-login-flow-CnqTWs_H.js} +1 -1
  53. package/bundle/web-dist/client/assets/{use-update-token-mutation-DQCwCYfR.js → use-update-token-mutation-Cxb9K0fM.js} +1 -1
  54. package/bundle/web-dist/client/index.html +2 -2
  55. package/bundle/web-dist/server/assets/{cost-59jBdx-I.js → cost-eEHeTRT5.js} +4 -5
  56. package/bundle/web-dist/server/assets/{lazyRouteComponent-CUR-kWzI.js → lazyRouteComponent-Bi0fYGLs.js} +2 -2
  57. package/bundle/web-dist/server/assets/{logs-CaaWO9-Z.js → logs-CIVFxfoi.js} +10 -9
  58. package/bundle/web-dist/server/assets/{monit-CBil92fJ.js → monit-DBXezg7R.js} +4 -4
  59. package/bundle/web-dist/server/assets/{plus-Bvdj8Pwx.js → plus-BH-t4cm_.js} +1 -1
  60. package/bundle/web-dist/server/assets/{react-dom-CyuShs9K.js → react-dom-CZ2eynd8.js} +1 -1
  61. package/bundle/web-dist/server/assets/{refresh-cw-3QAiSWBd.js → refresh-cw-CosAdcz0.js} +1 -1
  62. package/bundle/web-dist/server/assets/{routes-DtVf9yqo.js → routes-COWYIvA-.js} +6 -7
  63. package/bundle/web-dist/server/assets/{services.generated-FPwsG9p3.js → services.generated-607iwhMI.js} +2 -2
  64. package/bundle/web-dist/server/assets/{settings-DeHNwoMv.js → settings-C_6dpK5p.js} +3 -3
  65. package/bundle/web-dist/server/assets/{show-8NNdRxhM.js → show-DY396-LD.js} +8 -20
  66. package/bundle/web-dist/server/assets/{tokens-odgS2-9q.js → tokens-COvV7qUP.js} +4 -4
  67. package/bundle/web-dist/server/assets/{use-oauth-login-flow-DTQKdYBO.js → use-oauth-login-flow-VYCYf3uv.js} +1 -1
  68. package/bundle/web-dist/server/assets/{use-update-token-mutation-xvXbPrCI.js → use-update-token-mutation-D8XYV5of.js} +1 -1
  69. package/bundle/web-dist/server/entry-server.generated.js +10 -10
  70. package/package.json +1 -1
  71. package/skills/qgrid/SKILL.md +9 -8
  72. package/skills/qgrid/references/ai-sdk-provider-contract.md +25 -16
  73. package/skills/qgrid/references/decision-rationale.md +8 -8
  74. package/skills/qgrid/references/openai-codex-runtime.md +19 -11
  75. package/skills/qgrid/references/request-log-run-lifecycle.md +4 -2
  76. package/skills/qgrid/references/sonamu-api-web-flow.md +2 -2
  77. package/skills/qgrid/references/verification-and-debugging.md +23 -9
  78. package/bundle/web-dist/client/assets/cost-CY2BJEPC.js +0 -1
  79. package/bundle/web-dist/client/assets/logs-3n_U4qnI.js +0 -1
@@ -7,10 +7,12 @@ import { QueryInput, QuotaThresholdExceededError } from "../../../application/qg
7
7
  import { type GenerateRequest } from "../common/provider-dispatcher";
8
8
  import {
9
9
  buildOpenAIResponsesRequest,
10
+ normalizeOpenAIEvent,
10
11
  type OpenAINormalizedEvent,
11
12
  type OpenAIResponsesOptions,
12
13
  } from "./openai-backend-protocol";
13
14
  import { ImageGenerationError, OpenAIDispatcher } from "./openai-dispatcher";
15
+ import { buildStandaloneImageRequest } from "./openai-image-generation";
14
16
  import { type OpenAITransportKind } from "./openai-transport-config";
15
17
 
16
18
  const credentials = {
@@ -116,7 +118,7 @@ describe("OpenAIDispatcher direct runtime", () => {
116
118
  it("applies thinking:false to the automatic image text driver", async () => {
117
119
  const run = vi.fn((options: OpenAIResponsesOptions) => {
118
120
  expect(options.reasoning).toEqual({ effort: "none" });
119
- expect(options.imageGeneration).toBe(true);
121
+ expect(options.imageGeneration).toBeUndefined();
120
122
  return events({ type: "text-delta", text: "hello" }, { type: "completed", responseId: "r" });
121
123
  });
122
124
  const d = dispatcher(run);
@@ -406,31 +408,116 @@ describe("OpenAIDispatcher direct runtime", () => {
406
408
  ));
407
409
  const d = dispatcher(run);
408
410
  await d.onTokenAdded(1, "one", credentials);
409
- const result = await d.generate(request({ imageGeneration: "auto" }));
411
+ const result = await d.generate(request({ imageGeneration: true }));
410
412
  expect(result.images).toMatchObject([{ data: "first", mediaType: "image/png" }, { data: "second", mediaType: "image/webp" }]);
411
413
  expect(run).toHaveBeenCalledTimes(1);
412
414
  });
413
415
 
414
- it("lets automatic mode return text or a hosted image with one driver request", async () => {
415
- for (const image of [false, true]) {
416
- const run = vi.fn((options: OpenAIResponsesOptions) => {
417
- const body = buildOpenAIResponsesRequest(options);
418
- expect(body.parallel_tool_calls).toBe(false);
419
- expect(body.tools).toEqual(expect.arrayContaining([{ type: "image_generation" }, expect.objectContaining({ name: "generate_transparent_image" })]));
420
- return events(...(image ? [{ type: "image" as const, base64: "png", mimeType: "image/png" as const }] : [{ type: "text-delta" as const, text: "hello" }]), { type: "completed", responseId: "r" });
421
- });
422
- const d = dispatcher(run);
423
- await d.onTokenAdded(1, "one", credentials);
424
- const result = await d.generate(request({ imageGeneration: "auto" }));
425
- expect(run).toHaveBeenCalledTimes(1);
426
- expect(image ? result.images?.length : result.text).toBe(image ? 1 : "hello");
427
- }
416
+ it.each([true, false])("preserves aggregate hosted image usage separately from driver usage (driver usage: %s)", async (withDriverUsage) => {
417
+ const imageUsage = {
418
+ input_tokens: 2260,
419
+ input_tokens_details: { image_tokens: 2012, text_tokens: 248 },
420
+ output_tokens: 1415,
421
+ output_tokens_details: { image_tokens: 1415, text_tokens: 0 },
422
+ total_tokens: 3675,
423
+ };
424
+ const rawEvents = [
425
+ ...["first", "second"].map((id) => ({ type: "response.output_item.done", item: {
426
+ id, type: "image_generation_call", result: "UklGRgAAAABXRUJQ",
427
+ size: "1037x1516", quality: "medium", background: "opaque", output_format: "webp",
428
+ } })),
429
+ { type: "response.completed", response: {
430
+ id: "r", model: "gpt-6-luna",
431
+ ...(withDriverUsage ? { usage: { input_tokens: 10, output_tokens: 3, total_tokens: 13 } } : {}),
432
+ tool_usage: { image_gen: imageUsage },
433
+ tools: [{ type: "image_generation", model: "gpt-image-2-codex", quality: "auto", size: "auto", background: "auto", output_format: "webp", n: 1 }],
434
+ } },
435
+ ];
436
+ const d = dispatcher((options) => {
437
+ expect(buildOpenAIResponsesRequest(options).tools).toEqual([{
438
+ type: "image_generation", quality: "high", size: "1024x1536", output_format: "webp",
439
+ }]);
440
+ return events(...rawEvents.map(normalizeOpenAIEvent).filter((event): event is OpenAINormalizedEvent => event !== undefined));
441
+ });
442
+ await d.onTokenAdded(1, "one", credentials);
443
+ const result = await d.generate(request({ model: "gpt-6-luna", imageGeneration: true,
444
+ imageGenerationOptions: { quality: "high", size: "1024x1536", outputFormat: "webp" },
445
+ }));
446
+ expect(result.usage).toMatchObject({
447
+ inputTokens: withDriverUsage ? 10 : 0,
448
+ outputTokens: withDriverUsage ? 3 : 0,
449
+ totalTokens: withDriverUsage ? 13 : 0,
450
+ });
451
+ const generation = { route: "codex-responses", model: "gpt-image-2-codex", size: "1037x1516", quality: "medium", background: "opaque" };
452
+ expect(result.images?.map((image) => image.generation)).toEqual([
453
+ { ...generation, usage: imageUsage }, generation,
454
+ ]);
455
+ });
456
+
457
+ it("registers only the internal image function and allows text-only replies", async () => {
458
+ const run = vi.fn((options: OpenAIResponsesOptions) => {
459
+ const body = buildOpenAIResponsesRequest(options);
460
+ expect(options.imageGeneration).toBeUndefined();
461
+ expect(body.parallel_tool_calls).toBe(false);
462
+ expect(body.instructions).toContain("self-contained prompt");
463
+ expect(body.tools).toEqual([expect.objectContaining({
464
+ type: "function", name: "generate_image", strict: false,
465
+ parameters: {
466
+ type: "object", additionalProperties: false,
467
+ required: ["prompt", "transparent_background", "num_last_images_to_include"],
468
+ properties: {
469
+ prompt: expect.objectContaining({ type: "string", minLength: 1 }),
470
+ transparent_background: { type: "boolean" },
471
+ num_last_images_to_include: { type: "integer", minimum: 0, maximum: 5 },
472
+ },
473
+ },
474
+ })]);
475
+ return events({ type: "text-delta", text: "hello" }, { type: "completed", responseId: "r" });
476
+ });
477
+ const d = dispatcher(run);
478
+ await d.onTokenAdded(1, "one", credentials);
479
+ const result = await d.generate(request({ imageGeneration: "auto" }));
480
+ expect(run).toHaveBeenCalledOnce();
481
+ expect(result.text).toBe("hello");
482
+ expect(result.images).toBeUndefined();
483
+ });
484
+
485
+ it.each(["zero-usage", "model-config"])("does not treat completed %s metadata as hosted image execution", async (metadata) => {
486
+ const completed = normalizeOpenAIEvent({
487
+ type: "response.completed",
488
+ response: {
489
+ id: "text-response", model: "gpt-6-luna",
490
+ usage: { input_tokens: 164, output_tokens: 9, total_tokens: 173 },
491
+ tools: metadata === "model-config"
492
+ ? [{ type: "image_generation", model: "gpt-image-2-codex" }]
493
+ : [{ type: "function", name: "generate_image" }],
494
+ ...(metadata === "zero-usage" ? { tool_usage: { image_gen: {
495
+ input_tokens: 0, output_tokens: 0, total_tokens: 0,
496
+ input_tokens_details: { text_tokens: 0, image_tokens: 0 },
497
+ output_tokens_details: { image_tokens: 0, text_tokens: 0 },
498
+ } } } : {}),
499
+ output: [{ type: "message" }],
500
+ },
501
+ });
502
+ expect(completed).toMatchObject({ type: "completed", imageGeneration: { route: "codex-responses" } });
503
+ const run = vi.fn(() => events({ type: "text-delta", text: "hello" }, completed!));
504
+ const d = dispatcher(run);
505
+ await d.onTokenAdded(1, "one", credentials);
506
+ const result = await d.generate(request({ imageGeneration: "auto" }));
507
+ expect(result).toMatchObject({ text: "hello", model: "gpt-6-luna", usage: { inputTokens: 164, outputTokens: 9, totalTokens: 173 } });
508
+ expect(result.images).toBeUndefined();
509
+ expect(run).toHaveBeenCalledOnce();
428
510
  });
429
511
 
430
- it("runs one standalone transparent call after completed arguments and preserves driver accounting", async () => {
512
+ it.each([false, true])("runs a standalone image call (transparent: %s) and preserves driver and image usage", async (transparent) => {
431
513
  const calls: OpenAIResponsesOptions[] = [];
432
514
  const signals: Array<AbortSignal | undefined> = [];
433
- const item = { type: "function_call", call_id: "call", name: "generate_transparent_image", arguments: JSON.stringify({ prompt: "remove background" }) };
515
+ const item = { type: "function_call", call_id: "call", name: "generate_image", arguments: JSON.stringify({ prompt: "a complete new image prompt", transparent_background: transparent, num_last_images_to_include: 0 }) };
516
+ const generation = {
517
+ route: "codex-images" as const, model: "gpt-image-2", size: "1024x1024", quality: "medium",
518
+ background: transparent ? "transparent" : "opaque",
519
+ usage: { input_tokens: 20, input_tokens_details: { text_tokens: 20, image_tokens: 0 }, output_tokens: 100, total_tokens: 120 },
520
+ };
434
521
  const d = dispatcher((options, signal) => {
435
522
  calls.push(options); signals.push(signal);
436
523
  if (calls.length === 1) return events(
@@ -439,29 +526,77 @@ describe("OpenAIDispatcher direct runtime", () => {
439
526
  { type: "output-item", item, completed: true },
440
527
  { type: "completed", responseId: "driver", model: "actual-driver", usage: { inputTokens: 10, cachedInputTokens: 2, outputTokens: 3, reasoningTokens: 1, totalTokens: 13 } },
441
528
  );
442
- return events({ type: "image", base64: "png", mimeType: "image/png", generation: { route: "codex-images", model: "gpt-image-2" } }, { type: "completed", responseId: "image", model: "gpt-image-2", usage: { inputTokens: 999, outputTokens: 999, cachedInputTokens: 0, reasoningTokens: 0, totalTokens: 1998 } });
529
+ return events({ type: "image", base64: "png", mimeType: "image/png", generation }, { type: "completed", responseId: "image", model: "gpt-image-2", usage: { inputTokens: 999, outputTokens: 999, cachedInputTokens: 0, reasoningTokens: 0, totalTokens: 1998 } });
443
530
  });
444
531
  await d.onTokenAdded(1, "one", credentials);
445
532
  const history = [{ type: "message", role: "user", content: [{ type: "input_image", image_url: "https://example.com/ref.png" }] }];
446
- const result = await d.generate(request({ imageGeneration: "auto", systemPrompt: "system", coldHistory: history, imageGenerationOptions: { outputFormat: "webp" } }));
533
+ const result = await d.generate(request({ imageGeneration: "auto", systemPrompt: "system", coldHistory: history, imageGenerationOptions: { quality: "high", size: "1024x1536" } }));
447
534
  expect(calls).toHaveLength(2);
448
- expect(calls[1]).toMatchObject({ instructions: "system", imageGeneration: { background: "transparent", output_format: "png" } });
449
- expect(calls[1]!.history[0]).toEqual(history[0]);
535
+ expect(calls[0]!.instructions).toContain("system");
536
+ expect(calls[0]!.history[0]).toEqual(history[0]);
537
+ expect(calls[1]).toEqual({
538
+ model: "gpt-image-2", imageGeneration: { background: transparent ? "transparent" : "opaque", output_format: "png", quality: "high", size: "1024x1536" },
539
+ history: [{ type: "message", role: "user", content: [{ type: "input_text", text: "a complete new image prompt" }] }],
540
+ });
541
+ expect(buildStandaloneImageRequest(calls[1]!).url).toMatch(/\/images\/generations$/);
450
542
  expect(calls[1]!.tools).toBeUndefined();
451
543
  expect(calls[1]!.outputSchema).toBeUndefined();
452
544
  expect(signals[0]).toBe(signals[1]);
453
- expect(result).toMatchObject({ model: "actual-driver", usage: { inputTokens: 10, outputTokens: 3, totalTokens: 13 }, images: [{ generation: { model: "gpt-image-2" } }] });
545
+ expect(result).toMatchObject({ model: "actual-driver", usage: { inputTokens: 10, outputTokens: 3, totalTokens: 13, cachedInputTokens: 2, reasoningOutputTokens: 1 }, images: [{ generation }] });
546
+ expect(d.clients.size).toBe(1);
547
+ });
548
+
549
+ it("edits with only the selected recent images in chronological order despite longer image history", async () => {
550
+ const calls: OpenAIResponsesOptions[] = [];
551
+ const history = [
552
+ ...Array.from({ length: 6 }, (_, i) => ({ type: "message", role: "user", content: [{ type: "input_image", image_url: `https://example.com/${i}.png` }] })),
553
+ { type: "image_generation_call", result: "UklGRgAAAABXRUJQ", output_format: "webp" },
554
+ { type: "message", role: "user", content: [{ type: "image_url", image_url: { url: "https://example.com/last.png" } }] },
555
+ ];
556
+ const d = dispatcher((options) => {
557
+ calls.push(options);
558
+ if (calls.length === 1) return events(
559
+ { type: "output-item", completed: true, item: { type: "function_call", call_id: "edit", name: "generate_image", arguments: JSON.stringify({ prompt: "combine these references", transparent_background: false, num_last_images_to_include: 3 }) } },
560
+ { type: "completed", responseId: "driver" },
561
+ );
562
+ return events({ type: "image", base64: "png", mimeType: "image/png" }, { type: "completed", responseId: "image" });
563
+ });
564
+ await d.onTokenAdded(1, "one", credentials);
565
+ await d.generate(request({ imageGeneration: "auto", coldHistory: history, coldInput: [{ type: "image", url: "https://example.com/current.png" }] }));
566
+ expect(calls[0]!.history).toHaveLength(history.length + 1);
567
+ const direct = buildStandaloneImageRequest(calls[1]!);
568
+ expect(direct.url).toMatch(/\/images\/edits$/);
569
+ expect(direct.body.prompt).toBe("user:\ncombine these references");
570
+ expect(direct.body.images).toEqual([
571
+ { image_url: "data:image/webp;base64,UklGRgAAAABXRUJQ" },
572
+ { image_url: "https://example.com/last.png" },
573
+ { image_url: "https://example.com/current.png" },
574
+ ]);
575
+ expect(calls).toHaveLength(2);
454
576
  });
455
577
 
456
578
  it("rejects incomplete, invalid, multiple and mixed automatic image calls before standalone execution", async () => {
457
- const item = { type: "function_call", call_id: "call", name: "generate_transparent_image", arguments: JSON.stringify({ prompt: "remove background" }) };
579
+ const args = { prompt: "remove background", transparent_background: true, num_last_images_to_include: 0 };
580
+ const item = { type: "function_call", call_id: "call", name: "generate_image", arguments: JSON.stringify(args) };
458
581
  const done = { type: "output-item" as const, item, completed: true };
459
582
  const badEvents: OpenAINormalizedEvent[][] = [
460
583
  [{ ...done, completed: false }],
461
- [{ ...done, item: { ...item, arguments: '{"prompt":"x","extra":true}' } }],
584
+ ...[
585
+ "{", "[]", "null", JSON.stringify({ prompt: "x" }),
586
+ JSON.stringify({ ...args, extra: true }),
587
+ JSON.stringify({ ...args, prompt: " " }),
588
+ JSON.stringify({ ...args, transparent_background: "true" }),
589
+ ...[-1, 0.5, 6, "1"].map((count) => JSON.stringify({ ...args, num_last_images_to_include: count })),
590
+ // Valid count, but this conversation has no images.
591
+ JSON.stringify({ ...args, num_last_images_to_include: 1 }),
592
+ ].map((arguments_) => [{ ...done, item: { ...item, arguments: arguments_ } }]),
462
593
  [done, { ...done, item: { ...item, call_id: "second" } }],
594
+ [done, { ...done, item: { ...item, arguments: JSON.stringify({ ...args, transparent_background: false }) } }],
595
+ [{ ...done, item: { ...item, call_id: "" } }],
596
+ [{ ...done, item: { ...item, name: "unexpected" } }],
463
597
  [done, { type: "image", base64: "png", mimeType: "image/png" }],
464
598
  [{ type: "output-item", item: { type: "image_generation_call" }, completed: false }],
599
+ [{ type: "completed", responseId: "native", imageGeneration: { route: "codex-responses", usage: { input_tokens: 1, output_tokens: 0, total_tokens: 1 } } }],
465
600
  ];
466
601
  for (const stream of badEvents) {
467
602
  const run = vi.fn(() => events(...stream, { type: "completed", responseId: "driver" }));
@@ -472,6 +607,25 @@ describe("OpenAIDispatcher direct runtime", () => {
472
607
  }
473
608
  });
474
609
 
610
+ it("requires driver completion before running its image call", async () => {
611
+ const run = vi.fn(() => events({ type: "output-item", completed: true, item: {
612
+ type: "function_call", call_id: "call", name: "generate_image",
613
+ arguments: JSON.stringify({ prompt: "draw", transparent_background: false, num_last_images_to_include: 0 }),
614
+ } }));
615
+ const d = dispatcher(run);
616
+ await d.onTokenAdded(1, "one", credentials);
617
+ await expect(d.generate(request({ imageGeneration: "auto" }))).rejects.toMatchObject({ kind: "incomplete" });
618
+ expect(run).toHaveBeenCalledOnce();
619
+ });
620
+
621
+ it.each(["jpeg", "webp"] as const)("rejects automatic %s output before driver transport", async (outputFormat) => {
622
+ const run = vi.fn(() => events());
623
+ const d = dispatcher(run);
624
+ await d.onTokenAdded(1, "one", credentials);
625
+ await expect(d.generate(request({ imageGeneration: "auto", imageGenerationOptions: { outputFormat } }))).rejects.toMatchObject({ kind: "gate", message: expect.stringContaining("PNG") });
626
+ expect(run).not.toHaveBeenCalled();
627
+ });
628
+
475
629
  it("rejects structured output in auto mode before transport", async () => {
476
630
  const run = vi.fn(() => events());
477
631
  const d = dispatcher(run);
@@ -480,38 +634,75 @@ describe("OpenAIDispatcher direct runtime", () => {
480
634
  expect(run).not.toHaveBeenCalled();
481
635
  });
482
636
 
483
- it.each(["missing-image", "missing-completion", "cancel"])("rejects standalone %s", async (failure) => {
637
+ it.each(["missing-image", "missing-completion", "cancel", "error", "network", "references"])("keeps completed driver accounting after standalone %s", async (failure) => {
484
638
  const controller = new AbortController();
639
+ const networkError = new TypeError("fetch failed", { cause: new Error("connection reset") });
485
640
  let count = 0;
486
641
  const d = dispatcher(async function* (_options, signal) {
487
642
  count++;
488
643
  if (count === 1) {
489
- yield { type: "output-item", completed: true, item: { type: "function_call", call_id: "call", name: "generate_transparent_image", arguments: '{"prompt":"transparent"}' } };
490
- yield { type: "completed", responseId: "driver" };
644
+ yield { type: "text-delta", text: "I will draw it." };
645
+ yield { type: "output-item", completed: true, item: { type: "function_call", call_id: "call", name: "generate_image", arguments: JSON.stringify({ prompt: "transparent", transparent_background: true, num_last_images_to_include: failure === "references" ? 1 : 0 }) } };
646
+ yield { type: "completed", responseId: "driver", model: "gpt-6-luna", usage: { inputTokens: 100, outputTokens: 20, cachedInputTokens: 10, reasoningTokens: 5, totalTokens: 120 } };
491
647
  } else {
492
648
  if (failure === "cancel") { controller.abort(); expect(signal?.aborted).toBe(true); throw signal!.reason; }
649
+ if (failure === "network") throw networkError;
650
+ if (failure === "error") { yield { type: "error", error: new Error("image failed") }; return; }
493
651
  if (failure === "missing-image") yield { type: "completed", responseId: "image" };
494
652
  else yield { type: "image", base64: "png", mimeType: "image/png" };
495
653
  }
496
654
  });
497
655
  await d.onTokenAdded(1, "one", credentials);
498
- await expect(d.generate(request({ imageGeneration: "auto", abortSignal: controller.signal }))).rejects.toMatchObject(failure === "cancel" ? { name: "AbortError" } : { kind: "incomplete" });
499
- expect(count).toBe(2);
656
+ const error = await d.generate(request({ imageGeneration: "auto", abortSignal: controller.signal })).catch((error) => error);
657
+ expect(error).toMatchObject({
658
+ kind: "incomplete",
659
+ name: failure === "cancel" ? "AbortError" : failure === "network" ? "TypeError" : "ImageGenerationError",
660
+ completedDriver: {
661
+ text: "I will draw it.", tokenName: "one", model: "gpt-6-luna", requestedModel: "gpt-test",
662
+ usage: { inputTokens: 100, outputTokens: 20, cachedInputTokens: 10, reasoningOutputTokens: 5, totalTokens: 120 },
663
+ durationMs: expect.any(Number), ttftMs: expect.any(Number),
664
+ },
665
+ });
666
+ expect(error.completedDriver.images).toBeUndefined();
667
+ if (failure === "network") {
668
+ expect(error.message).toBe(networkError.message);
669
+ expect(error.cause).toBe(networkError);
670
+ expect(error.cause.cause.message).toBe("connection reset");
671
+ }
672
+ expect(count).toBe(failure === "references" ? 1 : 2);
500
673
  });
501
674
 
502
675
  it("does not start standalone generation after cancellation", async () => {
503
676
  const controller = new AbortController();
504
677
  const run = vi.fn(async function* () {
505
- yield { type: "output-item" as const, completed: true, item: { type: "function_call", call_id: "call", name: "generate_transparent_image", arguments: '{"prompt":"transparent"}' } };
678
+ yield { type: "output-item" as const, completed: true, item: { type: "function_call", call_id: "call", name: "generate_image", arguments: '{"prompt":"transparent","transparent_background":true,"num_last_images_to_include":0}' } };
506
679
  yield { type: "completed" as const, responseId: "driver" };
507
680
  controller.abort();
508
681
  });
509
682
  const d = dispatcher(run);
510
683
  await d.onTokenAdded(1, "one", credentials);
511
- await expect(d.generate(request({ imageGeneration: "auto", abortSignal: controller.signal }))).rejects.toMatchObject({ name: "AbortError" });
684
+ await expect(d.generate(request({ imageGeneration: "auto", abortSignal: controller.signal }))).rejects.toMatchObject({ name: "AbortError", completedDriver: { tokenName: "one" } });
512
685
  expect(run).toHaveBeenCalledTimes(1);
513
686
  });
514
687
 
688
+ it.each(["incomplete-driver", "direct-image", "plain-text"])("does not invent completed driver accounting for %s", async (path) => {
689
+ const failure = new Error("generation failed");
690
+ const d = dispatcher(async function* () {
691
+ if (path === "incomplete-driver") yield { type: "output-item", completed: true, item: {
692
+ type: "function_call", call_id: "call", name: "generate_image", arguments: '{"prompt":"draw","transparent_background":false,"num_last_images_to_include":0}',
693
+ } };
694
+ else yield { type: "completed", responseId: "response" };
695
+ throw failure;
696
+ });
697
+ await d.onTokenAdded(1, "one", credentials);
698
+ const error = await d.generate(request({
699
+ imageGeneration: path === "incomplete-driver" ? "auto" : path === "direct-image" ? true : undefined,
700
+ model: path === "direct-image" ? "gpt-image-2" : "gpt-6-luna",
701
+ })).catch((error) => error);
702
+ expect(error).toBe(failure);
703
+ expect(error.completedDriver).toBeUndefined();
704
+ });
705
+
515
706
  it("maps images and preserves image failure classifications", async () => {
516
707
  const success = dispatcher(() =>
517
708
  events(