@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.
- package/bundle/dist/application/qgrid/qgrid.frame.js +17 -19
- package/bundle/dist/application/qgrid/qgrid.types.js +3 -2
- package/bundle/dist/application/qgrid/token-subscriber.js +23 -9
- package/bundle/dist/application/qgrid/token-trigger-setup.js +4 -4
- package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
- package/bundle/dist/application/sonamu.generated.js +5 -3
- package/bundle/dist/application/sonamu.generated.sso.js +3 -2
- package/bundle/dist/application/token/token.model.js +8 -3
- package/bundle/dist/application/token/token.types.js +8 -3
- package/bundle/dist/i18n/sd.generated.js +2 -1
- package/bundle/dist/index.js +3 -6
- package/bundle/dist/migrations/20260521134025_alter_request_log_steps_alter1.js +3 -9
- package/bundle/dist/migrations/20260706120000_alter_request_logs_add_image_cost.js +1 -1
- package/bundle/dist/migrations/20260710090000_alter_tokens_add_weight.js +41 -0
- package/bundle/dist/server-bootstrap.js +17 -0
- package/bundle/dist/sonamu.config.js +1 -10
- package/bundle/dist/startup-migrations.js +21 -0
- package/bundle/dist/testing/global.js +93 -5
- package/bundle/dist/testing/test-database-name.js +22 -0
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +24 -27
- package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +43 -0
- package/bundle/dist/utils/providers/openai/codex-worker.js +4 -2
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +220 -49
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +60 -14
- package/bundle/src/application/qgrid/qgrid.frame.ts +24 -25
- package/bundle/src/application/qgrid/qgrid.types.ts +1 -0
- package/bundle/src/application/qgrid/token-subscriber.test.ts +102 -0
- package/bundle/src/application/qgrid/token-subscriber.ts +37 -6
- package/bundle/src/application/qgrid/token-trigger-setup.test.ts +9 -0
- package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -2
- package/bundle/src/application/qgrid/token-weight-migration.test.ts +42 -0
- package/bundle/src/application/qgrid/tool-emulation.ts +6 -1
- package/bundle/src/application/sonamu.generated.http +4 -2
- package/bundle/src/application/sonamu.generated.sso.ts +1 -0
- package/bundle/src/application/sonamu.generated.ts +10 -1
- package/bundle/src/application/token/token.entity.json +17 -1
- package/bundle/src/application/token/token.model.test.ts +43 -0
- package/bundle/src/application/token/token.model.ts +18 -2
- package/bundle/src/application/token/token.types.test.ts +11 -0
- package/bundle/src/application/token/token.types.ts +3 -0
- package/bundle/src/i18n/sd.generated.ts +1 -0
- package/bundle/src/index.ts +2 -5
- package/bundle/src/migrations/20260521134025_alter_request_log_steps_alter1.ts +4 -8
- package/bundle/src/migrations/20260706120000_alter_request_logs_add_image_cost.ts +1 -1
- package/bundle/src/migrations/20260710090000_alter_tokens_add_weight.ts +38 -0
- package/bundle/src/server-bootstrap.test.ts +39 -0
- package/bundle/src/server-bootstrap.ts +21 -0
- package/bundle/src/sonamu.config.ts +0 -12
- package/bundle/src/startup-migrations.test.ts +25 -0
- package/bundle/src/startup-migrations.ts +20 -0
- package/bundle/src/testing/global.test.ts +130 -0
- package/bundle/src/testing/global.ts +143 -13
- package/bundle/src/testing/test-database-name.test.ts +38 -0
- package/bundle/src/testing/test-database-name.ts +21 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +75 -45
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +23 -29
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -1
- package/bundle/src/utils/providers/common/smooth-weighted-round-robin.test.ts +73 -0
- package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +48 -0
- package/bundle/src/utils/providers/openai/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 +304 -12
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +354 -68
- package/bundle/web-dist/client/assets/{index-DsGDBrrH.js → index-D0dRwMp0.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-C6EMtwDn.js → logs-BMzzMMRA.js} +1 -1
- package/bundle/web-dist/client/assets/routes-XqQHRGxI.js +5 -0
- package/bundle/web-dist/client/assets/{sd.generated-BrUnRt0B.js → sd.generated-WA5SGJYZ.js} +1 -1
- package/bundle/web-dist/client/assets/{services.generated-AsEvHbJ7.js → services.generated-DzZLnb7S.js} +1 -1
- package/bundle/web-dist/client/assets/{show-BSW3PE7Z.js → show-63BsotX6.js} +1 -1
- package/bundle/web-dist/client/assets/tokens-C416mWQ-.js +1 -0
- package/bundle/web-dist/client/assets/use-update-token-mutation-BV6msjuy.js +1 -0
- package/bundle/web-dist/client/index.html +2 -2
- package/bundle/web-dist/server/assets/{lazyRouteComponent-BswulcOx.js → lazyRouteComponent-BrPAFtBA.js} +1 -1
- package/bundle/web-dist/server/assets/{logs-Bk9C0ELo.js → logs-CKM6W3W6.js} +3 -3
- package/bundle/web-dist/server/assets/{routes-D9OiKoqa.js → routes-VY2um2KO.js} +140 -9
- package/bundle/web-dist/server/assets/{sd.generated-DRuQSIKo.js → sd.generated-Cwg2f53z.js} +1 -0
- package/bundle/web-dist/server/assets/{services.generated-Dp0uKgpV.js → services.generated-CsotZP44.js} +5 -5
- package/bundle/web-dist/server/assets/{show-3GokIDEJ.js → show-CV2UFjaH.js} +3 -3
- package/bundle/web-dist/server/assets/{tokens-Cjw6cQxZ.js → tokens-DfGj1y2v.js} +5 -5
- package/bundle/web-dist/server/assets/use-update-token-mutation-BL96w-Pg.js +7 -0
- package/bundle/web-dist/server/entry-server.generated.js +7 -7
- package/package.json +1 -1
- package/skills/qgrid/SKILL.md +1 -1
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +2 -2
- package/skills/qgrid/references/cli-env-and-server-boot.md +9 -9
- package/skills/qgrid/references/decision-rationale.md +23 -0
- package/skills/qgrid/references/openai-codex-runtime.md +4 -3
- package/skills/qgrid/references/prompt-cache-and-usage.md +2 -0
- package/skills/qgrid/references/provider-runtime-differences.md +2 -1
- package/skills/qgrid/references/repo-map.md +2 -0
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +8 -3
- package/skills/qgrid/references/verification-and-debugging.md +3 -0
- package/bundle/web-dist/client/assets/routes-De9iuL80.js +0 -5
- package/bundle/web-dist/client/assets/tokens-BMsL6vk9.js +0 -1
|
@@ -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
|
|
49
|
-
const
|
|
50
|
-
|
|
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..
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 (
|
|
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
|
-
//
|
|
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
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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:
|
|
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 <
|
|
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(
|
|
655
|
-
void this.drainQueue(
|
|
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(
|
|
661
|
-
|
|
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,
|