@cartanova/qgrid-cli 2.10.4 → 2.10.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/bundle/dist/application/monit/monit.frame.js +1 -1
  2. package/bundle/dist/application/qgrid/expired-token-reminder.js +1 -1
  3. package/bundle/dist/application/qgrid/oauth.js +1 -1
  4. package/bundle/dist/application/qgrid/qgrid-image-generation.js +6 -6
  5. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +24 -7
  6. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +8 -3
  7. package/bundle/dist/application/qgrid/qgrid.frame.js +25 -11
  8. package/bundle/dist/application/qgrid/qgrid.types.js +4 -8
  9. package/bundle/dist/application/qgrid/token-death.js +1 -1
  10. package/bundle/dist/application/qgrid/token-window-keepalive.js +1 -1
  11. package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
  12. package/bundle/dist/application/request-log/request-log.model.js +23 -17
  13. package/bundle/dist/i18n/sd.generated.js +3 -3
  14. package/bundle/dist/sonamu.config.js +2 -2
  15. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +1 -1
  16. package/bundle/dist/utils/providers/anthropic/anthropic-thinking.js +1 -1
  17. package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +31 -9
  18. package/bundle/dist/utils/providers/openai/openai-direct-client.js +5 -4
  19. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +111 -57
  20. package/bundle/dist/utils/providers/openai/openai-image-generation.js +25 -17
  21. package/bundle/dist/utils/providers/openai/openai-refresh.js +1 -1
  22. package/bundle/src/application/qgrid/qgrid-image-generation.test.ts +34 -5
  23. package/bundle/src/application/qgrid/qgrid-image-generation.ts +17 -13
  24. package/bundle/src/application/qgrid/qgrid-response-format.test.ts +11 -0
  25. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +87 -9
  26. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +45 -5
  27. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +136 -2
  28. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +11 -1
  29. package/bundle/src/application/qgrid/qgrid.frame.test.ts +73 -0
  30. package/bundle/src/application/qgrid/qgrid.frame.ts +43 -6
  31. package/bundle/src/application/qgrid/qgrid.types.ts +14 -4
  32. package/bundle/src/application/request-log/request-log.entity.json +2 -2
  33. package/bundle/src/application/request-log/request-log.model.test.ts +17 -12
  34. package/bundle/src/application/request-log/request-log.model.ts +8 -2
  35. package/bundle/src/application/sonamu.generated.http +2 -2
  36. package/bundle/src/i18n/sd.generated.ts +2 -2
  37. package/bundle/src/utils/providers/openai/openai-backend-protocol.test.ts +42 -2
  38. package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +46 -2
  39. package/bundle/src/utils/providers/openai/openai-direct-client.test.ts +15 -0
  40. package/bundle/src/utils/providers/openai/openai-direct-client.ts +14 -4
  41. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +235 -32
  42. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +174 -72
  43. package/bundle/src/utils/providers/openai/openai-image-generation.test.ts +89 -0
  44. package/bundle/src/utils/providers/openai/openai-image-generation.ts +26 -15
  45. package/bundle/web-dist/client/assets/cost-DH2jZbrw.js +1 -0
  46. package/bundle/web-dist/client/assets/{index-reW8VvJF.js → index-D9ryIyL-.js} +4 -4
  47. package/bundle/web-dist/client/assets/logs-CLf2VNfk.js +1 -0
  48. package/bundle/web-dist/client/assets/{monit-DkABv7PG.js → monit-DojxOLj6.js} +1 -1
  49. package/bundle/web-dist/client/assets/{routes-BRmQ4M3-.js → routes-DXJhNvbI.js} +2 -2
  50. package/bundle/web-dist/client/assets/{services.generated-jnPHYMER.js → services.generated-DqFYgWsY.js} +1 -1
  51. package/bundle/web-dist/client/assets/{settings-BHvtycEZ.js → settings-bopyz9pa.js} +1 -1
  52. package/bundle/web-dist/client/assets/{show-qeTIzgDZ.js → show-DbLD3MzB.js} +1 -1
  53. package/bundle/web-dist/client/assets/{tokens-5Bep_G-w.js → tokens-2-AIi9sZ.js} +1 -1
  54. package/bundle/web-dist/client/assets/{use-oauth-login-flow-Cx-QO25M.js → use-oauth-login-flow-CnqTWs_H.js} +1 -1
  55. package/bundle/web-dist/client/assets/{use-update-token-mutation-DQCwCYfR.js → use-update-token-mutation-Cxb9K0fM.js} +1 -1
  56. package/bundle/web-dist/client/index.html +2 -2
  57. package/bundle/web-dist/server/assets/{cost-CSH_t17I.js → cost-eEHeTRT5.js} +5 -1
  58. package/bundle/web-dist/server/assets/{lazyRouteComponent-CUR-kWzI.js → lazyRouteComponent-Bi0fYGLs.js} +2 -2
  59. package/bundle/web-dist/server/assets/{logs-CagNGTBj.js → logs-CIVFxfoi.js} +11 -8
  60. package/bundle/web-dist/server/assets/{monit-DSxs4Jnl.js → monit-DBXezg7R.js} +4 -4
  61. package/bundle/web-dist/server/assets/{plus-Bvdj8Pwx.js → plus-BH-t4cm_.js} +1 -1
  62. package/bundle/web-dist/server/assets/{react-dom-CyuShs9K.js → react-dom-CZ2eynd8.js} +1 -1
  63. package/bundle/web-dist/server/assets/{refresh-cw-3QAiSWBd.js → refresh-cw-CosAdcz0.js} +1 -1
  64. package/bundle/web-dist/server/assets/{routes-i-sTqXMX.js → routes-COWYIvA-.js} +5 -6
  65. package/bundle/web-dist/server/assets/{services.generated-FPwsG9p3.js → services.generated-607iwhMI.js} +2 -2
  66. package/bundle/web-dist/server/assets/{settings-DeHNwoMv.js → settings-C_6dpK5p.js} +3 -3
  67. package/bundle/web-dist/server/assets/{show-C4jk1Hrp.js → show-DY396-LD.js} +8 -20
  68. package/bundle/web-dist/server/assets/{tokens-odgS2-9q.js → tokens-COvV7qUP.js} +4 -4
  69. package/bundle/web-dist/server/assets/{use-oauth-login-flow-DTQKdYBO.js → use-oauth-login-flow-VYCYf3uv.js} +1 -1
  70. package/bundle/web-dist/server/assets/{use-update-token-mutation-xvXbPrCI.js → use-update-token-mutation-D8XYV5of.js} +1 -1
  71. package/bundle/web-dist/server/entry-server.generated.js +10 -10
  72. package/package.json +3 -2
  73. package/skills/qgrid/SKILL.md +9 -8
  74. package/skills/qgrid/references/ai-sdk-provider-contract.md +25 -16
  75. package/skills/qgrid/references/decision-rationale.md +8 -8
  76. package/skills/qgrid/references/openai-codex-runtime.md +23 -11
  77. package/skills/qgrid/references/request-log-run-lifecycle.md +4 -2
  78. package/skills/qgrid/references/sonamu-api-web-flow.md +2 -2
  79. package/skills/qgrid/references/verification-and-debugging.md +23 -9
  80. package/bundle/web-dist/client/assets/cost-CpjVoAqp.js +0 -1
  81. package/bundle/web-dist/client/assets/logs-DDIt97D1.js +0 -1
