@cartanova/qgrid-cli 2.10.3 → 2.10.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 (78) hide show
  1. package/bundle/dist/application/qgrid/qgrid-image-generation.js +6 -2
  2. package/bundle/dist/application/qgrid/qgrid-response-format.js +4 -4
  3. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +63 -140
  4. package/bundle/dist/application/qgrid/qgrid.frame.js +3 -6
  5. package/bundle/dist/application/qgrid/qgrid.types.js +4 -8
  6. package/bundle/dist/application/request-log/request-log.model.js +21 -15
  7. package/bundle/dist/application/setting/setting.model.js +2 -64
  8. package/bundle/dist/application/setting/setting.store.js +3 -4
  9. package/bundle/dist/application/setting/setting.types.js +2 -12
  10. package/bundle/dist/i18n/en.js +1 -3
  11. package/bundle/dist/i18n/ko.js +1 -3
  12. package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +19 -8
  13. package/bundle/src/application/qgrid/qgrid-image-generation.test.ts +18 -0
  14. package/bundle/src/application/qgrid/qgrid-image-generation.ts +14 -3
  15. package/bundle/src/application/qgrid/qgrid-response-format.test.ts +9 -0
  16. package/bundle/src/application/qgrid/qgrid-response-format.ts +10 -7
  17. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +0 -111
  18. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +82 -178
  19. package/bundle/src/application/qgrid/qgrid.frame.test.ts +0 -38
  20. package/bundle/src/application/qgrid/qgrid.frame.ts +4 -6
  21. package/bundle/src/application/qgrid/qgrid.types.ts +14 -4
  22. package/bundle/src/application/request-log/request-log.model.test.ts +17 -12
  23. package/bundle/src/application/request-log/request-log.model.ts +8 -2
  24. package/bundle/src/application/setting/setting.model.test.ts +1 -101
  25. package/bundle/src/application/setting/setting.model.ts +0 -73
  26. package/bundle/src/application/setting/setting.store.ts +2 -3
  27. package/bundle/src/application/setting/setting.types.ts +0 -12
  28. package/bundle/src/application/sonamu.generated.http +2 -7
  29. package/bundle/src/i18n/en.ts +0 -3
  30. package/bundle/src/i18n/ko.ts +0 -3
  31. package/bundle/src/utils/providers/openai/openai-backend-protocol.test.ts +19 -1
  32. package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +22 -0
  33. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +12 -0
  34. package/bundle/web-dist/client/assets/cost-CY2BJEPC.js +1 -0
  35. package/bundle/web-dist/client/assets/index-CvG4UB9v.css +1 -0
  36. package/bundle/web-dist/client/assets/{index-BIC1sQxw.js → index-Dxw7DiXF.js} +4 -4
  37. package/bundle/web-dist/client/assets/logs-3n_U4qnI.js +1 -0
  38. package/bundle/web-dist/client/assets/{monit-CraVV3MQ.js → monit-E8eGW1Sj.js} +1 -1
  39. package/bundle/web-dist/client/assets/{routes-B3ZBoJ60.js → routes-7spLfIGZ.js} +2 -2
  40. package/bundle/web-dist/client/assets/services.generated-jnPHYMER.js +51 -0
  41. package/bundle/web-dist/client/assets/settings-BdosZFNC.js +1 -0
  42. package/bundle/web-dist/client/assets/{show-ItTExdAh.js → show-b2PFNI3D.js} +1 -1
  43. package/bundle/web-dist/client/assets/{tokens-BynKx8Vn.js → tokens-CBdHb2_U.js} +1 -1
  44. package/bundle/web-dist/client/assets/{use-oauth-login-flow-8xw6on5P.js → use-oauth-login-flow-Cyn3YPqW.js} +1 -1
  45. package/bundle/web-dist/client/assets/{use-update-token-mutation-D-Y8OySK.js → use-update-token-mutation-DQCwCYfR.js} +1 -1
  46. package/bundle/web-dist/client/index.html +3 -3
  47. package/bundle/web-dist/server/assets/{cost-CSH_t17I.js → cost-59jBdx-I.js} +8 -3
  48. package/bundle/web-dist/server/assets/{lazyRouteComponent-Bj1cPq6x.js → lazyRouteComponent-CUR-kWzI.js} +2 -2
  49. package/bundle/web-dist/server/assets/{logs-BSk9lOHe.js → logs-CaaWO9-Z.js} +9 -7
  50. package/bundle/web-dist/server/assets/{monit-D9W4ioUi.js → monit-CBil92fJ.js} +4 -4
  51. package/bundle/web-dist/server/assets/{plus-CmvbWuDf.js → plus-Bvdj8Pwx.js} +1 -1
  52. package/bundle/web-dist/server/assets/{react-dom-BjR1Iaz8.js → react-dom-CyuShs9K.js} +1 -1
  53. package/bundle/web-dist/server/assets/{refresh-cw-B8U9BL9-.js → refresh-cw-3QAiSWBd.js} +1 -1
  54. package/bundle/web-dist/server/assets/{routes-C7lB2Q85.js → routes-DtVf9yqo.js} +7 -7
  55. package/bundle/web-dist/server/assets/{services.generated-BM4X3Ecj.js → services.generated-FPwsG9p3.js} +116 -131
  56. package/bundle/web-dist/server/assets/{settings-BFVuldch.js → settings-DeHNwoMv.js} +40 -251
  57. package/bundle/web-dist/server/assets/{show-Cswh_tqs.js → show-8NNdRxhM.js} +3 -3
  58. package/bundle/web-dist/server/assets/{tokens-DiTUwUwr.js → tokens-odgS2-9q.js} +4 -4
  59. package/bundle/web-dist/server/assets/{use-oauth-login-flow-DYKnG4A2.js → use-oauth-login-flow-DTQKdYBO.js} +1 -1
  60. package/bundle/web-dist/server/assets/{use-update-token-mutation-DqGMopII.js → use-update-token-mutation-xvXbPrCI.js} +1 -1
  61. package/bundle/web-dist/server/entry-server.generated.js +10 -10
  62. package/package.json +3 -2
  63. package/skills/qgrid/SKILL.md +1 -1
  64. package/skills/qgrid/references/ai-sdk-provider-contract.md +1 -1
  65. package/skills/qgrid/references/cli-env-and-server-boot.md +12 -9
  66. package/skills/qgrid/references/decision-rationale.md +2 -2
  67. package/skills/qgrid/references/openai-codex-runtime.md +5 -1
  68. package/bundle/dist/utils/process-supervisor.js +0 -22
  69. package/bundle/dist/utils/server-restart.js +0 -61
  70. package/bundle/src/utils/process-supervisor.test.ts +0 -24
  71. package/bundle/src/utils/process-supervisor.ts +0 -17
  72. package/bundle/src/utils/server-restart.test.ts +0 -76
  73. package/bundle/src/utils/server-restart.ts +0 -68
  74. package/bundle/web-dist/client/assets/cost-CpjVoAqp.js +0 -1
  75. package/bundle/web-dist/client/assets/index-D_sagzsR.css +0 -1
  76. package/bundle/web-dist/client/assets/logs-zvdujZWn.js +0 -1
  77. package/bundle/web-dist/client/assets/services.generated-Cbvg2sGQ.js +0 -51
  78. package/bundle/web-dist/client/assets/settings-DwbzT5jo.js +0 -1
