@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.
- package/bundle/dist/application/qgrid/qgrid-image-generation.js +42 -0
- package/bundle/dist/application/qgrid/qgrid-response-format.js +44 -0
- package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +23 -4
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +15 -6
- package/bundle/dist/application/qgrid/qgrid.frame.js +32 -4
- package/bundle/dist/application/qgrid/qgrid.types.js +36 -12
- package/bundle/dist/application/qgrid/tool-emulation.js +16 -8
- package/bundle/dist/application/request-log/request-log.model.js +5 -2
- package/bundle/dist/application/sonamu.generated.js +13 -4
- package/bundle/dist/application/sonamu.generated.sso.js +9 -3
- package/bundle/dist/i18n/sd.generated.js +4 -1
- package/bundle/dist/migrations/20260705140614_alter_request_logs_add1_alter1.js +16 -0
- package/bundle/dist/migrations/20260705140615_alter_request_log_steps_alter1.js +14 -0
- package/bundle/dist/migrations/20260706120000_alter_request_logs_add_image_cost.js +17 -0
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +2 -1
- package/bundle/dist/utils/providers/openai/codex-rpc.js +1 -1
- package/bundle/dist/utils/providers/openai/codex-worker.js +78 -6
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +21 -4
- package/bundle/src/application/qgrid/qgrid-image-generation.ts +62 -0
- package/bundle/src/application/qgrid/qgrid-response-format.ts +59 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +105 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +34 -2
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +33 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +16 -5
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +78 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +48 -5
- package/bundle/src/application/qgrid/qgrid.types.ts +22 -0
- package/bundle/src/application/qgrid/tool-emulation.test.ts +44 -1
- package/bundle/src/application/qgrid/tool-emulation.ts +27 -4
- package/bundle/src/application/request-log/request-log.entity.json +73 -47
- package/bundle/src/application/request-log/request-log.model.ts +7 -0
- package/bundle/src/application/sonamu.generated.http +16 -3
- package/bundle/src/application/sonamu.generated.sso.ts +6 -0
- package/bundle/src/application/sonamu.generated.ts +12 -0
- package/bundle/src/i18n/sd.generated.ts +3 -0
- package/bundle/src/migrations/20260705140614_alter_request_logs_add1_alter1.ts +15 -0
- package/bundle/src/migrations/20260705140615_alter_request_log_steps_alter1.ts +13 -0
- package/bundle/src/migrations/20260706120000_alter_request_logs_add_image_cost.ts +14 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +5 -0
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +14 -0
- package/bundle/src/utils/providers/openai/codex-rpc.ts +2 -0
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +219 -0
- package/bundle/src/utils/providers/openai/codex-worker.ts +154 -5
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +95 -1
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +42 -0
- package/bundle/web-dist/client/assets/index-OQd6CclQ.js +78 -0
- package/bundle/web-dist/client/assets/index-VE_24EJf.css +1 -0
- package/bundle/web-dist/client/assets/{logs-B0pWpDrg.js → logs-8T91eoLY.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-7XhVuG7o.js → routes--GqHRT2A.js} +1 -1
- package/bundle/web-dist/client/assets/{sd.generated-DXhjjL2f.js → sd.generated-BrUnRt0B.js} +1 -1
- package/bundle/web-dist/client/assets/{services.generated-B7u0U1wC.js → services.generated-AsEvHbJ7.js} +1 -1
- package/bundle/web-dist/client/assets/{show-CcjZ9nr7.js → show-cHMucIDC.js} +3 -3
- package/bundle/web-dist/client/assets/{tokens-DlwAm0v4.js → tokens-BXAJUeCJ.js} +1 -1
- package/bundle/web-dist/client/index.html +3 -3
- package/bundle/web-dist/server/assets/{lazyRouteComponent-B0LkbiYf.js → lazyRouteComponent-BswulcOx.js} +1 -1
- package/bundle/web-dist/server/assets/{logs-dLx6gMXT.js → logs-B_ZLzpwY.js} +4 -4
- package/bundle/web-dist/server/assets/{routes-DD3Hb9xO.js → routes-D9OiKoqa.js} +2 -2
- package/bundle/web-dist/server/assets/{sd.generated-DdfpD_iN.js → sd.generated-DRuQSIKo.js} +3 -0
- package/bundle/web-dist/server/assets/{services.generated-D3ZCvIkt.js → services.generated-Dp0uKgpV.js} +1 -1
- package/bundle/web-dist/server/assets/{show-BQjeyv6A.js → show-CGlX9OYt.js} +104 -13
- package/bundle/web-dist/server/assets/{tokens-CSOsBpP7.js → tokens-Cjw6cQxZ.js} +2 -2
- package/bundle/web-dist/server/entry-server.generated.js +9 -8
- package/package.json +1 -1
- package/bundle/web-dist/client/assets/index-5WdFEqZT.css +0 -1
- 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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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
|
-
])
|
|
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
|
-
"
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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": { "
|
|
146
|
+
"RequestLogOrderBy": { "id-desc": "ID최신순" },
|
|
126
147
|
"RequestLogSearchField": {
|
|
127
|
-
"
|
|
148
|
+
"id": "ID",
|
|
149
|
+
"token_name": "토큰이름",
|
|
150
|
+
"user_prompt": "사용자 프롬프트"
|
|
128
151
|
},
|
|
129
152
|
"RequestLogStatus": {
|
|
130
|
-
"
|
|
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];
|