@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.
Files changed (94) hide show
  1. package/bundle/dist/application/qgrid/qgrid.frame.js +17 -19
  2. package/bundle/dist/application/qgrid/qgrid.types.js +3 -2
  3. package/bundle/dist/application/qgrid/token-subscriber.js +23 -9
  4. package/bundle/dist/application/qgrid/token-trigger-setup.js +4 -4
  5. package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
  6. package/bundle/dist/application/sonamu.generated.js +5 -3
  7. package/bundle/dist/application/sonamu.generated.sso.js +3 -2
  8. package/bundle/dist/application/token/token.model.js +8 -3
  9. package/bundle/dist/application/token/token.types.js +8 -3
  10. package/bundle/dist/i18n/sd.generated.js +2 -1
  11. package/bundle/dist/index.js +3 -6
  12. package/bundle/dist/migrations/20260521134025_alter_request_log_steps_alter1.js +3 -9
  13. package/bundle/dist/migrations/20260706120000_alter_request_logs_add_image_cost.js +1 -1
  14. package/bundle/dist/migrations/20260710090000_alter_tokens_add_weight.js +41 -0
  15. package/bundle/dist/server-bootstrap.js +17 -0
  16. package/bundle/dist/sonamu.config.js +1 -10
  17. package/bundle/dist/startup-migrations.js +21 -0
  18. package/bundle/dist/testing/global.js +93 -5
  19. package/bundle/dist/testing/test-database-name.js +22 -0
  20. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +24 -27
  21. package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +43 -0
  22. package/bundle/dist/utils/providers/openai/codex-worker.js +4 -2
  23. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +220 -49
  24. package/bundle/src/application/qgrid/qgrid.frame.test.ts +60 -14
  25. package/bundle/src/application/qgrid/qgrid.frame.ts +24 -25
  26. package/bundle/src/application/qgrid/qgrid.types.ts +1 -0
  27. package/bundle/src/application/qgrid/token-subscriber.test.ts +102 -0
  28. package/bundle/src/application/qgrid/token-subscriber.ts +37 -6
  29. package/bundle/src/application/qgrid/token-trigger-setup.test.ts +9 -0
  30. package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -2
  31. package/bundle/src/application/qgrid/token-weight-migration.test.ts +42 -0
  32. package/bundle/src/application/qgrid/tool-emulation.ts +6 -1
  33. package/bundle/src/application/sonamu.generated.http +4 -2
  34. package/bundle/src/application/sonamu.generated.sso.ts +1 -0
  35. package/bundle/src/application/sonamu.generated.ts +10 -1
  36. package/bundle/src/application/token/token.entity.json +17 -1
  37. package/bundle/src/application/token/token.model.test.ts +43 -0
  38. package/bundle/src/application/token/token.model.ts +18 -2
  39. package/bundle/src/application/token/token.types.test.ts +11 -0
  40. package/bundle/src/application/token/token.types.ts +3 -0
  41. package/bundle/src/i18n/sd.generated.ts +1 -0
  42. package/bundle/src/index.ts +2 -5
  43. package/bundle/src/migrations/20260521134025_alter_request_log_steps_alter1.ts +4 -8
  44. package/bundle/src/migrations/20260706120000_alter_request_logs_add_image_cost.ts +1 -1
  45. package/bundle/src/migrations/20260710090000_alter_tokens_add_weight.ts +38 -0
  46. package/bundle/src/server-bootstrap.test.ts +39 -0
  47. package/bundle/src/server-bootstrap.ts +21 -0
  48. package/bundle/src/sonamu.config.ts +0 -12
  49. package/bundle/src/startup-migrations.test.ts +25 -0
  50. package/bundle/src/startup-migrations.ts +20 -0
  51. package/bundle/src/testing/global.test.ts +130 -0
  52. package/bundle/src/testing/global.ts +143 -13
  53. package/bundle/src/testing/test-database-name.test.ts +38 -0
  54. package/bundle/src/testing/test-database-name.ts +21 -0
  55. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +75 -45
  56. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +23 -29
  57. package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -1
  58. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.test.ts +73 -0
  59. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +48 -0
  60. package/bundle/src/utils/providers/openai/codex-worker.test.ts +26 -0
  61. package/bundle/src/utils/providers/openai/codex-worker.ts +3 -1
  62. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +304 -12
  63. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +354 -68
  64. package/bundle/web-dist/client/assets/{index-DsGDBrrH.js → index-D0dRwMp0.js} +3 -3
  65. package/bundle/web-dist/client/assets/{logs-C6EMtwDn.js → logs-BMzzMMRA.js} +1 -1
  66. package/bundle/web-dist/client/assets/routes-XqQHRGxI.js +5 -0
  67. package/bundle/web-dist/client/assets/{sd.generated-BrUnRt0B.js → sd.generated-WA5SGJYZ.js} +1 -1
  68. package/bundle/web-dist/client/assets/{services.generated-AsEvHbJ7.js → services.generated-DzZLnb7S.js} +1 -1
  69. package/bundle/web-dist/client/assets/{show-BSW3PE7Z.js → show-63BsotX6.js} +1 -1
  70. package/bundle/web-dist/client/assets/tokens-C416mWQ-.js +1 -0
  71. package/bundle/web-dist/client/assets/use-update-token-mutation-BV6msjuy.js +1 -0
  72. package/bundle/web-dist/client/index.html +2 -2
  73. package/bundle/web-dist/server/assets/{lazyRouteComponent-BswulcOx.js → lazyRouteComponent-BrPAFtBA.js} +1 -1
  74. package/bundle/web-dist/server/assets/{logs-Bk9C0ELo.js → logs-CKM6W3W6.js} +3 -3
  75. package/bundle/web-dist/server/assets/{routes-D9OiKoqa.js → routes-VY2um2KO.js} +140 -9
  76. package/bundle/web-dist/server/assets/{sd.generated-DRuQSIKo.js → sd.generated-Cwg2f53z.js} +1 -0
  77. package/bundle/web-dist/server/assets/{services.generated-Dp0uKgpV.js → services.generated-CsotZP44.js} +5 -5
  78. package/bundle/web-dist/server/assets/{show-3GokIDEJ.js → show-CV2UFjaH.js} +3 -3
  79. package/bundle/web-dist/server/assets/{tokens-Cjw6cQxZ.js → tokens-DfGj1y2v.js} +5 -5
  80. package/bundle/web-dist/server/assets/use-update-token-mutation-BL96w-Pg.js +7 -0
  81. package/bundle/web-dist/server/entry-server.generated.js +7 -7
  82. package/package.json +1 -1
  83. package/skills/qgrid/SKILL.md +1 -1
  84. package/skills/qgrid/references/anthropic-claude-code-runtime.md +2 -2
  85. package/skills/qgrid/references/cli-env-and-server-boot.md +9 -9
  86. package/skills/qgrid/references/decision-rationale.md +23 -0
  87. package/skills/qgrid/references/openai-codex-runtime.md +4 -3
  88. package/skills/qgrid/references/prompt-cache-and-usage.md +2 -0
  89. package/skills/qgrid/references/provider-runtime-differences.md +2 -1
  90. package/skills/qgrid/references/repo-map.md +2 -0
  91. package/skills/qgrid/references/token-auth-quota-lifecycle.md +8 -3
  92. package/skills/qgrid/references/verification-and-debugging.md +3 -0
  93. package/bundle/web-dist/client/assets/routes-De9iuL80.js +0 -5
  94. package/bundle/web-dist/client/assets/tokens-BMsL6vk9.js +0 -1
