@cartanova/qgrid-cli 2.10.5 → 2.10.7

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 (96) 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 +26 -7
  7. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +12 -7
  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 +5 -3
  13. package/bundle/dist/application/sonamu.generated.js +4 -1
  14. package/bundle/dist/application/sonamu.generated.sso.js +3 -1
  15. package/bundle/dist/i18n/sd.generated.js +4 -3
  16. package/bundle/dist/migrations/20261016155350_alter_request_logs_add1_alter1.js +15 -0
  17. package/bundle/dist/sonamu.config.js +2 -2
  18. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +1 -1
  19. package/bundle/dist/utils/providers/anthropic/anthropic-thinking.js +1 -1
  20. package/bundle/dist/utils/providers/common/model-cost.js +14 -3
  21. package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +13 -2
  22. package/bundle/dist/utils/providers/openai/openai-direct-client.js +5 -4
  23. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +111 -57
  24. package/bundle/dist/utils/providers/openai/openai-image-generation.js +25 -17
  25. package/bundle/dist/utils/providers/openai/openai-refresh.js +1 -1
  26. package/bundle/src/application/qgrid/qgrid-image-generation.test.ts +19 -8
  27. package/bundle/src/application/qgrid/qgrid-image-generation.ts +17 -24
  28. package/bundle/src/application/qgrid/qgrid-response-format.test.ts +4 -2
  29. package/bundle/src/application/qgrid/qgrid-response-format.ts +7 -10
  30. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +105 -9
  31. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +47 -5
  32. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +160 -2
  33. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +40 -18
  34. package/bundle/src/application/qgrid/qgrid.frame.test.ts +74 -0
  35. package/bundle/src/application/qgrid/qgrid.frame.ts +39 -4
  36. package/bundle/src/application/request-log/request-log.entity.json +11 -2
  37. package/bundle/src/application/request-log/request-log.model.ts +4 -0
  38. package/bundle/src/application/sonamu.generated.http +2 -2
  39. package/bundle/src/application/sonamu.generated.sso.ts +2 -0
  40. package/bundle/src/application/sonamu.generated.ts +4 -0
  41. package/bundle/src/i18n/sd.generated.ts +3 -2
  42. package/bundle/src/migrations/20261016155350_alter_request_logs_add1_alter1.ts +15 -0
  43. package/bundle/src/utils/providers/common/model-cost.test.ts +28 -0
  44. package/bundle/src/utils/providers/common/model-cost.ts +19 -2
  45. package/bundle/src/utils/providers/openai/openai-backend-protocol.test.ts +23 -1
  46. package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +24 -2
  47. package/bundle/src/utils/providers/openai/openai-direct-client.test.ts +15 -0
  48. package/bundle/src/utils/providers/openai/openai-direct-client.ts +14 -4
  49. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +223 -32
  50. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +174 -72
  51. package/bundle/src/utils/providers/openai/openai-image-generation.test.ts +89 -0
  52. package/bundle/src/utils/providers/openai/openai-image-generation.ts +26 -15
  53. package/bundle/web-dist/client/assets/cost-DH2jZbrw.js +1 -0
  54. package/bundle/web-dist/client/assets/index-Bw2s88Ot.css +1 -0
  55. package/bundle/web-dist/client/assets/{index-Dxw7DiXF.js → index-CwqqPF4F.js} +4 -4
  56. package/bundle/web-dist/client/assets/logs-CZgl1RSu.js +1 -0
  57. package/bundle/web-dist/client/assets/{monit-E8eGW1Sj.js → monit-TarvUWiE.js} +1 -1
  58. package/bundle/web-dist/client/assets/request-model-CojtinqK.js +1 -0
  59. package/bundle/web-dist/client/assets/{routes-7spLfIGZ.js → routes-BXR6OLah.js} +2 -2
  60. package/bundle/web-dist/client/assets/{services.generated-jnPHYMER.js → services.generated-BsieU37H.js} +1 -1
  61. package/bundle/web-dist/client/assets/{settings-BdosZFNC.js → settings-B1y5MNSU.js} +1 -1
  62. package/bundle/web-dist/client/assets/show-CG-BAdhS.js +2 -0
  63. package/bundle/web-dist/client/assets/{tokens-CBdHb2_U.js → tokens-D9tAWBEW.js} +1 -1
  64. package/bundle/web-dist/client/assets/{use-oauth-login-flow-Cyn3YPqW.js → use-oauth-login-flow-DxdOhz4a.js} +1 -1
  65. package/bundle/web-dist/client/assets/{use-update-token-mutation-DQCwCYfR.js → use-update-token-mutation-DAq0F8p-.js} +1 -1
  66. package/bundle/web-dist/client/index.html +3 -3
  67. package/bundle/web-dist/server/assets/{cost-59jBdx-I.js → cost-eEHeTRT5.js} +4 -5
  68. package/bundle/web-dist/server/assets/{lazyRouteComponent-CUR-kWzI.js → lazyRouteComponent-Dtd0NR4r.js} +2 -2
  69. package/bundle/web-dist/server/assets/{logs-CaaWO9-Z.js → logs-l91avYif.js} +33 -21
  70. package/bundle/web-dist/server/assets/{monit-CBil92fJ.js → monit-DvzribSE.js} +4 -4
  71. package/bundle/web-dist/server/assets/{plus-Bvdj8Pwx.js → plus-KZrECewT.js} +1 -1
  72. package/bundle/web-dist/server/assets/{react-dom-CyuShs9K.js → react-dom-B4a6-uuR.js} +1 -1
  73. package/bundle/web-dist/server/assets/{refresh-cw-3QAiSWBd.js → refresh-cw-BND93JrF.js} +1 -1
  74. package/bundle/web-dist/server/assets/{request-model-CnCeaNLE.js → request-model-CbqfnQOJ.js} +19 -1
  75. package/bundle/web-dist/server/assets/{routes-DtVf9yqo.js → routes-BAPWTizT.js} +6 -7
  76. package/bundle/web-dist/server/assets/{services.generated-FPwsG9p3.js → services.generated-CEbGjsI0.js} +3 -2
  77. package/bundle/web-dist/server/assets/{settings-DeHNwoMv.js → settings-BZR5HSAL.js} +3 -3
  78. package/bundle/web-dist/server/assets/{show-8NNdRxhM.js → show-CxVZTAdv.js} +17 -21
  79. package/bundle/web-dist/server/assets/{tokens-odgS2-9q.js → tokens-D1hb4eKk.js} +4 -4
  80. package/bundle/web-dist/server/assets/{use-oauth-login-flow-DTQKdYBO.js → use-oauth-login-flow-Cvdy0zF8.js} +1 -1
  81. package/bundle/web-dist/server/assets/{use-update-token-mutation-xvXbPrCI.js → use-update-token-mutation-CfYmk_4_.js} +1 -1
  82. package/bundle/web-dist/server/entry-server.generated.js +13 -10
  83. package/package.json +1 -1
  84. package/skills/qgrid/SKILL.md +9 -8
  85. package/skills/qgrid/references/ai-sdk-provider-contract.md +27 -17
  86. package/skills/qgrid/references/decision-rationale.md +8 -8
  87. package/skills/qgrid/references/openai-codex-runtime.md +19 -11
  88. package/skills/qgrid/references/prompt-cache-and-usage.md +3 -0
  89. package/skills/qgrid/references/request-log-run-lifecycle.md +14 -2
  90. package/skills/qgrid/references/sonamu-api-web-flow.md +2 -2
  91. package/skills/qgrid/references/verification-and-debugging.md +23 -9
  92. package/bundle/web-dist/client/assets/cost-CY2BJEPC.js +0 -1
  93. package/bundle/web-dist/client/assets/index-CvG4UB9v.css +0 -1
  94. package/bundle/web-dist/client/assets/logs-3n_U4qnI.js +0 -1
  95. package/bundle/web-dist/client/assets/request-model-D4CZPmnG.js +0 -1
  96. package/bundle/web-dist/client/assets/show-b2PFNI3D.js +0 -2
