@cartanova/qgrid-cli 2.4.3 → 2.4.4
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 +13 -17
- 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/openai-dispatcher.js +53 -40
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +36 -14
- package/bundle/src/application/qgrid/qgrid.frame.ts +22 -25
- 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/openai-dispatcher.test.ts +109 -6
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +72 -55
- package/bundle/web-dist/client/assets/{index-DsGDBrrH.js → index-D1ExP77_.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-C6EMtwDn.js → logs-vuI-Zgnq.js} +1 -1
- package/bundle/web-dist/client/assets/routes-0Fki0JEi.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-BreAgaFH.js} +1 -1
- package/bundle/web-dist/client/assets/tokens-4VgHQtXd.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-DhIpPgXz.js} +130 -7
- 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
|
@@ -112,6 +112,9 @@ function fakeWorker(
|
|
|
112
112
|
get isReady() {
|
|
113
113
|
return options.ready ?? true;
|
|
114
114
|
},
|
|
115
|
+
get hasCapacity() {
|
|
116
|
+
return !busy;
|
|
117
|
+
},
|
|
115
118
|
tryAcquireTurn: vi.fn(() => {
|
|
116
119
|
if (busy) return false;
|
|
117
120
|
busy = true;
|
|
@@ -195,7 +198,7 @@ describe("OpenAIDispatcher token updates", () => {
|
|
|
195
198
|
await dispatcher.onTokenUpdated(1, "token", changedIdentity, null);
|
|
196
199
|
|
|
197
200
|
expect(worker.kill).toHaveBeenCalledTimes(1);
|
|
198
|
-
expect(spawnWorkers).toHaveBeenCalledWith(1, "token", changedIdentity, null);
|
|
201
|
+
expect(spawnWorkers).toHaveBeenCalledWith(1, "token", changedIdentity, null, 1);
|
|
199
202
|
});
|
|
200
203
|
|
|
201
204
|
it("uses tokenId keyed rate-limit cache", async () => {
|
|
@@ -227,7 +230,7 @@ describe("OpenAIDispatcher token updates", () => {
|
|
|
227
230
|
addWorkers(dispatcher, [workerA, workerB]);
|
|
228
231
|
|
|
229
232
|
await dispatcher.replaceTokens([
|
|
230
|
-
{ id: 1, name: "tok-A", credentials: credentials(), quotaThreshold: 80 },
|
|
233
|
+
{ id: 1, name: "tok-A", credentials: credentials(), quotaThreshold: 80, weight: 1 },
|
|
231
234
|
]);
|
|
232
235
|
readOpenAIQuotaUsageMock.mockResolvedValueOnce(quotaOk(85));
|
|
233
236
|
|
|
@@ -238,6 +241,106 @@ describe("OpenAIDispatcher token updates", () => {
|
|
|
238
241
|
});
|
|
239
242
|
});
|
|
240
243
|
|
|
244
|
+
describe("OpenAIDispatcher weighted routing", () => {
|
|
245
|
+
it("routes cold OpenAI requests by token weight instead of worker count", async () => {
|
|
246
|
+
const dispatcher = new OpenAIDispatcher();
|
|
247
|
+
const a0 = fakeWorker({ tokenId: 1, tokenName: "tok-A", workerIndex: 0 });
|
|
248
|
+
const a1 = fakeWorker({ tokenId: 1, tokenName: "tok-A", workerIndex: 1 });
|
|
249
|
+
const b0 = fakeWorker({ tokenId: 2, tokenName: "tok-B", workerIndex: 0 });
|
|
250
|
+
addWorkers(dispatcher, [a0, a1, b0]);
|
|
251
|
+
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 3);
|
|
252
|
+
await dispatcher.onTokenUpdated(2, "tok-B", credentials(), null, 1);
|
|
253
|
+
|
|
254
|
+
const names: string[] = [];
|
|
255
|
+
for (let i = 0; i < 4; i++) {
|
|
256
|
+
const worker = await dispatcher.acquireIdleWorker();
|
|
257
|
+
names.push(worker!.tokenName);
|
|
258
|
+
worker!.releaseTurn();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
expect(names.filter((name) => name === "tok-A")).toHaveLength(3);
|
|
262
|
+
expect(names.filter((name) => name === "tok-B")).toHaveLength(1);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("does not give a token extra cold turns for having more workers", async () => {
|
|
266
|
+
const dispatcher = new OpenAIDispatcher();
|
|
267
|
+
addWorkers(dispatcher, [
|
|
268
|
+
fakeWorker({ tokenId: 1, tokenName: "tok-A", workerIndex: 0 }),
|
|
269
|
+
fakeWorker({ tokenId: 1, tokenName: "tok-A", workerIndex: 1 }),
|
|
270
|
+
fakeWorker({ tokenId: 2, tokenName: "tok-B", workerIndex: 0 }),
|
|
271
|
+
]);
|
|
272
|
+
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 1);
|
|
273
|
+
await dispatcher.onTokenUpdated(2, "tok-B", credentials(), null, 1);
|
|
274
|
+
|
|
275
|
+
const names: string[] = [];
|
|
276
|
+
for (let i = 0; i < 4; i++) {
|
|
277
|
+
const worker = await dispatcher.acquireIdleWorker();
|
|
278
|
+
names.push(worker!.tokenName);
|
|
279
|
+
worker!.releaseTurn();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
expect(names).toEqual(["tok-A", "tok-B", "tok-A", "tok-B"]);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("skips a high-weight token when all of its workers are busy", async () => {
|
|
286
|
+
const dispatcher = new OpenAIDispatcher();
|
|
287
|
+
addWorkers(dispatcher, [
|
|
288
|
+
fakeWorker({ tokenId: 1, tokenName: "tok-A", busy: true }),
|
|
289
|
+
fakeWorker({ tokenId: 2, tokenName: "tok-B" }),
|
|
290
|
+
]);
|
|
291
|
+
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 100);
|
|
292
|
+
await dispatcher.onTokenUpdated(2, "tok-B", credentials(), null, 1);
|
|
293
|
+
|
|
294
|
+
await expect(dispatcher.acquireIdleWorker()).resolves.toMatchObject({ tokenName: "tok-B" });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("uses weighted selection when draining after a worker release", async () => {
|
|
298
|
+
const dispatcher = new OpenAIDispatcher();
|
|
299
|
+
const workerA = fakeWorker({ tokenId: 1, tokenName: "tok-A", busy: true });
|
|
300
|
+
const workerB = fakeWorker({ tokenId: 2, tokenName: "tok-B", busy: true });
|
|
301
|
+
addWorkers(dispatcher, [workerA, workerB]);
|
|
302
|
+
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 1);
|
|
303
|
+
await dispatcher.onTokenUpdated(2, "tok-B", credentials(), null, 3);
|
|
304
|
+
|
|
305
|
+
const queued = dispatcher.enqueue(async (worker) => worker.tokenName);
|
|
306
|
+
await waitForQueue(dispatcher);
|
|
307
|
+
workerA.releaseTurn();
|
|
308
|
+
workerB.releaseTurn();
|
|
309
|
+
|
|
310
|
+
await dispatcher.drainQueue();
|
|
311
|
+
await expect(queued).resolves.toBe("tok-B");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("does not advance weighted state for successful thread reuse", async () => {
|
|
315
|
+
const dispatcher = new OpenAIDispatcher();
|
|
316
|
+
const workerA = fakeWorker({ tokenId: 1, tokenName: "tok-A" });
|
|
317
|
+
const workerB = fakeWorker({
|
|
318
|
+
tokenId: 2,
|
|
319
|
+
tokenName: "tok-B",
|
|
320
|
+
threads: ["thread-B"],
|
|
321
|
+
});
|
|
322
|
+
addWorkers(dispatcher, [workerA, workerB]);
|
|
323
|
+
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 1);
|
|
324
|
+
await dispatcher.onTokenUpdated(2, "tok-B", credentials(), null, 1);
|
|
325
|
+
vi.spyOn(dispatcher, "executeTurn").mockImplementation(
|
|
326
|
+
async (worker, _req, reuseThreadId) =>
|
|
327
|
+
resultFor(worker, reuseThreadId ?? "thread-new"),
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const cold1 = await dispatcher.generate(baseReq());
|
|
331
|
+
const reused = await dispatcher.generate(
|
|
332
|
+
baseReq({ reuse: { workerId: 20, threadId: "thread-B", epoch: 1 } }),
|
|
333
|
+
);
|
|
334
|
+
const cold2 = await dispatcher.generate(baseReq());
|
|
335
|
+
|
|
336
|
+
expect([cold1.tokenName, cold2.tokenName]).toEqual(["tok-A", "tok-B"]);
|
|
337
|
+
expect(reused).toMatchObject({
|
|
338
|
+
tokenName: "tok-B",
|
|
339
|
+
threadCoord: { threadId: "thread-B" },
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
241
344
|
describe("OpenAIDispatcher quota threshold gate", () => {
|
|
242
345
|
it("excludes over-threshold tokenIds before idle selection", async () => {
|
|
243
346
|
const dispatcher = new OpenAIDispatcher();
|
|
@@ -280,7 +383,7 @@ describe("OpenAIDispatcher quota threshold gate", () => {
|
|
|
280
383
|
await waitForQueue(dispatcher);
|
|
281
384
|
|
|
282
385
|
worker.releaseTurn();
|
|
283
|
-
await dispatcher.drainQueue(
|
|
386
|
+
await dispatcher.drainQueue();
|
|
284
387
|
await expect(queued).resolves.toBe("tok-A");
|
|
285
388
|
});
|
|
286
389
|
|
|
@@ -310,11 +413,10 @@ describe("OpenAIDispatcher quota threshold gate", () => {
|
|
|
310
413
|
await waitForQueue(dispatcher);
|
|
311
414
|
readOpenAIQuotaUsageMock.mockReset();
|
|
312
415
|
readOpenAIQuotaUsageMock
|
|
313
|
-
.mockResolvedValueOnce(quotaOk(90))
|
|
314
416
|
.mockResolvedValueOnce(quotaOk(90))
|
|
315
417
|
.mockResolvedValueOnce(quotaOk(10));
|
|
316
418
|
|
|
317
|
-
await dispatcher.drainQueue(
|
|
419
|
+
await dispatcher.drainQueue();
|
|
318
420
|
|
|
319
421
|
await expect(queued).resolves.toBe("tok-B");
|
|
320
422
|
});
|
|
@@ -323,11 +425,12 @@ describe("OpenAIDispatcher quota threshold gate", () => {
|
|
|
323
425
|
const dispatcher = new OpenAIDispatcher();
|
|
324
426
|
const worker = fakeWorker({ tokenId: 1, tokenName: "tok-A" });
|
|
325
427
|
addWorkers(dispatcher, [worker]);
|
|
428
|
+
await dispatcher.onTokenUpdated(1, "tok-A", credentials(), null, 1);
|
|
326
429
|
const execute = vi.fn(async (w: CodexAppServerWorker) => w.tokenName);
|
|
327
430
|
const queued = dispatcher.enqueue(execute);
|
|
328
431
|
await waitForQueue(dispatcher);
|
|
329
432
|
|
|
330
|
-
await Promise.all([dispatcher.drainQueue(
|
|
433
|
+
await Promise.all([dispatcher.drainQueue(), dispatcher.drainQueue()]);
|
|
331
434
|
|
|
332
435
|
await expect(queued).resolves.toBe("tok-A");
|
|
333
436
|
expect(execute).toHaveBeenCalledTimes(1);
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type GenerateStreamCallbacks,
|
|
19
19
|
type ProviderDispatcher,
|
|
20
20
|
} from "../common/provider-dispatcher";
|
|
21
|
+
import { SmoothWeightedRoundRobin } from "../common/smooth-weighted-round-robin";
|
|
21
22
|
import { CodexAppServerWorker, type StreamCallbacks, type TurnRequest } from "./codex-worker";
|
|
22
23
|
import {
|
|
23
24
|
readOpenAIQuotaUsage,
|
|
@@ -92,6 +93,7 @@ type QueueItem = {
|
|
|
92
93
|
type TokenMetadata = {
|
|
93
94
|
name: string;
|
|
94
95
|
quotaThreshold?: number | null;
|
|
96
|
+
weight: number;
|
|
95
97
|
};
|
|
96
98
|
|
|
97
99
|
type TokenEligibility = {
|
|
@@ -104,8 +106,9 @@ type TokenEligibility = {
|
|
|
104
106
|
export class OpenAIDispatcher implements ProviderDispatcher {
|
|
105
107
|
workerPool = new Map<number, CodexAppServerWorker[]>();
|
|
106
108
|
tokenMetadata = new Map<number, TokenMetadata>();
|
|
107
|
-
rrCursor = 0;
|
|
108
109
|
queue: QueueItem[] = [];
|
|
110
|
+
private readonly weightedSelector = new SmoothWeightedRoundRobin();
|
|
111
|
+
private readonly workerCursorByToken = new Map<number, number>();
|
|
109
112
|
private draining = false;
|
|
110
113
|
private drainAgain = false;
|
|
111
114
|
|
|
@@ -123,6 +126,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
123
126
|
t.name,
|
|
124
127
|
t.credentials as OpenAICredentials,
|
|
125
128
|
t.quota_threshold,
|
|
129
|
+
t.weight,
|
|
126
130
|
);
|
|
127
131
|
if (!t.active) this.onTokenDeactivated(t.id);
|
|
128
132
|
}),
|
|
@@ -133,8 +137,11 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
133
137
|
this.rejectAllQueued("DISPATCHER_SHUTDOWN");
|
|
134
138
|
const kills = [...this.workerPool.values()].flat().map((w) => w.kill());
|
|
135
139
|
await Promise.allSettled(kills);
|
|
140
|
+
for (const id of this.tokenMetadata.keys()) this.weightedSelector.removeToken(id);
|
|
136
141
|
this.workerPool.clear();
|
|
137
142
|
this.tokenMetadata.clear();
|
|
143
|
+
this.workerCursorByToken.clear();
|
|
144
|
+
this.weightedSelector.resetScores();
|
|
138
145
|
this.rateLimitsCache.clear();
|
|
139
146
|
}
|
|
140
147
|
|
|
@@ -145,10 +152,11 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
145
152
|
name: string,
|
|
146
153
|
credentials: OpenAICredentials,
|
|
147
154
|
quotaThreshold?: number | null,
|
|
155
|
+
weight = 1,
|
|
148
156
|
): Promise<void> {
|
|
149
|
-
this.setTokenMetadata(id, name, quotaThreshold);
|
|
157
|
+
this.setTokenMetadata(id, name, quotaThreshold, weight);
|
|
150
158
|
if (this.workerPool.has(id)) return;
|
|
151
|
-
await this.spawnWorkers(id, name, credentials, quotaThreshold);
|
|
159
|
+
await this.spawnWorkers(id, name, credentials, quotaThreshold, weight);
|
|
152
160
|
}
|
|
153
161
|
|
|
154
162
|
async onTokenRemoved(id: number): Promise<void> {
|
|
@@ -156,6 +164,8 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
156
164
|
const tokenName = workers[0]?.tokenName;
|
|
157
165
|
this.workerPool.delete(id);
|
|
158
166
|
this.tokenMetadata.delete(id);
|
|
167
|
+
this.weightedSelector.removeToken(id);
|
|
168
|
+
this.workerCursorByToken.delete(id);
|
|
159
169
|
this.rateLimitsCache.delete(id);
|
|
160
170
|
await Promise.allSettled(workers.map((w) => w.kill()));
|
|
161
171
|
if (this.getAllReadyActiveWorkers().length === 0) {
|
|
@@ -172,14 +182,15 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
172
182
|
name: string,
|
|
173
183
|
credentials: OpenAICredentials,
|
|
174
184
|
quotaThreshold?: number | null,
|
|
185
|
+
weight = 1,
|
|
175
186
|
): Promise<void> {
|
|
176
187
|
const existing = this.workerPool.get(id) ?? [];
|
|
177
188
|
const renamed = existing.some((w) => w.tokenName !== name);
|
|
178
|
-
this.setTokenMetadata(id, name, quotaThreshold);
|
|
189
|
+
this.setTokenMetadata(id, name, quotaThreshold, weight);
|
|
179
190
|
if (renamed) this.rateLimitsCache.delete(id);
|
|
180
191
|
|
|
181
192
|
if (existing.length === 0) {
|
|
182
|
-
await this.spawnWorkers(id, name, credentials, quotaThreshold);
|
|
193
|
+
await this.spawnWorkers(id, name, credentials, quotaThreshold, weight);
|
|
183
194
|
return;
|
|
184
195
|
}
|
|
185
196
|
|
|
@@ -193,15 +204,17 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
193
204
|
this.workerPool.delete(id);
|
|
194
205
|
this.rateLimitsCache.delete(id);
|
|
195
206
|
await Promise.allSettled(existing.map((w) => w.kill()));
|
|
196
|
-
await this.spawnWorkers(id, name, credentials, quotaThreshold);
|
|
207
|
+
await this.spawnWorkers(id, name, credentials, quotaThreshold, weight);
|
|
197
208
|
}
|
|
198
209
|
|
|
199
210
|
onTokenDeactivated(id: number): void {
|
|
200
211
|
const workers = this.workerPool.get(id) ?? [];
|
|
201
212
|
const tokenName = workers[0]?.tokenName;
|
|
213
|
+
const changed = workers.some((worker) => worker.active);
|
|
202
214
|
workers.forEach((w) => {
|
|
203
215
|
w.active = false;
|
|
204
216
|
});
|
|
217
|
+
if (changed) this.weightedSelector.resetScores();
|
|
205
218
|
if (this.getAllReadyActiveWorkers().length === 0) {
|
|
206
219
|
this.rejectAllQueued("NO_ACTIVE_WORKERS");
|
|
207
220
|
}
|
|
@@ -212,9 +225,11 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
212
225
|
onTokenActivated(id: number): void {
|
|
213
226
|
const workers = this.workerPool.get(id) ?? [];
|
|
214
227
|
const tokenName = workers[0]?.tokenName;
|
|
228
|
+
const changed = workers.some((worker) => !worker.active);
|
|
215
229
|
workers.forEach((w) => {
|
|
216
230
|
w.active = true;
|
|
217
231
|
});
|
|
232
|
+
if (changed) this.weightedSelector.resetScores();
|
|
218
233
|
this.requestDrainQueue();
|
|
219
234
|
const label = tokenName ?? `token ${id}`;
|
|
220
235
|
logger.info(`workers activated: ${label}`);
|
|
@@ -226,6 +241,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
226
241
|
name: string;
|
|
227
242
|
credentials: OpenAICredentials;
|
|
228
243
|
quotaThreshold?: number | null;
|
|
244
|
+
weight: number;
|
|
229
245
|
}>,
|
|
230
246
|
): Promise<void> {
|
|
231
247
|
const next = new Set(rows.map((r) => r.id));
|
|
@@ -235,9 +251,15 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
235
251
|
|
|
236
252
|
for (const row of rows) {
|
|
237
253
|
if (this.workerPool.has(row.id)) {
|
|
238
|
-
await this.onTokenUpdated(
|
|
254
|
+
await this.onTokenUpdated(
|
|
255
|
+
row.id,
|
|
256
|
+
row.name,
|
|
257
|
+
row.credentials,
|
|
258
|
+
row.quotaThreshold,
|
|
259
|
+
row.weight,
|
|
260
|
+
);
|
|
239
261
|
} else {
|
|
240
|
-
await this.onTokenAdded(row.id, row.name, row.credentials, row.quotaThreshold);
|
|
262
|
+
await this.onTokenAdded(row.id, row.name, row.credentials, row.quotaThreshold, row.weight);
|
|
241
263
|
}
|
|
242
264
|
this.onTokenActivated(row.id);
|
|
243
265
|
}
|
|
@@ -273,7 +295,10 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
273
295
|
// 이미지 생성은 non-stream 전용(R2): codex 가 이미지 델타를 주지 않고 완성 base64 를
|
|
274
296
|
// 한 번에 준다. 스트리밍 경로로 이미지 요청이 오면 명시적 에러.
|
|
275
297
|
if (req.imageGeneration) {
|
|
276
|
-
throw new ImageGenerationError(
|
|
298
|
+
throw new ImageGenerationError(
|
|
299
|
+
"gate",
|
|
300
|
+
"image generation is not supported on the streaming path",
|
|
301
|
+
);
|
|
277
302
|
}
|
|
278
303
|
const excludedTokenIds = new Set<number>();
|
|
279
304
|
const reuseWorker = await this.acquireReuseWorker(req, excludedTokenIds);
|
|
@@ -341,8 +366,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
341
366
|
}
|
|
342
367
|
|
|
343
368
|
// ── Worker selection ────────────────────────────────────────────
|
|
344
|
-
//
|
|
345
|
-
// TODO: 추후 다른 알고리즘으로 변경 가능하게 지원
|
|
369
|
+
// Quota-eligible idle token을 weight로 고른 뒤, 그 token 안에서 worker를 순환한다.
|
|
346
370
|
async acquireIdleWorker(
|
|
347
371
|
excludedTokenIds = new Set<number>(),
|
|
348
372
|
): Promise<CodexAppServerWorker | null> {
|
|
@@ -350,18 +374,29 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
350
374
|
if (eligibility.readyActiveTokenIds.size === 0) return null;
|
|
351
375
|
if (eligibility.eligibleTokenIds.size === 0) this.throwQuotaThresholdExceeded(eligibility);
|
|
352
376
|
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
377
|
+
const workersByToken = new Map<number, CodexAppServerWorker[]>();
|
|
378
|
+
for (const worker of this.getAllReadyActiveWorkers()) {
|
|
379
|
+
if (!eligibility.eligibleTokenIds.has(worker.tokenId) || !worker.hasCapacity) continue;
|
|
380
|
+
const workers = workersByToken.get(worker.tokenId) ?? [];
|
|
381
|
+
workers.push(worker);
|
|
382
|
+
workersByToken.set(worker.tokenId, workers);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const selectedTokenId = this.weightedSelector.select(new Set(workersByToken.keys()));
|
|
386
|
+
if (selectedTokenId === null) return null;
|
|
387
|
+
const workers = workersByToken.get(selectedTokenId)!;
|
|
388
|
+
const cursor = this.workerCursorByToken.get(selectedTokenId) ?? 0;
|
|
389
|
+
for (let offset = 0; offset < workers.length; offset++) {
|
|
390
|
+
const index = (cursor + offset) % workers.length;
|
|
391
|
+
const worker = workers[index]!;
|
|
392
|
+
if (worker.tryAcquireTurn()) {
|
|
393
|
+
this.workerCursorByToken.set(selectedTokenId, (index + 1) % workers.length);
|
|
394
|
+
return worker;
|
|
362
395
|
}
|
|
363
396
|
}
|
|
364
|
-
|
|
397
|
+
throw new Error(
|
|
398
|
+
`weighted token ${selectedTokenId} lost idle capacity during synchronous acquire`,
|
|
399
|
+
);
|
|
365
400
|
}
|
|
366
401
|
|
|
367
402
|
getAllReadyActiveWorkers(): CodexAppServerWorker[] {
|
|
@@ -423,19 +458,17 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
423
458
|
this.queue = [];
|
|
424
459
|
}
|
|
425
460
|
|
|
426
|
-
async drainQueue(
|
|
461
|
+
async drainQueue(): Promise<void> {
|
|
427
462
|
if (this.draining) {
|
|
428
463
|
this.drainAgain = true;
|
|
429
464
|
return;
|
|
430
465
|
}
|
|
431
466
|
|
|
432
467
|
this.draining = true;
|
|
433
|
-
let worker = releasedWorker;
|
|
434
468
|
try {
|
|
435
469
|
do {
|
|
436
470
|
this.drainAgain = false;
|
|
437
|
-
await this.drainQueueOnce(
|
|
438
|
-
worker = undefined;
|
|
471
|
+
await this.drainQueueOnce();
|
|
439
472
|
} while (this.drainAgain);
|
|
440
473
|
} catch (e) {
|
|
441
474
|
logger.warn(`drainQueue failed: ${(e as Error).message}`);
|
|
@@ -444,20 +477,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
444
477
|
}
|
|
445
478
|
}
|
|
446
479
|
|
|
447
|
-
private async drainQueueOnce(
|
|
448
|
-
if (
|
|
449
|
-
releasedWorker &&
|
|
450
|
-
this.queue.length > 0 &&
|
|
451
|
-
releasedWorker.isReady &&
|
|
452
|
-
releasedWorker.active &&
|
|
453
|
-
(await this.isWorkerEligibleForQueueItem(releasedWorker, this.queue[0]!)) &&
|
|
454
|
-
releasedWorker.tryAcquireTurn()
|
|
455
|
-
) {
|
|
456
|
-
const next = this.queue.shift()!;
|
|
457
|
-
next.resolve(releasedWorker);
|
|
458
|
-
return;
|
|
459
|
-
}
|
|
460
|
-
|
|
480
|
+
private async drainQueueOnce(): Promise<void> {
|
|
461
481
|
while (this.queue.length > 0) {
|
|
462
482
|
const next = this.queue[0]!;
|
|
463
483
|
let worker: CodexAppServerWorker | null;
|
|
@@ -487,7 +507,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
487
507
|
return await execute(worker);
|
|
488
508
|
} finally {
|
|
489
509
|
worker.releaseTurn();
|
|
490
|
-
this.requestDrainQueue(
|
|
510
|
+
this.requestDrainQueue();
|
|
491
511
|
}
|
|
492
512
|
}
|
|
493
513
|
|
|
@@ -586,8 +606,9 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
586
606
|
tokenName: string,
|
|
587
607
|
credentials: OpenAICredentials,
|
|
588
608
|
quotaThreshold?: number | null,
|
|
609
|
+
weight = 1,
|
|
589
610
|
): Promise<void> {
|
|
590
|
-
this.setTokenMetadata(tokenId, tokenName, quotaThreshold);
|
|
611
|
+
this.setTokenMetadata(tokenId, tokenName, quotaThreshold, weight);
|
|
591
612
|
const workers: CodexAppServerWorker[] = [];
|
|
592
613
|
for (let i = 0; i < WORKERS_PER_TOKEN; i++) {
|
|
593
614
|
if (i > 0) await sleep(SPAWN_INTERVAL_MS);
|
|
@@ -651,14 +672,18 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
651
672
|
return entry;
|
|
652
673
|
}
|
|
653
674
|
|
|
654
|
-
private requestDrainQueue(
|
|
655
|
-
void this.drainQueue(
|
|
656
|
-
logger.warn(`drainQueue failed: ${(e as Error).message}`),
|
|
657
|
-
);
|
|
675
|
+
private requestDrainQueue(): void {
|
|
676
|
+
void this.drainQueue().catch((e) => logger.warn(`drainQueue failed: ${(e as Error).message}`));
|
|
658
677
|
}
|
|
659
678
|
|
|
660
|
-
private setTokenMetadata(
|
|
661
|
-
|
|
679
|
+
private setTokenMetadata(
|
|
680
|
+
id: number,
|
|
681
|
+
name: string,
|
|
682
|
+
quotaThreshold: number | null | undefined,
|
|
683
|
+
weight: number,
|
|
684
|
+
): void {
|
|
685
|
+
this.tokenMetadata.set(id, { name, quotaThreshold, weight });
|
|
686
|
+
this.weightedSelector.setToken(id, weight);
|
|
662
687
|
}
|
|
663
688
|
|
|
664
689
|
private getQuotaThreshold(tokenId: number): number | null | undefined {
|
|
@@ -725,14 +750,6 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
725
750
|
return { readyActiveTokenIds, eligibleTokenIds, overThresholdTokens, thresholdedTokenCount };
|
|
726
751
|
}
|
|
727
752
|
|
|
728
|
-
private async isWorkerEligibleForQueueItem(
|
|
729
|
-
worker: CodexAppServerWorker,
|
|
730
|
-
item: QueueItem,
|
|
731
|
-
): Promise<boolean> {
|
|
732
|
-
if (item.excludedTokenIds.has(worker.tokenId)) return false;
|
|
733
|
-
return this.isTokenQuotaEligible(worker.tokenId, worker.tokenName);
|
|
734
|
-
}
|
|
735
|
-
|
|
736
753
|
private async isTokenQuotaEligible(
|
|
737
754
|
tokenId: number,
|
|
738
755
|
tokenName: string,
|