@cartanova/qgrid-cli 2.4.4 → 2.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundle/dist/application/qgrid/qgrid.frame.js +5 -3
- package/bundle/dist/application/qgrid/qgrid.types.js +3 -2
- package/bundle/dist/utils/providers/openai/codex-worker.js +4 -2
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +173 -15
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +24 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +2 -0
- package/bundle/src/application/qgrid/qgrid.types.ts +1 -0
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +26 -0
- package/bundle/src/utils/providers/openai/codex-worker.ts +3 -1
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +196 -7
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +286 -17
- package/bundle/web-dist/client/assets/{index-D1ExP77_.js → index-D0dRwMp0.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-vuI-Zgnq.js → logs-BMzzMMRA.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-0Fki0JEi.js → routes-XqQHRGxI.js} +2 -2
- package/bundle/web-dist/client/assets/{show-BreAgaFH.js → show-63BsotX6.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-4VgHQtXd.js → tokens-C416mWQ-.js} +1 -1
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/assets/{routes-DhIpPgXz.js → routes-VY2um2KO.js} +10 -2
- package/bundle/web-dist/server/entry-server.generated.js +2 -2
- package/package.json +1 -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";
|
|
@@ -46,24 +49,106 @@ export class ImageGenerationError extends Error {
|
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
const DEFAULT_EFFORT = "low";
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
MAX_WORKERS_PER_TOKEN,
|
|
53
|
-
);
|
|
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;
|
|
54
55
|
const QUEUE_TIMEOUT_MS = 60_000;
|
|
55
56
|
const MAX_QUEUE_SIZE = 50;
|
|
56
57
|
const SPAWN_INTERVAL_MS = 500;
|
|
57
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
|
+
|
|
58
142
|
// thread 재사용(prompt cache 고정). 끄면 기존 "매 turn 새 thread + history inject" 동작.
|
|
59
143
|
const THREAD_REUSE_ENABLED = process.env.QGRID_OPENAI_THREAD_REUSE !== "false";
|
|
60
144
|
// 좌표 worker 가 busy 일 때 free 를 기다리는 최대 시간. 초과 시 새 thread 로 폴백.
|
|
61
145
|
const REUSE_WORKER_WAIT_MS = 5_000;
|
|
62
146
|
const REUSE_WORKER_POLL_MS = 50;
|
|
63
147
|
|
|
64
|
-
// workerId 합성: tokenId 와 workerIndex(0..
|
|
65
|
-
|
|
66
|
-
|
|
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;
|
|
67
152
|
}
|
|
68
153
|
|
|
69
154
|
function sleep(ms: number): Promise<void> {
|
|
@@ -92,8 +177,10 @@ type QueueItem = {
|
|
|
92
177
|
|
|
93
178
|
type TokenMetadata = {
|
|
94
179
|
name: string;
|
|
180
|
+
credentials: OpenAICredentials;
|
|
95
181
|
quotaThreshold?: number | null;
|
|
96
182
|
weight: number;
|
|
183
|
+
active: boolean;
|
|
97
184
|
};
|
|
98
185
|
|
|
99
186
|
type TokenEligibility = {
|
|
@@ -107,16 +194,33 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
107
194
|
workerPool = new Map<number, CodexAppServerWorker[]>();
|
|
108
195
|
tokenMetadata = new Map<number, TokenMetadata>();
|
|
109
196
|
queue: QueueItem[] = [];
|
|
197
|
+
readonly poolConfig: OpenAIWorkerPoolConfig;
|
|
110
198
|
private readonly weightedSelector = new SmoothWeightedRoundRobin();
|
|
111
199
|
private readonly workerCursorByToken = new Map<number, number>();
|
|
200
|
+
private readonly getHostAvailableGiB: () => number;
|
|
112
201
|
private draining = false;
|
|
113
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
|
+
}
|
|
114
215
|
|
|
115
216
|
async start(): Promise<void> {
|
|
217
|
+
this.stopped = false;
|
|
116
218
|
const { rows } = await TokenModel.findMany("A");
|
|
117
219
|
const openaiTokens = rows.filter((t) => t.provider === "openai");
|
|
118
220
|
logger.info(
|
|
119
|
-
|
|
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)`,
|
|
120
224
|
);
|
|
121
225
|
|
|
122
226
|
await Promise.allSettled(
|
|
@@ -131,9 +235,23 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
131
235
|
if (!t.active) this.onTokenDeactivated(t.id);
|
|
132
236
|
}),
|
|
133
237
|
);
|
|
238
|
+
|
|
239
|
+
if (this.poolConfig.autoscale) {
|
|
240
|
+
this.scalerTimer = setInterval(
|
|
241
|
+
() => this.requestAutoscaleEvaluation(),
|
|
242
|
+
this.poolConfig.scaleIntervalMs,
|
|
243
|
+
);
|
|
244
|
+
this.scalerTimer.unref?.();
|
|
245
|
+
}
|
|
134
246
|
}
|
|
135
247
|
|
|
136
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(() => {});
|
|
137
255
|
this.rejectAllQueued("DISPATCHER_SHUTDOWN");
|
|
138
256
|
const kills = [...this.workerPool.values()].flat().map((w) => w.kill());
|
|
139
257
|
await Promise.allSettled(kills);
|
|
@@ -154,7 +272,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
154
272
|
quotaThreshold?: number | null,
|
|
155
273
|
weight = 1,
|
|
156
274
|
): Promise<void> {
|
|
157
|
-
this.setTokenMetadata(id, name, quotaThreshold, weight);
|
|
275
|
+
this.setTokenMetadata(id, name, credentials, quotaThreshold, weight);
|
|
158
276
|
if (this.workerPool.has(id)) return;
|
|
159
277
|
await this.spawnWorkers(id, name, credentials, quotaThreshold, weight);
|
|
160
278
|
}
|
|
@@ -186,7 +304,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
186
304
|
): Promise<void> {
|
|
187
305
|
const existing = this.workerPool.get(id) ?? [];
|
|
188
306
|
const renamed = existing.some((w) => w.tokenName !== name);
|
|
189
|
-
this.setTokenMetadata(id, name, quotaThreshold, weight);
|
|
307
|
+
this.setTokenMetadata(id, name, credentials, quotaThreshold, weight);
|
|
190
308
|
if (renamed) this.rateLimitsCache.delete(id);
|
|
191
309
|
|
|
192
310
|
if (existing.length === 0) {
|
|
@@ -210,6 +328,8 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
210
328
|
onTokenDeactivated(id: number): void {
|
|
211
329
|
const workers = this.workerPool.get(id) ?? [];
|
|
212
330
|
const tokenName = workers[0]?.tokenName;
|
|
331
|
+
const metadata = this.tokenMetadata.get(id);
|
|
332
|
+
if (metadata) metadata.active = false;
|
|
213
333
|
const changed = workers.some((worker) => worker.active);
|
|
214
334
|
workers.forEach((w) => {
|
|
215
335
|
w.active = false;
|
|
@@ -225,6 +345,8 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
225
345
|
onTokenActivated(id: number): void {
|
|
226
346
|
const workers = this.workerPool.get(id) ?? [];
|
|
227
347
|
const tokenName = workers[0]?.tokenName;
|
|
348
|
+
const metadata = this.tokenMetadata.get(id);
|
|
349
|
+
if (metadata) metadata.active = true;
|
|
228
350
|
const changed = workers.some((worker) => !worker.active);
|
|
229
351
|
workers.forEach((w) => {
|
|
230
352
|
w.active = true;
|
|
@@ -268,6 +390,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
268
390
|
// ── Generate ────────────────────────────────────────────────────
|
|
269
391
|
|
|
270
392
|
async generate(req: GenerateRequest): Promise<GenerateResult> {
|
|
393
|
+
this.lastRequestAt = Date.now();
|
|
271
394
|
const excludedTokenIds = new Set<number>();
|
|
272
395
|
// thread 재사용 경로: 좌표 worker 를 점유해 기존 thread 에 turn 만 실행.
|
|
273
396
|
const reuseWorker = await this.acquireReuseWorker(req, excludedTokenIds);
|
|
@@ -292,6 +415,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
292
415
|
}
|
|
293
416
|
|
|
294
417
|
async generateStream(req: GenerateRequest, cb: GenerateStreamCallbacks): Promise<void> {
|
|
418
|
+
this.lastRequestAt = Date.now();
|
|
295
419
|
// 이미지 생성은 non-stream 전용(R2): codex 가 이미지 델타를 주지 않고 완성 base64 를
|
|
296
420
|
// 한 번에 준다. 스트리밍 경로로 이미지 요청이 오면 명시적 에러.
|
|
297
421
|
if (req.imageGeneration) {
|
|
@@ -354,7 +478,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
354
478
|
findWorkerById(workerId: number): CodexAppServerWorker | null {
|
|
355
479
|
for (const workers of this.workerPool.values()) {
|
|
356
480
|
for (const w of workers) {
|
|
357
|
-
if (
|
|
481
|
+
if (makeOpenAIWorkerId(w.tokenId, w.workerIndex) === workerId) return w;
|
|
358
482
|
}
|
|
359
483
|
}
|
|
360
484
|
return null;
|
|
@@ -438,6 +562,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
438
562
|
};
|
|
439
563
|
|
|
440
564
|
this.queue.push(item);
|
|
565
|
+
this.requestAutoscaleEvaluation();
|
|
441
566
|
});
|
|
442
567
|
}
|
|
443
568
|
|
|
@@ -560,7 +685,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
560
685
|
ttftMs: result.ttftMs,
|
|
561
686
|
model: result.model,
|
|
562
687
|
threadCoord: {
|
|
563
|
-
workerId:
|
|
688
|
+
workerId: makeOpenAIWorkerId(worker.tokenId, worker.workerIndex),
|
|
564
689
|
threadId: result.threadId,
|
|
565
690
|
epoch: worker.epoch,
|
|
566
691
|
},
|
|
@@ -589,7 +714,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
589
714
|
...result,
|
|
590
715
|
tokenName: worker.tokenName,
|
|
591
716
|
threadCoord: {
|
|
592
|
-
workerId:
|
|
717
|
+
workerId: makeOpenAIWorkerId(worker.tokenId, worker.workerIndex),
|
|
593
718
|
threadId: resolvedThreadId,
|
|
594
719
|
epoch: worker.epoch,
|
|
595
720
|
},
|
|
@@ -608,9 +733,9 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
608
733
|
quotaThreshold?: number | null,
|
|
609
734
|
weight = 1,
|
|
610
735
|
): Promise<void> {
|
|
611
|
-
this.setTokenMetadata(tokenId, tokenName, quotaThreshold, weight);
|
|
736
|
+
this.setTokenMetadata(tokenId, tokenName, credentials, quotaThreshold, weight);
|
|
612
737
|
const workers: CodexAppServerWorker[] = [];
|
|
613
|
-
for (let i = 0; i <
|
|
738
|
+
for (let i = 0; i < this.poolConfig.minWorkersPerToken; i++) {
|
|
614
739
|
if (i > 0) await sleep(SPAWN_INTERVAL_MS);
|
|
615
740
|
const worker = await this.spawnSingleWorker(tokenId, tokenName, credentials, i);
|
|
616
741
|
if (worker) workers.push(worker);
|
|
@@ -653,6 +778,143 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
653
778
|
}
|
|
654
779
|
}
|
|
655
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
|
+
|
|
656
918
|
// ── Rate limits ─────────────────────────────────────────────────
|
|
657
919
|
|
|
658
920
|
rateLimitsCache = new Map<number, { data: GetAccountRateLimitsResponse; cachedAt: number }>();
|
|
@@ -679,10 +941,17 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
679
941
|
private setTokenMetadata(
|
|
680
942
|
id: number,
|
|
681
943
|
name: string,
|
|
944
|
+
credentials: OpenAICredentials,
|
|
682
945
|
quotaThreshold: number | null | undefined,
|
|
683
946
|
weight: number,
|
|
684
947
|
): void {
|
|
685
|
-
this.tokenMetadata.set(id, {
|
|
948
|
+
this.tokenMetadata.set(id, {
|
|
949
|
+
name,
|
|
950
|
+
credentials,
|
|
951
|
+
quotaThreshold,
|
|
952
|
+
weight,
|
|
953
|
+
active: this.tokenMetadata.get(id)?.active ?? true,
|
|
954
|
+
});
|
|
686
955
|
this.weightedSelector.setToken(id, weight);
|
|
687
956
|
}
|
|
688
957
|
|