@@ -6,6 +6,9 @@
6
6
  * - TokenSubscriber 이벤트로 worker pool 동기화
7
7
  * - backpressure: 큐 full 또는 timeout 시 SERVER_BUSY
8
8
  */
9
+ import { readFileSync } from "node:fs";
10
+ import { freemem } from "node:os";
11
+
9
12
  import { getLogger } from "@logtape/logtape";
10
13
 
11
14
  import { QuotaThresholdExceededError } from "../../../application/qgrid/qgrid.types";
@@ -18,6 +21,7 @@ import {
18
21
  type GenerateStreamCallbacks,
19
22
  type ProviderDispatcher,
20
23
  } from "../common/provider-dispatcher";
24
+ import { SmoothWeightedRoundRobin } from "../common/smooth-weighted-round-robin";
21
25
  import { CodexAppServerWorker, type StreamCallbacks, type TurnRequest } from "./codex-worker";
22
26
  import {
23
27
  readOpenAIQuotaUsage,
@@ -45,24 +49,106 @@ export class ImageGenerationError extends Error {
45
49
  }
46
50
 
47
51
  const DEFAULT_EFFORT = "low";
48
- const MAX_WORKERS_PER_TOKEN = 5;
49
- const WORKERS_PER_TOKEN = Math.min(
50
- Number(process.env.QGRID_WORKERS_PER_TOKEN ?? 3),
51
- MAX_WORKERS_PER_TOKEN,
52
- );
52
+ export const MAX_OPENAI_WORKERS_PER_TOKEN = 20;
53
+ const DEFAULT_OPENAI_MIN_WORKERS_PER_TOKEN = 5;
54
+ const DEFAULT_OPENAI_MAX_WORKERS_PER_TOKEN = 15;
53
55
  const QUEUE_TIMEOUT_MS = 60_000;
54
56
  const MAX_QUEUE_SIZE = 50;
55
57
  const SPAWN_INTERVAL_MS = 500;
56
58
 
59
+ export const OPENAI_WORKER_BASE_RSS_GIB = 0.71;
60
+ export const OPENAI_WORKER_RSS_GIB = 0.157;
61
+
62
+ export type OpenAIWorkerPoolConfig = {
63
+ autoscale: boolean;
64
+ minWorkersPerToken: number;
65
+ maxWorkersPerToken: number;
66
+ scaleIntervalMs: number;
67
+ scaleDownIdleMs: number;
68
+ maxEstimatedRssGiB: number;
69
+ minHostAvailableGiB: number;
70
+ };
71
+
72
+ function boundedInteger(
73
+ value: string | undefined,
74
+ fallback: number,
75
+ min: number,
76
+ max: number,
77
+ ): number {
78
+ const parsed = Number(value);
79
+ if (!Number.isFinite(parsed)) return fallback;
80
+ return Math.max(min, Math.min(Math.floor(parsed), max));
81
+ }
82
+
83
+ function boundedNumber(value: string | undefined, fallback: number, min: number): number {
84
+ const parsed = Number(value);
85
+ if (!Number.isFinite(parsed)) return fallback;
86
+ return Math.max(min, parsed);
87
+ }
88
+
89
+ export function resolveOpenAIWorkerPoolConfig(
90
+ env: Record<string, string | undefined> = process.env,
91
+ ): OpenAIWorkerPoolConfig {
92
+ const autoscale = env.QGRID_OPENAI_AUTOSCALE !== "false" && env.QGRID_OPENAI_AUTOSCALE !== "0";
93
+ const legacyWorkers = boundedInteger(
94
+ env.QGRID_WORKERS_PER_TOKEN,
95
+ DEFAULT_OPENAI_MIN_WORKERS_PER_TOKEN,
96
+ 1,
97
+ MAX_OPENAI_WORKERS_PER_TOKEN,
98
+ );
99
+ const minWorkersPerToken = boundedInteger(
100
+ env.QGRID_OPENAI_MIN_WORKERS_PER_TOKEN,
101
+ legacyWorkers,
102
+ 1,
103
+ MAX_OPENAI_WORKERS_PER_TOKEN,
104
+ );
105
+ const configuredMax = boundedInteger(
106
+ env.QGRID_OPENAI_MAX_WORKERS_PER_TOKEN,
107
+ autoscale ? DEFAULT_OPENAI_MAX_WORKERS_PER_TOKEN : minWorkersPerToken,
108
+ 1,
109
+ MAX_OPENAI_WORKERS_PER_TOKEN,
110
+ );
111
+
112
+ return {
113
+ autoscale,
114
+ minWorkersPerToken,
115
+ maxWorkersPerToken: autoscale
116
+ ? Math.max(minWorkersPerToken, configuredMax)
117
+ : minWorkersPerToken,
118
+ scaleIntervalMs: boundedInteger(env.QGRID_OPENAI_SCALE_INTERVAL_MS, 5_000, 250, 300_000),
119
+ scaleDownIdleMs: boundedInteger(
120
+ env.QGRID_OPENAI_SCALE_DOWN_IDLE_MS,
121
+ 10 * 60_000,
122
+ 1_000,
123
+ 24 * 60 * 60_000,
124
+ ),
125
+ maxEstimatedRssGiB: boundedNumber(env.QGRID_OPENAI_MAX_ESTIMATED_RSS_GIB, 16, 1),
126
+ minHostAvailableGiB: boundedNumber(env.QGRID_OPENAI_MIN_HOST_AVAILABLE_GIB, 20, 0),
127
+ };
128
+ }
129
+
130
+ export function estimateOpenAIWorkerRssGiB(totalWorkers: number): number {
131
+ return OPENAI_WORKER_BASE_RSS_GIB + OPENAI_WORKER_RSS_GIB * totalWorkers;
132
+ }
133
+
134
+ function readHostAvailableGiB(): number {
135
+ try {
136
+ const match = readFileSync("/proc/meminfo", "utf8").match(/^MemAvailable:\s+(\d+)\s+kB$/m);
137
+ if (match?.[1]) return Number(match[1]) / 1024 / 1024;
138
+ } catch {}
139
+ return freemem() / 1024 / 1024 / 1024;
140
+ }
141
+
57
142
  // thread 재사용(prompt cache 고정). 끄면 기존 "매 turn 새 thread + history inject" 동작.
58
143
  const THREAD_REUSE_ENABLED = process.env.QGRID_OPENAI_THREAD_REUSE !== "false";
59
144
  // 좌표 worker 가 busy 일 때 free 를 기다리는 최대 시간. 초과 시 새 thread 로 폴백.
60
145
  const REUSE_WORKER_WAIT_MS = 5_000;
61
146
  const REUSE_WORKER_POLL_MS = 50;
62
147
 
63
- // workerId 합성: tokenId 와 workerIndex(0..4) 를 하나의 안정 숫자로 인코딩.
64
- function makeWorkerId(tokenId: number, workerIndex: number): number {
65
- return tokenId * 10 + workerIndex;
148
+ // workerId 합성: tokenId 와 workerIndex(0..19) 를 하나의 안정 숫자로 인코딩.
149
+ const OPENAI_WORKER_ID_STRIDE = 100;
150
+ export function makeOpenAIWorkerId(tokenId: number, workerIndex: number): number {
151
+ return tokenId * OPENAI_WORKER_ID_STRIDE + workerIndex;
66
152
  }
67
153
 
68
154
  function sleep(ms: number): Promise<void> {
@@ -91,7 +177,10 @@ type QueueItem = {
91
177
 
92
178
  type TokenMetadata = {
93
179
  name: string;
180
+ credentials: OpenAICredentials;
94
181
  quotaThreshold?: number | null;
182
+ weight: number;
183
+ active: boolean;
95
184
  };
96
185
 
97
186
  type TokenEligibility = {
@@ -104,16 +193,34 @@ type TokenEligibility = {
104
193
  export class OpenAIDispatcher implements ProviderDispatcher {
105
194
  workerPool = new Map<number, CodexAppServerWorker[]>();
106
195
  tokenMetadata = new Map<number, TokenMetadata>();
107
- rrCursor = 0;
108
196
  queue: QueueItem[] = [];
197
+ readonly poolConfig: OpenAIWorkerPoolConfig;
198
+ private readonly weightedSelector = new SmoothWeightedRoundRobin();
199
+ private readonly workerCursorByToken = new Map<number, number>();
200
+ private readonly getHostAvailableGiB: () => number;
109
201
  private draining = false;
110
202
  private drainAgain = false;
203
+ private scalerTimer: ReturnType<typeof setInterval> | null = null;
204
+ private scalingPromise: Promise<void> | null = null;
205
+ private lastRequestAt = Date.now();
206
+ private stopped = false;
207
+
208
+ constructor(
209
+ poolConfig: OpenAIWorkerPoolConfig = resolveOpenAIWorkerPoolConfig(),
210
+ getHostAvailableGiB: () => number = readHostAvailableGiB,
211
+ ) {
212
+ this.poolConfig = poolConfig;
213
+ this.getHostAvailableGiB = getHostAvailableGiB;
214
+ }
111
215
 
112
216
  async start(): Promise<void> {
217
+ this.stopped = false;
113
218
  const { rows } = await TokenModel.findMany("A");
114
219
  const openaiTokens = rows.filter((t) => t.provider === "openai");
115
220
  logger.info(
116
- `starting ${openaiTokens.length} openai tokens (${WORKERS_PER_TOKEN} workers each)`,
221
+ this.poolConfig.autoscale
222
+ ? `starting ${openaiTokens.length} openai tokens (${this.poolConfig.minWorkersPerToken}-${this.poolConfig.maxWorkersPerToken} workers each, autoscale)`
223
+ : `starting ${openaiTokens.length} openai tokens (${this.poolConfig.minWorkersPerToken} workers each)`,
117
224
  );
118
225
 
119
226
  await Promise.allSettled(
@@ -123,18 +230,36 @@ export class OpenAIDispatcher implements ProviderDispatcher {
123
230
  t.name,
124
231
  t.credentials as OpenAICredentials,
125
232
  t.quota_threshold,
233
+ t.weight,
126
234
  );
127
235
  if (!t.active) this.onTokenDeactivated(t.id);
128
236
  }),
129
237
  );
238
+
239
+ if (this.poolConfig.autoscale) {
240
+ this.scalerTimer = setInterval(
241
+ () => this.requestAutoscaleEvaluation(),
242
+ this.poolConfig.scaleIntervalMs,
243
+ );
244
+ this.scalerTimer.unref?.();
245
+ }
130
246
  }
131
247
 
132
248
  async stop(): Promise<void> {
249
+ this.stopped = true;
250
+ if (this.scalerTimer) {
251
+ clearInterval(this.scalerTimer);
252
+ this.scalerTimer = null;
253
+ }
254
+ await this.scalingPromise?.catch(() => {});
133
255
  this.rejectAllQueued("DISPATCHER_SHUTDOWN");
134
256
  const kills = [...this.workerPool.values()].flat().map((w) => w.kill());
135
257
  await Promise.allSettled(kills);
258
+ for (const id of this.tokenMetadata.keys()) this.weightedSelector.removeToken(id);
136
259
  this.workerPool.clear();
137
260
  this.tokenMetadata.clear();
261
+ this.workerCursorByToken.clear();
262
+ this.weightedSelector.resetScores();
138
263
  this.rateLimitsCache.clear();
139
264
  }
140
265
 
@@ -145,10 +270,11 @@ export class OpenAIDispatcher implements ProviderDispatcher {
145
270
  name: string,
146
271
  credentials: OpenAICredentials,
147
272
  quotaThreshold?: number | null,
273
+ weight = 1,
148
274
  ): Promise<void> {
149
- this.setTokenMetadata(id, name, quotaThreshold);
275
+ this.setTokenMetadata(id, name, credentials, quotaThreshold, weight);
150
276
  if (this.workerPool.has(id)) return;
151
- await this.spawnWorkers(id, name, credentials, quotaThreshold);
277
+ await this.spawnWorkers(id, name, credentials, quotaThreshold, weight);
152
278
  }
153
279
 
154
280
  async onTokenRemoved(id: number): Promise<void> {
@@ -156,6 +282,8 @@ export class OpenAIDispatcher implements ProviderDispatcher {
156
282
  const tokenName = workers[0]?.tokenName;
157
283
  this.workerPool.delete(id);
158
284
  this.tokenMetadata.delete(id);
285
+ this.weightedSelector.removeToken(id);
286
+ this.workerCursorByToken.delete(id);
159
287
  this.rateLimitsCache.delete(id);
160
288
  await Promise.allSettled(workers.map((w) => w.kill()));
161
289
  if (this.getAllReadyActiveWorkers().length === 0) {
@@ -172,14 +300,15 @@ export class OpenAIDispatcher implements ProviderDispatcher {
172
300
  name: string,
173
301
  credentials: OpenAICredentials,
174
302
  quotaThreshold?: number | null,
303
+ weight = 1,
175
304
  ): Promise<void> {
176
305
  const existing = this.workerPool.get(id) ?? [];
177
306
  const renamed = existing.some((w) => w.tokenName !== name);
178
- this.setTokenMetadata(id, name, quotaThreshold);
307
+ this.setTokenMetadata(id, name, credentials, quotaThreshold, weight);
179
308
  if (renamed) this.rateLimitsCache.delete(id);
180
309
 
181
310
  if (existing.length === 0) {
182
- await this.spawnWorkers(id, name, credentials, quotaThreshold);
311
+ await this.spawnWorkers(id, name, credentials, quotaThreshold, weight);
183
312
  return;
184
313
  }
185
314
 
@@ -193,15 +322,19 @@ export class OpenAIDispatcher implements ProviderDispatcher {
193
322
  this.workerPool.delete(id);
194
323
  this.rateLimitsCache.delete(id);
195
324
  await Promise.allSettled(existing.map((w) => w.kill()));
196
- await this.spawnWorkers(id, name, credentials, quotaThreshold);
325
+ await this.spawnWorkers(id, name, credentials, quotaThreshold, weight);
197
326
  }
198
327
 
199
328
  onTokenDeactivated(id: number): void {
200
329
  const workers = this.workerPool.get(id) ?? [];
201
330
  const tokenName = workers[0]?.tokenName;
331
+ const metadata = this.tokenMetadata.get(id);
332
+ if (metadata) metadata.active = false;
333
+ const changed = workers.some((worker) => worker.active);
202
334
  workers.forEach((w) => {
203
335
  w.active = false;
204
336
  });
337
+ if (changed) this.weightedSelector.resetScores();
205
338
  if (this.getAllReadyActiveWorkers().length === 0) {
206
339
  this.rejectAllQueued("NO_ACTIVE_WORKERS");
207
340
  }
@@ -212,9 +345,13 @@ export class OpenAIDispatcher implements ProviderDispatcher {
212
345
  onTokenActivated(id: number): void {
213
346
  const workers = this.workerPool.get(id) ?? [];
214
347
  const tokenName = workers[0]?.tokenName;
348
+ const metadata = this.tokenMetadata.get(id);
349
+ if (metadata) metadata.active = true;
350
+ const changed = workers.some((worker) => !worker.active);
215
351
  workers.forEach((w) => {
216
352
  w.active = true;
217
353
  });
354
+ if (changed) this.weightedSelector.resetScores();
218
355
  this.requestDrainQueue();
219
356
  const label = tokenName ?? `token ${id}`;
220
357
  logger.info(`workers activated: ${label}`);
@@ -226,6 +363,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
226
363
  name: string;
227
364
  credentials: OpenAICredentials;
228
365
  quotaThreshold?: number | null;
366
+ weight: number;
229
367
  }>,
230
368
  ): Promise<void> {
231
369
  const next = new Set(rows.map((r) => r.id));
@@ -235,9 +373,15 @@ export class OpenAIDispatcher implements ProviderDispatcher {
235
373
 
236
374
  for (const row of rows) {
237
375
  if (this.workerPool.has(row.id)) {
238
- await this.onTokenUpdated(row.id, row.name, row.credentials, row.quotaThreshold);
376
+ await this.onTokenUpdated(
377
+ row.id,
378
+ row.name,
379
+ row.credentials,
380
+ row.quotaThreshold,
381
+ row.weight,
382
+ );
239
383
  } else {
240
- await this.onTokenAdded(row.id, row.name, row.credentials, row.quotaThreshold);
384
+ await this.onTokenAdded(row.id, row.name, row.credentials, row.quotaThreshold, row.weight);
241
385
  }
242
386
  this.onTokenActivated(row.id);
243
387
  }
@@ -246,6 +390,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
246
390
  // ── Generate ────────────────────────────────────────────────────
247
391
 
248
392
  async generate(req: GenerateRequest): Promise<GenerateResult> {
393
+ this.lastRequestAt = Date.now();
249
394
  const excludedTokenIds = new Set<number>();
250
395
  // thread 재사용 경로: 좌표 worker 를 점유해 기존 thread 에 turn 만 실행.
251
396
  const reuseWorker = await this.acquireReuseWorker(req, excludedTokenIds);
@@ -270,10 +415,14 @@ export class OpenAIDispatcher implements ProviderDispatcher {
270
415
  }
271
416
 
272
417
  async generateStream(req: GenerateRequest, cb: GenerateStreamCallbacks): Promise<void> {
418
+ this.lastRequestAt = Date.now();
273
419
  // 이미지 생성은 non-stream 전용(R2): codex 가 이미지 델타를 주지 않고 완성 base64 를
274
420
  // 한 번에 준다. 스트리밍 경로로 이미지 요청이 오면 명시적 에러.
275
421
  if (req.imageGeneration) {
276
- throw new ImageGenerationError("gate", "image generation is not supported on the streaming path");
422
+ throw new ImageGenerationError(
423
+ "gate",
424
+ "image generation is not supported on the streaming path",
425
+ );
277
426
  }
278
427
  const excludedTokenIds = new Set<number>();
279
428
  const reuseWorker = await this.acquireReuseWorker(req, excludedTokenIds);
@@ -329,7 +478,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
329
478
  findWorkerById(workerId: number): CodexAppServerWorker | null {
330
479
  for (const workers of this.workerPool.values()) {
331
480
  for (const w of workers) {
332
- if (makeWorkerId(w.tokenId, w.workerIndex) === workerId) return w;
481
+ if (makeOpenAIWorkerId(w.tokenId, w.workerIndex) === workerId) return w;
333
482
  }
334
483
  }
335
484
  return null;
@@ -341,8 +490,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
341
490
  }
342
491
 
343
492
  // ── Worker selection ────────────────────────────────────────────
344
- // 기본 round-robin
345
- // TODO: 추후 다른 알고리즘으로 변경 가능하게 지원
493
+ // Quota-eligible idle token을 weight로 고른 뒤, 그 token 안에서 worker를 순환한다.
346
494
  async acquireIdleWorker(
347
495
  excludedTokenIds = new Set<number>(),
348
496
  ): Promise<CodexAppServerWorker | null> {
@@ -350,18 +498,29 @@ export class OpenAIDispatcher implements ProviderDispatcher {
350
498
  if (eligibility.readyActiveTokenIds.size === 0) return null;
351
499
  if (eligibility.eligibleTokenIds.size === 0) this.throwQuotaThresholdExceeded(eligibility);
352
500
 
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;
501
+ const workersByToken = new Map<number, CodexAppServerWorker[]>();
502
+ for (const worker of this.getAllReadyActiveWorkers()) {
503
+ if (!eligibility.eligibleTokenIds.has(worker.tokenId) || !worker.hasCapacity) continue;
504
+ const workers = workersByToken.get(worker.tokenId) ?? [];
505
+ workers.push(worker);
506
+ workersByToken.set(worker.tokenId, workers);
507
+ }
508
+
509
+ const selectedTokenId = this.weightedSelector.select(new Set(workersByToken.keys()));
510
+ if (selectedTokenId === null) return null;
511
+ const workers = workersByToken.get(selectedTokenId)!;
512
+ const cursor = this.workerCursorByToken.get(selectedTokenId) ?? 0;
513
+ for (let offset = 0; offset < workers.length; offset++) {
514
+ const index = (cursor + offset) % workers.length;
515
+ const worker = workers[index]!;
516
+ if (worker.tryAcquireTurn()) {
517
+ this.workerCursorByToken.set(selectedTokenId, (index + 1) % workers.length);
518
+ return worker;
362
519
  }
363
520
  }
364
- return null;
521
+ throw new Error(
522
+ `weighted token ${selectedTokenId} lost idle capacity during synchronous acquire`,
523
+ );
365
524
  }
366
525
 
367
526
  getAllReadyActiveWorkers(): CodexAppServerWorker[] {
@@ -403,6 +562,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
403
562
  };
404
563
 
405
564
  this.queue.push(item);
565
+ this.requestAutoscaleEvaluation();
406
566
  });
407
567
  }
408
568
 
@@ -423,19 +583,17 @@ export class OpenAIDispatcher implements ProviderDispatcher {
423
583
  this.queue = [];
424
584
  }
425
585
 
426
- async drainQueue(releasedWorker?: CodexAppServerWorker): Promise<void> {
586
+ async drainQueue(): Promise<void> {
427
587
  if (this.draining) {
428
588
  this.drainAgain = true;
429
589
  return;
430
590
  }
431
591
 
432
592
  this.draining = true;
433
- let worker = releasedWorker;
434
593
  try {
435
594
  do {
436
595
  this.drainAgain = false;
437
- await this.drainQueueOnce(worker);
438
- worker = undefined;
596
+ await this.drainQueueOnce();
439
597
  } while (this.drainAgain);
440
598
  } catch (e) {
441
599
  logger.warn(`drainQueue failed: ${(e as Error).message}`);
@@ -444,20 +602,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
444
602
  }
445
603
  }
446
604
 
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
-
605
+ private async drainQueueOnce(): Promise<void> {
461
606
  while (this.queue.length > 0) {
462
607
  const next = this.queue[0]!;
463
608
  let worker: CodexAppServerWorker | null;
@@ -487,7 +632,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
487
632
  return await execute(worker);
488
633
  } finally {
489
634
  worker.releaseTurn();
490
- this.requestDrainQueue(worker);
635
+ this.requestDrainQueue();
491
636
  }
492
637
  }
493
638
 
@@ -540,7 +685,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
540
685
  ttftMs: result.ttftMs,
541
686
  model: result.model,
542
687
  threadCoord: {
543
- workerId: makeWorkerId(worker.tokenId, worker.workerIndex),
688
+ workerId: makeOpenAIWorkerId(worker.tokenId, worker.workerIndex),
544
689
  threadId: result.threadId,
545
690
  epoch: worker.epoch,
546
691
  },
@@ -569,7 +714,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
569
714
  ...result,
570
715
  tokenName: worker.tokenName,
571
716
  threadCoord: {
572
- workerId: makeWorkerId(worker.tokenId, worker.workerIndex),
717
+ workerId: makeOpenAIWorkerId(worker.tokenId, worker.workerIndex),
573
718
  threadId: resolvedThreadId,
574
719
  epoch: worker.epoch,
575
720
  },
@@ -586,10 +731,11 @@ export class OpenAIDispatcher implements ProviderDispatcher {
586
731
  tokenName: string,
587
732
  credentials: OpenAICredentials,
588
733
  quotaThreshold?: number | null,
734
+ weight = 1,
589
735
  ): Promise<void> {
590
- this.setTokenMetadata(tokenId, tokenName, quotaThreshold);
736
+ this.setTokenMetadata(tokenId, tokenName, credentials, quotaThreshold, weight);
591
737
  const workers: CodexAppServerWorker[] = [];
592
- for (let i = 0; i < WORKERS_PER_TOKEN; i++) {
738
+ for (let i = 0; i < this.poolConfig.minWorkersPerToken; i++) {
593
739
  if (i > 0) await sleep(SPAWN_INTERVAL_MS);
594
740
  const worker = await this.spawnSingleWorker(tokenId, tokenName, credentials, i);
595
741
  if (worker) workers.push(worker);
@@ -632,6 +778,143 @@ export class OpenAIDispatcher implements ProviderDispatcher {
632
778
  }
633
779
  }
634
780
 
781
+ async evaluateAutoscaling(): Promise<void> {
782
+ if (!this.poolConfig.autoscale || this.stopped) return;
783
+ if (this.queue.length > 0) {
784
+ await this.scaleUpOneStep();
785
+ return;
786
+ }
787
+ if (Date.now() - this.lastRequestAt >= this.poolConfig.scaleDownIdleMs) {
788
+ await this.scaleDownOneStep();
789
+ }
790
+ }
791
+
792
+ async scaleUpOneStep(): Promise<void> {
793
+ if (!this.poolConfig.autoscale || this.stopped) return;
794
+ await this.runScaling(async () => {
795
+ const candidates = [...this.tokenMetadata.entries()].filter(([tokenId, metadata]) => {
796
+ if (!metadata.active) return false;
797
+ return (this.workerPool.get(tokenId)?.length ?? 0) < this.poolConfig.maxWorkersPerToken;
798
+ });
799
+ if (candidates.length === 0) return;
800
+
801
+ const projectedWorkerCount = this.workerCount + candidates.length;
802
+ const projectedRssGiB = estimateOpenAIWorkerRssGiB(projectedWorkerCount);
803
+ if (projectedRssGiB > this.poolConfig.maxEstimatedRssGiB) {
804
+ logger.warn(
805
+ `openai autoscale blocked: estimated RSS ${projectedRssGiB.toFixed(2)}GiB exceeds ${this.poolConfig.maxEstimatedRssGiB.toFixed(2)}GiB`,
806
+ );
807
+ return;
808
+ }
809
+
810
+ const hostAvailableGiB = this.getHostAvailableGiB();
811
+ if (hostAvailableGiB < this.poolConfig.minHostAvailableGiB) {
812
+ logger.warn(
813
+ `openai autoscale blocked: host available ${hostAvailableGiB.toFixed(2)}GiB below ${this.poolConfig.minHostAvailableGiB.toFixed(2)}GiB`,
814
+ );
815
+ return;
816
+ }
817
+
818
+ const spawned = await Promise.all(
819
+ candidates.map(async ([tokenId, metadata]) => {
820
+ const workers = this.workerPool.get(tokenId) ?? [];
821
+ const workerIndex = this.nextAvailableWorkerIndex(workers);
822
+ if (workerIndex >= this.poolConfig.maxWorkersPerToken) return null;
823
+ const worker = await this.spawnSingleWorker(
824
+ tokenId,
825
+ metadata.name,
826
+ metadata.credentials,
827
+ workerIndex,
828
+ );
829
+ if (!worker) return null;
830
+
831
+ const currentMetadata = this.tokenMetadata.get(tokenId);
832
+ const currentWorkers = this.workerPool.get(tokenId);
833
+ if (
834
+ !currentMetadata?.active ||
835
+ currentMetadata !== metadata ||
836
+ !currentWorkers ||
837
+ currentWorkers.some((entry) => entry.workerIndex === workerIndex)
838
+ ) {
839
+ await worker.kill().catch(() => {});
840
+ return null;
841
+ }
842
+ currentWorkers.push(worker);
843
+ currentWorkers.sort((a, b) => a.workerIndex - b.workerIndex);
844
+ return worker;
845
+ }),
846
+ );
847
+
848
+ const added = spawned.filter((worker) => worker !== null).length;
849
+ if (added > 0) {
850
+ logger.info(
851
+ `openai autoscale up: +${added} workers (${this.workerCount} total, queue ${this.queue.length})`,
852
+ );
853
+ this.requestDrainQueue();
854
+ if (this.queue.length > 0) {
855
+ const timer = setTimeout(
856
+ () => this.requestAutoscaleEvaluation(),
857
+ Math.min(this.poolConfig.scaleIntervalMs, 1_000),
858
+ );
859
+ timer.unref?.();
860
+ }
861
+ }
862
+ });
863
+ }
864
+
865
+ async scaleDownOneStep(): Promise<void> {
866
+ if (!this.poolConfig.autoscale || this.stopped) return;
867
+ await this.runScaling(async () => {
868
+ const removed: CodexAppServerWorker[] = [];
869
+ for (const tokenId of this.tokenMetadata.keys()) {
870
+ const workers = this.workerPool.get(tokenId) ?? [];
871
+ if (workers.length <= this.poolConfig.minWorkersPerToken) continue;
872
+
873
+ const worker = workers
874
+ .toSorted((a, b) => b.workerIndex - a.workerIndex)
875
+ .find((entry) => entry.hasCapacity);
876
+ if (!worker) continue;
877
+
878
+ this.workerPool.set(
879
+ tokenId,
880
+ workers.filter((entry) => entry !== worker),
881
+ );
882
+ removed.push(worker);
883
+ }
884
+
885
+ if (removed.length === 0) return;
886
+ await Promise.allSettled(removed.map((worker) => worker.kill()));
887
+ logger.info(`openai autoscale down: -${removed.length} workers (${this.workerCount} total)`);
888
+ });
889
+ }
890
+
891
+ private nextAvailableWorkerIndex(workers: CodexAppServerWorker[]): number {
892
+ const used = new Set(workers.map((worker) => worker.workerIndex));
893
+ for (let index = 0; index < this.poolConfig.maxWorkersPerToken; index++) {
894
+ if (!used.has(index)) return index;
895
+ }
896
+ return this.poolConfig.maxWorkersPerToken;
897
+ }
898
+
899
+ private requestAutoscaleEvaluation(): void {
900
+ if (!this.poolConfig.autoscale || this.stopped) return;
901
+ void this.evaluateAutoscaling().catch((e) =>
902
+ logger.warn(`openai autoscale evaluation failed: ${(e as Error).message}`),
903
+ );
904
+ }
905
+
906
+ private async runScaling(task: () => Promise<void>): Promise<void> {
907
+ if (this.scalingPromise) {
908
+ await this.scalingPromise;
909
+ return;
910
+ }
911
+ const scaling = task().finally(() => {
912
+ if (this.scalingPromise === scaling) this.scalingPromise = null;
913
+ });
914
+ this.scalingPromise = scaling;
915
+ await scaling;
916
+ }
917
+
635
918
  // ── Rate limits ─────────────────────────────────────────────────
636
919
 
637
920
  rateLimitsCache = new Map<number, { data: GetAccountRateLimitsResponse; cachedAt: number }>();
@@ -651,14 +934,25 @@ export class OpenAIDispatcher implements ProviderDispatcher {
651
934
  return entry;
652
935
  }
653
936
 
654
- private requestDrainQueue(releasedWorker?: CodexAppServerWorker): void {
655
- void this.drainQueue(releasedWorker).catch((e) =>
656
- logger.warn(`drainQueue failed: ${(e as Error).message}`),
657
- );
937
+ private requestDrainQueue(): void {
938
+ void this.drainQueue().catch((e) => logger.warn(`drainQueue failed: ${(e as Error).message}`));
658
939
  }
659
940
 
660
- private setTokenMetadata(id: number, name: string, quotaThreshold?: number | null): void {
661
- this.tokenMetadata.set(id, { name, quotaThreshold });
941
+ private setTokenMetadata(
942
+ id: number,
943
+ name: string,
944
+ credentials: OpenAICredentials,
945
+ quotaThreshold: number | null | undefined,
946
+ weight: number,
947
+ ): void {
948
+ this.tokenMetadata.set(id, {
949
+ name,
950
+ credentials,
951
+ quotaThreshold,
952
+ weight,
953
+ active: this.tokenMetadata.get(id)?.active ?? true,
954
+ });
955
+ this.weightedSelector.setToken(id, weight);
662
956
  }
663
957
 
664
958
  private getQuotaThreshold(tokenId: number): number | null | undefined {
@@ -725,14 +1019,6 @@ export class OpenAIDispatcher implements ProviderDispatcher {
725
1019
  return { readyActiveTokenIds, eligibleTokenIds, overThresholdTokens, thresholdedTokenCount };
726
1020
  }
727
1021
 
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
1022
  private async isTokenQuotaEligible(
737
1023
  tokenId: number,
738
1024
  tokenName: string,