@cartanova/qgrid-cli 2.10.5 → 2.10.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/bundle/dist/application/monit/monit.frame.js +1 -1
  2. package/bundle/dist/application/qgrid/expired-token-reminder.js +1 -1
  3. package/bundle/dist/application/qgrid/oauth.js +1 -1
  4. package/bundle/dist/application/qgrid/qgrid-image-generation.js +7 -11
  5. package/bundle/dist/application/qgrid/qgrid-response-format.js +4 -4
  6. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +24 -7
  7. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +8 -3
  8. package/bundle/dist/application/qgrid/qgrid.frame.js +24 -10
  9. package/bundle/dist/application/qgrid/token-death.js +1 -1
  10. package/bundle/dist/application/qgrid/token-window-keepalive.js +1 -1
  11. package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
  12. package/bundle/dist/application/request-log/request-log.model.js +2 -2
  13. package/bundle/dist/i18n/sd.generated.js +3 -3
  14. package/bundle/dist/sonamu.config.js +2 -2
  15. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +1 -1
  16. package/bundle/dist/utils/providers/anthropic/anthropic-thinking.js +1 -1
  17. package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +13 -2
  18. package/bundle/dist/utils/providers/openai/openai-direct-client.js +5 -4
  19. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +111 -57
  20. package/bundle/dist/utils/providers/openai/openai-image-generation.js +25 -17
  21. package/bundle/dist/utils/providers/openai/openai-refresh.js +1 -1
  22. package/bundle/src/application/qgrid/qgrid-image-generation.test.ts +19 -8
  23. package/bundle/src/application/qgrid/qgrid-image-generation.ts +17 -24
  24. package/bundle/src/application/qgrid/qgrid-response-format.test.ts +4 -2
  25. package/bundle/src/application/qgrid/qgrid-response-format.ts +7 -10
  26. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +87 -9
  27. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +45 -5
  28. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +136 -2
  29. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +11 -1
  30. package/bundle/src/application/qgrid/qgrid.frame.test.ts +73 -0
  31. package/bundle/src/application/qgrid/qgrid.frame.ts +39 -4
  32. package/bundle/src/application/request-log/request-log.entity.json +2 -2
  33. package/bundle/src/application/sonamu.generated.http +2 -2
  34. package/bundle/src/i18n/sd.generated.ts +2 -2
  35. package/bundle/src/utils/providers/openai/openai-backend-protocol.test.ts +23 -1
  36. package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +24 -2
  37. package/bundle/src/utils/providers/openai/openai-direct-client.test.ts +15 -0
  38. package/bundle/src/utils/providers/openai/openai-direct-client.ts +14 -4
  39. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +223 -32
  40. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +174 -72
  41. package/bundle/src/utils/providers/openai/openai-image-generation.test.ts +89 -0
  42. package/bundle/src/utils/providers/openai/openai-image-generation.ts +26 -15
  43. package/bundle/web-dist/client/assets/cost-DH2jZbrw.js +1 -0
  44. package/bundle/web-dist/client/assets/{index-Dxw7DiXF.js → index-D9ryIyL-.js} +4 -4
  45. package/bundle/web-dist/client/assets/logs-CLf2VNfk.js +1 -0
  46. package/bundle/web-dist/client/assets/{monit-E8eGW1Sj.js → monit-DojxOLj6.js} +1 -1
  47. package/bundle/web-dist/client/assets/{routes-7spLfIGZ.js → routes-DXJhNvbI.js} +2 -2
  48. package/bundle/web-dist/client/assets/{services.generated-jnPHYMER.js → services.generated-DqFYgWsY.js} +1 -1
  49. package/bundle/web-dist/client/assets/{settings-BdosZFNC.js → settings-bopyz9pa.js} +1 -1
  50. package/bundle/web-dist/client/assets/{show-b2PFNI3D.js → show-DbLD3MzB.js} +1 -1
  51. package/bundle/web-dist/client/assets/{tokens-CBdHb2_U.js → tokens-2-AIi9sZ.js} +1 -1
  52. package/bundle/web-dist/client/assets/{use-oauth-login-flow-Cyn3YPqW.js → use-oauth-login-flow-CnqTWs_H.js} +1 -1
  53. package/bundle/web-dist/client/assets/{use-update-token-mutation-DQCwCYfR.js → use-update-token-mutation-Cxb9K0fM.js} +1 -1
  54. package/bundle/web-dist/client/index.html +2 -2
  55. package/bundle/web-dist/server/assets/{cost-59jBdx-I.js → cost-eEHeTRT5.js} +4 -5
  56. package/bundle/web-dist/server/assets/{lazyRouteComponent-CUR-kWzI.js → lazyRouteComponent-Bi0fYGLs.js} +2 -2
  57. package/bundle/web-dist/server/assets/{logs-CaaWO9-Z.js → logs-CIVFxfoi.js} +10 -9
  58. package/bundle/web-dist/server/assets/{monit-CBil92fJ.js → monit-DBXezg7R.js} +4 -4
  59. package/bundle/web-dist/server/assets/{plus-Bvdj8Pwx.js → plus-BH-t4cm_.js} +1 -1
  60. package/bundle/web-dist/server/assets/{react-dom-CyuShs9K.js → react-dom-CZ2eynd8.js} +1 -1
  61. package/bundle/web-dist/server/assets/{refresh-cw-3QAiSWBd.js → refresh-cw-CosAdcz0.js} +1 -1
  62. package/bundle/web-dist/server/assets/{routes-DtVf9yqo.js → routes-COWYIvA-.js} +6 -7
  63. package/bundle/web-dist/server/assets/{services.generated-FPwsG9p3.js → services.generated-607iwhMI.js} +2 -2
  64. package/bundle/web-dist/server/assets/{settings-DeHNwoMv.js → settings-C_6dpK5p.js} +3 -3
  65. package/bundle/web-dist/server/assets/{show-8NNdRxhM.js → show-DY396-LD.js} +8 -20
  66. package/bundle/web-dist/server/assets/{tokens-odgS2-9q.js → tokens-COvV7qUP.js} +4 -4
  67. package/bundle/web-dist/server/assets/{use-oauth-login-flow-DTQKdYBO.js → use-oauth-login-flow-VYCYf3uv.js} +1 -1
  68. package/bundle/web-dist/server/assets/{use-update-token-mutation-xvXbPrCI.js → use-update-token-mutation-D8XYV5of.js} +1 -1
  69. package/bundle/web-dist/server/entry-server.generated.js +10 -10
  70. package/package.json +1 -1
  71. package/skills/qgrid/SKILL.md +9 -8
  72. package/skills/qgrid/references/ai-sdk-provider-contract.md +25 -16
  73. package/skills/qgrid/references/decision-rationale.md +8 -8
  74. package/skills/qgrid/references/openai-codex-runtime.md +19 -11
  75. package/skills/qgrid/references/request-log-run-lifecycle.md +4 -2
  76. package/skills/qgrid/references/sonamu-api-web-flow.md +2 -2
  77. package/skills/qgrid/references/verification-and-debugging.md +23 -9
  78. package/bundle/web-dist/client/assets/cost-CY2BJEPC.js +0 -1
  79. package/bundle/web-dist/client/assets/logs-3n_U4qnI.js +0 -1