@@ -1,10 +1,6 @@
1
- import { EventEmitter } from "node:events";
2
1
 
3
- import { Sonamu } from "sonamu";
4
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
3
 
6
- import { resetNativeRunRegistryForTests } from "../qgrid/qgrid-run-lifecycle";
7
- import { resetServerRestartForTests } from "../../utils/server-restart";
8
4
  import { SettingModel } from "./setting.model";
9
5
  import * as reminders from "../qgrid/expired-token-reminder";
10
6
  import { SlackNotificationError } from "../../utils/slack-notify";
@@ -26,99 +22,3 @@ describe("SettingModel.triggerExpiryReminder", () => {
26
22
  });
27
23
  });
28
24
  });
29
-
30
- function installContext(headers: Record<string, string>) {
31
- const raw = Object.assign(new EventEmitter(), {
32
- destroyed: false,
33
- writableEnded: false,
34
- });
35
- vi.spyOn(Sonamu, "getContext").mockReturnValue({
36
- transport: "http",
37
- headers,
38
- request: { protocol: "http" },
39
- reply: { raw },
40
- } as never);
41
- return raw;
42
- }
43
-
44
- describe("SettingModel.restartServer", () => {
45
- beforeEach(() => {
46
- vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
47
- });
48
-
49
- afterEach(() => {
50
- resetServerRestartForTests();
51
- resetNativeRunRegistryForTests();
52
- vi.unstubAllEnvs();
53
- vi.restoreAllMocks();
54
- });
55
-
56
- it("rejects restart when pm2 will not respawn the process", async () => {
57
- const raw = installContext({ host: "localhost:44900" });
58
- vi.stubEnv("pm_id", undefined);
59
-
60
- await expect(SettingModel.restartServer()).rejects.toMatchObject({ statusCode: 400 });
61
- expect(raw.listenerCount("finish")).toBe(0);
62
- expect(process.exit).not.toHaveBeenCalled();
63
- });
64
-
65
- it("rejects a browser request whose Origin host does not match Host", async () => {
66
- installContext({ origin: "https://evil.example", host: "qgrid.example.com" });
67
- vi.stubEnv("pm_id", "0");
68
-
69
- await expect(SettingModel.restartServer()).rejects.toMatchObject({ statusCode: 403 });
70
- expect(process.exit).not.toHaveBeenCalled();
71
- });
72
-
73
- it("rejects a malformed present Origin instead of treating it as an operational client", async () => {
74
- installContext({ origin: "null", host: "qgrid.example.com" });
75
- vi.stubEnv("pm_id", "0");
76
-
77
- await expect(SettingModel.restartServer()).rejects.toMatchObject({ statusCode: 403 });
78
- expect(process.exit).not.toHaveBeenCalled();
79
- });
80
-
81
- it("accepts a matching Origin using the Caddy-forwarded host", async () => {
82
- const raw = installContext({
83
- origin: "https://qgrid.example.com",
84
- host: "127.0.0.1:44900",
85
- "x-forwarded-host": "qgrid.example.com",
86
- });
87
- vi.stubEnv("pm_id", "0");
88
-
89
- await expect(SettingModel.restartServer()).resolves.toEqual({ supervisor: "pm2" });
90
- expect(process.exit).not.toHaveBeenCalled();
91
-
92
- raw.emit("finish");
93
- expect(process.exit).toHaveBeenCalledOnce();
94
- expect(process.exit).toHaveBeenCalledWith(0);
95
- });
96
-
97
- it("accepts an Origin-less operational request", async () => {
98
- const raw = installContext({ host: "qgrid.example.com" });
99
- vi.stubEnv("pm_id", "0");
100
-
101
- await expect(SettingModel.restartServer()).resolves.toEqual({ supervisor: "pm2" });
102
- expect(process.exit).not.toHaveBeenCalled();
103
-
104
- raw.emit("finish");
105
- expect(process.exit).toHaveBeenCalledWith(0);
106
- });
107
-
108
- it("coordinates simultaneous calls through one process exit", async () => {
109
- const firstRaw = installContext({ host: "qgrid.example.com" });
110
- vi.stubEnv("pm_id", "0");
111
-
112
- const first = SettingModel.restartServer();
113
- const second = SettingModel.restartServer();
114
- await expect(Promise.all([first, second])).resolves.toEqual([
115
- { supervisor: "pm2" },
116
- { supervisor: "pm2" },
117
- ]);
118
-
119
- firstRaw.emit("finish");
120
- firstRaw.emit("close");
121
- expect(process.exit).toHaveBeenCalledOnce();
122
- expect(process.exit).toHaveBeenCalledWith(0);
123
- });
124
- });
@@ -1,19 +1,14 @@
1
- import { getLogger } from "@logtape/logtape";
2
1
  import {
3
2
  BaseModelClass,
4
3
  type ListResult,
5
4
  asArray,
6
5
  NotFoundException,
7
6
  BadRequestException,
8
- SoException,
9
- Sonamu,
10
7
  api,
11
8
  exhaustive,
12
9
  } from "sonamu";
