@cartanova/qgrid-cli 2.4.2 → 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 (95) 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 +2 -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/model-cost.js +22 -1
  21. package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +43 -0
  22. package/bundle/dist/utils/providers/openai/codex-worker.js +19 -2
  23. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +53 -40
  24. package/bundle/src/application/qgrid/qgrid.frame.test.ts +36 -14
  25. package/bundle/src/application/qgrid/qgrid.frame.ts +22 -25
  26. package/bundle/src/application/qgrid/token-subscriber.test.ts +102 -0
  27. package/bundle/src/application/qgrid/token-subscriber.ts +37 -6
  28. package/bundle/src/application/qgrid/token-trigger-setup.test.ts +9 -0
  29. package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -2
  30. package/bundle/src/application/qgrid/token-weight-migration.test.ts +42 -0
  31. package/bundle/src/application/qgrid/tool-emulation.ts +6 -1
  32. package/bundle/src/application/sonamu.generated.http +4 -2
  33. package/bundle/src/application/sonamu.generated.sso.ts +1 -0
  34. package/bundle/src/application/sonamu.generated.ts +10 -1
  35. package/bundle/src/application/token/token.entity.json +17 -1
  36. package/bundle/src/application/token/token.model.test.ts +43 -0
  37. package/bundle/src/application/token/token.model.ts +18 -2
  38. package/bundle/src/application/token/token.types.test.ts +11 -0
  39. package/bundle/src/application/token/token.types.ts +3 -0
  40. package/bundle/src/i18n/sd.generated.ts +1 -0
  41. package/bundle/src/index.ts +2 -5
  42. package/bundle/src/migrations/20260521134025_alter_request_log_steps_alter1.ts +4 -8
  43. package/bundle/src/migrations/20260706120000_alter_request_logs_add_image_cost.ts +1 -1
  44. package/bundle/src/migrations/20260710090000_alter_tokens_add_weight.ts +38 -0
  45. package/bundle/src/server-bootstrap.test.ts +39 -0
  46. package/bundle/src/server-bootstrap.ts +21 -0
  47. package/bundle/src/sonamu.config.ts +3 -12
  48. package/bundle/src/startup-migrations.test.ts +25 -0
  49. package/bundle/src/startup-migrations.ts +20 -0
  50. package/bundle/src/testing/global.test.ts +130 -0
  51. package/bundle/src/testing/global.ts +143 -13
  52. package/bundle/src/testing/test-database-name.test.ts +38 -0
  53. package/bundle/src/testing/test-database-name.ts +21 -0
  54. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +75 -45
  55. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +23 -29
  56. package/bundle/src/utils/providers/common/model-cost.test.ts +54 -0
  57. package/bundle/src/utils/providers/common/model-cost.ts +31 -0
  58. package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -1
  59. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.test.ts +73 -0
  60. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +48 -0
  61. package/bundle/src/utils/providers/openai/codex-worker.test.ts +70 -1
  62. package/bundle/src/utils/providers/openai/codex-worker.ts +18 -6
  63. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +109 -6
  64. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +72 -55
  65. package/bundle/web-dist/client/assets/{index-CN7eSKO4.js → index-D1ExP77_.js} +3 -3
  66. package/bundle/web-dist/client/assets/{logs-NubdF_C_.js → logs-vuI-Zgnq.js} +1 -1
  67. package/bundle/web-dist/client/assets/routes-0Fki0JEi.js +5 -0
  68. package/bundle/web-dist/client/assets/{sd.generated-BrUnRt0B.js → sd.generated-WA5SGJYZ.js} +1 -1
  69. package/bundle/web-dist/client/assets/{services.generated-AsEvHbJ7.js → services.generated-DzZLnb7S.js} +1 -1
  70. package/bundle/web-dist/client/assets/{show-BvDjIZCX.js → show-BreAgaFH.js} +1 -1
  71. package/bundle/web-dist/client/assets/tokens-4VgHQtXd.js +1 -0
  72. package/bundle/web-dist/client/assets/use-update-token-mutation-BV6msjuy.js +1 -0
  73. package/bundle/web-dist/client/index.html +2 -2
  74. package/bundle/web-dist/server/assets/{lazyRouteComponent-BswulcOx.js → lazyRouteComponent-BrPAFtBA.js} +1 -1
  75. package/bundle/web-dist/server/assets/{logs-Bk9C0ELo.js → logs-CKM6W3W6.js} +3 -3
  76. package/bundle/web-dist/server/assets/{routes-D9OiKoqa.js → routes-DhIpPgXz.js} +130 -7
  77. package/bundle/web-dist/server/assets/{sd.generated-DRuQSIKo.js → sd.generated-Cwg2f53z.js} +1 -0
  78. package/bundle/web-dist/server/assets/{services.generated-Dp0uKgpV.js → services.generated-CsotZP44.js} +5 -5
  79. package/bundle/web-dist/server/assets/{show-3GokIDEJ.js → show-CV2UFjaH.js} +3 -3
  80. package/bundle/web-dist/server/assets/{tokens-Cjw6cQxZ.js → tokens-DfGj1y2v.js} +5 -5
  81. package/bundle/web-dist/server/assets/use-update-token-mutation-BL96w-Pg.js +7 -0
  82. package/bundle/web-dist/server/entry-server.generated.js +7 -7
  83. package/package.json +1 -1
  84. package/skills/qgrid/SKILL.md +1 -1
  85. package/skills/qgrid/references/anthropic-claude-code-runtime.md +2 -2
  86. package/skills/qgrid/references/cli-env-and-server-boot.md +9 -9
  87. package/skills/qgrid/references/decision-rationale.md +23 -0
  88. package/skills/qgrid/references/openai-codex-runtime.md +4 -3
  89. package/skills/qgrid/references/prompt-cache-and-usage.md +2 -0
  90. package/skills/qgrid/references/provider-runtime-differences.md +2 -1
  91. package/skills/qgrid/references/repo-map.md +2 -0
  92. package/skills/qgrid/references/token-auth-quota-lifecycle.md +8 -3
  93. package/skills/qgrid/references/verification-and-debugging.md +3 -0
  94. package/bundle/web-dist/client/assets/routes-NKxXqHvs.js +0 -5
  95. package/bundle/web-dist/client/assets/tokens-XAhJyLqj.js +0 -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,