@cartanova/qgrid-cli 2.10.3 → 2.10.5

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 (78) hide show
  1. package/bundle/dist/application/qgrid/qgrid-image-generation.js +6 -2
  2. package/bundle/dist/application/qgrid/qgrid-response-format.js +4 -4
  3. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +63 -140
  4. package/bundle/dist/application/qgrid/qgrid.frame.js +3 -6
  5. package/bundle/dist/application/qgrid/qgrid.types.js +4 -8
  6. package/bundle/dist/application/request-log/request-log.model.js +21 -15
  7. package/bundle/dist/application/setting/setting.model.js +2 -64
  8. package/bundle/dist/application/setting/setting.store.js +3 -4
  9. package/bundle/dist/application/setting/setting.types.js +2 -12
  10. package/bundle/dist/i18n/en.js +1 -3
  11. package/bundle/dist/i18n/ko.js +1 -3
  12. package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +19 -8
  13. package/bundle/src/application/qgrid/qgrid-image-generation.test.ts +18 -0
  14. package/bundle/src/application/qgrid/qgrid-image-generation.ts +14 -3
  15. package/bundle/src/application/qgrid/qgrid-response-format.test.ts +9 -0
  16. package/bundle/src/application/qgrid/qgrid-response-format.ts +10 -7
  17. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +0 -111
  18. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +82 -178
  19. package/bundle/src/application/qgrid/qgrid.frame.test.ts +0 -38
  20. package/bundle/src/application/qgrid/qgrid.frame.ts +4 -6
  21. package/bundle/src/application/qgrid/qgrid.types.ts +14 -4
  22. package/bundle/src/application/request-log/request-log.model.test.ts +17 -12
  23. package/bundle/src/application/request-log/request-log.model.ts +8 -2
  24. package/bundle/src/application/setting/setting.model.test.ts +1 -101
  25. package/bundle/src/application/setting/setting.model.ts +0 -73
  26. package/bundle/src/application/setting/setting.store.ts +2 -3
  27. package/bundle/src/application/setting/setting.types.ts +0 -12
  28. package/bundle/src/application/sonamu.generated.http +2 -7
  29. package/bundle/src/i18n/en.ts +0 -3
  30. package/bundle/src/i18n/ko.ts +0 -3
  31. package/bundle/src/utils/providers/openai/openai-backend-protocol.test.ts +19 -1
  32. package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +22 -0
  33. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +12 -0
  34. package/bundle/web-dist/client/assets/cost-CY2BJEPC.js +1 -0
  35. package/bundle/web-dist/client/assets/index-CvG4UB9v.css +1 -0
  36. package/bundle/web-dist/client/assets/{index-BIC1sQxw.js → index-Dxw7DiXF.js} +4 -4
  37. package/bundle/web-dist/client/assets/logs-3n_U4qnI.js +1 -0
  38. package/bundle/web-dist/client/assets/{monit-CraVV3MQ.js → monit-E8eGW1Sj.js} +1 -1
  39. package/bundle/web-dist/client/assets/{routes-B3ZBoJ60.js → routes-7spLfIGZ.js} +2 -2
  40. package/bundle/web-dist/client/assets/services.generated-jnPHYMER.js +51 -0
  41. package/bundle/web-dist/client/assets/settings-BdosZFNC.js +1 -0
  42. package/bundle/web-dist/client/assets/{show-ItTExdAh.js → show-b2PFNI3D.js} +1 -1
  43. package/bundle/web-dist/client/assets/{tokens-BynKx8Vn.js → tokens-CBdHb2_U.js} +1 -1
  44. package/bundle/web-dist/client/assets/{use-oauth-login-flow-8xw6on5P.js → use-oauth-login-flow-Cyn3YPqW.js} +1 -1
  45. package/bundle/web-dist/client/assets/{use-update-token-mutation-D-Y8OySK.js → use-update-token-mutation-DQCwCYfR.js} +1 -1
  46. package/bundle/web-dist/client/index.html +3 -3
  47. package/bundle/web-dist/server/assets/{cost-CSH_t17I.js → cost-59jBdx-I.js} +8 -3
  48. package/bundle/web-dist/server/assets/{lazyRouteComponent-Bj1cPq6x.js → lazyRouteComponent-CUR-kWzI.js} +2 -2
  49. package/bundle/web-dist/server/assets/{logs-BSk9lOHe.js → logs-CaaWO9-Z.js} +9 -7
  50. package/bundle/web-dist/server/assets/{monit-D9W4ioUi.js → monit-CBil92fJ.js} +4 -4
  51. package/bundle/web-dist/server/assets/{plus-CmvbWuDf.js → plus-Bvdj8Pwx.js} +1 -1
  52. package/bundle/web-dist/server/assets/{react-dom-BjR1Iaz8.js → react-dom-CyuShs9K.js} +1 -1
  53. package/bundle/web-dist/server/assets/{refresh-cw-B8U9BL9-.js → refresh-cw-3QAiSWBd.js} +1 -1
  54. package/bundle/web-dist/server/assets/{routes-C7lB2Q85.js → routes-DtVf9yqo.js} +7 -7
  55. package/bundle/web-dist/server/assets/{services.generated-BM4X3Ecj.js → services.generated-FPwsG9p3.js} +116 -131
  56. package/bundle/web-dist/server/assets/{settings-BFVuldch.js → settings-DeHNwoMv.js} +40 -251
  57. package/bundle/web-dist/server/assets/{show-Cswh_tqs.js → show-8NNdRxhM.js} +3 -3
  58. package/bundle/web-dist/server/assets/{tokens-DiTUwUwr.js → tokens-odgS2-9q.js} +4 -4
  59. package/bundle/web-dist/server/assets/{use-oauth-login-flow-DYKnG4A2.js → use-oauth-login-flow-DTQKdYBO.js} +1 -1
  60. package/bundle/web-dist/server/assets/{use-update-token-mutation-DqGMopII.js → use-update-token-mutation-xvXbPrCI.js} +1 -1
  61. package/bundle/web-dist/server/entry-server.generated.js +10 -10
  62. package/package.json +3 -2
  63. package/skills/qgrid/SKILL.md +1 -1
  64. package/skills/qgrid/references/ai-sdk-provider-contract.md +1 -1
  65. package/skills/qgrid/references/cli-env-and-server-boot.md +12 -9
  66. package/skills/qgrid/references/decision-rationale.md +2 -2
  67. package/skills/qgrid/references/openai-codex-runtime.md +5 -1
  68. package/bundle/dist/utils/process-supervisor.js +0 -22
  69. package/bundle/dist/utils/server-restart.js +0 -61
  70. package/bundle/src/utils/process-supervisor.test.ts +0 -24
  71. package/bundle/src/utils/process-supervisor.ts +0 -17
  72. package/bundle/src/utils/server-restart.test.ts +0 -76
  73. package/bundle/src/utils/server-restart.ts +0 -68
  74. package/bundle/web-dist/client/assets/cost-CpjVoAqp.js +0 -1
  75. package/bundle/web-dist/client/assets/index-D_sagzsR.css +0 -1
  76. package/bundle/web-dist/client/assets/logs-zvdujZWn.js +0 -1
  77. package/bundle/web-dist/client/assets/services.generated-Cbvg2sGQ.js +0 -51
  78. package/bundle/web-dist/client/assets/settings-DwbzT5jo.js +0 -1
