@cartanova/qgrid-cli 2.3.7 → 2.3.8

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 (65) hide show
  1. package/bundle/dist/application/qgrid/qgrid-image-generation.js +42 -0
  2. package/bundle/dist/application/qgrid/qgrid-response-format.js +44 -0
  3. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +23 -4
  4. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +15 -6
  5. package/bundle/dist/application/qgrid/qgrid.frame.js +32 -4
  6. package/bundle/dist/application/qgrid/qgrid.types.js +36 -12
  7. package/bundle/dist/application/qgrid/tool-emulation.js +16 -8
  8. package/bundle/dist/application/request-log/request-log.model.js +5 -2
  9. package/bundle/dist/application/sonamu.generated.js +13 -4
  10. package/bundle/dist/application/sonamu.generated.sso.js +9 -3
  11. package/bundle/dist/i18n/sd.generated.js +4 -1
  12. package/bundle/dist/migrations/20260705140614_alter_request_logs_add1_alter1.js +16 -0
  13. package/bundle/dist/migrations/20260705140615_alter_request_log_steps_alter1.js +14 -0
  14. package/bundle/dist/migrations/20260706120000_alter_request_logs_add_image_cost.js +17 -0
  15. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +2 -1
  16. package/bundle/dist/utils/providers/openai/codex-rpc.js +1 -1
  17. package/bundle/dist/utils/providers/openai/codex-worker.js +78 -6
  18. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +21 -4
  19. package/bundle/src/application/qgrid/qgrid-image-generation.ts +62 -0
  20. package/bundle/src/application/qgrid/qgrid-response-format.ts +59 -0
  21. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +105 -0
  22. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +34 -2
  23. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +33 -0
  24. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +16 -5
  25. package/bundle/src/application/qgrid/qgrid.frame.test.ts +78 -0
  26. package/bundle/src/application/qgrid/qgrid.frame.ts +48 -5
  27. package/bundle/src/application/qgrid/qgrid.types.ts +22 -0
  28. package/bundle/src/application/qgrid/tool-emulation.test.ts +44 -1
  29. package/bundle/src/application/qgrid/tool-emulation.ts +27 -4
  30. package/bundle/src/application/request-log/request-log.entity.json +73 -47
  31. package/bundle/src/application/request-log/request-log.model.ts +7 -0
  32. package/bundle/src/application/sonamu.generated.http +16 -3
  33. package/bundle/src/application/sonamu.generated.sso.ts +6 -0
  34. package/bundle/src/application/sonamu.generated.ts +12 -0
  35. package/bundle/src/i18n/sd.generated.ts +3 -0
  36. package/bundle/src/migrations/20260705140614_alter_request_logs_add1_alter1.ts +15 -0
  37. package/bundle/src/migrations/20260705140615_alter_request_log_steps_alter1.ts +13 -0
  38. package/bundle/src/migrations/20260706120000_alter_request_logs_add_image_cost.ts +14 -0
  39. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +5 -0
  40. package/bundle/src/utils/providers/common/provider-dispatcher.ts +14 -0
  41. package/bundle/src/utils/providers/openai/codex-rpc.ts +2 -0
  42. package/bundle/src/utils/providers/openai/codex-worker.test.ts +219 -0
  43. package/bundle/src/utils/providers/openai/codex-worker.ts +154 -5
  44. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +95 -1
  45. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +42 -0
  46. package/bundle/web-dist/client/assets/index-OQd6CclQ.js +78 -0
  47. package/bundle/web-dist/client/assets/index-VE_24EJf.css +1 -0
  48. package/bundle/web-dist/client/assets/{logs-B0pWpDrg.js → logs-8T91eoLY.js} +1 -1
  49. package/bundle/web-dist/client/assets/{routes-7XhVuG7o.js → routes--GqHRT2A.js} +1 -1
  50. package/bundle/web-dist/client/assets/{sd.generated-DXhjjL2f.js → sd.generated-BrUnRt0B.js} +1 -1
  51. package/bundle/web-dist/client/assets/{services.generated-B7u0U1wC.js → services.generated-AsEvHbJ7.js} +1 -1
  52. package/bundle/web-dist/client/assets/{show-CcjZ9nr7.js → show-cHMucIDC.js} +3 -3
  53. package/bundle/web-dist/client/assets/{tokens-DlwAm0v4.js → tokens-BXAJUeCJ.js} +1 -1
  54. package/bundle/web-dist/client/index.html +3 -3
  55. package/bundle/web-dist/server/assets/{lazyRouteComponent-B0LkbiYf.js → lazyRouteComponent-BswulcOx.js} +1 -1
  56. package/bundle/web-dist/server/assets/{logs-dLx6gMXT.js → logs-B_ZLzpwY.js} +4 -4
  57. package/bundle/web-dist/server/assets/{routes-DD3Hb9xO.js → routes-D9OiKoqa.js} +2 -2
  58. package/bundle/web-dist/server/assets/{sd.generated-DdfpD_iN.js → sd.generated-DRuQSIKo.js} +3 -0
  59. package/bundle/web-dist/server/assets/{services.generated-D3ZCvIkt.js → services.generated-Dp0uKgpV.js} +1 -1
  60. package/bundle/web-dist/server/assets/{show-BQjeyv6A.js → show-CGlX9OYt.js} +104 -13
  61. package/bundle/web-dist/server/assets/{tokens-CSOsBpP7.js → tokens-Cjw6cQxZ.js} +2 -2
  62. package/bundle/web-dist/server/entry-server.generated.js +9 -8
  63. package/package.json +1 -1
  64. package/bundle/web-dist/client/assets/index-5WdFEqZT.css +0 -1
  65. package/bundle/web-dist/client/assets/index-jnUY7nJ-.js +0 -78