13
10
 
14
11
  import { SD, type LocalizedString } from "../../i18n/sd.generated";
15
- import { detectSupervisor } from "../../utils/process-supervisor";
16
- import { armServerRestartExit, beginServerRestart } from "../../utils/server-restart";
17
12
  import { SlackNotificationError } from "../../utils/slack-notify";
18
13
  import { type SettingSubsetKey, type SettingSubsetMapping } from "../sonamu.generated";
19
14
  import { settingSubsetQueries, settingLoaderQueries } from "../sonamu.generated.sso";
@@ -37,37 +32,8 @@ import {
37
32
  type SettingListParams,
38
33
  type SettingSaveParams,
39
34
  type SettingsResponse,
40
- type SupervisorKind,
41
35
  } from "./setting.types";
42
36
 
43
- const logger = getLogger(["qgrid", "setting"]);
44
-
45
- class ForbiddenException extends SoException {
46
- constructor() {
47
- super(403, SD("error.forbidden"));
48
- }
49
- }
50
-
51
- function firstHeaderValue(value: string | string[] | undefined): string | undefined {
52
- return (Array.isArray(value) ? value[0] : value)?.split(",")[0]?.trim();
53
- }
54
-
55
- /** Origin 없는 운영 CLI 요청은 통과시키되, 브라우저 교차 출처 POST 는 막는다. */
56
- function assertRestartOrigin(): void {
57
- const { headers } = Sonamu.getContext();
58
- const origin = firstHeaderValue(headers.origin);
59
- if (headers.origin === undefined) return;
60
- if (!origin) throw new ForbiddenException();
61
-
62
- const host = firstHeaderValue(headers["x-forwarded-host"]) ?? firstHeaderValue(headers.host);
63
- try {
64
- if (!host || new URL(origin).host !== host) throw new ForbiddenException();
65
- } catch (error) {
66
- if (error instanceof ForbiddenException) throw error;
67
- throw new ForbiddenException();
68
- }
69
- }
70
-
71
37
  /*
72
38
  Setting Model
73
39
  */
