@cartanova/qgrid-cli 2.10.4 → 2.10.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundle/dist/application/monit/monit.frame.js +1 -1
- package/bundle/dist/application/qgrid/expired-token-reminder.js +1 -1
- package/bundle/dist/application/qgrid/oauth.js +1 -1
- package/bundle/dist/application/qgrid/qgrid-image-generation.js +6 -6
- package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +24 -7
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +8 -3
- package/bundle/dist/application/qgrid/qgrid.frame.js +25 -11
- package/bundle/dist/application/qgrid/qgrid.types.js +4 -8
- package/bundle/dist/application/qgrid/token-death.js +1 -1
- package/bundle/dist/application/qgrid/token-window-keepalive.js +1 -1
- package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
- package/bundle/dist/application/request-log/request-log.model.js +23 -17
- package/bundle/dist/i18n/sd.generated.js +3 -3
- package/bundle/dist/sonamu.config.js +2 -2
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +1 -1
- package/bundle/dist/utils/providers/anthropic/anthropic-thinking.js +1 -1
- package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +31 -9
- package/bundle/dist/utils/providers/openai/openai-direct-client.js +5 -4
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +111 -57
- package/bundle/dist/utils/providers/openai/openai-image-generation.js +25 -17
- package/bundle/dist/utils/providers/openai/openai-refresh.js +1 -1
- package/bundle/src/application/qgrid/qgrid-image-generation.test.ts +34 -5
- package/bundle/src/application/qgrid/qgrid-image-generation.ts +17 -13
- package/bundle/src/application/qgrid/qgrid-response-format.test.ts +11 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +87 -9
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +45 -5
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +136 -2
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +11 -1
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +73 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +43 -6
- package/bundle/src/application/qgrid/qgrid.types.ts +14 -4
- package/bundle/src/application/request-log/request-log.entity.json +2 -2
- package/bundle/src/application/request-log/request-log.model.test.ts +17 -12
- package/bundle/src/application/request-log/request-log.model.ts +8 -2
- package/bundle/src/application/sonamu.generated.http +2 -2
- package/bundle/src/i18n/sd.generated.ts +2 -2
- package/bundle/src/utils/providers/openai/openai-backend-protocol.test.ts +42 -2
- package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +46 -2
- package/bundle/src/utils/providers/openai/openai-direct-client.test.ts +15 -0
- package/bundle/src/utils/providers/openai/openai-direct-client.ts +14 -4
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +235 -32
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +174 -72
- package/bundle/src/utils/providers/openai/openai-image-generation.test.ts +89 -0
- package/bundle/src/utils/providers/openai/openai-image-generation.ts +26 -15
- package/bundle/web-dist/client/assets/cost-DH2jZbrw.js +1 -0
- package/bundle/web-dist/client/assets/{index-reW8VvJF.js → index-D9ryIyL-.js} +4 -4
- package/bundle/web-dist/client/assets/logs-CLf2VNfk.js +1 -0
- package/bundle/web-dist/client/assets/{monit-DkABv7PG.js → monit-DojxOLj6.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-BRmQ4M3-.js → routes-DXJhNvbI.js} +2 -2
- package/bundle/web-dist/client/assets/{services.generated-jnPHYMER.js → services.generated-DqFYgWsY.js} +1 -1
- package/bundle/web-dist/client/assets/{settings-BHvtycEZ.js → settings-bopyz9pa.js} +1 -1
- package/bundle/web-dist/client/assets/{show-qeTIzgDZ.js → show-DbLD3MzB.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-5Bep_G-w.js → tokens-2-AIi9sZ.js} +1 -1
- package/bundle/web-dist/client/assets/{use-oauth-login-flow-Cx-QO25M.js → use-oauth-login-flow-CnqTWs_H.js} +1 -1
- package/bundle/web-dist/client/assets/{use-update-token-mutation-DQCwCYfR.js → use-update-token-mutation-Cxb9K0fM.js} +1 -1
- package/bundle/web-dist/client/index.html +2 -2
- package/bundle/web-dist/server/assets/{cost-CSH_t17I.js → cost-eEHeTRT5.js} +5 -1
- package/bundle/web-dist/server/assets/{lazyRouteComponent-CUR-kWzI.js → lazyRouteComponent-Bi0fYGLs.js} +2 -2
- package/bundle/web-dist/server/assets/{logs-CagNGTBj.js → logs-CIVFxfoi.js} +11 -8
- package/bundle/web-dist/server/assets/{monit-DSxs4Jnl.js → monit-DBXezg7R.js} +4 -4
- package/bundle/web-dist/server/assets/{plus-Bvdj8Pwx.js → plus-BH-t4cm_.js} +1 -1
- package/bundle/web-dist/server/assets/{react-dom-CyuShs9K.js → react-dom-CZ2eynd8.js} +1 -1
- package/bundle/web-dist/server/assets/{refresh-cw-3QAiSWBd.js → refresh-cw-CosAdcz0.js} +1 -1
- package/bundle/web-dist/server/assets/{routes-i-sTqXMX.js → routes-COWYIvA-.js} +5 -6
- package/bundle/web-dist/server/assets/{services.generated-FPwsG9p3.js → services.generated-607iwhMI.js} +2 -2
- package/bundle/web-dist/server/assets/{settings-DeHNwoMv.js → settings-C_6dpK5p.js} +3 -3
- package/bundle/web-dist/server/assets/{show-C4jk1Hrp.js → show-DY396-LD.js} +8 -20
- package/bundle/web-dist/server/assets/{tokens-odgS2-9q.js → tokens-COvV7qUP.js} +4 -4
- package/bundle/web-dist/server/assets/{use-oauth-login-flow-DTQKdYBO.js → use-oauth-login-flow-VYCYf3uv.js} +1 -1
- package/bundle/web-dist/server/assets/{use-update-token-mutation-xvXbPrCI.js → use-update-token-mutation-D8XYV5of.js} +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +10 -10
- package/package.json +3 -2
- package/skills/qgrid/SKILL.md +9 -8
- package/skills/qgrid/references/ai-sdk-provider-contract.md +25 -16
- package/skills/qgrid/references/decision-rationale.md +8 -8
- package/skills/qgrid/references/openai-codex-runtime.md +23 -11
- package/skills/qgrid/references/request-log-run-lifecycle.md +4 -2
- package/skills/qgrid/references/sonamu-api-web-flow.md +2 -2
- package/skills/qgrid/references/verification-and-debugging.md +23 -9
- package/bundle/web-dist/client/assets/cost-CpjVoAqp.js +0 -1
- package/bundle/web-dist/client/assets/logs-DDIt97D1.js +0 -1
|
@@ -6,10 +6,12 @@ import {
|
|
|
6
6
|
type GenerateStreamCallbacks,
|
|
7
7
|
} from "../../utils/providers/common/provider-dispatcher";
|
|
8
8
|
import { systemHash } from "./conv-routing";
|
|
9
|
-
import { OpenAIDispatcher } from "../../utils/providers/openai/openai-dispatcher";
|
|
9
|
+
import { ImageGenerationError, OpenAIDispatcher } from "../../utils/providers/openai/openai-dispatcher";
|
|
10
10
|
import { normalizeOpenAIEvent } from "../../utils/providers/openai/openai-backend-protocol";
|
|
11
|
-
import { buildStrictOutputSchema, QgridDispatcherClass } from "./qgrid.dispatcher";
|
|
11
|
+
import { buildStrictOutputSchema, completedDriverFromImageFailure, QgridDispatcherClass } from "./qgrid.dispatcher";
|
|
12
12
|
import { QueryInput, type QueryOutput } from "./qgrid.types";
|
|
13
|
+
import { PNG } from "pngjs";
|
|
14
|
+
import { estimateImageGenerationCostMicroUsd } from "./qgrid-image-generation";
|
|
13
15
|
import { type TokenSubsetA } from "../sonamu.generated";
|
|
14
16
|
|
|
15
17
|
function providerResult(overrides: Partial<GenerateResult> = {}): GenerateResult {
|
|
@@ -36,6 +38,138 @@ function deeplyNestedOutputSchema(depth: number): string {
|
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
describe("QgridDispatcherClass", () => {
|
|
41
|
+
it("prices only the completed chat driver from an image failure", () => {
|
|
42
|
+
const error = new ImageGenerationError("incomplete", "images unavailable");
|
|
43
|
+
error.completedDriver = providerResult({
|
|
44
|
+
model: "gpt-6-luna", requestedModel: "gpt-6-astra", ttftMs: 4,
|
|
45
|
+
usage: { totalTokens: 120, inputTokens: 100, outputTokens: 20, cachedInputTokens: 10, reasoningOutputTokens: 5 },
|
|
46
|
+
});
|
|
47
|
+
const result = completedDriverFromImageFailure(error)!;
|
|
48
|
+
expect(result).toMatchObject({
|
|
49
|
+
model: "gpt-6-luna", requestedModel: "gpt-6-astra", tokenName: "provider/test",
|
|
50
|
+
usage: { input_tokens: 100, output_tokens: 20, cache_read_input_tokens: 10, reasoning_tokens: 5 },
|
|
51
|
+
costSource: "pricing_table", finishReason: "stop", durationMs: 12, ttftMs: 4,
|
|
52
|
+
content: [{ type: "text", text: "ok" }],
|
|
53
|
+
});
|
|
54
|
+
expect(result.costUsd).toBeCloseTo(0.0000191, 10);
|
|
55
|
+
expect(estimateImageGenerationCostMicroUsd(result, undefined)).toBeNull();
|
|
56
|
+
expect(result.runContext).toBeUndefined();
|
|
57
|
+
expect(completedDriverFromImageFailure(new Error("failed"))).toBeUndefined();
|
|
58
|
+
expect(completedDriverFromImageFailure(new ImageGenerationError("gate", "invalid"))).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
it.each([
|
|
61
|
+
[false, 0, "generations"],
|
|
62
|
+
[false, 2, "edits"],
|
|
63
|
+
[true, 1, "edits"],
|
|
64
|
+
] as const)("auto chat uses Images %s/%s/%s and keeps both usages", async (transparent, referenceCount, endpoint) => {
|
|
65
|
+
const png = new PNG({ width: 2, height: 1 });
|
|
66
|
+
png.data.set([0, 0, 0, transparent ? 0 : 255, 255, 0, 0, 255]);
|
|
67
|
+
const imageUsage = {
|
|
68
|
+
input_tokens: 10, output_tokens: 100, total_tokens: 110,
|
|
69
|
+
input_tokens_details: { text_tokens: 4, image_tokens: 6 },
|
|
70
|
+
};
|
|
71
|
+
const fetchMock = vi.fn(async (url: string | URL | Request, _init?: RequestInit) => {
|
|
72
|
+
if (String(url).endsWith("/responses")) {
|
|
73
|
+
const events = [
|
|
74
|
+
{ type: "response.output_item.done", item: {
|
|
75
|
+
type: "function_call", call_id: "image-call", name: "generate_image",
|
|
76
|
+
arguments: JSON.stringify({ prompt: "A red square", transparent_background: transparent, num_last_images_to_include: referenceCount }),
|
|
77
|
+
} },
|
|
78
|
+
{ type: "response.completed", response: {
|
|
79
|
+
id: "driver", model: "gpt-6-luna",
|
|
80
|
+
usage: { input_tokens: 100, output_tokens: 20, total_tokens: 120 },
|
|
81
|
+
} },
|
|
82
|
+
];
|
|
83
|
+
return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } });
|
|
84
|
+
}
|
|
85
|
+
return Response.json({
|
|
86
|
+
data: [{ b64_json: PNG.sync.write(png).toString("base64") }],
|
|
87
|
+
background: transparent ? "transparent" : "opaque", quality: "medium", usage: imageUsage,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
const provider = new OpenAIDispatcher("https", { fetch: fetchMock });
|
|
91
|
+
await provider.onTokenAdded(1, "chat-token", {
|
|
92
|
+
accessToken: "test-access", refreshToken: "", accountId: "test-account", accessTokenExpiresAt: Date.now() + 60000,
|
|
93
|
+
});
|
|
94
|
+
const dispatcher = new QgridDispatcherClass();
|
|
95
|
+
dispatcher.openaiDispatcher = provider;
|
|
96
|
+
try {
|
|
97
|
+
const result = await dispatcher.query({
|
|
98
|
+
model: "openai/gpt-6-luna", prompt: "Draw or edit a square", imageGeneration: "auto",
|
|
99
|
+
history: JSON.stringify([{ type: "message", role: "user", content: [{ type: "input_image", image_url: "https://example.com/old.png" }] }]),
|
|
100
|
+
input: [{ type: "image", url: "https://example.com/new.png" }],
|
|
101
|
+
});
|
|
102
|
+
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
|
103
|
+
"https://chatgpt.com/backend-api/codex/responses",
|
|
104
|
+
`https://chatgpt.com/backend-api/codex/images/${endpoint}`,
|
|
105
|
+
]);
|
|
106
|
+
const driverInit = fetchMock.mock.calls[0]![1]!;
|
|
107
|
+
const imageInit = fetchMock.mock.calls[1]![1]!;
|
|
108
|
+
expect(JSON.parse(String(driverInit.body)).tools).toEqual([
|
|
109
|
+
expect.objectContaining({ type: "function", name: "generate_image" }),
|
|
110
|
+
]);
|
|
111
|
+
const imageBody = JSON.parse(String(imageInit.body));
|
|
112
|
+
expect(imageBody).toMatchObject({ model: "gpt-image-2", background: transparent ? "transparent" : "opaque" });
|
|
113
|
+
expect(imageBody.prompt).toContain("A red square");
|
|
114
|
+
expect(imageBody.prompt).not.toContain("Draw or edit a square");
|
|
115
|
+
const refs = ["https://example.com/old.png", "https://example.com/new.png"].slice(-referenceCount);
|
|
116
|
+
expect(imageBody.images).toEqual(referenceCount ? refs.map((image_url) => ({ image_url })) : undefined);
|
|
117
|
+
expect(imageInit.signal).toBe(driverInit.signal);
|
|
118
|
+
expect(new Headers(imageInit.headers).get("authorization")).toBe(new Headers(driverInit.headers).get("authorization"));
|
|
119
|
+
expect(result).toMatchObject({ model: "gpt-6-luna", usage: { input_tokens: 100, output_tokens: 20 } });
|
|
120
|
+
expect(result.costUsd).toBeCloseTo(0.00002, 8);
|
|
121
|
+
expect(result.content.find((item) => item.type === "image")).toMatchObject({ generation: { route: "codex-images", usage: imageUsage } });
|
|
122
|
+
expect(estimateImageGenerationCostMicroUsd(result, undefined)).toBe(3068);
|
|
123
|
+
} finally {
|
|
124
|
+
await provider.stop();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("routes the image model straight to subscription edits and keeps image usage separate", async () => {
|
|
129
|
+
const png = new PNG({ width: 1, height: 1 });
|
|
130
|
+
png.data.set([255, 0, 0, 255]);
|
|
131
|
+
const base64 = PNG.sync.write(png).toString("base64");
|
|
132
|
+
const usage = {
|
|
133
|
+
input_tokens: 10, output_tokens: 100, total_tokens: 110,
|
|
134
|
+
input_tokens_details: { text_tokens: 4, image_tokens: 6 },
|
|
135
|
+
};
|
|
136
|
+
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
|
137
|
+
data: [{ b64_json: base64 }], usage, background: "opaque", quality: "medium",
|
|
138
|
+
})));
|
|
139
|
+
const provider = new OpenAIDispatcher("https", { fetch: fetchMock });
|
|
140
|
+
await provider.onTokenAdded(1, "test", {
|
|
141
|
+
accessToken: "unused", refreshToken: "", accountId: "unused",
|
|
142
|
+
accessTokenExpiresAt: Date.now() + 60000,
|
|
143
|
+
});
|
|
144
|
+
const dispatcher = new QgridDispatcherClass();
|
|
145
|
+
dispatcher.openaiDispatcher = provider;
|
|
146
|
+
try {
|
|
147
|
+
const result = await dispatcher.query({
|
|
148
|
+
model: "openai/gpt-image-2", prompt: "recolor", imageGeneration: true,
|
|
149
|
+
input: [
|
|
150
|
+
{ type: "text", text: "recolor", text_elements: [] },
|
|
151
|
+
{ type: "image", url: `data:image/png;base64,${base64}` },
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
|
155
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
156
|
+
"https://chatgpt.com/backend-api/codex/images/edits",
|
|
157
|
+
expect.objectContaining({ method: "POST", signal: expect.any(AbortSignal) }),
|
|
158
|
+
);
|
|
159
|
+
expect(result).toMatchObject({
|
|
160
|
+
model: "gpt-image-2", costUsd: 0,
|
|
161
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
162
|
+
content: expect.arrayContaining([expect.objectContaining({
|
|
163
|
+
type: "image", mediaType: "image/png",
|
|
164
|
+
generation: { route: "codex-images", model: "gpt-image-2", background: "opaque", size: "1x1", quality: "medium", usage },
|
|
165
|
+
})]),
|
|
166
|
+
});
|
|
167
|
+
expect(result.runContext?.threadCoord).toBeUndefined();
|
|
168
|
+
} finally {
|
|
169
|
+
await provider.stop();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
39
173
|
it.each(["generate", "stream"])("%s yields a completed tool-call message before a speculative later answer", async (mode) => {
|
|
40
174
|
const tool = JSON.stringify({result: {action: "tool_call", answer: null, toolCalls: [{toolName: "lookupMarker", args: "{}"}]}});
|
|
41
175
|
const answer = JSON.stringify({result: {action: "answer", answer: {marker: ""}, toolCalls: null}});
|
|
@@ -17,7 +17,10 @@ import {
|
|
|
17
17
|
serializeAndValidateDispatchSchema,
|
|
18
18
|
} from "../../utils/providers/common/schema-validation";
|
|
19
19
|
import { strictify } from "../../utils/providers/common/strictifier";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
ImageGenerationError,
|
|
22
|
+
type OpenAIDispatcher,
|
|
23
|
+
} from "../../utils/providers/openai/openai-dispatcher";
|
|
21
24
|
import { type TokenSubsetA } from "../sonamu.generated";
|
|
22
25
|
import { decideConvRouting, issueConvContext } from "./conv-routing";
|
|
23
26
|
import {
|
|
@@ -325,6 +328,13 @@ function buildRawOutputSchema(
|
|
|
325
328
|
: callerOutputSchema;
|
|
326
329
|
}
|
|
327
330
|
|
|
331
|
+
export function completedDriverFromImageFailure(error: unknown): QueryOutput | undefined {
|
|
332
|
+
if (!(error instanceof ImageGenerationError) || !error.completedDriver) return undefined;
|
|
333
|
+
return applyToolCallEmulation(toEmulationResult(error.completedDriver), undefined, {
|
|
334
|
+
answerKind: "text",
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
328
338
|
// Anthropic adapter 는 cache creation 을 inputTokens 에 포함해 표준화하고, 별도 필드에도 보존한다.
|
|
329
339
|
function toEmulationResult(
|
|
330
340
|
result: GenerateResult,
|
|
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
|
|
6
6
|
import { QgridFrame } from "./qgrid.frame";
|
|
7
7
|
import { QueryInput } from "./qgrid.types";
|
|
8
|
+
import { ImageGenerationError } from "../../utils/providers/openai/openai-dispatcher";
|
|
8
9
|
|
|
9
10
|
const {
|
|
10
11
|
findOneMock,
|
|
@@ -213,6 +214,7 @@ describe("QgridFrame.query request logging", () => {
|
|
|
213
214
|
beforeQueryMock.mockReset().mockResolvedValue({ requestLogId: 41, stepIndex: 0 });
|
|
214
215
|
afterQueryMock.mockReset().mockResolvedValue({});
|
|
215
216
|
finishRunWithErrorMock.mockReset();
|
|
217
|
+
finishRunAbortedMock.mockReset();
|
|
216
218
|
dispatcherQueryMock.mockReset();
|
|
217
219
|
findActiveByProviderAndNameMock.mockReset();
|
|
218
220
|
});
|
|
@@ -385,6 +387,45 @@ describe("QgridFrame.query request logging", () => {
|
|
|
385
387
|
expect(finishRunWithErrorMock).toHaveBeenCalledWith(41, "provider failed", args);
|
|
386
388
|
});
|
|
387
389
|
|
|
390
|
+
it.each(["error", "abort", "logger-disabled"])("handles completed driver accounting on image %s", async (mode) => {
|
|
391
|
+
const requestRaw = Object.assign(new EventEmitter(), { aborted: false });
|
|
392
|
+
const responseRaw = Object.assign(new EventEmitter(), { destroyed: false, writableEnded: false });
|
|
393
|
+
vi.spyOn(Sonamu, "getContext").mockReturnValue({
|
|
394
|
+
transport: "http", request: { raw: requestRaw }, reply: { raw: responseRaw },
|
|
395
|
+
} as never);
|
|
396
|
+
const error = new ImageGenerationError("incomplete", "image failed");
|
|
397
|
+
error.completedDriver = {
|
|
398
|
+
text: "drawing", tokenName: "one", model: "gpt-6-luna", durationMs: 20, ttftMs: 2,
|
|
399
|
+
threadCoord: { workerId: 1, threadId: "", epoch: -1 },
|
|
400
|
+
usage: { inputTokens: 100, outputTokens: 20, cachedInputTokens: 0, reasoningOutputTokens: 0, totalTokens: 120 },
|
|
401
|
+
};
|
|
402
|
+
dispatcherQueryMock.mockImplementationOnce(async () => {
|
|
403
|
+
if (mode === "abort") { responseRaw.destroyed = true; responseRaw.emit("close"); }
|
|
404
|
+
throw error;
|
|
405
|
+
});
|
|
406
|
+
const args = {
|
|
407
|
+
prompt: "draw", model: "openai/gpt-6-luna", imageGeneration: "auto" as const,
|
|
408
|
+
...(mode === "logger-disabled" ? { logger: false } : {}),
|
|
409
|
+
};
|
|
410
|
+
await expect(QgridFrame.query(args)).rejects.toBe(error);
|
|
411
|
+
expect(afterQueryMock).not.toHaveBeenCalled();
|
|
412
|
+
if (mode === "logger-disabled") {
|
|
413
|
+
expect(beforeQueryMock).not.toHaveBeenCalled();
|
|
414
|
+
expect(finishRunWithErrorMock).not.toHaveBeenCalled();
|
|
415
|
+
expect(finishRunAbortedMock).not.toHaveBeenCalled();
|
|
416
|
+
} else {
|
|
417
|
+
const completedStep = expect.objectContaining({
|
|
418
|
+
stepIndex: 0,
|
|
419
|
+
result: expect.objectContaining({
|
|
420
|
+
model: "gpt-6-luna", tokenName: "one", costUsd: 0.00002,
|
|
421
|
+
usage: expect.objectContaining({ input_tokens: 100, output_tokens: 20 }),
|
|
422
|
+
}),
|
|
423
|
+
});
|
|
424
|
+
if (mode === "abort") expect(finishRunAbortedMock).toHaveBeenCalledWith(41, args, completedStep);
|
|
425
|
+
else expect(finishRunWithErrorMock).toHaveBeenCalledWith(41, "image failed", args, completedStep);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
388
429
|
it("marks an incoherent structured envelope failure as a request-log error", async () => {
|
|
389
430
|
const error = new Error(
|
|
390
431
|
"tool-call emulation: structured response envelope is missing required keys: toolCalls",
|
|
@@ -775,6 +816,32 @@ describe("QgridFrame.query request logging", () => {
|
|
|
775
816
|
expect(QueryInput.safeParse({ prompt: "hi", logger: false }).success).toBe(true);
|
|
776
817
|
});
|
|
777
818
|
|
|
819
|
+
it.each([
|
|
820
|
+
{ imageGeneration: undefined },
|
|
821
|
+
{ imageGeneration: "auto" as const },
|
|
822
|
+
{ imageGeneration: true, tools: [{ name: "lookup", inputSchema: { type: "object" } }] },
|
|
823
|
+
{ imageGeneration: true, jsonSchema: '{"type":"object"}' },
|
|
824
|
+
{ imageGeneration: true, imageGenerationOptions: { outputFormat: "webp" as const } },
|
|
825
|
+
])("rejects unsupported standalone image requests before logging: %j", async (options) => {
|
|
826
|
+
await expect(QgridFrame.query({
|
|
827
|
+
prompt: "draw", model: "openai/gpt-image-2", ...options,
|
|
828
|
+
})).rejects.toMatchObject({ statusCode: 400 });
|
|
829
|
+
expect(beforeQueryMock).not.toHaveBeenCalled();
|
|
830
|
+
expect(dispatcherQueryMock).not.toHaveBeenCalled();
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it.each([
|
|
834
|
+
{ imageGenerationOptions: { outputFormat: "webp" as const } },
|
|
835
|
+
{ tools: [{ name: "lookup", inputSchema: { type: "object" } }] },
|
|
836
|
+
{ jsonSchema: '{"type":"object"}' },
|
|
837
|
+
])("rejects unsupported auto image controls before logging: %j", async (options) => {
|
|
838
|
+
await expect(QgridFrame.query({
|
|
839
|
+
prompt: "draw", model: "openai/gpt-6-luna", imageGeneration: "auto", ...options,
|
|
840
|
+
})).rejects.toMatchObject({ statusCode: 400 });
|
|
841
|
+
expect(beforeQueryMock).not.toHaveBeenCalled();
|
|
842
|
+
expect(dispatcherQueryMock).not.toHaveBeenCalled();
|
|
843
|
+
});
|
|
844
|
+
|
|
778
845
|
it("accepts bounded positive integer timeout values only", () => {
|
|
779
846
|
expect(QueryInput.safeParse({ prompt: "hi", timeout: 360_000 }).success).toBe(true);
|
|
780
847
|
expect(QueryInput.safeParse({ prompt: "hi", timeout: 0 }).success).toBe(false);
|
|
@@ -789,6 +856,12 @@ describe("QgridFrame.prepareStream", () => {
|
|
|
789
856
|
requestLogSaveMock.mockReset();
|
|
790
857
|
});
|
|
791
858
|
|
|
859
|
+
it("rejects an image model without a streaming image flag", async () => {
|
|
860
|
+
await expect(QgridFrame.prepareStream({
|
|
861
|
+
prompt: "draw", model: "openai/gpt-image-2",
|
|
862
|
+
})).rejects.toMatchObject({ statusCode: 400 });
|
|
863
|
+
});
|
|
864
|
+
|
|
792
865
|
it.each([true, "auto"] as const)("rejects imageGeneration=%s before creating an SSE stream", async (imageGeneration) => {
|
|
793
866
|
await expect(
|
|
794
867
|
QgridFrame.prepareStream({
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
refreshAccessToken,
|
|
38
38
|
RefreshFailedError,
|
|
39
39
|
} from "./oauth";
|
|
40
|
+
import { CODEX_IMAGE_GENERATION_MODEL } from "./qgrid-image-generation";
|
|
40
41
|
import {
|
|
41
42
|
afterQuery,
|
|
42
43
|
beforeQuery,
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
} from "./qgrid-run-lifecycle";
|
|
46
47
|
import {
|
|
47
48
|
buildAndValidateStrictOutputSchema,
|
|
49
|
+
completedDriverFromImageFailure,
|
|
48
50
|
type InternalQueryInput,
|
|
49
51
|
QgridDispatcher,
|
|
50
52
|
} from "./qgrid.dispatcher";
|
|
@@ -145,12 +147,35 @@ function parsePastedOAuthResult(input: string): { code: string; state: string }
|
|
|
145
147
|
}
|
|
146
148
|
|
|
147
149
|
function rejectImageGenerationStream(args: QueryInput): void {
|
|
148
|
-
if (!args.imageGeneration) return;
|
|
150
|
+
if (!args.imageGeneration && args.model !== `openai/${CODEX_IMAGE_GENERATION_MODEL}`) return;
|
|
149
151
|
throw new BadRequestException(
|
|
150
|
-
"qgrid: imageGeneration is not supported with streaming; use query
|
|
152
|
+
"qgrid: imageGeneration is not supported with streaming; use query or generateImage with qgrid.image() instead." as LocalizedString,
|
|
151
153
|
);
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
function rejectInvalidImageQuery(args: QueryInput): void {
|
|
157
|
+
const standalone = args.model === `openai/${CODEX_IMAGE_GENERATION_MODEL}`;
|
|
158
|
+
if (!standalone && args.imageGeneration !== "auto") return;
|
|
159
|
+
if (standalone && args.imageGeneration !== true) {
|
|
160
|
+
throw new BadRequestException(
|
|
161
|
+
"qgrid: the image model requires imageGeneration: true; use qgrid.image() with generateImage." as LocalizedString,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (args.tools?.length || args.jsonSchema !== undefined) {
|
|
165
|
+
throw new BadRequestException(
|
|
166
|
+
"qgrid: automatic and standalone image generation do not support caller tools or structured output." as LocalizedString,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (
|
|
170
|
+
args.imageGenerationOptions?.outputFormat &&
|
|
171
|
+
args.imageGenerationOptions.outputFormat !== "png"
|
|
172
|
+
) {
|
|
173
|
+
throw new BadRequestException(
|
|
174
|
+
"qgrid: automatic and standalone image generation only support PNG output." as LocalizedString,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
154
179
|
// anthropic 은 --json-schema 를 쓰지 않으므로(SON-532) strict 변환·argv 제한 없이 원형의
|
|
155
180
|
// 구문·복잡도만 검증된다 — provider 분기는 buildAndValidateStrictOutputSchema 가 소유한다.
|
|
156
181
|
function rejectInvalidCallerSchemas(args: QueryInput): void {
|
|
@@ -247,6 +272,7 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
247
272
|
|
|
248
273
|
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
249
274
|
async query(args: QueryInput): Promise<QueryOutput> {
|
|
275
|
+
rejectInvalidImageQuery(args);
|
|
250
276
|
rejectInvalidCallerSchemas(args);
|
|
251
277
|
const resolvedArgs = await resolveTokenName(args);
|
|
252
278
|
const disconnect = createHttpDisconnectHandle();
|
|
@@ -260,10 +286,19 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
260
286
|
try {
|
|
261
287
|
result = await QgridDispatcher.query(resolvedArgs, disconnect.signal);
|
|
262
288
|
} catch (e) {
|
|
289
|
+
const completedDriver = completedDriverFromImageFailure(e);
|
|
290
|
+
const completedStep = completedDriver
|
|
291
|
+
? ([{ stepIndex, result: completedDriver }] as const)
|
|
292
|
+
: ([] as const);
|
|
263
293
|
if (disconnect.signal.aborted) {
|
|
264
|
-
await finishRunAborted(requestLogId, resolvedArgs);
|
|
294
|
+
await finishRunAborted(requestLogId, resolvedArgs, ...completedStep);
|
|
265
295
|
} else {
|
|
266
|
-
await finishRunWithError(
|
|
296
|
+
await finishRunWithError(
|
|
297
|
+
requestLogId,
|
|
298
|
+
(e as Error).message,
|
|
299
|
+
resolvedArgs,
|
|
300
|
+
...completedStep,
|
|
301
|
+
);
|
|
267
302
|
}
|
|
268
303
|
throw e;
|
|
269
304
|
}
|
|
@@ -500,8 +535,10 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
500
535
|
// 화면의 목록 필터를 그대로 받아 같은 조건의 합계를 돌려준다 — 목록과 비용이
|
|
501
536
|
// 서로 다른 모수를 보면 사용자에게는 숫자가 어긋난 것으로 읽힌다.
|
|
502
537
|
@api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
|
|
503
|
-
async totalCost(
|
|
504
|
-
|
|
538
|
+
async totalCost(
|
|
539
|
+
params?: RequestLogListParams,
|
|
540
|
+
): Promise<{ usd: number; unknownImageCostCount: number }> {
|
|
541
|
+
return RequestLogModel.totalCost(params);
|
|
505
542
|
}
|
|
506
543
|
|
|
507
544
|
@api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
|
|
@@ -12,10 +12,10 @@ export const QgridTool = z.object({
|
|
|
12
12
|
});
|
|
13
13
|
export type QgridTool = z.infer<typeof QgridTool>;
|
|
14
14
|
|
|
15
|
-
// Observed
|
|
15
|
+
// Observed image response values, distinct from requested generation hints.
|
|
16
16
|
export const ImageGenerationMetadata = z.object({
|
|
17
|
-
route: z.
|
|
18
|
-
model: z.
|
|
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
|
-
|
|
82
|
+
// Subscription-backend hints, not public Images API resolution guarantees.
|
|
83
|
+
export const ImageGenerationSize = z.union([
|
|
84
|
+
z.literal("auto"),
|
|
85
|
+
z
|
|
86
|
+
.string()
|
|
87
|
+
.regex(/^[1-9]\d*x[1-9]\d*$/)
|
|
88
|
+
.refine(
|
|
89
|
+
(size) => size.split("x").every((edge) => Number.isSafeInteger(Number(edge))),
|
|
90
|
+
"Image dimensions must be positive safe integers",
|
|
91
|
+
),
|
|
92
|
+
]);
|
|
83
93
|
export type ImageGenerationSize = z.infer<typeof ImageGenerationSize>;
|
|
84
94
|
|
|
85
95
|
export const ImageGenerationOptions = z
|
|
@@ -134,14 +134,14 @@
|
|
|
134
134
|
"name": "image_cost_usd",
|
|
135
135
|
"type": "integer",
|
|
136
136
|
"nullable": true,
|
|
137
|
-
"desc": "이미지 생성
|
|
137
|
+
"desc": "이미지 생성 비용"
|
|
138
138
|
},
|
|
139
139
|
{
|
|
140
140
|
"name": "image_cost_method",
|
|
141
141
|
"type": "string",
|
|
142
142
|
"length": 100,
|
|
143
143
|
"nullable": true,
|
|
144
|
-
"desc": "이미지 비용
|
|
144
|
+
"desc": "이미지 비용 계산 방식"
|
|
145
145
|
},
|
|
146
146
|
{
|
|
147
147
|
"name": "effort",
|
|
@@ -564,7 +564,7 @@ describe("RequestLogModel cost provenance", () => {
|
|
|
564
564
|
* @param confirmedTotal SUM 쿼리가 돌려줄 micro-USD 합계
|
|
565
565
|
* @param legacyRows 재계산 대상 row 들
|
|
566
566
|
*/
|
|
567
|
-
function mockTotalCostQueries(confirmedTotal: number, legacyRows: unknown[]) {
|
|
567
|
+
function mockTotalCostQueries(confirmedTotal: number, legacyRows: unknown[], unknownImageCostCount = 0) {
|
|
568
568
|
// subset qb 는 SELECT 를 비우고(clear) 집계/축소된 컬럼만 다시 얹는다 — 구현과 같은 체인.
|
|
569
569
|
const sumChain = {
|
|
570
570
|
where: vi.fn(),
|
|
@@ -572,7 +572,7 @@ describe("RequestLogModel cost provenance", () => {
|
|
|
572
572
|
whereRaw: vi.fn(),
|
|
573
573
|
clear: vi.fn(),
|
|
574
574
|
select: vi.fn(),
|
|
575
|
-
first: vi.fn(async () => ({ total: confirmedTotal })),
|
|
575
|
+
first: vi.fn(async () => ({ total: confirmedTotal, unknownImageCostCount })),
|
|
576
576
|
};
|
|
577
577
|
sumChain.where.mockReturnValue(sumChain);
|
|
578
578
|
sumChain.whereIn.mockReturnValue(sumChain);
|
|
@@ -606,7 +606,7 @@ describe("RequestLogModel cost provenance", () => {
|
|
|
606
606
|
it("uses exact stored cost for rows with a cost source", async () => {
|
|
607
607
|
mockTotalCostQueries(3_000, []);
|
|
608
608
|
|
|
609
|
-
await expect(RequestLogModel.totalCost()).resolves.
|
|
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
|
|
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
|
|
758
|
-
expect(rows).toHaveLength(
|
|
759
|
-
//
|
|
760
|
-
await
|
|
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(
|
|
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
|
-
|
|
875
|
+
const usd = legacyRows.reduce((sum, row) => {
|
|
871
876
|
const alreadySummed = Math.max(row.cost_usd ?? 0, 0) / MICRO_USD;
|
|
872
877
|
const usage = normalizedUsageForCost(row);
|
|
873
878
|
if (!usage.model) return sum;
|
|
@@ -884,6 +889,7 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
884
889
|
alreadySummed
|
|
885
890
|
);
|
|
886
891
|
}, storedTotal);
|
|
892
|
+
return { usd, unknownImageCostCount: Number(stored?.unknownImageCostCount ?? 0) };
|
|
887
893
|
}
|
|
888
894
|
|
|
889
895
|
async distinctProjectNames(): Promise<string[]> {
|
|
@@ -320,7 +320,7 @@ Content-Type: application/json
|
|
|
320
320
|
"imageGeneration": false,
|
|
321
321
|
"imageGenerationOptions": {
|
|
322
322
|
"quality": "low",
|
|
323
|
-
"size": "
|
|
323
|
+
"size": "auto",
|
|
324
324
|
"background": "auto",
|
|
325
325
|
"outputFormat": "png"
|
|
326
326
|
}
|
|
@@ -385,7 +385,7 @@ Content-Type: application/json
|
|
|
385
385
|
"imageGeneration": false,
|
|
386
386
|
"imageGenerationOptions": {
|
|
387
387
|
"quality": "low",
|
|
388
|
-
"size": "
|
|
388
|
+
"size": "auto",
|
|
389
389
|
"background": "auto",
|
|
390
390
|
"outputFormat": "png"
|
|
391
391
|
}
|
|
@@ -230,8 +230,8 @@ const entityLabels = {
|
|
|
230
230
|
"entity.RequestLog.ttft_ms": "first gen TTFT(ms)",
|
|
231
231
|
"entity.RequestLog.cost_usd": "API 비용",
|
|
232
232
|
"entity.RequestLog.cost_source": "비용 출처(provider/pricing_table/mixed)",
|
|
233
|
-
"entity.RequestLog.image_cost_usd": "이미지 생성
|
|
234
|
-
"entity.RequestLog.image_cost_method": "이미지 비용
|
|
233
|
+
"entity.RequestLog.image_cost_usd": "이미지 생성 비용",
|
|
234
|
+
"entity.RequestLog.image_cost_method": "이미지 비용 계산 방식",
|
|
235
235
|
"entity.RequestLog.effort": "effort level",
|
|
236
236
|
"entity.RequestLog.history": "inject_items 히스토리",
|
|
237
237
|
"entity.RequestLog.tools": "요청에 장착된 tool 정의 목록",
|
|
@@ -8,8 +8,26 @@ import {
|
|
|
8
8
|
} from "./openai-backend-protocol";
|
|
9
9
|
|
|
10
10
|
describe("OpenAI Codex backend protocol", () => {
|
|
11
|
+
it.each(["png", "jpeg", "webp"])("preserves reported hosted %s generation settings", (output_format) => {
|
|
12
|
+
expect(normalizeOpenAIEvent({ type: "response.output_item.done", item: {
|
|
13
|
+
type: "image_generation_call", result: "aGVsbG8=", output_format,
|
|
14
|
+
size: "841x1870", quality: "low", background: "opaque",
|
|
15
|
+
} })).toMatchObject({ generation: {
|
|
16
|
+
route: "codex-responses", size: "841x1870", quality: "low", background: "opaque",
|
|
17
|
+
} });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("reports PNG byte dimensions instead of a conflicting backend size", () => {
|
|
21
|
+
const bytes = Buffer.alloc(24);
|
|
22
|
+
Buffer.from("89504e470d0a1a0a", "hex").copy(bytes);
|
|
23
|
+
bytes.writeUInt32BE(841, 16);
|
|
24
|
+
bytes.writeUInt32BE(1870, 20);
|
|
25
|
+
expect(normalizeOpenAIEvent({ type: "response.output_item.done", item: {
|
|
26
|
+
type: "image_generation_call", result: bytes.toString("base64"), size: "1152x2560",
|
|
27
|
+
} })).toMatchObject({ generation: { route: "codex-responses", size: "841x1870" } });
|
|
28
|
+
});
|
|
11
29
|
it("distinguishes partial function items from completed arguments", () => {
|
|
12
|
-
const item = { type: "function_call", call_id: "call", name: "
|
|
30
|
+
const item = { type: "function_call", call_id: "call", name: "generate_image", arguments: "{}" };
|
|
13
31
|
expect(normalizeOpenAIEvent({ type: "response.output_item.added", item })).toEqual({ type: "output-item", item, completed: false });
|
|
14
32
|
expect(normalizeOpenAIEvent({ type: "response.output_item.done", item })).toEqual({ type: "output-item", item, completed: true });
|
|
15
33
|
});
|
|
@@ -129,6 +147,28 @@ describe("OpenAI Codex backend protocol", () => {
|
|
|
129
147
|
type: "response.output_item.done",
|
|
130
148
|
item: { type: "image_generation_call", id: "img", result: "aGVsbG8=", revised_prompt: "better" },
|
|
131
149
|
}),
|
|
132
|
-
).toEqual({ type: "image", id: "img", base64: "aGVsbG8=", mimeType: "image/png", revisedPrompt: "better" });
|
|
150
|
+
).toEqual({ type: "image", id: "img", base64: "aGVsbG8=", mimeType: "image/png", revisedPrompt: "better", generation: { route: "codex-responses" } });
|
|
133
151
|
});
|
|
152
|
+
|
|
153
|
+
it("preserves zero image-tool usage without treating it as missing", () => {
|
|
154
|
+
const usage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
|
|
155
|
+
expect(normalizeOpenAIEvent({ type: "response.completed", response: {
|
|
156
|
+
id: "r", tool_usage: { image_gen: usage },
|
|
157
|
+
} })).toEqual({
|
|
158
|
+
type: "completed", responseId: "r",
|
|
159
|
+
imageGeneration: { route: "codex-responses", usage },
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it.each([undefined, null, {}, { input_tokens: "4", output_tokens: 1, total_tokens: 5 }, { input_tokens: -1, output_tokens: 1, total_tokens: 0 }])(
|
|
164
|
+
"ignores missing or malformed image-tool usage: %j", (usage) => {
|
|
165
|
+
expect(normalizeOpenAIEvent({ type: "response.completed", response: {
|
|
166
|
+
id: "r", tool_usage: { image_gen: usage },
|
|
167
|
+
tools: [{ type: "image_generation", model: "gpt-image-2-codex", size: "auto", quality: "auto" }],
|
|
168
|
+
} })).toEqual({
|
|
169
|
+
type: "completed", responseId: "r",
|
|
170
|
+
imageGeneration: { route: "codex-responses", model: "gpt-image-2-codex" },
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
);
|
|
134
174
|
});
|