@cartanova/qgrid-cli 2.10.4 → 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 (81) 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 +6 -6
  5. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +24 -7
  6. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +8 -3
  7. package/bundle/dist/application/qgrid/qgrid.frame.js +25 -11
  8. package/bundle/dist/application/qgrid/qgrid.types.js +4 -8
  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 +23 -17
  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 +31 -9
  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 +34 -5
  23. package/bundle/src/application/qgrid/qgrid-image-generation.ts +17 -13
  24. package/bundle/src/application/qgrid/qgrid-response-format.test.ts +11 -0
  25. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +87 -9
  26. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +45 -5
  27. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +136 -2
  28. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +11 -1
  29. package/bundle/src/application/qgrid/qgrid.frame.test.ts +73 -0
  30. package/bundle/src/application/qgrid/qgrid.frame.ts +43 -6
  31. package/bundle/src/application/qgrid/qgrid.types.ts +14 -4
  32. package/bundle/src/application/request-log/request-log.entity.json +2 -2
  33. package/bundle/src/application/request-log/request-log.model.test.ts +17 -12
  34. package/bundle/src/application/request-log/request-log.model.ts +8 -2
  35. package/bundle/src/application/sonamu.generated.http +2 -2
  36. package/bundle/src/i18n/sd.generated.ts +2 -2
  37. package/bundle/src/utils/providers/openai/openai-backend-protocol.test.ts +42 -2
  38. package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +46 -2
  39. package/bundle/src/utils/providers/openai/openai-direct-client.test.ts +15 -0
  40. package/bundle/src/utils/providers/openai/openai-direct-client.ts +14 -4
  41. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +235 -32
  42. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +174 -72
  43. package/bundle/src/utils/providers/openai/openai-image-generation.test.ts +89 -0
  44. package/bundle/src/utils/providers/openai/openai-image-generation.ts +26 -15
  45. package/bundle/web-dist/client/assets/cost-DH2jZbrw.js +1 -0
  46. package/bundle/web-dist/client/assets/{index-reW8VvJF.js → index-D9ryIyL-.js} +4 -4
  47. package/bundle/web-dist/client/assets/logs-CLf2VNfk.js +1 -0
  48. package/bundle/web-dist/client/assets/{monit-DkABv7PG.js → monit-DojxOLj6.js} +1 -1
  49. package/bundle/web-dist/client/assets/{routes-BRmQ4M3-.js → routes-DXJhNvbI.js} +2 -2
  50. package/bundle/web-dist/client/assets/{services.generated-jnPHYMER.js → services.generated-DqFYgWsY.js} +1 -1
  51. package/bundle/web-dist/client/assets/{settings-BHvtycEZ.js → settings-bopyz9pa.js} +1 -1
  52. package/bundle/web-dist/client/assets/{show-qeTIzgDZ.js → show-DbLD3MzB.js} +1 -1
  53. package/bundle/web-dist/client/assets/{tokens-5Bep_G-w.js → tokens-2-AIi9sZ.js} +1 -1
  54. package/bundle/web-dist/client/assets/{use-oauth-login-flow-Cx-QO25M.js → use-oauth-login-flow-CnqTWs_H.js} +1 -1
  55. package/bundle/web-dist/client/assets/{use-update-token-mutation-DQCwCYfR.js → use-update-token-mutation-Cxb9K0fM.js} +1 -1
  56. package/bundle/web-dist/client/index.html +2 -2
  57. package/bundle/web-dist/server/assets/{cost-CSH_t17I.js → cost-eEHeTRT5.js} +5 -1
  58. package/bundle/web-dist/server/assets/{lazyRouteComponent-CUR-kWzI.js → lazyRouteComponent-Bi0fYGLs.js} +2 -2
  59. package/bundle/web-dist/server/assets/{logs-CagNGTBj.js → logs-CIVFxfoi.js} +11 -8
  60. package/bundle/web-dist/server/assets/{monit-DSxs4Jnl.js → monit-DBXezg7R.js} +4 -4
  61. package/bundle/web-dist/server/assets/{plus-Bvdj8Pwx.js → plus-BH-t4cm_.js} +1 -1
  62. package/bundle/web-dist/server/assets/{react-dom-CyuShs9K.js → react-dom-CZ2eynd8.js} +1 -1
  63. package/bundle/web-dist/server/assets/{refresh-cw-3QAiSWBd.js → refresh-cw-CosAdcz0.js} +1 -1
  64. package/bundle/web-dist/server/assets/{routes-i-sTqXMX.js → routes-COWYIvA-.js} +5 -6
  65. package/bundle/web-dist/server/assets/{services.generated-FPwsG9p3.js → services.generated-607iwhMI.js} +2 -2
  66. package/bundle/web-dist/server/assets/{settings-DeHNwoMv.js → settings-C_6dpK5p.js} +3 -3
  67. package/bundle/web-dist/server/assets/{show-C4jk1Hrp.js → show-DY396-LD.js} +8 -20
  68. package/bundle/web-dist/server/assets/{tokens-odgS2-9q.js → tokens-COvV7qUP.js} +4 -4
  69. package/bundle/web-dist/server/assets/{use-oauth-login-flow-DTQKdYBO.js → use-oauth-login-flow-VYCYf3uv.js} +1 -1
  70. package/bundle/web-dist/server/assets/{use-update-token-mutation-xvXbPrCI.js → use-update-token-mutation-D8XYV5of.js} +1 -1
  71. package/bundle/web-dist/server/entry-server.generated.js +10 -10
  72. package/package.json +3 -2
  73. package/skills/qgrid/SKILL.md +9 -8
  74. package/skills/qgrid/references/ai-sdk-provider-contract.md +25 -16
  75. package/skills/qgrid/references/decision-rationale.md +8 -8
  76. package/skills/qgrid/references/openai-codex-runtime.md +23 -11
  77. package/skills/qgrid/references/request-log-run-lifecycle.md +4 -2
  78. package/skills/qgrid/references/sonamu-api-web-flow.md +2 -2
  79. package/skills/qgrid/references/verification-and-debugging.md +23 -9
  80. package/bundle/web-dist/client/assets/cost-CpjVoAqp.js +0 -1
  81. package/bundle/web-dist/client/assets/logs-DDIt97D1.js +0 -1
