@cartanova/qgrid-cli 2.4.3 → 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.
- package/bundle/dist/application/qgrid/qgrid.frame.js +17 -19
- package/bundle/dist/application/qgrid/qgrid.types.js +3 -2
- package/bundle/dist/application/qgrid/token-subscriber.js +23 -9
- package/bundle/dist/application/qgrid/token-trigger-setup.js +4 -4
- package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
- package/bundle/dist/application/sonamu.generated.js +5 -3
- package/bundle/dist/application/sonamu.generated.sso.js +3 -2
- package/bundle/dist/application/token/token.model.js +8 -3
- package/bundle/dist/application/token/token.types.js +8 -3
- package/bundle/dist/i18n/sd.generated.js +2 -1
- package/bundle/dist/index.js +3 -6
- package/bundle/dist/migrations/20260521134025_alter_request_log_steps_alter1.js +3 -9
- package/bundle/dist/migrations/20260706120000_alter_request_logs_add_image_cost.js +1 -1
- package/bundle/dist/migrations/20260710090000_alter_tokens_add_weight.js +41 -0
- package/bundle/dist/server-bootstrap.js +17 -0
- package/bundle/dist/sonamu.config.js +1 -10
- package/bundle/dist/startup-migrations.js +21 -0
- package/bundle/dist/testing/global.js +93 -5
- package/bundle/dist/testing/test-database-name.js +22 -0
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +24 -27
- package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +43 -0
- package/bundle/dist/utils/providers/openai/codex-worker.js +4 -2
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +220 -49
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +60 -14
- package/bundle/src/application/qgrid/qgrid.frame.ts +24 -25
- package/bundle/src/application/qgrid/qgrid.types.ts +1 -0
- package/bundle/src/application/qgrid/token-subscriber.test.ts +102 -0
- package/bundle/src/application/qgrid/token-subscriber.ts +37 -6
- package/bundle/src/application/qgrid/token-trigger-setup.test.ts +9 -0
- package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -2
- package/bundle/src/application/qgrid/token-weight-migration.test.ts +42 -0
- package/bundle/src/application/qgrid/tool-emulation.ts +6 -1
- package/bundle/src/application/sonamu.generated.http +4 -2
- package/bundle/src/application/sonamu.generated.sso.ts +1 -0
- package/bundle/src/application/sonamu.generated.ts +10 -1
- package/bundle/src/application/token/token.entity.json +17 -1
- package/bundle/src/application/token/token.model.test.ts +43 -0
- package/bundle/src/application/token/token.model.ts +18 -2
- package/bundle/src/application/token/token.types.test.ts +11 -0
- package/bundle/src/application/token/token.types.ts +3 -0
- package/bundle/src/i18n/sd.generated.ts +1 -0
- package/bundle/src/index.ts +2 -5
- package/bundle/src/migrations/20260521134025_alter_request_log_steps_alter1.ts +4 -8
- package/bundle/src/migrations/20260706120000_alter_request_logs_add_image_cost.ts +1 -1
- package/bundle/src/migrations/20260710090000_alter_tokens_add_weight.ts +38 -0
- package/bundle/src/server-bootstrap.test.ts +39 -0
- package/bundle/src/server-bootstrap.ts +21 -0
- package/bundle/src/sonamu.config.ts +0 -12
- package/bundle/src/startup-migrations.test.ts +25 -0
- package/bundle/src/startup-migrations.ts +20 -0
- package/bundle/src/testing/global.test.ts +130 -0
- package/bundle/src/testing/global.ts +143 -13
- package/bundle/src/testing/test-database-name.test.ts +38 -0
- package/bundle/src/testing/test-database-name.ts +21 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +75 -45
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +23 -29
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -1
- package/bundle/src/utils/providers/common/smooth-weighted-round-robin.test.ts +73 -0
- package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +48 -0
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +26 -0
- package/bundle/src/utils/providers/openai/codex-worker.ts +3 -1
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +304 -12
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +354 -68
- package/bundle/web-dist/client/assets/{index-DsGDBrrH.js → index-D0dRwMp0.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-C6EMtwDn.js → logs-BMzzMMRA.js} +1 -1
- package/bundle/web-dist/client/assets/routes-XqQHRGxI.js +5 -0
- package/bundle/web-dist/client/assets/{sd.generated-BrUnRt0B.js → sd.generated-WA5SGJYZ.js} +1 -1
- package/bundle/web-dist/client/assets/{services.generated-AsEvHbJ7.js → services.generated-DzZLnb7S.js} +1 -1
- package/bundle/web-dist/client/assets/{show-BSW3PE7Z.js → show-63BsotX6.js} +1 -1
- package/bundle/web-dist/client/assets/tokens-C416mWQ-.js +1 -0
- package/bundle/web-dist/client/assets/use-update-token-mutation-BV6msjuy.js +1 -0
- package/bundle/web-dist/client/index.html +2 -2
- package/bundle/web-dist/server/assets/{lazyRouteComponent-BswulcOx.js → lazyRouteComponent-BrPAFtBA.js} +1 -1
- package/bundle/web-dist/server/assets/{logs-Bk9C0ELo.js → logs-CKM6W3W6.js} +3 -3
- package/bundle/web-dist/server/assets/{routes-D9OiKoqa.js → routes-VY2um2KO.js} +140 -9
- package/bundle/web-dist/server/assets/{sd.generated-DRuQSIKo.js → sd.generated-Cwg2f53z.js} +1 -0
- package/bundle/web-dist/server/assets/{services.generated-Dp0uKgpV.js → services.generated-CsotZP44.js} +5 -5
- package/bundle/web-dist/server/assets/{show-3GokIDEJ.js → show-CV2UFjaH.js} +3 -3
- package/bundle/web-dist/server/assets/{tokens-Cjw6cQxZ.js → tokens-DfGj1y2v.js} +5 -5
- package/bundle/web-dist/server/assets/use-update-token-mutation-BL96w-Pg.js +7 -0
- package/bundle/web-dist/server/entry-server.generated.js +7 -7
- package/package.json +1 -1
- package/skills/qgrid/SKILL.md +1 -1
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +2 -2
- package/skills/qgrid/references/cli-env-and-server-boot.md +9 -9
- package/skills/qgrid/references/decision-rationale.md +23 -0
- package/skills/qgrid/references/openai-codex-runtime.md +4 -3
- package/skills/qgrid/references/prompt-cache-and-usage.md +2 -0
- package/skills/qgrid/references/provider-runtime-differences.md +2 -1
- package/skills/qgrid/references/repo-map.md +2 -0
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +8 -3
- package/skills/qgrid/references/verification-and-debugging.md +3 -0
- package/bundle/web-dist/client/assets/routes-De9iuL80.js +0 -5
- package/bundle/web-dist/client/assets/tokens-BMsL6vk9.js +0 -1
|
@@ -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 {
|
|
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
|
|
81
|
+
workerId: makeOpenAIWorkerId(worker.tokenId, worker.workerIndex),
|
|
74
82
|
threadId,
|
|
75
83
|
epoch: worker.epoch,
|
|
76
84
|
},
|
|
@@ -112,6 +120,9 @@ function fakeWorker(
|
|
|
112
120
|
get isReady() {
|
|
113
121
|
return options.ready ?? true;
|
|
114
122
|
},
|
|
123
|
+
get hasCapacity() {
|
|
124
|
+
return !busy;
|
|
125
|
+
},
|
|
115
126
|
tryAcquireTurn: vi.fn(() => {
|
|
116
127
|
if (busy) return false;
|
|
117
128
|
busy = true;
|
|
@@ -156,6 +167,21 @@ function addWorkers(dispatcher: OpenAIDispatcher, workers: CodexAppServerWorker[
|
|
|
156
167
|
for (const [tokenId, group] of byToken) dispatcher.workerPool.set(tokenId, group);
|
|
157
168
|
}
|
|
158
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
|
+
|
|
159
185
|
async function waitForQueue(dispatcher: OpenAIDispatcher, length = 1): Promise<void> {
|
|
160
186
|
for (let i = 0; i < 10; i++) {
|
|
161
187
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
@@ -164,6 +190,159 @@ async function waitForQueue(dispatcher: OpenAIDispatcher, length = 1): Promise<v
|
|
|
164
190
|
expect(dispatcher.queueLength).toBe(length);
|
|
165
191
|
}
|
|
166
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
|
+
|
|
167
346
|
describe("OpenAIDispatcher token updates", () => {
|
|
168
347
|
it("keeps existing workers when only OpenAI auth tokens rotate", async () => {
|
|
169
348
|
const dispatcher = new OpenAIDispatcher();
|
|
@@ -195,7 +374,7 @@ describe("OpenAIDispatcher token updates", () => {
|
|
|
195
374
|
await dispatcher.onTokenUpdated(1, "token", changedIdentity, null);
|
|
196
375
|
|
|
197
376
|
expect(worker.kill).toHaveBeenCalledTimes(1);
|
|
198
|
-
expect(spawnWorkers).toHaveBeenCalledWith(1, "token", changedIdentity, null);
|
|
377
|
+
expect(spawnWorkers).toHaveBeenCalledWith(1, "token", changedIdentity, null, 1);
|
|
199
378
|
});
|
|
200
379
|
|
|
201
380
|
it("uses tokenId keyed rate-limit cache", async () => {
|
|
@@ -227,7 +406,7 @@ describe("OpenAIDispatcher token updates", () => {
|
|
|
227
406
|
addWorkers(dispatcher, [workerA, workerB]);
|
|
228
407
|
|
|
229
408
|
await dispatcher.replaceTokens([
|
|
230
|
-
{ id: 1, name: "tok-A", credentials: credentials(), quotaThreshold: 80 },
|
|
409
|
+
{ id: 1, name: "tok-A", credentials: credentials(), quotaThreshold: 80, weight: 1 },
|
|
231
410
|
]);
|
|
232
411
|
readOpenAIQuotaUsageMock.mockResolvedValueOnce(quotaOk(85));
|
|
233
412
|
|
|
@@ -238,6 +417,108 @@ describe("OpenAIDispatcher token updates", () => {
|
|
|
238
417
|
});
|
|
239
418
|
});
|
|
240
419
|
|
|
420
|
+
describe("OpenAIDispatcher weighted routing", () => {
|
|
421
|
+
it("routes cold OpenAI requests by token weight instead of worker count", async () => {
|
|
422
|
+
const dispatcher = new OpenAIDispatcher();
|
|
423
|
+
const a0 = fakeWorker({ tokenId: 1, tokenName: "tok-A", workerIndex: 0 });
|
|
424
|
+
const a1 = fakeWorker({ tokenId: 1, tokenName: "tok-A", workerIndex: 1 });
|
|
425
|
+
const b0 = fakeWorker({ tokenId: 2, tokenName: "tok-B", workerIndex: 0 });
|
|
426
|
+
addWorkers(dispatcher, [a0, a1, b0]);
|
|
427
|
+
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 3);
|
|
428
|
+
await dispatcher.onTokenUpdated(2, "tok-B", credentials(), null, 1);
|
|
429
|
+
|
|
430
|
+
const names: string[] = [];
|
|
431
|
+
for (let i = 0; i < 4; i++) {
|
|
432
|
+
const worker = await dispatcher.acquireIdleWorker();
|
|
433
|
+
names.push(worker!.tokenName);
|
|
434
|
+
worker!.releaseTurn();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
expect(names.filter((name) => name === "tok-A")).toHaveLength(3);
|
|
438
|
+
expect(names.filter((name) => name === "tok-B")).toHaveLength(1);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("does not give a token extra cold turns for having more workers", async () => {
|
|
442
|
+
const dispatcher = new OpenAIDispatcher();
|
|
443
|
+
addWorkers(dispatcher, [
|
|
444
|
+
fakeWorker({ tokenId: 1, tokenName: "tok-A", workerIndex: 0 }),
|
|
445
|
+
fakeWorker({ tokenId: 1, tokenName: "tok-A", workerIndex: 1 }),
|
|
446
|
+
fakeWorker({ tokenId: 2, tokenName: "tok-B", workerIndex: 0 }),
|
|
447
|
+
]);
|
|
448
|
+
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 1);
|
|
449
|
+
await dispatcher.onTokenUpdated(2, "tok-B", credentials(), null, 1);
|
|
450
|
+
|
|
451
|
+
const names: string[] = [];
|
|
452
|
+
for (let i = 0; i < 4; i++) {
|
|
453
|
+
const worker = await dispatcher.acquireIdleWorker();
|
|
454
|
+
names.push(worker!.tokenName);
|
|
455
|
+
worker!.releaseTurn();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
expect(names).toEqual(["tok-A", "tok-B", "tok-A", "tok-B"]);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("skips a high-weight token when all of its workers are busy", async () => {
|
|
462
|
+
const dispatcher = new OpenAIDispatcher();
|
|
463
|
+
addWorkers(dispatcher, [
|
|
464
|
+
fakeWorker({ tokenId: 1, tokenName: "tok-A", busy: true }),
|
|
465
|
+
fakeWorker({ tokenId: 2, tokenName: "tok-B" }),
|
|
466
|
+
]);
|
|
467
|
+
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 100);
|
|
468
|
+
await dispatcher.onTokenUpdated(2, "tok-B", credentials(), null, 1);
|
|
469
|
+
|
|
470
|
+
await expect(dispatcher.acquireIdleWorker()).resolves.toMatchObject({ tokenName: "tok-B" });
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("uses weighted selection when draining after a worker release", async () => {
|
|
474
|
+
const dispatcher = new OpenAIDispatcher();
|
|
475
|
+
const workerA = fakeWorker({ tokenId: 1, tokenName: "tok-A", busy: true });
|
|
476
|
+
const workerB = fakeWorker({ tokenId: 2, tokenName: "tok-B", busy: true });
|
|
477
|
+
addWorkers(dispatcher, [workerA, workerB]);
|
|
478
|
+
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 1);
|
|
479
|
+
await dispatcher.onTokenUpdated(2, "tok-B", credentials(), null, 3);
|
|
480
|
+
|
|
481
|
+
const queued = dispatcher.enqueue(async (worker) => worker.tokenName);
|
|
482
|
+
await waitForQueue(dispatcher);
|
|
483
|
+
workerA.releaseTurn();
|
|
484
|
+
workerB.releaseTurn();
|
|
485
|
+
|
|
486
|
+
await dispatcher.drainQueue();
|
|
487
|
+
await expect(queued).resolves.toBe("tok-B");
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("does not advance weighted state for successful thread reuse", async () => {
|
|
491
|
+
const dispatcher = new OpenAIDispatcher();
|
|
492
|
+
const workerA = fakeWorker({ tokenId: 1, tokenName: "tok-A" });
|
|
493
|
+
const workerB = fakeWorker({
|
|
494
|
+
tokenId: 2,
|
|
495
|
+
tokenName: "tok-B",
|
|
496
|
+
threads: ["thread-B"],
|
|
497
|
+
});
|
|
498
|
+
addWorkers(dispatcher, [workerA, workerB]);
|
|
499
|
+
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 1);
|
|
500
|
+
await dispatcher.onTokenUpdated(2, "tok-B", credentials(), null, 1);
|
|
501
|
+
vi.spyOn(dispatcher, "executeTurn").mockImplementation(
|
|
502
|
+
async (worker, _req, reuseThreadId) =>
|
|
503
|
+
resultFor(worker, reuseThreadId ?? "thread-new"),
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
const cold1 = await dispatcher.generate(baseReq());
|
|
507
|
+
const reused = await dispatcher.generate(
|
|
508
|
+
baseReq({
|
|
509
|
+
reuse: { workerId: makeOpenAIWorkerId(2, 0), threadId: "thread-B", epoch: 1 },
|
|
510
|
+
}),
|
|
511
|
+
);
|
|
512
|
+
const cold2 = await dispatcher.generate(baseReq());
|
|
513
|
+
|
|
514
|
+
expect([cold1.tokenName, cold2.tokenName]).toEqual(["tok-A", "tok-B"]);
|
|
515
|
+
expect(reused).toMatchObject({
|
|
516
|
+
tokenName: "tok-B",
|
|
517
|
+
threadCoord: { threadId: "thread-B" },
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
241
522
|
describe("OpenAIDispatcher quota threshold gate", () => {
|
|
242
523
|
it("excludes over-threshold tokenIds before idle selection", async () => {
|
|
243
524
|
const dispatcher = new OpenAIDispatcher();
|
|
@@ -280,7 +561,7 @@ describe("OpenAIDispatcher quota threshold gate", () => {
|
|
|
280
561
|
await waitForQueue(dispatcher);
|
|
281
562
|
|
|
282
563
|
worker.releaseTurn();
|
|
283
|
-
await dispatcher.drainQueue(
|
|
564
|
+
await dispatcher.drainQueue();
|
|
284
565
|
await expect(queued).resolves.toBe("tok-A");
|
|
285
566
|
});
|
|
286
567
|
|
|
@@ -310,11 +591,10 @@ describe("OpenAIDispatcher quota threshold gate", () => {
|
|
|
310
591
|
await waitForQueue(dispatcher);
|
|
311
592
|
readOpenAIQuotaUsageMock.mockReset();
|
|
312
593
|
readOpenAIQuotaUsageMock
|
|
313
|
-
.mockResolvedValueOnce(quotaOk(90))
|
|
314
594
|
.mockResolvedValueOnce(quotaOk(90))
|
|
315
595
|
.mockResolvedValueOnce(quotaOk(10));
|
|
316
596
|
|
|
317
|
-
await dispatcher.drainQueue(
|
|
597
|
+
await dispatcher.drainQueue();
|
|
318
598
|
|
|
319
599
|
await expect(queued).resolves.toBe("tok-B");
|
|
320
600
|
});
|
|
@@ -323,11 +603,12 @@ describe("OpenAIDispatcher quota threshold gate", () => {
|
|
|
323
603
|
const dispatcher = new OpenAIDispatcher();
|
|
324
604
|
const worker = fakeWorker({ tokenId: 1, tokenName: "tok-A" });
|
|
325
605
|
addWorkers(dispatcher, [worker]);
|
|
606
|
+
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 1);
|
|
326
607
|
const execute = vi.fn(async (w: CodexAppServerWorker) => w.tokenName);
|
|
327
608
|
const queued = dispatcher.enqueue(execute);
|
|
328
609
|
await waitForQueue(dispatcher);
|
|
329
610
|
|
|
330
|
-
await Promise.all([dispatcher.drainQueue(
|
|
611
|
+
await Promise.all([dispatcher.drainQueue(), dispatcher.drainQueue()]);
|
|
331
612
|
|
|
332
613
|
await expect(queued).resolves.toBe("tok-A");
|
|
333
614
|
expect(execute).toHaveBeenCalledTimes(1);
|
|
@@ -367,7 +648,9 @@ describe("OpenAIDispatcher quota threshold gate", () => {
|
|
|
367
648
|
);
|
|
368
649
|
|
|
369
650
|
const result = await dispatcher.generate(
|
|
370
|
-
baseReq({
|
|
651
|
+
baseReq({
|
|
652
|
+
reuse: { workerId: makeOpenAIWorkerId(1, 0), threadId: "thread-1", epoch: 1 },
|
|
653
|
+
}),
|
|
371
654
|
);
|
|
372
655
|
|
|
373
656
|
expect(result.tokenName).toBe("tok-B");
|
|
@@ -389,7 +672,9 @@ describe("OpenAIDispatcher quota threshold gate", () => {
|
|
|
389
672
|
);
|
|
390
673
|
|
|
391
674
|
const result = await dispatcher.generate(
|
|
392
|
-
baseReq({
|
|
675
|
+
baseReq({
|
|
676
|
+
reuse: { workerId: makeOpenAIWorkerId(1, 0), threadId: "thread-1", epoch: 1 },
|
|
677
|
+
}),
|
|
393
678
|
);
|
|
394
679
|
|
|
395
680
|
expect(result.tokenName).toBe("tok-A");
|
|
@@ -440,7 +725,11 @@ describe("OpenAIDispatcher quota threshold gate", () => {
|
|
|
440
725
|
readOpenAIQuotaUsageMock.mockResolvedValueOnce(quotaOk(85));
|
|
441
726
|
|
|
442
727
|
await expect(
|
|
443
|
-
dispatcher.generate(
|
|
728
|
+
dispatcher.generate(
|
|
729
|
+
baseReq({
|
|
730
|
+
reuse: { workerId: makeOpenAIWorkerId(1, 0), threadId: "thread-1", epoch: 1 },
|
|
731
|
+
}),
|
|
732
|
+
),
|
|
444
733
|
).rejects.toBeInstanceOf(QuotaThresholdExceededError);
|
|
445
734
|
});
|
|
446
735
|
});
|
|
@@ -518,7 +807,10 @@ describe("OpenAIDispatcher image generation routing", () => {
|
|
|
518
807
|
it("forces cold thread for image requests even when a reuse coordinate is present", async () => {
|
|
519
808
|
const dispatcher = new OpenAIDispatcher();
|
|
520
809
|
const worker = await dispatcher.acquireReuseWorker(
|
|
521
|
-
baseReq({
|
|
810
|
+
baseReq({
|
|
811
|
+
imageGeneration: true,
|
|
812
|
+
reuse: { workerId: makeOpenAIWorkerId(1, 0), threadId: "t1", epoch: 1 },
|
|
813
|
+
}),
|
|
522
814
|
);
|
|
523
815
|
expect(worker).toBeNull();
|
|
524
816
|
});
|