@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.
Files changed (59) hide show
  1. package/bundle/dist/application/qgrid/expired-token-reminder.js +6 -5
  2. package/bundle/dist/application/qgrid/qgrid-image-generation.js +12 -3
  3. package/bundle/dist/application/qgrid/qgrid-response-format.js +14 -12
  4. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -2
  5. package/bundle/dist/application/qgrid/qgrid.types.js +44 -4
  6. package/bundle/dist/application/qgrid/tool-emulation.js +4 -2
  7. package/bundle/dist/application/setting/setting.model.js +8 -2
  8. package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +22 -3
  9. package/bundle/dist/utils/providers/openai/openai-direct-client.js +39 -1
  10. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +47 -21
  11. package/bundle/dist/utils/providers/openai/openai-image-generation.js +146 -0
  12. package/bundle/dist/utils/providers/openai/openai-quota.js +11 -3
  13. package/bundle/dist/utils/providers/openai/openai-refresh.js +7 -5
  14. package/bundle/dist/utils/slack-notify.js +8 -4
  15. package/bundle/src/application/qgrid/expired-token-reminder.test.ts +6 -1
  16. package/bundle/src/application/qgrid/expired-token-reminder.ts +10 -4
  17. package/bundle/src/application/qgrid/qgrid-image-generation.test.ts +53 -0
  18. package/bundle/src/application/qgrid/qgrid-image-generation.ts +32 -2
  19. package/bundle/src/application/qgrid/qgrid-response-format.test.ts +32 -1
  20. package/bundle/src/application/qgrid/qgrid-response-format.ts +28 -18
  21. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +32 -0
  22. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -1
  23. package/bundle/src/application/qgrid/qgrid.types.ts +47 -5
  24. package/bundle/src/application/qgrid/tool-emulation.test.ts +25 -0
  25. package/bundle/src/application/qgrid/tool-emulation.ts +6 -1
  26. package/bundle/src/application/setting/setting.model.test.ts +20 -0
  27. package/bundle/src/application/setting/setting.model.ts +10 -2
  28. package/bundle/src/application/sonamu.generated.http +4 -2
  29. package/bundle/src/utils/providers/common/provider-dispatcher.ts +8 -2
  30. package/bundle/src/utils/providers/openai/openai-backend-protocol.test.ts +23 -0
  31. package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +27 -2
  32. package/bundle/src/utils/providers/openai/openai-direct-client.ts +48 -1
  33. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +155 -2
  34. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +54 -19
  35. package/bundle/src/utils/providers/openai/openai-image-generation.test.ts +152 -0
  36. package/bundle/src/utils/providers/openai/openai-image-generation.ts +183 -0
  37. package/bundle/src/utils/providers/openai/openai-quota.test.ts +32 -0
  38. package/bundle/src/utils/providers/openai/openai-quota.ts +15 -8
  39. package/bundle/src/utils/providers/openai/openai-refresh.test.ts +18 -0
  40. package/bundle/src/utils/providers/openai/openai-refresh.ts +11 -4
  41. package/bundle/src/utils/slack-notify.test.ts +14 -0
  42. package/bundle/src/utils/slack-notify.ts +19 -3
  43. package/bundle/web-dist/client/assets/{index-fogJvajr.js → index-BtVmANcA.js} +3 -3
  44. package/bundle/web-dist/client/assets/{logs-CsY2cqvt.js → logs-BCuaLfPL.js} +1 -1
  45. package/bundle/web-dist/client/assets/{monit-ChgaLEcD.js → monit-BkGkzl6m.js} +1 -1
  46. package/bundle/web-dist/client/assets/{routes-BvyiTnOZ.js → routes-DqY8SXcB.js} +1 -1
  47. package/bundle/web-dist/client/assets/{settings-ZR2J9rWb.js → settings-DbkDAJfh.js} +1 -1
  48. package/bundle/web-dist/client/assets/{show-CjwO4-So.js → show-Dx5JFzwy.js} +1 -1
  49. package/bundle/web-dist/client/assets/{tokens-Bx-hG1gm.js → tokens-DHahzDQJ.js} +1 -1
  50. package/bundle/web-dist/client/assets/{use-oauth-login-flow-Bqax_icS.js → use-oauth-login-flow-Cjdnj_8D.js} +1 -1
  51. package/bundle/web-dist/client/index.html +1 -1
  52. package/bundle/web-dist/server/entry-server.generated.js +1 -1
  53. package/package.json +2 -1
  54. package/skills/qgrid/SKILL.md +8 -1
  55. package/skills/qgrid/references/ai-sdk-provider-contract.md +4 -2
  56. package/skills/qgrid/references/decision-rationale.md +2 -2
  57. package/skills/qgrid/references/openai-codex-runtime.md +9 -1
  58. package/skills/qgrid/references/request-log-run-lifecycle.md +1 -1
  59. 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
- // dispatcher 가 넘기는 이미지 결과. qgrid 는 base64 payload 만 전달하고 포맷은 보장하지 않는다.
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
- return { sent: await sendExpiredTokenReminderNow() };
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 { type ImageGenerationOptions } from "../../../application/qgrid/qgrid.types";
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 PNG
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
- | { type: "image"; id?: string; base64: string; mimeType: "image/png"; revisedPrompt?: string }
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: "image/png",
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
- import { QuotaThresholdExceededError } from "../../../application/qgrid/qgrid.types";
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
- ? { imageGeneration: req.imageGenerationOptions ? { ...req.imageGenerationOptions } : true }
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
- Promise<OpenAIRateLimitsWithMeta & { generation: number }>
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({ data: event.base64, revisedPrompt: event.revisedPrompt ?? null });
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: async () => {
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: token.generation };
544
- if (this.tokenMetadata.get(tokenId) === token) this.rateLimitsCache.set(tokenId, entry);
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(() => this.pendingRateLimits.delete(tokenId));
547
- this.pendingRateLimits.set(tokenId, fetchPromise);
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,