@cartanova/qgrid-cli 2.7.6 → 2.7.7

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 (71) hide show
  1. package/bundle/dist/application/qgrid/qgrid.frame.js +5 -3
  2. package/bundle/dist/application/qgrid/token-trigger-setup.js +3 -2
  3. package/bundle/dist/application/qgrid/token-window-keepalive.js +10 -4
  4. package/bundle/dist/application/setting/setting.constant.js +8 -4
  5. package/bundle/dist/application/setting/setting.model.js +4 -3
  6. package/bundle/dist/application/setting/setting.store.js +2 -2
  7. package/bundle/dist/application/setting/setting.types.js +4 -2
  8. package/bundle/dist/application/sonamu.generated.js +5 -3
  9. package/bundle/dist/application/sonamu.generated.sso.js +3 -2
  10. package/bundle/dist/application/token/token.model.js +7 -2
  11. package/bundle/dist/application/token/token.types.js +3 -2
  12. package/bundle/dist/i18n/sd.generated.js +2 -1
  13. package/bundle/dist/migrations/20260820153531_alter_tokens_add1_alter1.js +15 -0
  14. package/bundle/dist/utils/providers/openai/openai-quota.js +4 -3
  15. package/bundle/src/application/qgrid/qgrid.frame.test.ts +9 -0
  16. package/bundle/src/application/qgrid/qgrid.frame.ts +2 -0
  17. package/bundle/src/application/qgrid/token-trigger-setup.test.ts +6 -0
  18. package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -1
  19. package/bundle/src/application/qgrid/token-window-keepalive.test.ts +23 -2
  20. package/bundle/src/application/qgrid/token-window-keepalive.ts +13 -3
  21. package/bundle/src/application/setting/setting.constant.test.ts +2 -2
  22. package/bundle/src/application/setting/setting.constant.ts +8 -3
  23. package/bundle/src/application/setting/setting.model.ts +13 -2
  24. package/bundle/src/application/setting/setting.store.ts +2 -2
  25. package/bundle/src/application/setting/setting.types.ts +2 -0
  26. package/bundle/src/application/sonamu.generated.http +2 -1
  27. package/bundle/src/application/sonamu.generated.sso.ts +1 -0
  28. package/bundle/src/application/sonamu.generated.ts +3 -0
  29. package/bundle/src/application/token/token.entity.json +8 -1
  30. package/bundle/src/application/token/token.model.test.ts +29 -0
  31. package/bundle/src/application/token/token.model.ts +10 -1
  32. package/bundle/src/application/token/token.types.test.ts +10 -0
  33. package/bundle/src/application/token/token.types.ts +1 -0
  34. package/bundle/src/i18n/sd.generated.ts +1 -0
  35. package/bundle/src/migrations/20260820153531_alter_tokens_add1_alter1.ts +15 -0
  36. package/bundle/src/utils/providers/openai/openai-quota.test.ts +31 -0
  37. package/bundle/src/utils/providers/openai/openai-quota.ts +8 -2
  38. package/bundle/web-dist/client/assets/index-BshX4Gd8.css +1 -0
  39. package/bundle/web-dist/client/assets/{index-BbOyk01G.js → index-CokxkK2G.js} +4 -4
  40. package/bundle/web-dist/client/assets/{logs-B7Pc88bf.js → logs-Ckqp98Y3.js} +1 -1
  41. package/bundle/web-dist/client/assets/{monit-CzMV4lZf.js → monit-u0pMHbyf.js} +1 -1
  42. package/bundle/web-dist/client/assets/{routes-COWTRxZo.js → routes-DobxbCAV.js} +2 -2
  43. package/bundle/web-dist/client/assets/{services.generated-CA7oRRYY.js → services.generated-Da1Eq5_R.js} +1 -1
  44. package/bundle/web-dist/client/assets/settings-C8rfyi9z.js +1 -0
  45. package/bundle/web-dist/client/assets/{show-YvdybkKd.js → show-FbUQYs4L.js} +1 -1
  46. package/bundle/web-dist/client/assets/tokens-DFwEavFD.js +1 -0
  47. package/bundle/web-dist/client/assets/use-oauth-login-flow-D-9h3WUt.js +1 -0
  48. package/bundle/web-dist/client/assets/use-update-token-mutation-CZ57EKA1.js +1 -0
  49. package/bundle/web-dist/client/index.html +3 -3
  50. package/bundle/web-dist/server/assets/{lazyRouteComponent-tS-rTksX.js → lazyRouteComponent-V1S8gqMS.js} +2 -2
  51. package/bundle/web-dist/server/assets/{logs-ttf79emP.js → logs-2Tkqcncq.js} +2 -2
  52. package/bundle/web-dist/server/assets/{monit-CJBUH0l6.js → monit-DrJ15xJL.js} +2 -2
  53. package/bundle/web-dist/server/assets/{plus-BuSuoeon.js → plus-DCai02Na.js} +1 -1
  54. package/bundle/web-dist/server/assets/{react-dom-7_SqhNWR.js → react-dom-BFiG5Daf.js} +1 -1
  55. package/bundle/web-dist/server/assets/{routes-z79f1sOM.js → routes-B9X0lvjQ.js} +5 -4
  56. package/bundle/web-dist/server/assets/{services.generated-B5UkNmgc.js → services.generated-y7xVOTCo.js} +4 -2
  57. package/bundle/web-dist/server/assets/{settings-CIYR7h3G.js → settings-JznSjwGQ.js} +79 -5
  58. package/bundle/web-dist/server/assets/{show-CLrA4ZQ-.js → show-D8kuAFbc.js} +2 -2
  59. package/bundle/web-dist/server/assets/{tokens-BwlkZELV.js → tokens-CyItE-L-.js} +4 -3
  60. package/bundle/web-dist/server/assets/{use-oauth-login-flow-B2QllVMi.js → use-oauth-login-flow-CItVKngs.js} +2 -7
  61. package/bundle/web-dist/server/assets/use-update-token-mutation-BNaRPVaX.js +7 -0
  62. package/bundle/web-dist/server/entry-server.generated.js +10 -10
  63. package/package.json +1 -1
  64. package/skills/qgrid/references/cli-env-and-server-boot.md +1 -0
  65. package/skills/qgrid/references/sonamu-api-web-flow.md +2 -0
  66. package/skills/qgrid/references/token-auth-quota-lifecycle.md +25 -1
  67. package/bundle/web-dist/client/assets/index-D9xE_tO7.css +0 -1
  68. package/bundle/web-dist/client/assets/settings-Dpaosy-o.js +0 -1
  69. package/bundle/web-dist/client/assets/tokens-DynVDx0c.js +0 -1
  70. package/bundle/web-dist/client/assets/use-oauth-login-flow-OzmHElsE.js +0 -1
  71. /package/bundle/web-dist/client/assets/{plus-DqBd6gHk.js → plus-p4vtg6LN.js} +0 -0