@@ -89,6 +89,7 @@ export async function beforeQuery(args: QueryInput): Promise<{
89
89
  system_prompt: args.system,
90
90
  requested_model_name: args.model,
91
91
  effort: args.thinking === false ? undefined : args.effort,
92
+ service_tier: args.model?.startsWith("openai/") ? args.serviceTier : undefined,
92
93
  project_name: args.projectName,
93
94
  history: filterHistoryForStorage(args.history),
94
95
  tools: args.tools?.length ? args.tools : undefined,
@@ -100,12 +101,12 @@ export async function beforeQuery(args: QueryInput): Promise<{
100
101
  return { requestLogId, stepIndex };
101
102
  }
102
103
 
103
- export async function afterQuery(
104
+ async function appendGenerateStep(
104
105
  requestLogId: number,
105
106
  stepIndex: number,
106
107
  args: QueryInput,
107
108
  result: QueryOutput,
108
- ): Promise<RunLifecycleResult> {
109
+ ) {
109
110
  // provider가 canonical model을 돌려줘도 요청 route 자체([1m] 등)는 보존한다.
110
111
  const requestedModelName = withProviderPrefix(
111
112
  args.model,
@@ -132,6 +133,21 @@ export async function afterQuery(
132
133
  ttft_ms: result.ttftMs,
133
134
  finish_reason: result.finishReason,
134
135
  });
136
+ return { requestedModelName, servedModelName };
137
+ }
138
+
139
+ export async function afterQuery(
140
+ requestLogId: number,
141
+ stepIndex: number,
142
+ args: QueryInput,
143
+ result: QueryOutput,
144
+ ): Promise<RunLifecycleResult> {
145
+ const { requestedModelName, servedModelName } = await appendGenerateStep(
146
+ requestLogId,
147
+ stepIndex,
148
+ args,
149
+ result,
150
+ );
135
151
 
136
152
  const imageParts = getImageParts(result);
137
153
  const imageCostMicroUsd = estimateImageGenerationCostMicroUsd(
@@ -182,6 +198,7 @@ export async function afterQuery(
182
198
  requested_model_name: requestedModelName,
183
199
  model_name: servedModelName,
184
200
  fallback_count: agg.fallback_count,
201
+ service_tier: args.model?.startsWith("openai/") ? (args.serviceTier ?? null) : null,
185
202
  cost_usd: agg.cost_usd,
186
203
  cost_source: agg.cost_source,
187
204
  image_cost_usd: imageCostMicroUsd,
@@ -200,12 +217,17 @@ export async function finishRunWithError(
200
217
  requestLogId: number,
201
218
  errorMessage: string,
202
219
  args?: QueryInput,
220
+ completedStep?: { stepIndex: number; result: QueryOutput },
203
221
  ): Promise<void> {
204
- await finishTerminalRun(requestLogId, "error", errorMessage, args);
222
+ await finishTerminalRun(requestLogId, "error", errorMessage, args, completedStep);
205
223
  }
206
224
 
207
- export async function finishRunAborted(requestLogId: number, args?: QueryInput): Promise<void> {
208
- await finishTerminalRun(requestLogId, "aborted", "client disconnected", args);
225
+ export async function finishRunAborted(
226
+ requestLogId: number,
227
+ args?: QueryInput,
228
+ completedStep?: { stepIndex: number; result: QueryOutput },
229
+ ): Promise<void> {
230
+ await finishTerminalRun(requestLogId, "aborted", "client disconnected", args, completedStep);
209
231
  }
210
232
 
211
233
  async function finishTerminalRun(
@@ -213,8 +235,18 @@ async function finishTerminalRun(
213
235
  status: "error" | "aborted",
214
236
  errorMessage: string,
215
237
  args?: QueryInput,
238
+ completedStep?: { stepIndex: number; result: QueryOutput },
216
239
  ): Promise<void> {
217
240
  try {
241
+ if (completedStep && args) {
242
+ try {
243
+ await appendGenerateStep(requestLogId, completedStep.stepIndex, args, completedStep.result);
244
+ } catch (e) {
245
+ logger.error(
246
+ `appendGenerateStep failed while finishing ${status}: ${(e as Error).message}`,
247
+ );
248
+ }
249
+ }
218
250
  let aggregate: Awaited<ReturnType<typeof RequestLogModel.aggregateStepUsage>> | undefined;
219
251
  try {
220
252
  aggregate = await RequestLogModel.aggregateStepUsage(requestLogId);
@@ -226,6 +258,16 @@ async function finishTerminalRun(
226
258
  status,
227
259
  error_message: errorMessage,
228
260
  history: args ? filterHistoryForStorage(args.history) : undefined,
261
+ ...(completedStep
262
+ ? {
263
+ token_name: completedStep.result.tokenName,
264
+ requested_model_name: withProviderPrefix(
265
+ args?.model,
266
+ args?.model ?? completedStep.result.requestedModel ?? completedStep.result.model,
267
+ ),
268
+ model_name: withProviderPrefix(args?.model, completedStep.result.model),
269
+ }
270
+ : {}),
229
271
  ...(aggregate
230
272
  ? {
231
273
  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,162 @@ function deeplyNestedOutputSchema(depth: number): string {
36
38
  }
37
39
 
38
40
  describe("QgridDispatcherClass", () => {
41
+ it.each([false, true])("applies Fast pricing to OpenAI query (stream=%s)", async (stream) => {
42
+ const dispatcher = new QgridDispatcherClass();
43
+ const result = providerResult({
44
+ model: "gpt-6.1-sol",
45
+ usage: { totalTokens: 2000, inputTokens: 1000, cachedInputTokens: 500, outputTokens: 1000, reasoningOutputTokens: 0 },
46
+ });
47
+ dispatcher.openaiDispatcher = {
48
+ generate: vi.fn(async () => result),
49
+ generateStream: vi.fn(async (_req, cb) => cb.onComplete(result)),
50
+ } as never;
51
+ const input = { prompt: "hi", model: "openai/gpt-6.1-sol", serviceTier: "fast" as const };
52
+ let output: QueryOutput | undefined;
53
+ if (stream) {
54
+ await dispatcher.queryStream(input, {
55
+ onDelta: () => {}, onComplete: (value) => { output = value; }, onError: (error) => { throw error; },
56
+ });
57
+ } else {
58
+ output = await dispatcher.query(input);
59
+ }
60
+ expect(output?.costUsd).toBeCloseTo(0.0221, 10);
61
+ expect(output?.costSource).toBe("pricing_table");
62
+ });
63
+
64
+ it("prices only the completed chat driver from an image failure", () => {
65
+ const error = new ImageGenerationError("incomplete", "images unavailable");
66
+ error.completedDriver = providerResult({
67
+ model: "gpt-6-luna", requestedModel: "gpt-6-astra", ttftMs: 4,
68
+ usage: { totalTokens: 120, inputTokens: 100, outputTokens: 20, cachedInputTokens: 10, reasoningOutputTokens: 5 },
69
+ });
70
+ const result = completedDriverFromImageFailure(error)!;
71
+ expect(result).toMatchObject({
72
+ model: "gpt-6-luna", requestedModel: "gpt-6-astra", tokenName: "provider/test",
73
+ usage: { input_tokens: 100, output_tokens: 20, cache_read_input_tokens: 10, reasoning_tokens: 5 },
74
+ costSource: "pricing_table", finishReason: "stop", durationMs: 12, ttftMs: 4,
75
+ content: [{ type: "text", text: "ok" }],
76
+ });
77
+ expect(result.costUsd).toBeCloseTo(0.0000191, 10);
78
+ expect(completedDriverFromImageFailure(error, "fast")?.costUsd).toBeCloseTo(0.0000382, 10);
79
+ expect(estimateImageGenerationCostMicroUsd(result, undefined)).toBeNull();
80
+ expect(result.runContext).toBeUndefined();
81
+ expect(completedDriverFromImageFailure(new Error("failed"))).toBeUndefined();
82
+ expect(completedDriverFromImageFailure(new ImageGenerationError("gate", "invalid"))).toBeUndefined();
83
+ });
84
+ it.each([
85
+ [false, 0, "generations"],
86
+ [false, 2, "edits"],
87
+ [true, 1, "edits"],
88
+ ] as const)("auto chat uses Images %s/%s/%s and keeps both usages", async (transparent, referenceCount, endpoint) => {
89
+ const png = new PNG({ width: 2, height: 1 });
90
+ png.data.set([0, 0, 0, transparent ? 0 : 255, 255, 0, 0, 255]);
91
+ const imageUsage = {
92
+ input_tokens: 10, output_tokens: 100, total_tokens: 110,
93
+ input_tokens_details: { text_tokens: 4, image_tokens: 6 },
94
+ };
95
+ const fetchMock = vi.fn(async (url: string | URL | Request, _init?: RequestInit) => {
96
+ if (String(url).endsWith("/responses")) {
97
+ const events = [
98
+ { type: "response.output_item.done", item: {
99
+ type: "function_call", call_id: "image-call", name: "generate_image",
100
+ arguments: JSON.stringify({ prompt: "A red square", transparent_background: transparent, num_last_images_to_include: referenceCount }),
101
+ } },
102
+ { type: "response.completed", response: {
103
+ id: "driver", model: "gpt-6-luna",
104
+ usage: { input_tokens: 100, output_tokens: 20, total_tokens: 120 },
105
+ } },
106
+ ];
107
+ return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } });
108
+ }
109
+ return Response.json({
110
+ data: [{ b64_json: PNG.sync.write(png).toString("base64") }],
111
+ background: transparent ? "transparent" : "opaque", quality: "medium", usage: imageUsage,
112
+ });
113
+ });
114
+ const provider = new OpenAIDispatcher("https", { fetch: fetchMock });
115
+ await provider.onTokenAdded(1, "chat-token", {
116
+ accessToken: "test-access", refreshToken: "", accountId: "test-account", accessTokenExpiresAt: Date.now() + 60000,
117
+ });
118
+ const dispatcher = new QgridDispatcherClass();
119
+ dispatcher.openaiDispatcher = provider;
120
+ try {
121
+ const result = await dispatcher.query({
122
+ model: "openai/gpt-6-luna", prompt: "Draw or edit a square", imageGeneration: "auto",
123
+ history: JSON.stringify([{ type: "message", role: "user", content: [{ type: "input_image", image_url: "https://example.com/old.png" }] }]),
124
+ input: [{ type: "image", url: "https://example.com/new.png" }],
125
+ });
126
+ expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
127
+ "https://chatgpt.com/backend-api/codex/responses",
128
+ `https://chatgpt.com/backend-api/codex/images/${endpoint}`,
129
+ ]);
130
+ const driverInit = fetchMock.mock.calls[0]![1]!;
131
+ const imageInit = fetchMock.mock.calls[1]![1]!;
132
+ expect(JSON.parse(String(driverInit.body)).tools).toEqual([
133
+ expect.objectContaining({ type: "function", name: "generate_image" }),
134
+ ]);
135
+ const imageBody = JSON.parse(String(imageInit.body));
136
+ expect(imageBody).toMatchObject({ model: "gpt-image-2", background: transparent ? "transparent" : "opaque" });
137
+ expect(imageBody.prompt).toContain("A red square");
138
+ expect(imageBody.prompt).not.toContain("Draw or edit a square");
139
+ const refs = ["https://example.com/old.png", "https://example.com/new.png"].slice(-referenceCount);
140
+ expect(imageBody.images).toEqual(referenceCount ? refs.map((image_url) => ({ image_url })) : undefined);
141
+ expect(imageInit.signal).toBe(driverInit.signal);
142
+ expect(new Headers(imageInit.headers).get("authorization")).toBe(new Headers(driverInit.headers).get("authorization"));
143
+ expect(result).toMatchObject({ model: "gpt-6-luna", usage: { input_tokens: 100, output_tokens: 20 } });
144
+ expect(result.costUsd).toBeCloseTo(0.00002, 8);
145
+ expect(result.content.find((item) => item.type === "image")).toMatchObject({ generation: { route: "codex-images", usage: imageUsage } });
146
+ expect(estimateImageGenerationCostMicroUsd(result, undefined)).toBe(3068);
147
+ } finally {
148
+ await provider.stop();
149
+ }
150
+ });
151
+
152
+ it("routes the image model straight to subscription edits and keeps image usage separate", async () => {
153
+ const png = new PNG({ width: 1, height: 1 });
154
+ png.data.set([255, 0, 0, 255]);
155
+ const base64 = PNG.sync.write(png).toString("base64");
156
+ const usage = {
157
+ input_tokens: 10, output_tokens: 100, total_tokens: 110,
158
+ input_tokens_details: { text_tokens: 4, image_tokens: 6 },
159
+ };
160
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({
161
+ data: [{ b64_json: base64 }], usage, background: "opaque", quality: "medium",
162
+ })));
163
+ const provider = new OpenAIDispatcher("https", { fetch: fetchMock });
164
+ await provider.onTokenAdded(1, "test", {
165
+ accessToken: "unused", refreshToken: "", accountId: "unused",
166
+ accessTokenExpiresAt: Date.now() + 60000,
167
+ });
168
+ const dispatcher = new QgridDispatcherClass();
169
+ dispatcher.openaiDispatcher = provider;
170
+ try {
171
+ const result = await dispatcher.query({
172
+ model: "openai/gpt-image-2", prompt: "recolor", imageGeneration: true,
173
+ input: [
174
+ { type: "text", text: "recolor", text_elements: [] },
175
+ { type: "image", url: `data:image/png;base64,${base64}` },
176
+ ],
177
+ });
178
+ expect(fetchMock).toHaveBeenCalledOnce();
179
+ expect(fetchMock).toHaveBeenCalledWith(
180
+ "https://chatgpt.com/backend-api/codex/images/edits",
181
+ expect.objectContaining({ method: "POST", signal: expect.any(AbortSignal) }),
182
+ );
183
+ expect(result).toMatchObject({
184
+ model: "gpt-image-2", costUsd: 0,
185
+ usage: { input_tokens: 0, output_tokens: 0 },
186
+ content: expect.arrayContaining([expect.objectContaining({
187
+ type: "image", mediaType: "image/png",
188
+ generation: { route: "codex-images", model: "gpt-image-2", background: "opaque", size: "1x1", quality: "medium", usage },
189
+ })]),
190
+ });
191
+ expect(result.runContext?.threadCoord).toBeUndefined();
192
+ } finally {
193
+ await provider.stop();
194
+ }
195
+ });
196
+
39
197
  it.each(["generate", "stream"])("%s yields a completed tool-call message before a speculative later answer", async (mode) => {
40
198
  const tool = JSON.stringify({result: {action: "tool_call", answer: null, toolCalls: [{toolName: "lookupMarker", args: "{}"}]}});
41
199
  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 {
@@ -124,7 +127,7 @@ export class QgridDispatcherClass {
124
127
  ? undefined
125
128
  : issueConvContext(result.threadCoord, decision, result.threadCoord.workerId);
126
129
 
127
- return applyToolCallEmulation(toEmulationResult(result), input.tools, {
130
+ return applyToolCallEmulation(toEmulationResult(result, input.serviceTier), input.tools, {
128
131
  threadCoord: coord,
129
132
  images: result.images,
130
133
  answerKind,
@@ -203,14 +206,18 @@ export class QgridDispatcherClass {
203
206
  onComplete: (turnResult) => {
204
207
  this.recordCompleted("openai", turnResult.tokenName);
205
208
  cb.onComplete(
206
- applyToolCallEmulation(toEmulationResult(turnResult), input.tools, {
207
- threadCoord: issueConvContext(
208
- turnResult.threadCoord,
209
- decision,
210
- turnResult.threadCoord.workerId,
211
- ),
212
- answerKind,
213
- }),
209
+ applyToolCallEmulation(
210
+ toEmulationResult(turnResult, input.serviceTier),
211
+ input.tools,
212
+ {
213
+ threadCoord: issueConvContext(
214
+ turnResult.threadCoord,
215
+ decision,
216
+ turnResult.threadCoord.workerId,
217
+ ),
218
+ answerKind,
219
+ },
220
+ ),
214
221
  );
215
222
  },
216
223
  onError: cb.onError,
@@ -325,9 +332,20 @@ function buildRawOutputSchema(
325
332
  : callerOutputSchema;
326
333
  }
327
334
 
335
+ export function completedDriverFromImageFailure(
336
+ error: unknown,
337
+ serviceTier?: string,
338
+ ): QueryOutput | undefined {
339
+ if (!(error instanceof ImageGenerationError) || !error.completedDriver) return undefined;
340
+ return applyToolCallEmulation(toEmulationResult(error.completedDriver, serviceTier), undefined, {
341
+ answerKind: "text",
342
+ });
343
+ }
344
+
328
345
  // Anthropic adapter 는 cache creation 을 inputTokens 에 포함해 표준화하고, 별도 필드에도 보존한다.
329
346
  function toEmulationResult(
330
347
  result: GenerateResult,
348
+ serviceTier?: string,
331
349
  ): Omit<QueryOutput, "content" | "finishReason" | "runContext"> {
332
350
  const hasProviderCost = result.costUsd !== undefined && result.costUsd > 0;
333
351
  return {
@@ -349,14 +367,18 @@ function toEmulationResult(
349
367
  ttftMs: result.ttftMs ?? 0,
350
368
  costUsd: hasProviderCost
351
369
  ? result.costUsd!
352
- : calculateCostUsd(result.model, {
353
- inputTokens: result.usage.inputTokens,
354
- outputTokens: result.usage.outputTokens,
355
- cachedInputTokens: result.usage.cachedInputTokens,
356
- cacheCreationInputTokens: result.usage.cacheCreationInputTokens ?? 0,
357
- cacheCreationInputTokens5m: result.usage.cacheCreationInputTokens5m,
358
- cacheCreationInputTokens1h: result.usage.cacheCreationInputTokens1h,
359
- }),
370
+ : calculateCostUsd(
371
+ result.model,
372
+ {
373
+ inputTokens: result.usage.inputTokens,
374
+ outputTokens: result.usage.outputTokens,
375
+ cachedInputTokens: result.usage.cachedInputTokens,
376
+ cacheCreationInputTokens: result.usage.cacheCreationInputTokens ?? 0,
377
+ cacheCreationInputTokens5m: result.usage.cacheCreationInputTokens5m,
378
+ cacheCreationInputTokens1h: result.usage.cacheCreationInputTokens1h,
379
+ },
380
+ serviceTier,
381
+ ),
360
382
  costSource: hasProviderCost ? "provider" : "pricing_table",
361
383
  };
362
384
  }
@@ -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,46 @@ 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
+ serviceTier: "fast" as const,
409
+ ...(mode === "logger-disabled" ? { logger: false } : {}),
410
+ };
411
+ await expect(QgridFrame.query(args)).rejects.toBe(error);
412
+ expect(afterQueryMock).not.toHaveBeenCalled();
413
+ if (mode === "logger-disabled") {
414
+ expect(beforeQueryMock).not.toHaveBeenCalled();
415
+ expect(finishRunWithErrorMock).not.toHaveBeenCalled();
416
+ expect(finishRunAbortedMock).not.toHaveBeenCalled();
417
+ } else {
418
+ const completedStep = expect.objectContaining({
419
+ stepIndex: 0,
420
+ result: expect.objectContaining({
421
+ model: "gpt-6-luna", tokenName: "one", costUsd: 0.00004,
422
+ usage: expect.objectContaining({ input_tokens: 100, output_tokens: 20 }),
423
+ }),
424
+ });
425
+ if (mode === "abort") expect(finishRunAbortedMock).toHaveBeenCalledWith(41, args, completedStep);
426
+ else expect(finishRunWithErrorMock).toHaveBeenCalledWith(41, "image failed", args, completedStep);
427
+ }
428
+ });
429
+
388
430
  it("marks an incoherent structured envelope failure as a request-log error", async () => {
389
431
  const error = new Error(
390
432
  "tool-call emulation: structured response envelope is missing required keys: toolCalls",
@@ -775,6 +817,32 @@ describe("QgridFrame.query request logging", () => {
775
817
  expect(QueryInput.safeParse({ prompt: "hi", logger: false }).success).toBe(true);
776
818
  });
777
819
 
820
+ it.each([
821
+ { imageGeneration: undefined },
822
+ { imageGeneration: "auto" as const },
823
+ { imageGeneration: true, tools: [{ name: "lookup", inputSchema: { type: "object" } }] },
824
+ { imageGeneration: true, jsonSchema: '{"type":"object"}' },
825
+ { imageGeneration: true, imageGenerationOptions: { outputFormat: "webp" as const } },
826
+ ])("rejects unsupported standalone image requests before logging: %j", async (options) => {
827
+ await expect(QgridFrame.query({
828
+ prompt: "draw", model: "openai/gpt-image-2", ...options,
829
+ })).rejects.toMatchObject({ statusCode: 400 });
830
+ expect(beforeQueryMock).not.toHaveBeenCalled();
831
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
832
+ });
833
+
834
+ it.each([
835
+ { imageGenerationOptions: { outputFormat: "webp" as const } },
836
+ { tools: [{ name: "lookup", inputSchema: { type: "object" } }] },
837
+ { jsonSchema: '{"type":"object"}' },
838
+ ])("rejects unsupported auto image controls before logging: %j", async (options) => {
839
+ await expect(QgridFrame.query({
840
+ prompt: "draw", model: "openai/gpt-6-luna", imageGeneration: "auto", ...options,
841
+ })).rejects.toMatchObject({ statusCode: 400 });
842
+ expect(beforeQueryMock).not.toHaveBeenCalled();
843
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
844
+ });
845
+
778
846
  it("accepts bounded positive integer timeout values only", () => {
779
847
  expect(QueryInput.safeParse({ prompt: "hi", timeout: 360_000 }).success).toBe(true);
780
848
  expect(QueryInput.safeParse({ prompt: "hi", timeout: 0 }).success).toBe(false);
@@ -789,6 +857,12 @@ describe("QgridFrame.prepareStream", () => {
789
857
  requestLogSaveMock.mockReset();
790
858
  });
791
859
 
860
+ it("rejects an image model without a streaming image flag", async () => {
861
+ await expect(QgridFrame.prepareStream({
862
+ prompt: "draw", model: "openai/gpt-image-2",
863
+ })).rejects.toMatchObject({ statusCode: 400 });
864
+ });
865
+
792
866
  it.each([true, "auto"] as const)("rejects imageGeneration=%s before creating an SSE stream", async (imageGeneration) => {
793
867
  await expect(
794
868
  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, resolvedArgs.serviceTier);
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,21 @@
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
+ },
146
+ {
147
+ "name": "service_tier",
148
+ "type": "string",
149
+ "length": 20,
150
+ "nullable": true,
151
+ "desc": "Requested OpenAI service tier"
145
152
  },
146
153
  {
147
154
  "name": "effort",
@@ -288,6 +295,7 @@
288
295
  "image_cost_usd",
289
296
  "image_cost_method",
290
297
  "effort",
298
+ "service_tier",
291
299
  "history",
292
300
  "tools",
293
301
  "status",
@@ -318,6 +326,7 @@
318
326
  "image_cost_usd",
319
327
  "image_cost_method",
320
328
  "effort",
329
+ "service_tier",
321
330
  "status",
322
331
  "response_json_ok",
323
332
  "tool_call_count",
@@ -481,6 +481,7 @@ class RequestLogModelClass extends BaseModelClass<
481
481
  system_prompt?: string | null;
482
482
  requested_model_name?: string | null;
483
483
  effort?: string | null;
484
+ service_tier?: string | null;
484
485
  project_name?: string | null;
485
486
  history?: unknown;
486
487
  tools?: ToolDefinitions;
@@ -496,6 +497,7 @@ class RequestLogModelClass extends BaseModelClass<
496
497
  requested_model_name: params.requested_model_name ?? null,
497
498
  model_name: null,
498
499
  effort: params.effort ?? null,
500
+ service_tier: params.service_tier ?? null,
499
501
  project_name: params.project_name ?? null,
500
502
  status: "running",
501
503
  response: "",
@@ -546,6 +548,7 @@ class RequestLogModelClass extends BaseModelClass<
546
548
  duration_ms?: number;
547
549
  requested_model_name?: string | null;
548
550
  model_name?: string | null;
551
+ service_tier?: string | null;
549
552
  fallback_count?: number | null;
550
553
  cost_usd?: number | null;
551
554
  cost_source?: string | null;
@@ -574,6 +577,7 @@ class RequestLogModelClass extends BaseModelClass<
574
577
  "duration_ms",
575
578
  "requested_model_name",
576
579
  "model_name",
580
+ "service_tier",
577
581
  "fallback_count",
578
582
  "cost_usd",
579
583
  "cost_source",
@@ -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
  }