@@ -100,12 +100,12 @@ export async function beforeQuery(args: QueryInput): Promise<{
100
100
  return { requestLogId, stepIndex };
101
101
  }
102
102
 
103
- export async function afterQuery(
103
+ async function appendGenerateStep(
104
104
  requestLogId: number,
105
105
  stepIndex: number,
106
106
  args: QueryInput,
107
107
  result: QueryOutput,
108
- ): Promise<RunLifecycleResult> {
108
+ ) {
109
109
  // provider가 canonical model을 돌려줘도 요청 route 자체([1m] 등)는 보존한다.
110
110
  const requestedModelName = withProviderPrefix(
111
111
  args.model,
@@ -132,6 +132,21 @@ export async function afterQuery(
132
132
  ttft_ms: result.ttftMs,
133
133
  finish_reason: result.finishReason,
134
134
  });
135
+ return { requestedModelName, servedModelName };
136
+ }
137
+
138
+ export async function afterQuery(
139
+ requestLogId: number,
140
+ stepIndex: number,
141
+ args: QueryInput,
142
+ result: QueryOutput,
143
+ ): Promise<RunLifecycleResult> {
144
+ const { requestedModelName, servedModelName } = await appendGenerateStep(
145
+ requestLogId,
146
+ stepIndex,
147
+ args,
148
+ result,
149
+ );
135
150
 
136
151
  const imageParts = getImageParts(result);
137
152
  const imageCostMicroUsd = estimateImageGenerationCostMicroUsd(
@@ -200,12 +215,17 @@ export async function finishRunWithError(
200
215
  requestLogId: number,
201
216
  errorMessage: string,
202
217
  args?: QueryInput,
218
+ completedStep?: { stepIndex: number; result: QueryOutput },
203
219
  ): Promise<void> {
204
- await finishTerminalRun(requestLogId, "error", errorMessage, args);
220
+ await finishTerminalRun(requestLogId, "error", errorMessage, args, completedStep);
205
221
  }
206
222
 
207
- export async function finishRunAborted(requestLogId: number, args?: QueryInput): Promise<void> {
208
- await finishTerminalRun(requestLogId, "aborted", "client disconnected", args);
223
+ export async function finishRunAborted(
224
+ requestLogId: number,
225
+ args?: QueryInput,
226
+ completedStep?: { stepIndex: number; result: QueryOutput },
227
+ ): Promise<void> {
228
+ await finishTerminalRun(requestLogId, "aborted", "client disconnected", args, completedStep);
209
229
  }
210
230
 
211
231
  async function finishTerminalRun(
@@ -213,8 +233,18 @@ async function finishTerminalRun(
213
233
  status: "error" | "aborted",
214
234
  errorMessage: string,
215
235
  args?: QueryInput,
236
+ completedStep?: { stepIndex: number; result: QueryOutput },
216
237
  ): Promise<void> {
217
238
  try {
239
+ if (completedStep && args) {
240
+ try {
241
+ await appendGenerateStep(requestLogId, completedStep.stepIndex, args, completedStep.result);
242
+ } catch (e) {
243
+ logger.error(
244
+ `appendGenerateStep failed while finishing ${status}: ${(e as Error).message}`,
245
+ );
246
+ }
247
+ }
218
248
  let aggregate: Awaited<ReturnType<typeof RequestLogModel.aggregateStepUsage>> | undefined;
219
249
  try {
220
250
  aggregate = await RequestLogModel.aggregateStepUsage(requestLogId);
@@ -226,6 +256,16 @@ async function finishTerminalRun(
226
256
  status,
227
257
  error_message: errorMessage,
228
258
  history: args ? filterHistoryForStorage(args.history) : undefined,
259
+ ...(completedStep
260
+ ? {
261
+ token_name: completedStep.result.tokenName,
262
+ requested_model_name: withProviderPrefix(
263
+ args?.model,
264
+ args?.model ?? completedStep.result.requestedModel ?? completedStep.result.model,
265
+ ),
266
+ model_name: withProviderPrefix(args?.model, completedStep.result.model),
267
+ }
268
+ : {}),
229
269
  ...(aggregate
230
270
  ? {
231
271
  input_tokens: aggregate.input_tokens,
@@ -6,10 +6,12 @@ import {
6
6
  type GenerateStreamCallbacks,
7
7
  } from "../../utils/providers/common/provider-dispatcher";
8
8
  import { systemHash } from "./conv-routing";
9
- import { OpenAIDispatcher } from "../../utils/providers/openai/openai-dispatcher";
9
+ import { ImageGenerationError, OpenAIDispatcher } from "../../utils/providers/openai/openai-dispatcher";
10
10
  import { normalizeOpenAIEvent } from "../../utils/providers/openai/openai-backend-protocol";
11
- import { buildStrictOutputSchema, QgridDispatcherClass } from "./qgrid.dispatcher";
11
+ import { buildStrictOutputSchema, completedDriverFromImageFailure, QgridDispatcherClass } from "./qgrid.dispatcher";
12
12
  import { QueryInput, type QueryOutput } from "./qgrid.types";
13
+ import { PNG } from "pngjs";
14
+ import { estimateImageGenerationCostMicroUsd } from "./qgrid-image-generation";
13
15
  import { type TokenSubsetA } from "../sonamu.generated";
14
16
 
15
17
  function providerResult(overrides: Partial<GenerateResult> = {}): GenerateResult {
@@ -36,6 +38,138 @@ function deeplyNestedOutputSchema(depth: number): string {
36
38
  }
37
39
 
38
40
  describe("QgridDispatcherClass", () => {
41
+ it("prices only the completed chat driver from an image failure", () => {
42
+ const error = new ImageGenerationError("incomplete", "images unavailable");
43
+ error.completedDriver = providerResult({
44
+ model: "gpt-6-luna", requestedModel: "gpt-6-astra", ttftMs: 4,
45
+ usage: { totalTokens: 120, inputTokens: 100, outputTokens: 20, cachedInputTokens: 10, reasoningOutputTokens: 5 },
46
+ });
47
+ const result = completedDriverFromImageFailure(error)!;
48
+ expect(result).toMatchObject({
49
+ model: "gpt-6-luna", requestedModel: "gpt-6-astra", tokenName: "provider/test",
50
+ usage: { input_tokens: 100, output_tokens: 20, cache_read_input_tokens: 10, reasoning_tokens: 5 },
51
+ costSource: "pricing_table", finishReason: "stop", durationMs: 12, ttftMs: 4,
52
+ content: [{ type: "text", text: "ok" }],
53
+ });
54
+ expect(result.costUsd).toBeCloseTo(0.0000191, 10);
55
+ expect(estimateImageGenerationCostMicroUsd(result, undefined)).toBeNull();
56
+ expect(result.runContext).toBeUndefined();
57
+ expect(completedDriverFromImageFailure(new Error("failed"))).toBeUndefined();
58
+ expect(completedDriverFromImageFailure(new ImageGenerationError("gate", "invalid"))).toBeUndefined();
59
+ });
60
+ it.each([
61
+ [false, 0, "generations"],
62
+ [false, 2, "edits"],
63
+ [true, 1, "edits"],
64
+ ] as const)("auto chat uses Images %s/%s/%s and keeps both usages", async (transparent, referenceCount, endpoint) => {
65
+ const png = new PNG({ width: 2, height: 1 });
66
+ png.data.set([0, 0, 0, transparent ? 0 : 255, 255, 0, 0, 255]);
67
+ const imageUsage = {
68
+ input_tokens: 10, output_tokens: 100, total_tokens: 110,
69
+ input_tokens_details: { text_tokens: 4, image_tokens: 6 },
70
+ };
71
+ const fetchMock = vi.fn(async (url: string | URL | Request, _init?: RequestInit) => {
72
+ if (String(url).endsWith("/responses")) {
73
+ const events = [
74
+ { type: "response.output_item.done", item: {
75
+ type: "function_call", call_id: "image-call", name: "generate_image",
76
+ arguments: JSON.stringify({ prompt: "A red square", transparent_background: transparent, num_last_images_to_include: referenceCount }),
77
+ } },
78
+ { type: "response.completed", response: {
79
+ id: "driver", model: "gpt-6-luna",
80
+ usage: { input_tokens: 100, output_tokens: 20, total_tokens: 120 },
81
+ } },
82
+ ];
83
+ return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } });
84
+ }
85
+ return Response.json({
86
+ data: [{ b64_json: PNG.sync.write(png).toString("base64") }],
87
+ background: transparent ? "transparent" : "opaque", quality: "medium", usage: imageUsage,
88
+ });
89
+ });
90
+ const provider = new OpenAIDispatcher("https", { fetch: fetchMock });
91
+ await provider.onTokenAdded(1, "chat-token", {
92
+ accessToken: "test-access", refreshToken: "", accountId: "test-account", accessTokenExpiresAt: Date.now() + 60000,
93
+ });
94
+ const dispatcher = new QgridDispatcherClass();
95
+ dispatcher.openaiDispatcher = provider;
96
+ try {
97
+ const result = await dispatcher.query({
98
+ model: "openai/gpt-6-luna", prompt: "Draw or edit a square", imageGeneration: "auto",
99
+ history: JSON.stringify([{ type: "message", role: "user", content: [{ type: "input_image", image_url: "https://example.com/old.png" }] }]),
100
+ input: [{ type: "image", url: "https://example.com/new.png" }],
101
+ });
102
+ expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
103
+ "https://chatgpt.com/backend-api/codex/responses",
104
+ `https://chatgpt.com/backend-api/codex/images/${endpoint}`,
105
+ ]);
106
+ const driverInit = fetchMock.mock.calls[0]![1]!;
107
+ const imageInit = fetchMock.mock.calls[1]![1]!;
108
+ expect(JSON.parse(String(driverInit.body)).tools).toEqual([
109
+ expect.objectContaining({ type: "function", name: "generate_image" }),
110
+ ]);
111
+ const imageBody = JSON.parse(String(imageInit.body));
112
+ expect(imageBody).toMatchObject({ model: "gpt-image-2", background: transparent ? "transparent" : "opaque" });
113
+ expect(imageBody.prompt).toContain("A red square");
114
+ expect(imageBody.prompt).not.toContain("Draw or edit a square");
115
+ const refs = ["https://example.com/old.png", "https://example.com/new.png"].slice(-referenceCount);
116
+ expect(imageBody.images).toEqual(referenceCount ? refs.map((image_url) => ({ image_url })) : undefined);
117
+ expect(imageInit.signal).toBe(driverInit.signal);
118
+ expect(new Headers(imageInit.headers).get("authorization")).toBe(new Headers(driverInit.headers).get("authorization"));
119
+ expect(result).toMatchObject({ model: "gpt-6-luna", usage: { input_tokens: 100, output_tokens: 20 } });
120
+ expect(result.costUsd).toBeCloseTo(0.00002, 8);
121
+ expect(result.content.find((item) => item.type === "image")).toMatchObject({ generation: { route: "codex-images", usage: imageUsage } });
122
+ expect(estimateImageGenerationCostMicroUsd(result, undefined)).toBe(3068);
123
+ } finally {
124
+ await provider.stop();
125
+ }
126
+ });
127
+
128
+ it("routes the image model straight to subscription edits and keeps image usage separate", async () => {
129
+ const png = new PNG({ width: 1, height: 1 });
130
+ png.data.set([255, 0, 0, 255]);
131
+ const base64 = PNG.sync.write(png).toString("base64");
132
+ const usage = {
133
+ input_tokens: 10, output_tokens: 100, total_tokens: 110,
134
+ input_tokens_details: { text_tokens: 4, image_tokens: 6 },
135
+ };
136
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({
137
+ data: [{ b64_json: base64 }], usage, background: "opaque", quality: "medium",
138
+ })));
139
+ const provider = new OpenAIDispatcher("https", { fetch: fetchMock });
140
+ await provider.onTokenAdded(1, "test", {
141
+ accessToken: "unused", refreshToken: "", accountId: "unused",
142
+ accessTokenExpiresAt: Date.now() + 60000,
143
+ });
144
+ const dispatcher = new QgridDispatcherClass();
145
+ dispatcher.openaiDispatcher = provider;
146
+ try {
147
+ const result = await dispatcher.query({
148
+ model: "openai/gpt-image-2", prompt: "recolor", imageGeneration: true,
149
+ input: [
150
+ { type: "text", text: "recolor", text_elements: [] },
151
+ { type: "image", url: `data:image/png;base64,${base64}` },
152
+ ],
153
+ });
154
+ expect(fetchMock).toHaveBeenCalledOnce();
155
+ expect(fetchMock).toHaveBeenCalledWith(
156
+ "https://chatgpt.com/backend-api/codex/images/edits",
157
+ expect.objectContaining({ method: "POST", signal: expect.any(AbortSignal) }),
158
+ );
159
+ expect(result).toMatchObject({
160
+ model: "gpt-image-2", costUsd: 0,
161
+ usage: { input_tokens: 0, output_tokens: 0 },
162
+ content: expect.arrayContaining([expect.objectContaining({
163
+ type: "image", mediaType: "image/png",
164
+ generation: { route: "codex-images", model: "gpt-image-2", background: "opaque", size: "1x1", quality: "medium", usage },
165
+ })]),
166
+ });
167
+ expect(result.runContext?.threadCoord).toBeUndefined();
168
+ } finally {
169
+ await provider.stop();
170
+ }
171
+ });
172
+
39
173
  it.each(["generate", "stream"])("%s yields a completed tool-call message before a speculative later answer", async (mode) => {
40
174
  const tool = JSON.stringify({result: {action: "tool_call", answer: null, toolCalls: [{toolName: "lookupMarker", args: "{}"}]}});
41
175
  const answer = JSON.stringify({result: {action: "answer", answer: {marker: ""}, toolCalls: null}});
@@ -17,7 +17,10 @@ import {
17
17
  serializeAndValidateDispatchSchema,
18
18
  } from "../../utils/providers/common/schema-validation";
19
19
  import { strictify } from "../../utils/providers/common/strictifier";
20
- import { type OpenAIDispatcher } from "../../utils/providers/openai/openai-dispatcher";
20
+ import {
21
+ ImageGenerationError,
22
+ type OpenAIDispatcher,
23
+ } from "../../utils/providers/openai/openai-dispatcher";
21
24
  import { type TokenSubsetA } from "../sonamu.generated";
22
25
  import { decideConvRouting, issueConvContext } from "./conv-routing";
23
26
  import {
@@ -325,6 +328,13 @@ function buildRawOutputSchema(
325
328
  : callerOutputSchema;
326
329
  }
327
330
 
331
+ export function completedDriverFromImageFailure(error: unknown): QueryOutput | undefined {
332
+ if (!(error instanceof ImageGenerationError) || !error.completedDriver) return undefined;
333
+ return applyToolCallEmulation(toEmulationResult(error.completedDriver), undefined, {
334
+ answerKind: "text",
335
+ });
336
+ }
337
+
328
338
  // Anthropic adapter 는 cache creation 을 inputTokens 에 포함해 표준화하고, 별도 필드에도 보존한다.
329
339
  function toEmulationResult(
330
340
  result: GenerateResult,
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
 
6
6
  import { QgridFrame } from "./qgrid.frame";
7
7
  import { QueryInput } from "./qgrid.types";
8
+ import { ImageGenerationError } from "../../utils/providers/openai/openai-dispatcher";
8
9
 
9
10
  const {
10
11
  findOneMock,
@@ -213,6 +214,7 @@ describe("QgridFrame.query request logging", () => {
213
214
  beforeQueryMock.mockReset().mockResolvedValue({ requestLogId: 41, stepIndex: 0 });
214
215
  afterQueryMock.mockReset().mockResolvedValue({});
215
216
  finishRunWithErrorMock.mockReset();
217
+ finishRunAbortedMock.mockReset();
216
218
  dispatcherQueryMock.mockReset();
217
219
  findActiveByProviderAndNameMock.mockReset();
218
220
  });
@@ -385,6 +387,45 @@ describe("QgridFrame.query request logging", () => {
385
387
  expect(finishRunWithErrorMock).toHaveBeenCalledWith(41, "provider failed", args);
386
388
  });
387
389
 
390
+ it.each(["error", "abort", "logger-disabled"])("handles completed driver accounting on image %s", async (mode) => {
391
+ const requestRaw = Object.assign(new EventEmitter(), { aborted: false });
392
+ const responseRaw = Object.assign(new EventEmitter(), { destroyed: false, writableEnded: false });
393
+ vi.spyOn(Sonamu, "getContext").mockReturnValue({
394
+ transport: "http", request: { raw: requestRaw }, reply: { raw: responseRaw },
395
+ } as never);
396
+ const error = new ImageGenerationError("incomplete", "image failed");
397
+ error.completedDriver = {
398
+ text: "drawing", tokenName: "one", model: "gpt-6-luna", durationMs: 20, ttftMs: 2,
399
+ threadCoord: { workerId: 1, threadId: "", epoch: -1 },
400
+ usage: { inputTokens: 100, outputTokens: 20, cachedInputTokens: 0, reasoningOutputTokens: 0, totalTokens: 120 },
401
+ };
402
+ dispatcherQueryMock.mockImplementationOnce(async () => {
403
+ if (mode === "abort") { responseRaw.destroyed = true; responseRaw.emit("close"); }
404
+ throw error;
405
+ });
406
+ const args = {
407
+ prompt: "draw", model: "openai/gpt-6-luna", imageGeneration: "auto" as const,
408
+ ...(mode === "logger-disabled" ? { logger: false } : {}),
409
+ };
410
+ await expect(QgridFrame.query(args)).rejects.toBe(error);
411
+ expect(afterQueryMock).not.toHaveBeenCalled();
412
+ if (mode === "logger-disabled") {
413
+ expect(beforeQueryMock).not.toHaveBeenCalled();
414
+ expect(finishRunWithErrorMock).not.toHaveBeenCalled();
415
+ expect(finishRunAbortedMock).not.toHaveBeenCalled();
416
+ } else {
417
+ const completedStep = expect.objectContaining({
418
+ stepIndex: 0,
419
+ result: expect.objectContaining({
420
+ model: "gpt-6-luna", tokenName: "one", costUsd: 0.00002,
421
+ usage: expect.objectContaining({ input_tokens: 100, output_tokens: 20 }),
422
+ }),
423
+ });
424
+ if (mode === "abort") expect(finishRunAbortedMock).toHaveBeenCalledWith(41, args, completedStep);
425
+ else expect(finishRunWithErrorMock).toHaveBeenCalledWith(41, "image failed", args, completedStep);
426
+ }
427
+ });
428
+
388
429
  it("marks an incoherent structured envelope failure as a request-log error", async () => {
389
430
  const error = new Error(
390
431
  "tool-call emulation: structured response envelope is missing required keys: toolCalls",
@@ -775,6 +816,32 @@ describe("QgridFrame.query request logging", () => {
775
816
  expect(QueryInput.safeParse({ prompt: "hi", logger: false }).success).toBe(true);
776
817
  });
777
818
 
819
+ it.each([
820
+ { imageGeneration: undefined },
821
+ { imageGeneration: "auto" as const },
822
+ { imageGeneration: true, tools: [{ name: "lookup", inputSchema: { type: "object" } }] },
823
+ { imageGeneration: true, jsonSchema: '{"type":"object"}' },
824
+ { imageGeneration: true, imageGenerationOptions: { outputFormat: "webp" as const } },
825
+ ])("rejects unsupported standalone image requests before logging: %j", async (options) => {
826
+ await expect(QgridFrame.query({
827
+ prompt: "draw", model: "openai/gpt-image-2", ...options,
828
+ })).rejects.toMatchObject({ statusCode: 400 });
829
+ expect(beforeQueryMock).not.toHaveBeenCalled();
830
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
831
+ });
832
+
833
+ it.each([
834
+ { imageGenerationOptions: { outputFormat: "webp" as const } },
835
+ { tools: [{ name: "lookup", inputSchema: { type: "object" } }] },
836
+ { jsonSchema: '{"type":"object"}' },
837
+ ])("rejects unsupported auto image controls before logging: %j", async (options) => {
838
+ await expect(QgridFrame.query({
839
+ prompt: "draw", model: "openai/gpt-6-luna", imageGeneration: "auto", ...options,
840
+ })).rejects.toMatchObject({ statusCode: 400 });
841
+ expect(beforeQueryMock).not.toHaveBeenCalled();
842
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
843
+ });
844
+
778
845
  it("accepts bounded positive integer timeout values only", () => {
779
846
  expect(QueryInput.safeParse({ prompt: "hi", timeout: 360_000 }).success).toBe(true);
780
847
  expect(QueryInput.safeParse({ prompt: "hi", timeout: 0 }).success).toBe(false);
@@ -789,6 +856,12 @@ describe("QgridFrame.prepareStream", () => {
789
856
  requestLogSaveMock.mockReset();
790
857
  });
791
858
 
859
+ it("rejects an image model without a streaming image flag", async () => {
860
+ await expect(QgridFrame.prepareStream({
861
+ prompt: "draw", model: "openai/gpt-image-2",
862
+ })).rejects.toMatchObject({ statusCode: 400 });
863
+ });
864
+
792
865
  it.each([true, "auto"] as const)("rejects imageGeneration=%s before creating an SSE stream", async (imageGeneration) => {
793
866
  await expect(
794
867
  QgridFrame.prepareStream({
@@ -37,6 +37,7 @@ import {
37
37
  refreshAccessToken,
38
38
  RefreshFailedError,
39
39
  } from "./oauth";
40
+ import { CODEX_IMAGE_GENERATION_MODEL } from "./qgrid-image-generation";
40
41
  import {
41
42
  afterQuery,
42
43
  beforeQuery,
@@ -45,6 +46,7 @@ import {
45
46
  } from "./qgrid-run-lifecycle";
46
47
  import {
47
48
  buildAndValidateStrictOutputSchema,
49
+ completedDriverFromImageFailure,
48
50
  type InternalQueryInput,
49
51
  QgridDispatcher,
50
52
  } from "./qgrid.dispatcher";
@@ -145,12 +147,35 @@ function parsePastedOAuthResult(input: string): { code: string; state: string }
145
147
  }
146
148
 
147
149
  function rejectImageGenerationStream(args: QueryInput): void {
148
- if (!args.imageGeneration) return;
150
+ if (!args.imageGeneration && args.model !== `openai/${CODEX_IMAGE_GENERATION_MODEL}`) return;
149
151
  throw new BadRequestException(
150
- "qgrid: imageGeneration is not supported with streaming; use query/generateText instead." as LocalizedString,
152
+ "qgrid: imageGeneration is not supported with streaming; use query or generateImage with qgrid.image() instead." as LocalizedString,
151
153
  );
152
154
  }
153
155
 
156
+ function rejectInvalidImageQuery(args: QueryInput): void {
157
+ const standalone = args.model === `openai/${CODEX_IMAGE_GENERATION_MODEL}`;
158
+ if (!standalone && args.imageGeneration !== "auto") return;
159
+ if (standalone && args.imageGeneration !== true) {
160
+ throw new BadRequestException(
161
+ "qgrid: the image model requires imageGeneration: true; use qgrid.image() with generateImage." as LocalizedString,
162
+ );
163
+ }
164
+ if (args.tools?.length || args.jsonSchema !== undefined) {
165
+ throw new BadRequestException(
166
+ "qgrid: automatic and standalone image generation do not support caller tools or structured output." as LocalizedString,
167
+ );
168
+ }
169
+ if (
170
+ args.imageGenerationOptions?.outputFormat &&
171
+ args.imageGenerationOptions.outputFormat !== "png"
172
+ ) {
173
+ throw new BadRequestException(
174
+ "qgrid: automatic and standalone image generation only support PNG output." as LocalizedString,
175
+ );
176
+ }
177
+ }
178
+
154
179
  // anthropic 은 --json-schema 를 쓰지 않으므로(SON-532) strict 변환·argv 제한 없이 원형의
155
180
  // 구문·복잡도만 검증된다 — provider 분기는 buildAndValidateStrictOutputSchema 가 소유한다.
156
181
  function rejectInvalidCallerSchemas(args: QueryInput): void {
@@ -247,6 +272,7 @@ class QgridFrameClass extends BaseFrameClass {
247
272
 
248
273
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
249
274
  async query(args: QueryInput): Promise<QueryOutput> {
275
+ rejectInvalidImageQuery(args);
250
276
  rejectInvalidCallerSchemas(args);
251
277
  const resolvedArgs = await resolveTokenName(args);
252
278
  const disconnect = createHttpDisconnectHandle();
@@ -260,10 +286,19 @@ class QgridFrameClass extends BaseFrameClass {
260
286
  try {
261
287
  result = await QgridDispatcher.query(resolvedArgs, disconnect.signal);
262
288
  } catch (e) {
289
+ const completedDriver = completedDriverFromImageFailure(e);
290
+ const completedStep = completedDriver
291
+ ? ([{ stepIndex, result: completedDriver }] as const)
292
+ : ([] as const);
263
293
  if (disconnect.signal.aborted) {
264
- await finishRunAborted(requestLogId, resolvedArgs);
294
+ await finishRunAborted(requestLogId, resolvedArgs, ...completedStep);
265
295
  } else {
266
- await finishRunWithError(requestLogId, (e as Error).message, resolvedArgs);
296
+ await finishRunWithError(
297
+ requestLogId,
298
+ (e as Error).message,
299
+ resolvedArgs,
300
+ ...completedStep,
301
+ );
267
302
  }
268
303
  throw e;
269
304
  }
@@ -134,14 +134,14 @@
134
134
  "name": "image_cost_usd",
135
135
  "type": "integer",
136
136
  "nullable": true,
137
- "desc": "이미지 생성 추정 비용"
137
+ "desc": "이미지 생성 비용"
138
138
  },
139
139
  {
140
140
  "name": "image_cost_method",
141
141
  "type": "string",
142
142
  "length": 100,
143
143
  "nullable": true,
144
- "desc": "이미지 비용 추정 방식"
144
+ "desc": "이미지 비용 계산 방식"
145
145
  },
146
146
  {
147
147
  "name": "effort",
@@ -320,7 +320,7 @@ Content-Type: application/json
320
320
  "imageGeneration": false,
321
321
  "imageGenerationOptions": {
322
322
  "quality": "low",
323
- "size": "1024x1024",
323
+ "size": "auto",
324
324
  "background": "auto",
325
325
  "outputFormat": "png"
326
326
  }
@@ -385,7 +385,7 @@ Content-Type: application/json
385
385
  "imageGeneration": false,
386
386
  "imageGenerationOptions": {
387
387
  "quality": "low",
388
- "size": "1024x1024",
388
+ "size": "auto",
389
389
  "background": "auto",
390
390
  "outputFormat": "png"
391
391
  }
@@ -230,8 +230,8 @@ const entityLabels = {
230
230
  "entity.RequestLog.ttft_ms": "first gen TTFT(ms)",
231
231
  "entity.RequestLog.cost_usd": "API 비용",
232
232
  "entity.RequestLog.cost_source": "비용 출처(provider/pricing_table/mixed)",
233
- "entity.RequestLog.image_cost_usd": "이미지 생성 추정 비용",
234
- "entity.RequestLog.image_cost_method": "이미지 비용 추정 방식",
233
+ "entity.RequestLog.image_cost_usd": "이미지 생성 비용",
234
+ "entity.RequestLog.image_cost_method": "이미지 비용 계산 방식",
235
235
  "entity.RequestLog.effort": "effort level",
236
236
  "entity.RequestLog.history": "inject_items 히스토리",
237
237
  "entity.RequestLog.tools": "요청에 장착된 tool 정의 목록",
@@ -27,7 +27,7 @@ describe("OpenAI Codex backend protocol", () => {
27
27
  } })).toMatchObject({ generation: { route: "codex-responses", size: "841x1870" } });
28
28
  });
29
29
  it("distinguishes partial function items from completed arguments", () => {
30
- const item = { type: "function_call", call_id: "call", name: "generate_transparent_image", arguments: "{}" };
30
+ const item = { type: "function_call", call_id: "call", name: "generate_image", arguments: "{}" };
31
31
  expect(normalizeOpenAIEvent({ type: "response.output_item.added", item })).toEqual({ type: "output-item", item, completed: false });
32
32
  expect(normalizeOpenAIEvent({ type: "response.output_item.done", item })).toEqual({ type: "output-item", item, completed: true });
33
33
  });
@@ -149,4 +149,26 @@ describe("OpenAI Codex backend protocol", () => {
149
149
  }),
150
150
  ).toEqual({ type: "image", id: "img", base64: "aGVsbG8=", mimeType: "image/png", revisedPrompt: "better", generation: { route: "codex-responses" } });
151
151
  });
152
+
153
+ it("preserves zero image-tool usage without treating it as missing", () => {
154
+ const usage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
155
+ expect(normalizeOpenAIEvent({ type: "response.completed", response: {
156
+ id: "r", tool_usage: { image_gen: usage },
157
+ } })).toEqual({
158
+ type: "completed", responseId: "r",
159
+ imageGeneration: { route: "codex-responses", usage },
160
+ });
161
+ });
162
+
163
+ it.each([undefined, null, {}, { input_tokens: "4", output_tokens: 1, total_tokens: 5 }, { input_tokens: -1, output_tokens: 1, total_tokens: 0 }])(
164
+ "ignores missing or malformed image-tool usage: %j", (usage) => {
165
+ expect(normalizeOpenAIEvent({ type: "response.completed", response: {
166
+ id: "r", tool_usage: { image_gen: usage },
167
+ tools: [{ type: "image_generation", model: "gpt-image-2-codex", size: "auto", quality: "auto" }],
168
+ } })).toEqual({
169
+ type: "completed", responseId: "r",
170
+ imageGeneration: { route: "codex-responses", model: "gpt-image-2-codex" },
171
+ });
172
+ },
173
+ );
152
174
  });
@@ -1,6 +1,6 @@
1
1
  import os from "node:os";
2
2
 
3
- import { type ImageGenerationMetadata } from "../../../application/qgrid/qgrid.types";
3
+ import { ImageGenerationMetadata } from "../../../application/qgrid/qgrid.types";
4
4
  import { type OpenAIEffort } from "../common/effort";
5
5
 
6
6
  export const CHATGPT_CODEX_RESPONSES_URL =
@@ -180,7 +180,13 @@ export type OpenAINormalizedEvent =
180
180
  generation?: ImageGenerationMetadata;
181
181
  }
182
182
  | { type: "output-item"; item: OpenAIResponseItem; completed?: boolean }
183
- | { type: "completed"; responseId: string; usage?: OpenAIUsage; model?: string }
183
+ | {
184
+ type: "completed";
185
+ responseId: string;
186
+ usage?: OpenAIUsage;
187
+ model?: string;
188
+ imageGeneration?: ImageGenerationMetadata;
189
+ }
184
190
  | { type: "error"; error: OpenAIProtocolError };
185
191
 
186
192
  export class OpenAIProtocolError extends Error {
@@ -286,11 +292,27 @@ export function normalizeOpenAIEvent(raw: unknown): OpenAINormalizedEvent | unde
286
292
  }
287
293
  if (type === "response.completed") {
288
294
  const response = (event.response ?? {}) as Record<string, unknown>;
295
+ const toolUsage = response.tool_usage as Record<string, unknown> | undefined;
296
+ const parsedImageUsage = ImageGenerationMetadata.shape.usage.safeParse(toolUsage?.image_gen);
297
+ const imageUsage = parsedImageUsage.success ? parsedImageUsage.data : undefined;
298
+ const imageTool = Array.isArray(response.tools)
299
+ ? response.tools.find((tool) => tool?.type === "image_generation")
300
+ : undefined;
301
+ const imageModel = typeof imageTool?.model === "string" ? imageTool.model : undefined;
289
302
  return {
290
303
  type: "completed",
291
304
  responseId: String(response.id ?? ""),
292
305
  ...(response.usage ? { usage: usageFrom(response.usage) } : {}),
293
306
  ...(typeof response.model === "string" ? { model: response.model } : {}),
307
+ ...(imageUsage || imageModel
308
+ ? {
309
+ imageGeneration: {
310
+ route: "codex-responses" as const,
311
+ ...(imageModel ? { model: imageModel } : {}),
312
+ ...(imageUsage ? { usage: imageUsage } : {}),
313
+ },
314
+ }
315
+ : {}),
294
316
  };
295
317
  }
296
318
  if (type === "error" || type === "response.failed" || type === "response.incomplete") {
@@ -29,6 +29,21 @@ async function collect<T>(iterable: AsyncIterable<T>): Promise<T[]> {
29
29
  }
30
30
 
31
31
  describe("OpenAI direct HTTPS client", () => {
32
+ it.each([true, { background: "opaque" }])("keeps chat-model images on Responses (%j)", async (imageGeneration) => {
33
+ const fetchMock = vi.fn<typeof fetch>(async () =>
34
+ sse({ type: "response.completed", response: { id: "hosted-image" } }),
35
+ );
36
+ const client = new OpenAIDirectClient({
37
+ credentials: { accessToken: "access", accountId: "acct" }, fetch: fetchMock,
38
+ });
39
+ await collect(client.responses({ model: "gpt-6-luna", history: [], imageGeneration }));
40
+ expect(fetchMock).toHaveBeenCalledTimes(1);
41
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(CHATGPT_CODEX_RESPONSES_URL);
42
+ expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({
43
+ model: "gpt-6-luna", tools: [expect.objectContaining({ type: "image_generation" })],
44
+ });
45
+ });
46
+
32
47
  it("posts the pinned body and Codex identity without live calls", async () => {
33
48
  const fetchMock = vi.fn<typeof fetch>(async () =>
34
49
  sse(
@@ -450,8 +450,9 @@ export class OpenAIDirectClient {
450
450
 
451
451
  responses(options: OpenAIResponsesOptions, signal?: AbortSignal): OpenAIEventStream {
452
452
  if (
453
- typeof options.imageGeneration === "object" &&
454
- options.imageGeneration.background === "transparent"
453
+ options.model === "gpt-image-2" ||
454
+ (typeof options.imageGeneration === "object" &&
455
+ options.imageGeneration.background === "transparent")
455
456
  ) {
456
457
  return this.standaloneImages(options, signal);
457
458
  }
@@ -498,10 +499,19 @@ export class OpenAIDirectClient {
498
499
  continue;
499
500
  }
500
501
  if (!response.ok) throw await responseError(response);
501
- const images = await readStandaloneImageResponse(response);
502
+ const images = await readStandaloneImageResponse(
503
+ response,
504
+ typeof options.imageGeneration === "object" &&
505
+ options.imageGeneration.background === "transparent",
506
+ );
502
507
  signal?.throwIfAborted();
503
508
  yield* images;
504
- yield { type: "completed", responseId: requestId, model: "gpt-image-2" };
509
+ const firstImage = images[0];
510
+ yield {
511
+ type: "completed",
512
+ responseId: requestId,
513
+ model: firstImage?.type === "image" ? firstImage.generation?.model : "gpt-image-2",
514
+ };
505
515
  return;
506
516
  }
507
517
  }