@@ -1,8 +1,5 @@
1
1
  import { getLogger } from "@logtape/logtape";
2
- import { ServiceUnavailableException } from "sonamu";
3
2
 
4
- import { SD } from "../../i18n/sd.generated";
5
- import { isRestartPending } from "../../utils/server-restart";
6
3
  import { RequestLogModel } from "../request-log/request-log.model";
7
4
  import {
8
5
  estimateImageGenerationCostMicroUsd,
@@ -20,44 +17,6 @@ const STALE_RUN_THRESHOLD_MS = 30 * 60 * 1000;
20
17
  const STALE_CLEANUP_INTERVAL_MS = 60 * 1000;
21
18
  let staleCleanupInFlight: Promise<void> | undefined;
22
19
  let lastStaleCleanupStartedAt = 0;
23
- const activeNativeRunRefs = new Map<number, number>();
24
- const interruptedNativeRunIds = new Set<number>();
25
- const activeNativeFinalizers = new Map<number, Set<Promise<void>>>();
26
-
27
- function restartUnavailable(): ServiceUnavailableException {
28
- return new ServiceUnavailableException(SD("qgrid.restartPending")());
29
- }
30
-
31
- function registerNativeRun(requestLogId: number): void {
32
- activeNativeRunRefs.set(requestLogId, (activeNativeRunRefs.get(requestLogId) ?? 0) + 1);
33
- }
34
-
35
- function releaseNativeRun(requestLogId: number): void {
36
- const refs = activeNativeRunRefs.get(requestLogId);
37
- if (refs === undefined || refs <= 1) activeNativeRunRefs.delete(requestLogId);
38
- else activeNativeRunRefs.set(requestLogId, refs - 1);
39
- }
40
-
41
- function trackNativeFinalizer<T>(requestLogId: number, finalize: () => Promise<T>): Promise<T> {
42
- const operation = finalize();
43
- const settled = operation.then(
44
- () => undefined,
45
- () => undefined,
46
- );
47
- const finalizers = activeNativeFinalizers.get(requestLogId) ?? new Set<Promise<void>>();
48
- finalizers.add(settled);
49
- activeNativeFinalizers.set(requestLogId, finalizers);
50
- void settled.then(() => {
51
- finalizers.delete(settled);
52
- if (finalizers.size === 0) activeNativeFinalizers.delete(requestLogId);
53
- });
54
- return operation;
55
- }
56
-
57
- export function assertNativeRunAdmission(): void {
58
- if (isRestartPending()) throw restartUnavailable();
59
- }
60
-
61
20
  function withProviderPrefix(
62
21
  routeModel: string | undefined,
63
22
  model: string | undefined,
@@ -110,8 +69,6 @@ export async function beforeQuery(args: QueryInput): Promise<{
110
69
  requestLogId: number;
111
70
  stepIndex: number;
112
71
  }> {
113
- assertNativeRunAdmission();
114
-
115
72
  let requestLogId: number;
116
73
  let stepIndex = 0;
117
74
 
@@ -140,14 +97,6 @@ export async function beforeQuery(args: QueryInput): Promise<{
140
97
  });
141
98
  }
142
99
 
143
- // DB 준비 중에 재시작이 시작됐다면 provider 실행으로 넘어가지 않는다.
144
- // 이 요청은 재시작 snapshot에 없었으므로 방금 준비한 run을 직접 마감한다.
145
- if (isRestartPending()) {
146
- await finishTerminalRun(requestLogId, "error", "server restarted");
147
- throw restartUnavailable();
148
- }
149
-
150
- registerNativeRun(requestLogId);
151
100
  return { requestLogId, stepIndex };
152
101
  }
