@cartanova/qgrid-cli 2.4.4 → 2.4.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.
@@ -305,4 +305,28 @@ describe("QgridFrame.usage", () => {
305
305
  });
306
306
  expect(getRateLimitsByTokenIdMock).not.toHaveBeenCalled();
307
307
  });
308
+
309
+ it("returns the provider-reported OpenAI window duration", async () => {
310
+ findManyMock.mockResolvedValueOnce({
311
+ rows: [{ ...tokenEntry, provider: "openai", active: true }],
312
+ });
313
+ getRateLimitsByTokenIdMock.mockResolvedValueOnce({
314
+ data: {
315
+ rateLimits: {
316
+ primary: { usedPercent: 11, resetsAt: 1_784_000_000, windowDurationMins: 10_080 },
317
+ secondary: null,
318
+ },
319
+ },
320
+ });
321
+
322
+ await expect(QgridFrame.usage(1)).resolves.toEqual({
323
+ provider: "openai",
324
+ fiveHour: {
325
+ utilization: 11,
326
+ resetsAt: new Date(1_784_000_000 * 1_000).toISOString(),
327
+ windowDurationMins: 10_080,
328
+ },
329
+ sevenDay: null,
330
+ });
331
+ });
308
332
  });
@@ -583,12 +583,14 @@ class QgridFrameClass extends BaseFrameClass {
583
583
  ? {
584
584
  utilization: rl.primary.usedPercent,
585
585
  resetsAt: unixSecondsToIso(rl.primary.resetsAt),
586
+ windowDurationMins: rl.primary.windowDurationMins,
586
587
  }
587
588
  : null,
588
589
  sevenDay: rl?.secondary
589
590
  ? {
590
591
  utilization: rl.secondary.usedPercent,
591
592
  resetsAt: unixSecondsToIso(rl.secondary.resetsAt),
593
+ windowDurationMins: rl.secondary.windowDurationMins,
592
594
  }
593
595
  : null,
594
596
  };
@@ -256,6 +256,7 @@ const RateWindow = z
256
256
  .object({
257
257
  utilization: z.number().nullable(),
258
258
  resetsAt: z.string().nullable(),
259
+ windowDurationMins: z.number().nullable().optional(),
259
260
  })
260
261
  .nullable();
261
262
 
@@ -92,6 +92,32 @@ function createWorkerWithNotificationRpc(
92
92
  return { worker, request, handlers };
93
93
  }
94
94
 
