@cartanova/qgrid-cli 2.9.2 → 2.9.5

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 (77) 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 +8 -6
  4. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -2
  5. package/bundle/dist/application/qgrid/qgrid.frame.js +4 -3
  6. package/bundle/dist/application/qgrid/qgrid.types.js +31 -4
  7. package/bundle/dist/application/qgrid/token-death.js +6 -22
  8. package/bundle/dist/application/qgrid/token-subscriber.js +2 -1
  9. package/bundle/dist/application/qgrid/tool-emulation.js +3 -2
  10. package/bundle/dist/application/setting/setting.model.js +8 -2
  11. package/bundle/dist/application/token/token.model.js +22 -17
  12. package/bundle/dist/utils/providers/common/model-cost.js +21 -13
  13. package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +1 -1
  14. package/bundle/dist/utils/providers/openai/openai-direct-client.js +39 -1
  15. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +41 -20
  16. package/bundle/dist/utils/providers/openai/openai-image-generation.js +145 -0
  17. package/bundle/dist/utils/providers/openai/openai-quota.js +11 -3
  18. package/bundle/dist/utils/providers/openai/openai-refresh.js +7 -5
  19. package/bundle/dist/utils/slack-notify.js +8 -4
  20. package/bundle/src/application/qgrid/expired-token-reminder.test.ts +6 -1
  21. package/bundle/src/application/qgrid/expired-token-reminder.ts +10 -4
  22. package/bundle/src/application/qgrid/qgrid-image-generation.test.ts +53 -0
  23. package/bundle/src/application/qgrid/qgrid-image-generation.ts +32 -2
  24. package/bundle/src/application/qgrid/qgrid-response-format.test.ts +26 -0
  25. package/bundle/src/application/qgrid/qgrid-response-format.ts +21 -13
  26. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +32 -0
  27. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -1
  28. package/bundle/src/application/qgrid/qgrid.frame.test.ts +20 -0
  29. package/bundle/src/application/qgrid/qgrid.frame.ts +3 -2
  30. package/bundle/src/application/qgrid/qgrid.types.ts +32 -0
  31. package/bundle/src/application/qgrid/token-death.test.ts +11 -10
  32. package/bundle/src/application/qgrid/token-death.ts +9 -21
  33. package/bundle/src/application/qgrid/token-subscriber.test.ts +5 -1
  34. package/bundle/src/application/qgrid/token-subscriber.ts +1 -0
  35. package/bundle/src/application/qgrid/tool-emulation.test.ts +18 -0
  36. package/bundle/src/application/qgrid/tool-emulation.ts +3 -0
  37. package/bundle/src/application/setting/setting.model.test.ts +20 -0
  38. package/bundle/src/application/setting/setting.model.ts +10 -2
  39. package/bundle/src/application/sonamu.generated.http +4 -2
  40. package/bundle/src/application/token/token.model.test.ts +24 -5
  41. package/bundle/src/application/token/token.model.ts +30 -23
  42. package/bundle/src/utils/providers/common/effort.test.ts +8 -0
  43. package/bundle/src/utils/providers/common/model-cost.test.ts +25 -12
  44. package/bundle/src/utils/providers/common/model-cost.ts +28 -14
  45. package/bundle/src/utils/providers/common/provider-dispatcher.ts +5 -1
  46. package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +9 -1
  47. package/bundle/src/utils/providers/openai/openai-direct-client.ts +48 -1
  48. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +137 -2
  49. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +44 -18
  50. package/bundle/src/utils/providers/openai/openai-image-generation.test.ts +135 -0
  51. package/bundle/src/utils/providers/openai/openai-image-generation.ts +177 -0
  52. package/bundle/src/utils/providers/openai/openai-quota.test.ts +32 -0
  53. package/bundle/src/utils/providers/openai/openai-quota.ts +15 -8
  54. package/bundle/src/utils/providers/openai/openai-refresh.test.ts +18 -0
  55. package/bundle/src/utils/providers/openai/openai-refresh.ts +11 -4
  56. package/bundle/src/utils/slack-notify.test.ts +14 -0
  57. package/bundle/src/utils/slack-notify.ts +19 -3
  58. package/bundle/web-dist/client/assets/{index-DH1n8kgG.js → index-BcCytvFf.js} +3 -3
  59. package/bundle/web-dist/client/assets/{logs-D36O-NET.js → logs-BkOilk5x.js} +1 -1
  60. package/bundle/web-dist/client/assets/{monit-DhZAaz9K.js → monit-8oCSLCdL.js} +1 -1
  61. package/bundle/web-dist/client/assets/{routes-C7q1CsSv.js → routes-DFIF4IoW.js} +2 -2
  62. package/bundle/web-dist/client/assets/{settings-M_jfoIAe.js → settings-nUI-DR3U.js} +1 -1
  63. package/bundle/web-dist/client/assets/{show-CORjMGQs.js → show-Bzx6Xkoc.js} +1 -1
  64. package/bundle/web-dist/client/assets/{tokens-C7-j4VXD.js → tokens-DFWCL7Ts.js} +1 -1
  65. package/bundle/web-dist/client/assets/{use-oauth-login-flow-Fi62xbBc.js → use-oauth-login-flow-B5MAjpVc.js} +1 -1
  66. package/bundle/web-dist/client/index.html +1 -1
  67. package/bundle/web-dist/server/assets/{routes-CxMHXfdH.js → routes-B408iqh1.js} +29 -14
  68. package/bundle/web-dist/server/entry-server.generated.js +3 -2
  69. package/package.json +2 -1
  70. package/skills/qgrid/SKILL.md +2 -1
  71. package/skills/qgrid/references/ai-sdk-provider-contract.md +2 -2
  72. package/skills/qgrid/references/decision-rationale.md +2 -2
  73. package/skills/qgrid/references/openai-codex-runtime.md +79 -0
  74. package/skills/qgrid/references/provider-runtime-differences.md +1 -1
  75. package/skills/qgrid/references/request-log-run-lifecycle.md +1 -1
  76. package/skills/qgrid/references/sonamu-api-web-flow.md +1 -1
  77. package/skills/qgrid/references/token-auth-quota-lifecycle.md +4 -3
