@cartanova/qgrid-cli 2.7.6 → 2.7.9

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 (86) hide show
  1. package/bundle/dist/application/qgrid/qgrid.frame.js +67 -30
  2. package/bundle/dist/application/qgrid/qgrid.types.js +6 -3
  3. package/bundle/dist/application/qgrid/token-trigger-setup.js +3 -2
  4. package/bundle/dist/application/qgrid/token-window-keepalive.js +10 -4
  5. package/bundle/dist/application/request-log/request-log.model.js +2 -2
  6. package/bundle/dist/application/setting/setting.constant.js +8 -4
  7. package/bundle/dist/application/setting/setting.model.js +4 -3
  8. package/bundle/dist/application/setting/setting.store.js +2 -2
  9. package/bundle/dist/application/setting/setting.types.js +4 -2
  10. package/bundle/dist/application/sonamu.generated.js +5 -3
  11. package/bundle/dist/application/sonamu.generated.sso.js +3 -2
  12. package/bundle/dist/application/token/token.model.js +7 -2
  13. package/bundle/dist/application/token/token.types.js +3 -2
  14. package/bundle/dist/i18n/sd.generated.js +2 -1
  15. package/bundle/dist/migrations/20260820153531_alter_tokens_add1_alter1.js +15 -0
  16. package/bundle/dist/sonamu.config.js +3 -1
  17. package/bundle/dist/utils/providers/openai/openai-callback-relay.js +86 -0
  18. package/bundle/dist/utils/providers/openai/openai-quota.js +4 -3
  19. package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +71 -8
  20. package/bundle/src/application/qgrid/qgrid.frame.test.ts +52 -0
  21. package/bundle/src/application/qgrid/qgrid.frame.ts +74 -26
  22. package/bundle/src/application/qgrid/qgrid.types.ts +4 -1
  23. package/bundle/src/application/qgrid/token-trigger-setup.test.ts +6 -0
  24. package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -1
  25. package/bundle/src/application/qgrid/token-window-keepalive.test.ts +23 -2
  26. package/bundle/src/application/qgrid/token-window-keepalive.ts +13 -3
  27. package/bundle/src/application/request-log/request-log.model.test.ts +25 -0
  28. package/bundle/src/application/request-log/request-log.model.ts +3 -1
  29. package/bundle/src/application/setting/setting.constant.test.ts +2 -2
  30. package/bundle/src/application/setting/setting.constant.ts +8 -3
  31. package/bundle/src/application/setting/setting.model.ts +13 -2
  32. package/bundle/src/application/setting/setting.store.ts +2 -2
  33. package/bundle/src/application/setting/setting.types.ts +2 -0
  34. package/bundle/src/application/sonamu.generated.http +9 -4
  35. package/bundle/src/application/sonamu.generated.sso.ts +1 -0
  36. package/bundle/src/application/sonamu.generated.ts +3 -0
  37. package/bundle/src/application/token/token.entity.json +8 -1
  38. package/bundle/src/application/token/token.model.test.ts +29 -0
  39. package/bundle/src/application/token/token.model.ts +10 -1
  40. package/bundle/src/application/token/token.types.test.ts +10 -0
  41. package/bundle/src/application/token/token.types.ts +1 -0
  42. package/bundle/src/i18n/sd.generated.ts +1 -0
  43. package/bundle/src/migrations/20260820153531_alter_tokens_add1_alter1.ts +15 -0
  44. package/bundle/src/sonamu.config.ts +2 -0
  45. package/bundle/src/utils/providers/openai/openai-callback-relay.test.ts +109 -0
  46. package/bundle/src/utils/providers/openai/openai-callback-relay.ts +105 -0
  47. package/bundle/src/utils/providers/openai/openai-quota.test.ts +31 -0
  48. package/bundle/src/utils/providers/openai/openai-quota.ts +8 -2
  49. package/bundle/web-dist/client/assets/index-BshX4Gd8.css +1 -0
  50. package/bundle/web-dist/client/assets/{index-BbOyk01G.js → index-D59Q_9Z4.js} +4 -4
  51. package/bundle/web-dist/client/assets/{logs-B7Pc88bf.js → logs-BHVfI8ut.js} +1 -1
  52. package/bundle/web-dist/client/assets/{monit-CzMV4lZf.js → monit-EkLxH1oN.js} +1 -1
  53. package/bundle/web-dist/client/assets/{routes-COWTRxZo.js → routes-Kbvuclip.js} +2 -2
  54. package/bundle/web-dist/client/assets/{services.generated-CA7oRRYY.js → services.generated-Da1Eq5_R.js} +1 -1
  55. package/bundle/web-dist/client/assets/settings-B4uTFP3J.js +1 -0
  56. package/bundle/web-dist/client/assets/{show-YvdybkKd.js → show-BjIdSPTd.js} +1 -1
  57. package/bundle/web-dist/client/assets/tokens-BbCSxbFu.js +1 -0
  58. package/bundle/web-dist/client/assets/use-oauth-login-flow-4OltfVAd.js +1 -0
  59. package/bundle/web-dist/client/assets/use-update-token-mutation-CZ57EKA1.js +1 -0
  60. package/bundle/web-dist/client/index.html +3 -3
  61. package/bundle/web-dist/server/assets/{lazyRouteComponent-tS-rTksX.js → lazyRouteComponent-V1S8gqMS.js} +2 -2
  62. package/bundle/web-dist/server/assets/{logs-ttf79emP.js → logs-2Tkqcncq.js} +2 -2
  63. package/bundle/web-dist/server/assets/{monit-CJBUH0l6.js → monit-DrJ15xJL.js} +2 -2
  64. package/bundle/web-dist/server/assets/{plus-BuSuoeon.js → plus-DCai02Na.js} +1 -1
  65. package/bundle/web-dist/server/assets/{react-dom-7_SqhNWR.js → react-dom-BFiG5Daf.js} +1 -1
  66. package/bundle/web-dist/server/assets/{routes-z79f1sOM.js → routes-Ct3I9263.js} +7 -5
  67. package/bundle/web-dist/server/assets/{services.generated-B5UkNmgc.js → services.generated-y7xVOTCo.js} +4 -2
  68. package/bundle/web-dist/server/assets/{settings-CIYR7h3G.js → settings-JznSjwGQ.js} +79 -5
  69. package/bundle/web-dist/server/assets/{show-CLrA4ZQ-.js → show-D8kuAFbc.js} +2 -2
  70. package/bundle/web-dist/server/assets/{tokens-BwlkZELV.js → tokens-PR42jF1J.js} +6 -4
  71. package/bundle/web-dist/server/assets/{use-oauth-login-flow-B2QllVMi.js → use-oauth-login-flow-C-33Z5HA.js} +18 -17
  72. package/bundle/web-dist/server/assets/use-update-token-mutation-BNaRPVaX.js +7 -0
  73. package/bundle/web-dist/server/entry-server.generated.js +10 -10
  74. package/dist/cli.js +10 -11
  75. package/package.json +1 -1
  76. package/skills/qgrid/references/ai-sdk-provider-contract.md +2 -0
  77. package/skills/qgrid/references/cli-env-and-server-boot.md +2 -1
  78. package/skills/qgrid/references/openai-codex-runtime.md +12 -5
  79. package/skills/qgrid/references/request-log-run-lifecycle.md +2 -0
  80. package/skills/qgrid/references/sonamu-api-web-flow.md +2 -0
  81. package/skills/qgrid/references/token-auth-quota-lifecycle.md +27 -1
  82. package/bundle/web-dist/client/assets/index-D9xE_tO7.css +0 -1
  83. package/bundle/web-dist/client/assets/settings-Dpaosy-o.js +0 -1
  84. package/bundle/web-dist/client/assets/tokens-DynVDx0c.js +0 -1
  85. package/bundle/web-dist/client/assets/use-oauth-login-flow-OzmHElsE.js +0 -1
  86. /package/bundle/web-dist/client/assets/{plus-DqBd6gHk.js → plus-p4vtg6LN.js} +0 -0
