@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.
Files changed (95) hide show
  1. package/bundle/dist/application/qgrid/qgrid.frame.js +13 -17
  2. package/bundle/dist/application/qgrid/token-subscriber.js +23 -9
  3. package/bundle/dist/application/qgrid/token-trigger-setup.js +4 -4
  4. package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
  5. package/bundle/dist/application/sonamu.generated.js +5 -3
  6. package/bundle/dist/application/sonamu.generated.sso.js +3 -2
  7. package/bundle/dist/application/token/token.model.js +8 -3
  8. package/bundle/dist/application/token/token.types.js +8 -3
  9. package/bundle/dist/i18n/sd.generated.js +2 -1
  10. package/bundle/dist/index.js +3 -6
  11. package/bundle/dist/migrations/20260521134025_alter_request_log_steps_alter1.js +3 -9
  12. package/bundle/dist/migrations/20260706120000_alter_request_logs_add_image_cost.js +1 -1
  13. package/bundle/dist/migrations/20260710090000_alter_tokens_add_weight.js +41 -0
  14. package/bundle/dist/server-bootstrap.js +17 -0
  15. package/bundle/dist/sonamu.config.js +2 -10
  16. package/bundle/dist/startup-migrations.js +21 -0
  17. package/bundle/dist/testing/global.js +93 -5
  18. package/bundle/dist/testing/test-database-name.js +22 -0
  19. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +24 -27
  20. package/bundle/dist/utils/providers/common/model-cost.js +22 -1
  21. package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +43 -0
  22. package/bundle/dist/utils/providers/openai/codex-worker.js +19 -2
  23. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +53 -40
  24. package/bundle/src/application/qgrid/qgrid.frame.test.ts +36 -14
  25. package/bundle/src/application/qgrid/qgrid.frame.ts +22 -25
  26. package/bundle/src/application/qgrid/token-subscriber.test.ts +102 -0
  27. package/bundle/src/application/qgrid/token-subscriber.ts +37 -6
  28. package/bundle/src/application/qgrid/token-trigger-setup.test.ts +9 -0
  29. package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -2
  30. package/bundle/src/application/qgrid/token-weight-migration.test.ts +42 -0
  31. package/bundle/src/application/qgrid/tool-emulation.ts +6 -1
  32. package/bundle/src/application/sonamu.generated.http +4 -2
  33. package/bundle/src/application/sonamu.generated.sso.ts +1 -0
  34. package/bundle/src/application/sonamu.generated.ts +10 -1
  35. package/bundle/src/application/token/token.entity.json +17 -1
  36. package/bundle/src/application/token/token.model.test.ts +43 -0
  37. package/bundle/src/application/token/token.model.ts +18 -2
  38. package/bundle/src/application/token/token.types.test.ts +11 -0
  39. package/bundle/src/application/token/token.types.ts +3 -0
  40. package/bundle/src/i18n/sd.generated.ts +1 -0
  41. package/bundle/src/index.ts +2 -5
  42. package/bundle/src/migrations/20260521134025_alter_request_log_steps_alter1.ts +4 -8
  43. package/bundle/src/migrations/20260706120000_alter_request_logs_add_image_cost.ts +1 -1
  44. package/bundle/src/migrations/20260710090000_alter_tokens_add_weight.ts +38 -0
  45. package/bundle/src/server-bootstrap.test.ts +39 -0
  46. package/bundle/src/server-bootstrap.ts +21 -0
  47. package/bundle/src/sonamu.config.ts +3 -12
  48. package/bundle/src/startup-migrations.test.ts +25 -0
  49. package/bundle/src/startup-migrations.ts +20 -0
  50. package/bundle/src/testing/global.test.ts +130 -0
  51. package/bundle/src/testing/global.ts +143 -13
  52. package/bundle/src/testing/test-database-name.test.ts +38 -0
  53. package/bundle/src/testing/test-database-name.ts +21 -0
  54. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +75 -45
  55. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +23 -29
  56. package/bundle/src/utils/providers/common/model-cost.test.ts +54 -0
  57. package/bundle/src/utils/providers/common/model-cost.ts +31 -0
  58. package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -1
  59. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.test.ts +73 -0
  60. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +48 -0
  61. package/bundle/src/utils/providers/openai/codex-worker.test.ts +70 -1
  62. package/bundle/src/utils/providers/openai/codex-worker.ts +18 -6
  63. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +109 -6
  64. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +72 -55
  65. package/bundle/web-dist/client/assets/{index-CN7eSKO4.js → index-D1ExP77_.js} +3 -3
  66. package/bundle/web-dist/client/assets/{logs-NubdF_C_.js → logs-vuI-Zgnq.js} +1 -1
  67. package/bundle/web-dist/client/assets/routes-0Fki0JEi.js +5 -0
  68. package/bundle/web-dist/client/assets/{sd.generated-BrUnRt0B.js → sd.generated-WA5SGJYZ.js} +1 -1
  69. package/bundle/web-dist/client/assets/{services.generated-AsEvHbJ7.js → services.generated-DzZLnb7S.js} +1 -1
  70. package/bundle/web-dist/client/assets/{show-BvDjIZCX.js → show-BreAgaFH.js} +1 -1
  71. package/bundle/web-dist/client/assets/tokens-4VgHQtXd.js +1 -0
  72. package/bundle/web-dist/client/assets/use-update-token-mutation-BV6msjuy.js +1 -0
  73. package/bundle/web-dist/client/index.html +2 -2
  74. package/bundle/web-dist/server/assets/{lazyRouteComponent-BswulcOx.js → lazyRouteComponent-BrPAFtBA.js} +1 -1
  75. package/bundle/web-dist/server/assets/{logs-Bk9C0ELo.js → logs-CKM6W3W6.js} +3 -3
  76. package/bundle/web-dist/server/assets/{routes-D9OiKoqa.js → routes-DhIpPgXz.js} +130 -7
  77. package/bundle/web-dist/server/assets/{sd.generated-DRuQSIKo.js → sd.generated-Cwg2f53z.js} +1 -0
  78. package/bundle/web-dist/server/assets/{services.generated-Dp0uKgpV.js → services.generated-CsotZP44.js} +5 -5
  79. package/bundle/web-dist/server/assets/{show-3GokIDEJ.js → show-CV2UFjaH.js} +3 -3
  80. package/bundle/web-dist/server/assets/{tokens-Cjw6cQxZ.js → tokens-DfGj1y2v.js} +5 -5
  81. package/bundle/web-dist/server/assets/use-update-token-mutation-BL96w-Pg.js +7 -0
  82. package/bundle/web-dist/server/entry-server.generated.js +7 -7
  83. package/package.json +1 -1
  84. package/skills/qgrid/SKILL.md +1 -1
  85. package/skills/qgrid/references/anthropic-claude-code-runtime.md +2 -2
  86. package/skills/qgrid/references/cli-env-and-server-boot.md +9 -9
  87. package/skills/qgrid/references/decision-rationale.md +23 -0
  88. package/skills/qgrid/references/openai-codex-runtime.md +4 -3
  89. package/skills/qgrid/references/prompt-cache-and-usage.md +2 -0
  90. package/skills/qgrid/references/provider-runtime-differences.md +2 -1
  91. package/skills/qgrid/references/repo-map.md +2 -0
  92. package/skills/qgrid/references/token-auth-quota-lifecycle.md +8 -3
  93. package/skills/qgrid/references/verification-and-debugging.md +3 -0
  94. package/bundle/web-dist/client/assets/routes-NKxXqHvs.js +0 -5
  95. 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("least-used RR: 두 토큰 번갈아 분배", async () => {
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 = new Set<string>();
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.size).toBe(2);
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("minCount 는 초과 토큰을 제외한 eligible 집합에서 계산한다", async () => {
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 이후 charge 선반영으로 서로 다른 eligible 토큰을 고른다", async () => {
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)으로 관리하고 least-used, round-robin 으로 고른다
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
- // tokenId 별 사용 카운터(least-used RR). name 이 아니라 id 기준 — 이름 충돌 안전
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
- // lifecycle 정합: RR 카운터/인덱스도 비워 stop→start 재등록 시 이전 카운트가 안 남게 한다.
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.requestCounts.delete(id);
149
+ this.weightedSelector.removeToken(id);
143
150
  }
144
151
 
145
- private countOf(id: number): number {
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 minCount = Math.min(...eligible.map((r) => this.countOf(r.id)));
168
- const idle = eligible.filter((r) => this.countOf(r.id) === minCount);
169
- const picked = idle[this.rrIndex % idle.length];
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
+ });