@@ -1,6 +1,6 @@
1
1
  import os from "node:os";
2
2
 
3
- import { type ImageGenerationMetadata } from "../../../application/qgrid/qgrid.types";
3
+ import { ImageGenerationMetadata } from "../../../application/qgrid/qgrid.types";
4
4
  import { type OpenAIEffort } from "../common/effort";
5
5
 
6
6
  export const CHATGPT_CODEX_RESPONSES_URL =
@@ -180,7 +180,13 @@ export type OpenAINormalizedEvent =
180
180
  generation?: ImageGenerationMetadata;
181
181
  }
182
182
  | { type: "output-item"; item: OpenAIResponseItem; completed?: boolean }
183
- | { type: "completed"; responseId: string; usage?: OpenAIUsage; model?: string }
183
+ | {
184
+ type: "completed";
185
+ responseId: string;
186
+ usage?: OpenAIUsage;
187
+ model?: string;
188
+ imageGeneration?: ImageGenerationMetadata;
189
+ }
184
190
  | { type: "error"; error: OpenAIProtocolError };
185
191
 
186
192
  export class OpenAIProtocolError extends Error {
@@ -248,11 +254,33 @@ export function normalizeOpenAIEvent(raw: unknown): OpenAINormalizedEvent | unde
248
254
  ) {
249
255
  const item = event.item as OpenAIResponseItem;
250
256
  if (item.type === "image_generation_call" && typeof item.result === "string") {
257
+ // PNG exposes dimensions in its fixed header. Other formats retain the
258
+ // backend's observed size; never substitute requested image controls.
259
+ const header = Buffer.from(item.result.slice(0, 32), "base64");
260
+ const pngSize =
261
+ header.length >= 24 &&
262
+ header.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex")) &&
263
+ header.readUInt32BE(16) > 0 &&
264
+ header.readUInt32BE(20) > 0
265
+ ? `${header.readUInt32BE(16)}x${header.readUInt32BE(20)}`
266
+ : undefined;
267
+ const size =
268
+ pngSize ??
269
+ (typeof item.size === "string" && /^[1-9]\d*x[1-9]\d*$/.test(item.size)
270
+ ? item.size
271
+ : undefined);
251
272
  return {
252
273
  type: "image",
253
274
  ...(typeof item.id === "string" ? { id: item.id } : {}),
254
275
  base64: item.result,
255
276
  mimeType: imageMimeType(item.result, item.output_format),
277
+ generation: {
278
+ route: "codex-responses",
279
+ ...(size ? { size } : {}),
280
+ ...(typeof item.model === "string" ? { model: item.model } : {}),
281
+ ...(typeof item.quality === "string" ? { quality: item.quality } : {}),
282
+ ...(typeof item.background === "string" ? { background: item.background } : {}),
283
+ },
256
284
  ...(typeof item.revised_prompt === "string"
257
285
  ? { revisedPrompt: item.revised_prompt }
258
286
  : typeof item.revisedPrompt === "string"
@@ -264,11 +292,27 @@ export function normalizeOpenAIEvent(raw: unknown): OpenAINormalizedEvent | unde
264
292
  }
265
293
  if (type === "response.completed") {
266
294
  const response = (event.response ?? {}) as Record<string, unknown>;
295
+ const toolUsage = response.tool_usage as Record<string, unknown> | undefined;
296
+ const parsedImageUsage = ImageGenerationMetadata.shape.usage.safeParse(toolUsage?.image_gen);
297
+ const imageUsage = parsedImageUsage.success ? parsedImageUsage.data : undefined;
298
+ const imageTool = Array.isArray(response.tools)
299
+ ? response.tools.find((tool) => tool?.type === "image_generation")
300
+ : undefined;
301
+ const imageModel = typeof imageTool?.model === "string" ? imageTool.model : undefined;
267
302
  return {
268
303
  type: "completed",
269
304
  responseId: String(response.id ?? ""),
270
305
  ...(response.usage ? { usage: usageFrom(response.usage) } : {}),
271
306
  ...(typeof response.model === "string" ? { model: response.model } : {}),
307
+ ...(imageUsage || imageModel
308
+ ? {
309
+ imageGeneration: {
310
+ route: "codex-responses" as const,
311
+ ...(imageModel ? { model: imageModel } : {}),
312
+ ...(imageUsage ? { usage: imageUsage } : {}),
313
+ },
314
+ }
315
+ : {}),
272
316
  };
273
317
  }
274
318
  if (type === "error" || type === "response.failed" || type === "response.incomplete") {
@@ -29,6 +29,21 @@ async function collect<T>(iterable: AsyncIterable<T>): Promise<T[]> {
29
29
  }
30
30
 
31
31
  describe("OpenAI direct HTTPS client", () => {
32
+ it.each([true, { background: "opaque" }])("keeps chat-model images on Responses (%j)", async (imageGeneration) => {
33
+ const fetchMock = vi.fn<typeof fetch>(async () =>
34
+ sse({ type: "response.completed", response: { id: "hosted-image" } }),
35
+ );
36
+ const client = new OpenAIDirectClient({
37
+ credentials: { accessToken: "access", accountId: "acct" }, fetch: fetchMock,
38
+ });
39
+ await collect(client.responses({ model: "gpt-6-luna", history: [], imageGeneration }));
40
+ expect(fetchMock).toHaveBeenCalledTimes(1);
41
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(CHATGPT_CODEX_RESPONSES_URL);
42
+ expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({
43
+ model: "gpt-6-luna", tools: [expect.objectContaining({ type: "image_generation" })],
44
+ });
45
+ });
46
+
32
47
  it("posts the pinned body and Codex identity without live calls", async () => {
33
48
  const fetchMock = vi.fn<typeof fetch>(async () =>
34
49
  sse(
@@ -450,8 +450,9 @@ export class OpenAIDirectClient {
450
450
 
451
451
  responses(options: OpenAIResponsesOptions, signal?: AbortSignal): OpenAIEventStream {
452
452
  if (
453
- typeof options.imageGeneration === "object" &&
454
- options.imageGeneration.background === "transparent"
453
+ options.model === "gpt-image-2" ||
454
+ (typeof options.imageGeneration === "object" &&
455
+ options.imageGeneration.background === "transparent")
455
456
  ) {
456
457
  return this.standaloneImages(options, signal);
457
458
  }
@@ -498,10 +499,19 @@ export class OpenAIDirectClient {
498
499
  continue;
499
500
  }
500
501
  if (!response.ok) throw await responseError(response);
501
- const images = await readStandaloneImageResponse(response);
502
+ const images = await readStandaloneImageResponse(
503
+ response,
504
+ typeof options.imageGeneration === "object" &&
505
+ options.imageGeneration.background === "transparent",
506
+ );
502
507
  signal?.throwIfAborted();
503
508
  yield* images;
504
- yield { type: "completed", responseId: requestId, model: "gpt-image-2" };
509
+ const firstImage = images[0];
510
+ yield {
511
+ type: "completed",
512
+ responseId: requestId,
513
+ model: firstImage?.type === "image" ? firstImage.generation?.model : "gpt-image-2",
514
+ };
505
515
  return;
506
516
  }
507
517
  }
@@ -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);
@@ -144,6 +146,18 @@ describe("OpenAIDispatcher direct runtime", () => {
144
146
  }
145
147
  });
146
148
 
149
+ it.each(["auto", "1152x2560"])("forwards %s size hints and preserves hosted observations", async (size) => {
150
+ const args = QueryInput.parse({ prompt: "draw in 9:20", imageGeneration: true, imageGenerationOptions: { size } });
151
+ const generation = { route: "codex-responses" as const, size: "841x1870", quality: "low" };
152
+ const d = dispatcher((options) => {
153
+ expect(buildOpenAIResponsesRequest(options).tools).toContainEqual({ type: "image_generation", size });
154
+ return events({ type: "image", base64: "data", mimeType: "image/png", generation }, { type: "completed", responseId: "r" });
155
+ });
156
+ await d.onTokenAdded(1, "one", credentials);
157
+ const result = await d.generate(request({ imageGeneration: args.imageGeneration, imageGenerationOptions: args.imageGenerationOptions }));
158
+ expect(result.images).toMatchObject([{ generation }]);
159
+ });
160
+
147
161
  beforeEach(() => { refreshMock.mockReset(); });
148
162
 
149
163
  it("refreshes a quota 401 once, shares the recovery, and uses the new credentials", async () => {
@@ -394,31 +408,116 @@ describe("OpenAIDispatcher direct runtime", () => {
394
408
  ));
395
409
  const d = dispatcher(run);
396
410
  await d.onTokenAdded(1, "one", credentials);
397
- const result = await d.generate(request({ imageGeneration: "auto" }));
411
+ const result = await d.generate(request({ imageGeneration: true }));
398
412
  expect(result.images).toMatchObject([{ data: "first", mediaType: "image/png" }, { data: "second", mediaType: "image/webp" }]);
399
413
  expect(run).toHaveBeenCalledTimes(1);
400
414
  });
401
415
 
402
- it("lets automatic mode return text or a hosted image with one driver request", async () => {
403
- for (const image of [false, true]) {
404
- const run = vi.fn((options: OpenAIResponsesOptions) => {
405
- const body = buildOpenAIResponsesRequest(options);
406
- expect(body.parallel_tool_calls).toBe(false);
407
- expect(body.tools).toEqual(expect.arrayContaining([{ type: "image_generation" }, expect.objectContaining({ name: "generate_transparent_image" })]));
408
- 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" });
409
- });
410
- const d = dispatcher(run);
411
- await d.onTokenAdded(1, "one", credentials);
412
- const result = await d.generate(request({ imageGeneration: "auto" }));
413
- expect(run).toHaveBeenCalledTimes(1);
414
- expect(image ? result.images?.length : result.text).toBe(image ? 1 : "hello");
415
- }
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();
416
510
  });
417
511
 
418
- 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) => {
419
513
  const calls: OpenAIResponsesOptions[] = [];
420
514
  const signals: Array<AbortSignal | undefined> = [];
421
- 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
+ };
422
521
  const d = dispatcher((options, signal) => {
423
522
  calls.push(options); signals.push(signal);
424
523
  if (calls.length === 1) return events(
@@ -427,29 +526,77 @@ describe("OpenAIDispatcher direct runtime", () => {
427
526
  { type: "output-item", item, completed: true },
428
527
  { type: "completed", responseId: "driver", model: "actual-driver", usage: { inputTokens: 10, cachedInputTokens: 2, outputTokens: 3, reasoningTokens: 1, totalTokens: 13 } },
429
528
  );
430
- 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 } });
431
530
  });
432
531
  await d.onTokenAdded(1, "one", credentials);
433
532
  const history = [{ type: "message", role: "user", content: [{ type: "input_image", image_url: "https://example.com/ref.png" }] }];
434
- 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" } }));
435
534
  expect(calls).toHaveLength(2);
436
- expect(calls[1]).toMatchObject({ instructions: "system", imageGeneration: { background: "transparent", output_format: "png" } });
437
- 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$/);
438
542
  expect(calls[1]!.tools).toBeUndefined();
439
543
  expect(calls[1]!.outputSchema).toBeUndefined();
440
544
  expect(signals[0]).toBe(signals[1]);
441
- 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);
442
576
  });
443
577
 
444
578
  it("rejects incomplete, invalid, multiple and mixed automatic image calls before standalone execution", async () => {
445
- 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) };
446
581
  const done = { type: "output-item" as const, item, completed: true };
447
582
  const badEvents: OpenAINormalizedEvent[][] = [
448
583
  [{ ...done, completed: false }],
449
- [{ ...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_ } }]),
450
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" } }],
451
597
  [done, { type: "image", base64: "png", mimeType: "image/png" }],
452
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 } } }],
453
600
  ];