@@ -1,5 +1,6 @@
1
1
  import { getLogger } from "@logtape/logtape";
2
2
 
3
+ import { TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY } from "../setting/setting.constant";
3
4
  import { getSetting } from "../setting/setting.store";
4
5
  import { TokenModel } from "../token/token.model";
5
6
  import { type InternalQueryInput } from "./qgrid.dispatcher";
@@ -17,13 +18,14 @@ export const POST_KEEPALIVE_USAGE_DELAY_MS = 61_000;
17
18
  export const KEEPALIVE_MODEL = "anthropic/claude-haiku-4-5";
18
19
  export const KEEPALIVE_PROJECT_NAME = "qgrid-token-window-keepalive";
19
20
  export const KEEPALIVE_SETTING_KEY = "qgrid.tokenWindowKeepaliveEnabled";
20
- export const KEEPALIVE_ENV_KEY = "QGRID_TOKEN_WINDOW_KEEPALIVE_ENABLED";
21
+ export const KEEPALIVE_ENV_KEY = TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY;
21
22
 
22
23
  type KeepaliveToken = {
23
24
  id: number;
24
25
  name: string;
25
26
  provider: string;
26
27
  active: boolean;
28
+ keepalive_enabled: boolean;
27
29
  };
28
30
 
29
31
  type TimerHandle = ReturnType<typeof setTimeout>;