@@ -41,7 +41,8 @@ Content-Type: application/json
41
41
  "active": false,
42
42
  "ord": 0,
43
43
  "quota_threshold": null,
44
- "weight": 1
44
+ "weight": 1,
45
+ "keepalive_enabled": false
45
46
  }
46
47
  ]
47
48
  }
@@ -437,7 +438,9 @@ Content-Type: application/json
437
438
  "modelName": "MODELNAME",
438
439
  "effort": "EFFORT",
439
440
  "projectName": "PROJECTNAME",
440
- "history": "HISTORY"
441
+ "history": "HISTORY",
442
+ "isStructured": false,
443
+ "jsonSchema": null
441
444
  }
442
445
  }
443
446
 
@@ -501,7 +504,8 @@ Content-Type: application/json
501
504
  "costSource": "provider",
502
505
  "totalDurationMs": 0,
503
506
  "history": "HISTORY",
504
- "errorMessage": "ERRORMESSAGE"
507
+ "errorMessage": "ERRORMESSAGE",
508
+ "responseJsonOk": false
505
509
  }
506
510
  }
507
511
 
@@ -566,7 +570,8 @@ Content-Type: application/json
566
570
  "id": 0,
567
571
  "name": "NAME",
568
572
  "quotaThreshold": null,
569
- "weight": 0
573
+ "weight": 0,
574
+ "keepaliveEnabled": false
570
575
  }
