@cartanova/qgrid-cli 2.3.7 → 2.3.9

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 (82) hide show
  1. package/README.ko.md +131 -0
  2. package/README.md +60 -44
  3. package/bundle/dist/application/qgrid/qgrid-image-generation.js +42 -0
  4. package/bundle/dist/application/qgrid/qgrid-response-format.js +44 -0
  5. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +23 -4
  6. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +15 -6
  7. package/bundle/dist/application/qgrid/qgrid.frame.js +32 -4
  8. package/bundle/dist/application/qgrid/qgrid.types.js +36 -12
  9. package/bundle/dist/application/qgrid/tool-emulation.js +16 -8
  10. package/bundle/dist/application/request-log/request-log.model.js +5 -2
  11. package/bundle/dist/application/sonamu.generated.js +13 -4
  12. package/bundle/dist/application/sonamu.generated.sso.js +9 -3
  13. package/bundle/dist/i18n/sd.generated.js +4 -1
  14. package/bundle/dist/migrations/20260705140614_alter_request_logs_add1_alter1.js +16 -0
  15. package/bundle/dist/migrations/20260706120000_alter_request_logs_add_image_cost.js +17 -0
  16. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +2 -1
  17. package/bundle/dist/utils/providers/openai/codex-rpc.js +1 -1
  18. package/bundle/dist/utils/providers/openai/codex-worker.js +78 -6
  19. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +21 -4
  20. package/bundle/src/application/qgrid/qgrid-image-generation.ts +62 -0
  21. package/bundle/src/application/qgrid/qgrid-response-format.ts +59 -0
  22. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +105 -0
  23. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +34 -2
  24. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +33 -0
  25. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +16 -5
  26. package/bundle/src/application/qgrid/qgrid.frame.test.ts +78 -0
  27. package/bundle/src/application/qgrid/qgrid.frame.ts +48 -5
  28. package/bundle/src/application/qgrid/qgrid.types.ts +22 -0
  29. package/bundle/src/application/qgrid/tool-emulation.test.ts +44 -1
  30. package/bundle/src/application/qgrid/tool-emulation.ts +27 -4
  31. package/bundle/src/application/request-log/request-log.entity.json +73 -47
  32. package/bundle/src/application/request-log/request-log.model.ts +7 -0
  33. package/bundle/src/application/sonamu.generated.http +16 -3
  34. package/bundle/src/application/sonamu.generated.sso.ts +6 -0
  35. package/bundle/src/application/sonamu.generated.ts +12 -0
  36. package/bundle/src/i18n/sd.generated.ts +3 -0
  37. package/bundle/src/migrations/20260705140614_alter_request_logs_add1_alter1.ts +15 -0
  38. package/bundle/src/migrations/20260706120000_alter_request_logs_add_image_cost.ts +14 -0
  39. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +5 -0
  40. package/bundle/src/utils/providers/common/provider-dispatcher.ts +14 -0
  41. package/bundle/src/utils/providers/openai/codex-rpc.ts +2 -0
  42. package/bundle/src/utils/providers/openai/codex-worker.test.ts +219 -0
  43. package/bundle/src/utils/providers/openai/codex-worker.ts +154 -5
  44. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +95 -1
  45. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +42 -0
  46. package/bundle/web-dist/client/assets/index-Cj1iWi5i.js +78 -0
  47. package/bundle/web-dist/client/assets/index-VE_24EJf.css +1 -0
  48. package/bundle/web-dist/client/assets/{logs-B0pWpDrg.js → logs-DvGWhZTu.js} +1 -1
  49. package/bundle/web-dist/client/assets/{routes-7XhVuG7o.js → routes-B_xvkM-J.js} +1 -1
  50. package/bundle/web-dist/client/assets/{sd.generated-DXhjjL2f.js → sd.generated-BrUnRt0B.js} +1 -1
  51. package/bundle/web-dist/client/assets/{services.generated-B7u0U1wC.js → services.generated-AsEvHbJ7.js} +1 -1
  52. package/bundle/web-dist/client/assets/{show-CcjZ9nr7.js → show-C6W3ywZz.js} +3 -3
  53. package/bundle/web-dist/client/assets/{tokens-DlwAm0v4.js → tokens-CxjeN72g.js} +1 -1
  54. package/bundle/web-dist/client/index.html +3 -3
  55. package/bundle/web-dist/server/assets/{lazyRouteComponent-B0LkbiYf.js → lazyRouteComponent-BswulcOx.js} +1 -1
  56. package/bundle/web-dist/server/assets/{logs-dLx6gMXT.js → logs-B_ZLzpwY.js} +4 -4
  57. package/bundle/web-dist/server/assets/{routes-DD3Hb9xO.js → routes-D9OiKoqa.js} +2 -2
  58. package/bundle/web-dist/server/assets/{sd.generated-DdfpD_iN.js → sd.generated-DRuQSIKo.js} +3 -0
  59. package/bundle/web-dist/server/assets/{services.generated-D3ZCvIkt.js → services.generated-Dp0uKgpV.js} +1 -1
  60. package/bundle/web-dist/server/assets/{show-BQjeyv6A.js → show-CGlX9OYt.js} +104 -13
  61. package/bundle/web-dist/server/assets/{tokens-CSOsBpP7.js → tokens-Cjw6cQxZ.js} +2 -2
  62. package/bundle/web-dist/server/entry-server.generated.js +9 -8
  63. package/package.json +7 -3
  64. package/postinstall.mjs +119 -0
  65. package/skills/qgrid/SKILL.md +54 -0
  66. package/skills/qgrid/agents/openai.yaml +4 -0
  67. package/skills/qgrid/references/ai-sdk-provider-contract.md +100 -0
  68. package/skills/qgrid/references/anthropic-claude-code-runtime.md +170 -0
  69. package/skills/qgrid/references/cli-env-and-server-boot.md +66 -0
  70. package/skills/qgrid/references/decision-rationale.md +179 -0
  71. package/skills/qgrid/references/docs-workflow.md +32 -0
  72. package/skills/qgrid/references/openai-codex-runtime.md +153 -0
  73. package/skills/qgrid/references/prompt-cache-and-usage.md +101 -0
  74. package/skills/qgrid/references/provider-runtime-differences.md +54 -0
  75. package/skills/qgrid/references/repo-map.md +37 -0
  76. package/skills/qgrid/references/request-log-run-lifecycle.md +76 -0
  77. package/skills/qgrid/references/sonamu-api-web-flow.md +47 -0
  78. package/skills/qgrid/references/token-auth-quota-lifecycle.md +205 -0
  79. package/skills/qgrid/references/tool-calling-and-multiturn.md +144 -0
  80. package/skills/qgrid/references/verification-and-debugging.md +193 -0
  81. package/bundle/web-dist/client/assets/index-5WdFEqZT.css +0 -1
  82. package/bundle/web-dist/client/assets/index-jnUY7nJ-.js +0 -78