153
102
 
@@ -157,102 +106,94 @@ export async function afterQuery(
157
106
  args: QueryInput,
158
107
  result: QueryOutput,
159
108
  ): Promise<RunLifecycleResult> {
160
- if (interruptedNativeRunIds.has(requestLogId)) return {};
109
+ // provider가 canonical model을 돌려줘도 요청 route 자체([1m] 등)는 보존한다.
110
+ const requestedModelName = withProviderPrefix(
111
+ args.model,
112
+ args.model ?? result.requestedModel ?? result.model,
113
+ );
114
+ const servedModelName = withProviderPrefix(args.model, result.model);
115
+
116
+ // generate step 기록
117
+ await RequestLogModel.appendStep(requestLogId, {
118
+ step_index: stepIndex,
119
+ type: "generate",
120
+ model_name: servedModelName,
121
+ requested_model_name: requestedModelName,
122
+ fallback_count: result.modelFallbacks?.length ?? 0,
123
+ input_tokens: result.usage.input_tokens,
124
+ output_tokens: result.usage.output_tokens,
125
+ cache_read_tokens: result.usage.cache_read_input_tokens,
126
+ cache_creation_tokens: result.usage.cache_creation_input_tokens,
127
+ cache_creation_5m_tokens: result.usage.cache_creation_5m_input_tokens,
128
+ cache_creation_1h_tokens: result.usage.cache_creation_1h_input_tokens,
129
+ cost_usd: Math.round(result.costUsd * 1_000_000),
130
+ cost_source: result.costSource,
131
+ duration_ms: result.durationMs,
132
+ ttft_ms: result.ttftMs,
133
+ finish_reason: result.finishReason,
134
+ });
161
135
 
162
- return trackNativeFinalizer(requestLogId, async () => {
163
- try {
164
- // provider가 canonical model을 돌려줘도 요청 route 자체([1m] 등)는 보존한다.
165
- const requestedModelName = withProviderPrefix(
166
- args.model,
167
- args.model ?? result.requestedModel ?? result.model,
168
- );
169
- const servedModelName = withProviderPrefix(args.model, result.model);
136
+ const imageParts = getImageParts(result);
137
+ const imageCostMicroUsd = estimateImageGenerationCostMicroUsd(
138
+ result,
139
+ args.imageGenerationOptions,
140
+ );
141
+ for (const step of buildImageGenerationToolSteps(args, imageParts, stepIndex)) {
142
+ await RequestLogModel.appendStep(requestLogId, step);
143
+ }
170
144
 
171
- // generate step 기록
145
+ if (result.finishReason === "tool-calls") {
146
+ // tool-call step을 즉시 기록하고, 다음 follow-up이 같은 row에 결과를 채운다.
147
+ const toolCalls = result.content.filter((c) => c.type === "tool-call");
148
+ for (let i = 0; i < toolCalls.length; i++) {
149
+ const tc = toolCalls[i]!;
150
+ if (tc.type !== "tool-call") continue;
172
151
  await RequestLogModel.appendStep(requestLogId, {
173
152
  step_index: stepIndex,
174
- type: "generate",
175
- model_name: servedModelName,
176
- requested_model_name: requestedModelName,
177
- fallback_count: result.modelFallbacks?.length ?? 0,
178
- input_tokens: result.usage.input_tokens,
179
- output_tokens: result.usage.output_tokens,
180
- cache_read_tokens: result.usage.cache_read_input_tokens,
181
- cache_creation_tokens: result.usage.cache_creation_input_tokens,
182
- cache_creation_5m_tokens: result.usage.cache_creation_5m_input_tokens,
183
- cache_creation_1h_tokens: result.usage.cache_creation_1h_input_tokens,
184
- cost_usd: Math.round(result.costUsd * 1_000_000),
185
- cost_source: result.costSource,
186
- duration_ms: result.durationMs,
187
- ttft_ms: result.ttftMs,
188
- finish_reason: result.finishReason,
153
+ type: "tool_call",
154
+ tool_call_index: imageParts.length + i,
155
+ tool_call_id: tc.toolCallId,
156
+ tool_name: tc.toolName,
157
+ tool_args: tc.input,
189
158
  });
159
+ }
190
160
 
191
- const imageParts = getImageParts(result);
192
- const imageCostMicroUsd = estimateImageGenerationCostMicroUsd(
193
- result,
194
- args.imageGenerationOptions,
195
- );
196
- for (const step of buildImageGenerationToolSteps(args, imageParts, stepIndex)) {
197
- await RequestLogModel.appendStep(requestLogId, step);
198
- }
199
-
200
- if (result.finishReason === "tool-calls") {
201
- // tool-call step을 즉시 기록하고, 다음 follow-up이 같은 row에 결과를 채운다.
202
- const toolCalls = result.content.filter((c) => c.type === "tool-call");
203
- for (let i = 0; i < toolCalls.length; i++) {
204
- const tc = toolCalls[i]!;
205
- if (tc.type !== "tool-call") continue;
206
- await RequestLogModel.appendStep(requestLogId, {
207
- step_index: stepIndex,
208
- type: "tool_call",
209
- tool_call_index: imageParts.length + i,
210
- tool_call_id: tc.toolCallId,
211
- tool_name: tc.toolName,
212
- tool_args: tc.input,
213
- });
214
- }
215
-
216
- return { runContext: { requestLogId } };
217
- }
218
-
219
- // finishReason === "stop" → run 종료 (전체 step usage 합산)
220
- const agg = await RequestLogModel.aggregateStepUsage(requestLogId);
221
- const responseText = formatResponseForLog(result);
222
- await RequestLogModel.finishRun(requestLogId, {
223
- status: "succeeded",
224
- response: responseText,
225
- // structured 요청만 판정한다. deti 배치의 broken JSON 류를 목록에서 바로
226
- // 구별하기 위한 기록이며, 비structured 요청은 null 로 남는다.
227
- response_json_ok: args.jsonSchema ? isParseableJson(responseText) : null,
228
- token_name: result.tokenName,
229
- input_tokens: agg.input_tokens,
230
- output_tokens: agg.output_tokens,
231
- cache_read_tokens: agg.cache_read_tokens,
232
- cache_creation_tokens: agg.cache_creation_tokens,
233
- cache_creation_5m_tokens: agg.cache_creation_5m_tokens,
234
- cache_creation_1h_tokens: agg.cache_creation_1h_tokens,
235
- duration_ms: agg.duration_ms,
236
- // multi-step run 부모의 모델은 최종 turn을 서빙한 모델이다.
237
- requested_model_name: requestedModelName,
238
- model_name: servedModelName,
239
- fallback_count: agg.fallback_count,
240
- cost_usd: agg.cost_usd,
241
- cost_source: agg.cost_source,
242
- image_cost_usd: imageCostMicroUsd,
243
- ...(imageParts.length > 0 ? { is_image_generation: true } : {}),
244
- image_cost_method:
245
- imageCostMicroUsd !== null
246
- ? imageGenerationCostMethod(args.imageGenerationOptions, result)
247
- : null,
248
- history: filterHistoryForStorage(args.history),
249
- });
161
+ return { runContext: { requestLogId } };
162
+ }
250
163
 
251
- return {};
252
- } finally {
253
- releaseNativeRun(requestLogId);
254
- }
164
+ // finishReason === "stop" → run 종료 (전체 step usage 합산)
165
+ const agg = await RequestLogModel.aggregateStepUsage(requestLogId);
166
+ const responseText = formatResponseForLog(result);
167
+ await RequestLogModel.finishRun(requestLogId, {
168
+ status: "succeeded",
169
+ response: responseText,
170
+ // structured 요청만 판정한다. deti 배치의 broken JSON 류를 목록에서 바로
171
+ // 구별하기 위한 기록이며, 비structured 요청은 null 로 남는다.
172
+ response_json_ok: args.jsonSchema ? isParseableJson(responseText) : null,
173
+ token_name: result.tokenName,
174
+ input_tokens: agg.input_tokens,
175
+ output_tokens: agg.output_tokens,
176
+ cache_read_tokens: agg.cache_read_tokens,
177
+ cache_creation_tokens: agg.cache_creation_tokens,
178
+ cache_creation_5m_tokens: agg.cache_creation_5m_tokens,
179
+ cache_creation_1h_tokens: agg.cache_creation_1h_tokens,
180
+ duration_ms: agg.duration_ms,
181
+ // multi-step run 부모의 모델은 최종 turn을 서빙한 모델이다.
182
+ requested_model_name: requestedModelName,
183
+ model_name: servedModelName,
184
+ fallback_count: agg.fallback_count,
185
+ cost_usd: agg.cost_usd,
186
+ cost_source: agg.cost_source,
187
+ image_cost_usd: imageCostMicroUsd,
188
+ ...(imageParts.length > 0 ? { is_image_generation: true } : {}),
189
+ image_cost_method:
190
+ imageCostMicroUsd !== null
191
+ ? imageGenerationCostMethod(args.imageGenerationOptions, result)
192
+ : null,
193
+ history: filterHistoryForStorage(args.history),
255
194
  });
195
+
196
+ return {};
256
197
  }
257
198
 
258
199
  export async function finishRunWithError(
@@ -260,48 +201,11 @@ export async function finishRunWithError(
260
201
  errorMessage: string,
261
202
  args?: QueryInput,
262
203
  ): Promise<void> {
263
- if (interruptedNativeRunIds.has(requestLogId)) return;
264
- return trackNativeFinalizer(requestLogId, async () => {
265
- try {
266
- await finishTerminalRun(requestLogId, "error", errorMessage, args);
267
- } finally {
268
- releaseNativeRun(requestLogId);
269
- }
270
- });
204
+ await finishTerminalRun(requestLogId, "error", errorMessage, args);
271
205
  }
272
206
 
273
207
  export async function finishRunAborted(requestLogId: number, args?: QueryInput): Promise<void> {
274
- if (interruptedNativeRunIds.has(requestLogId)) return;
275
- return trackNativeFinalizer(requestLogId, async () => {
276
- try {
277
- await finishTerminalRun(requestLogId, "aborted", "client disconnected", args);
278
- } finally {
279
- releaseNativeRun(requestLogId);
280
- }
281
- });
282
- }
283
-
284
- /** 현재 이 프로세스가 provider를 실행 중인 native run만 재시작 오류로 마감한다. */
285
- export async function finishActiveNativeRunsForRestart(): Promise<void> {
286
- const requestLogIds = [...activeNativeRunRefs.keys()];
287
- for (const requestLogId of requestLogIds) {
288
- interruptedNativeRunIds.add(requestLogId);
289
- activeNativeRunRefs.delete(requestLogId);
290
- }
291
-
292
- await Promise.all(
293
- requestLogIds.map(async (requestLogId) => {
294
- await Promise.all(activeNativeFinalizers.get(requestLogId) ?? []);
295
- await finishTerminalRun(requestLogId, "error", "server restarted");
296
- }),
297
- );
298
- }
299
-
300
- /** 프로세스 전역 registry를 격리해야 하는 단위 테스트 전용. */
301
- export function resetNativeRunRegistryForTests(): void {
302
- activeNativeRunRefs.clear();
303
- interruptedNativeRunIds.clear();
304
- activeNativeFinalizers.clear();
208
+ await finishTerminalRun(requestLogId, "aborted", "client disconnected", args);
305
209
  }
306
210
 
307
211
  async function finishTerminalRun(
@@ -21,7 +21,6 @@ const {
21
21
  dispatcherQueryStreamMock,
22
22
  beforeQueryMock,
23
23
  afterQueryMock,
24
- assertNativeRunAdmissionMock,
25
24
  finishRunWithErrorMock,
26
25
  finishRunAbortedMock,
27
26
  getRateLimitsByTokenIdMock,
@@ -45,7 +44,6 @@ const {
45
44
  dispatcherQueryStreamMock: vi.fn(),
46
45
  beforeQueryMock: vi.fn(),
47
46
  afterQueryMock: vi.fn(),
48
- assertNativeRunAdmissionMock: vi.fn(),
49
47
  finishRunWithErrorMock: vi.fn(),
50
48
  finishRunAbortedMock: vi.fn(),
51
49
  getRateLimitsByTokenIdMock: vi.fn(),
@@ -95,7 +93,6 @@ vi.mock("./qgrid.dispatcher", async (importOriginal) => {
95
93
  });
96
94
 
97
95
  vi.mock("./qgrid-run-lifecycle", () => ({
98
- assertNativeRunAdmission: assertNativeRunAdmissionMock,
99
96
  beforeQuery: beforeQueryMock,
100
97
  afterQuery: afterQueryMock,
101
98
  finishRunWithError: finishRunWithErrorMock,
@@ -215,7 +212,6 @@ describe("QgridFrame.query request logging", () => {
215
212
  beforeEach(() => {
216
213
  beforeQueryMock.mockReset().mockResolvedValue({ requestLogId: 41, stepIndex: 0 });
217
214
  afterQueryMock.mockReset().mockResolvedValue({});
218
- assertNativeRunAdmissionMock.mockReset();
219
215
  finishRunWithErrorMock.mockReset();
220
216
  dispatcherQueryMock.mockReset();
221
217
  findActiveByProviderAndNameMock.mockReset();
@@ -368,18 +364,6 @@ describe("QgridFrame.query request logging", () => {
368
364
  expect(finishRunWithErrorMock).not.toHaveBeenCalled();
369
365
  });
370
366
 
371
- it("rejects logger-disabled native dispatch while restart admission is closed", async () => {
372
- assertNativeRunAdmissionMock.mockImplementationOnce(() => {
373
- throw Object.assign(new Error("server restarting"), { statusCode: 503 });
374
- });
375
-
376
- await expect(
377
- QgridFrame.query({ prompt: "hi", model: "openai/gpt-5-codex", logger: false }),
378
- ).rejects.toMatchObject({ statusCode: 503 });
379
- expect(dispatcherQueryMock).not.toHaveBeenCalled();
380
- expect(beforeQueryMock).not.toHaveBeenCalled();
381
- });
382
-
383
367
  it("merges a tool run id with the provider thread coordinate", async () => {
384
368
  dispatcherQueryMock.mockResolvedValueOnce(queryOutput());
385
369
  afterQueryMock.mockResolvedValueOnce({ runContext: { requestLogId: 41 } });
@@ -952,7 +936,6 @@ describe("QgridFrame.queryStream request logging", () => {
952
936
  beforeEach(() => {
953
937
  beforeQueryMock.mockReset().mockResolvedValue({ requestLogId: 52, stepIndex: 0 });
954
938
  afterQueryMock.mockReset().mockResolvedValue({});
955
- assertNativeRunAdmissionMock.mockReset();
956
939
  finishRunWithErrorMock.mockReset();
957
940
  finishRunAbortedMock.mockReset();
958
941
  dispatcherQueryStreamMock.mockReset();
@@ -986,27 +969,6 @@ describe("QgridFrame.queryStream request logging", () => {
986
969
  };
987
970
  }
988
971
 
989
- it("rejects stream preparation while restart admission is closed", async () => {
990
- assertNativeRunAdmissionMock.mockImplementationOnce(() => {
991
- throw Object.assign(new Error("server restarting"), { statusCode: 503 });
992
- });
993
-
994
- await expect(QgridFrame.prepareStream({ prompt: "hi" })).rejects.toMatchObject({
995
- statusCode: 503,
996
- });
997
- expect(dispatcherQueryStreamMock).not.toHaveBeenCalled();
998
- });
999
-
1000
- it("rechecks admission before dispatching a previously prepared stream", async () => {
1001
- const { streamId } = await QgridFrame.prepareStream({ prompt: "hi", logger: false });
1002
- assertNativeRunAdmissionMock.mockImplementationOnce(() => {
1003
- throw Object.assign(new Error("server restarting"), { statusCode: 503 });
1004
- });
1005
-
1006
- await expect(QgridFrame.queryStream(streamId)).rejects.toMatchObject({ statusCode: 503 });
1007
- expect(dispatcherQueryStreamMock).not.toHaveBeenCalled();
1008
- });
1009
-
1010
972
  function installSseContext() {
1011
973
  let closeHandler: (() => void) | undefined;
1012
974
  const sse = {
@@ -38,7 +38,6 @@ import {
38
38
  RefreshFailedError,
39
39
  } from "./oauth";
40
40
  import {
41
- assertNativeRunAdmission,
42
41
  afterQuery,
43
42
  beforeQuery,
44
43
  finishRunAborted,
@@ -248,7 +247,6 @@ class QgridFrameClass extends BaseFrameClass {
248
247
 
249
248
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
250
249
  async query(args: QueryInput): Promise<QueryOutput> {
251
- assertNativeRunAdmission();
252
250
  rejectInvalidCallerSchemas(args);
253
251
  const resolvedArgs = await resolveTokenName(args);
254
252
  const disconnect = createHttpDisconnectHandle();
@@ -299,7 +297,6 @@ class QgridFrameClass extends BaseFrameClass {
299
297
 
300
298
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
301
299
  async prepareStream(args: QueryInput): Promise<{ streamId: string }> {
302
- assertNativeRunAdmission();
303
300
  rejectImageGenerationStream(args);
304
301
  rejectInvalidCallerSchemas(args);
305
302
  const resolvedArgs = await resolveTokenName(args);
@@ -315,7 +312,6 @@ class QgridFrameClass extends BaseFrameClass {
315
312
  const args = pendingStreams.get(streamId);
316
313
  pendingStreams.delete(streamId);
317
314
  if (!args) throw new Error("invalid or expired streamId");
318
- assertNativeRunAdmission();
319
315
  rejectImageGenerationStream(args);
320
316
 
321
317
  const ctx = Sonamu.getContext();
@@ -504,8 +500,10 @@ class QgridFrameClass extends BaseFrameClass {
504
500
  // 화면의 목록 필터를 그대로 받아 같은 조건의 합계를 돌려준다 — 목록과 비용이
505
501
  // 서로 다른 모수를 보면 사용자에게는 숫자가 어긋난 것으로 읽힌다.
506
502
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
507
- async totalCost(params?: RequestLogListParams): Promise<{ usd: number }> {
508
- return { usd: await RequestLogModel.totalCost(params) };
503
+ async totalCost(
504
+ params?: RequestLogListParams,
505
+ ): Promise<{ usd: number; unknownImageCostCount: number }> {
506
+ return RequestLogModel.totalCost(params);
509
507
  }
510
508
 
511
509
  @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
@@ -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[]> {