@@ -246,7 +212,6 @@ class SettingModelClass extends BaseModelClass<
246
212
  return {
247
213
  settings,
248
214
  runtime: this.runtimeInfo(),
249
- supervisor: detectSupervisor(),
250
215
  keepaliveRunnerEnabled: process.env[TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY] === "true",
251
216
  };
252
217
  }
@@ -309,44 +274,6 @@ class SettingModelClass extends BaseModelClass<
309
274
  throw error;
310
275
  }
311
276
  }
312
-
313
- /**
314
- * 서버를 재시작한다 — 정확히는 스스로 종료하고, pm2 가 다시 띄우는 것에 맡긴다.
315
- *
316
- * `restart` 로 표시된 워커 설정은 dispatcher 생성자에서 한 번만 읽히므로 이 경로 말고는
317
- * 반영할 방법이 없었다. 지금까지는 SSH 로 `pm2 restart` 를 쳐야 했다.
318
- *
319
- * 부수 효과가 하나 더 있다: pm2 는 `ecosystem.config.cjs` 의 원래 명령으로 다시 띄우고,
320
- * 그 명령에 `--skip-update` 가 없으면 CLI 가 npm 최신 버전을 받아 올린다. 즉 재시작은
321
- * 배포이기도 하다 — 화면 문구가 이 사실을 함께 알린다.
322
- */
323
- @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
324
- async restartServer(): Promise<{ supervisor: SupervisorKind }> {
325
- assertRestartOrigin();
326
- const supervisor = detectSupervisor();
327
- if (!supervisor) {
328
- // 다시 띄워줄 주체가 없으면 종료가 곧 정지다. 살릴 수 없는데 죽이지 않는다.
329
- throw new BadRequestException(SD("setting.noSupervisor")());
330
- }
331
-
332
- const { reply } = Sonamu.getContext();
333
- const started = beginServerRestart();
334
- if (started) {
335
- logger.warn(`restart requested via dashboard, exiting for ${supervisor} to respawn`);
336
-
337
- try {
338
- // 정적 import로 qgrid.frame/token 그래프를 당겨오면 모듈 목 테스트가 오염된다.
339
- // 재시작이 실제로 확정된 시점에 active native run 정합성 처리만 로드한다.
340
- const { finishActiveNativeRunsForRestart } = await import("../qgrid/qgrid-run-lifecycle");
341
- await finishActiveNativeRunsForRestart();
342
- } finally {
343
- // 정합성 처리가 실패해도 응답이 flush 된 뒤에는 재시작을 끝까지 진행한다.
344
- armServerRestartExit(reply.raw);
345
- }
346
- }
347
-
348
- return { supervisor };
349
- }
350
277
  }