@@ -3,12 +3,20 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
 
4
4
  import { getLogger } from "@logtape/logtape";
5
5
 
6
+ import { type Model } from "../../../codex-protocol/v2/Model";
7
+ import { type ModelListResponse } from "../../../codex-protocol/v2/ModelListResponse";
8
+ import { type ModelProviderCapabilitiesReadResponse } from "../../../codex-protocol/v2/ModelProviderCapabilitiesReadResponse";
6
9
  import { type ThreadInjectItemsParams } from "../../../codex-protocol/v2/ThreadInjectItemsParams";
7
10
  import { type ThreadStartParams } from "../../../codex-protocol/v2/ThreadStartParams";
8
11
  import { type ThreadStartResponse } from "../../../codex-protocol/v2/ThreadStartResponse";
9
12
  import { type TokenUsageBreakdown } from "../../../codex-protocol/v2/TokenUsageBreakdown";
10
13
  import { type TurnStartParams } from "../../../codex-protocol/v2/TurnStartParams";
11
14
  import { type TurnStartResponse } from "../../../codex-protocol/v2/TurnStartResponse";
15
+ import {
16
+ CODEX_IMAGE_GENERATION_MODEL,
17
+ resolveImageGenerationOptions,
18
+ } from "../../../application/qgrid/qgrid-image-generation";
19
+ import { type ImageGenerationOptions } from "../../../application/qgrid/qgrid.types";
12
20
  import { type StreamCallbacks as CommonStreamCallbacks } from "../common/provider-dispatcher";
13
21
  import { createTtftTracker } from "../common/ttft";
14
22
  import { CodexRpcClient } from "./codex-rpc";
