@cartanova/qgrid-cli 2.9.4 → 2.9.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/qgrid/expired-token-reminder.js +6 -5
- package/bundle/dist/application/qgrid/qgrid-image-generation.js +12 -3
- package/bundle/dist/application/qgrid/qgrid-response-format.js +14 -12
- package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -2
- package/bundle/dist/application/qgrid/qgrid.types.js +44 -4
- package/bundle/dist/application/qgrid/tool-emulation.js +4 -2
- package/bundle/dist/application/setting/setting.model.js +8 -2
- package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +22 -3
- package/bundle/dist/utils/providers/openai/openai-direct-client.js +39 -1
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +47 -21
- package/bundle/dist/utils/providers/openai/openai-image-generation.js +146 -0
- package/bundle/dist/utils/providers/openai/openai-quota.js +11 -3
- package/bundle/dist/utils/providers/openai/openai-refresh.js +7 -5
- package/bundle/dist/utils/slack-notify.js +8 -4
- package/bundle/src/application/qgrid/expired-token-reminder.test.ts +6 -1
- package/bundle/src/application/qgrid/expired-token-reminder.ts +10 -4
- package/bundle/src/application/qgrid/qgrid-image-generation.test.ts +53 -0
- package/bundle/src/application/qgrid/qgrid-image-generation.ts +32 -2
- package/bundle/src/application/qgrid/qgrid-response-format.test.ts +32 -1
- package/bundle/src/application/qgrid/qgrid-response-format.ts +28 -18
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +32 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -1
- package/bundle/src/application/qgrid/qgrid.types.ts +47 -5
- package/bundle/src/application/qgrid/tool-emulation.test.ts +25 -0
- package/bundle/src/application/qgrid/tool-emulation.ts +6 -1
- package/bundle/src/application/setting/setting.model.test.ts +20 -0
- package/bundle/src/application/setting/setting.model.ts +10 -2
- package/bundle/src/application/sonamu.generated.http +4 -2
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +8 -2
- package/bundle/src/utils/providers/openai/openai-backend-protocol.test.ts +23 -0
- package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +27 -2
- package/bundle/src/utils/providers/openai/openai-direct-client.ts +48 -1
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +155 -2
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +54 -19
- package/bundle/src/utils/providers/openai/openai-image-generation.test.ts +152 -0
- package/bundle/src/utils/providers/openai/openai-image-generation.ts +183 -0
- package/bundle/src/utils/providers/openai/openai-quota.test.ts +32 -0
- package/bundle/src/utils/providers/openai/openai-quota.ts +15 -8
- package/bundle/src/utils/providers/openai/openai-refresh.test.ts +18 -0
- package/bundle/src/utils/providers/openai/openai-refresh.ts +11 -4
- package/bundle/src/utils/slack-notify.test.ts +14 -0
- package/bundle/src/utils/slack-notify.ts +19 -3
- package/bundle/web-dist/client/assets/{index-fogJvajr.js → index-BtVmANcA.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-CsY2cqvt.js → logs-BCuaLfPL.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-ChgaLEcD.js → monit-BkGkzl6m.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-BvyiTnOZ.js → routes-DqY8SXcB.js} +1 -1
- package/bundle/web-dist/client/assets/{settings-ZR2J9rWb.js → settings-DbkDAJfh.js} +1 -1
- package/bundle/web-dist/client/assets/{show-CjwO4-So.js → show-Dx5JFzwy.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-Bx-hG1gm.js → tokens-DHahzDQJ.js} +1 -1
- package/bundle/web-dist/client/assets/{use-oauth-login-flow-Bqax_icS.js → use-oauth-login-flow-Cjdnj_8D.js} +1 -1
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +1 -1
- package/package.json +2 -1
- package/skills/qgrid/SKILL.md +8 -1
- package/skills/qgrid/references/ai-sdk-provider-contract.md +4 -2
- package/skills/qgrid/references/decision-rationale.md +2 -2
- package/skills/qgrid/references/openai-codex-runtime.md +9 -1
- package/skills/qgrid/references/request-log-run-lifecycle.md +1 -1
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +1 -1
|
@@ -12,6 +12,7 @@ const STRUCTURED_ENVELOPE_LIMITS = {
|
|
|
12
12
|
maxDepth: 132,
|
|
13
13
|
} as const;
|
|
14
14
|
import {
|
|
15
|
+
type ImageGenerationMetadata,
|
|
15
16
|
type QgridContent,
|
|
16
17
|
type QgridThreadCoord,
|
|
17
18
|
type QgridTool,
|
|
@@ -66,10 +67,12 @@ export class ToolCallEmulationError extends Error {
|
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
//
|
|
70
|
+
// Preserve image bytes and the provider's resolved MIME type.
|
|
70
71
|
interface EmulationImage {
|
|
71
72
|
data: string;
|
|
73
|
+
mediaType?: Extract<QgridContent, { type: "image" }>["mediaType"];
|
|
72
74
|
revisedPrompt?: string | null;
|
|
75
|
+
generation?: ImageGenerationMetadata;
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
// answer 인코딩 종류. envelope 디코더는 한 벌이고 이 값은 answer 브랜치의 타입과
|
|
@@ -211,7 +214,9 @@ function appendImages(content: QgridContent[], images?: EmulationImage[]): Qgrid
|
|
|
211
214
|
(image): QgridContent => ({
|
|
212
215
|
type: "image",
|
|
213
216
|
data: image.data,
|
|
217
|
+
...(image.mediaType ? { mediaType: image.mediaType } : {}),
|
|
214
218
|
revisedPrompt: image.revisedPrompt ?? null,
|
|
219
|
+
...(image.generation ? { generation: image.generation } : {}),
|
|
215
220
|
}),
|
|
216
221
|
),
|
|
217
222
|
];
|
|
@@ -6,6 +6,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
6
6
|
import { resetNativeRunRegistryForTests } from "../qgrid/qgrid-run-lifecycle";
|
|
7
7
|
import { resetServerRestartForTests } from "../../utils/server-restart";
|
|
8
8
|
import { SettingModel } from "./setting.model";
|
|
9
|
+
import * as reminders from "../qgrid/expired-token-reminder";
|
|
10
|
+
import { SlackNotificationError } from "../../utils/slack-notify";
|
|
11
|
+
|
|
12
|
+
describe("SettingModel.triggerExpiryReminder", () => {
|
|
13
|
+
afterEach(() => vi.restoreAllMocks());
|
|
14
|
+
|
|
15
|
+
it("returns the sent count after successful delivery", async () => {
|
|
16
|
+
vi.spyOn(reminders, "sendExpiredTokenReminderNow").mockResolvedValue(3);
|
|
17
|
+
await expect(SettingModel.triggerExpiryReminder()).resolves.toEqual({ sent: 3 });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("returns a readable API error when Slack rejects a manual send", async () => {
|
|
21
|
+
vi.spyOn(reminders, "sendExpiredTokenReminderNow").mockRejectedValue(
|
|
22
|
+
new SlackNotificationError("Slack 알림 발송 실패: not_in_channel"),
|
|
23
|
+
);
|
|
24
|
+
await expect(SettingModel.triggerExpiryReminder()).rejects.toMatchObject({
|
|
25
|
+
statusCode: 400, message: "Slack 알림 발송 실패: not_in_channel",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
9
29
|
|
|
10
30
|
function installContext(headers: Record<string, string>) {
|
|
11
31
|
const raw = Object.assign(new EventEmitter(), {
|
|
@@ -11,9 +11,10 @@ import {
|
|
|
11
11
|
exhaustive,
|
|
12
12
|
} from "sonamu";
|
|
13
13
|
|
|
14
|
-
import { SD } from "../../i18n/sd.generated";
|
|
14
|
+
import { SD, type LocalizedString } from "../../i18n/sd.generated";
|
|
15
15
|
import { detectSupervisor } from "../../utils/process-supervisor";
|
|
16
16
|
import { armServerRestartExit, beginServerRestart } from "../../utils/server-restart";
|
|
17
|
+
import { SlackNotificationError } from "../../utils/slack-notify";
|
|
17
18
|
import { type SettingSubsetKey, type SettingSubsetMapping } from "../sonamu.generated";
|
|
18
19
|
import { settingSubsetQueries, settingLoaderQueries } from "../sonamu.generated.sso";
|
|
19
20
|
import {
|
|
@@ -315,7 +316,14 @@ class SettingModelClass extends BaseModelClass<
|
|
|
315
316
|
// setting.model → expired-token-reminder → token.model 경로가 생겨,
|
|
316
317
|
// token.model 을 mock 하는 테스트가 실제 모듈을 먼저 로드하게 된다.
|
|
317
318
|
const { sendExpiredTokenReminderNow } = await import("../qgrid/expired-token-reminder");
|
|
318
|
-
|
|
319
|
+
try {
|
|
320
|
+
return { sent: await sendExpiredTokenReminderNow() };
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (error instanceof SlackNotificationError) {
|
|
323
|
+
throw new BadRequestException(error.message as LocalizedString);
|
|
324
|
+
}
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
319
327
|
}
|
|
320
328
|
|
|
321
329
|
/**
|
|
@@ -326,7 +326,8 @@ Content-Type: application/json
|
|
|
326
326
|
"imageGeneration": false,
|
|
327
327
|
"imageGenerationOptions": {
|
|
328
328
|
"quality": "low",
|
|
329
|
-
"size": "1024x1024"
|
|
329
|
+
"size": "1024x1024",
|
|
330
|
+
"background": "auto"
|
|
330
331
|
}
|
|
331
332
|
}
|
|
332
333
|
}
|
|
@@ -388,7 +389,8 @@ Content-Type: application/json
|
|
|
388
389
|
"imageGeneration": false,
|
|
389
390
|
"imageGenerationOptions": {
|
|
390
391
|
"quality": "low",
|
|
391
|
-
"size": "1024x1024"
|
|
392
|
+
"size": "1024x1024",
|
|
393
|
+
"background": "auto"
|
|
392
394
|
}
|
|
393
395
|
}
|
|
394
396
|
}
|
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
* MVP 에서는 stream() 만. getRateLimits(), listModels() 는 future.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type ImageGenerationMetadata,
|
|
9
|
+
type ImageGenerationOptions,
|
|
10
|
+
type QgridContent,
|
|
11
|
+
} from "../../../application/qgrid/qgrid.types";
|
|
8
12
|
import { type JsonValue, type TokenUsageBreakdown, type UserInput } from "./provider-types";
|
|
9
13
|
|
|
10
14
|
// qgrid provider 내부 표준 usage. provider 전용 cache write 세부 필드는 여기서 확장해 보존한다.
|
|
@@ -25,8 +29,10 @@ export interface ReuseThreadCoord {
|
|
|
25
29
|
// provider-무관 이미지 결과. OpenAI 경로에서만 채워지며, 상위(qgrid.dispatcher)가
|
|
26
30
|
// content 파트로 전파한다. Anthropic 경로는 이 필드를 채우지 않는다.
|
|
27
31
|
export interface GeneratedImage {
|
|
28
|
-
data: string; // base64
|
|
32
|
+
data: string; // base64 image
|
|
33
|
+
mediaType?: Extract<QgridContent, { type: "image" }>["mediaType"];
|
|
29
34
|
revisedPrompt: string | null;
|
|
35
|
+
generation?: ImageGenerationMetadata;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
// Provider/런타임이 요청 모델 대신 다른 모델로 실제 응답을 생성한 이력.
|
|
@@ -8,6 +8,29 @@ import {
|
|
|
8
8
|
} from "./openai-backend-protocol";
|
|
9
9
|
|
|
10
10
|
describe("OpenAI Codex backend protocol", () => {
|
|
11
|
+
it("uses returned bytes rather than a mismatched format label", () => {
|
|
12
|
+
expect(normalizeOpenAIEvent({ type: "response.output_item.done", item: {
|
|
13
|
+
type: "image_generation_call", result: "UklGRgAAAABXRUJQ", output_format: "png",
|
|
14
|
+
} })).toMatchObject({ mimeType: "image/webp" });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("detects JPEG when the format label is absent", () => {
|
|
18
|
+
expect(normalizeOpenAIEvent({ type: "response.output_item.done", item: {
|
|
19
|
+
type: "image_generation_call", result: "/9j/",
|
|
20
|
+
} })).toMatchObject({ mimeType: "image/jpeg" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it.each([
|
|
24
|
+
["png", "iVBORw0KGgo=", "image/png"],
|
|
25
|
+
["jpeg", "/9j/", "image/jpeg"],
|
|
26
|
+
["webp", "UklGRgAAAABXRUJQ", "image/webp"],
|
|
27
|
+
])("preserves %s output MIME type", (format, result, mimeType) => {
|
|
28
|
+
expect(normalizeOpenAIEvent({
|
|
29
|
+
type: "response.output_item.done",
|
|
30
|
+
item: { type: "image_generation_call", result, output_format: format },
|
|
31
|
+
})).toMatchObject({ type: "image", mimeType });
|
|
32
|
+
});
|
|
33
|
+
|
|
11
34
|
it("preserves raw history and pins Responses controls", () => {
|
|
12
35
|
const history = [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }];
|
|
13
36
|
const request = buildOpenAIResponsesRequest({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
|
|
3
|
+
import { type ImageGenerationMetadata } from "../../../application/qgrid/qgrid.types";
|
|
3
4
|
import { type OpenAIEffort } from "../common/effort";
|
|
4
5
|
|
|
5
6
|
export const CHATGPT_CODEX_RESPONSES_URL =
|
|
@@ -170,7 +171,14 @@ export interface OpenAIUsage {
|
|
|
170
171
|
export type OpenAINormalizedEvent =
|
|
171
172
|
| { type: "created"; responseId?: string }
|
|
172
173
|
| { type: "text-delta"; text: string }
|
|
173
|
-
| {
|
|
174
|
+
| {
|
|
175
|
+
type: "image";
|
|
176
|
+
id?: string;
|
|
177
|
+
base64: string;
|
|
178
|
+
mimeType: "image/png" | "image/jpeg" | "image/webp";
|
|
179
|
+
revisedPrompt?: string;
|
|
180
|
+
generation?: ImageGenerationMetadata;
|
|
181
|
+
}
|
|
174
182
|
| { type: "output-item"; item: OpenAIResponseItem }
|
|
175
183
|
| { type: "completed"; responseId: string; usage?: OpenAIUsage; model?: string }
|
|
176
184
|
| { type: "error"; error: OpenAIProtocolError };
|
|
@@ -201,6 +209,23 @@ function usageFrom(value: unknown): OpenAIUsage | undefined {
|
|
|
201
209
|
};
|
|
202
210
|
}
|
|
203
211
|
|
|
212
|
+
export function imageMimeType(
|
|
213
|
+
base64: string,
|
|
214
|
+
outputFormat?: unknown,
|
|
215
|
+
): "image/png" | "image/jpeg" | "image/webp" {
|
|
216
|
+
// Inspect only the header, without decoding a potentially large image twice.
|
|
217
|
+
const header = Buffer.from(base64.slice(0, 24), "base64");
|
|
218
|
+
if (header.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
|
|
219
|
+
return "image/png";
|
|
220
|
+
if (header[0] === 255 && header[1] === 216 && header[2] === 255) return "image/jpeg";
|
|
221
|
+
if (header.toString("ascii", 0, 4) === "RIFF" && header.toString("ascii", 8, 12) === "WEBP")
|
|
222
|
+
return "image/webp";
|
|
223
|
+
if (outputFormat === "jpeg") return "image/jpeg";
|
|
224
|
+
if (outputFormat === "webp") return "image/webp";
|
|
225
|
+
if (outputFormat === undefined || outputFormat === "png") return "image/png";
|
|
226
|
+
throw new OpenAIProtocolError(`Unsupported image output format: ${String(outputFormat)}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
204
229
|
export function normalizeOpenAIEvent(raw: unknown): OpenAINormalizedEvent | undefined {
|
|
205
230
|
if (!raw || typeof raw !== "object") return undefined;
|
|
206
231
|
const event = raw as Record<string, unknown>;
|
|
@@ -223,7 +248,7 @@ export function normalizeOpenAIEvent(raw: unknown): OpenAINormalizedEvent | unde
|
|
|
223
248
|
type: "image",
|
|
224
249
|
...(typeof item.id === "string" ? { id: item.id } : {}),
|
|
225
250
|
base64: item.result,
|
|
226
|
-
mimeType:
|
|
251
|
+
mimeType: imageMimeType(item.result, item.output_format),
|
|
227
252
|
...(typeof item.revised_prompt === "string"
|
|
228
253
|
? { revisedPrompt: item.revised_prompt }
|
|
229
254
|
: typeof item.revisedPrompt === "string"
|
|
@@ -12,6 +12,10 @@ import {
|
|
|
12
12
|
type OpenAIResponsesOptions,
|
|
13
13
|
type OpenAIResponsesRequest,
|
|
14
14
|
} from "./openai-backend-protocol";
|
|
15
|
+
import {
|
|
16
|
+
buildStandaloneImageRequest,
|
|
17
|
+
readStandaloneImageResponse,
|
|
18
|
+
} from "./openai-image-generation";
|
|
15
19
|
import { normalizeOpenAISSE, type OpenAIEventStream } from "./openai-sse";
|
|
16
20
|
|
|
17
21
|
export interface OpenAIDirectCredentials {
|
|
@@ -446,8 +450,10 @@ export interface OpenAIDirectClientOptions extends OpenAIHttpsTransportOptions {
|
|
|
446
450
|
|
|
447
451
|
export class OpenAIDirectClient {
|
|
448
452
|
private readonly transport: OpenAIResponsesTransport;
|
|
453
|
+
private imageCredentials: OpenAIDirectCredentials;
|
|
449
454
|
|
|
450
|
-
constructor(options: OpenAIDirectClientOptions) {
|
|
455
|
+
constructor(private readonly options: OpenAIDirectClientOptions) {
|
|
456
|
+
this.imageCredentials = options.credentials;
|
|
451
457
|
this.transport =
|
|
452
458
|
options.transport ??
|
|
453
459
|
(options.transportKind === "websocket"
|
|
@@ -456,6 +462,12 @@ export class OpenAIDirectClient {
|
|
|
456
462
|
}
|
|
457
463
|
|
|
458
464
|
responses(options: OpenAIResponsesOptions, signal?: AbortSignal): OpenAIEventStream {
|
|
465
|
+
if (
|
|
466
|
+
typeof options.imageGeneration === "object" &&
|
|
467
|
+
options.imageGeneration.background === "transparent"
|
|
468
|
+
) {
|
|
469
|
+
return this.standaloneImages(options, signal);
|
|
470
|
+
}
|
|
459
471
|
const clientRequestId = crypto.randomUUID();
|
|
460
472
|
const affinityId = options.promptCacheKey
|
|
461
473
|
? cacheAffinityUuid(options.promptCacheKey)
|
|
@@ -472,6 +484,41 @@ export class OpenAIDirectClient {
|
|
|
472
484
|
});
|
|
473
485
|
}
|
|
474
486
|
|
|
487
|
+
private async *standaloneImages(
|
|
488
|
+
options: OpenAIResponsesOptions,
|
|
489
|
+
signal?: AbortSignal,
|
|
490
|
+
): OpenAIEventStream {
|
|
491
|
+
const request = buildStandaloneImageRequest(options);
|
|
492
|
+
const requestId = crypto.randomUUID();
|
|
493
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
494
|
+
signal?.throwIfAborted();
|
|
495
|
+
const response = await (this.options.fetch ?? fetch)(request.url, {
|
|
496
|
+
method: "POST",
|
|
497
|
+
headers: {
|
|
498
|
+
...buildCodexIdentityHeaders(
|
|
499
|
+
this.imageCredentials.accessToken,
|
|
500
|
+
this.imageCredentials.accountId,
|
|
501
|
+
),
|
|
502
|
+
Accept: "application/json",
|
|
503
|
+
"x-codex-image-turn-id": requestId,
|
|
504
|
+
},
|
|
505
|
+
body: JSON.stringify(request.body),
|
|
506
|
+
signal,
|
|
507
|
+
});
|
|
508
|
+
if (response.status === 401 && attempt === 0 && this.options.refreshCredentials) {
|
|
509
|
+
await response.body?.cancel();
|
|
510
|
+
this.imageCredentials = await this.options.refreshCredentials();
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (!response.ok) throw await responseError(response);
|
|
514
|
+
const images = await readStandaloneImageResponse(response);
|
|
515
|
+
signal?.throwIfAborted();
|
|
516
|
+
yield* images;
|
|
517
|
+
yield { type: "completed", responseId: requestId, model: "gpt-image-2" };
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
475
522
|
close(): void {
|
|
476
523
|
this.transport.close?.();
|
|
477
524
|
}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const { refreshMock } = vi.hoisted(() => ({ refreshMock: vi.fn() }));
|
|
4
|
+
vi.mock("./openai-refresh", () => ({ handleChatgptAuthTokensRefresh: refreshMock }));
|
|
5
|
+
|
|
6
|
+
import { QueryInput, QuotaThresholdExceededError } from "../../../application/qgrid/qgrid.types";
|
|
4
7
|
import { type GenerateRequest } from "../common/provider-dispatcher";
|
|
5
8
|
import {
|
|
9
|
+
buildOpenAIResponsesRequest,
|
|
6
10
|
type OpenAINormalizedEvent,
|
|
7
11
|
type OpenAIResponsesOptions,
|
|
8
12
|
} from "./openai-backend-protocol";
|
|
@@ -47,6 +51,155 @@ async function tickTimer(): Promise<void> {
|
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
describe("OpenAIDispatcher direct runtime", () => {
|
|
54
|
+
it.each(["png", "jpeg", "webp"] as const)("forwards %s format and preserves returned MIME", async (outputFormat) => {
|
|
55
|
+
const args = QueryInput.parse({ prompt: "draw", imageGeneration: true, imageGenerationOptions: { outputFormat } });
|
|
56
|
+
let body: ReturnType<typeof buildOpenAIResponsesRequest> | undefined;
|
|
57
|
+
const d = dispatcher((options) => {
|
|
58
|
+
body = buildOpenAIResponsesRequest(options);
|
|
59
|
+
return events({ type: "image", base64: "data", mimeType: `image/${outputFormat}` }, { type: "completed", responseId: "r" });
|
|
60
|
+
});
|
|
61
|
+
await d.onTokenAdded(1, "one", credentials);
|
|
62
|
+
const result = await d.generate(request({ imageGeneration: args.imageGeneration, imageGenerationOptions: args.imageGenerationOptions }));
|
|
63
|
+
expect(body?.tools).toEqual([{ type: "image_generation", output_format: outputFormat }]);
|
|
64
|
+
expect(result.images).toMatchObject([{ mediaType: `image/${outputFormat}` }]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("rejects unsupported output formats and non-PNG transparent requests", () => {
|
|
68
|
+
for (const imageGenerationOptions of [{ outputFormat: "gif" }, { outputFormat: "webp", background: "transparent" }, { outputFormat: "jpeg", background: "transparent" }]) {
|
|
69
|
+
expect(QueryInput.safeParse({ prompt: "draw", imageGeneration: true, imageGenerationOptions }).success).toBe(false);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
beforeEach(() => { refreshMock.mockReset(); });
|
|
74
|
+
|
|
75
|
+
it("refreshes a quota 401 once, shares the recovery, and uses the new credentials", async () => {
|
|
76
|
+
const fetchMock = vi.fn()
|
|
77
|
+
.mockResolvedValueOnce(new Response("", { status: 401 }))
|
|
78
|
+
.mockResolvedValueOnce(new Response(JSON.stringify({
|
|
79
|
+
rate_limits: { primary_window: { used_percent: 20 } },
|
|
80
|
+
})));
|
|
81
|
+
refreshMock.mockResolvedValue({ accessToken: "new-access", chatgptAccountId: "acct" });
|
|
82
|
+
const factory = vi.fn(() => ({ responses: () => events({ type: "completed", responseId: "r" }) }));
|
|
83
|
+
const d = new OpenAIDispatcher(config(), { fetch: fetchMock, clientFactory: factory });
|
|
84
|
+
await d.onTokenAdded(1, "one", credentials);
|
|
85
|
+
const [first, second] = await Promise.all([
|
|
86
|
+
d.getRateLimitsByTokenId(1), d.getRateLimitsByTokenId(1),
|
|
87
|
+
]);
|
|
88
|
+
expect(first).toEqual(second);
|
|
89
|
+
expect(first.data.rateLimits.primary?.usedPercent).toBe(20);
|
|
90
|
+
expect(refreshMock).toHaveBeenCalledExactlyOnceWith(1);
|
|
91
|
+
expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("Authorization")).toBe("Bearer new-access");
|
|
92
|
+
await d.generate(request());
|
|
93
|
+
expect(factory).toHaveBeenCalledWith(expect.objectContaining({
|
|
94
|
+
credentials: { accessToken: "new-access", accountId: "acct" },
|
|
95
|
+
}), 1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("does not loop when quota still returns 401 after refresh", async () => {
|
|
99
|
+
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response("", { status: 401 })));
|
|
100
|
+
refreshMock.mockResolvedValue({ accessToken: "new-access", chatgptAccountId: "acct" });
|
|
101
|
+
const d = new OpenAIDispatcher(config(), { fetch: fetchMock });
|
|
102
|
+
await d.onTokenAdded(1, "one", credentials);
|
|
103
|
+
await expect(d.getRateLimitsByTokenId(1)).rejects.toThrow("HTTP 401");
|
|
104
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
105
|
+
expect(refreshMock).toHaveBeenCalledTimes(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it.each([403, 429, 500, 503])("does not refresh credentials for quota HTTP %s", async (status) => {
|
|
109
|
+
const d = new OpenAIDispatcher(config(), {
|
|
110
|
+
fetch: vi.fn().mockResolvedValue(new Response("", { status })),
|
|
111
|
+
});
|
|
112
|
+
await d.onTokenAdded(1, "one", credentials);
|
|
113
|
+
await expect(d.getRateLimitsByTokenId(1)).rejects.toThrow(`HTTP ${status}`);
|
|
114
|
+
expect(refreshMock).not.toHaveBeenCalled();
|
|
115
|
+
expect(d.tokenCount).toBe(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("propagates refresh failure instead of retrying rejected credentials", async () => {
|
|
119
|
+
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 401 }));
|
|
120
|
+
refreshMock.mockRejectedValue(new Error("OpenAI refresh failed: 401 refresh_token_reused"));
|
|
121
|
+
const d = new OpenAIDispatcher(config(), { fetch: fetchMock });
|
|
122
|
+
await d.onTokenAdded(1, "one", credentials);
|
|
123
|
+
await expect(d.getRateLimitsByTokenId(1)).rejects.toThrow("refresh_token_reused");
|
|
124
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("uses a newer subscriber credential and never caches an old generation's lookup", async () => {
|
|
128
|
+
let finishRefresh!: (value: { accessToken: string; chatgptAccountId: string }) => void;
|
|
129
|
+
refreshMock.mockImplementation(() => new Promise((resolve) => { finishRefresh = resolve; }));
|
|
130
|
+
const quota = (percent: number) => new Response(JSON.stringify({
|
|
131
|
+
rate_limits: { primary_window: { used_percent: percent } },
|
|
132
|
+
}));
|
|
133
|
+
const fetchMock = vi.fn()
|
|
134
|
+
.mockResolvedValueOnce(new Response("", { status: 401 }))
|
|
135
|
+
.mockResolvedValueOnce(quota(20))
|
|
136
|
+
.mockResolvedValueOnce(quota(30));
|
|
137
|
+
const d = new OpenAIDispatcher(config(), { fetch: fetchMock });
|
|
138
|
+
await d.onTokenAdded(1, "one", credentials);
|
|
139
|
+
const pending = d.getRateLimitsByTokenId(1);
|
|
140
|
+
await vi.waitFor(() => expect(refreshMock).toHaveBeenCalledTimes(1));
|
|
141
|
+
await d.onTokenUpdated(1, "one", { ...credentials, accessToken: "subscriber-access" });
|
|
142
|
+
finishRefresh({ accessToken: "older-refresh-access", chatgptAccountId: "acct" });
|
|
143
|
+
await pending;
|
|
144
|
+
expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("Authorization")).toBe("Bearer subscriber-access");
|
|
145
|
+
expect((await d.getRateLimitsByTokenId(1)).data.rateLimits.primary?.usedPercent).toBe(30);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("does not share pending quota requests across subscriber generations", async () => {
|
|
149
|
+
let finishOld!: (value: Response) => void;
|
|
150
|
+
const quota = (percent: number) => new Response(JSON.stringify({
|
|
151
|
+
rate_limits: { primary_window: { used_percent: percent } },
|
|
152
|
+
}));
|
|
153
|
+
const fetchMock = vi.fn()
|
|
154
|
+
.mockImplementationOnce(() => new Promise((resolve) => { finishOld = resolve; }))
|
|
155
|
+
.mockResolvedValueOnce(quota(10));
|
|
156
|
+
const d = new OpenAIDispatcher(config(), { fetch: fetchMock });
|
|
157
|
+
await d.onTokenAdded(1, "one", credentials);
|
|
158
|
+
const old = d.getRateLimitsByTokenId(1);
|
|
159
|
+
await d.onTokenUpdated(1, "one", { ...credentials, accessToken: "new" });
|
|
160
|
+
const current = d.getRateLimitsByTokenId(1);
|
|
161
|
+
finishOld(quota(90));
|
|
162
|
+
await old;
|
|
163
|
+
expect((await current).data.rateLimits.primary?.usedPercent).toBe(10);
|
|
164
|
+
expect((await d.getRateLimitsByTokenId(1)).data.rateLimits.primary?.usedPercent).toBe(10);
|
|
165
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it.each(["transparent", "opaque", "auto"] as const)(
|
|
169
|
+
"preserves %s background through validation and Responses serialization",
|
|
170
|
+
async (background) => {
|
|
171
|
+
const args = QueryInput.parse({
|
|
172
|
+
model: "openai/gpt-test",
|
|
173
|
+
prompt: "an isolated red circle",
|
|
174
|
+
imageGeneration: true,
|
|
175
|
+
imageGenerationOptions: { background, quality: "high", size: "1024x1024" },
|
|
176
|
+
});
|
|
177
|
+
let body: ReturnType<typeof buildOpenAIResponsesRequest> | undefined;
|
|
178
|
+
const d = dispatcher((options) => {
|
|
179
|
+
body = buildOpenAIResponsesRequest(options);
|
|
180
|
+
return events(
|
|
181
|
+
{ type: "image", id: "i", base64: "png", mimeType: "image/png" },
|
|
182
|
+
{ type: "completed", responseId: "r" },
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
await d.onTokenAdded(1, "one", credentials);
|
|
186
|
+
await d.generate(request({
|
|
187
|
+
imageGeneration: args.imageGeneration,
|
|
188
|
+
imageGenerationOptions: args.imageGenerationOptions,
|
|
189
|
+
}));
|
|
190
|
+
expect(body?.tools).toEqual([{
|
|
191
|
+
type: "image_generation", background, quality: "high", size: "1024x1024",
|
|
192
|
+
}]);
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
it("rejects unsupported image backgrounds", () => {
|
|
197
|
+
expect(QueryInput.safeParse({
|
|
198
|
+
prompt: "a circle", imageGeneration: true,
|
|
199
|
+
imageGenerationOptions: { background: "green" },
|
|
200
|
+
}).success).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
|
|
50
203
|
it("passes the resolved transport to the injectable client factory", async () => {
|
|
51
204
|
const factory = vi.fn(
|
|
52
205
|
(_options: import("./openai-direct-client").OpenAIDirectClientOptions) => ({
|
|
@@ -112,6 +112,7 @@ function asReasoning(req: GenerateRequest): OpenAIResponsesOptions["reasoning"]
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
function requestOptions(req: GenerateRequest): OpenAIResponsesOptions {
|
|
115
|
+
const { outputFormat, ...imageControls } = req.imageGenerationOptions ?? {};
|
|
115
116
|
return {
|
|
116
117
|
model: req.model ?? "",
|
|
117
118
|
...(req.systemPrompt ? { instructions: req.systemPrompt } : {}),
|
|
@@ -124,7 +125,14 @@ function requestOptions(req: GenerateRequest): OpenAIResponsesOptions {
|
|
|
124
125
|
...(req.promptCacheKey ? { promptCacheKey: req.promptCacheKey } : {}),
|
|
125
126
|
...(req.outputSchema ? { outputSchema: { schema: req.outputSchema as JsonValue } } : {}),
|
|
126
127
|
...(req.imageGeneration
|
|
127
|
-
? {
|
|
128
|
+
? {
|
|
129
|
+
imageGeneration: req.imageGenerationOptions
|
|
130
|
+
? {
|
|
131
|
+
...imageControls,
|
|
132
|
+
...(outputFormat ? { output_format: outputFormat } : {}),
|
|
133
|
+
}
|
|
134
|
+
: true,
|
|
135
|
+
}
|
|
128
136
|
: {}),
|
|
129
137
|
};
|
|
130
138
|
}
|
|
@@ -143,7 +151,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
143
151
|
readonly rateLimitsCache = new Map<number, OpenAIRateLimitsWithMeta & { generation: number }>();
|
|
144
152
|
private readonly pendingRateLimits = new Map<
|
|
145
153
|
number,
|
|
146
|
-
|
|
154
|
+
{ token: TokenMetadata; generation: number; promise: Promise<OpenAIRateLimitsWithMeta> }
|
|
147
155
|
>();
|
|
148
156
|
private readonly clients = new Map<number, ClientEntry>();
|
|
149
157
|
private readonly retiredClients = new Set<ClientEntry>();
|
|
@@ -318,7 +326,12 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
318
326
|
onDelta?.(event.text);
|
|
319
327
|
} else if (event.type === "image") {
|
|
320
328
|
imageAttempted = true;
|
|
321
|
-
images.push({
|
|
329
|
+
images.push({
|
|
330
|
+
data: event.base64,
|
|
331
|
+
mediaType: event.mimeType,
|
|
332
|
+
revisedPrompt: event.revisedPrompt ?? null,
|
|
333
|
+
...(event.generation ? { generation: event.generation } : {}),
|
|
334
|
+
});
|
|
322
335
|
} else if (event.type === "output-item" && event.item.type === "image_generation_call") {
|
|
323
336
|
imageAttempted = true;
|
|
324
337
|
} else if (event.type === "completed" && event.usage) {
|
|
@@ -356,6 +369,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
356
369
|
durationMs: Date.now() - startedAt,
|
|
357
370
|
ttftMs: firstDeltaAt === undefined ? null : firstDeltaAt - startedAt,
|
|
358
371
|
model: servingModel,
|
|
372
|
+
...(servingModel !== req.model ? { requestedModel: req.model } : {}),
|
|
359
373
|
threadCoord: { workerId: selection.tokenId, threadId: req.promptCacheKey ?? "", epoch: -1 },
|
|
360
374
|
...(images.length ? { images } : {}),
|
|
361
375
|
};
|
|
@@ -378,17 +392,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
378
392
|
},
|
|
379
393
|
transportKind: this.transportKind,
|
|
380
394
|
fetch: this.fetchImpl,
|
|
381
|
-
refreshCredentials:
|
|
382
|
-
const refreshed = await handleChatgptAuthTokensRefresh(selection.tokenId);
|
|
383
|
-
const current = this.tokenMetadata.get(selection.tokenId);
|
|
384
|
-
if (current)
|
|
385
|
-
current.credentials = {
|
|
386
|
-
...current.credentials,
|
|
387
|
-
accessToken: refreshed.accessToken,
|
|
388
|
-
accountId: refreshed.chatgptAccountId,
|
|
389
|
-
};
|
|
390
|
-
return { accessToken: refreshed.accessToken, accountId: refreshed.chatgptAccountId };
|
|
391
|
-
},
|
|
395
|
+
refreshCredentials: () => this.refreshCredentials(selection.tokenId),
|
|
392
396
|
},
|
|
393
397
|
selection.tokenId,
|
|
394
398
|
);
|
|
@@ -519,6 +523,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
519
523
|
): Promise<OpenAIRateLimitsWithMeta> {
|
|
520
524
|
const token = this.tokenMetadata.get(tokenId);
|
|
521
525
|
if (!token) throw new Error(`openai token ${tokenId} not found`);
|
|
526
|
+
const generation = token.generation;
|
|
522
527
|
const cached = this.rateLimitsCache.get(tokenId);
|
|
523
528
|
if (
|
|
524
529
|
cached &&
|
|
@@ -531,23 +536,53 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
531
536
|
// 조회가 몰릴 수 있다 — 진행 중인 fetch 를 공유한다. 최초 호출자의 abort 로
|
|
532
537
|
// 공유 조회가 실패해도 소비처(isQuotaEligible)가 fail-open 으로 처리한다.
|
|
533
538
|
const pending = this.pendingRateLimits.get(tokenId);
|
|
534
|
-
if (pending) return pending;
|
|
539
|
+
if (pending?.token === token && pending.generation === generation) return pending.promise;
|
|
535
540
|
const fetchPromise = (async () => {
|
|
536
541
|
const result = await readOpenAIQuotaUsage({
|
|
537
542
|
credentials: token.credentials,
|
|
543
|
+
refreshCredentials: () => this.refreshCredentials(tokenId),
|
|
538
544
|
fetch: this.fetchImpl,
|
|
539
545
|
...(signal ? { signal } : {}),
|
|
540
546
|
});
|
|
541
547
|
if (result.kind === "lookup_failed") throw new Error(result.reason);
|
|
542
548
|
if (!result.raw) throw new Error("OpenAI quota response missing raw rate limits");
|
|
543
|
-
const entry = { data: result.raw, cachedAt: Date.now(), generation
|
|
544
|
-
if (this.tokenMetadata.get(tokenId) === token
|
|
549
|
+
const entry = { data: result.raw, cachedAt: Date.now(), generation };
|
|
550
|
+
if (this.tokenMetadata.get(tokenId) === token && token.generation === generation) {
|
|
551
|
+
this.rateLimitsCache.set(tokenId, entry);
|
|
552
|
+
}
|
|
545
553
|
return entry;
|
|
546
|
-
})().finally(() =>
|
|
547
|
-
|
|
554
|
+
})().finally(() => {
|
|
555
|
+
if (this.pendingRateLimits.get(tokenId)?.promise === fetchPromise) {
|
|
556
|
+
this.pendingRateLimits.delete(tokenId);
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
this.pendingRateLimits.set(tokenId, { token, generation, promise: fetchPromise });
|
|
548
560
|
return fetchPromise;
|
|
549
561
|
}
|
|
550
562
|
|
|
563
|
+
private async refreshCredentials(tokenId: number) {
|
|
564
|
+
const previous = this.tokenMetadata.get(tokenId);
|
|
565
|
+
const generation = previous?.generation;
|
|
566
|
+
const refreshed = await handleChatgptAuthTokensRefresh(tokenId);
|
|
567
|
+
const credentials = {
|
|
568
|
+
accessToken: refreshed.accessToken,
|
|
569
|
+
accountId: refreshed.chatgptAccountId,
|
|
570
|
+
};
|
|
571
|
+
const current = this.tokenMetadata.get(tokenId);
|
|
572
|
+
if (!current?.active) throw new Error(`openai token ${tokenId} is no longer active`);
|
|
573
|
+
// A subscriber update may already contain newer credentials. Never overwrite it
|
|
574
|
+
// with the result of a refresh that started against an older generation.
|
|
575
|
+
if (current === previous && current.generation === generation) {
|
|
576
|
+
current.credentials = { ...current.credentials, ...credentials };
|
|
577
|
+
this.retireClient(tokenId);
|
|
578
|
+
this.invalidateRateLimitsCache(tokenId);
|
|
579
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
accessToken: current.credentials.accessToken,
|
|
582
|
+
accountId: current.credentials.accountId,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
551
586
|
private async isQuotaEligible(
|
|
552
587
|
id: number,
|
|
553
588
|
token: TokenMetadata,
|