351
278
 
352
279
  export const SettingModel = new SettingModelClass();
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * 런타임 설정 조회 캐시.
3
3
  *
4
- * 값의 출처는 DB(`settings`) → env 순이다. env 를 남겨두는 이유는 전환 때문이다 — dev0 는
5
- * `ecosystem.config.json` 으로 값을 주입하는데, DB 만 보게 만들면 배포 직후 설정이 전부
6
- * 기본값으로 떨어진다.
4
+ * 값의 출처는 DB(`settings`) → env 순이다. 저장된 설정이 없으면 기동 시 전달된
5
+ * 환경변수를 사용하므로, 설정을 DB에 저장하기 전에도 기존 값이 유지된다.
7
6
  *
8
7
  * 설정은 요청 경로(permit 해석, 알림 전송)에서 읽히므로 매번 DB 를 때리지 않고 메모리에
9
8
  * 둔다. `immediate` 변경도 현재 API 프로세스에만 즉시 반영된다. 인스턴스가 여러 개면 다른
@@ -21,16 +21,6 @@ export type SettingApplies = z.infer<typeof SettingApplies>;
21
21
  export const SettingSource = z.enum(["db", "env", "default"]);
22
22
  export type SettingSource = z.infer<typeof SettingSource>;
23
23
 
24
- /**
25
- * 프로세스를 다시 띄워주는 도구. 재시작은 스스로 종료하고 이 도구에 맡기므로,
26
- * 없으면(null) 화면에서 재시작을 시도조차 하지 않는다.
27
- *
28
- * API 반환 타입에 쓰이므로 zod 로 둔다 — sonamu syncer 가 클라이언트 코드를 만들 때
29
- * 타입만 있는 선언은 모듈을 찾지 못한다.
30
- */
31
- export const SupervisorKind = z.enum(["pm2"]);
32
- export type SupervisorKind = z.infer<typeof SupervisorKind>;
33
-
34
24
  export const SettingItem = z.object({
35
25
  key: z.string(),
36
26
  group: z.string(),
@@ -59,8 +49,6 @@ export type RuntimeInfoItem = z.infer<typeof RuntimeInfoItem>;
59
49
  export const SettingsResponse = z.object({
60
50
  settings: SettingItem.array(),
61
51
  runtime: RuntimeInfoItem.array(),
62
- /** null 이면 재시작 버튼을 쓸 수 없다 — 종료해도 되살릴 주체가 없다. */
63
- supervisor: SupervisorKind.nullable(),
64
52
  /** 이 인스턴스가 keepalive 스케줄러를 소유하도록 명시적으로 허용됐는지. */
65
53
  keepaliveRunnerEnabled: z.boolean(),
66
54
  });
@@ -98,13 +98,6 @@ Content-Type: application/json
98
98
 
99
99
  ###
100
100
 
101
- POST {{baseUrl}}/api/setting/restartServer
102
- Content-Type: application/json
103
-
104
- {}
105
-
106
- ###
107
-
108
101
  GET {{baseUrl}}/api/requestLogStep/findById
109
102
  ?subset=A
110
103
  &id=0
@@ -297,6 +290,7 @@ Content-Type: application/json
297
290
  "inputSchema": "unknown"
298
291
  }
299
292
  ],
293
+ "thinking": true,
300
294
  "effort": "EFFORT",
301
295
  "verbosity": "low",
302
296
  "reasoningSummary": "auto",
@@ -361,6 +355,7 @@ Content-Type: application/json
361
355
  "inputSchema": "unknown"
362
356
  }
363
357
  ],
358
+ "thinking": true,
364
359
  "effort": "EFFORT",
365
360
  "verbosity": "low",