@@ -39,6 +47,17 @@ export interface TurnRequest {
39
47
  verbosity?: string;
40
48
  reasoningSummary?: string;
41
49
  serviceTier?: string;
50
+ // 이 turn 에서 codex 내장 image_generation tool 을 켠다.
51
+ // thread 단위로만 활성화되며(전역 config 는 image_generation=false 유지),
52
+ // 플래그 없는 요청은 tool 구성이 현행과 동일하다.
53
+ imageGeneration?: boolean;
54
+ imageGenerationOptions?: ImageGenerationOptions;
55
+ }
56
+
57
+ // 한 turn 에서 생성된 이미지 하나. codex imageGeneration item 에서 파생.
58
+ export interface TurnImage {
59
+ data: string; // base64 image payload (item.result)
60
+ revisedPrompt: string | null;
42
61
  }
43
62
 
44
63
  export interface TurnResult {
@@ -47,6 +66,11 @@ export interface TurnResult {
47
66
  durationMs: number;
48
67
  ttftMs: number | null;
49
68
  model: string;
69
+ // 이미지 turn 에서만 채워짐. 완성 이미지가 없으면 빈 배열/undefined.
70
+ images?: TurnImage[];
71
+ // 이미지 tool 호출이 관측됐는지(item/started 또는 hasResult 있는 completed).
72
+ // 이미지 0개일 때 재시도 가능 실패("시도 후 미완성")와 재시도 무익("tool 미호출")을 구분.
73
+ imageAttempted?: boolean;
50
74
  }
51
75
 
52
76
  export type StreamCallbacks = CommonStreamCallbacks<TurnResult>;
@@ -66,6 +90,27 @@ const THREAD_DEFAULTS = {
66
90
  const BASE_INSTRUCTIONS =
67
91
  "You are a helpful assistant. Do not use any tools such as shell, file operations, or web search. Respond with text only.";
68
92
 
93
+ // 이미지 생성 thread 전용 instruction. 기본 BASE_INSTRUCTIONS 의 "text only" 지시가
94
+ // 실질 억제기이므로(codex image_generation feature 는 default-on), 이미지 모드에서는
95
+ // image_generation tool 사용을 명시적으로 허용한다. shell/web 등 나머지 tool 은 여전히 금지.
96
+ const IMAGE_BASE_INSTRUCTIONS =
97
+ "You are a helpful assistant. When asked to create an image, use the image_generation tool to produce it. Do not use any other tools such as shell, file operations, or web search.";
98
+
99
+ function imageBaseInstructions(options: ImageGenerationOptions | undefined): string {
100
+ const resolved = resolveImageGenerationOptions(options);
101
+ return `${IMAGE_BASE_INSTRUCTIONS} Requested image output: model ${CODEX_IMAGE_GENERATION_MODEL}, quality ${resolved.quality}, size ${resolved.size}, format png.`;
102
+ }
103
+
104
+ // 실측상 완료 item 의 status 는 "generating" 으로 와도 result 에 완성 base64 가 실린다.
105
+ // 따라서 완성 판정은 status 문자열이 아니라 result 의 유효성(비어있지 않고 표준 base64 인지)으로 한다.
106
+ function isCompletedImageResult(result: unknown): result is string {
107
+ if (typeof result !== "string") return false;
108
+ const trimmed = result.trim();
109
+ if (trimmed.length === 0 || trimmed.startsWith("data:")) return false;
110
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(trimmed) || trimmed.length % 4 !== 0) return false;
111
+ return true;
112
+ }
113
+
69
114
  // codex가 매 요청에 자동 주입하는 내장 tool(shell/web_search/spawn_agent 등 14개)과
70
115
  // instruction 블록(permissions/environment_context/skills, ~10KB)을 비활성화
71
116
  // $CODEX_HOME/config.toml 로 써넣으면 codex 부팅 시 scan
@@ -114,6 +159,10 @@ export class CodexAppServerWorker {
114
159
  private epochCounter = 0;
115
160
  private static readonly THREAD_IDLE_TTL_MS = 10 * 60_000;
116
161
  private static readonly MAX_THREADS_PER_WORKER = 16;
162
+ // 이미지 게이트 검증용 캐시. spawn 단위로 무효화(restart 시 clear).
163
+ // capability 는 provider 단위 boolean, 모델 멀티모달 여부는 model/list 로 확인.
164
+ private cachedCapabilities?: ModelProviderCapabilitiesReadResponse;
165
+ private cachedModels?: Map<string, Model>;
117
166
 
118
167
  constructor(private config: WorkerConfig) {
119
168
  const suffix = config.workerIndex !== undefined ? `-${config.workerIndex}` : "";
@@ -126,6 +175,9 @@ export class CodexAppServerWorker {
126
175
  // 새 codex 프로세스 → 기존 ephemeral thread 전부 무효. epoch 증가로 stale 감지.
127
176
  this.epochCounter++;
128
177
  this.threadMeta.clear();
178
+ // 새 프로세스 → capability/model 캐시 무효(로그인 계정/버전이 바뀌었을 수 있음).
179
+ this.cachedCapabilities = undefined;
180
+ this.cachedModels = undefined;
129
181
  const cwd = `${this.codexHome}/cwd`;
130
182
  mkdirSync(cwd, { recursive: true });
131
183
  // codex 내장 tool/web_search/instruction 블록 비활성화
@@ -291,6 +343,49 @@ export class CodexAppServerWorker {
291
343
  return this.rpc.request("account/rateLimits/read", {});
292
344
  }
293
345
 
346
+ // ── Image generation gate ───────────────────────────────────────
347
+ // 이미지 tool 은 codex 에서 (AuthMode::Chatgpt) AND (image_generation feature)
348
+ // AND (모델 멀티모달) 3중 게이트다. qgrid 는 OAuth 전용이고 feature 는 thread 에서
349
+ // 켜므로, dispatcher 가 사전 검증할 남은 조건은 capability(provider) + 모델 멀티모달이다.
350
+
351
+ private async loadCapabilities(): Promise<ModelProviderCapabilitiesReadResponse> {
352
+ if (!this.rpc || !this.ready) throw new Error("worker not ready");
353
+ if (!this.cachedCapabilities) {
354
+ this.cachedCapabilities = await this.rpc.request<ModelProviderCapabilitiesReadResponse>(
355
+ "modelProvider/capabilities/read",
356
+ {},
357
+ );
358
+ }
359
+ return this.cachedCapabilities;
360
+ }
361
+
362
+ private async loadModels(): Promise<Map<string, Model>> {
363
+ if (!this.rpc || !this.ready) throw new Error("worker not ready");
364
+ if (!this.cachedModels) {
365
+ const res = await this.rpc.request<ModelListResponse>("model/list", {});
366
+ this.cachedModels = new Map(res.data.map((m) => [m.model, m]));
367
+ }
368
+ return this.cachedModels;
369
+ }
370
+
371
+ // 주어진 모델(미지정이면 thread 기본 모델)로 이미지 생성이 가능한지 검증.
372
+ // 불가 사유를 문자열로 반환하고, 가능하면 null 을 반환한다.
373
+ async checkImageGenerationSupport(model?: string): Promise<string | null> {
374
+ if (!this.rpc || !this.ready) return "worker not ready";
375
+ const caps = await this.loadCapabilities();
376
+ if (!caps.imageGeneration) return "provider does not support image generation";
377
+ // 모델 멀티모달 검증. model 미지정이면 provider capability 만으로 통과시키고,
378
+ // 실제 적용 모델의 멀티모달 여부는 turn 실행 결과(이미지 0개 → U3 실패 분류)가 잡는다.
379
+ if (model) {
380
+ const models = await this.loadModels();
381
+ const info = models.get(model);
382
+ if (info && !info.inputModalities.includes("image")) {
383
+ return `model ${model} is not multimodal (no image input modality)`;
384
+ }
385
+ }
386
+ return null;
387
+ }
388
+
294
389
  // ── Server-request delegation ───────────────────────────────────
295
390
 
296
391
  setServerRequestHandler(handler: (method: string, params: unknown) => Promise<unknown>): void {
@@ -331,13 +426,19 @@ export class CodexAppServerWorker {
331
426
  const threadConfig: ThreadStartParams["config"] = {
332
427
  ...THREAD_DEFAULTS.config,
333
428
  ...(req.verbosity ? { model_verbosity: req.verbosity } : {}),
429
+ // 이미지 모드: thread 단위로만 image_generation feature 를 켠다.
430
+ // 전역 config.toml 은 image_generation=false 유지(텍스트 thread 이중 차단),
431
+ // dotted CLI-스타일 override 키로 이 thread 에서만 활성화(실측 확인).
432
+ ...(req.imageGeneration ? { "features.image_generation": true } : {}),
334
433
  };
335
434
  const { thread, model: threadModel } = await this.rpc.request<ThreadStartResponse>(
336
435
  "thread/start",
337
436
  {
338
437
  ephemeral: true,
339
438
  cwd: `${this.codexHome}/cwd`,
340
- baseInstructions: BASE_INSTRUCTIONS,
439
+ baseInstructions: req.imageGeneration
440
+ ? imageBaseInstructions(req.imageGenerationOptions)
441
+ : BASE_INSTRUCTIONS,
341
442
  developerInstructions: req.developerInstructions ?? "",
342
443
  sandbox: THREAD_DEFAULTS.sandbox,
343
444
  approvalPolicy: THREAD_DEFAULTS.approvalPolicy,
@@ -352,7 +453,12 @@ export class CodexAppServerWorker {
352
453
  items: req.history,
353
454
  } satisfies ThreadInjectItemsParams);
354
455
  }
355
- this.threadMeta.set(threadId, { lastUsedAt: Date.now() });
456
+ // 이미지 turn 은 재사용에서 제외(R8): base64 가 thread 메모리·캐시 prefix 에
457
+ // 상주하는 것을 막고, 이미지 thread 를 1 회용으로 둔다. threadMeta 미등록 →
458
+ // 후속 reuse 조회에서 이 thread 는 후보가 되지 않는다.
459
+ if (!req.imageGeneration) {
460
+ this.threadMeta.set(threadId, { lastUsedAt: Date.now() });
461
+ }
356
462
 
357
463
  return { threadId, model: req.model ?? threadModel };
358
464
  }
@@ -498,11 +604,14 @@ export class CodexAppServerWorker {
498
604
  reasoningOutputTokens: 0,
499
605
  };
500
606
  let durationMs = 0;
607
+ const images = new Map<string, TurnImage>(); // item.id → 이미지 (dedup)
608
+ let imageAttempted = false;
501
609
 
502
610
  const cleanup = () => {
503
611
  clearTimeout(timeout);
504
612
  if (this.activeTurnAbort === abort) this.activeTurnAbort = undefined;
505
613
  for (const n of [
614
+ "item/started",
506
615
  "item/completed",
507
616
  "item/agentMessage/delta",
508
617
  "turn/completed",
@@ -530,9 +639,23 @@ export class CodexAppServerWorker {
530
639
  abort = finishWithError;
531
640
  this.activeTurnAbort = abort;
532
641
 
642
+ // 이미지 tool 호출 관측(재시도 판정용). started 는 result 없이 오므로
643
+ // attempted 신호로만 쓰고, 완성 이미지는 completed 에서 result 유효성으로 판정한다.
644
+ this.rpc!.onNotification("item/started", (p) => {
645
+ if (p.threadId !== threadId) return;
646
+ if (p.item.type === "imageGeneration") imageAttempted = true;
647
+ });
648
+
533
649
  this.rpc!.onNotification("item/completed", (p) => {
534
650
  if (p.threadId !== threadId) return;
535
- if (p.item.type === "agentMessage") text = p.item.text;
651
+ if (p.item.type === "agentMessage") {
652
+ text = p.item.text;
653
+ } else if (p.item.type === "imageGeneration") {
654
+ imageAttempted = true;
655
+ if (isCompletedImageResult(p.item.result)) {
656
+ images.set(p.item.id, { data: p.item.result, revisedPrompt: p.item.revisedPrompt });
657
+ }
658
+ }
536
659
  });
537
660
 
538
661
  this.rpc!.onNotification("item/agentMessage/delta", (p) => {
@@ -553,8 +676,17 @@ export class CodexAppServerWorker {
553
676
  if (p.threadId !== threadId) return;
554
677
  durationMs = p.turn.durationMs ?? 0;
555
678
  if (p.turn.status === "completed") {
556
- finishWithResult({ text, usage, durationMs, ttftMs: ttftTracker.value(), model });
679
+ finishWithResult({
680
+ text,
681
+ usage,
682
+ durationMs,
683
+ ttftMs: ttftTracker.value(),
684
+ model,
685
+ images: images.size > 0 ? [...images.values()] : undefined,
686
+ imageAttempted,
687
+ });
557
688
  } else {
689
+ // turn 실패/중단 시 부분 이미지는 폐기(성공으로 승격하지 않음).
558
690
  finishWithError(new Error(`turn failed: ${JSON.stringify(p.turn.error)}`));
559
691
  }
560
692
  });
@@ -589,11 +721,14 @@ export class CodexAppServerWorker {
589
721
  cachedInputTokens: 0,
590
722
  reasoningOutputTokens: 0,
591
723
  };
724
+ const images = new Map<string, TurnImage>(); // item.id → 이미지 (dedup)
725
+ let imageAttempted = false;
592
726
 
593
727
  const cleanup = () => {
594
728
  clearTimeout(timeout);
595
729
  if (this.activeTurnAbort === abort) this.activeTurnAbort = undefined;
596
730
  for (const n of [
731
+ "item/started",
597
732
  "item/completed",
598
733
  "item/agentMessage/delta",
599
734
  "turn/completed",
@@ -630,9 +765,21 @@ export class CodexAppServerWorker {
630
765
  cb.onDelta(p.delta);
631
766
  });
632
767
 
768
+ this.rpc!.onNotification("item/started", (p) => {
769
+ if (p.threadId !== threadId) return;
770
+ if (p.item.type === "imageGeneration") imageAttempted = true;
771
+ });
772
+
633
773
  this.rpc!.onNotification("item/completed", (p) => {
634
774
  if (p.threadId !== threadId) return;
635
- if (p.item.type === "agentMessage") text = p.item.text;
775
+ if (p.item.type === "agentMessage") {
776
+ text = p.item.text;
777
+ } else if (p.item.type === "imageGeneration") {
778
+ imageAttempted = true;
779
+ if (isCompletedImageResult(p.item.result)) {
780
+ images.set(p.item.id, { data: p.item.result, revisedPrompt: p.item.revisedPrompt });
781
+ }
782
+ }
636
783
  });
637
784
 
638
785
  this.rpc!.onNotification("thread/tokenUsage/updated", (p) => {
@@ -652,6 +799,8 @@ export class CodexAppServerWorker {
652
799
  durationMs: p.turn.durationMs ?? 0,
653
800
  ttftMs: ttftTracker.value(),
654
801
  model,
802
+ images: images.size > 0 ? [...images.values()] : undefined,
803
+ imageAttempted,
655
804
  });
656
805
  } else {
657
806
  finishWithError(new Error(`turn ${p.turn.status}: ${JSON.stringify(p.turn.error)}`));
@@ -4,7 +4,7 @@ import { QuotaThresholdExceededError } from "../../../application/qgrid/qgrid.ty
4
4
  import { type OpenAICredentials } from "../../../application/token/token.types";
5
5
  import { type GenerateRequest, type GenerateResult } from "../common/provider-dispatcher";
6
6
  import { type CodexAppServerWorker } from "./codex-worker";
7
- import { OpenAIDispatcher } from "./openai-dispatcher";
7
+ import { ImageGenerationError, OpenAIDispatcher } from "./openai-dispatcher";
8
8
  import { type OpenAIQuotaUsageResult } from "./openai-quota";
9
9
 
10
10
  const { readOpenAIQuotaUsageMock, loggerInfoMock, loggerWarnMock } = vi.hoisted(() => ({
@@ -91,6 +91,10 @@ function fakeWorker(
91
91
  threads?: string[];
92
92
  epoch?: number;
93
93
  rateLimits?: unknown;
94
+ // 이미지 게이트/생성 mock. imageGateReason 이 있으면 checkImageGenerationSupport 가
95
+ // 그 사유를 반환(불가), null 이면 통과. imageTurnResult 로 worker.executeTurn 반환값 지정.
96
+ imageGateReason?: string | null;
97
+ imageTurnResult?: { images?: Array<{ data: string; revisedPrompt: string | null }>; imageAttempted?: boolean };
94
98
  },
95
99
  ): CodexAppServerWorker {
96
100
  const options =
@@ -120,6 +124,17 @@ function fakeWorker(
120
124
  getRateLimits: vi.fn(
121
125
  async () => options.rateLimits ?? { rateLimits: {}, rateLimitsByLimitId: null },
122
126
  ),
127
+ checkImageGenerationSupport: vi.fn(async () => options.imageGateReason ?? null),
128
+ executeTurn: vi.fn(async () => ({
129
+ text: "ok",
130
+ usage: { totalTokens: 1, inputTokens: 1, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0 },
131
+ durationMs: 10,
132
+ ttftMs: null,
133
+ model: "gpt-5-codex",
134
+ threadId: "thread-new",
135
+ images: options.imageTurnResult?.images,
136
+ imageAttempted: options.imageTurnResult?.imageAttempted,
137
+ })),
123
138
  } as unknown as CodexAppServerWorker;
124
139
  return worker;
125
140
  }
@@ -429,3 +444,82 @@ describe("OpenAIDispatcher quota threshold gate", () => {
429
444
  ).rejects.toBeInstanceOf(QuotaThresholdExceededError);
430
445
  });
431
446
  });
447
+
448
+ describe("OpenAIDispatcher image generation gate and failure classification", () => {
449
+ it("throws a gate error without running the turn when capability is unsupported", async () => {
450
+ const dispatcher = new OpenAIDispatcher();
451
+ const worker = fakeWorker({ imageGateReason: "provider does not support image generation" });
452
+
453
+ await expect(
454
+ dispatcher.executeTurn(worker, baseReq({ imageGeneration: true })),
455
+ ).rejects.toMatchObject({ kind: "gate" });
456
+ // 게이트 실패 시 turn 은 실행되지 않는다.
457
+ expect((worker as unknown as { executeTurn: ReturnType<typeof vi.fn> }).executeTurn).not.toHaveBeenCalled();
458
+ });
459
+
460
+ it("throws a gate error for a non-multimodal model", async () => {
461
+ const dispatcher = new OpenAIDispatcher();
462
+ const worker = fakeWorker({ imageGateReason: "model gpt-x is not multimodal (no image input modality)" });
463
+
464
+ await expect(
465
+ dispatcher.executeTurn(worker, baseReq({ imageGeneration: true, model: "gpt-x" })),
466
+ ).rejects.toBeInstanceOf(ImageGenerationError);
467
+ });
468
+
469
+ it("classifies zero images with no tool call as not_called (retry futile)", async () => {
470
+ const dispatcher = new OpenAIDispatcher();
471
+ const worker = fakeWorker({ imageGateReason: null, imageTurnResult: { imageAttempted: false } });
472
+
473
+ await expect(
474
+ dispatcher.executeTurn(worker, baseReq({ imageGeneration: true })),
475
+ ).rejects.toMatchObject({ kind: "not_called" });
476
+ });
477
+
478
+ it("classifies zero images after an attempt as incomplete (retry candidate)", async () => {
479
+ const dispatcher = new OpenAIDispatcher();
480
+ const worker = fakeWorker({ imageGateReason: null, imageTurnResult: { imageAttempted: true } });
481
+
482
+ await expect(
483
+ dispatcher.executeTurn(worker, baseReq({ imageGeneration: true })),
484
+ ).rejects.toMatchObject({ kind: "incomplete" });
485
+ });
486
+
487
+ it("passes an image result through without gating a valid request", async () => {
488
+ const dispatcher = new OpenAIDispatcher();
489
+ const worker = fakeWorker({
490
+ imageGateReason: null,
491
+ imageTurnResult: { images: [{ data: "iVBORw0KGgoBAgM", revisedPrompt: "a cat" }], imageAttempted: true },
492
+ });
493
+
494
+ const result = await dispatcher.executeTurn(worker, baseReq({ imageGeneration: true }));
495
+ expect(result.images).toHaveLength(1);
496
+ expect(result.images?.[0]?.revisedPrompt).toBe("a cat");
497
+ });
498
+
499
+ it("does not gate or classify a normal text request", async () => {
500
+ const dispatcher = new OpenAIDispatcher();
501
+ const worker = fakeWorker({ imageTurnResult: {} });
502
+
503
+ const result = await dispatcher.executeTurn(worker, baseReq());
504
+ expect(result.text).toBe("ok");
505
+ expect((worker as unknown as { checkImageGenerationSupport: ReturnType<typeof vi.fn> }).checkImageGenerationSupport).not.toHaveBeenCalled();
506
+ });
507
+ });
508
+
509
+ describe("OpenAIDispatcher image generation routing", () => {
510
+ it("rejects image requests on the streaming path", async () => {
511
+ const dispatcher = new OpenAIDispatcher();
512
+ const cb = { onDelta: vi.fn(), onComplete: vi.fn(), onError: vi.fn() };
513
+ await expect(
514
+ dispatcher.generateStream(baseReq({ imageGeneration: true }), cb),
515
+ ).rejects.toBeInstanceOf(ImageGenerationError);
516
+ });
517
+
518
+ it("forces cold thread for image requests even when a reuse coordinate is present", async () => {
519
+ const dispatcher = new OpenAIDispatcher();
520
+ const worker = await dispatcher.acquireReuseWorker(
521
+ baseReq({ imageGeneration: true, reuse: { workerId: 10, threadId: "t1", epoch: 1 } }),
522
+ );
523
+ expect(worker).toBeNull();
524
+ });
525
+ });
@@ -28,6 +28,22 @@ import { handleChatgptAuthTokensRefresh } from "./openai-refresh";
28
28
 
29
29
  const logger = getLogger(["qgrid", "openai-dispatcher"]);
30
30
 
31
+ // 이미지 생성 요청이 게이트/생성에 실패했을 때의 구분자.
32
+ // - "gate": 사전 검증 실패(capability/모델 멀티모달 불충족) — turn 미실행.
33
+ // - "not_called": turn 은 성공했으나 모델이 image tool 을 호출 안 함 — 재시도 무익(프롬프트 문제).
34
+ // - "incomplete": tool 시도됐으나 완성 이미지 미도착 — 재시도 후보(서버측 실패/거부).
35
+ export type ImageFailureKind = "gate" | "not_called" | "incomplete";
36
+
37
+ export class ImageGenerationError extends Error {
38
+ constructor(
39
+ readonly kind: ImageFailureKind,
40
+ message: string,
41
+ ) {
42
+ super(message);
43
+ this.name = "ImageGenerationError";
44
+ }
45
+ }
46
+
31
47
  const DEFAULT_EFFORT = "low";
32
48
  const MAX_WORKERS_PER_TOKEN = 5;
33
49
  const WORKERS_PER_TOKEN = Math.min(
@@ -254,6 +270,11 @@ export class OpenAIDispatcher implements ProviderDispatcher {
254
270
  }
255
271
 
256
272
  async generateStream(req: GenerateRequest, cb: GenerateStreamCallbacks): Promise<void> {
273
+ // 이미지 생성은 non-stream 전용(R2): codex 가 이미지 델타를 주지 않고 완성 base64 를
274
+ // 한 번에 준다. 스트리밍 경로로 이미지 요청이 오면 명시적 에러.
275
+ if (req.imageGeneration) {
276
+ throw new ImageGenerationError("gate", "image generation is not supported on the streaming path");
277
+ }
257
278
  const excludedTokenIds = new Set<number>();
258
279
  const reuseWorker = await this.acquireReuseWorker(req, excludedTokenIds);
259
280
  if (reuseWorker) {
@@ -281,6 +302,9 @@ export class OpenAIDispatcher implements ProviderDispatcher {
281
302
  req: GenerateRequest,
282
303
  excludedTokenIds = new Set<number>(),
283
304
  ): Promise<CodexAppServerWorker | null> {
305
+ // 이미지 요청은 항상 cold thread(R8): 재사용 좌표가 있어도 건너뛴다.
306
+ // base64 가 thread 메모리·캐시 prefix 에 상주하는 것을 막는다.
307
+ if (req.imageGeneration) return null;
284
308
  if (!THREAD_REUSE_ENABLED || !req.reuse) return null;
285
309
  const { workerId, threadId, epoch } = req.reuse;
286
310
 
@@ -481,6 +505,8 @@ export class OpenAIDispatcher implements ProviderDispatcher {
481
505
  verbosity: req.verbosity,
482
506
  reasoningSummary: req.reasoningSummary,
483
507
  serviceTier: req.serviceTier,
508
+ imageGeneration: req.imageGeneration,
509
+ imageGenerationOptions: req.imageGenerationOptions,
484
510
  };
485
511
  }
486
512
 
@@ -489,8 +515,23 @@ export class OpenAIDispatcher implements ProviderDispatcher {
489
515
  req: GenerateRequest,
490
516
  reuseThreadId?: string,
491
517
  ): Promise<GenerateResult> {
518
+ // 이미지 요청 사전 게이트(R5): 배정된 worker 의 capability + 적용 모델 멀티모달.
519
+ // 불충족이면 turn 을 실행하지 않고 명시적 에러.
520
+ if (req.imageGeneration) {
521
+ const reason = await worker.checkImageGenerationSupport(req.model);
522
+ if (reason) throw new ImageGenerationError("gate", reason);
523
+ }
524
+
492
525
  const turnReq = this.buildTurnRequest(req, reuseThreadId);
493
526
  const result = await worker.executeTurn(turnReq, reuseThreadId);
527
+
528
+ // 이미지 0개 실패 분류(R6): tool 미호출(재시도 무익) vs 시도 후 미완성(재시도 후보).
529
+ if (req.imageGeneration && (!result.images || result.images.length === 0)) {
530
+ throw result.imageAttempted
531
+ ? new ImageGenerationError("incomplete", "image tool ran but produced no completed image")
532
+ : new ImageGenerationError("not_called", "model did not call the image_generation tool");
533
+ }
534
+
494
535
  return {
495
536
  text: result.text,
496
537
  tokenName: worker.tokenName,
@@ -503,6 +544,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
503
544
  threadId: result.threadId,
504
545
  epoch: worker.epoch,
505
546
  },
547
+ images: result.images,
506
548
  };
507
549
  }
508
550