@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.
Files changed (89) hide show
  1. package/bundle/dist/application/qgrid/qgrid.frame.js +13 -17
  2. package/bundle/dist/application/qgrid/token-subscriber.js +23 -9
  3. package/bundle/dist/application/qgrid/token-trigger-setup.js +4 -4
  4. package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
  5. package/bundle/dist/application/sonamu.generated.js +5 -3
  6. package/bundle/dist/application/sonamu.generated.sso.js +3 -2
  7. package/bundle/dist/application/token/token.model.js +8 -3
  8. package/bundle/dist/application/token/token.types.js +8 -3
  9. package/bundle/dist/i18n/sd.generated.js +2 -1
  10. package/bundle/dist/index.js +3 -6
  11. package/bundle/dist/migrations/20260521134025_alter_request_log_steps_alter1.js +3 -9
  12. package/bundle/dist/migrations/20260706120000_alter_request_logs_add_image_cost.js +1 -1
  13. package/bundle/dist/migrations/20260710090000_alter_tokens_add_weight.js +41 -0
  14. package/bundle/dist/server-bootstrap.js +17 -0
  15. package/bundle/dist/sonamu.config.js +1 -10
  16. package/bundle/dist/startup-migrations.js +21 -0
  17. package/bundle/dist/testing/global.js +93 -5
  18. package/bundle/dist/testing/test-database-name.js +22 -0
  19. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +24 -27
  20. package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +43 -0
  21. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +53 -40
  22. package/bundle/src/application/qgrid/qgrid.frame.test.ts +36 -14
  23. package/bundle/src/application/qgrid/qgrid.frame.ts +22 -25
  24. package/bundle/src/application/qgrid/token-subscriber.test.ts +102 -0
  25. package/bundle/src/application/qgrid/token-subscriber.ts +37 -6
  26. package/bundle/src/application/qgrid/token-trigger-setup.test.ts +9 -0
  27. package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -2
  28. package/bundle/src/application/qgrid/token-weight-migration.test.ts +42 -0
  29. package/bundle/src/application/qgrid/tool-emulation.ts +6 -1
  30. package/bundle/src/application/sonamu.generated.http +4 -2
  31. package/bundle/src/application/sonamu.generated.sso.ts +1 -0
  32. package/bundle/src/application/sonamu.generated.ts +10 -1
  33. package/bundle/src/application/token/token.entity.json +17 -1
  34. package/bundle/src/application/token/token.model.test.ts +43 -0
  35. package/bundle/src/application/token/token.model.ts +18 -2
  36. package/bundle/src/application/token/token.types.test.ts +11 -0
  37. package/bundle/src/application/token/token.types.ts +3 -0
  38. package/bundle/src/i18n/sd.generated.ts +1 -0
  39. package/bundle/src/index.ts +2 -5
  40. package/bundle/src/migrations/20260521134025_alter_request_log_steps_alter1.ts +4 -8
  41. package/bundle/src/migrations/20260706120000_alter_request_logs_add_image_cost.ts +1 -1
  42. package/bundle/src/migrations/20260710090000_alter_tokens_add_weight.ts +38 -0
  43. package/bundle/src/server-bootstrap.test.ts +39 -0
  44. package/bundle/src/server-bootstrap.ts +21 -0
  45. package/bundle/src/sonamu.config.ts +0 -12
  46. package/bundle/src/startup-migrations.test.ts +25 -0
  47. package/bundle/src/startup-migrations.ts +20 -0
  48. package/bundle/src/testing/global.test.ts +130 -0
  49. package/bundle/src/testing/global.ts +143 -13
  50. package/bundle/src/testing/test-database-name.test.ts +38 -0
  51. package/bundle/src/testing/test-database-name.ts +21 -0
  52. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +75 -45
  53. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +23 -29
  54. package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -1
  55. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.test.ts +73 -0
  56. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +48 -0
  57. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +109 -6
  58. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +72 -55
  59. package/bundle/web-dist/client/assets/{index-DsGDBrrH.js → index-D1ExP77_.js} +3 -3
  60. package/bundle/web-dist/client/assets/{logs-C6EMtwDn.js → logs-vuI-Zgnq.js} +1 -1
  61. package/bundle/web-dist/client/assets/routes-0Fki0JEi.js +5 -0
  62. package/bundle/web-dist/client/assets/{sd.generated-BrUnRt0B.js → sd.generated-WA5SGJYZ.js} +1 -1
  63. package/bundle/web-dist/client/assets/{services.generated-AsEvHbJ7.js → services.generated-DzZLnb7S.js} +1 -1
  64. package/bundle/web-dist/client/assets/{show-BSW3PE7Z.js → show-BreAgaFH.js} +1 -1
  65. package/bundle/web-dist/client/assets/tokens-4VgHQtXd.js +1 -0
  66. package/bundle/web-dist/client/assets/use-update-token-mutation-BV6msjuy.js +1 -0
  67. package/bundle/web-dist/client/index.html +2 -2
  68. package/bundle/web-dist/server/assets/{lazyRouteComponent-BswulcOx.js → lazyRouteComponent-BrPAFtBA.js} +1 -1
  69. package/bundle/web-dist/server/assets/{logs-Bk9C0ELo.js → logs-CKM6W3W6.js} +3 -3
  70. package/bundle/web-dist/server/assets/{routes-D9OiKoqa.js → routes-DhIpPgXz.js} +130 -7
  71. package/bundle/web-dist/server/assets/{sd.generated-DRuQSIKo.js → sd.generated-Cwg2f53z.js} +1 -0
  72. package/bundle/web-dist/server/assets/{services.generated-Dp0uKgpV.js → services.generated-CsotZP44.js} +5 -5
  73. package/bundle/web-dist/server/assets/{show-3GokIDEJ.js → show-CV2UFjaH.js} +3 -3
  74. package/bundle/web-dist/server/assets/{tokens-Cjw6cQxZ.js → tokens-DfGj1y2v.js} +5 -5
  75. package/bundle/web-dist/server/assets/use-update-token-mutation-BL96w-Pg.js +7 -0
  76. package/bundle/web-dist/server/entry-server.generated.js +7 -7
  77. package/package.json +1 -1
  78. package/skills/qgrid/SKILL.md +1 -1
  79. package/skills/qgrid/references/anthropic-claude-code-runtime.md +2 -2
  80. package/skills/qgrid/references/cli-env-and-server-boot.md +9 -9
  81. package/skills/qgrid/references/decision-rationale.md +23 -0
  82. package/skills/qgrid/references/openai-codex-runtime.md +4 -3
  83. package/skills/qgrid/references/prompt-cache-and-usage.md +2 -0
  84. package/skills/qgrid/references/provider-runtime-differences.md +2 -1
  85. package/skills/qgrid/references/repo-map.md +2 -0
  86. package/skills/qgrid/references/token-auth-quota-lifecycle.md +8 -3
  87. package/skills/qgrid/references/verification-and-debugging.md +3 -0
  88. package/bundle/web-dist/client/assets/routes-De9iuL80.js +0 -5
  89. 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(worker);
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(workerA);
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(worker), dispatcher.drainQueue(worker)]);
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(row.id, row.name, row.credentials, row.quotaThreshold);
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("gate", "image generation is not supported on the streaming path");
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
- // 기본 round-robin
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 allWorkers = this.getAllReadyActiveWorkers().filter((w) =>
354
- eligibility.eligibleTokenIds.has(w.tokenId),
355
- );
356
- if (allWorkers.length === 0) return null;
357
- for (let i = 0; i < allWorkers.length; i++) {
358
- const w = allWorkers[(this.rrCursor + i) % allWorkers.length]!;
359
- if (w.tryAcquireTurn()) {
360
- this.rrCursor = (this.rrCursor + i + 1) % allWorkers.length;
361
- return w;
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
- return null;
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(releasedWorker?: CodexAppServerWorker): Promise<void> {
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(worker);
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(releasedWorker?: CodexAppServerWorker): Promise<void> {
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(worker);
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(releasedWorker?: CodexAppServerWorker): void {
655
- void this.drainQueue(releasedWorker).catch((e) =>
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(id: number, name: string, quotaThreshold?: number | null): void {
661
- this.tokenMetadata.set(id, { name, quotaThreshold });
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,