454
601
  for (const stream of badEvents) {
455
602
  const run = vi.fn(() => events(...stream, { type: "completed", responseId: "driver" }));
@@ -460,6 +607,25 @@ describe("OpenAIDispatcher direct runtime", () => {
460
607
  }
461
608
  });
462
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
+
463
629
  it("rejects structured output in auto mode before transport", async () => {
464
630
  const run = vi.fn(() => events());
465
631
  const d = dispatcher(run);
@@ -468,38 +634,75 @@ describe("OpenAIDispatcher direct runtime", () => {
468
634
  expect(run).not.toHaveBeenCalled();
469
635
  });
470
636
 
471
- 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) => {
472
638
  const controller = new AbortController();
639
+ const networkError = new TypeError("fetch failed", { cause: new Error("connection reset") });
473
640
  let count = 0;
474
641
  const d = dispatcher(async function* (_options, signal) {
475
642
  count++;
476
643
  if (count === 1) {
477
- yield { type: "output-item", completed: true, item: { type: "function_call", call_id: "call", name: "generate_transparent_image", arguments: '{"prompt":"transparent"}' } };
478
- 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 } };
479
647
  } else {
480
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; }
481
651
  if (failure === "missing-image") yield { type: "completed", responseId: "image" };
482
652
  else yield { type: "image", base64: "png", mimeType: "image/png" };
483
653
  }
484
654
  });
485
655
  await d.onTokenAdded(1, "one", credentials);
486
- await expect(d.generate(request({ imageGeneration: "auto", abortSignal: controller.signal }))).rejects.toMatchObject(failure === "cancel" ? { name: "AbortError" } : { kind: "incomplete" });
487
- 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);
488
673
  });
489
674
 
490
675
  it("does not start standalone generation after cancellation", async () => {
491
676
  const controller = new AbortController();
492
677
  const run = vi.fn(async function* () {
493
- 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}' } };
494
679
  yield { type: "completed" as const, responseId: "driver" };
495
680
  controller.abort();
496
681
  });
497
682
  const d = dispatcher(run);
498
683
  await d.onTokenAdded(1, "one", credentials);
499
- 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" } });
500
685
  expect(run).toHaveBeenCalledTimes(1);
501
686
  });
502
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
+
503
706
  it("maps images and preserves image failure classifications", async () => {
504
707
  const success = dispatcher(() =>
505
708
  events(