@@ -153,6 +153,22 @@ describe("QgridDispatcherClass", () => {
153
153
  expect(req.coldInput).toEqual([{ type: "text", text: "next", text_elements: [] }]);
154
154
  });
155
155
 
156
+ it("Anthropic query 에도 imageGeneration 플래그를 전달해 provider 가 명시적으로 거부하게 한다", async () => {
157
+ const dispatcher = new QgridDispatcherClass();
158
+ const generate = vi.fn(async (_req: GenerateRequest) =>
159
+ providerResult({ model: "claude-sonnet-4-6" }),
160
+ );
161
+ dispatcher.anthropicDispatcher = { generate } as never;
162
+
163
+ await dispatcher.query({
164
+ prompt: "draw",
165
+ model: "anthropic/claude-sonnet-4-6",
166
+ imageGeneration: true,
167
+ });
168
+
169
+ expect(generate.mock.calls[0]![0].imageGeneration).toBe(true);
170
+ });
171
+
156
172
  it("Anthropic queryStream 도 reuse/reuseInput 을 provider 로 전달하지 않고 delta/complete 를 보존한다", async () => {
157
173
  const dispatcher = new QgridDispatcherClass();
158
174
  const generateStream = vi.fn(async (_req, cb) => {
@@ -195,6 +211,23 @@ describe("QgridDispatcherClass", () => {
195
211
  );
196
212
  });
197
213
 
214
+ it("Anthropic queryStream 에도 imageGeneration 플래그를 전달해 provider 가 명시적으로 거부하게 한다", async () => {
215
+ const dispatcher = new QgridDispatcherClass();
216
+ const generateStream = vi.fn(async (_req, _cb) => {});
217
+ dispatcher.anthropicDispatcher = { generateStream } as never;
218
+
219
+ await dispatcher.queryStream(
220
+ {
221
+ prompt: "draw",
222
+ model: "anthropic/claude-sonnet-4-6",
223
+ imageGeneration: true,
224
+ },
225
+ { onDelta: vi.fn(), onComplete: vi.fn(), onError: vi.fn() },
226
+ );
227
+
228
+ expect(generateStream.mock.calls[0]![0].imageGeneration).toBe(true);
229
+ });
230
+
198
231
  it("jsonSchema 를 required + additionalProperties:false 로 strictify 한다", () => {
199
232
  const schema = buildStrictOutputSchema({
200
233
  jsonSchema: JSON.stringify({
@@ -87,13 +87,17 @@ export class QgridDispatcherClass {
87
87
  coldHistory: decision.coldHistory,
88
88
  reuse: decision.reuse,
89
89
  reuseInput: decision.reuseInput,
90
+ imageGeneration: input.imageGeneration,
91
+ imageGenerationOptions: input.imageGenerationOptions,
90
92
  });
91
93
 
92
- return applyToolCallEmulation(
93
- toEmulationResult(result),
94
- input.tools,
95
- issueConvContext(result.threadCoord, decision),
96
- );
94
+ // 이미지 요청은 cold-only(R8)라 재사용 좌표를 발급하지 않는다. 좌표를 실으면
95
+ // sessionKey 소비자의 warm 좌표를 죽은 좌표로 덮어써 다음 텍스트 turn 이 cold 로 떨어진다.
96
+ const coord = input.imageGeneration
97
+ ? undefined
98
+ : issueConvContext(result.threadCoord, decision);
99
+
100
+ return applyToolCallEmulation(toEmulationResult(result), input.tools, coord, result.images);
97
101
  } else if (route.provider === "anthropic") {
98
102
  if (!this.anthropicDispatcher) throw new QuotaError("Anthropic dispatcher not initialized");
99
103
 
@@ -106,6 +110,8 @@ export class QgridDispatcherClass {
106
110
  effort: input.effort,
107
111
  coldInput: decision.coldInput,
108
112
  coldHistory: decision.coldHistory,
113
+ imageGeneration: input.imageGeneration,
114
+ imageGenerationOptions: input.imageGenerationOptions,
109
115
  });
110
116
 
111
117
  return applyToolCallEmulation(
@@ -144,6 +150,9 @@ export class QgridDispatcherClass {
144
150
  reuse: decision.reuse,
145
151
  reuseInput: decision.reuseInput,
146
152
  abortSignal,
153
+ // 이미지 플래그를 전달해야 generateStream 의 non-stream 전용 거부(R2)가 발동한다.
154
+ imageGeneration: input.imageGeneration,
155
+ imageGenerationOptions: input.imageGenerationOptions,
147
156
  },
148
157
  {
149
158
  onDelta: cb.onDelta,
@@ -175,6 +184,8 @@ export class QgridDispatcherClass {
175
184
  coldInput: decision.coldInput,
176
185
  coldHistory: decision.coldHistory,
177
186
  abortSignal,
187
+ imageGeneration: input.imageGeneration,
188
+ imageGenerationOptions: input.imageGenerationOptions,
178
189
  },
179
190
  {
180
191
  onDelta: cb.onDelta,
@@ -7,6 +7,7 @@ const {
7
7
  findManyMock,
8
8
  saveMock,
9
9
  requestLogSaveMock,
10
+ appendStepMock,
10
11
  dispatcherQueryMock,
11
12
  getRateLimitsByTokenIdMock,
12
13
  } = vi.hoisted(() => ({
@@ -14,6 +15,7 @@ const {
14
15
  findManyMock: vi.fn(),
15
16
  saveMock: vi.fn(),
16
17
  requestLogSaveMock: vi.fn(),
18
+ appendStepMock: vi.fn(),
17
19
  dispatcherQueryMock: vi.fn(),
18
20
  getRateLimitsByTokenIdMock: vi.fn(),
19
21
  }));
@@ -22,6 +24,7 @@ vi.mock("../request-log/request-log.model", () => ({
22
24
  MICRO_USD: 1_000_000,
23
25
  RequestLogModel: {
24
26
  save: requestLogSaveMock,
27
+ appendStep: appendStepMock,
25
28
  },
26
29
  }));
27
30
 
@@ -65,6 +68,8 @@ describe("QgridFrame.updateToken", () => {
65
68
  saveMock.mockResolvedValue([1]);
66
69
  requestLogSaveMock.mockReset();
67
70
  requestLogSaveMock.mockResolvedValue([1]);
71
+ appendStepMock.mockReset();
72
+ appendStepMock.mockResolvedValue(1);
68
73
  dispatcherQueryMock.mockReset();
69
74
  });
70
75
 
@@ -138,6 +143,79 @@ describe("QgridFrame.query auto logging", () => {
138
143
  expect.objectContaining({ ttft_ms: 0 }),
139
144
  ]);
140
145
  });
146
+
147
+ it("persists image parts in response as data-url img tags", async () => {
148
+ dispatcherQueryMock.mockResolvedValueOnce({
149
+ ...queryOutput(39),
150
+ text: "image ready",
151
+ content: [
152
+ { type: "text", text: "image ready" },
153
+ { type: "image", data: "iVBORw0KGgoBAgM", revisedPrompt: "green triangle" },
154
+ ],
155
+ });
156
+
157
+ await QgridFrame.query({
158
+ prompt: "draw",
159
+ model: "openai/gpt-5-codex",
160
+ logMode: "auto",
161
+ imageGeneration: true,
162
+ });
163
+
164
+ expect(requestLogSaveMock).toHaveBeenCalledWith([
165
+ expect.objectContaining({
166
+ response:
167
+ 'image ready\n<img src="data:image/png;base64,iVBORw0KGgoBAgM" alt="green triangle" />',
168
+ tool_call_count: 0,
169
+ is_image_generation: true,
170
+ image_cost_usd: 41000,
171
+ image_cost_method: "assumed:gpt-image-2:medium:1536x1024:png",
172
+ }),
173
+ ]);
174
+ expect(appendStepMock).toHaveBeenCalledWith(
175
+ 1,
176
+ expect.objectContaining({
177
+ step_index: 0,
178
+ type: "tool_call",
179
+ tool_call_index: 0,
180
+ tool_call_id: "image_generation:0:0",
181
+ tool_name: "image_generation",
182
+ tool_args: JSON.stringify({
183
+ prompt: "draw",
184
+ driverModel: "openai/gpt-5-codex",
185
+ tool: {
186
+ type: "image_generation",
187
+ outputFormat: "png",
188
+ },
189
+ pricingAssumption: {
190
+ model: "gpt-image-2",
191
+ quality: "medium",
192
+ size: "1536x1024",
193
+ },
194
+ }),
195
+ tool_result: '<img src="data:image/png;base64,iVBORw0KGgoBAgM" alt="green triangle" />',
196
+ }),
197
+ );
198
+ });
199
+ });
200
+
201
+ describe("QgridFrame.prepareStream", () => {
202
+ beforeEach(() => {
203
+ dispatcherQueryMock.mockReset();
204
+ requestLogSaveMock.mockReset();
205
+ });
206
+
207
+ it("rejects imageGeneration before creating an SSE stream", async () => {
208
+ await expect(
209
+ QgridFrame.prepareStream({
210
+ prompt: "draw",
211
+ model: "openai/gpt-5-codex",
212
+ imageGeneration: true,
213
+ }),
214
+ ).rejects.toThrow(/imageGeneration is not supported with streaming/);
215
+
216
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
217
+ expect(requestLogSaveMock).not.toHaveBeenCalled();
218
+ });
141
219
  });
142
220
 
143
221
  describe("QgridFrame.usage", () => {
@@ -33,6 +33,16 @@ import {
33
33
  finishRunAborted,
34
34
  finishRunWithError,
35
35
  } from "./qgrid-run-lifecycle";
36
+ import {
37
+ formatImagePartForLog,
38
+ formatResponseForLog,
39
+ getImageParts,
40
+ imageGenerationToolArgs,
41
+ } from "./qgrid-response-format";
42
+ import {
43
+ estimateImageGenerationCostMicroUsd,
44
+ imageGenerationCostMethod,
45
+ } from "./qgrid-image-generation";
36
46
  import { QgridDispatcher } from "./qgrid.dispatcher";
37
47
  import {
38
48
  type QueryInput,
@@ -90,6 +100,13 @@ function getOAuthRedirectUri(): string {
90
100
  return `http://localhost:${serverPort}/callback`;
91
101
  }
92
102
 
103
+ function rejectImageGenerationStream(args: QueryInput): void {
104
+ if (!args.imageGeneration) return;
105
+ throw new BadRequestException(
106
+ "qgrid: imageGeneration is not supported with streaming; use query/generateText instead." as LocalizedString,
107
+ );
108
+ }
109
+
93
110
  const logger = getLogger(["qgrid"]);
94
111
  const oauthLogger = getLogger(["qgrid", "oauth"]);
95
112
 
@@ -132,11 +149,13 @@ class QgridFrameClass extends BaseFrameClass {
132
149
  // logMode "auto" 단일 요청 로깅: run lifecycle(step 분해) 없이 request_log 1건만 남긴다.
133
150
  // query와 queryStream 양쪽에서 사용.
134
151
  private saveAutoRequestLog(args: QueryInput, result: QueryOutput): void {
152
+ const imageParts = getImageParts(result);
153
+ const imageCostMicroUsd = estimateImageGenerationCostMicroUsd(
154
+ result,
155
+ args.imageGenerationOptions,
156
+ );
135
157
  const toolCallCount = result.content.filter((item) => item.type === "tool-call").length;
136
- const responseText = result.content
137
- .filter((item) => item.type === "text")
138
- .map((item) => item.text)
139
- .join("\n");
158
+ const responseText = formatResponseForLog(result);
140
159
 
141
160
  RequestLogModel.save([
142
161
  {
@@ -153,6 +172,9 @@ class QgridFrameClass extends BaseFrameClass {
153
172
  duration_ms: result.durationMs,
154
173
  ttft_ms: result.ttftMs,
155
174
  cost_usd: result.costUsd !== null ? Math.round(result.costUsd * MICRO_USD) : null,
175
+ image_cost_usd: imageCostMicroUsd,
176
+ image_cost_method:
177
+ imageCostMicroUsd !== null ? imageGenerationCostMethod(args.imageGenerationOptions) : null,
156
178
  effort: args.effort ?? null,
157
179
  // malformed history가 와도 성공한 턴(특히 stream sse.end())을 깨지 않도록 방어.
158
180
  history: ((): { type: string }[] | null => {
@@ -164,12 +186,32 @@ class QgridFrameClass extends BaseFrameClass {
164
186
  })(),
165
187
  status: "succeeded",
166
188
  tool_call_count: toolCallCount,
189
+ // 이미지 turn 식별(R13): quota 소모를 이미지 워크로드에 귀속.
190
+ is_image_generation: args.imageGeneration ?? false,
167
191
  },
168
- ]).catch((e) => logger.error(`requestLog save failed: ${(e as Error).message}`));
192
+ ])
193
+ .then(async (ids) => {
194
+ const requestLogId = ids[0];
195
+ if (!requestLogId || imageParts.length === 0) return;
196
+ for (let i = 0; i < imageParts.length; i++) {
197
+ const image = imageParts[i]!;
198
+ await RequestLogModel.appendStep(requestLogId, {
199
+ step_index: 0,
200
+ type: "tool_call",
201
+ tool_call_index: i,
202
+ tool_call_id: `image_generation:0:${i}`,
203
+ tool_name: "image_generation",
204
+ tool_args: imageGenerationToolArgs(args),
205
+ tool_result: formatImagePartForLog(image),
206
+ });
207
+ }
208
+ })
209
+ .catch((e) => logger.error(`requestLog save failed: ${(e as Error).message}`));
169
210
  }
170
211
 
171
212
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
172
213
  async prepareStream(args: QueryInput): Promise<{ streamId: string }> {
214
+ rejectImageGenerationStream(args);
173
215
  const streamId = crypto.randomUUID();
174
216
  pendingStreams.set(streamId, args);
175
217
  setTimeout(() => pendingStreams.delete(streamId), 30_000);
@@ -181,6 +223,7 @@ class QgridFrameClass extends BaseFrameClass {
181
223
  const args = pendingStreams.get(streamId);
182
224
  pendingStreams.delete(streamId);
183
225
  if (!args) throw new Error("invalid or expired streamId");
226
+ rejectImageGenerationStream(args);
184
227
 
185
228
  const effectiveLogMode = args.logMode ?? (args.isStep ? "run" : "auto");
186
229
  const shouldLog = effectiveLogMode === "run";
@@ -23,6 +23,12 @@ export const QgridContent = z.discriminatedUnion("type", [
23
23
  toolName: z.string(),
24
24
  input: z.string(),
25
25
  }),
26
+ z.object({
27
+ type: z.literal("image"),
28
+ // Codex image_generation result base64. qgrid 는 포맷/확장자 책임을 지지 않는다.
29
+ data: z.string(),
30
+ revisedPrompt: z.string().nullish(),
31
+ }),
26
32
  ]);
27
33
  export type QgridContent = z.infer<typeof QgridContent>;
28
34
 
@@ -38,6 +44,18 @@ export type ReasoningSummary = z.infer<typeof ReasoningSummary>;
38
44
  export const ServiceTier = z.enum(["fast", "flex"]);
39
45
  export type ServiceTier = z.infer<typeof ServiceTier>;
40
46
 
47
+ export const ImageGenerationQuality = z.enum(["low", "medium", "high"]);
48
+ export type ImageGenerationQuality = z.infer<typeof ImageGenerationQuality>;
49
+
50
+ export const ImageGenerationSize = z.enum(["1024x1024", "1024x1536", "1536x1024"]);
51
+ export type ImageGenerationSize = z.infer<typeof ImageGenerationSize>;
52
+
53
+ export const ImageGenerationOptions = z.object({
54
+ quality: ImageGenerationQuality.optional(),
55
+ size: ImageGenerationSize.optional(),
56
+ });
57
+ export type ImageGenerationOptions = z.infer<typeof ImageGenerationOptions>;
58
+
41
59
  // ─── Run Lifecycle Context (SDK ↔ Server contract) ───
42
60
 
43
61
  export const QgridLogMode = z.enum(["auto", "run", "none"]);
@@ -86,6 +104,10 @@ export const QueryInput = z.object({
86
104
  logMode: QgridLogMode.optional(),
87
105
  runContext: QgridRunContext.optional(),
88
106
  toolResults: z.array(QgridToolResultInput).optional(),
107
+ // codex 내장 image_generation tool 을 켠다(OpenAI 경로 전용, opt-in, non-stream).
108
+ imageGeneration: z.boolean().optional(),
109
+ // qgrid 가격 추정 및 Codex 이미지 요청 힌트. 이미지 모델은 gpt-image-2 로 고정 가정한다.
110
+ imageGenerationOptions: ImageGenerationOptions.optional(),
89
111
  });
90
112
  export type QueryInput = z.infer<typeof QueryInput>;
91
113
 
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
3
  import { type QgridTool } from "./qgrid.types";
4
- import { buildToolCallSchema } from "./tool-emulation";
4
+ import { applyToolCallEmulation, buildToolCallSchema } from "./tool-emulation";
5
5
 
6
6
  describe("buildToolCallSchema", () => {
7
7
  it("documents that emulated tools must be requested through StructuredOutput", () => {
@@ -48,3 +48,46 @@ describe("buildToolCallSchema", () => {
48
48
  ).toContain("getWeather");
49
49
  });
50
50
  });
51
+
52
+ describe("applyToolCallEmulation image parts", () => {
53
+ const baseResult = {
54
+ text: "here is your image",
55
+ tokenName: "token",
56
+ model: "gpt-5.5",
57
+ usage: {
58
+ input_tokens: 1,
59
+ output_tokens: 1,
60
+ cache_creation_input_tokens: 0,
61
+ cache_read_input_tokens: 0,
62
+ },
63
+ durationMs: 10,
64
+ ttftMs: 0,
65
+ costUsd: 0,
66
+ };
67
+ const img = { data: "iVBORw0KGgoBAgM", revisedPrompt: "a red circle" };
68
+
69
+ it("appends an image part after text when images are present (no tools)", () => {
70
+ const out = applyToolCallEmulation(baseResult, undefined, undefined, [img]);
71
+ expect(out.content).toEqual([
72
+ { type: "text", text: "here is your image" },
73
+ { type: "image", data: "iVBORw0KGgoBAgM", revisedPrompt: "a red circle" },
74
+ ]);
75
+ // 이미지는 finishReason 과 직교 — 여전히 stop.
76
+ expect(out.finishReason).toBe("stop");
77
+ });
78
+
79
+ it("appends multiple images preserving order", () => {
80
+ const out = applyToolCallEmulation(baseResult, undefined, undefined, [
81
+ { data: "iVBORw0KGgoAAA", revisedPrompt: "one" },
82
+ { data: "iVBORw0KGgoBBB", revisedPrompt: "two" },
83
+ ]);
84
+ const images = out.content.filter((c) => c.type === "image");
85
+ expect(images).toHaveLength(2);
86
+ expect(images.map((c) => (c.type === "image" ? c.revisedPrompt : null))).toEqual(["one", "two"]);
87
+ });
88
+
89
+ it("leaves content unchanged when no images are passed (regression guard)", () => {
90
+ const out = applyToolCallEmulation(baseResult, undefined, undefined);
91
+ expect(out.content).toEqual([{ type: "text", text: "here is your image" }]);
92
+ });
93
+ });
@@ -16,6 +16,27 @@ type ToolCallResponse = {
16
16
  toolCalls?: Array<{ toolName: string; args: string }> | null;
17
17
  };
18
18
 
19
+ // dispatcher 가 넘기는 이미지 결과. qgrid 는 base64 payload 만 전달하고 포맷은 보장하지 않는다.
20
+ export interface EmulationImage {
21
+ data: string;
22
+ revisedPrompt?: string | null;
23
+ }
24
+
25
+ // 조립된 content 뒤에 이미지 파트를 append 한다. 모든 반환 분기가 공유.
26
+ function appendImages(content: QgridContent[], images?: EmulationImage[]): QgridContent[] {
27
+ if (!images?.length) return content;
28
+ return [
29
+ ...content,
30
+ ...images.map(
31
+ (img): QgridContent => ({
32
+ type: "image",
33
+ data: img.data,
34
+ revisedPrompt: img.revisedPrompt ?? null,
35
+ }),
36
+ ),
37
+ ];
38
+ }
39
+
19
40
  export function buildToolCallSchema(tools: QgridTool[]): JsonValue {
20
41
  const toolDescriptions = tools
21
42
  .map((tool) => {
@@ -72,13 +93,15 @@ export function applyToolCallEmulation(
72
93
  result: Omit<QueryOutput, "content" | "finishReason" | "runContext">,
73
94
  tools?: QgridTool[],
74
95
  threadCoord?: QgridThreadCoord,
96
+ images?: EmulationImage[],
75
97
  ): QueryOutput {
76
98
  // thread 재사용 좌표를 runContext 로 실어 올린다 (auto 모드는 requestLogId 없이 threadCoord 만).
77
99
  const runContext = threadCoord ? { threadCoord } : undefined;
100
+ // 이미지는 finishReason 과 직교하는 content 파트. 조립된 content 뒤에 append 한다.
78
101
 
79
102
  if (!tools?.length) {
80
103
  const content: QgridContent[] = [{ type: "text", text: result.text }];
81
- return { ...result, content, finishReason: "stop", runContext };
104
+ return { ...result, content: appendImages(content, images), finishReason: "stop", runContext };
82
105
  }
83
106
 
84
107
  let parsed: ToolCallResponse;
@@ -88,7 +111,7 @@ export function applyToolCallEmulation(
88
111
  logger.warn(`tool-call emulation parse failed, falling back to text: ${(e as Error).message}`);
89
112
  return {
90
113
  ...result,
91
- content: [{ type: "text", text: result.text }],
114
+ content: appendImages([{ type: "text", text: result.text }], images),
92
115
  finishReason: "stop",
93
116
  runContext,
94
117
  };
@@ -108,14 +131,14 @@ export function applyToolCallEmulation(
108
131
  input: toolCall.args,
109
132
  };
110
133
  });
111
- return { ...result, content, finishReason: "tool-calls", runContext };
134
+ return { ...result, content: appendImages(content, images), finishReason: "tool-calls", runContext };
112
135
  }
113
136
 
114
137
  const text = parsed.answer ?? result.text;
115
138
  return {
116
139
  ...result,
117
140
  text,
118
- content: [{ type: "text", text }],
141
+ content: appendImages([{ type: "text", text }], images),
119
142
  finishReason: "stop",
120
143
  runContext,
121
144
  };
@@ -44,6 +44,19 @@
44
44
  { "name": "duration_ms", "type": "integer", "desc": "응답 소요시간(ms)", "dbDefault": "0" },
45
45
  { "name": "ttft_ms", "type": "integer", "desc": "first gen TTFT(ms)", "dbDefault": "0" },
46
46
  { "name": "cost_usd", "type": "integer", "nullable": true, "desc": "API 비용" },
47
+ {
48
+ "name": "image_cost_usd",
49
+ "type": "integer",
50
+ "nullable": true,
51
+ "desc": "이미지 생성 추정 비용"
52
+ },
53
+ {
54
+ "name": "image_cost_method",
55
+ "type": "string",
56
+ "length": 100,
57
+ "nullable": true,
58
+ "desc": "이미지 비용 추정 방식"
59
+ },
47
60
  { "name": "effort", "type": "string", "length": 10, "nullable": true, "desc": "effort level" },
48
61
  {
49
62
  "name": "history",
@@ -60,7 +73,13 @@
60
73
  "dbDefault": "\"succeeded\""
61
74
  },
62
75
  { "name": "error_message", "type": "string", "nullable": true, "desc": "에러 메시지" },
63
- { "name": "tool_call_count", "type": "integer", "desc": "tool call 횟수", "dbDefault": "0" }
76
+ { "name": "tool_call_count", "type": "integer", "desc": "tool call 횟수", "dbDefault": "0" },
77
+ {
78
+ "name": "is_image_generation",
79
+ "type": "boolean",
80
+ "desc": "이미지 생성 turn 여부 (quota 귀속용)",
81
+ "dbDefault": "false"
82
+ }
64
83
  ],
65
84
  "indexes": [
66
85
  {
@@ -77,57 +96,64 @@
77
96
  }
78
97
  ],
79
98
  "subsets": {
80
- "A": {
81
- "fields": [
82
- "id",
83
- "created_at",
84
- "token_name",
85
- "project_name",
86
- "model_name",
87
- "user_prompt",
88
- "system_prompt",
89
- "response",
90
- "input_tokens",
91
- "output_tokens",
92
- "cache_read_tokens",
93
- "cache_creation_tokens",
94
- "duration_ms",
95
- "ttft_ms",
96
- "cost_usd",
97
- "effort",
98
- "history",
99
- "status",
100
- "error_message",
101
- "tool_call_count"
102
- ]
103
- },
104
- "P": {
105
- "fields": [
106
- "id",
107
- "created_at",
108
- "token_name",
109
- "project_name",
110
- "model_name",
111
- "input_tokens",
112
- "output_tokens",
113
- "cache_read_tokens",
114
- "cache_creation_tokens",
115
- "duration_ms",
116
- "ttft_ms",
117
- "cost_usd",
118
- "effort",
119
- "status",
120
- "tool_call_count"
121
- ]
122
- }
99
+ "A": [
100
+ "id",
101
+ "created_at",
102
+ "token_name",
103
+ "project_name",
104
+ "model_name",
105
+ "user_prompt",
106
+ "system_prompt",
107
+ "response",
108
+ "input_tokens",
109
+ "output_tokens",
110
+ "cache_read_tokens",
111
+ "cache_creation_tokens",
112
+ "duration_ms",
113
+ "ttft_ms",
114
+ "cost_usd",
115
+ "image_cost_usd",
116
+ "image_cost_method",
117
+ "effort",
118
+ "history",
119
+ "status",
120
+ "error_message",
121
+ "tool_call_count",
122
+ "is_image_generation"
123
+ ],
124
+ "P": [
125
+ "id",
126
+ "created_at",
127
+ "token_name",
128
+ "project_name",
129
+ "model_name",
130
+ "input_tokens",
131
+ "output_tokens",
132
+ "cache_read_tokens",
133
+ "cache_creation_tokens",
134
+ "duration_ms",
135
+ "ttft_ms",
136
+ "cost_usd",
137
+ "image_cost_usd",
138
+ "image_cost_method",
139
+ "effort",
140
+ "status",
141
+ "tool_call_count",
142
+ "is_image_generation"
143
+ ]
123
144
  },
124
145
  "enums": {
125
- "RequestLogOrderBy": { "values": { "id-desc": "ID최신순" } },
146
+ "RequestLogOrderBy": { "id-desc": "ID최신순" },
126
147
  "RequestLogSearchField": {
127
- "values": { "id": "ID", "token_name": "토큰이름", "user_prompt": "사용자 프롬프트" }
148
+ "id": "ID",
149
+ "token_name": "토큰이름",
150
+ "user_prompt": "사용자 프롬프트"
128
151
  },
129
152
  "RequestLogStatus": {
130
- "values": { "running": "실행 중", "succeeded": "성공", "error": "에러", "aborted": "중단" }
153
+ "running": "실행 중",
154
+ "succeeded": "성공",
155
+ "error": "에러",
156
+ "aborted": "중단"
131
157
  }
132
158
  }
133
159
  }
@@ -209,6 +209,7 @@ class RequestLogModelClass extends BaseModelClass<
209
209
  effort?: string | null;
210
210
  project_name?: string | null;
211
211
  history?: unknown;
212
+ is_image_generation?: boolean;
212
213
  }): Promise<number> {
213
214
  const wdb = this.getPuri("w");
214
215
  wdb.ubRegister("request_logs", {
@@ -225,6 +226,8 @@ class RequestLogModelClass extends BaseModelClass<
225
226
  cache_creation_tokens: 0,
226
227
  duration_ms: 0,
227
228
  tool_call_count: 0,
229
+ // 이미지 turn 식별(R13). run 경로(tools+image 조합)도 auto 경로와 동일하게 마킹.
230
+ is_image_generation: params.is_image_generation ?? false,
228
231
  ...(params.history !== undefined ? { history: params.history as { type: string }[] } : {}),
229
232
  });
230
233
  return wdb.transaction(async (trx) => {
@@ -259,6 +262,8 @@ class RequestLogModelClass extends BaseModelClass<
259
262
  history?: unknown;
260
263
  error_message?: string;
261
264
  tool_call_count?: number;
265
+ image_cost_usd?: number | null;
266
+ image_cost_method?: string | null;
262
267
  },
263
268
  ): Promise<void> {
264
269
  if (params.status === "succeeded" && !params.token_name) {
@@ -275,6 +280,8 @@ class RequestLogModelClass extends BaseModelClass<
275
280
  "duration_ms",
276
281
  "error_message",
277
282
  "tool_call_count",
283
+ "image_cost_usd",
284
+ "image_cost_method",
278
285
  ] as const;
279
286
  for (const key of fields) {
280
287
  if (params[key] !== undefined) update[key] = params[key];