@@ -33,6 +35,7 @@ export type TokenWindowKeepaliveDeps = {
33
35
  readUsage: (tokenId: number) => Promise<UsageResponse>;
34
36
  dispatch: (input: InternalQueryInput) => Promise<unknown>;
35
37
  readSetting: typeof getSetting;
38
+ isRunnerEnabled: () => boolean;
36
39
  now: () => number;
37
40
  setTimer: (callback: () => void, delayMs: number) => TimerHandle;
38
41
  clearTimer: (handle: TimerHandle) => void;
@@ -43,6 +46,7 @@ const defaultDeps: TokenWindowKeepaliveDeps = {
43
46
  readUsage: (tokenId) => QgridFrame.usage(tokenId),
44
47
  dispatch: (input) => QgridFrame.query(input),
45
48
  readSetting: getSetting,
49
+ isRunnerEnabled: () => process.env[KEEPALIVE_ENV_KEY] === "true",
46
50
  now: () => Date.now(),
47
51
  setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
48
52
  clearTimer: (handle) => clearTimeout(handle),
@@ -191,7 +195,11 @@ export async function startTokenWindowKeepalive(
191
195
  deps: TokenWindowKeepaliveDeps = defaultDeps,
192
196
  ): Promise<void> {
193
197
  clearScheduledTimers();
194
- if (deps.readSetting(KEEPALIVE_SETTING_KEY, KEEPALIVE_ENV_KEY) === "false") {
198
+ if (!deps.isRunnerEnabled()) {
199
+ logger.info("token window keepalive runner disabled for this instance");
200
+ return;
201
+ }
202
+ if (deps.readSetting(KEEPALIVE_SETTING_KEY) === "false") {
195
203
  logger.info("token window keepalive disabled");
196
204
  return;
197
205
  }
@@ -206,7 +214,9 @@ export async function startTokenWindowKeepalive(
206
214
  }
207
215
  if (generation !== expectedGeneration) return;
208
216
 
209
- const targets = tokens.filter((token) => token.active && token.provider === "anthropic");
217
+ const targets = tokens.filter(
218
+ (token) => token.active && token.provider === "anthropic" && token.keepalive_enabled,
219
+ );
210
220
  const targetIds = new Set(targets.map((token) => token.id));
211
221
  for (const tokenId of fireGuards.keys()) {
212
222
  if (!targetIds.has(tokenId)) fireGuards.delete(tokenId);
@@ -76,11 +76,11 @@ describe("SETTING_DEFS", () => {
76
76
  it("token window keepalive 비상 스위치를 즉시 적용 설정으로 등록한다", () => {
77
77
  expect(findSettingDef("qgrid.tokenWindowKeepaliveEnabled")).toMatchObject({
78
78
  group: "qgrid",
79
- envKey: "QGRID_TOKEN_WINDOW_KEEPALIVE_ENABLED",
80
79
  kind: "boolean",
81
80
  applies: "immediate",
82
81
  fallback: "true",
83
82
  });
83
+ expect(findSettingDef("qgrid.tokenWindowKeepaliveEnabled")?.envKey).toBeUndefined();
84
84
  });
85
85
 
86
86
  it("키가 중복되지 않는다", () => {
@@ -90,7 +90,7 @@ describe("SETTING_DEFS", () => {
90
90
 
91
91
  it("env 키도 중복되지 않는다", () => {
92
92
  // 겹치면 한 env 를 두 설정이 서로 덮어쓴다.
93
- const envKeys = SETTING_DEFS.map((d) => d.envKey);
93
+ const envKeys = SETTING_DEFS.flatMap((d) => (d.envKey ? [d.envKey] : []));
94
94
  expect(new Set(envKeys).size).toBe(envKeys.length);
95
95
  });
96
96
 
@@ -22,7 +22,7 @@ export type SettingGroup = "qgrid" | "slack" | "slackConnection";
22
22
  export type SettingDef = {
23
23
  key: string;
24
24
  group: SettingGroup;
25
- envKey: string;
25
+ envKey?: string;
26
26
  label: string;
27
27
  kind: SettingKind;
28
28
  /** 적용 시점. 부팅 시 한 번만 읽히는 값은 재시작이 필요하다. */
@@ -39,16 +39,21 @@ export type SettingDef = {
39
39
  help?: string;
40
40
  };
41
41
 
42
+ /**
43
+ * keepalive 스케줄러를 실제로 소유할 인스턴스만 켠다. Settings DB 값과 일부러 분리한다 —
44
+ * 같은 DB를 보는 개발자 로컬 인스턴스가 전역 설정을 읽고 함께 발사하면 중복 요청이 된다.
45
+ */
46
+ export const TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY = "QGRID_TOKEN_WINDOW_KEEPALIVE_ENABLED";
47
+
42
48
  export const SETTING_DEFS: SettingDef[] = [
43
49
  {
44
50
  key: "qgrid.tokenWindowKeepaliveEnabled",
45
51
  fallback: "true",
46
52
  group: "qgrid",
47
- envKey: "QGRID_TOKEN_WINDOW_KEEPALIVE_ENABLED",
48
53
  label: "토큰 윈도우 keepalive",
49
54
  kind: "boolean",
50
55
  applies: "immediate",
51
- help: "끄면 활성 Anthropic 토큰의 5시간 윈도우 자동 유지 요청을 중단합니다",
56
+ help: "끄면 이 서버에서 선택한 Anthropic 토큰의 5시간 윈도우 자동 유지 요청을 중단합니다",
52
57
  },
53
58
  {
54
59
  key: "slack.enabled",
@@ -16,7 +16,13 @@ import { detectSupervisor } from "../../utils/process-supervisor";
16
16
  import { armServerRestartExit, beginServerRestart } from "../../utils/server-restart";
17
17
  import { type SettingSubsetKey, type SettingSubsetMapping } from "../sonamu.generated";
18
18
  import { settingSubsetQueries, settingLoaderQueries } from "../sonamu.generated.sso";
19
- import { findSettingDef, maskSecret, SETTING_DEFS, validateSettingValue } from "./setting.constant";
19
+ import {
20
+ findSettingDef,
21
+ maskSecret,
22
+ SETTING_DEFS,
23
+ TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY,
24
+ validateSettingValue,
25
+ } from "./setting.constant";
20
26
  import {
21
27
  getSetting,
22
28
  isStored,
@@ -252,7 +258,12 @@ class SettingModelClass extends BaseModelClass<
252
258
  };
253
259
  });
254
260
 
255
- return { settings, runtime: this.runtimeInfo(), supervisor: detectSupervisor() };
261
+ return {
262
+ settings,
263
+ runtime: this.runtimeInfo(),
264
+ supervisor: detectSupervisor(),
265
+ keepaliveRunnerEnabled: process.env[TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY] === "true",
266
+ };
256
267
  }
257
268
 
258
269
  private runtimeInfo(): RuntimeInfoItem[] {
@@ -54,8 +54,8 @@ export async function loadSettings(persistence: SettingPersistence = SettingMode
54
54
  }
55
55
 
56
56
  /** DB → env 순. 둘 다 없으면 undefined 라 호출부의 기본값으로 떨어진다. */
57
- export function getSetting(key: string, envKey: string): string | undefined {
58
- return cache?.get(key) ?? process.env[envKey];
57
+ export function getSetting(key: string, envKey?: string): string | undefined {
58
+ return cache?.get(key) ?? (envKey ? process.env[envKey] : undefined);
59
59
  }
60
60
 
61
61
  /** 저장된 값인지 — 화면에서 출처를 구분해 보여준다. */
@@ -61,5 +61,7 @@ export const SettingsResponse = z.object({
61
61
  runtime: RuntimeInfoItem.array(),
62
62
  /** null 이면 재시작 버튼을 쓸 수 없다 — 종료해도 되살릴 주체가 없다. */
63
63
  supervisor: SupervisorKind.nullable(),
64
+ /** 이 인스턴스가 keepalive 스케줄러를 소유하도록 명시적으로 허용됐는지. */
65
+ keepaliveRunnerEnabled: z.boolean(),
64
66
  });
65
67
  export type SettingsResponse = z.infer<typeof SettingsResponse>;
@@ -566,7 +566,8 @@ Content-Type: application/json
566
566
  "id": 0,
567
567
  "name": "NAME",
568
568
  "quotaThreshold": null,
569
- "weight": 0
569
+ "weight": 0,
570
+ "keepaliveEnabled": false
570
571
  }
571
572
 
572
573
  ###
@@ -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
+ }
@@ -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
  }