@cartanova/qgrid-cli 2.4.2 → 2.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundle/dist/application/qgrid/qgrid.frame.js +13 -17
- 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 +2 -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/model-cost.js +22 -1
- package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +43 -0
- package/bundle/dist/utils/providers/openai/codex-worker.js +19 -2
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +53 -40
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +36 -14
- package/bundle/src/application/qgrid/qgrid.frame.ts +22 -25
- 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 +3 -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/model-cost.test.ts +54 -0
- package/bundle/src/utils/providers/common/model-cost.ts +31 -0
- 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 +70 -1
- package/bundle/src/utils/providers/openai/codex-worker.ts +18 -6
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +109 -6
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +72 -55
- package/bundle/web-dist/client/assets/{index-CN7eSKO4.js → index-D1ExP77_.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-NubdF_C_.js → logs-vuI-Zgnq.js} +1 -1
- package/bundle/web-dist/client/assets/routes-0Fki0JEi.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-BvDjIZCX.js → show-BreAgaFH.js} +1 -1
- package/bundle/web-dist/client/assets/tokens-4VgHQtXd.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-DhIpPgXz.js} +130 -7
- 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-NKxXqHvs.js +0 -5
- package/bundle/web-dist/client/assets/tokens-XAhJyLqj.js +0 -1
|
@@ -95,6 +95,15 @@ function firstRefreshTokenArg() {
|
|
|
95
95
|
return call;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
async function selectedTokenNames(
|
|
99
|
+
dispatcher: AnthropicDispatcher,
|
|
100
|
+
count: number,
|
|
101
|
+
): Promise<string[]> {
|
|
102
|
+
return Promise.all(
|
|
103
|
+
Array.from({ length: count }, async () => (await dispatcher.generate(baseReq())).tokenName),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
98
107
|
describe("AnthropicDispatcher", () => {
|
|
99
108
|
beforeEach(() => {
|
|
100
109
|
runClaudeSessionMock.mockReset();
|
|
@@ -113,7 +122,7 @@ describe("AnthropicDispatcher", () => {
|
|
|
113
122
|
|
|
114
123
|
it("happy: generate → GenerateResult (threadCoord 조립, systemHash 없음)", async () => {
|
|
115
124
|
const d = new AnthropicDispatcher();
|
|
116
|
-
d.onTokenAdded(1, "tok-A", creds());
|
|
125
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
117
126
|
runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ ttftMs: 42 }));
|
|
118
127
|
|
|
119
128
|
const result = await d.generate(baseReq());
|
|
@@ -127,7 +136,7 @@ describe("AnthropicDispatcher", () => {
|
|
|
127
136
|
|
|
128
137
|
it("cold 호출: coldInput/coldHistory 를 그대로 전달하고 continuation session id 는 없다", async () => {
|
|
129
138
|
const d = new AnthropicDispatcher();
|
|
130
|
-
d.onTokenAdded(1, "tok-A", creds());
|
|
139
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
131
140
|
|
|
132
141
|
await d.generate(
|
|
133
142
|
baseReq({ coldHistory: [{ type: "message", role: "assistant", content: [] }] }),
|
|
@@ -141,7 +150,7 @@ describe("AnthropicDispatcher", () => {
|
|
|
141
150
|
|
|
142
151
|
it("reuse/reuseInput 이 실려 와도 무시하고 coldInput/coldHistory 로 실행한다", async () => {
|
|
143
152
|
const d = new AnthropicDispatcher();
|
|
144
|
-
d.onTokenAdded(1, "tok-A", creds());
|
|
153
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
145
154
|
|
|
146
155
|
await d.generate(
|
|
147
156
|
baseReq({
|
|
@@ -159,7 +168,7 @@ describe("AnthropicDispatcher", () => {
|
|
|
159
168
|
|
|
160
169
|
it("structured(outputSchema): jsonSchema 로 직렬화되어 전달", async () => {
|
|
161
170
|
const d = new AnthropicDispatcher();
|
|
162
|
-
d.onTokenAdded(1, "tok-A", creds());
|
|
171
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
163
172
|
await d.generate(baseReq({ outputSchema: { type: "object", properties: {} } }));
|
|
164
173
|
|
|
165
174
|
const call = firstRunRequest();
|
|
@@ -168,7 +177,7 @@ describe("AnthropicDispatcher", () => {
|
|
|
168
177
|
|
|
169
178
|
it("quota 소진 → 에러", async () => {
|
|
170
179
|
const d = new AnthropicDispatcher();
|
|
171
|
-
d.onTokenAdded(1, "tok-A", creds());
|
|
180
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
172
181
|
runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ quotaExhausted: true }));
|
|
173
182
|
|
|
174
183
|
await expect(d.generate(baseReq())).rejects.toThrow(/quota exhausted/);
|
|
@@ -176,7 +185,7 @@ describe("AnthropicDispatcher", () => {
|
|
|
176
185
|
|
|
177
186
|
it("claude 에러 → 에러", async () => {
|
|
178
187
|
const d = new AnthropicDispatcher();
|
|
179
|
-
d.onTokenAdded(1, "tok-A", creds());
|
|
188
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
180
189
|
runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ isError: true, text: "boom" }));
|
|
181
190
|
|
|
182
191
|
await expect(d.generate(baseReq())).rejects.toThrow(/claude error/);
|
|
@@ -184,7 +193,7 @@ describe("AnthropicDispatcher", () => {
|
|
|
184
193
|
|
|
185
194
|
it("cold 실패는 resume retry 없이 그대로 전파한다", async () => {
|
|
186
195
|
const d = new AnthropicDispatcher();
|
|
187
|
-
d.onTokenAdded(1, "tok-A", creds());
|
|
196
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
188
197
|
runClaudeSessionMock.mockRejectedValueOnce(new Error("closed without result"));
|
|
189
198
|
|
|
190
199
|
await expect(
|
|
@@ -198,25 +207,46 @@ describe("AnthropicDispatcher", () => {
|
|
|
198
207
|
expect(runClaudeSessionMock).toHaveBeenCalledTimes(1);
|
|
199
208
|
});
|
|
200
209
|
|
|
201
|
-
it("
|
|
210
|
+
it("routes Anthropic requests in a 3:1 ratio", async () => {
|
|
202
211
|
const d = new AnthropicDispatcher();
|
|
203
|
-
d.onTokenAdded(1, "tok-A", creds());
|
|
204
|
-
d.onTokenAdded(2, "tok-B", creds());
|
|
212
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 3);
|
|
213
|
+
d.onTokenAdded(2, "tok-B", creds(), null, 1);
|
|
205
214
|
|
|
206
|
-
const names =
|
|
207
|
-
const r1 = await d.generate(baseReq());
|
|
208
|
-
const r2 = await d.generate(baseReq());
|
|
209
|
-
names.add(r1.tokenName);
|
|
210
|
-
names.add(r2.tokenName);
|
|
215
|
+
const names = await selectedTokenNames(d, 4);
|
|
211
216
|
|
|
212
|
-
expect(names.
|
|
217
|
+
expect(names.filter((name) => name === "tok-A")).toHaveLength(3);
|
|
218
|
+
expect(names.filter((name) => name === "tok-B")).toHaveLength(1);
|
|
213
219
|
expect(readAnthropicQuotaUsageMock).not.toHaveBeenCalled();
|
|
214
220
|
});
|
|
215
221
|
|
|
222
|
+
it("recomputes weighted selection from quota-eligible tokens", async () => {
|
|
223
|
+
const d = new AnthropicDispatcher();
|
|
224
|
+
d.onTokenAdded(1, "tok-A", creds(), 80, 10);
|
|
225
|
+
d.onTokenAdded(2, "tok-B", creds(), 80, 1);
|
|
226
|
+
readAnthropicQuotaUsageMock
|
|
227
|
+
.mockResolvedValueOnce(quotaOk(90))
|
|
228
|
+
.mockResolvedValueOnce(quotaOk(10));
|
|
229
|
+
|
|
230
|
+
expect((await d.generate(baseReq())).tokenName).toBe("tok-B");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("resets the schedule when a token weight changes", async () => {
|
|
234
|
+
const d = new AnthropicDispatcher();
|
|
235
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
236
|
+
d.onTokenAdded(2, "tok-B", creds(), null, 1);
|
|
237
|
+
await d.generate(baseReq());
|
|
238
|
+
|
|
239
|
+
d.onTokenUpdated(1, "tok-A", creds(), null, 1);
|
|
240
|
+
d.onTokenUpdated(2, "tok-B", creds(), null, 3);
|
|
241
|
+
const names = await selectedTokenNames(d, 4);
|
|
242
|
+
|
|
243
|
+
expect(names.filter((name) => name === "tok-B")).toHaveLength(3);
|
|
244
|
+
});
|
|
245
|
+
|
|
216
246
|
it("threshold 초과 토큰은 후보에서 제외하고 미설정 토큰을 선택한다", async () => {
|
|
217
247
|
const d = new AnthropicDispatcher();
|
|
218
|
-
d.onTokenAdded(1, "tok-A", creds(), 80);
|
|
219
|
-
d.onTokenAdded(2, "tok-B", creds());
|
|
248
|
+
d.onTokenAdded(1, "tok-A", creds(), 80, 1);
|
|
249
|
+
d.onTokenAdded(2, "tok-B", creds(), null, 1);
|
|
220
250
|
readAnthropicQuotaUsageMock.mockResolvedValueOnce(quotaOk(85, 1_000));
|
|
221
251
|
|
|
222
252
|
const result = await d.generate(baseReq());
|
|
@@ -239,8 +269,8 @@ describe("AnthropicDispatcher", () => {
|
|
|
239
269
|
|
|
240
270
|
it("threshold 경계는 utilization >= threshold 일 때 제외한다", async () => {
|
|
241
271
|
const d = new AnthropicDispatcher();
|
|
242
|
-
d.onTokenAdded(1, "tok-A", creds(), 80);
|
|
243
|
-
d.onTokenAdded(2, "tok-B", creds(), 80);
|
|
272
|
+
d.onTokenAdded(1, "tok-A", creds(), 80, 1);
|
|
273
|
+
d.onTokenAdded(2, "tok-B", creds(), 80, 1);
|
|
244
274
|
readAnthropicQuotaUsageMock
|
|
245
275
|
.mockResolvedValueOnce(quotaOk(80))
|
|
246
276
|
.mockResolvedValueOnce(quotaOk(79));
|
|
@@ -252,7 +282,7 @@ describe("AnthropicDispatcher", () => {
|
|
|
252
282
|
|
|
253
283
|
it("quota 조회 실패는 fail-open 으로 통과시키고 lookup_fail_open 로그를 남긴다", async () => {
|
|
254
284
|
const d = new AnthropicDispatcher();
|
|
255
|
-
d.onTokenAdded(1, "tok-A", creds(), 80);
|
|
285
|
+
d.onTokenAdded(1, "tok-A", creds(), 80, 1);
|
|
256
286
|
readAnthropicQuotaUsageMock.mockResolvedValueOnce(quotaFail("timeout"));
|
|
257
287
|
|
|
258
288
|
const result = await d.generate(baseReq());
|
|
@@ -272,7 +302,7 @@ describe("AnthropicDispatcher", () => {
|
|
|
272
302
|
|
|
273
303
|
it("utilization 0 은 정상 조회로 보고 fail-open 로그를 남기지 않는다", async () => {
|
|
274
304
|
const d = new AnthropicDispatcher();
|
|
275
|
-
d.onTokenAdded(1, "tok-A", creds(), 80);
|
|
305
|
+
d.onTokenAdded(1, "tok-A", creds(), 80, 1);
|
|
276
306
|
readAnthropicQuotaUsageMock.mockResolvedValueOnce(quotaOk(0));
|
|
277
307
|
|
|
278
308
|
const result = await d.generate(baseReq());
|
|
@@ -286,8 +316,8 @@ describe("AnthropicDispatcher", () => {
|
|
|
286
316
|
|
|
287
317
|
it("모든 threshold 설정 토큰이 초과되면 typed error 로 실패한다", async () => {
|
|
288
318
|
const d = new AnthropicDispatcher();
|
|
289
|
-
d.onTokenAdded(1, "tok-A", creds(), 80);
|
|
290
|
-
d.onTokenAdded(2, "tok-B", creds(), 90);
|
|
319
|
+
d.onTokenAdded(1, "tok-A", creds(), 80, 1);
|
|
320
|
+
d.onTokenAdded(2, "tok-B", creds(), 90, 1);
|
|
291
321
|
readAnthropicQuotaUsageMock
|
|
292
322
|
.mockResolvedValueOnce(quotaOk(85, 100))
|
|
293
323
|
.mockResolvedValueOnce(quotaOk(95, 200));
|
|
@@ -301,11 +331,11 @@ describe("AnthropicDispatcher", () => {
|
|
|
301
331
|
);
|
|
302
332
|
});
|
|
303
333
|
|
|
304
|
-
it("
|
|
334
|
+
it("가중 선택은 quota 초과 토큰을 제외한 eligible 집합에서 계산한다", async () => {
|
|
305
335
|
const d = new AnthropicDispatcher();
|
|
306
|
-
d.onTokenAdded(1, "tok-A", creds(), 80);
|
|
307
|
-
d.onTokenAdded(2, "tok-B", creds(), 80);
|
|
308
|
-
d.onTokenAdded(3, "tok-C", creds(), 80);
|
|
336
|
+
d.onTokenAdded(1, "tok-A", creds(), 80, 1);
|
|
337
|
+
d.onTokenAdded(2, "tok-B", creds(), 80, 1);
|
|
338
|
+
d.onTokenAdded(3, "tok-C", creds(), 80, 1);
|
|
309
339
|
|
|
310
340
|
readAnthropicQuotaUsageMock
|
|
311
341
|
.mockResolvedValueOnce(quotaOk(10))
|
|
@@ -325,10 +355,10 @@ describe("AnthropicDispatcher", () => {
|
|
|
325
355
|
expect(new Set(["tok-B", "tok-C"]).has(result.tokenName)).toBe(true);
|
|
326
356
|
});
|
|
327
357
|
|
|
328
|
-
it("동시 요청은 quota await 이후
|
|
358
|
+
it("동시 요청은 quota await 이후 동기 선택으로 서로 다른 eligible 토큰을 고른다", async () => {
|
|
329
359
|
const d = new AnthropicDispatcher();
|
|
330
|
-
d.onTokenAdded(1, "tok-A", creds(), 80);
|
|
331
|
-
d.onTokenAdded(2, "tok-B", creds(), 80);
|
|
360
|
+
d.onTokenAdded(1, "tok-A", creds(), 80, 1);
|
|
361
|
+
d.onTokenAdded(2, "tok-B", creds(), 80, 1);
|
|
332
362
|
readAnthropicQuotaUsageMock.mockResolvedValue(quotaOk(0));
|
|
333
363
|
|
|
334
364
|
const results = await Promise.all([d.generate(baseReq()), d.generate(baseReq())]);
|
|
@@ -338,7 +368,7 @@ describe("AnthropicDispatcher", () => {
|
|
|
338
368
|
|
|
339
369
|
it("onTokenRemoved 후 그 토큰 미선택", async () => {
|
|
340
370
|
const d = new AnthropicDispatcher();
|
|
341
|
-
d.onTokenAdded(1, "tok-A", creds());
|
|
371
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
342
372
|
d.onTokenRemoved(1);
|
|
343
373
|
|
|
344
374
|
await expect(d.generate(baseReq())).rejects.toThrow(/No anthropic tokens/);
|
|
@@ -346,8 +376,8 @@ describe("AnthropicDispatcher", () => {
|
|
|
346
376
|
|
|
347
377
|
it("onTokenUpdated 는 토큰 credentials 를 in-place 갱신한다", async () => {
|
|
348
378
|
const d = new AnthropicDispatcher();
|
|
349
|
-
d.onTokenAdded(1, "tok-A", creds(3_600_000, "acc-1"));
|
|
350
|
-
d.onTokenUpdated(1, "tok-A", creds(7_200_000, "acc-2"));
|
|
379
|
+
d.onTokenAdded(1, "tok-A", creds(3_600_000, "acc-1"), null, 1);
|
|
380
|
+
d.onTokenUpdated(1, "tok-A", creds(7_200_000, "acc-2"), null, 1);
|
|
351
381
|
|
|
352
382
|
await d.generate(baseReq());
|
|
353
383
|
|
|
@@ -357,7 +387,7 @@ describe("AnthropicDispatcher", () => {
|
|
|
357
387
|
|
|
358
388
|
it("model prefix 정규화: 'anthropic/claude-opus-4-8' → canonical 로 세션/result.model", async () => {
|
|
359
389
|
const d = new AnthropicDispatcher();
|
|
360
|
-
d.onTokenAdded(1, "tok-A", creds());
|
|
390
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
361
391
|
|
|
362
392
|
const result = await d.generate(baseReq({ model: "anthropic/claude-opus-4-8" }));
|
|
363
393
|
|
|
@@ -368,7 +398,7 @@ describe("AnthropicDispatcher", () => {
|
|
|
368
398
|
|
|
369
399
|
it("[1m] suffix 정규화: result/runClaudeSession 에는 base canonical 만 전달", async () => {
|
|
370
400
|
const d = new AnthropicDispatcher();
|
|
371
|
-
d.onTokenAdded(1, "tok-A", creds());
|
|
401
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
372
402
|
|
|
373
403
|
const result = await d.generate(baseReq({ model: "anthropic/claude-sonnet-4-6[1m]" }));
|
|
374
404
|
|
|
@@ -379,7 +409,7 @@ describe("AnthropicDispatcher", () => {
|
|
|
379
409
|
|
|
380
410
|
it("unsupported alias + [1m] 은 조용히 다운그레이드하지 않는다", async () => {
|
|
381
411
|
const d = new AnthropicDispatcher();
|
|
382
|
-
d.onTokenAdded(1, "tok-A", creds());
|
|
412
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
383
413
|
|
|
384
414
|
await expect(d.generate(baseReq({ model: "sonnet[1m]" }))).rejects.toThrow(
|
|
385
415
|
/Unsupported Anthropic 1M model suffix/,
|
|
@@ -388,7 +418,7 @@ describe("AnthropicDispatcher", () => {
|
|
|
388
418
|
|
|
389
419
|
it("model 미지정 → ANTHROPIC_DEFAULT_MODEL 적용", async () => {
|
|
390
420
|
const d = new AnthropicDispatcher();
|
|
391
|
-
d.onTokenAdded(1, "tok-A", creds());
|
|
421
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
392
422
|
|
|
393
423
|
const result = await d.generate(baseReq({ model: undefined }));
|
|
394
424
|
|
|
@@ -399,9 +429,9 @@ describe("AnthropicDispatcher", () => {
|
|
|
399
429
|
|
|
400
430
|
it("replaceTokens: DB 기준 풀 재동기화 — 없는 토큰 제거, 새 토큰 추가", async () => {
|
|
401
431
|
const d = new AnthropicDispatcher();
|
|
402
|
-
d.onTokenAdded(1, "tok-A", creds());
|
|
432
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
403
433
|
|
|
404
|
-
d.replaceTokens([{ id: 2, name: "tok-B", credentials: creds() }]);
|
|
434
|
+
d.replaceTokens([{ id: 2, name: "tok-B", credentials: creds(), weight: 1 }]);
|
|
405
435
|
const result = await d.generate(baseReq());
|
|
406
436
|
|
|
407
437
|
expect(result.tokenName).toBe("tok-B");
|
|
@@ -409,7 +439,7 @@ describe("AnthropicDispatcher", () => {
|
|
|
409
439
|
|
|
410
440
|
it("generateStream: onDelta/onComplete 호출", async () => {
|
|
411
441
|
const d = new AnthropicDispatcher();
|
|
412
|
-
d.onTokenAdded(1, "tok-A", creds());
|
|
442
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
413
443
|
runClaudeSessionMock.mockImplementationOnce(async (_req, onDelta: (t: string) => void) => {
|
|
414
444
|
onDelta("부분");
|
|
415
445
|
return sessionResult({ text: "부분완성" });
|
|
@@ -430,7 +460,7 @@ describe("AnthropicDispatcher", () => {
|
|
|
430
460
|
|
|
431
461
|
it("generateStream: Claude server error 는 onError 로 전달하고 complete 하지 않는다", async () => {
|
|
432
462
|
const d = new AnthropicDispatcher();
|
|
433
|
-
d.onTokenAdded(1, "tok-A", creds());
|
|
463
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
434
464
|
const serverError = new Error(
|
|
435
465
|
"claude error (anthropic/yds): API Error: 529 Overloaded. This is a server-side issue",
|
|
436
466
|
);
|
|
@@ -449,7 +479,7 @@ describe("AnthropicDispatcher", () => {
|
|
|
449
479
|
|
|
450
480
|
it("refresh: 만료 임박 토큰은 provider 포함해 refreshToken 호출, 새 access token 으로 세션 진행", async () => {
|
|
451
481
|
const d = new AnthropicDispatcher();
|
|
452
|
-
d.onTokenAdded(1, "tok-A", creds(30_000));
|
|
482
|
+
d.onTokenAdded(1, "tok-A", creds(30_000), null, 1);
|
|
453
483
|
refreshTokenMock.mockResolvedValueOnce("sk-ant-oat01-refreshed");
|
|
454
484
|
|
|
455
485
|
await d.generate(baseReq());
|
|
@@ -463,7 +493,7 @@ describe("AnthropicDispatcher", () => {
|
|
|
463
493
|
|
|
464
494
|
it("refresh: 만료 여유 있으면 refresh 안 함, 기존 access token 사용", async () => {
|
|
465
495
|
const d = new AnthropicDispatcher();
|
|
466
|
-
d.onTokenAdded(1, "tok-A", creds(3_600_000));
|
|
496
|
+
d.onTokenAdded(1, "tok-A", creds(3_600_000), null, 1);
|
|
467
497
|
|
|
468
498
|
await d.generate(baseReq());
|
|
469
499
|
|
|
@@ -473,7 +503,7 @@ describe("AnthropicDispatcher", () => {
|
|
|
473
503
|
|
|
474
504
|
it("refresh 실패해도 기존 access token 으로 진행 — 요청을 죽이지 않음", async () => {
|
|
475
505
|
const d = new AnthropicDispatcher();
|
|
476
|
-
d.onTokenAdded(1, "tok-A", creds(30_000));
|
|
506
|
+
d.onTokenAdded(1, "tok-A", creds(30_000), null, 1);
|
|
477
507
|
refreshTokenMock.mockRejectedValueOnce(new Error("refresh boom"));
|
|
478
508
|
|
|
479
509
|
const result = await d.generate(baseReq());
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* AnthropicDispatcher
|
|
3
3
|
*
|
|
4
4
|
* OpenAIDispatcher와 달리, worker pool을 두는 대신 요청별 fresh spawn
|
|
5
|
-
* 토큰은 인메모리(MAP)으로 관리하고
|
|
5
|
+
* 토큰은 인메모리(MAP)으로 관리하고 smooth weighted round-robin 으로 고른다
|
|
6
6
|
*
|
|
7
7
|
* - 모든 요청은 fresh `--session-id` 로 실행
|
|
8
8
|
* - 멀티턴 문맥은 클라이언트가 매 호출 보내는 full history 를 평탄화해 전달한다.
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
type GenerateStreamCallbacks,
|
|
24
24
|
type ProviderDispatcher,
|
|
25
25
|
} from "../common/provider-dispatcher";
|
|
26
|
+
import { SmoothWeightedRoundRobin } from "../common/smooth-weighted-round-robin";
|
|
26
27
|
import { assertSupportedOneMillionSuffix, canonicalAnthropicModel } from "./anthropic-constants";
|
|
27
28
|
import { readAnthropicQuotaUsage, type AnthropicQuotaUsageResult } from "./anthropic-quota";
|
|
28
29
|
import { makeAnthropicWorkerId, runClaudeSession } from "./claude-session";
|
|
@@ -40,6 +41,7 @@ interface PooledToken {
|
|
|
40
41
|
name: string;
|
|
41
42
|
credentials: AnthropicCredentials;
|
|
42
43
|
quotaThreshold?: number | null;
|
|
44
|
+
weight: number;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
function quotaThresholdExceededMessage(
|
|
@@ -57,9 +59,7 @@ function quotaThresholdExceededMessage(
|
|
|
57
59
|
export class AnthropicDispatcher implements ProviderDispatcher {
|
|
58
60
|
// tokenId → 풀 항목. start()/token 이벤트로 채움
|
|
59
61
|
private tokenPool = new Map<number, PooledToken>();
|
|
60
|
-
|
|
61
|
-
private requestCounts = new Map<number, number>();
|
|
62
|
-
private rrIndex = 0;
|
|
62
|
+
private readonly weightedSelector = new SmoothWeightedRoundRobin();
|
|
63
63
|
|
|
64
64
|
async start(): Promise<void> {
|
|
65
65
|
// OpenAIDispatcher.start() 와 동일하게 DB 에서 기존 anthropic 토큰을 self-bootstrap 한다.
|
|
@@ -73,16 +73,18 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
73
73
|
name: t.name,
|
|
74
74
|
credentials: t.credentials as AnthropicCredentials,
|
|
75
75
|
quotaThreshold: t.quota_threshold,
|
|
76
|
+
weight: t.weight,
|
|
76
77
|
});
|
|
78
|
+
this.weightedSelector.setToken(t.id, t.weight);
|
|
77
79
|
logger.info(`worker spawned: ${t.name}`);
|
|
78
80
|
}
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
async stop(): Promise<void> {
|
|
84
|
+
const tokenIds = [...this.tokenPool.keys()];
|
|
85
|
+
for (const id of tokenIds) this.weightedSelector.removeToken(id);
|
|
82
86
|
this.tokenPool.clear();
|
|
83
|
-
|
|
84
|
-
this.requestCounts.clear();
|
|
85
|
-
this.rrIndex = 0;
|
|
87
|
+
this.weightedSelector.resetScores();
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
// 현재 풀의 토큰 수(startup 로그/health 용).
|
|
@@ -101,6 +103,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
101
103
|
name: string;
|
|
102
104
|
credentials: AnthropicCredentials;
|
|
103
105
|
quotaThreshold?: number | null;
|
|
106
|
+
weight: number;
|
|
104
107
|
}>,
|
|
105
108
|
): void {
|
|
106
109
|
const next = new Set(rows.map((r) => r.id));
|
|
@@ -111,9 +114,9 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
111
114
|
}
|
|
112
115
|
for (const r of rows) {
|
|
113
116
|
if (this.tokenPool.has(r.id)) {
|
|
114
|
-
this.onTokenUpdated(r.id, r.name, r.credentials, r.quotaThreshold);
|
|
117
|
+
this.onTokenUpdated(r.id, r.name, r.credentials, r.quotaThreshold, r.weight);
|
|
115
118
|
} else {
|
|
116
|
-
this.onTokenAdded(r.id, r.name, r.credentials, r.quotaThreshold);
|
|
119
|
+
this.onTokenAdded(r.id, r.name, r.credentials, r.quotaThreshold, r.weight);
|
|
117
120
|
}
|
|
118
121
|
}
|
|
119
122
|
}
|
|
@@ -124,8 +127,10 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
124
127
|
name: string,
|
|
125
128
|
credentials: AnthropicCredentials,
|
|
126
129
|
quotaThreshold?: number | null,
|
|
130
|
+
weight = 1,
|
|
127
131
|
): void {
|
|
128
|
-
this.tokenPool.set(id, { id, name, credentials, quotaThreshold });
|
|
132
|
+
this.tokenPool.set(id, { id, name, credentials, quotaThreshold, weight });
|
|
133
|
+
this.weightedSelector.setToken(id, weight);
|
|
129
134
|
}
|
|
130
135
|
|
|
131
136
|
onTokenUpdated(
|
|
@@ -133,20 +138,18 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
133
138
|
name: string,
|
|
134
139
|
credentials: AnthropicCredentials,
|
|
135
140
|
quotaThreshold?: number | null,
|
|
141
|
+
weight = 1,
|
|
136
142
|
): void {
|
|
137
|
-
this.tokenPool.set(id, { id, name, credentials, quotaThreshold });
|
|
143
|
+
this.tokenPool.set(id, { id, name, credentials, quotaThreshold, weight });
|
|
144
|
+
this.weightedSelector.setToken(id, weight);
|
|
138
145
|
}
|
|
139
146
|
|
|
140
147
|
onTokenRemoved(id: number): void {
|
|
141
148
|
this.tokenPool.delete(id);
|
|
142
|
-
this.
|
|
149
|
+
this.weightedSelector.removeToken(id);
|
|
143
150
|
}
|
|
144
151
|
|
|
145
|
-
|
|
146
|
-
return this.requestCounts.get(id) ?? 0;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// 요청마다 least-used RR 로 토큰을 고른다(동점이면 rrIndex 로 회전).
|
|
152
|
+
// quota 통과 후보만으로 요청마다 smooth weighted round-robin 선택한다.
|
|
150
153
|
private async selectToken(): Promise<PooledToken | null> {
|
|
151
154
|
const rows = [...this.tokenPool.values()];
|
|
152
155
|
if (rows.length === 0) return null;
|
|
@@ -164,12 +167,9 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
164
167
|
);
|
|
165
168
|
}
|
|
166
169
|
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (!picked) return null;
|
|
171
|
-
this.rrIndex++;
|
|
172
|
-
return this.charge(picked);
|
|
170
|
+
const selectedId = this.weightedSelector.select(new Set(eligible.map((token) => token.id)));
|
|
171
|
+
if (selectedId === null) return null;
|
|
172
|
+
return this.tokenPool.get(selectedId) ?? null;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
private async filterEligibleTokens(rows: PooledToken[]): Promise<{
|
|
@@ -252,12 +252,6 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
252
252
|
});
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
-
// 선택된 토큰의 사용 카운트를 await 전에 선반영(동시 요청이 다른 토큰을 고르도록).
|
|
256
|
-
private charge(token: PooledToken): PooledToken {
|
|
257
|
-
this.requestCounts.set(token.id, this.countOf(token.id) + 1);
|
|
258
|
-
return token;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
255
|
async generate(req: GenerateRequest): Promise<GenerateResult> {
|
|
262
256
|
return this.run(req, () => {});
|
|
263
257
|
}
|
|
@@ -3,6 +3,60 @@ import { describe, expect, it } from "vitest";
|
|
|
3
3
|
import { calculateCostUsd, getModelCosts } from "./model-cost";
|
|
4
4
|
|
|
5
5
|
describe("calculateCostUsd", () => {
|
|
6
|
+
it.each([
|
|
7
|
+
["gpt-5.6-sol", 5, 30, 0.5, 6.25, 3.30625],
|
|
8
|
+
["gpt-5.6-terra", 2.5, 15, 0.25, 3.125, 1.653125],
|
|
9
|
+
["gpt-5.6-luna", 1, 6, 0.1, 1.25, 0.66125],
|
|
10
|
+
])(
|
|
11
|
+
"%s applies published input, output, cache-read, and cache-write pricing",
|
|
12
|
+
(model, inputTokens, outputTokens, cachedInputTokens, cacheCreationInputTokens, expectedCost) => {
|
|
13
|
+
expect(getModelCosts(model)).toMatchObject({
|
|
14
|
+
inputTokens,
|
|
15
|
+
outputTokens,
|
|
16
|
+
cachedInputTokens,
|
|
17
|
+
cacheCreationInputTokens,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(
|
|
21
|
+
calculateCostUsd(model, {
|
|
22
|
+
inputTokens: 100_000,
|
|
23
|
+
outputTokens: 100_000,
|
|
24
|
+
cachedInputTokens: 50_000,
|
|
25
|
+
cacheCreationInputTokens: 25_000,
|
|
26
|
+
}),
|
|
27
|
+
).toBeCloseTo(expectedCost, 10);
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
it.each([
|
|
32
|
+
["gpt-5.6-sol", 1.245],
|
|
33
|
+
["gpt-5.6-terra", 0.6225],
|
|
34
|
+
["gpt-5.6-luna", 0.249],
|
|
35
|
+
])("%s applies the published long-context surcharge", (model, expectedCost) => {
|
|
36
|
+
expect(
|
|
37
|
+
calculateCostUsd(model, {
|
|
38
|
+
inputTokens: 300_000,
|
|
39
|
+
outputTokens: 1_000,
|
|
40
|
+
cachedInputTokens: 200_000,
|
|
41
|
+
}),
|
|
42
|
+
).toBeCloseTo(expectedCost, 10);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it.each([
|
|
46
|
+
["gpt-5.6-sol", 1.37],
|
|
47
|
+
["gpt-5.6-terra", 0.685],
|
|
48
|
+
["gpt-5.6-luna", 0.274],
|
|
49
|
+
])("%s applies the long-context input multiplier to cache writes", (model, expectedCost) => {
|
|
50
|
+
expect(
|
|
51
|
+
calculateCostUsd(model, {
|
|
52
|
+
inputTokens: 300_000,
|
|
53
|
+
outputTokens: 1_000,
|
|
54
|
+
cachedInputTokens: 200_000,
|
|
55
|
+
cacheCreationInputTokens: 50_000,
|
|
56
|
+
}),
|
|
57
|
+
).toBeCloseTo(expectedCost, 10);
|
|
58
|
+
});
|
|
59
|
+
|
|
6
60
|
it("Anthropic cache read/write 를 전체 입력에서 분리해 각각 단가를 적용한다", () => {
|
|
7
61
|
const cost = calculateCostUsd("claude-sonnet-4-6", {
|
|
8
62
|
inputTokens: 1_917,
|
|
@@ -43,6 +43,37 @@ const LONG_CONTEXT_272K: NonNullable<ModelCosts["longContext"]> = {
|
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
const OPENAI_COSTS: Record<string, ModelCosts> = {
|
|
46
|
+
// GPT-5.6 Sol, Terra, Luna. OpenAI native API 는 1.05M context / 128K max output 이지만,
|
|
47
|
+
// qgrid 의 codex app-server 경로는 context_window=372K (max_context_window 도 372K,
|
|
48
|
+
// effective 95% ≈ 353K) 로 제한된다 — codex 0.144.1 models_cache 실측 (2026-07-10).
|
|
49
|
+
// cache write 는 uncached input 의 1.25x 로 과금되지만, codex app-server usage
|
|
50
|
+
// (TokenUsageBreakdown) 에 cache write 토큰 필드가 없어 현재 OpenAI 실행 경로의
|
|
51
|
+
// cost 계산에서는 항상 0 으로 잡힌다 → 그만큼 과소집계. 단가는 외부 logger/manual
|
|
52
|
+
// usage 입력과 향후 프로토콜 확장을 위해 유지한다.
|
|
53
|
+
// @see https://developers.openai.com/api/docs/models/gpt-5.6-sol
|
|
54
|
+
// @see https://developers.openai.com/api/docs/models/gpt-5.6-terra
|
|
55
|
+
// @see https://developers.openai.com/api/docs/models/gpt-5.6-luna
|
|
56
|
+
"gpt-5.6-sol": {
|
|
57
|
+
inputTokens: 5,
|
|
58
|
+
outputTokens: 30,
|
|
59
|
+
cachedInputTokens: 0.5,
|
|
60
|
+
cacheCreationInputTokens: 6.25,
|
|
61
|
+
longContext: LONG_CONTEXT_272K,
|
|
62
|
+
},
|
|
63
|
+
"gpt-5.6-terra": {
|
|
64
|
+
inputTokens: 2.5,
|
|
65
|
+
outputTokens: 15,
|
|
66
|
+
cachedInputTokens: 0.25,
|
|
67
|
+
cacheCreationInputTokens: 3.125,
|
|
68
|
+
longContext: LONG_CONTEXT_272K,
|
|
69
|
+
},
|
|
70
|
+
"gpt-5.6-luna": {
|
|
71
|
+
inputTokens: 1,
|
|
72
|
+
outputTokens: 6,
|
|
73
|
+
cachedInputTokens: 0.1,
|
|
74
|
+
cacheCreationInputTokens: 1.25,
|
|
75
|
+
longContext: LONG_CONTEXT_272K,
|
|
76
|
+
},
|
|
46
77
|
// https://openai.com/index/introducing-gpt-5-5/ (2026-04 출시)
|
|
47
78
|
"gpt-5.5": {
|
|
48
79
|
inputTokens: 5,
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* MVP 에서는 stream() 만. getRateLimits(), listModels() 는 future.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { type ImageGenerationOptions } from "../../../application/qgrid/qgrid.types";
|
|
7
8
|
import { type JsonValue } from "../../../codex-protocol/serde_json/JsonValue";
|
|
8
9
|
import { type TokenUsageBreakdown } from "../../../codex-protocol/v2/TokenUsageBreakdown";
|
|
9
10
|
import { type UserInput } from "../../../codex-protocol/v2/UserInput";
|
|
10
|
-
import { type ImageGenerationOptions } from "../../../application/qgrid/qgrid.types";
|
|
11
11
|
|
|
12
12
|
// qgrid provider 내부 표준 usage. codex-protocol 의 TokenUsageBreakdown 은 생성 파일이므로
|
|
13
13
|
// provider 전용 cache write 세부 필드는 여기서 확장해 보존한다.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { SmoothWeightedRoundRobin } from "./smooth-weighted-round-robin";
|
|
4
|
+
|
|
5
|
+
function pickMany(selector: SmoothWeightedRoundRobin, ids: number[], count: number): number[] {
|
|
6
|
+
const eligible = new Set(ids);
|
|
7
|
+
return Array.from({ length: count }, () => selector.select(eligible)!);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("SmoothWeightedRoundRobin", () => {
|
|
11
|
+
it("uses token-id order as the deterministic tie break for equal weights", () => {
|
|
12
|
+
const selector = new SmoothWeightedRoundRobin();
|
|
13
|
+
selector.setToken(2, 1);
|
|
14
|
+
selector.setToken(1, 1);
|
|
15
|
+
|
|
16
|
+
expect(pickMany(selector, [1, 2], 4)).toEqual([1, 2, 1, 2]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("produces a smooth 3:1 distribution", () => {
|
|
20
|
+
const selector = new SmoothWeightedRoundRobin();
|
|
21
|
+
selector.setToken(1, 3);
|
|
22
|
+
selector.setToken(2, 1);
|
|
23
|
+
|
|
24
|
+
expect(pickMany(selector, [1, 2], 4)).toEqual([1, 1, 2, 1]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("produces a complete 5:2:1 weighted cycle", () => {
|
|
28
|
+
const selector = new SmoothWeightedRoundRobin();
|
|
29
|
+
selector.setToken(1, 5);
|
|
30
|
+
selector.setToken(2, 2);
|
|
31
|
+
selector.setToken(3, 1);
|
|
32
|
+
|
|
33
|
+
const picked = pickMany(selector, [1, 2, 3], 8);
|
|
34
|
+
expect(picked.filter((id) => id === 1)).toHaveLength(5);
|
|
35
|
+
expect(picked.filter((id) => id === 2)).toHaveLength(2);
|
|
36
|
+
expect(picked.filter((id) => id === 3)).toHaveLength(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("selects only from the current eligible set", () => {
|
|
40
|
+
const selector = new SmoothWeightedRoundRobin();
|
|
41
|
+
selector.setToken(1, 10);
|
|
42
|
+
selector.setToken(2, 1);
|
|
43
|
+
|
|
44
|
+
expect(selector.select(new Set([2]))).toBe(2);
|
|
45
|
+
expect(selector.select(new Set([1, 2]))).toBe(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("resets scores when a weight changes or a token is removed", () => {
|
|
49
|
+
const selector = new SmoothWeightedRoundRobin();
|
|
50
|
+
selector.setToken(1, 3);
|
|
51
|
+
selector.setToken(2, 1);
|
|
52
|
+
selector.select(new Set([1, 2]));
|
|
53
|
+
|
|
54
|
+
selector.setToken(1, 1);
|
|
55
|
+
expect(pickMany(selector, [1, 2], 2)).toEqual([1, 2]);
|
|
56
|
+
|
|
57
|
+
selector.removeToken(1);
|
|
58
|
+
expect(selector.select(new Set([1, 2]))).toBe(2);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns null for empty or unregistered candidates", () => {
|
|
62
|
+
const selector = new SmoothWeightedRoundRobin();
|
|
63
|
+
expect(selector.select(new Set())).toBeNull();
|
|
64
|
+
expect(selector.select(new Set([99]))).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("rejects weights outside 1 through 100", () => {
|
|
68
|
+
const selector = new SmoothWeightedRoundRobin();
|
|
69
|
+
expect(() => selector.setToken(1, 0)).toThrow(RangeError);
|
|
70
|
+
expect(() => selector.setToken(1, 101)).toThrow(RangeError);
|
|
71
|
+
expect(() => selector.setToken(1, 1.5)).toThrow(RangeError);
|
|
72
|
+
});
|
|
73
|
+
});
|