@@ -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
  }
@@ -500,8 +535,10 @@ class QgridFrameClass extends BaseFrameClass {
500
535
  // 화면의 목록 필터를 그대로 받아 같은 조건의 합계를 돌려준다 — 목록과 비용이
501
536
  // 서로 다른 모수를 보면 사용자에게는 숫자가 어긋난 것으로 읽힌다.
502
537
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
503
- async totalCost(params?: RequestLogListParams): Promise<{ usd: number }> {
504
- return { usd: await RequestLogModel.totalCost(params) };
538
+ async totalCost(
539
+ params?: RequestLogListParams,
540
+ ): Promise<{ usd: number; unknownImageCostCount: number }> {
541
+ return RequestLogModel.totalCost(params);
505
542
  }
506
543
 
507
544
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
@@ -12,10 +12,10 @@ export const QgridTool = z.object({
12
12
  });
13
13
  export type QgridTool = z.infer<typeof QgridTool>;
14
14
 
15
- // Observed standalone Images response values, distinct from requested generation hints.
15
+ // Observed image response values, distinct from requested generation hints.
16
16
  export const ImageGenerationMetadata = z.object({
17
- route: z.literal("codex-images"),
18
- model: z.literal("gpt-image-2"),
17
+ route: z.enum(["codex-images", "codex-responses"]),
18
+ model: z.string().optional(),
19
19
  background: z.string().optional(),
20
20
  quality: z.string().optional(),
21
21
  size: z.string().optional(),
@@ -79,7 +79,17 @@ export type ServiceTier = z.infer<typeof ServiceTier>;
79
79
  export const ImageGenerationQuality = z.enum(["low", "medium", "high"]);
80
80
  export type ImageGenerationQuality = z.infer<typeof ImageGenerationQuality>;
81
81
 
82
- export const ImageGenerationSize = z.enum(["1024x1024", "1024x1536", "1536x1024"]);
82
+ // Subscription-backend hints, not public Images API resolution guarantees.
83
+ export const ImageGenerationSize = z.union([
84
+ z.literal("auto"),
85
+ z
86
+ .string()
87
+ .regex(/^[1-9]\d*x[1-9]\d*$/)
88
+ .refine(
89
+ (size) => size.split("x").every((edge) => Number.isSafeInteger(Number(edge))),
90
+ "Image dimensions must be positive safe integers",
91
+ ),
92
+ ]);
83
93
  export type ImageGenerationSize = z.infer<typeof ImageGenerationSize>;
84
94
 
85
95
  export const ImageGenerationOptions = z
@@ -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",
@@ -564,7 +564,7 @@ describe("RequestLogModel cost provenance", () => {
564
564
  * @param confirmedTotal SUM 쿼리가 돌려줄 micro-USD 합계
565
565
  * @param legacyRows 재계산 대상 row 들
566
566
  */
567
- function mockTotalCostQueries(confirmedTotal: number, legacyRows: unknown[]) {
567
+ function mockTotalCostQueries(confirmedTotal: number, legacyRows: unknown[], unknownImageCostCount = 0) {
568
568
  // subset qb 는 SELECT 를 비우고(clear) 집계/축소된 컬럼만 다시 얹는다 — 구현과 같은 체인.
569
569
  const sumChain = {
570
570
  where: vi.fn(),
@@ -572,7 +572,7 @@ describe("RequestLogModel cost provenance", () => {
572
572
  whereRaw: vi.fn(),
573
573
  clear: vi.fn(),
574
574
  select: vi.fn(),
575
- first: vi.fn(async () => ({ total: confirmedTotal })),
575
+ first: vi.fn(async () => ({ total: confirmedTotal, unknownImageCostCount })),
576
576
  };
577
577
  sumChain.where.mockReturnValue(sumChain);
578
578
  sumChain.whereIn.mockReturnValue(sumChain);
@@ -606,7 +606,7 @@ describe("RequestLogModel cost provenance", () => {
606
606
  it("uses exact stored cost for rows with a cost source", async () => {
607
607
  mockTotalCostQueries(3_000, []);
608
608
 
609
- await expect(RequestLogModel.totalCost()).resolves.toBe(0.003);
609
+ await expect(RequestLogModel.totalCost()).resolves.toEqual({ usd: 0.003, unknownImageCostCount: 0 });
610
610
  });
611
611
 
612
612
  it("sums every row in SQL without a cost_source predicate", async () => {
@@ -637,7 +637,7 @@ describe("RequestLogModel cost provenance", () => {
637
637
  },
638
638
  ]);
639
639
 
640
- await expect(RequestLogModel.totalCost()).resolves.toBeCloseTo(0.4725, 10);
640
+ await expect(RequestLogModel.totalCost().then((result) => result.usd)).resolves.toBeCloseTo(0.4725, 10);
641
641
  });
642
642
 
643
643
  it("adds only the delta for legacy rows already counted in the SQL sum", async () => {
@@ -656,7 +656,7 @@ describe("RequestLogModel cost provenance", () => {
656
656
  },
657
657
  ]);
658
658
 
659
- await expect(RequestLogModel.totalCost()).resolves.toBeCloseTo(0.4725, 10);
659
+ await expect(RequestLogModel.totalCost().then((result) => result.usd)).resolves.toBeCloseTo(0.4725, 10);
660
660
  });
661
661
 
662
662
  it("leaves the stored value untouched for legacy rows without a model name", async () => {
@@ -675,7 +675,7 @@ describe("RequestLogModel cost provenance", () => {
675
675
  },
676
676
  ]);
677
677
 
678
- await expect(RequestLogModel.totalCost()).resolves.toBeCloseTo(0.005, 10);
678
+ await expect(RequestLogModel.totalCost().then((result) => result.usd)).resolves.toBeCloseTo(0.005, 10);
679
679
  });
680
680
 
681
681
  it("applies list filters to both cost queries so totals match the filtered list", async () => {
@@ -721,7 +721,7 @@ describe("RequestLogModel cost provenance", () => {
721
721
  },
722
722
  ]);
723
723
 
724
- await expect(RequestLogModel.totalCost()).resolves.toBeCloseTo(0.4755, 10);
724
+ await expect(RequestLogModel.totalCost().then((result) => result.usd)).resolves.toBeCloseTo(0.4755, 10);
725
725
  });
726
726
  });
727
727
 
@@ -738,10 +738,13 @@ describe("RequestLogModel PostgreSQL", () => {
738
738
  { cost_usd: 5_000, image_cost_usd: 10_000, cost_source: "provider" },
739
739
  { cost_usd: 1_000, image_cost_usd: 20_000, cost_source: null },
740
740
  { cost_usd: 900_000, image_cost_usd: 700_000, cost_source: null },
741
+ { cost_usd: 1_000, image_cost_usd: null, cost_source: "provider", is_image_generation: true },
742
+ { cost_usd: 2_000, image_cost_usd: null, cost_source: "provider", is_image_generation: false },
743
+ { cost_usd: 500_000, image_cost_usd: null, cost_source: "provider", is_image_generation: true },
741
744
  ].entries()) {
742
745
  const id = await RequestLogModel.createRun({
743
746
  user_prompt: "cost regression",
744
- project_name: index < 2 ? projectName : `${projectName}-other`,
747
+ project_name: index === 2 || index === 5 ? `${projectName}-other` : projectName,
745
748
  });
746
749
  ids.push(id);
747
750
  await RequestLogModel.getPuri("w").table("request_logs").where("id", id).update({
@@ -754,10 +757,12 @@ describe("RequestLogModel PostgreSQL", () => {
754
757
 
755
758
  const params = { sonamuFilter: { project_name: projectName }, num: 0, page: 1 };
756
759
  const { rows } = await RequestLogModel.findMany("C", params);
757
- expect(rows.map((row) => row.id)).toEqual(expect.arrayContaining(ids.slice(0, 2)));
758
- expect(rows).toHaveLength(2);
759
- // Confirmed: .005 + .010; legacy driver repriced to .003, plus .020 image.
760
- await expect(RequestLogModel.totalCost(params)).resolves.toBeCloseTo(0.038, 10);
760
+ expect(rows.map((row) => row.id)).toEqual(expect.arrayContaining([ids[0], ids[1], ids[3], ids[4]]));
761
+ expect(rows).toHaveLength(4);
762
+ // .038 known images + .001 hosted driver + .002 text; other project excluded.
763
+ const total = await RequestLogModel.totalCost(params);
764
+ expect(total.usd).toBeCloseTo(0.041, 10);
765
+ expect(total.unknownImageCostCount).toBe(1);
761
766
  } finally {
762
767
  await RequestLogModel.del(ids);
763
768
  }
@@ -820,7 +820,9 @@ class RequestLogModelClass extends BaseModelClass<
820
820
  * 필터 조건을 여기서 다시 구현하지 않고 `applyListFilters` 를 공유하는 것이 핵심이다 —
821
821
  * 두 쿼리가 각자 필터를 가지면 화면에서 필터를 걸었을 때 행 수와 비용이 조용히 어긋난다.
822
822
  */
823
- async totalCost(rawParams: RequestLogListParams = { num: 0, page: 1 }): Promise<number> {
823
+ async totalCost(
824
+ rawParams: RequestLogListParams = { num: 0, page: 1 },
825
+ ): Promise<{ usd: number; unknownImageCostCount: number }> {
824
826
  const params = {
825
827
  num: 0,
826
828
  page: 1,
@@ -843,6 +845,9 @@ class RequestLogModelClass extends BaseModelClass<
843
845
  total: Puri.rawNumber(
844
846
  "COALESCE(SUM(GREATEST(request_logs.cost_usd, 0) + COALESCE(request_logs.image_cost_usd, 0)), 0)",
845
847
  ),
848
+ unknownImageCostCount: Puri.rawNumber(
849
+ "COUNT(*) FILTER (WHERE request_logs.is_image_generation = true AND request_logs.image_cost_usd IS NULL)",
850
+ ),
846
851
  })
847
852
  .first();
848
853
 
@@ -867,7 +872,7 @@ class RequestLogModelClass extends BaseModelClass<
867
872
  const storedTotal = Number(stored?.total ?? 0) / MICRO_USD;
868
873
 
869
874
  // legacy row 는 위 SUM 에 저장값으로 이미 포함되어 있다. 재계산값과의 차액만 더한다.
870
- return legacyRows.reduce((sum, row) => {
875
+ const usd = legacyRows.reduce((sum, row) => {
871
876
  const alreadySummed = Math.max(row.cost_usd ?? 0, 0) / MICRO_USD;
872
877
  const usage = normalizedUsageForCost(row);
873
878
  if (!usage.model) return sum;
@@ -884,6 +889,7 @@ class RequestLogModelClass extends BaseModelClass<
884
889
  alreadySummed
885
890
  );
886
891
  }, storedTotal);
892
+ return { usd, unknownImageCostCount: Number(stored?.unknownImageCostCount ?? 0) };
887
893
  }
888
894
 
889
895
  async distinctProjectNames(): Promise<string[]> {
@@ -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 정의 목록",
@@ -8,8 +8,26 @@ import {
8
8
  } from "./openai-backend-protocol";
9
9
 
10
10
  describe("OpenAI Codex backend protocol", () => {
11
+ it.each(["png", "jpeg", "webp"])("preserves reported hosted %s generation settings", (output_format) => {
12
+ expect(normalizeOpenAIEvent({ type: "response.output_item.done", item: {
13
+ type: "image_generation_call", result: "aGVsbG8=", output_format,
14
+ size: "841x1870", quality: "low", background: "opaque",
15
+ } })).toMatchObject({ generation: {
16
+ route: "codex-responses", size: "841x1870", quality: "low", background: "opaque",
17
+ } });
18
+ });
19
+
20
+ it("reports PNG byte dimensions instead of a conflicting backend size", () => {
21
+ const bytes = Buffer.alloc(24);
22
+ Buffer.from("89504e470d0a1a0a", "hex").copy(bytes);
23
+ bytes.writeUInt32BE(841, 16);
24
+ bytes.writeUInt32BE(1870, 20);
25
+ expect(normalizeOpenAIEvent({ type: "response.output_item.done", item: {
26
+ type: "image_generation_call", result: bytes.toString("base64"), size: "1152x2560",
27
+ } })).toMatchObject({ generation: { route: "codex-responses", size: "841x1870" } });
28
+ });
11
29
  it("distinguishes partial function items from completed arguments", () => {
12
- 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: "{}" };
13
31
  expect(normalizeOpenAIEvent({ type: "response.output_item.added", item })).toEqual({ type: "output-item", item, completed: false });
14
32
  expect(normalizeOpenAIEvent({ type: "response.output_item.done", item })).toEqual({ type: "output-item", item, completed: true });
15
33
  });
@@ -129,6 +147,28 @@ describe("OpenAI Codex backend protocol", () => {
129
147
  type: "response.output_item.done",
130
148
  item: { type: "image_generation_call", id: "img", result: "aGVsbG8=", revised_prompt: "better" },
131
149
  }),
132
- ).toEqual({ type: "image", id: "img", base64: "aGVsbG8=", mimeType: "image/png", revisedPrompt: "better" });
150
+ ).toEqual({ type: "image", id: "img", base64: "aGVsbG8=", mimeType: "image/png", revisedPrompt: "better", generation: { route: "codex-responses" } });
133
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
+ );
134
174
  });