366
361
  "reasoningSummary": "auto",
@@ -49,12 +49,9 @@ export default {
49
49
  "error.serviceUnavailable": "Service unavailable",
50
50
  "setting.invalidValue": (reason: string) => `Invalid setting value: ${reason}`,
51
51
  "setting.unknownKey": (key: string) => `Unknown setting key ${key}`,
52
- "setting.noSupervisor": () =>
53
- "Restart is unavailable when running locally — nothing would bring the server back up",
54
52
  "qgrid.dispatcherFailed": (provider: string) => `${provider} dispatcher failed to initialize`,
55
53
  "qgrid.dispatcherStarting": (provider: string) =>
56
54
  `${provider} dispatcher is starting up. Please retry shortly`,
57
- "qgrid.restartPending": () => "Server restart is in progress. Please retry shortly",
58
55
  "error.unauthorized": "Unauthorized",
59
56
  notFound: (name: string, id: number) => `${name} ID ${id} not found`,
60
57
  "validation.email": "Invalid email format",
@@ -48,12 +48,9 @@ export default {
48
48
  "error.serviceUnavailable": "서비스를 사용할 수 없습니다",
49
49
  "setting.invalidValue": (reason: string) => `설정 값이 올바르지 않습니다: ${reason}`,
50
50
  "setting.unknownKey": (key: string) => `알 수 없는 설정 키 ${key}`,
51
- "setting.noSupervisor": () =>
52
- "로컬 실행 환경에서는 재시작할 수 없습니다 — 종료하면 다시 켜줄 모니터링 도구가 없습니다",
53
51
  "qgrid.dispatcherFailed": (provider: string) => `${provider} dispatcher 초기화에 실패했습니다`,
54
52
  "qgrid.dispatcherStarting": (provider: string) =>
55
53
  `${provider} dispatcher 기동 중입니다. 잠시 후 다시 시도해주세요`,
56
- "qgrid.restartPending": () => "서버 재시작 중입니다. 잠시 후 다시 시도해주세요",
57
54
  "error.unauthorized": "인증이 필요합니다",
58
55
  "error.unknownSearchField": (field: string) => `구현되지 않은 검색 필드 ${field}`,
59
56
  notFound: (name: string, id: number) => `존재하지 않는 ${name} ID ${id}`,
@@ -8,6 +8,24 @@ import {
8
8
  } from "./openai-backend-protocol";
9
9
 
10
10
  describe("OpenAI Codex backend protocol", () => {
11
+ it.each(["png", "jpeg", "webp"])("preserves reported hosted %s generation settings", (output_format) => {
12
+ expect(normalizeOpenAIEvent({ type: "response.output_item.done", item: {
13
+ type: "image_generation_call", result: "aGVsbG8=", output_format,
14
+ size: "841x1870", quality: "low", background: "opaque",
15
+ } })).toMatchObject({ generation: {
16
+ route: "codex-responses", size: "841x1870", quality: "low", background: "opaque",
17
+ } });
18
+ });
19
+
20
+ it("reports PNG byte dimensions instead of a conflicting backend size", () => {
21
+ const bytes = Buffer.alloc(24);
22
+ Buffer.from("89504e470d0a1a0a", "hex").copy(bytes);
23
+ bytes.writeUInt32BE(841, 16);
24
+ bytes.writeUInt32BE(1870, 20);
25
+ expect(normalizeOpenAIEvent({ type: "response.output_item.done", item: {
26
+ type: "image_generation_call", result: bytes.toString("base64"), size: "1152x2560",
27
+ } })).toMatchObject({ generation: { route: "codex-responses", size: "841x1870" } });
28
+ });
11
29
  it("distinguishes partial function items from completed arguments", () => {
12
30
  const item = { type: "function_call", call_id: "call", name: "generate_transparent_image", arguments: "{}" };
13
31
  expect(normalizeOpenAIEvent({ type: "response.output_item.added", item })).toEqual({ type: "output-item", item, completed: false });
@@ -129,6 +147,6 @@ describe("OpenAI Codex backend protocol", () => {
129
147
  type: "response.output_item.done",
130
148
  item: { type: "image_generation_call", id: "img", result: "aGVsbG8=", revised_prompt: "better" },
131
149
  }),
132
- ).toEqual({ type: "image", id: "img", base64: "aGVsbG8=", mimeType: "image/png", revisedPrompt: "better" });
150
+ ).toEqual({ type: "image", id: "img", base64: "aGVsbG8=", mimeType: "image/png", revisedPrompt: "better", generation: { route: "codex-responses" } });
133
151
  });
134
152
  });
