@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.
- package/README.ko.md +131 -0
- package/README.md +60 -44
- package/bundle/dist/application/qgrid/qgrid-image-generation.js +42 -0
- package/bundle/dist/application/qgrid/qgrid-response-format.js +44 -0
- package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +23 -4
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +15 -6
- package/bundle/dist/application/qgrid/qgrid.frame.js +32 -4
- package/bundle/dist/application/qgrid/qgrid.types.js +36 -12
- package/bundle/dist/application/qgrid/tool-emulation.js +16 -8
- package/bundle/dist/application/request-log/request-log.model.js +5 -2
- package/bundle/dist/application/sonamu.generated.js +13 -4
- package/bundle/dist/application/sonamu.generated.sso.js +9 -3
- package/bundle/dist/i18n/sd.generated.js +4 -1
- package/bundle/dist/migrations/20260705140614_alter_request_logs_add1_alter1.js +16 -0
- package/bundle/dist/migrations/20260706120000_alter_request_logs_add_image_cost.js +17 -0
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +2 -1
- package/bundle/dist/utils/providers/openai/codex-rpc.js +1 -1
- package/bundle/dist/utils/providers/openai/codex-worker.js +78 -6
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +21 -4
- package/bundle/src/application/qgrid/qgrid-image-generation.ts +62 -0
- package/bundle/src/application/qgrid/qgrid-response-format.ts +59 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +105 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +34 -2
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +33 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +16 -5
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +78 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +48 -5
- package/bundle/src/application/qgrid/qgrid.types.ts +22 -0
- package/bundle/src/application/qgrid/tool-emulation.test.ts +44 -1
- package/bundle/src/application/qgrid/tool-emulation.ts +27 -4
- package/bundle/src/application/request-log/request-log.entity.json +73 -47
- package/bundle/src/application/request-log/request-log.model.ts +7 -0
- package/bundle/src/application/sonamu.generated.http +16 -3
- package/bundle/src/application/sonamu.generated.sso.ts +6 -0
- package/bundle/src/application/sonamu.generated.ts +12 -0
- package/bundle/src/i18n/sd.generated.ts +3 -0
- package/bundle/src/migrations/20260705140614_alter_request_logs_add1_alter1.ts +15 -0
- package/bundle/src/migrations/20260706120000_alter_request_logs_add_image_cost.ts +14 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +5 -0
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +14 -0
- package/bundle/src/utils/providers/openai/codex-rpc.ts +2 -0
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +219 -0
- package/bundle/src/utils/providers/openai/codex-worker.ts +154 -5
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +95 -1
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +42 -0
- package/bundle/web-dist/client/assets/index-Cj1iWi5i.js +78 -0
- package/bundle/web-dist/client/assets/index-VE_24EJf.css +1 -0
- package/bundle/web-dist/client/assets/{logs-B0pWpDrg.js → logs-DvGWhZTu.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-7XhVuG7o.js → routes-B_xvkM-J.js} +1 -1
- package/bundle/web-dist/client/assets/{sd.generated-DXhjjL2f.js → sd.generated-BrUnRt0B.js} +1 -1
- package/bundle/web-dist/client/assets/{services.generated-B7u0U1wC.js → services.generated-AsEvHbJ7.js} +1 -1
- package/bundle/web-dist/client/assets/{show-CcjZ9nr7.js → show-C6W3ywZz.js} +3 -3
- package/bundle/web-dist/client/assets/{tokens-DlwAm0v4.js → tokens-CxjeN72g.js} +1 -1
- package/bundle/web-dist/client/index.html +3 -3
- package/bundle/web-dist/server/assets/{lazyRouteComponent-B0LkbiYf.js → lazyRouteComponent-BswulcOx.js} +1 -1
- package/bundle/web-dist/server/assets/{logs-dLx6gMXT.js → logs-B_ZLzpwY.js} +4 -4
- package/bundle/web-dist/server/assets/{routes-DD3Hb9xO.js → routes-D9OiKoqa.js} +2 -2
- package/bundle/web-dist/server/assets/{sd.generated-DdfpD_iN.js → sd.generated-DRuQSIKo.js} +3 -0
- package/bundle/web-dist/server/assets/{services.generated-D3ZCvIkt.js → services.generated-Dp0uKgpV.js} +1 -1
- package/bundle/web-dist/server/assets/{show-BQjeyv6A.js → show-CGlX9OYt.js} +104 -13
- package/bundle/web-dist/server/assets/{tokens-CSOsBpP7.js → tokens-Cjw6cQxZ.js} +2 -2
- package/bundle/web-dist/server/entry-server.generated.js +9 -8
- package/package.json +7 -3
- package/postinstall.mjs +119 -0
- package/skills/qgrid/SKILL.md +54 -0
- package/skills/qgrid/agents/openai.yaml +4 -0
- package/skills/qgrid/references/ai-sdk-provider-contract.md +100 -0
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +170 -0
- package/skills/qgrid/references/cli-env-and-server-boot.md +66 -0
- package/skills/qgrid/references/decision-rationale.md +179 -0
- package/skills/qgrid/references/docs-workflow.md +32 -0
- package/skills/qgrid/references/openai-codex-runtime.md +153 -0
- package/skills/qgrid/references/prompt-cache-and-usage.md +101 -0
- package/skills/qgrid/references/provider-runtime-differences.md +54 -0
- package/skills/qgrid/references/repo-map.md +37 -0
- package/skills/qgrid/references/request-log-run-lifecycle.md +76 -0
- package/skills/qgrid/references/sonamu-api-web-flow.md +47 -0
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +205 -0
- package/skills/qgrid/references/tool-calling-and-multiturn.md +144 -0
- package/skills/qgrid/references/verification-and-debugging.md +193 -0
- package/bundle/web-dist/client/assets/index-5WdFEqZT.css +0 -1
- 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:
|
|
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
|
-
|
|
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")
|
|
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({
|
|
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")
|
|
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
|
|