95
+ describe("CodexAppServerWorker home isolation", () => {
96
+ it("places worker homes under the configured OS temp directory", () => {
97
+ const previousTmpdir = process.env.TMPDIR;
98
+ const customTmpdir = mkdtempSync(join(tmpdir(), "qgrid-tmp-root-test-"));
99
+
100
+ try {
101
+ process.env.TMPDIR = customTmpdir;
102
+ const worker = new CodexAppServerWorker({
103
+ tokenId: 41,
104
+ tokenName: "token",
105
+ accessToken: "access",
106
+ accountId: "account",
107
+ workerIndex: 14,
108
+ });
109
+
110
+ expect((worker as unknown as { codexHome: string }).codexHome).toBe(
111
+ join(customTmpdir, "qgrid-codex", "41-14"),
112
+ );
113
+ } finally {
114
+ if (previousTmpdir === undefined) delete process.env.TMPDIR;
115
+ else process.env.TMPDIR = previousTmpdir;
116
+ rmSync(customTmpdir, { recursive: true, force: true });
117
+ }
118
+ });
119
+ });
120
+
95
121
  describe("CodexAppServerWorker active turn cleanup", () => {
96
122
  it("rejects a non-streaming turn when the worker process exits", async () => {
97
123
  const { worker } = createWorkerWithFakeRpc();
@@ -1,5 +1,7 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
2
  import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
3
5
 
4
6
  import { getLogger } from "@logtape/logtape";
5
7
 
@@ -169,7 +171,7 @@ export class CodexAppServerWorker {
169
171
 
170
172
  constructor(private config: WorkerConfig) {
171
173
  const suffix = config.workerIndex !== undefined ? `-${config.workerIndex}` : "";
172
- this.codexHome = `/tmp/qgrid-codex/${config.tokenId}${suffix}`;
174
+ this.codexHome = join(tmpdir(), "qgrid-codex", `${config.tokenId}${suffix}`);
173
175
  }
174
176
 
175
177
  // ── Lifecycle ───────────────────────────────────────────────────
@@ -4,7 +4,15 @@ import { QuotaThresholdExceededError } from "../../../application/qgrid/qgrid.ty
4
4
  import { type OpenAICredentials } from "../../../application/token/token.types";
5
5
  import { type GenerateRequest, type GenerateResult } from "../common/provider-dispatcher";
6
6
  import { type CodexAppServerWorker } from "./codex-worker";
7
- import { ImageGenerationError, OpenAIDispatcher } from "./openai-dispatcher";
7
+ import {
8
+ estimateOpenAIWorkerRssGiB,
9
+ ImageGenerationError,
10
+ makeOpenAIWorkerId,
11
+ MAX_OPENAI_WORKERS_PER_TOKEN,
12
+ type OpenAIWorkerPoolConfig,
13
+ OpenAIDispatcher,
14
+ resolveOpenAIWorkerPoolConfig,
15
+ } from "./openai-dispatcher";
8
16
  import { type OpenAIQuotaUsageResult } from "./openai-quota";
9
17
 
10
18
  const { readOpenAIQuotaUsageMock, loggerInfoMock, loggerWarnMock } = vi.hoisted(() => ({
@@ -70,7 +78,7 @@ function resultFor(worker: CodexAppServerWorker, threadId = "thread-new"): Gener
70
78
  durationMs: 10,
71
79
  model: "gpt-5-codex",
72
80
  threadCoord: {
73
- workerId: worker.tokenId * 10 + worker.workerIndex,
81
+ workerId: makeOpenAIWorkerId(worker.tokenId, worker.workerIndex),
74
82
  threadId,
75
83
  epoch: worker.epoch,
76
84
  },
@@ -159,6 +167,21 @@ function addWorkers(dispatcher: OpenAIDispatcher, workers: CodexAppServerWorker[
159
167
  for (const [tokenId, group] of byToken) dispatcher.workerPool.set(tokenId, group);
160
168
  }
161
169
 
170
+ function autoscaleConfig(
171
+ overrides: Partial<OpenAIWorkerPoolConfig> = {},
172
+ ): OpenAIWorkerPoolConfig {
173
+ return {
174
+ autoscale: true,
175
+ minWorkersPerToken: 1,
176
+ maxWorkersPerToken: 3,
177
+ scaleIntervalMs: 5_000,
178
+ scaleDownIdleMs: 600_000,
179
+ maxEstimatedRssGiB: 16,
180
+ minHostAvailableGiB: 20,
181
+ ...overrides,
182
+ };
183
+ }
184
+
162
185
  async function waitForQueue(dispatcher: OpenAIDispatcher, length = 1): Promise<void> {
163
186
  for (let i = 0; i < 10; i++) {
164
187
  await new Promise((resolve) => setTimeout(resolve, 0));
@@ -167,6 +190,159 @@ async function waitForQueue(dispatcher: OpenAIDispatcher, length = 1): Promise<v
167
190
  expect(dispatcher.queueLength).toBe(length);
168
191
  }
169
192
 
193
+ describe("OpenAIDispatcher worker capacity", () => {
194
+ it("supports 20 workers per token without worker id collisions", () => {
195
+ expect(MAX_OPENAI_WORKERS_PER_TOKEN).toBe(20);
196
+
197
+ const tokenIds = [1, 2, 41, 47, 55, 56, 58];
198
+ const workerIds = tokenIds.flatMap((tokenId) =>
199
+ Array.from({ length: MAX_OPENAI_WORKERS_PER_TOKEN }, (_, workerIndex) =>
200
+ makeOpenAIWorkerId(tokenId, workerIndex),
201
+ ),
202
+ );
203
+
204
+ expect(new Set(workerIds)).toHaveLength(workerIds.length);
205
+ expect(makeOpenAIWorkerId(1, 10)).not.toBe(makeOpenAIWorkerId(2, 0));
206
+ });
207
+
208
+ it("defaults to autoscaling from 5 to 15 workers per token", () => {
209
+ expect(resolveOpenAIWorkerPoolConfig({})).toMatchObject({
210
+ autoscale: true,
211
+ minWorkersPerToken: 5,
212
+ maxWorkersPerToken: 15,
213
+ });
214
+ });
215
+
216
+ it("keeps an explicit fixed worker mode for emergency rollback", () => {
217
+ expect(
218
+ resolveOpenAIWorkerPoolConfig({
219
+ QGRID_WORKERS_PER_TOKEN: "5",
220
+ QGRID_OPENAI_AUTOSCALE: "false",
221
+ }),
222
+ ).toMatchObject({
223
+ autoscale: false,
224
+ minWorkersPerToken: 5,
225
+ maxWorkersPerToken: 5,
226
+ });
227
+ });
228
+
229
+ it("estimates worker RSS from the dev0 measurement model", () => {
230
+ expect(estimateOpenAIWorkerRssGiB(50)).toBeCloseTo(8.56, 2);
231
+ expect(estimateOpenAIWorkerRssGiB(60)).toBeCloseTo(10.13, 2);
232
+ });
233
+ });
234
+
235
+ describe("OpenAIDispatcher autoscaling", () => {
236
+ it("scales six active tokens from 5 to 15 workers per token", async () => {
237
+ const dispatcher = new OpenAIDispatcher(
238
+ autoscaleConfig({
239
+ minWorkersPerToken: 5,
240
+ maxWorkersPerToken: 15,
241
+ maxEstimatedRssGiB: 16,
242
+ }),
243
+ () => 64,
244
+ );
245
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
246
+
247
+ for (let tokenId = 1; tokenId <= 6; tokenId++) {
248
+ await dispatcher.onTokenAdded(tokenId, `token-${tokenId}`, credentials(), null, 1);
249
+ dispatcher.workerPool.set(
250
+ tokenId,
251
+ Array.from({ length: 5 }, (_, workerIndex) => fakeWorker({ tokenId, workerIndex })),
252
+ );
253
+ }
254
+
255
+ vi.spyOn(dispatcher, "spawnSingleWorker").mockImplementation(
256
+ async (tokenId, tokenName, _credentials, workerIndex) =>
257
+ fakeWorker({ tokenId, tokenName, workerIndex }),
258
+ );
259
+
260
+ for (let step = 0; step < 12; step++) await dispatcher.scaleUpOneStep();
261
+
262
+ expect(dispatcher.workerCount).toBe(90);
263
+ expect([...dispatcher.workerPool.values()].map((workers) => workers.length)).toEqual([
264
+ 15, 15, 15, 15, 15, 15,
265
+ ]);
266
+ expect(estimateOpenAIWorkerRssGiB(dispatcher.workerCount)).toBeLessThan(16);
267
+ });
268
+
269
+ it("requests a scale-up evaluation when a request enters the queue", async () => {
270
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
271
+ addWorkers(dispatcher, [fakeWorker({ tokenId: 1, busy: true })]);
272
+ const scaleUpOneStep = vi.spyOn(dispatcher, "scaleUpOneStep").mockResolvedValue(undefined);
273
+
274
+ const queued = dispatcher.enqueue(async () => "ok");
275
+ const rejection = expect(queued).rejects.toThrow("TEST_CLEANUP");
276
+ await waitForQueue(dispatcher);
277
+ dispatcher.rejectAllQueued("TEST_CLEANUP");
278
+
279
+ await rejection;
280
+ expect(scaleUpOneStep).toHaveBeenCalledTimes(1);
281
+ });
282
+
283
+ it("adds one worker to each active token in a scale-up step", async () => {
284
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
285
+ const tokenCredentials = credentials();
286
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
287
+ await dispatcher.onTokenAdded(1, "token", tokenCredentials, null, 1);
288
+ dispatcher.workerPool.set(1, [fakeWorker({ tokenId: 1, workerIndex: 0 })]);
289
+ const added = fakeWorker({ tokenId: 1, workerIndex: 1 });
290
+ const spawnSingleWorker = vi.spyOn(dispatcher, "spawnSingleWorker").mockResolvedValue(added);
291
+
292
+ await dispatcher.scaleUpOneStep();
293
+
294
+ expect(spawnSingleWorker).toHaveBeenCalledWith(1, "token", tokenCredentials, 1);
295
+ expect(dispatcher.workerPool.get(1)).toHaveLength(2);
296
+ expect(dispatcher.workerCount).toBe(2);
297
+ });
298
+
299
+ it("does not scale past the estimated RSS guard", async () => {
300
+ const dispatcher = new OpenAIDispatcher(
301
+ autoscaleConfig({ maxEstimatedRssGiB: 0.9 }),
302
+ () => 64,
303
+ );
304
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
305
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
306
+ dispatcher.workerPool.set(1, [fakeWorker({ tokenId: 1, workerIndex: 0 })]);
307
+ const spawnSingleWorker = vi.spyOn(dispatcher, "spawnSingleWorker");
308
+
309
+ await dispatcher.scaleUpOneStep();
310
+
311
+ expect(spawnSingleWorker).not.toHaveBeenCalled();
312
+ expect(dispatcher.workerCount).toBe(1);
313
+ });
314
+
315
+ it("does not scale when host available memory is below the floor", async () => {
316
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 10);
317
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
318
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
319
+ dispatcher.workerPool.set(1, [fakeWorker({ tokenId: 1, workerIndex: 0 })]);
320
+ const spawnSingleWorker = vi.spyOn(dispatcher, "spawnSingleWorker");
321
+
322
+ await dispatcher.scaleUpOneStep();
323
+
324
+ expect(spawnSingleWorker).not.toHaveBeenCalled();
325
+ expect(dispatcher.workerCount).toBe(1);
326
+ });
327
+
328
+ it("removes only idle workers above the configured minimum for inactive tokens", async () => {
329
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
330
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
331
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
332
+ const busy = fakeWorker({ tokenId: 1, workerIndex: 0, busy: true });
333
+ const idle = fakeWorker({ tokenId: 1, workerIndex: 1 });
334
+ dispatcher.workerPool.set(1, [busy, idle]);
335
+ dispatcher.onTokenDeactivated(1);
336
+
337
+ await dispatcher.scaleDownOneStep();
338
+ await dispatcher.scaleDownOneStep();
339
+
340
+ expect(idle.kill).toHaveBeenCalledTimes(1);
341
+ expect(busy.kill).not.toHaveBeenCalled();
342
+ expect(dispatcher.workerPool.get(1)).toEqual([busy]);
343
+ });
344
+ });
345
+
170
346
  describe("OpenAIDispatcher token updates", () => {
171
347
  it("keeps existing workers when only OpenAI auth tokens rotate", async () => {
172
348
  const dispatcher = new OpenAIDispatcher();
@@ -329,7 +505,9 @@ describe("OpenAIDispatcher weighted routing", () => {
329
505
 
330
506
  const cold1 = await dispatcher.generate(baseReq());
331
507
  const reused = await dispatcher.generate(
332
- baseReq({ reuse: { workerId: 20, threadId: "thread-B", epoch: 1 } }),
508
+ baseReq({
509
+ reuse: { workerId: makeOpenAIWorkerId(2, 0), threadId: "thread-B", epoch: 1 },
510
+ }),
333
511
  );
334
512
  const cold2 = await dispatcher.generate(baseReq());
335
513
 
@@ -470,7 +648,9 @@ describe("OpenAIDispatcher quota threshold gate", () => {
470
648
  );
471
649
 
472
650
  const result = await dispatcher.generate(
473
- baseReq({ reuse: { workerId: 10, threadId: "thread-1", epoch: 1 } }),
651
+ baseReq({
652
+ reuse: { workerId: makeOpenAIWorkerId(1, 0), threadId: "thread-1", epoch: 1 },
653
+ }),
474
654
  );
475
655
 
476
656
  expect(result.tokenName).toBe("tok-B");
@@ -492,7 +672,9 @@ describe("OpenAIDispatcher quota threshold gate", () => {
492
672
  );
493
673
 
494
674
  const result = await dispatcher.generate(
495
- baseReq({ reuse: { workerId: 10, threadId: "thread-1", epoch: 1 } }),
675
+ baseReq({
676
+ reuse: { workerId: makeOpenAIWorkerId(1, 0), threadId: "thread-1", epoch: 1 },
677
+ }),
496
678
  );
497
679
 
498
680
  expect(result.tokenName).toBe("tok-A");
@@ -543,7 +725,11 @@ describe("OpenAIDispatcher quota threshold gate", () => {
543
725
  readOpenAIQuotaUsageMock.mockResolvedValueOnce(quotaOk(85));
544
726
 
545
727
  await expect(
546
- dispatcher.generate(baseReq({ reuse: { workerId: 10, threadId: "thread-1", epoch: 1 } })),
728
+ dispatcher.generate(
729
+ baseReq({
730
+ reuse: { workerId: makeOpenAIWorkerId(1, 0), threadId: "thread-1", epoch: 1 },
731
+ }),
732
+ ),
547
733
  ).rejects.toBeInstanceOf(QuotaThresholdExceededError);
548
734
  });
549
735
  });
@@ -621,7 +807,10 @@ describe("OpenAIDispatcher image generation routing", () => {
621
807
  it("forces cold thread for image requests even when a reuse coordinate is present", async () => {
622
808
  const dispatcher = new OpenAIDispatcher();
623
809
  const worker = await dispatcher.acquireReuseWorker(
624
- baseReq({ imageGeneration: true, reuse: { workerId: 10, threadId: "t1", epoch: 1 } }),
810
+ baseReq({
811
+ imageGeneration: true,
812
+ reuse: { workerId: makeOpenAIWorkerId(1, 0), threadId: "t1", epoch: 1 },
813
+ }),
625
814
  );
626
815
  expect(worker).toBeNull();
627
816
  });