@@ -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,136 @@ async function tickTimer(): Promise<void> {
47
51
  }
48
52
 
49
53
  describe("OpenAIDispatcher direct runtime", () => {
54
+ beforeEach(() => { refreshMock.mockReset(); });
55
+
56
+ it("refreshes a quota 401 once, shares the recovery, and uses the new credentials", async () => {
57
+ const fetchMock = vi.fn()
58
+ .mockResolvedValueOnce(new Response("", { status: 401 }))
59
+ .mockResolvedValueOnce(new Response(JSON.stringify({
60
+ rate_limits: { primary_window: { used_percent: 20 } },
61
+ })));
62
+ refreshMock.mockResolvedValue({ accessToken: "new-access", chatgptAccountId: "acct" });
63
+ const factory = vi.fn(() => ({ responses: () => events({ type: "completed", responseId: "r" }) }));
64
+ const d = new OpenAIDispatcher(config(), { fetch: fetchMock, clientFactory: factory });
65
+ await d.onTokenAdded(1, "one", credentials);
66
+ const [first, second] = await Promise.all([
67
+ d.getRateLimitsByTokenId(1), d.getRateLimitsByTokenId(1),
68
+ ]);
69
+ expect(first).toEqual(second);
70
+ expect(first.data.rateLimits.primary?.usedPercent).toBe(20);
71
+ expect(refreshMock).toHaveBeenCalledExactlyOnceWith(1);
72
+ expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("Authorization")).toBe("Bearer new-access");
73
+ await d.generate(request());
74
+ expect(factory).toHaveBeenCalledWith(expect.objectContaining({
75
+ credentials: { accessToken: "new-access", accountId: "acct" },
76
+ }), 1);
77
+ });
78
+
79
+ it("does not loop when quota still returns 401 after refresh", async () => {
80
+ const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response("", { status: 401 })));
81
+ refreshMock.mockResolvedValue({ accessToken: "new-access", chatgptAccountId: "acct" });
82
+ const d = new OpenAIDispatcher(config(), { fetch: fetchMock });
83
+ await d.onTokenAdded(1, "one", credentials);
84
+ await expect(d.getRateLimitsByTokenId(1)).rejects.toThrow("HTTP 401");
85
+ expect(fetchMock).toHaveBeenCalledTimes(2);
86
+ expect(refreshMock).toHaveBeenCalledTimes(1);
87
+ });
88
+
89
+ it.each([403, 429, 500, 503])("does not refresh credentials for quota HTTP %s", async (status) => {
90
+ const d = new OpenAIDispatcher(config(), {
91
+ fetch: vi.fn().mockResolvedValue(new Response("", { status })),
92
+ });
93
+ await d.onTokenAdded(1, "one", credentials);
94
+ await expect(d.getRateLimitsByTokenId(1)).rejects.toThrow(`HTTP ${status}`);
95
+ expect(refreshMock).not.toHaveBeenCalled();
96
+ expect(d.tokenCount).toBe(1);
97
+ });
98
+
99
+ it("propagates refresh failure instead of retrying rejected credentials", async () => {
100
+ const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 401 }));
101
+ refreshMock.mockRejectedValue(new Error("OpenAI refresh failed: 401 refresh_token_reused"));
102
+ const d = new OpenAIDispatcher(config(), { fetch: fetchMock });
103
+ await d.onTokenAdded(1, "one", credentials);
104
+ await expect(d.getRateLimitsByTokenId(1)).rejects.toThrow("refresh_token_reused");
105
+ expect(fetchMock).toHaveBeenCalledTimes(1);
106
+ });
107
+
108
+ it("uses a newer subscriber credential and never caches an old generation's lookup", async () => {
109
+ let finishRefresh!: (value: { accessToken: string; chatgptAccountId: string }) => void;
110
+ refreshMock.mockImplementation(() => new Promise((resolve) => { finishRefresh = resolve; }));
111
+ const quota = (percent: number) => new Response(JSON.stringify({
112
+ rate_limits: { primary_window: { used_percent: percent } },
113
+ }));
114
+ const fetchMock = vi.fn()
115
+ .mockResolvedValueOnce(new Response("", { status: 401 }))
116
+ .mockResolvedValueOnce(quota(20))
117
+ .mockResolvedValueOnce(quota(30));
118
+ const d = new OpenAIDispatcher(config(), { fetch: fetchMock });
119
+ await d.onTokenAdded(1, "one", credentials);
120
+ const pending = d.getRateLimitsByTokenId(1);
121
+ await vi.waitFor(() => expect(refreshMock).toHaveBeenCalledTimes(1));
122
+ await d.onTokenUpdated(1, "one", { ...credentials, accessToken: "subscriber-access" });
123
+ finishRefresh({ accessToken: "older-refresh-access", chatgptAccountId: "acct" });
124
+ await pending;
125
+ expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("Authorization")).toBe("Bearer subscriber-access");
126
+ expect((await d.getRateLimitsByTokenId(1)).data.rateLimits.primary?.usedPercent).toBe(30);
127
+ });
128
+
129
+ it("does not share pending quota requests across subscriber generations", async () => {
130
+ let finishOld!: (value: Response) => void;
131
+ const quota = (percent: number) => new Response(JSON.stringify({
132
+ rate_limits: { primary_window: { used_percent: percent } },
133
+ }));
134
+ const fetchMock = vi.fn()
135
+ .mockImplementationOnce(() => new Promise((resolve) => { finishOld = resolve; }))
136
+ .mockResolvedValueOnce(quota(10));
137
+ const d = new OpenAIDispatcher(config(), { fetch: fetchMock });
138
+ await d.onTokenAdded(1, "one", credentials);
139
+ const old = d.getRateLimitsByTokenId(1);
140
+ await d.onTokenUpdated(1, "one", { ...credentials, accessToken: "new" });
141
+ const current = d.getRateLimitsByTokenId(1);
142
+ finishOld(quota(90));
143
+ await old;
144
+ expect((await current).data.rateLimits.primary?.usedPercent).toBe(10);
145
+ expect((await d.getRateLimitsByTokenId(1)).data.rateLimits.primary?.usedPercent).toBe(10);
146
+ expect(fetchMock).toHaveBeenCalledTimes(2);
147
+ });
148
+
149
+ it.each(["transparent", "opaque", "auto"] as const)(
150
+ "preserves %s background through validation and Responses serialization",
151
+ async (background) => {
152
+ const args = QueryInput.parse({
153
+ model: "openai/gpt-test",
154
+ prompt: "an isolated red circle",
155
+ imageGeneration: true,
156
+ imageGenerationOptions: { background, quality: "high", size: "1024x1024" },
157
+ });
158
+ let body: ReturnType<typeof buildOpenAIResponsesRequest> | undefined;
159
+ const d = dispatcher((options) => {
160
+ body = buildOpenAIResponsesRequest(options);
161
+ return events(
162
+ { type: "image", id: "i", base64: "png", mimeType: "image/png" },
163
+ { type: "completed", responseId: "r" },
164
+ );
165
+ });
166
+ await d.onTokenAdded(1, "one", credentials);
167
+ await d.generate(request({
168
+ imageGeneration: args.imageGeneration,
169
+ imageGenerationOptions: args.imageGenerationOptions,
170
+ }));
171
+ expect(body?.tools).toEqual([{
172
+ type: "image_generation", background, quality: "high", size: "1024x1024",
173
+ }]);
174
+ },
175
+ );
176
+
177
+ it("rejects unsupported image backgrounds", () => {
178
+ expect(QueryInput.safeParse({
179
+ prompt: "a circle", imageGeneration: true,
180
+ imageGenerationOptions: { background: "green" },
181
+ }).success).toBe(false);
182
+ });
183
+
50
184
  it("passes the resolved transport to the injectable client factory", async () => {
51
185
  const factory = vi.fn(
52
186
  (_options: import("./openai-direct-client").OpenAIDirectClientOptions) => ({
@@ -430,6 +564,7 @@ describe("effort 어휘 해석 (Codex 카탈로그 기준)", () => {
430
564
  }
431
565
 
432
566
  it("모델이 지원하는 값은 그대로 보내고, 상한 초과·공개 API 어휘는 reasoning 에서 빼 백엔드 기본값을 쓴다", async () => {
567
+ await expect(mappedEffortFor("gpt-6-astra", "ultra")).resolves.toBe("ultra");
433
568
  await expect(mappedEffortFor("gpt-5.6-terra", "ultra")).resolves.toBe("ultra");
434
569
  await expect(mappedEffortFor("gpt-5.5", "xhigh")).resolves.toBe("xhigh");
435
570
  await expect(mappedEffortFor("gpt-5.5", "max")).resolves.toBeUndefined();
@@ -143,7 +143,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
143
143
  readonly rateLimitsCache = new Map<number, OpenAIRateLimitsWithMeta & { generation: number }>();
144
144
  private readonly pendingRateLimits = new Map<
145
145
  number,
146
- Promise<OpenAIRateLimitsWithMeta & { generation: number }>
146
+ { token: TokenMetadata; generation: number; promise: Promise<OpenAIRateLimitsWithMeta> }
147
147
  >();
148
148
  private readonly clients = new Map<number, ClientEntry>();
149
149
  private readonly retiredClients = new Set<ClientEntry>();
@@ -318,7 +318,11 @@ export class OpenAIDispatcher implements ProviderDispatcher {
318
318
  onDelta?.(event.text);
319
319
  } else if (event.type === "image") {
320
320
  imageAttempted = true;
321
- images.push({ data: event.base64, revisedPrompt: event.revisedPrompt ?? null });
321
+ images.push({
322
+ data: event.base64,
323
+ revisedPrompt: event.revisedPrompt ?? null,
324
+ ...(event.generation ? { generation: event.generation } : {}),
325
+ });
322
326
  } else if (event.type === "output-item" && event.item.type === "image_generation_call") {
323
327
  imageAttempted = true;
324
328
  } else if (event.type === "completed" && event.usage) {
@@ -356,6 +360,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
356
360
  durationMs: Date.now() - startedAt,
357
361
  ttftMs: firstDeltaAt === undefined ? null : firstDeltaAt - startedAt,
358
362
  model: servingModel,
363
+ ...(servingModel !== req.model ? { requestedModel: req.model } : {}),
359
364
  threadCoord: { workerId: selection.tokenId, threadId: req.promptCacheKey ?? "", epoch: -1 },
360
365
  ...(images.length ? { images } : {}),
361
366
  };
@@ -378,17 +383,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
378
383
  },
379
384
  transportKind: this.transportKind,
380
385
  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
- },
386
+ refreshCredentials: () => this.refreshCredentials(selection.tokenId),
392
387
  },
393
388
  selection.tokenId,
394
389
  );
@@ -519,6 +514,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
519
514
  ): Promise<OpenAIRateLimitsWithMeta> {
520
515
  const token = this.tokenMetadata.get(tokenId);
521
516
  if (!token) throw new Error(`openai token ${tokenId} not found`);
517
+ const generation = token.generation;
522
518
  const cached = this.rateLimitsCache.get(tokenId);
523
519
  if (
524
520
  cached &&
@@ -531,23 +527,53 @@ export class OpenAIDispatcher implements ProviderDispatcher {
531
527
  // 조회가 몰릴 수 있다 — 진행 중인 fetch 를 공유한다. 최초 호출자의 abort 로
532
528
  // 공유 조회가 실패해도 소비처(isQuotaEligible)가 fail-open 으로 처리한다.
533
529
  const pending = this.pendingRateLimits.get(tokenId);
534
- if (pending) return pending;
530
+ if (pending?.token === token && pending.generation === generation) return pending.promise;
535
531
  const fetchPromise = (async () => {
536
532
  const result = await readOpenAIQuotaUsage({
537
533
  credentials: token.credentials,
534
+ refreshCredentials: () => this.refreshCredentials(tokenId),
538
535
  fetch: this.fetchImpl,
539
536
  ...(signal ? { signal } : {}),
540
537
  });
541
538
  if (result.kind === "lookup_failed") throw new Error(result.reason);
542
539
  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);
540
+ const entry = { data: result.raw, cachedAt: Date.now(), generation };
541
+ if (this.tokenMetadata.get(tokenId) === token && token.generation === generation) {
542
+ this.rateLimitsCache.set(tokenId, entry);
543
+ }
545
544
  return entry;
546
- })().finally(() => this.pendingRateLimits.delete(tokenId));
547
- this.pendingRateLimits.set(tokenId, fetchPromise);
545
+ })().finally(() => {
546
+ if (this.pendingRateLimits.get(tokenId)?.promise === fetchPromise) {
547
+ this.pendingRateLimits.delete(tokenId);
548
+ }
549
+ });
550
+ this.pendingRateLimits.set(tokenId, { token, generation, promise: fetchPromise });
548
551
  return fetchPromise;
549
552
  }
550
553
 
554
+ private async refreshCredentials(tokenId: number) {
555
+ const previous = this.tokenMetadata.get(tokenId);
556
+ const generation = previous?.generation;
557
+ const refreshed = await handleChatgptAuthTokensRefresh(tokenId);
558
+ const credentials = {
559
+ accessToken: refreshed.accessToken,
560
+ accountId: refreshed.chatgptAccountId,
561
+ };
562
+ const current = this.tokenMetadata.get(tokenId);
563
+ if (!current?.active) throw new Error(`openai token ${tokenId} is no longer active`);
564
+ // A subscriber update may already contain newer credentials. Never overwrite it
565
+ // with the result of a refresh that started against an older generation.
566
+ if (current === previous && current.generation === generation) {
567
+ current.credentials = { ...current.credentials, ...credentials };
568
+ this.retireClient(tokenId);
569
+ this.invalidateRateLimitsCache(tokenId);
570
+ }
571
+ return {
572
+ accessToken: current.credentials.accessToken,
573
+ accountId: current.credentials.accountId,
574
+ };
575
+ }
576
+
551
577
  private async isQuotaEligible(
552
578
  id: number,
553
579
  token: TokenMetadata,
@@ -0,0 +1,135 @@
1
+ import { PNG } from "pngjs";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { OpenAIDirectClient } from "./openai-direct-client";
5
+
6
+ function png(alpha: number[] = [0, 255]): string {
7
+ const image = new PNG({ width: 2, height: 1 });
8
+ for (let i = 0; i < 2; i++) image.data.set([255, 0, 0, alpha[i]!], i * 4);
9
+ return PNG.sync.write(image).toString("base64");
10
+ }
11
+
12
+ function result(data = png()) {
13
+ return {
14
+ created: 123, background: "transparent", quality: "medium", size: "2x1",
15
+ data: [{ b64_json: data }],
16
+ usage: { input_tokens: 28, output_tokens: 2, total_tokens: 30 },
17
+ };
18
+ }
19
+
20
+ const options = {
21
+ model: "gpt-5.5", instructions: "Keep the red color.",
22
+ history: [{ type: "message", role: "user", content: [{ type: "input_text", text: "Draw a circle." }] }],
23
+ imageGeneration: { background: "transparent", size: "1024x1024", quality: "high" },
24
+ };
25
+
26
+ async function run(fetchImpl: typeof fetch, overrides = {}) {
27
+ const client = new OpenAIDirectClient({
28
+ credentials: { accessToken: "test", accountId: "account" }, fetch: fetchImpl,
29
+ });
30
+ return Array.fromAsync(client.responses({ ...options, ...overrides }));
31
+ }
32
+
33
+ describe("standalone transparent images", () => {
34
+ it("uses the Images endpoint and preserves actual metadata without charging a driver", async () => {
35
+ const fetchMock = vi.fn<typeof fetch>(async () => Response.json(result()));
36
+ const events = await run(fetchMock);
37
+ const [url, init] = fetchMock.mock.calls[0]!;
38
+ expect(url).toBe("https://chatgpt.com/backend-api/codex/images/generations");
39
+ expect(init?.headers).toMatchObject({ Accept: "application/json" });
40
+ expect(JSON.parse(String(init?.body))).toMatchObject({
41
+ model: "gpt-image-2", background: "transparent", quality: "high", size: "1024x1024",
42
+ prompt: expect.stringContaining("Keep the red color."),
43
+ });
44
+ expect(events[0]).toMatchObject({
45
+ type: "image", generation: {
46
+ route: "codex-images", model: "gpt-image-2", size: "2x1", quality: "medium",
47
+ usage: { input_tokens: 28, output_tokens: 2 },
48
+ },
49
+ });
50
+ expect(events[1]).toMatchObject({ type: "completed", model: "gpt-image-2" });
51
+ expect(events[1]).not.toHaveProperty("usage");
52
+ });
53
+
54
+ it("edits with reference images from both history and the latest input", async () => {
55
+ const fetchMock = vi.fn<typeof fetch>(async () => Response.json(result()));
56
+ await run(fetchMock, { history: [
57
+ { type: "message", role: "user", content: [
58
+ { type: "input_text", text: "Keep this character." },
59
+ { type: "input_image", image_url: "data:image/png;base64,first" },
60
+ ] },
61
+ { type: "message", role: "user", content: [
62
+ { type: "input_text", text: "Use this pose on transparent background." },
63
+ { type: "input_image", image_url: "https://example.com/pose.png" },
64
+ ] },
65
+ ] });
66
+ const [url, init] = fetchMock.mock.calls[0]!;
67
+ expect(url).toBe("https://chatgpt.com/backend-api/codex/images/edits");
68
+ expect(JSON.parse(String(init?.body))).toMatchObject({
69
+ images: [{ image_url: "data:image/png;base64,first" }, { image_url: "https://example.com/pose.png" }],
70
+ prompt: expect.stringContaining("Use this pose"),
71
+ });
72
+ });
73
+
74
+ it.each([[255, 255], [0, 0], [254, 255]])("rejects images without a transparent background and visible content (%s)", async (...alpha) => {
75
+ const fetchMock = vi.fn<typeof fetch>(async () => Response.json(result(png(alpha))));
76
+ await expect(run(fetchMock)).rejects.toThrow(/transparent|visible/);
77
+ expect(fetchMock).toHaveBeenCalledTimes(1);
78
+ });
79
+
80
+ it("rejects malformed images without falling back", async () => {
81
+ const fetchMock = vi.fn<typeof fetch>(async () => Response.json(result("bm90IHBuZw==")));
82
+ await expect(run(fetchMock)).rejects.toThrow(/PNG/);
83
+ expect(fetchMock).toHaveBeenCalledTimes(1);
84
+ });
85
+
86
+ it("does not silently ignore structured output on the image-only route", async () => {
87
+ const fetchMock = vi.fn<typeof fetch>();
88
+ await expect(run(fetchMock, { outputSchema: { schema: { type: "object" } } })).rejects.toThrow(/structured/);
89
+ expect(fetchMock).not.toHaveBeenCalled();
90
+ });
91
+
92
+ it("never replays a rejected generation", async () => {
93
+ const fetchMock = vi.fn<typeof fetch>(async () => Response.json({ error: { message: "denied" } }, { status: 429 }));
94
+ await expect(run(fetchMock)).rejects.toThrow("denied");
95
+ expect(fetchMock).toHaveBeenCalledTimes(1);
96
+ });
97
+
98
+ it("refreshes credentials once after a rejected 401", async () => {
99
+ const fetchMock = vi.fn<typeof fetch>()
100
+ .mockResolvedValueOnce(new Response("expired", { status: 401 }))
101
+ .mockResolvedValueOnce(Response.json(result()));
102
+ const refresh = vi.fn(async () => ({ accessToken: "new", accountId: "new-account" }));
103
+ const client = new OpenAIDirectClient({
104
+ credentials: { accessToken: "old", accountId: "old-account" }, fetch: fetchMock,
105
+ refreshCredentials: refresh,
106
+ });
107
+ await Array.fromAsync(client.responses(options));
108
+ expect(refresh).toHaveBeenCalledTimes(1);
109
+ expect(fetchMock.mock.calls[1]?.[1]?.headers).toMatchObject({ Authorization: "Bearer new" });
110
+ });
111
+
112
+ it("forwards cancellation and never retries it", async () => {
113
+ const fetchMock = vi.fn<typeof fetch>(async (_url, init) => {
114
+ expect(init?.signal).toBe(controller.signal);
115
+ await new Promise((_resolve, reject) => init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }));
116
+ return Response.json(result());
117
+ });
118
+ const controller = new AbortController();
119
+ const client = new OpenAIDirectClient({ credentials: { accessToken: "a", accountId: "b" }, fetch: fetchMock });
120
+ const pending = Array.fromAsync(client.responses(options, controller.signal));
121
+ const assertion = expect(pending).rejects.toThrow("cancelled");
122
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
123
+ controller.abort(new Error("cancelled"));
124
+ await assertion;
125
+ expect(fetchMock).toHaveBeenCalledOnce();
126
+ });
127
+
128
+ it("rejects too many references before network I/O", async () => {
129
+ const fetchMock = vi.fn<typeof fetch>();
130
+ await expect(run(fetchMock, { history: [{ type: "message", role: "user", content:
131
+ Array.from({ length: 6 }, () => ({ type: "input_image", image_url: "https://example.com/a.png" })),
132
+ }] })).rejects.toThrow("at most 5");
133
+ expect(fetchMock).not.toHaveBeenCalled();
134
+ });
135
+ });
@@ -0,0 +1,177 @@
1
+ import { PNG } from "pngjs";
2
+ import { z } from "zod";
3
+
4
+ import { ImageGenerationMetadata } from "../../../application/qgrid/qgrid.types";
5
+ import {
6
+ CHATGPT_CODEX_RESPONSES_URL,
7
+ OpenAIProtocolError,
8
+ type OpenAINormalizedEvent,
9
+ type OpenAIResponsesOptions,
10
+ } from "./openai-backend-protocol";
11
+
12
+ const MAX_PNG_BYTES = 32 * 1024 * 1024;
13
+ const MAX_IMAGE_PIXELS = 4096 * 4096;
14
+ const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
15
+ const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
16
+
17
+ export function buildStandaloneImageRequest(options: OpenAIResponsesOptions) {
18
+ if (options.outputSchema || options.tools?.length) {
19
+ throw new OpenAIProtocolError(
20
+ "Transparent image generation does not support tools or structured output",
21
+ );
22
+ }
23
+ const controls = options.imageGeneration;
24
+ if (!controls || controls === true || controls.background !== "transparent") {
25
+ throw new OpenAIProtocolError(
26
+ "Standalone images require an explicit transparent background request",
27
+ );
28
+ }
29
+ const text: string[] = [];
30
+ const images: Array<{ image_url: string }> = [];
31
+ if (options.instructions) text.push(`System instructions:\n${options.instructions}`);
32
+ for (const item of options.history) {
33
+ if (item.type === "reasoning") continue;
34
+ if (item.type === "image_generation_call" && typeof item.result === "string") {
35
+ images.push({ image_url: `data:image/png;base64,${item.result}` });
36
+ continue;
37
+ }
38
+ if (item.type !== "message" && typeof item.role !== "string") {
39
+ throw new OpenAIProtocolError(
40
+ "Unsupported conversation item for transparent image generation",
41
+ );
42
+ }
43
+ if (typeof item.content === "string") {
44
+ text.push(`${item.role ?? "user"}:\n${item.content}`);
45
+ continue;
46
+ }
47
+ if (!Array.isArray(item.content)) {
48
+ throw new OpenAIProtocolError("Unsupported message content for transparent image generation");
49
+ }
50
+ for (const part of item.content) {
51
+ if (!part || typeof part !== "object") {
52
+ throw new OpenAIProtocolError("Unsupported message part for transparent image generation");
53
+ }
54
+ if (
55
+ ["input_text", "output_text", "text"].includes(part.type) &&
56
+ typeof part.text === "string"
57
+ ) {
58
+ text.push(`${item.role ?? "user"}:\n${part.text}`);
59
+ } else if (part.type === "input_image" || part.type === "image_url") {
60
+ const url = typeof part.image_url === "string" ? part.image_url : part.image_url?.url;
61
+ if (typeof url !== "string" || !/^(https:\/\/|data:image\/)/i.test(url)) {
62
+ throw new OpenAIProtocolError("Image references must be HTTPS URLs or image data URLs");
63
+ }
64
+ images.push({ image_url: url });
65
+ } else {
66
+ throw new OpenAIProtocolError("Unsupported message part for transparent image generation");
67
+ }
68
+ }
69
+ }
70
+ if (images.length > 5)
71
+ throw new OpenAIProtocolError("Transparent image editing supports at most 5 reference images");
72
+ if (!text.some((part) => part.trim())) throw new OpenAIProtocolError("Image prompt is empty");
73
+ return {
74
+ url: CHATGPT_CODEX_RESPONSES_URL.replace(
75
+ /\/responses$/,
76
+ `/images/${images.length ? "edits" : "generations"}`,
77
+ ),
78
+ body: {
79
+ model: "gpt-image-2",
80
+ prompt: text.join("\n\n"),
81
+ background: "transparent",
82
+ ...(controls.quality ? { quality: controls.quality } : {}),
83
+ ...(controls.size ? { size: controls.size } : {}),
84
+ ...(images.length ? { images } : {}),
85
+ },
86
+ };
87
+ }
88
+
89
+ // Decode before accepting the result: an alpha channel alone can still be fully opaque.
90
+ export function inspectTransparentPng(base64: string): { width: number; height: number } {
91
+ if (base64.length > Math.ceil(MAX_PNG_BYTES / 3) * 4 || !/^[A-Za-z0-9+/]+={0,2}$/.test(base64)) {
92
+ throw new OpenAIProtocolError("Invalid or oversized generated PNG");
93
+ }
94
+ const bytes = Buffer.from(base64, "base64");
95
+ if (
96
+ bytes.length < 33 ||
97
+ !bytes.subarray(0, 8).equals(PNG_SIGNATURE) ||
98
+ bytes.toString("ascii", 12, 16) !== "IHDR"
99
+ ) {
100
+ throw new OpenAIProtocolError("Image generation did not return a PNG");
101
+ }
102
+ const width = bytes.readUInt32BE(16);
103
+ const height = bytes.readUInt32BE(20);
104
+ if (!width || !height || width * height > MAX_IMAGE_PIXELS) {
105
+ throw new OpenAIProtocolError("Generated PNG dimensions exceed the decoding limit");
106
+ }
107
+ let image: PNG;
108
+ try {
109
+ image = PNG.sync.read(bytes);
110
+ } catch {
111
+ throw new OpenAIProtocolError("Image generation returned an invalid PNG");
112
+ }
113
+ let transparent = false;
114
+ let visible = false;
115
+ for (let i = 3; i < image.data.length; i += 4) {
116
+ transparent ||= image.data[i] === 0;
117
+ visible ||= image.data[i]! > 0;
118
+ if (transparent && visible) return { width: image.width, height: image.height };
119
+ }
120
+ throw new OpenAIProtocolError(
121
+ "Image generation did not return visible content on a transparent background",
122
+ );
123
+ }
124
+
125
+ export async function readStandaloneImageResponse(
126
+ response: Response,
127
+ ): Promise<OpenAINormalizedEvent[]> {
128
+ if (!response.body) throw new OpenAIProtocolError("Image response had no body");
129
+ const reader = response.body.getReader();
130
+ const chunks: Uint8Array[] = [];
131
+ let size = 0;
132
+ try {
133
+ while (true) {
134
+ const { done, value } = await reader.read();
135
+ if (done) break;
136
+ size += value.byteLength;
137
+ if (size > MAX_RESPONSE_BYTES) {
138
+ await reader.cancel();
139
+ throw new OpenAIProtocolError("Image response exceeded the size limit");
140
+ }
141
+ chunks.push(value);
142
+ }
143
+ } finally {
144
+ reader.releaseLock();
145
+ }
146
+ const schema = z.object({
147
+ data: z
148
+ .array(z.object({ b64_json: z.string().min(1), revised_prompt: z.string().nullish() }))
149
+ .min(1)
150
+ .max(16),
151
+ quality: z.string().optional(),
152
+ usage: ImageGenerationMetadata.shape.usage,
153
+ });
154
+ let parsed: z.infer<typeof schema>;
155
+ try {
156
+ parsed = schema.parse(JSON.parse(Buffer.concat(chunks).toString("utf8")));
157
+ } catch {
158
+ throw new OpenAIProtocolError("Invalid standalone image response");
159
+ }
160
+ return parsed.data.map((item, index) => {
161
+ const dimensions = inspectTransparentPng(item.b64_json);
162
+ return {
163
+ type: "image",
164
+ base64: item.b64_json,
165
+ mimeType: "image/png",
166
+ ...(item.revised_prompt ? { revisedPrompt: item.revised_prompt } : {}),
167
+ generation: {
168
+ route: "codex-images",
169
+ model: "gpt-image-2",
170
+ background: "transparent",
171
+ size: `${dimensions.width}x${dimensions.height}`,
172
+ ...(parsed.quality ? { quality: parsed.quality } : {}),
173
+ ...(index === 0 && parsed.usage ? { usage: parsed.usage } : {}),
174
+ },
175
+ };
176
+ });
177
+ }