@cartanova/qgrid-cli 2.7.4 → 2.7.6
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.dispatcher.js +7 -3
- package/bundle/dist/application/qgrid/qgrid.frame.js +27 -11
- package/bundle/dist/application/qgrid/qgrid.types.js +2 -1
- package/bundle/dist/application/qgrid/token-subscriber.js +19 -1
- package/bundle/dist/application/qgrid/token-window-keepalive.js +153 -0
- package/bundle/dist/application/setting/setting.constant.js +11 -1
- package/bundle/dist/application/token/token.model.js +18 -1
- package/bundle/dist/migrations/20260819102011_alter_tokens_alter1.js +13 -0
- package/bundle/dist/sonamu.config.js +9 -1
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +7 -4
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +11 -3
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +51 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +14 -4
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +58 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +39 -12
- package/bundle/src/application/qgrid/qgrid.types.ts +2 -0
- package/bundle/src/application/qgrid/token-subscriber.test.ts +112 -0
- package/bundle/src/application/qgrid/token-subscriber.ts +35 -0
- package/bundle/src/application/qgrid/token-window-keepalive.test.ts +223 -0
- package/bundle/src/application/qgrid/token-window-keepalive.ts +227 -0
- package/bundle/src/application/setting/setting.constant.test.ts +10 -0
- package/bundle/src/application/setting/setting.constant.ts +11 -1
- package/bundle/src/application/sonamu.generated.http +2 -0
- package/bundle/src/application/token/token.entity.json +8 -1
- package/bundle/src/application/token/token.model.test.ts +37 -0
- package/bundle/src/application/token/token.model.ts +20 -0
- package/bundle/src/migrations/20260819102011_alter_tokens_alter1.ts +13 -0
- package/bundle/src/sonamu.config.ts +14 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +46 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +13 -4
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +2 -0
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +30 -0
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +17 -1
- package/bundle/web-dist/client/assets/index-BbOyk01G.js +106 -0
- package/bundle/web-dist/client/assets/{logs-UlTjUAUB.js → logs-B7Pc88bf.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-CVNZnd8j.js → monit-CzMV4lZf.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-CF7Jlmfi.js → routes-COWTRxZo.js} +1 -1
- package/bundle/web-dist/client/assets/settings-Dpaosy-o.js +1 -0
- package/bundle/web-dist/client/assets/{show-CGhg0fbn.js → show-YvdybkKd.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-DaEtcsoL.js → tokens-DynVDx0c.js} +1 -1
- package/bundle/web-dist/client/assets/{use-oauth-login-flow-Eovfx07A.js → use-oauth-login-flow-OzmHElsE.js} +1 -1
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/assets/{settings-nCv71sax.js → settings-CIYR7h3G.js} +1 -0
- package/bundle/web-dist/server/entry-server.generated.js +96 -51
- package/package.json +3 -2
- package/bundle/web-dist/client/assets/index-CPYQIUqO.js +0 -106
- package/bundle/web-dist/client/assets/settings-N-8sXHl6.js +0 -1
|
@@ -329,6 +329,7 @@ Content-Type: application/json
|
|
|
329
329
|
"serviceTier": "fast",
|
|
330
330
|
"history": "HISTORY",
|
|
331
331
|
"projectName": "PROJECTNAME",
|
|
332
|
+
"tokenName": "TOKENNAME",
|
|
332
333
|
"cacheAffinityKey": "CACHEAFFINITYKEY",
|
|
333
334
|
"logger": false,
|
|
334
335
|
"runContext": {
|
|
@@ -390,6 +391,7 @@ Content-Type: application/json
|
|
|
390
391
|
"serviceTier": "fast",
|
|
391
392
|
"history": "HISTORY",
|
|
392
393
|
"projectName": "PROJECTNAME",
|
|
394
|
+
"tokenName": "TOKENNAME",
|
|
393
395
|
"cacheAffinityKey": "CACHEAFFINITYKEY",
|
|
394
396
|
"logger": false,
|
|
395
397
|
"runContext": {
|
|
@@ -28,7 +28,14 @@
|
|
|
28
28
|
"dbDefault": "1"
|
|
29
29
|
}
|
|
30
30
|
],
|
|
31
|
-
"indexes": [
|
|
31
|
+
"indexes": [
|
|
32
|
+
{
|
|
33
|
+
"type": "unique",
|
|
34
|
+
"name": "tokens_provider_name_unique",
|
|
35
|
+
"columns": [{ "name": "provider" }, { "name": "name" }],
|
|
36
|
+
"using": "btree"
|
|
37
|
+
}
|
|
38
|
+
],
|
|
32
39
|
"subsets": {
|
|
33
40
|
"A": [
|
|
34
41
|
"id",
|
|
@@ -111,6 +111,43 @@ describe("TokenModel.updateFields", () => {
|
|
|
111
111
|
});
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
describe("TokenModel.findActiveByProviderAndName", () => {
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
vi.restoreAllMocks();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("active·provider·name 세 조건으로 단일 토큰을 조회한다", async () => {
|
|
120
|
+
const where = vi.fn();
|
|
121
|
+
const qb = { where };
|
|
122
|
+
const row = { id: 7, active: true, ...baseToken };
|
|
123
|
+
vi.spyOn(
|
|
124
|
+
TokenModel as unknown as { getSubsetQueries: (subset: "A") => { qb: typeof qb } },
|
|
125
|
+
"getSubsetQueries",
|
|
126
|
+
).mockReturnValue({ qb });
|
|
127
|
+
const executeSubsetQuery = vi
|
|
128
|
+
.spyOn(
|
|
129
|
+
TokenModel as unknown as {
|
|
130
|
+
executeSubsetQuery: (input: unknown) => Promise<{ rows: typeof row[] }>;
|
|
131
|
+
},
|
|
132
|
+
"executeSubsetQuery",
|
|
133
|
+
)
|
|
134
|
+
.mockResolvedValue({ rows: [row] });
|
|
135
|
+
|
|
136
|
+
await expect(
|
|
137
|
+
TokenModel.findActiveByProviderAndName("A", "anthropic", "anthropic/tok-A"),
|
|
138
|
+
).resolves.toBe(row);
|
|
139
|
+
|
|
140
|
+
expect(where.mock.calls).toEqual([
|
|
141
|
+
["tokens.active", true],
|
|
142
|
+
["tokens.provider", "anthropic"],
|
|
143
|
+
["tokens.name", "anthropic/tok-A"],
|
|
144
|
+
]);
|
|
145
|
+
expect(executeSubsetQuery).toHaveBeenCalledWith(
|
|
146
|
+
expect.objectContaining({ subset: "A", qb, params: { num: 1, page: 1 } }),
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
114
151
|
describe("TokenModel.deactivateIfActive", () => {
|
|
115
152
|
afterEach(() => {
|
|
116
153
|
vi.restoreAllMocks();
|
|
@@ -146,6 +146,26 @@ class TokenModelClass extends BaseModelClass<
|
|
|
146
146
|
return result.rows;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
async findActiveByProviderAndName<T extends TokenSubsetKey>(
|
|
150
|
+
subset: T,
|
|
151
|
+
provider: string,
|
|
152
|
+
name: string,
|
|
153
|
+
): Promise<TokenSubsetMapping[T] | undefined> {
|
|
154
|
+
const { qb } = this.getSubsetQueries(subset);
|
|
155
|
+
qb.where("tokens.active", true);
|
|
156
|
+
qb.where("tokens.provider", provider);
|
|
157
|
+
qb.where("tokens.name", name);
|
|
158
|
+
const enhancers = this.createEnhancers({ A: (row) => ({ ...row }) });
|
|
159
|
+
const result = await this.executeSubsetQuery({
|
|
160
|
+
subset,
|
|
161
|
+
qb,
|
|
162
|
+
params: { num: 1, page: 1 },
|
|
163
|
+
enhancers,
|
|
164
|
+
debug: false,
|
|
165
|
+
});
|
|
166
|
+
return result.rows[0];
|
|
167
|
+
}
|
|
168
|
+
|
|
149
169
|
/**
|
|
150
170
|
* 라우팅에서 빠진 토큰들. 세션 만료로 자동 비활성화된 것과 수동으로 끈 것이 섞여 있다 —
|
|
151
171
|
* 둘 다 "재로그인하면 살아나는" 상태라 알림 대상으로는 같게 본다.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Knex } from "knex";
|
|
2
|
+
|
|
3
|
+
export async function up(knex: Knex): Promise<void> {
|
|
4
|
+
await knex.raw(
|
|
5
|
+
`CREATE UNIQUE INDEX tokens_provider_name_unique ON tokens USING btree(provider ASC NULLS LAST, name ASC NULLS LAST) NULLS DISTINCT;`,
|
|
6
|
+
);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function down(knex: Knex): Promise<void> {
|
|
10
|
+
await knex.schema.alterTable("tokens", (table) => {
|
|
11
|
+
table.dropIndex(["provider", "name"], "tokens_provider_name_unique");
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -17,6 +17,11 @@ import { QgridDispatcher } from "./application/qgrid/qgrid.dispatcher";
|
|
|
17
17
|
import { QgridFrame } from "./application/qgrid/qgrid.frame";
|
|
18
18
|
import { TokenSubscriber } from "./application/qgrid/token-subscriber";
|
|
19
19
|
import { ensureTokensTrigger } from "./application/qgrid/token-trigger-setup";
|
|
20
|
+
import {
|
|
21
|
+
rescheduleTokenWindowKeepalive,
|
|
22
|
+
startTokenWindowKeepalive,
|
|
23
|
+
stopTokenWindowKeepalive,
|
|
24
|
+
} from "./application/qgrid/token-window-keepalive";
|
|
20
25
|
import { loadSettings, setSettingChangeHandler } from "./application/setting/setting.store";
|
|
21
26
|
import { handleServerError } from "./server-error-handler";
|
|
22
27
|
import { AnthropicDispatcher } from "./utils/providers/anthropic/anthropic-dispatcher";
|
|
@@ -176,6 +181,9 @@ export default defineConfig({
|
|
|
176
181
|
if (key === "slack.expiryReminderMinutes" || key === "slack.remindersEnabled") {
|
|
177
182
|
rescheduleExpiredTokenReminder();
|
|
178
183
|
}
|
|
184
|
+
if (key === "qgrid.tokenWindowKeepaliveEnabled") {
|
|
185
|
+
void rescheduleTokenWindowKeepalive();
|
|
186
|
+
}
|
|
179
187
|
});
|
|
180
188
|
|
|
181
189
|
let triggerReady = true;
|
|
@@ -213,7 +221,11 @@ export default defineConfig({
|
|
|
213
221
|
log.warn(`anthropic dispatcher failed: ${(e as Error).message}`);
|
|
214
222
|
}
|
|
215
223
|
|
|
224
|
+
subscriber.setTokenChangeHandler(() => {
|
|
225
|
+
void rescheduleTokenWindowKeepalive();
|
|
226
|
+
});
|
|
216
227
|
startExpiredTokenReminder();
|
|
228
|
+
void startTokenWindowKeepalive();
|
|
217
229
|
|
|
218
230
|
const anthropicCount = QgridDispatcher.anthropicDispatcher?.tokenCount ?? 0;
|
|
219
231
|
const openaiCount = QgridDispatcher.openaiDispatcher?.tokenCount ?? 0;
|
|
@@ -228,7 +240,9 @@ export default defineConfig({
|
|
|
228
240
|
onShutdown: async () => {
|
|
229
241
|
const log = getLogger(["qgrid", "startup"]);
|
|
230
242
|
stopExpiredTokenReminder();
|
|
243
|
+
stopTokenWindowKeepalive();
|
|
231
244
|
setSettingChangeHandler(null);
|
|
245
|
+
QgridDispatcher.subscriber?.setTokenChangeHandler(null);
|
|
232
246
|
if (QgridDispatcher.openaiDispatcher) {
|
|
233
247
|
await QgridDispatcher.openaiDispatcher.stop();
|
|
234
248
|
}
|
|
@@ -249,6 +249,52 @@ describe("AnthropicDispatcher", () => {
|
|
|
249
249
|
expect(readAnthropicQuotaUsageMock).not.toHaveBeenCalled();
|
|
250
250
|
});
|
|
251
251
|
|
|
252
|
+
it("preferredTokenId 가 있으면 가중 선택과 무관하게 그 토큰을 고른다", async () => {
|
|
253
|
+
const d = new AnthropicDispatcher();
|
|
254
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 10);
|
|
255
|
+
d.onTokenAdded(2, "tok-B", creds(), null, 1);
|
|
256
|
+
|
|
257
|
+
const result = await d.generate(baseReq({ preferredTokenId: 2 }));
|
|
258
|
+
|
|
259
|
+
expect(result.tokenName).toBe("tok-B");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("preferredTokenId 가 풀에 없으면 다른 토큰으로 대체하지 않는다", async () => {
|
|
263
|
+
const d = new AnthropicDispatcher();
|
|
264
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
265
|
+
|
|
266
|
+
await expect(d.generate(baseReq({ preferredTokenId: 99 }))).rejects.toThrow(
|
|
267
|
+
"Preferred anthropic token 99 is not available",
|
|
268
|
+
);
|
|
269
|
+
expect(runClaudeSessionMock).not.toHaveBeenCalled();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("지정 토큰이 threshold 를 넘으면 eligible 토큰으로 대체하지 않는다", async () => {
|
|
273
|
+
const d = new AnthropicDispatcher();
|
|
274
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
275
|
+
d.onTokenAdded(2, "tok-B", creds(), 80, 1);
|
|
276
|
+
readAnthropicQuotaUsageMock.mockResolvedValueOnce(quotaOk(80));
|
|
277
|
+
|
|
278
|
+
const error = await d.generate(baseReq({ preferredTokenId: 2 })).catch((e) => e);
|
|
279
|
+
|
|
280
|
+
expect(error).toBeInstanceOf(QuotaThresholdExceededError);
|
|
281
|
+
expect(error.message).toBe(
|
|
282
|
+
"All anthropic tokens exceeded quota threshold: tok-B (threshold 80%)",
|
|
283
|
+
);
|
|
284
|
+
expect(readAnthropicQuotaUsageMock).toHaveBeenCalledTimes(1);
|
|
285
|
+
expect(runClaudeSessionMock).not.toHaveBeenCalled();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("지정 선택은 다음 비지정 요청의 가중 선택 상태를 소비하지 않는다", async () => {
|
|
289
|
+
const d = new AnthropicDispatcher();
|
|
290
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
291
|
+
d.onTokenAdded(2, "tok-B", creds(), null, 2);
|
|
292
|
+
|
|
293
|
+
expect((await d.generate(baseReq())).tokenName).toBe("tok-B");
|
|
294
|
+
expect((await d.generate(baseReq({ preferredTokenId: 2 }))).tokenName).toBe("tok-B");
|
|
295
|
+
expect((await d.generate(baseReq())).tokenName).toBe("tok-A");
|
|
296
|
+
});
|
|
297
|
+
|
|
252
298
|
it("recomputes weighted selection from quota-eligible tokens", async () => {
|
|
253
299
|
const d = new AnthropicDispatcher();
|
|
254
300
|
d.onTokenAdded(1, "tok-A", creds(), 80, 10);
|
|
@@ -161,9 +161,16 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
161
161
|
this.weightedSelector.removeToken(id);
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
164
|
+
// 지정 요청은 해당 토큰만 quota 판정하고 weighted 상태를 건드리지 않는다.
|
|
165
|
+
// 미지정 요청만 quota 통과 후보로 smooth weighted round-robin 을 진행한다.
|
|
166
|
+
private async selectToken(preferredTokenId?: number): Promise<PooledToken | null> {
|
|
167
|
+
const preferred =
|
|
168
|
+
preferredTokenId === undefined ? undefined : this.tokenPool.get(preferredTokenId);
|
|
169
|
+
if (preferredTokenId !== undefined && !preferred) {
|
|
170
|
+
throw new Error(`Preferred anthropic token ${preferredTokenId} is not available`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const rows = preferred ? [preferred] : [...this.tokenPool.values()];
|
|
167
174
|
if (rows.length === 0) return null;
|
|
168
175
|
const { eligible, overThresholdTokens } = await this.filterEligibleTokens(rows);
|
|
169
176
|
if (eligible.length === 0) {
|
|
@@ -179,6 +186,8 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
179
186
|
);
|
|
180
187
|
}
|
|
181
188
|
|
|
189
|
+
if (preferred) return preferred;
|
|
190
|
+
|
|
182
191
|
const selectedId = this.weightedSelector.select(new Set(eligible.map((token) => token.id)));
|
|
183
192
|
if (selectedId === null) return null;
|
|
184
193
|
return this.tokenPool.get(selectedId) ?? null;
|
|
@@ -305,7 +314,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
305
314
|
const model = canonicalAnthropicModel(req.model);
|
|
306
315
|
const jsonSchema = serializeAndValidateDispatchSchema(req.outputSchema, "anthropic");
|
|
307
316
|
|
|
308
|
-
const token = await this.selectToken();
|
|
317
|
+
const token = await this.selectToken(req.preferredTokenId);
|
|
309
318
|
if (!token) throw new Error("No anthropic tokens available");
|
|
310
319
|
|
|
311
320
|
const exec = async (): Promise<GenerateResult> => {
|
|
@@ -64,6 +64,8 @@ export interface GenerateRequest {
|
|
|
64
64
|
// and prompt-cache routing without relying on a process-local worker/thread.
|
|
65
65
|
promptCacheKey?: string;
|
|
66
66
|
preferredTokenId?: number;
|
|
67
|
+
// 사람이 지정한 exact target. Cache affinity 선호와 달리 부적격 시 다른 토큰으로 대체하지 않는다.
|
|
68
|
+
requirePreferredToken?: boolean;
|
|
67
69
|
// 재사용 좌표 + delta input. 둘은 한 쌍 — 검증 통과 시에만 설정한다.
|
|
68
70
|
// dispatcher 가 worker/thread 생존을 재검증해 성공하면 reuseInput(delta)을, 실패하면
|
|
69
71
|
// coldInput + coldHistory 로 폴백한다(전체 history 로 문맥 복구).
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
+
import { QuotaThresholdExceededError } from "../../../application/qgrid/qgrid.types";
|
|
3
4
|
import { type GenerateRequest } from "../common/provider-dispatcher";
|
|
4
5
|
import {
|
|
5
6
|
type OpenAINormalizedEvent,
|
|
@@ -178,6 +179,35 @@ describe("OpenAIDispatcher direct runtime", () => {
|
|
|
178
179
|
expect((await d.generate(request())).tokenName).toBe("two");
|
|
179
180
|
});
|
|
180
181
|
|
|
182
|
+
it("required preferred token 이 없으면 다른 토큰으로 대체하지 않는다", async () => {
|
|
183
|
+
const d = dispatcher(() => events({ type: "completed", responseId: "r" }));
|
|
184
|
+
await d.onTokenAdded(1, "one", credentials);
|
|
185
|
+
|
|
186
|
+
await expect(
|
|
187
|
+
d.generate(request({ preferredTokenId: 99, requirePreferredToken: true })),
|
|
188
|
+
).rejects.toThrow("Preferred openai token 99 is not available");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("required preferred token 이 threshold 를 넘으면 eligible 토큰으로 대체하지 않는다", async () => {
|
|
192
|
+
const d = new OpenAIDispatcher(config(), {
|
|
193
|
+
clientFactory: () => ({ responses: () => events({ type: "completed", responseId: "r" }) }),
|
|
194
|
+
fetch: async () =>
|
|
195
|
+
new Response(
|
|
196
|
+
JSON.stringify({ rate_limits: { primary_window: { used_percent: 90 } } }),
|
|
197
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
198
|
+
),
|
|
199
|
+
});
|
|
200
|
+
await d.onTokenAdded(1, "one", credentials, null);
|
|
201
|
+
await d.onTokenAdded(2, "two", { ...credentials, accountId: "acct2" }, 80);
|
|
202
|
+
|
|
203
|
+
const error = await d
|
|
204
|
+
.generate(request({ preferredTokenId: 2, requirePreferredToken: true }))
|
|
205
|
+
.catch((caught) => caught);
|
|
206
|
+
|
|
207
|
+
expect(error).toBeInstanceOf(QuotaThresholdExceededError);
|
|
208
|
+
expect(error.message).toContain("two (threshold 80%)");
|
|
209
|
+
});
|
|
210
|
+
|
|
181
211
|
it("runs concurrent requests without queueing and tracks in-flight counts", async () => {
|
|
182
212
|
const releases: Array<() => void> = [];
|
|
183
213
|
const d = dispatcher(() => ({
|
|
@@ -272,7 +272,11 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
272
272
|
): Promise<GenerateResult> {
|
|
273
273
|
const signal = activeSignal(req.abortSignal, req.timeoutMs);
|
|
274
274
|
if (signal.aborted) throw abortError(signal);
|
|
275
|
-
const selection = await this.selectToken(
|
|
275
|
+
const selection = await this.selectToken(
|
|
276
|
+
req.preferredTokenId,
|
|
277
|
+
req.requirePreferredToken ?? false,
|
|
278
|
+
signal,
|
|
279
|
+
);
|
|
276
280
|
this.inFlightCount++;
|
|
277
281
|
try {
|
|
278
282
|
if (signal.aborted) throw abortError(signal);
|
|
@@ -420,6 +424,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
420
424
|
*/
|
|
421
425
|
private async selectToken(
|
|
422
426
|
preferredTokenId: number | undefined,
|
|
427
|
+
requirePreferredToken: boolean,
|
|
423
428
|
signal: AbortSignal,
|
|
424
429
|
): Promise<TokenSelection> {
|
|
425
430
|
if (preferredTokenId !== undefined) {
|
|
@@ -427,6 +432,17 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
427
432
|
if (preferred?.active && (await this.isQuotaEligible(preferredTokenId, preferred, signal))) {
|
|
428
433
|
return { tokenId: preferredTokenId, metadata: preferred };
|
|
429
434
|
}
|
|
435
|
+
if (requirePreferredToken) {
|
|
436
|
+
if (!preferred?.active) {
|
|
437
|
+
throw new Error(`Preferred openai token ${preferredTokenId} is not available`);
|
|
438
|
+
}
|
|
439
|
+
const threshold = preferred.quotaThreshold;
|
|
440
|
+
if (threshold !== null && threshold !== undefined) {
|
|
441
|
+
throw new QuotaThresholdExceededError(
|
|
442
|
+
quotaMessage([{ name: preferred.name, threshold }]),
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
430
446
|
}
|
|
431
447
|
|
|
432
448
|
// 토큰별 quota 판정은 서로 독립이라 병렬로 — 직렬 대기는 콜드 캐시에서
|