@@ -248,11 +248,33 @@ export function normalizeOpenAIEvent(raw: unknown): OpenAINormalizedEvent | unde
248
248
  ) {
249
249
  const item = event.item as OpenAIResponseItem;
250
250
  if (item.type === "image_generation_call" && typeof item.result === "string") {
251
+ // PNG exposes dimensions in its fixed header. Other formats retain the
252
+ // backend's observed size; never substitute requested image controls.
253
+ const header = Buffer.from(item.result.slice(0, 32), "base64");
254
+ const pngSize =
255
+ header.length >= 24 &&
256
+ header.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex")) &&
257
+ header.readUInt32BE(16) > 0 &&
258
+ header.readUInt32BE(20) > 0
259
+ ? `${header.readUInt32BE(16)}x${header.readUInt32BE(20)}`
260
+ : undefined;
261
+ const size =
262
+ pngSize ??
263
+ (typeof item.size === "string" && /^[1-9]\d*x[1-9]\d*$/.test(item.size)
264
+ ? item.size
265
+ : undefined);
251
266
  return {
252
267
  type: "image",
253
268
  ...(typeof item.id === "string" ? { id: item.id } : {}),
254
269
  base64: item.result,
255
270
  mimeType: imageMimeType(item.result, item.output_format),
271
+ generation: {
272
+ route: "codex-responses",
273
+ ...(size ? { size } : {}),
274
+ ...(typeof item.model === "string" ? { model: item.model } : {}),
275
+ ...(typeof item.quality === "string" ? { quality: item.quality } : {}),
276
+ ...(typeof item.background === "string" ? { background: item.background } : {}),
277
+ },
256
278
  ...(typeof item.revised_prompt === "string"
257
279
  ? { revisedPrompt: item.revised_prompt }
258
280
  : typeof item.revisedPrompt === "string"
@@ -144,6 +144,18 @@ describe("OpenAIDispatcher direct runtime", () => {
144
144
  }
145
145
  });
146
146
 
147
+ it.each(["auto", "1152x2560"])("forwards %s size hints and preserves hosted observations", async (size) => {
148
+ const args = QueryInput.parse({ prompt: "draw in 9:20", imageGeneration: true, imageGenerationOptions: { size } });
149
+ const generation = { route: "codex-responses" as const, size: "841x1870", quality: "low" };
150
+ const d = dispatcher((options) => {
151
+ expect(buildOpenAIResponsesRequest(options).tools).toContainEqual({ type: "image_generation", size });
152
+ return events({ type: "image", base64: "data", mimeType: "image/png", generation }, { type: "completed", responseId: "r" });
153
+ });
154
+ await d.onTokenAdded(1, "one", credentials);
155
+ const result = await d.generate(request({ imageGeneration: args.imageGeneration, imageGenerationOptions: args.imageGenerationOptions }));
156
+ expect(result.images).toMatchObject([{ generation }]);
157
+ });
158
+
147
159
  beforeEach(() => { refreshMock.mockReset(); });
148
160
 
149
161
  it("refreshes a quota 401 once, shares the recovery, and uses the new credentials", async () => {
@@ -0,0 +1 @@
1
+ var e=1e6,t=4;function n(t){return t/e}function r(e,n=!1){return`${n?`Known `:``}$${e.toFixed(t)}`}function i(e){return r(n(e))}function a(e){let t=e.is_image_generation&&e.image_cost_usd===null;return e.cost_usd===null&&e.image_cost_usd===null?t?`Unknown`:`—`:r(n((e.cost_usd??0)+(e.image_cost_usd??0)),t)}function o(e){return e.input_tokens<=0?`—`:`${Math.round(e.cache_read_tokens/e.input_tokens*100)}%`}export{r as i,i as n,a as r,o as t};