@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
@@ -14,6 +14,7 @@ import {
14
14
  import { SmoothWeightedRoundRobin } from "../common/smooth-weighted-round-robin";
15
15
  import { ThinkingValidationError } from "../common/thinking";
16
16
  import {
17
+ imageMimeType,
17
18
  type JsonValue,
18
19
  type OpenAIResponseItem,
19
20
  type OpenAIResponsesOptions,
@@ -34,16 +35,25 @@ const THINKING_OFF_MODELS = new Set([
34
35
  "gpt-5.5",
35
36
  ]);
36
37
  const DEFAULT_REQUEST_TIMEOUT_MS = 600_000;
37
- const TRANSPARENT_IMAGE_TOOL = "generate_transparent_image";
38
+ const IMAGE_TOOL = "generate_image";
39
+
40
+ type ImageToolArguments = {
41
+ prompt: string;
42
+ transparent_background: boolean;
43
+ num_last_images_to_include: number;
44
+ };
38
45
 
39
46
  export type ImageFailureKind = "gate" | "not_called" | "incomplete";
40
47
 
41
48
  export class ImageGenerationError extends Error {
49
+ completedDriver?: GenerateResult;
50
+
42
51
  constructor(
43
52
  readonly kind: ImageFailureKind,
44
53
  message: string,
54
+ options?: ErrorOptions,
45
55
  ) {
46
- super(message);
56
+ super(message, options);
47
57
  this.name = "ImageGenerationError";
48
58
  }
49
59
  }
@@ -104,6 +114,26 @@ function inputItems(req: GenerateRequest): OpenAIResponseItem[] {
104
114
  return [...history, { type: "message", role: "user", content }];
105
115
  }
106
116
 
117
+ function imageReferences(req: GenerateRequest): OpenAIResponseItem[] {
118
+ return inputItems(req).flatMap((item) => {
119
+ if (item.type === "image_generation_call" && typeof item.result === "string") {
120
+ return [
121
+ {
122
+ type: "input_image",
123
+ image_url: `data:${imageMimeType(item.result, item.output_format)};base64,${item.result}`,
124
+ },
125
+ ];
126
+ }
127
+ if (!Array.isArray(item.content)) return [];
128
+ return item.content.filter(
129
+ (part): part is OpenAIResponseItem =>
130
+ part !== null &&
131
+ typeof part === "object" &&
132
+ (part.type === "input_image" || part.type === "image_url"),
133
+ );
134
+ });
135
+ }
136
+
107
137
  function asReasoning(req: GenerateRequest): OpenAIResponsesOptions["reasoning"] | undefined {
108
138
  if (req.thinking === false) return { effort: "none" };
109
139
  const summary =
@@ -130,6 +160,9 @@ function requestOptions(req: GenerateRequest): OpenAIResponsesOptions {
130
160
  "Automatic image generation does not support structured output or external tools",
131
161
  );
132
162
  }
163
+ if (auto && outputFormat && outputFormat !== "png") {
164
+ throw new ImageGenerationError("gate", "Automatic image generation only supports PNG output");
165
+ }
133
166
  return {
134
167
  model: req.model ?? "",
135
168
  ...(req.systemPrompt ? { instructions: req.systemPrompt } : {}),
@@ -137,7 +170,7 @@ function requestOptions(req: GenerateRequest): OpenAIResponsesOptions {
137
170
  ? {
138
171
  instructions: [
139
172
  req.systemPrompt,
140
- "Use image_generation for ordinary image generation or editing. Use generate_transparent_image only when a transparent background is requested. Text replies are allowed when no image is needed. Use only one kind of image tool per response, and at most one generate_transparent_image call.",
173
+ "Use generate_image when image generation or editing is needed; otherwise reply with text. Call it at most once per response. Supply a complete, self-contained prompt with all relevant conversation instructions. Set transparent_background to true only when a transparent background is requested. Set num_last_images_to_include to 0 for a new image without references. For editing or reference-based generation, choose the smallest number of recent conversation images that includes all needed references, at most 5. References retain their chronological order. Do not select images for an unrelated new request.",
141
174
  ]
142
175
  .filter(Boolean)
143
176
  .join("\n\n"),
@@ -145,20 +178,24 @@ function requestOptions(req: GenerateRequest): OpenAIResponsesOptions {
145
178
  tools: [
146
179
  {
147
180
  type: "function",
148
- name: TRANSPARENT_IMAGE_TOOL,
181
+ name: IMAGE_TOOL,
149
182
  description:
150
- "Generate or edit an image with a transparent background using the conversation's reference images.",
151
- strict: true,
183
+ "Generate or edit an image, optionally with a transparent background or recent conversation images as references.",
184
+ // Astra's subscription backend rejects strict tools; validate arguments locally.
185
+ strict: false,
152
186
  parameters: {
153
187
  type: "object",
154
188
  properties: {
155
189
  prompt: {
156
190
  type: "string",
191
+ minLength: 1,
157
192
  description:
158
- "Complete instructions for the transparent image generation or edit.",
193
+ "Complete, self-contained instructions for the image generation or edit.",
159
194
  },
195
+ transparent_background: { type: "boolean" },
196
+ num_last_images_to_include: { type: "integer", minimum: 0, maximum: 5 },
160
197
  },
161
- required: ["prompt"],
198
+ required: ["prompt", "transparent_background", "num_last_images_to_include"],
162
199
  additionalProperties: false,
163
200
  },
164
201
  },
@@ -173,12 +210,11 @@ function requestOptions(req: GenerateRequest): OpenAIResponsesOptions {
173
210
  : {}),
174
211
  ...(req.promptCacheKey ? { promptCacheKey: req.promptCacheKey } : {}),
175
212
  ...(req.outputSchema ? { outputSchema: { schema: req.outputSchema as JsonValue } } : {}),
176
- ...(req.imageGeneration
213
+ ...(req.imageGeneration && !auto
177
214
  ? {
178
215
  imageGeneration: req.imageGenerationOptions
179
216
  ? {
180
217
  ...imageControls,
181
- ...(auto ? { background: "auto" } : {}),
182
218
  ...(outputFormat ? { output_format: outputFormat } : {}),
183
219
  }
184
220
  : true,
@@ -368,12 +404,22 @@ export class OpenAIDispatcher implements ProviderDispatcher {
368
404
  const images: GeneratedImage[] = [];
369
405
  let imageAttempted = false;
370
406
  const auto = req.imageGeneration === "auto";
371
- const transparentCalls = new Map<string, string>();
372
- const transparentCallIds = new Set<unknown>();
373
- let transparentAttempted = false;
407
+ const imageCalls = new Map<string, ImageToolArguments>();
408
+ const imageCallIds = new Set<unknown>();
409
+ let imageToolAttempted = false;
374
410
  let driverCompleted = false;
375
411
  let servingModel = req.model ?? "";
376
412
  const metadata = selection.metadata;
413
+ const driverResult = (): GenerateResult => ({
414
+ text,
415
+ tokenName: metadata.name,
416
+ usage,
417
+ durationMs: Date.now() - startedAt,
418
+ ttftMs: firstDeltaAt === undefined ? null : firstDeltaAt - startedAt,
419
+ model: servingModel,
420
+ ...(servingModel !== req.model ? { requestedModel: req.model } : {}),
421
+ threadCoord: { workerId: selection.tokenId, threadId: req.promptCacheKey ?? "", epoch: -1 },
422
+ });
377
423
  const clientEntry = this.acquireClient(selection);
378
424
  try {
379
425
  for await (const event of clientEntry.client.responses(
@@ -428,6 +474,8 @@ export class OpenAIDispatcher implements ProviderDispatcher {
428
474
  streamedMessageIds.add(event.item.id);
429
475
  }
430
476
  } else if (event.type === "image") {
477
+ if (auto)
478
+ throw new ImageGenerationError("incomplete", "Unexpected hosted image generation");
431
479
  imageAttempted = true;
432
480
  images.push({
433
481
  data: event.base64,
@@ -436,20 +484,22 @@ export class OpenAIDispatcher implements ProviderDispatcher {
436
484
  ...(event.generation ? { generation: event.generation } : {}),
437
485
  });
438
486
  } else if (event.type === "output-item" && event.item.type === "image_generation_call") {
487
+ if (auto)
488
+ throw new ImageGenerationError("incomplete", "Unexpected hosted image generation");
439
489
  imageAttempted = true;
440
490
  } else if (auto && event.type === "output-item" && event.item.type === "function_call") {
441
- if (event.item.name !== TRANSPARENT_IMAGE_TOOL) {
491
+ if (event.item.name !== IMAGE_TOOL) {
442
492
  throw new ImageGenerationError("incomplete", "Unexpected automatic image tool call");
443
493
  }
444
- transparentAttempted = true;
445
- transparentCallIds.add(event.item.call_id);
494
+ imageToolAttempted = true;
495
+ imageCallIds.add(event.item.call_id);
446
496
  if (!event.completed) continue;
447
497
  const id = event.item.call_id;
448
498
  let args: unknown;
449
499
  try {
450
500
  args = JSON.parse(String(event.item.arguments));
451
501
  } catch {
452
- throw new ImageGenerationError("incomplete", "Invalid transparent image arguments");
502
+ throw new ImageGenerationError("incomplete", "Invalid image tool arguments");
453
503
  }
454
504
  if (
455
505
  typeof id !== "string" ||
@@ -457,67 +507,111 @@ export class OpenAIDispatcher implements ProviderDispatcher {
457
507
  !args ||
458
508
  typeof args !== "object" ||
459
509
  Array.isArray(args) ||
460
- Object.keys(args).length !== 1 ||
510
+ Object.keys(args).length !== 3 ||
461
511
  !("prompt" in args) ||
462
512
  typeof args.prompt !== "string" ||
463
- !args.prompt.trim()
513
+ !args.prompt.trim() ||
514
+ !("transparent_background" in args) ||
515
+ typeof args.transparent_background !== "boolean" ||
516
+ !("num_last_images_to_include" in args) ||
517
+ typeof args.num_last_images_to_include !== "number" ||
518
+ !Number.isInteger(args.num_last_images_to_include) ||
519
+ args.num_last_images_to_include < 0 ||
520
+ args.num_last_images_to_include > 5
464
521
  ) {
465
- throw new ImageGenerationError("incomplete", "Invalid transparent image arguments");
522
+ throw new ImageGenerationError("incomplete", "Invalid image tool arguments");
466
523
  }
467
- if (transparentCalls.has(id) && transparentCalls.get(id) !== args.prompt) {
468
- throw new ImageGenerationError(
469
- "incomplete",
470
- "Conflicting transparent image tool calls",
471
- );
524
+ const previous = imageCalls.get(id);
525
+ if (
526
+ previous &&
527
+ (previous.prompt !== args.prompt ||
528
+ previous.transparent_background !== args.transparent_background ||
529
+ previous.num_last_images_to_include !== args.num_last_images_to_include)
530
+ ) {
531
+ throw new ImageGenerationError("incomplete", "Conflicting image tool calls");
472
532
  }
473
- transparentCalls.set(id, args.prompt);
474
- } else if (event.type === "completed" && event.usage) {
475
- driverCompleted = true;
476
- servingModel = event.model ?? servingModel;
477
- usage = {
478
- totalTokens: event.usage.totalTokens,
479
- inputTokens: event.usage.inputTokens,
480
- outputTokens: event.usage.outputTokens,
481
- cachedInputTokens: event.usage.cachedInputTokens,
482
- reasoningOutputTokens: event.usage.reasoningTokens,
483
- };
533
+ imageCalls.set(id, {
534
+ prompt: args.prompt,
535
+ transparent_background: args.transparent_background,
536
+ num_last_images_to_include: args.num_last_images_to_include,
537
+ });
484
538
  } else if (event.type === "completed") {
539
+ const imageUsage = event.imageGeneration?.usage;
540
+ // The backend also reports all-zero image usage for text-only responses.
541
+ if (
542
+ auto &&
543
+ imageUsage &&
544
+ (imageUsage.input_tokens > 0 ||
545
+ imageUsage.output_tokens > 0 ||
546
+ imageUsage.total_tokens > 0)
547
+ )
548
+ throw new ImageGenerationError("incomplete", "Unexpected hosted image generation");
485
549
  driverCompleted = true;
486
550
  servingModel = event.model ?? servingModel;
551
+ if (event.usage) {
552
+ usage = {
553
+ totalTokens: event.usage.totalTokens,
554
+ inputTokens: event.usage.inputTokens,
555
+ outputTokens: event.usage.outputTokens,
556
+ cachedInputTokens: event.usage.cachedInputTokens,
557
+ reasoningOutputTokens: event.usage.reasoningTokens,
558
+ };
559
+ }
560
+ if (event.imageGeneration) {
561
+ const hostedImages = images.filter(
562
+ (image) => image.generation?.route === "codex-responses",
563
+ );
564
+ for (const [index, image] of hostedImages.entries()) {
565
+ image.generation = {
566
+ route: "codex-responses",
567
+ ...(event.imageGeneration.model ? { model: event.imageGeneration.model } : {}),
568
+ ...image.generation,
569
+ // Tool usage covers the whole response, not each returned image.
570
+ ...(index === 0 && event.imageGeneration.usage
571
+ ? { usage: event.imageGeneration.usage }
572
+ : {}),
573
+ };
574
+ }
575
+ }
487
576
  } else if (event.type === "error") {
488
577
  if (req.imageGeneration)
489
578
  throw new ImageGenerationError("incomplete", event.error.message);
490
579
  throw event.error;
491
580
  }
492
581
  }
493
- if (auto && transparentAttempted) {
494
- if (
495
- !driverCompleted ||
496
- transparentCalls.size !== 1 ||
497
- transparentCallIds.size !== 1 ||
498
- imageAttempted
499
- ) {
582
+ if (auto && imageToolAttempted) {
583
+ if (!driverCompleted || imageCalls.size !== 1 || imageCallIds.size !== 1) {
584
+ throw new ImageGenerationError("incomplete", "Expected one completed image tool call");
585
+ }
586
+ if (req.abortSignal?.aborted) throw abortError(req.abortSignal);
587
+ const args = [...imageCalls.values()][0]!;
588
+ const referenceCount = args.num_last_images_to_include;
589
+ const references = referenceCount ? imageReferences(req) : [];
590
+ if (referenceCount > references.length) {
500
591
  throw new ImageGenerationError(
501
592
  "incomplete",
502
- "Expected one completed transparent image call without another image tool",
593
+ "Requested image references are unavailable",
503
594
  );
504
595
  }
505
- if (req.abortSignal?.aborted) throw abortError(req.abortSignal);
506
- const prompt = [...transparentCalls.values()][0]!;
507
596
  let imageCompleted = false;
508
597
  for await (const event of clientEntry.client.responses(
509
598
  {
510
- model: req.model ?? "",
511
- ...(req.systemPrompt ? { instructions: req.systemPrompt } : {}),
599
+ model: "gpt-image-2",
512
600
  history: [
513
- ...inputItems(req),
514
- { type: "message", role: "user", content: [{ type: "input_text", text: prompt }] },
601
+ {
602
+ type: "message",
603
+ role: "user",
604
+ content: [
605
+ { type: "input_text", text: args.prompt },
606
+ ...(referenceCount ? references.slice(-referenceCount) : []),
607
+ ],
608
+ },
515
609
  ],
516
610
  imageGeneration: {
517
611
  quality: req.imageGenerationOptions?.quality,
518
612
  size: req.imageGenerationOptions?.size,
519
613
  output_format: "png",
520
- background: "transparent",
614
+ background: args.transparent_background ? "transparent" : "opaque",
521
615
  },
522
616
  },
523
617
  req.abortSignal,
@@ -531,38 +625,46 @@ export class OpenAIDispatcher implements ProviderDispatcher {
531
625
  });
532
626
  else if (event.type === "completed") imageCompleted = true;
533
627
  else if (event.type === "error")
534
- throw new ImageGenerationError("incomplete", event.error.message);
628
+ throw new ImageGenerationError("incomplete", event.error.message, {
629
+ cause: event.error,
630
+ });
535
631
  }
536
632
  imageAttempted = true;
537
633
  if (!imageCompleted)
538
- throw new ImageGenerationError(
539
- "incomplete",
540
- "Transparent image generation did not complete",
541
- );
634
+ throw new ImageGenerationError("incomplete", "Image generation did not complete");
542
635
  }
543
636
  if (auto && imageAttempted && !driverCompleted)
544
637
  throw new ImageGenerationError("incomplete", "Image generation response did not complete");
545
638
  if (req.abortSignal?.aborted) throw abortError(req.abortSignal);
639
+ if ((req.imageGeneration === true || (auto && imageAttempted)) && images.length === 0) {
640
+ throw new ImageGenerationError(
641
+ imageAttempted ? "incomplete" : "not_called",
642
+ imageAttempted
643
+ ? "image generation did not complete"
644
+ : "model did not call image generation",
645
+ );
646
+ }
647
+ } catch (error) {
648
+ if (auto && driverCompleted && imageToolAttempted) {
649
+ const failure =
650
+ error instanceof ImageGenerationError
651
+ ? error
652
+ : new ImageGenerationError(
653
+ "incomplete",
654
+ error instanceof Error ? error.message : String(error),
655
+ { cause: error },
656
+ );
657
+ if (error instanceof Error) failure.name = error.name;
658
+ // Only the completed chat stage is chargeable here; discard partial image output.
659
+ failure.completedDriver = driverResult();
660
+ throw failure;
661
+ }
662
+ throw error;
546
663
  } finally {
547
664
  this.releaseClient(clientEntry);
548
665
  }
549
- if ((req.imageGeneration === true || (auto && imageAttempted)) && images.length === 0) {
550
- throw new ImageGenerationError(
551
- imageAttempted ? "incomplete" : "not_called",
552
- imageAttempted
553
- ? "image generation did not complete"
554
- : "model did not call image generation",
555
- );
556
- }
557
666
  return {
558
- text,
559
- tokenName: metadata.name,
560
- usage,
561
- durationMs: Date.now() - startedAt,
562
- ttftMs: firstDeltaAt === undefined ? null : firstDeltaAt - startedAt,
563
- model: servingModel,
564
- ...(servingModel !== req.model ? { requestedModel: req.model } : {}),
565
- threadCoord: { workerId: selection.tokenId, threadId: req.promptCacheKey ?? "", epoch: -1 },
667
+ ...driverResult(),
566
668
  ...(images.length ? { images } : {}),
567
669
  };
568
670
  }
@@ -150,3 +150,92 @@ describe("standalone transparent images", () => {
150
150
  expect(fetchMock).not.toHaveBeenCalled();
151
151
  });
152
152
  });
153
+
154
+ describe("standalone image model", () => {
155
+ it.each([true, { background: "opaque" }, { background: "auto" }])(
156
+ "generates ordinary PNGs directly without the Responses transport (%j)",
157
+ async (imageGeneration) => {
158
+ const stream = vi.fn();
159
+ const fetchMock = vi.fn<typeof fetch>(async () => Response.json({
160
+ data: [{ b64_json: png([255, 255]) }],
161
+ }));
162
+ const client = new OpenAIDirectClient({
163
+ credentials: { accessToken: "subscription-token", accountId: "account" },
164
+ fetch: fetchMock,
165
+ transport: { stream },
166
+ });
167
+ const events = await Array.fromAsync(client.responses({
168
+ ...options, model: "gpt-image-2", imageGeneration,
169
+ }));
170
+ expect(stream).not.toHaveBeenCalled();
171
+ expect(fetchMock).toHaveBeenCalledTimes(1);
172
+ const [url, init] = fetchMock.mock.calls[0]!;
173
+ expect(url).toBe("https://chatgpt.com/backend-api/codex/images/generations");
174
+ expect(init?.headers).toMatchObject({
175
+ Authorization: "Bearer subscription-token", "ChatGPT-Account-ID": "account",
176
+ Accept: "application/json",
177
+ });
178
+ const body = JSON.parse(String(init?.body));
179
+ expect(body.model).toBe("gpt-image-2");
180
+ expect(body.background).toBe(typeof imageGeneration === "object" ? imageGeneration.background : undefined);
181
+ expect(events[0]).toMatchObject({
182
+ type: "image", mimeType: "image/png",
183
+ generation: { route: "codex-images", model: "gpt-image-2", size: "2x1" },
184
+ });
185
+ expect(events[0]).not.toHaveProperty("generation.background");
186
+ expect(events[0]).not.toHaveProperty("generation.quality");
187
+ expect(events[1]).toMatchObject({ type: "completed", model: "gpt-image-2" });
188
+ expect(events[1]).not.toHaveProperty("usage");
189
+ },
190
+ );
191
+
192
+ it("edits references and preserves observed settings and aggregate usage once", async () => {
193
+ const usage = {
194
+ input_tokens: 2260, output_tokens: 1415, total_tokens: 3675,
195
+ input_tokens_details: { text_tokens: 248, image_tokens: 2012 },
196
+ };
197
+ const fetchMock = vi.fn<typeof fetch>(async () => Response.json({
198
+ model: "gpt-image-2-codex", quality: "medium", background: "opaque", size: "1024x1536",
199
+ data: [{ b64_json: png([255, 255]), revised_prompt: "A red circle." }, { b64_json: png() }],
200
+ usage,
201
+ }));
202
+ const events = await run(fetchMock, {
203
+ model: "gpt-image-2",
204
+ imageGeneration: { background: "auto", quality: "high", size: "1024x1536", output_format: "png" },
205
+ history: [{ role: "user", content: [
206
+ { type: "input_text", text: "Use these references." },
207
+ { type: "input_image", image_url: "data:image/png;base64,reference" },
208
+ { type: "input_image", image_url: "https://example.com/reference.png" },
209
+ ] }],
210
+ });
211
+ expect(fetchMock.mock.calls[0]?.[0]).toBe("https://chatgpt.com/backend-api/codex/images/edits");
212
+ expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({
213
+ model: "gpt-image-2", background: "auto", quality: "high", size: "1024x1536",
214
+ images: [{ image_url: "data:image/png;base64,reference" }, { image_url: "https://example.com/reference.png" }],
215
+ });
216
+ expect(events[0]).toMatchObject({
217
+ type: "image", revisedPrompt: "A red circle.",
218
+ generation: { model: "gpt-image-2-codex", background: "opaque", quality: "medium", size: "2x1", usage },
219
+ });
220
+ expect(events[1]).not.toHaveProperty("generation.usage");
221
+ expect(events[2]).toMatchObject({ type: "completed", model: "gpt-image-2-codex" });
222
+ expect(events[2]).not.toHaveProperty("usage");
223
+ });
224
+
225
+ it.each([
226
+ { imageGeneration: false },
227
+ { imageGeneration: undefined },
228
+ { imageGeneration: { output_format: "webp" } },
229
+ { imageGeneration: { output_format: "jpeg" } },
230
+ ])("rejects disabled generation and unsupported output formats before I/O (%j)", async (overrides) => {
231
+ const fetchMock = vi.fn<typeof fetch>();
232
+ await expect(run(fetchMock, { model: "gpt-image-2", ...overrides })).rejects.toThrow(/enabled|PNG/);
233
+ expect(fetchMock).not.toHaveBeenCalled();
234
+ });
235
+
236
+ it("validates decoded PNG content on the ordinary route", async () => {
237
+ const fetchMock = vi.fn<typeof fetch>(async () => Response.json(result("bm90IHBuZw==")));
238
+ await expect(run(fetchMock, { model: "gpt-image-2", imageGeneration: true })).rejects.toThrow(/PNG/);
239
+ expect(fetchMock).toHaveBeenCalledTimes(1);
240
+ });
241
+ });
@@ -18,17 +18,20 @@ const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
18
18
  export function buildStandaloneImageRequest(options: OpenAIResponsesOptions) {
19
19
  if (options.outputSchema || options.tools?.length) {
20
20
  throw new OpenAIProtocolError(
21
- "Transparent image generation does not support tools or structured output",
21
+ "Standalone image generation does not support tools or structured output",
22
22
  );
23
23
  }
24
- const controls = options.imageGeneration;
25
- if (!controls || controls === true || controls.background !== "transparent") {
24
+ if (!options.imageGeneration) {
25
+ throw new OpenAIProtocolError("Standalone images require image generation to be enabled");
26
+ }
27
+ const controls = typeof options.imageGeneration === "object" ? options.imageGeneration : {};
28
+ if (options.model !== "gpt-image-2" && controls.background !== "transparent") {
26
29
  throw new OpenAIProtocolError(
27
30
  "Standalone images require an explicit transparent background request",
28
31
  );
29
32
  }
30
33
  if (controls.output_format && controls.output_format !== "png") {
31
- throw new OpenAIProtocolError("Transparent image generation only supports PNG output");
34
+ throw new OpenAIProtocolError("Standalone image generation only supports PNG output");
32
35
  }
33
36
  const text: string[] = [];
34
37
  const images: Array<{ image_url: string }> = [];
@@ -43,7 +46,7 @@ export function buildStandaloneImageRequest(options: OpenAIResponsesOptions) {
43
46
  }
44
47
  if (item.type !== "message" && typeof item.role !== "string") {
45
48
  throw new OpenAIProtocolError(
46
- "Unsupported conversation item for transparent image generation",
49
+ "Unsupported conversation item for standalone image generation",
47
50
  );
48
51
  }
49
52
  if (typeof item.content === "string") {
@@ -51,11 +54,11 @@ export function buildStandaloneImageRequest(options: OpenAIResponsesOptions) {
51
54
  continue;
52
55
  }
53
56
  if (!Array.isArray(item.content)) {
54
- throw new OpenAIProtocolError("Unsupported message content for transparent image generation");
57
+ throw new OpenAIProtocolError("Unsupported message content for standalone image generation");
55
58
  }
56
59
  for (const part of item.content) {
57
60
  if (!part || typeof part !== "object") {
58
- throw new OpenAIProtocolError("Unsupported message part for transparent image generation");
61
+ throw new OpenAIProtocolError("Unsupported message part for standalone image generation");
59
62
  }
60
63
  if (
61
64
  ["input_text", "output_text", "text"].includes(part.type) &&
@@ -69,12 +72,12 @@ export function buildStandaloneImageRequest(options: OpenAIResponsesOptions) {
69
72
  }
70
73
  images.push({ image_url: url });
71
74
  } else {
72
- throw new OpenAIProtocolError("Unsupported message part for transparent image generation");
75
+ throw new OpenAIProtocolError("Unsupported message part for standalone image generation");
73
76
  }
74
77
  }
75
78
  }
76
79
  if (images.length > 5)
77
- throw new OpenAIProtocolError("Transparent image editing supports at most 5 reference images");
80
+ throw new OpenAIProtocolError("Standalone image editing supports at most 5 reference images");
78
81
  if (!text.some((part) => part.trim())) throw new OpenAIProtocolError("Image prompt is empty");
79
82
  return {
80
83
  url: CHATGPT_CODEX_RESPONSES_URL.replace(
@@ -84,7 +87,7 @@ export function buildStandaloneImageRequest(options: OpenAIResponsesOptions) {
84
87
  body: {
85
88
  model: "gpt-image-2",
86
89
  prompt: text.join("\n\n"),
87
- background: "transparent",
90
+ ...(controls.background ? { background: controls.background } : {}),
88
91
  ...(controls.quality ? { quality: controls.quality } : {}),
89
92
  ...(controls.size ? { size: controls.size } : {}),
90
93
  ...(images.length ? { images } : {}),
@@ -92,8 +95,10 @@ export function buildStandaloneImageRequest(options: OpenAIResponsesOptions) {
92
95
  };
93
96
  }
94
97
 
95
- // Decode before accepting the result: an alpha channel alone can still be fully opaque.
96
- export function inspectTransparentPng(base64: string): { width: number; height: number } {
98
+ function inspectGeneratedPng(
99
+ base64: string,
100
+ requireTransparency: boolean,
101
+ ): { width: number; height: number } {
97
102
  if (base64.length > Math.ceil(MAX_PNG_BYTES / 3) * 4 || !/^[A-Za-z0-9+/]+={0,2}$/.test(base64)) {
98
103
  throw new OpenAIProtocolError("Invalid or oversized generated PNG");
99
104
  }
@@ -116,6 +121,8 @@ export function inspectTransparentPng(base64: string): { width: number; height:
116
121
  } catch {
117
122
  throw new OpenAIProtocolError("Image generation returned an invalid PNG");
118
123
  }
124
+ if (!requireTransparency) return { width: image.width, height: image.height };
125
+ // An alpha channel alone can still be fully opaque or have no visible content.
119
126
  let transparent = false;
120
127
  let visible = false;
121
128
  for (let i = 3; i < image.data.length; i += 4) {
@@ -130,6 +137,7 @@ export function inspectTransparentPng(base64: string): { width: number; height:
130
137
 
131
138
  export async function readStandaloneImageResponse(
132
139
  response: Response,
140
+ requireTransparency: boolean,
133
141
  ): Promise<OpenAINormalizedEvent[]> {
134
142
  if (!response.body) throw new OpenAIProtocolError("Image response had no body");
135
143
  const reader = response.body.getReader();
@@ -155,6 +163,8 @@ export async function readStandaloneImageResponse(
155
163
  .min(1)
156
164
  .max(16),
157
165
  quality: z.string().optional(),
166
+ model: z.string().optional(),
167
+ background: z.string().optional(),
158
168
  usage: ImageGenerationMetadata.shape.usage,
159
169
  });
160
170
  let parsed: z.infer<typeof schema>;
@@ -163,8 +173,9 @@ export async function readStandaloneImageResponse(
163
173
  } catch {
164
174
  throw new OpenAIProtocolError("Invalid standalone image response");
165
175
  }
176
+ const background = parsed.background ?? (requireTransparency ? "transparent" : undefined);
166
177
  return parsed.data.map((item, index) => {
167
- const dimensions = inspectTransparentPng(item.b64_json);
178
+ const dimensions = inspectGeneratedPng(item.b64_json, requireTransparency);
168
179
  return {
169
180
  type: "image",
170
181
  base64: item.b64_json,
@@ -172,8 +183,8 @@ export async function readStandaloneImageResponse(
172
183
  ...(item.revised_prompt ? { revisedPrompt: item.revised_prompt } : {}),
173
184
  generation: {
174
185
  route: "codex-images",
175
- model: "gpt-image-2",
176
- background: "transparent",
186
+ model: parsed.model ?? "gpt-image-2",
187
+ ...(background ? { background } : {}),
177
188
  size: `${dimensions.width}x${dimensions.height}`,
178
189
  ...(parsed.quality ? { quality: parsed.quality } : {}),
179
190
  ...(index === 0 && parsed.usage ? { usage: parsed.usage } : {}),
@@ -0,0 +1 @@
1
+ var e=1e6,t=4;function n(t){return t/e}function r(e){return`$${e.toFixed(t)}`}function i(e){return r(n(e))}function a(e){return e.cost_usd===null&&e.image_cost_usd===null?`—`:r(n((e.cost_usd??0)+(e.image_cost_usd??0)))}function o(e){return e.input_tokens<=0?`—`:`${Math.round(e.cache_read_tokens/e.input_tokens*100)}%`}export{r as i,i as n,a as r,o as t};