571
576
 
572
577
  ###
@@ -213,6 +213,7 @@ export const tokenSubsetQueries = {
213
213
  ord: "tokens.ord",
214
214
  quota_threshold: "tokens.quota_threshold",
215
215
  weight: "tokens.weight",
216
+ keepalive_enabled: "tokens.keepalive_enabled",
216
217
  });
217
218
  },
218
219
  };
@@ -299,6 +299,7 @@ export const TokenBaseSchema = z.object({
299
299
  ord: z.int(),
300
300
  quota_threshold: z.int().nullable(),
301
301
  weight: z.int(),
302
+ keepalive_enabled: z.boolean(),
302
303
  });
303
304
  export type TokenBaseSchema = z.infer<typeof TokenBaseSchema> & {
304
305
  readonly __hasDefault__: readonly [
@@ -307,6 +308,7 @@ export type TokenBaseSchema = z.infer<typeof TokenBaseSchema> & {
307
308
  "ord",
308
309
  "quota_threshold",
309
310
  "weight",
311
+ "keepalive_enabled",
310
312
  "id",
311
313
  ];
312
314
  };
@@ -558,6 +560,7 @@ export const TokenSubsetA = z.object({
558
560
  ord: z.int(),
559
561
  quota_threshold: z.int().nullable(),
560
562
  weight: z.int(),
563
+ keepalive_enabled: z.boolean(),
561
564
  });
562
565
  export type TokenSubsetA = z.infer<typeof TokenSubsetA>;
563
566
  export type TokenSubsetMapping = {
@@ -26,6 +26,12 @@
26
26
  "type": "integer",
27
27
  "desc": "가중 라운드로빈 라우팅 가중치 (1..100)",
28
28
  "dbDefault": "1"
29
+ },
30
+ {
31
+ "name": "keepalive_enabled",
32
+ "type": "boolean",
33
+ "desc": "Anthropic 5시간 토큰 윈도우 keepalive 사용 여부",
34
+ "dbDefault": "false"
29
35
  }
30
36
  ],
31
37
  "indexes": [
@@ -46,7 +52,8 @@
46
52
  "active",
47
53
  "ord",
48
54
  "quota_threshold",
49
- "weight"
55
+ "weight",
56
+ "keepalive_enabled"
50
57
  ]
51
58
  },
52
59
  "enums": {
@@ -111,6 +111,35 @@ describe("TokenModel.updateFields", () => {
111
111
  });
112
112
  });
113
113
 
114
+ describe("TokenModel.replaceByAccount", () => {
115
+ afterEach(() => {
116
+ vi.restoreAllMocks();
117
+ });
118
+
119
+ it("re-login으로 row가 교체돼도 기존 keepalive 선택을 보존한다", async () => {
120
+ vi.spyOn(TokenModel, "findByAccountIdentifier").mockResolvedValue([
121
+ {
122
+ id: 1,
123
+ created_at: new Date(),
124
+ active: true,
125
+ ord: 0,
126
+ quota_threshold: 80,
127
+ weight: 1,
128
+ keepalive_enabled: true,
129
+ ...baseToken,
130
+ },
131
+ ]);
132
+ vi.spyOn(TokenModel, "del").mockResolvedValue(1);
133
+ const save = vi.spyOn(TokenModel, "save").mockResolvedValue([2]);
134
+
135
+ await TokenModel.replaceByAccount("anthropic", "acc-1", baseToken);
136
+
137
+ expect(save).toHaveBeenCalledWith([
138
+ expect.objectContaining({ keepalive_enabled: true }),
139
+ ]);
140
+ });
141
+ });
142
+
114
143
  describe("TokenModel.findActiveByProviderAndName", () => {
115
144
  afterEach(() => {
116
145
  vi.restoreAllMocks();
@@ -23,6 +23,7 @@ export type TokenUpdateFields = {
23
23
  name?: string;
24
24
  quota_threshold?: number | null;
25
25
  weight?: number;
26
+ keepalive_enabled?: boolean;
26
27
  };
27
28
 
28
29
  function applyCreateDefaults(sp: TokenSaveParams): TokenSaveParams {
@@ -235,8 +236,12 @@ class TokenModelClass extends BaseModelClass<
235
236
  accountId: string | undefined,
236
237
  saveParams: TokenSaveParams,
237
238
  ): Promise<void> {
239
+ let keepaliveEnabled = saveParams.keepalive_enabled;
238
240
  if (accountId) {
239
241
  const oldEntries = await this.findByAccountIdentifier("A", provider, accountId);
242
+ if (keepaliveEnabled === undefined && oldEntries.length > 0) {
243
+ keepaliveEnabled = oldEntries.some((entry) => entry.keepalive_enabled);
244
+ }
240
245
  if (oldEntries.length > 0) await this.del(oldEntries.map((o) => o.id));
241
246
  } else {
242
247
  logger.warn(
@@ -244,7 +249,11 @@ class TokenModelClass extends BaseModelClass<
244
249
  );
245
250
  }
246
251
 
247
- await this.save([saveParams]);
252
+ await this.save([
253
+ keepaliveEnabled === undefined
254
+ ? saveParams
255
+ : { ...saveParams, keepalive_enabled: keepaliveEnabled },
256
+ ]);
248
257
  }
249
258
 
250
259
  /**
@@ -42,4 +42,14 @@ describe("TokenSaveParams", () => {
42
42
  expect(() => TokenSaveParams.parse({ ...baseToken, weight })).toThrow();
43
43
  }
44
44
  });
45
+
46
+ it("keeps per-token keepalive opt-in optional and accepts booleans", () => {
47
+ expect(TokenSaveParams.parse(baseToken)).not.toHaveProperty("keepalive_enabled");
48
+ expect(
49
+ TokenSaveParams.parse({ ...baseToken, keepalive_enabled: true }).keepalive_enabled,
50
+ ).toBe(true);
51
+ expect(() =>
52
+ TokenSaveParams.parse({ ...baseToken, keepalive_enabled: "true" }),
53
+ ).toThrow();
54
+ });
45
55
  });
@@ -41,6 +41,7 @@ export const TokenSaveParams = TokenBaseSchema.partial({
41
41
  ord: true,
42
42
  quota_threshold: true,
43
43
  weight: true,
44
+ keepalive_enabled: true,
44
45
  }).extend({
45
46
  quota_threshold: TokenQuotaThreshold.optional(),
46
47
  weight: TokenWeight.optional(),
@@ -320,6 +320,7 @@ const entityLabels = {
320
320
  "entity.Token.ord": "정렬 순서",
321
321
  "entity.Token.quota_threshold": "quota 사용률 임계치(%). null이면 미설정",
322
322
  "entity.Token.weight": "가중 라운드로빈 라우팅 가중치 (1..100)",
323
+ "entity.Token.keepalive_enabled": "Anthropic 5시간 토큰 윈도우 keepalive 사용 여부",
323
324
  "enum.TokenOrderBy.id-desc": "ID최신순",
324
325
  "enum.TokenOrderBy.ord-asc": "순서순",
325
326
  "enum.TokenSearchField.id": "ID",
@@ -0,0 +1,15 @@
1
+ import { type Knex } from "knex";
2
+
3
+ export async function up(knex: Knex): Promise<void> {
4
+ await knex.schema.alterTable("tokens", (table) => {
5
+ // add
6
+ table.boolean("keepalive_enabled").notNullable().defaultTo(knex.raw("false"));
7
+ });
8
+ }
9
+
10
+ export async function down(knex: Knex): Promise<void> {
11
+ await knex.schema.alterTable("tokens", (table) => {
12
+ // rollback - add
13
+ table.dropColumns("keepalive_enabled");
14
+ });
15
+ }
@@ -25,6 +25,7 @@ import {
25
25
  import { loadSettings, setSettingChangeHandler } from "./application/setting/setting.store";
26
26
  import { handleServerError } from "./server-error-handler";
27
27
  import { AnthropicDispatcher } from "./utils/providers/anthropic/anthropic-dispatcher";
28
+ import { stopOpenAICallbackRelay } from "./utils/providers/openai/openai-callback-relay";
28
29
  import { OpenAIDispatcher } from "./utils/providers/openai/openai-dispatcher";
29
30
 
30
31
  const host = process.env.HOST ?? "localhost";
@@ -241,6 +242,7 @@ export default defineConfig({
241
242
  const log = getLogger(["qgrid", "startup"]);
242
243
  stopExpiredTokenReminder();
243
244
  stopTokenWindowKeepalive();
245
+ stopOpenAICallbackRelay();
244
246
  setSettingChangeHandler(null);
245
247
  QgridDispatcher.subscriber?.setTokenChangeHandler(null);
246
248
  if (QgridDispatcher.openaiDispatcher) {
@@ -0,0 +1,109 @@
1
+ import http from "node:http";
2
+ import type { AddressInfo } from "node:net";
3
+
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+
6
+ import {
7
+ OPENAI_CALLBACK_PATH,
8
+ OPENAI_CALLBACK_PORTS,
9
+ openAICallbackRedirectUri,
10
+ startOpenAICallbackRelay,
11
+ stopOpenAICallbackRelay,
12
+ } from "./openai-callback-relay";
13
+
14
+ const DASHBOARD = "http://localhost:44900";
15
+
16
+ function freePort(): Promise<number> {
17
+ return new Promise((resolve) => {
18
+ const probe = http.createServer();
19
+ probe.listen(0, "127.0.0.1", () => {
20
+ const port = (probe.address() as AddressInfo).port;
21
+ probe.close(() => resolve(port));
22
+ });
23
+ });
24
+ }
25
+
26
+ function occupy(port: number): Promise<http.Server> {
27
+ return new Promise((resolve) => {
28
+ const server = http.createServer();
29
+ server.listen(port, "127.0.0.1", () => resolve(server));
30
+ });
31
+ }
32
+
33
+ function get(url: string): Promise<{ status: number; location?: string }> {
34
+ return new Promise((resolve, reject) => {
35
+ const request = http.get(url, (response) => {
36
+ response.resume();
37
+ resolve({
38
+ status: response.statusCode ?? 0,
39
+ ...(response.headers.location ? { location: response.headers.location } : {}),
40
+ });
41
+ });
42
+ request.on("error", reject);
43
+ });
44
+ }
45
+
46
+ describe("openai callback relay", () => {
47
+ const occupied: http.Server[] = [];
48
+
49
+ afterEach(async () => {
50
+ stopOpenAICallbackRelay();
51
+ await Promise.all(occupied.splice(0).map((s) => new Promise((r) => s.close(r))));
52
+ });
53
+
54
+ it("pins the redirect URI to the ports OpenAI registered for the Codex CLI client", () => {
55
+ expect(OPENAI_CALLBACK_PORTS).toEqual([1455, 1457]);
56
+ expect(openAICallbackRedirectUri(1455)).toBe("http://localhost:1455/auth/callback");
57
+ });
58
+
59
+ it("forwards only code and state to the qgrid callback route", async () => {
60
+ const port = await freePort();
61
+ const redirectUri = await startOpenAICallbackRelay(DASHBOARD, 60_000, [port]);
62
+ expect(redirectUri).toBe(`http://localhost:${port}${OPENAI_CALLBACK_PATH}`);
63
+
64
+ const result = await get(
65
+ `http://127.0.0.1:${port}${OPENAI_CALLBACK_PATH}?code=the-code&state=the-state&extra=x`,
66
+ );
67
+
68
+ expect(result.status).toBe(302);
69
+ expect(result.location).toBe(
70
+ `${DASHBOARD}${OPENAI_CALLBACK_PATH}?code=the-code&state=the-state`,
71
+ );
72
+ });
73
+
74
+ it("answers 404 on any other path", async () => {
75
+ const port = await freePort();
76
+ await startOpenAICallbackRelay(DASHBOARD, 60_000, [port]);
77
+
78
+ expect((await get(`http://127.0.0.1:${port}/callback?code=c&state=s`)).status).toBe(404);
79
+ });
80
+
81
+ it("falls back to the next registered port when the first is taken", async () => {
82
+ const taken = await freePort();
83
+ const fallback = await freePort();
84
+ occupied.push(await occupy(taken));
85
+
86
+ const redirectUri = await startOpenAICallbackRelay(DASHBOARD, 60_000, [taken, fallback]);
87
+
88
+ expect(redirectUri).toBe(`http://localhost:${fallback}${OPENAI_CALLBACK_PATH}`);
89
+ });
90
+
91
+ it("reuses the running listener for a second login", async () => {
92
+ const port = await freePort();
93
+ const first = await startOpenAICallbackRelay(DASHBOARD, 60_000, [port]);
94
+ const second = await startOpenAICallbackRelay(DASHBOARD, 60_000, [await freePort()]);
95
+
96
+ expect(second).toBe(first);
97
+ });
98
+
99
+ it("fails when every registered port is taken", async () => {
100
+ const a = await freePort();
101
+ const b = await freePort();
102
+ occupied.push(await occupy(a), await occupy(b));
103
+
104
+ await expect(startOpenAICallbackRelay(DASHBOARD, 60_000, [a, b])).rejects.toThrow(
105
+ /PORTS_UNAVAILABLE/,
106
+ );
107
+ });
108
+ });
109
+
@@ -0,0 +1,105 @@
1
+ import http from "node:http";
2
+
3
+ import { getLogger } from "@logtape/logtape";
4
+
5
+ const logger = getLogger(["qgrid", "oauth"]);
6
+
7
+ /**
8
+ * OpenAI matches the Codex CLI client's redirect URIs by exact string, and only these two loopback
9
+ * callbacks are registered. Any other host, port, or path makes /oauth/authorize fail with
10
+ * `invalid_authorize_request` before the login screen renders, so qgrid cannot point the callback
11
+ * at its own configurable server port. Keep in sync with the Codex CLI allow-list (default 1455,
12
+ * fallback 1457).
13
+ */
14
+ export const OPENAI_CALLBACK_PORTS = [1455, 1457] as const;
15
+ export const OPENAI_CALLBACK_PATH = "/auth/callback";
16
+
17
+ export function openAICallbackRedirectUri(port: number): string {
18
+ return `http://localhost:${port}${OPENAI_CALLBACK_PATH}`;
19
+ }
20
+
21
+ type Relay = {
22
+ server: http.Server;
23
+ redirectUri: string;
24
+ dashboardBase: string;
25
+ expiryTimer: NodeJS.Timeout;
26
+ };
27
+
28
+ let relay: Relay | null = null;
29
+
30
+ function forward(request: http.IncomingMessage, response: http.ServerResponse): void {
31
+ const current = relay;
32
+ const incoming = new URL(request.url ?? "/", "http://localhost");
33
+ if (!current || incoming.pathname !== OPENAI_CALLBACK_PATH) {
34
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
35
+ response.end("not found");
36
+ return;
37
+ }
38
+
39
+ // Only the two OAuth result fields cross over, onto a fixed loopback base. The request never
40
+ // contributes scheme, authority, or path bytes to the redirect target.
41
+ const target = new URL(OPENAI_CALLBACK_PATH, current.dashboardBase);
42
+ for (const field of ["code", "state"] as const) {
43
+ const value = incoming.searchParams.get(field);
44
+ if (value) target.searchParams.set(field, value);
45
+ }
46
+ response.writeHead(302, { Location: target.toString(), "Cache-Control": "no-store" });
47
+ response.end();
48
+ }
49
+
50
+ function listenOn(port: number): Promise<http.Server | null> {
51
+ return new Promise((resolve, reject) => {
52
+ const server = http.createServer(forward);
53
+ server.once("error", (error: NodeJS.ErrnoException) => {
54
+ server.close();
55
+ if (error.code === "EADDRINUSE" || error.code === "EACCES") resolve(null);
56
+ else reject(error);
57
+ });
58
+ // Bind loopback only, like the Codex CLI: the relay must not be reachable off-host.
59
+ server.listen(port, "127.0.0.1", () => resolve(server));
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Opens a short-lived loopback listener on a registered Codex callback port and forwards the
65
+ * authorization code to qgrid own callback route. Returns the redirect URI to put in the authorize
66
+ * request. Repeated logins reuse the running listener and refresh its lifetime.
67
+ */
68
+ export async function startOpenAICallbackRelay(
69
+ dashboardBase: string,
70
+ ttlMs: number,
71
+ ports: readonly number[] = OPENAI_CALLBACK_PORTS,
72
+ ): Promise<string> {
73
+ if (relay) {
74
+ relay.dashboardBase = dashboardBase;
75
+ relay.expiryTimer.refresh();
76
+ return relay.redirectUri;
77
+ }
78
+
79
+ for (const port of ports) {
80
+ const server = await listenOn(port);
81
+ if (!server) continue;
82
+
83
+ const expiryTimer = setTimeout(() => stopOpenAICallbackRelay(), ttlMs);
84
+ expiryTimer.unref();
85
+ const address = server.address();
86
+ const boundPort = typeof address === "object" && address ? address.port : port;
87
+ relay = {
88
+ server,
89
+ redirectUri: openAICallbackRedirectUri(boundPort),
90
+ dashboardBase,
91
+ expiryTimer,
92
+ };
93
+ logger.info(`openai oauth callback relay listening on 127.0.0.1:${boundPort}`);
94
+ return relay.redirectUri;
95
+ }
96
+
97
+ throw new Error(`OPENAI_CALLBACK_PORTS_UNAVAILABLE_${ports.join("_")}`);
98
+ }
99
+
100
+ export function stopOpenAICallbackRelay(): void {
101
+ if (!relay) return;
102
+ clearTimeout(relay.expiryTimer);
103
+ relay.server.close();
104
+ relay = null;
105
+ }
@@ -147,6 +147,37 @@ describe("readOpenAIQuotaUsage", () => {
147
147
  });
148
148
  });
149
149
 
150
+ it("converts the current WHAM limit_window_seconds duration to minutes", async () => {
151
+ const fetchMock = vi.fn<typeof fetch>(async () =>
152
+ new Response(
153
+ JSON.stringify({
154
+ rate_limit: {
155
+ primary_window: {
156
+ used_percent: 21,
157
+ limit_window_seconds: 604_800,
158
+ reset_at: 123,
159
+ },
160
+ },
161
+ }),
162
+ { status: 200 },
163
+ ),
164
+ );
165
+
166
+ await expect(
167
+ readOpenAIQuotaUsage({
168
+ credentials: { accessToken: "access", accountId: "acct" },
169
+ fetch: fetchMock,
170
+ }),
171
+ ).resolves.toMatchObject({
172
+ kind: "ok",
173
+ raw: {
174
+ rateLimits: {
175
+ primary: { usedPercent: 21, windowDurationMins: 10_080, resetsAt: 123 },
176
+ },
177
+ },
178
+ });
179
+ });
180
+
150
181
  it("fails open for direct HTTP failures", async () => {
151
182
  await expect(
152
183
  readOpenAIQuotaUsage({
@@ -47,11 +47,17 @@ function windowFrom(value: unknown) {
47
47
  const source = value as Record<string, unknown>;
48
48
  const usedPercent = Number(source.usedPercent ?? source.used_percent);
49
49
  if (!Number.isFinite(usedPercent)) return null;
50
- const duration = source.windowDurationMins ?? source.window_minutes;
50
+ const durationMins = source.windowDurationMins ?? source.window_minutes;
51
+ const durationSeconds = source.limitWindowSeconds ?? source.limit_window_seconds;
51
52
  const resets = source.resetsAt ?? source.reset_at;
52
53
  return {
53
54
  usedPercent,
54
- windowDurationMins: duration === null || duration === undefined ? null : Number(duration),
55
+ windowDurationMins:
56
+ durationMins !== null && durationMins !== undefined
57
+ ? Number(durationMins)
58
+ : durationSeconds !== null && durationSeconds !== undefined
59
+ ? Number(durationSeconds) / 60
60
+ : null,
55
61
  resetsAt: resets === null || resets === undefined ? null : Number(resets),
56
62
  };
57
63
  }