@cartanova/qgrid-cli 2.3.5 → 2.3.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.
Files changed (92) hide show
  1. package/bundle/dist/application/qgrid/oauth.js +15 -12
  2. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -1
  3. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +2 -1
  4. package/bundle/dist/application/qgrid/qgrid.frame.js +23 -10
  5. package/bundle/dist/application/qgrid/qgrid.types.js +8 -2
  6. package/bundle/dist/application/qgrid/token-subscriber.js +16 -8
  7. package/bundle/dist/application/qgrid/token-trigger-setup.js +3 -2
  8. package/bundle/dist/application/request-log/request-log.model.js +12 -7
  9. package/bundle/dist/application/sonamu.generated.js +11 -3
  10. package/bundle/dist/application/sonamu.generated.sso.js +7 -2
  11. package/bundle/dist/application/token/token.model.js +10 -2
  12. package/bundle/dist/application/token/token.types.js +5 -3
  13. package/bundle/dist/i18n/sd.generated.js +4 -1
  14. package/bundle/dist/migrations/20260630223950_alter_tokens_add1_alter1.js +16 -0
  15. package/bundle/dist/migrations/20260701105327_alter_request_logs_add1_alter1.js +16 -0
  16. package/bundle/dist/migrations/20260701105328_alter_request_log_steps_add1_alter1.js +16 -0
  17. package/bundle/dist/sonamu.config.js +13 -6
  18. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +99 -12
  19. package/bundle/dist/utils/providers/anthropic/anthropic-quota.js +31 -0
  20. package/bundle/dist/utils/providers/anthropic/claude-session.js +8 -3
  21. package/bundle/dist/utils/providers/common/ttft.js +25 -0
  22. package/bundle/dist/utils/providers/openai/codex-worker.js +35 -7
  23. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +237 -39
  24. package/bundle/dist/utils/providers/openai/openai-quota.js +30 -0
  25. package/bundle/src/application/qgrid/oauth.ts +17 -7
  26. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +96 -0
  27. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -0
  28. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +16 -0
  29. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +1 -0
  30. package/bundle/src/application/qgrid/qgrid.frame.test.ts +128 -0
  31. package/bundle/src/application/qgrid/qgrid.frame.ts +41 -20
  32. package/bundle/src/application/qgrid/qgrid.types.ts +6 -0
  33. package/bundle/src/application/qgrid/token-subscriber.test.ts +89 -0
  34. package/bundle/src/application/qgrid/token-subscriber.ts +40 -11
  35. package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -1
  36. package/bundle/src/application/qgrid/tool-emulation.test.ts +7 -3
  37. package/bundle/src/application/request-log/request-log.entity.json +3 -0
  38. package/bundle/src/application/request-log/request-log.model.test.ts +92 -0
  39. package/bundle/src/application/request-log/request-log.model.ts +20 -6
  40. package/bundle/src/application/request-log-step/request-log-step.entity.json +3 -0
  41. package/bundle/src/application/sonamu.generated.http +7 -2
  42. package/bundle/src/application/sonamu.generated.sso.ts +5 -0
  43. package/bundle/src/application/sonamu.generated.ts +11 -1
  44. package/bundle/src/application/token/token.entity.json +8 -2
  45. package/bundle/src/application/token/token.model.test.ts +69 -0
  46. package/bundle/src/application/token/token.model.ts +8 -1
  47. package/bundle/src/application/token/token.types.test.ts +34 -0
  48. package/bundle/src/application/token/token.types.ts +5 -0
  49. package/bundle/src/i18n/sd.generated.ts +3 -0
  50. package/bundle/src/migrations/20260630223950_alter_tokens_add1_alter1.ts +15 -0
  51. package/bundle/src/migrations/20260701105327_alter_request_logs_add1_alter1.ts +15 -0
  52. package/bundle/src/migrations/20260701105328_alter_request_log_steps_add1_alter1.ts +15 -0
  53. package/bundle/src/sonamu.config.ts +8 -1
  54. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +183 -15
  55. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +142 -12
  56. package/bundle/src/utils/providers/anthropic/anthropic-quota.test.ts +89 -0
  57. package/bundle/src/utils/providers/anthropic/anthropic-quota.ts +27 -0
  58. package/bundle/src/utils/providers/anthropic/claude-session.ts +7 -2
  59. package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -0
  60. package/bundle/src/utils/providers/common/ttft.test.ts +37 -0
  61. package/bundle/src/utils/providers/common/ttft.ts +26 -0
  62. package/bundle/src/utils/providers/openai/codex-worker.test.ts +148 -0
  63. package/bundle/src/utils/providers/openai/codex-worker.ts +50 -8
  64. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +378 -6
  65. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +332 -37
  66. package/bundle/src/utils/providers/openai/openai-quota.test.ts +85 -0
  67. package/bundle/src/utils/providers/openai/openai-quota.ts +46 -0
  68. package/bundle/web-dist/client/assets/index-5WdFEqZT.css +1 -0
  69. package/bundle/web-dist/client/assets/{index-DeThumA3.js → index-jnUY7nJ-.js} +3 -3
  70. package/bundle/web-dist/client/assets/logs-B0pWpDrg.js +1 -0
  71. package/bundle/web-dist/client/assets/routes-7XhVuG7o.js +5 -0
  72. package/bundle/web-dist/client/assets/{sd.generated-7jzAQL2_.js → sd.generated-DXhjjL2f.js} +1 -1
  73. package/bundle/web-dist/client/assets/{services.generated-Bx1Eh7ot.js → services.generated-B7u0U1wC.js} +1 -1
  74. package/bundle/web-dist/client/assets/{show-C6OofoAL.js → show-CcjZ9nr7.js} +3 -3
  75. package/bundle/web-dist/client/assets/tokens-DlwAm0v4.js +1 -0
  76. package/bundle/web-dist/client/index.html +3 -3
  77. package/bundle/web-dist/server/assets/{lazyRouteComponent-BEaI4mOq.js → lazyRouteComponent-B0LkbiYf.js} +1 -2
  78. package/bundle/web-dist/server/assets/{logs-CHM0fhfO.js → logs-dLx6gMXT.js} +17 -5
  79. package/bundle/web-dist/server/assets/{routes-C9xFMJ_r.js → routes-DD3Hb9xO.js} +201 -13
  80. package/bundle/web-dist/server/assets/{sd.generated-BeUHLWKH.js → sd.generated-DdfpD_iN.js} +131 -1
  81. package/bundle/web-dist/server/assets/{services.generated-C39hdSv0.js → services.generated-D3ZCvIkt.js} +5 -4
  82. package/bundle/web-dist/server/assets/{show-BZWqH09U.js → show-BQjeyv6A.js} +76 -25
  83. package/bundle/web-dist/server/assets/{tokens-IPwv3pNB.js → tokens-CSOsBpP7.js} +4 -3
  84. package/bundle/web-dist/server/entry-server.generated.js +6 -7
  85. package/dist/cli.js +67 -15
  86. package/package.json +1 -1
  87. package/bundle/web-dist/client/assets/index-B5DSo5uH.css +0 -1
  88. package/bundle/web-dist/client/assets/logs-CtmOVH58.js +0 -1
  89. package/bundle/web-dist/client/assets/routes-Cfoe9J0I.js +0 -5
  90. package/bundle/web-dist/client/assets/tokens-BAZq8sTo.js +0 -1
  91. package/bundle/web-dist/server/assets/shim-B9ZrFt3Z.js +0 -129
  92. /package/bundle/web-dist/server/assets/{cost-DzjfVtUr.js → cost-CSH_t17I.js} +0 -0
@@ -46,6 +46,12 @@ export default defineConfig({
46
46
  database: process.env.QGRID_DB_NAME,
47
47
  },
48
48
  },
49
+ test: {
50
+ connection: {
51
+ ...connConfig,
52
+ database: process.env.QGRID_DB_NAME,
53
+ },
54
+ },
49
55
  },
50
56
  },
51
57
  api: {
@@ -105,7 +111,7 @@ export default defineConfig({
105
111
  return reply.redirect("/?oauth=error&reason=missing_params");
106
112
  }
107
113
  try {
108
- await QgridFrame.handleOAuthCallback(code, state, reply);
114
+ return QgridFrame.handleOAuthCallback(code, state, reply);
109
115
  } catch (e) {
110
116
  return reply.redirect(
111
117
  `/?oauth=error&reason=${encodeURIComponent((e as Error).message)}`,
@@ -157,6 +163,7 @@ export default defineConfig({
157
163
  // CSR fallback (index.html): 1분 캐시
158
164
  return CachePresets.shortLived;
159
165
  }
166
+ return CachePresets.noCache;
160
167
  },
161
168
  },
162
169
  cache: {
@@ -1,13 +1,28 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
+ import { QuotaThresholdExceededError } from "../../../application/qgrid/qgrid.types";
3
4
  import { type AnthropicCredentials } from "../../../application/token/token.types";
4
5
  import { type GenerateRequest } from "../common/provider-dispatcher";
5
6
  import { ANTHROPIC_DEFAULT_MODEL } from "./anthropic-constants";
6
7
  import { AnthropicDispatcher } from "./anthropic-dispatcher";
7
-
8
- const { runClaudeSessionMock, refreshTokenMock } = vi.hoisted(() => ({
8
+ import { type AnthropicQuotaUsageResult } from "./anthropic-quota";
9
+
10
+ const {
11
+ runClaudeSessionMock,
12
+ refreshTokenMock,
13
+ readAnthropicQuotaUsageMock,
14
+ loggerInfoMock,
15
+ loggerWarnMock,
16
+ } = vi.hoisted(() => ({
9
17
  runClaudeSessionMock: vi.fn(),
10
18
  refreshTokenMock: vi.fn(),
19
+ readAnthropicQuotaUsageMock: vi.fn(),
20
+ loggerInfoMock: vi.fn(),
21
+ loggerWarnMock: vi.fn(),
22
+ }));
23
+
24
+ vi.mock("@logtape/logtape", () => ({
25
+ getLogger: () => ({ info: loggerInfoMock, warn: loggerWarnMock }),
11
26
  }));
12
27
 
13
28
  vi.mock("./claude-session", async (importActual) => {
@@ -15,6 +30,10 @@ vi.mock("./claude-session", async (importActual) => {
15
30
  return { ...actual, runClaudeSession: runClaudeSessionMock };
16
31
  });
17
32
 
33
+ vi.mock("./anthropic-quota", () => ({
34
+ readAnthropicQuotaUsage: readAnthropicQuotaUsageMock,
35
+ }));
36
+
18
37
  vi.mock("../../../application/qgrid/qgrid.frame", () => ({
19
38
  QgridFrame: { refreshToken: refreshTokenMock },
20
39
  }));
@@ -56,12 +75,35 @@ function baseReq(overrides: Partial<GenerateRequest> = {}): GenerateRequest {
56
75
  };
57
76
  }
58
77
 
78
+ function quotaOk(utilizationPct: number, cacheAgeMs = 100): AnthropicQuotaUsageResult {
79
+ return { kind: "ok", utilizationPct, cacheAgeMs };
80
+ }
81
+
82
+ function quotaFail(reason = "usage lookup failed"): AnthropicQuotaUsageResult {
83
+ return { kind: "lookup_failed", reason };
84
+ }
85
+
86
+ function firstRunRequest() {
87
+ const call = runClaudeSessionMock.mock.calls[0]?.[0];
88
+ if (call === undefined) throw new Error("runClaudeSession was not called");
89
+ return call;
90
+ }
91
+
92
+ function firstRefreshTokenArg() {
93
+ const call = refreshTokenMock.mock.calls[0]?.[0];
94
+ if (call === undefined) throw new Error("refreshToken was not called");
95
+ return call;
96
+ }
97
+
59
98
  describe("AnthropicDispatcher", () => {
60
99
  beforeEach(() => {
61
100
  runClaudeSessionMock.mockReset();
62
101
  runClaudeSessionMock.mockResolvedValue(sessionResult());
63
102
  refreshTokenMock.mockReset();
64
103
  refreshTokenMock.mockResolvedValue("sk-ant-oat01-refreshed");
104
+ readAnthropicQuotaUsageMock.mockReset();
105
+ loggerInfoMock.mockReset();
106
+ loggerWarnMock.mockReset();
65
107
  });
66
108
 
67
109
  it("토큰 없으면 에러", async () => {
@@ -72,12 +114,14 @@ describe("AnthropicDispatcher", () => {
72
114
  it("happy: generate → GenerateResult (threadCoord 조립, systemHash 없음)", async () => {
73
115
  const d = new AnthropicDispatcher();
74
116
  d.onTokenAdded(1, "tok-A", creds());
117
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ ttftMs: 42 }));
75
118
 
76
119
  const result = await d.generate(baseReq());
77
120
 
78
121
  expect(result.text).toBe("hello");
79
122
  expect(result.tokenName).toBe("tok-A");
80
123
  expect(result.model).toBe("claude-sonnet-4-6");
124
+ expect(result.ttftMs).toBe(42);
81
125
  expect(result.threadCoord).toEqual({ workerId: 1, threadId: "sess-generated", epoch: 0 });
82
126
  });
83
127
 
@@ -89,7 +133,7 @@ describe("AnthropicDispatcher", () => {
89
133
  baseReq({ coldHistory: [{ type: "message", role: "assistant", content: [] }] }),
90
134
  );
91
135
 
92
- const call = runClaudeSessionMock.mock.calls[0]![0];
136
+ const call = firstRunRequest();
93
137
  expect(call).not.toHaveProperty("resumeSessionId");
94
138
  expect(call.coldHistory).toBeDefined();
95
139
  expect(call.input).toEqual([{ type: "text", text: "hi", text_elements: [] }]);
@@ -107,7 +151,7 @@ describe("AnthropicDispatcher", () => {
107
151
  }),
108
152
  );
109
153
 
110
- const call = runClaudeSessionMock.mock.calls[0]![0];
154
+ const call = firstRunRequest();
111
155
  expect(call).not.toHaveProperty("resumeSessionId");
112
156
  expect(call.coldHistory).toBeDefined();
113
157
  expect(call.input).toEqual([{ type: "text", text: "hi", text_elements: [] }]);
@@ -118,7 +162,7 @@ describe("AnthropicDispatcher", () => {
118
162
  d.onTokenAdded(1, "tok-A", creds());
119
163
  await d.generate(baseReq({ outputSchema: { type: "object", properties: {} } }));
120
164
 
121
- const call = runClaudeSessionMock.mock.calls[0]![0];
165
+ const call = firstRunRequest();
122
166
  expect(call.jsonSchema).toBe(JSON.stringify({ type: "object", properties: {} }));
123
167
  });
124
168
 
@@ -166,6 +210,130 @@ describe("AnthropicDispatcher", () => {
166
210
  names.add(r2.tokenName);
167
211
 
168
212
  expect(names.size).toBe(2);
213
+ expect(readAnthropicQuotaUsageMock).not.toHaveBeenCalled();
214
+ });
215
+
216
+ it("threshold 초과 토큰은 후보에서 제외하고 미설정 토큰을 선택한다", async () => {
217
+ const d = new AnthropicDispatcher();
218
+ d.onTokenAdded(1, "tok-A", creds(), 80);
219
+ d.onTokenAdded(2, "tok-B", creds());
220
+ readAnthropicQuotaUsageMock.mockResolvedValueOnce(quotaOk(85, 1_000));
221
+
222
+ const result = await d.generate(baseReq());
223
+
224
+ expect(result.tokenName).toBe("tok-B");
225
+ expect(readAnthropicQuotaUsageMock).toHaveBeenCalledTimes(1);
226
+ expect(loggerInfoMock).toHaveBeenCalledWith(
227
+ expect.stringContaining("over_threshold"),
228
+ expect.objectContaining({
229
+ tokenId: 1,
230
+ tokenName: "tok-A",
231
+ provider: "anthropic",
232
+ threshold: 80,
233
+ cachedUtilization: 85,
234
+ cacheAge: 1_000,
235
+ reason: "over_threshold",
236
+ }),
237
+ );
238
+ });
239
+
240
+ it("threshold 경계는 utilization >= threshold 일 때 제외한다", async () => {
241
+ const d = new AnthropicDispatcher();
242
+ d.onTokenAdded(1, "tok-A", creds(), 80);
243
+ d.onTokenAdded(2, "tok-B", creds(), 80);
244
+ readAnthropicQuotaUsageMock
245
+ .mockResolvedValueOnce(quotaOk(80))
246
+ .mockResolvedValueOnce(quotaOk(79));
247
+
248
+ const result = await d.generate(baseReq());
249
+
250
+ expect(result.tokenName).toBe("tok-B");
251
+ });
252
+
253
+ it("quota 조회 실패는 fail-open 으로 통과시키고 lookup_fail_open 로그를 남긴다", async () => {
254
+ const d = new AnthropicDispatcher();
255
+ d.onTokenAdded(1, "tok-A", creds(), 80);
256
+ readAnthropicQuotaUsageMock.mockResolvedValueOnce(quotaFail("timeout"));
257
+
258
+ const result = await d.generate(baseReq());
259
+
260
+ expect(result.tokenName).toBe("tok-A");
261
+ expect(loggerWarnMock).toHaveBeenCalledWith(
262
+ expect.stringContaining("lookup_fail_open"),
263
+ expect.objectContaining({
264
+ tokenId: 1,
265
+ tokenName: "tok-A",
266
+ provider: "anthropic",
267
+ threshold: 80,
268
+ reason: "lookup_fail_open",
269
+ }),
270
+ );
271
+ });
272
+
273
+ it("utilization 0 은 정상 조회로 보고 fail-open 로그를 남기지 않는다", async () => {
274
+ const d = new AnthropicDispatcher();
275
+ d.onTokenAdded(1, "tok-A", creds(), 80);
276
+ readAnthropicQuotaUsageMock.mockResolvedValueOnce(quotaOk(0));
277
+
278
+ const result = await d.generate(baseReq());
279
+
280
+ expect(result.tokenName).toBe("tok-A");
281
+ expect(loggerWarnMock).not.toHaveBeenCalledWith(
282
+ expect.stringContaining("lookup_fail_open"),
283
+ expect.anything(),
284
+ );
285
+ });
286
+
287
+ it("모든 threshold 설정 토큰이 초과되면 typed error 로 실패한다", async () => {
288
+ const d = new AnthropicDispatcher();
289
+ d.onTokenAdded(1, "tok-A", creds(), 80);
290
+ d.onTokenAdded(2, "tok-B", creds(), 90);
291
+ readAnthropicQuotaUsageMock
292
+ .mockResolvedValueOnce(quotaOk(85, 100))
293
+ .mockResolvedValueOnce(quotaOk(95, 200));
294
+
295
+ const error = await d.generate(baseReq()).catch((e) => e);
296
+
297
+ expect(error).toBeInstanceOf(QuotaThresholdExceededError);
298
+ expect(error).toMatchObject({ code: "QUOTA_THRESHOLD_EXCEEDED" });
299
+ expect(error.message).toBe(
300
+ "All anthropic tokens exceeded quota threshold: tok-A (threshold 80%), tok-B (threshold 90%)",
301
+ );
302
+ });
303
+
304
+ it("minCount 는 초과 토큰을 제외한 eligible 집합에서 계산한다", async () => {
305
+ 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);
309
+
310
+ readAnthropicQuotaUsageMock
311
+ .mockResolvedValueOnce(quotaOk(10))
312
+ .mockResolvedValueOnce(quotaOk(10))
313
+ .mockResolvedValueOnce(quotaOk(10));
314
+ await d.generate(baseReq());
315
+ readAnthropicQuotaUsageMock.mockReset();
316
+
317
+ readAnthropicQuotaUsageMock
318
+ .mockResolvedValueOnce(quotaOk(95))
319
+ .mockResolvedValueOnce(quotaOk(10))
320
+ .mockResolvedValueOnce(quotaOk(10));
321
+
322
+ const result = await d.generate(baseReq());
323
+
324
+ expect(result.tokenName).not.toBe("tok-A");
325
+ expect(new Set(["tok-B", "tok-C"]).has(result.tokenName)).toBe(true);
326
+ });
327
+
328
+ it("동시 요청은 quota await 이후 charge 선반영으로 서로 다른 eligible 토큰을 고른다", async () => {
329
+ const d = new AnthropicDispatcher();
330
+ d.onTokenAdded(1, "tok-A", creds(), 80);
331
+ d.onTokenAdded(2, "tok-B", creds(), 80);
332
+ readAnthropicQuotaUsageMock.mockResolvedValue(quotaOk(0));
333
+
334
+ const results = await Promise.all([d.generate(baseReq()), d.generate(baseReq())]);
335
+
336
+ expect(new Set(results.map((r) => r.tokenName)).size).toBe(2);
169
337
  });
170
338
 
171
339
  it("onTokenRemoved 후 그 토큰 미선택", async () => {
@@ -183,7 +351,7 @@ describe("AnthropicDispatcher", () => {
183
351
 
184
352
  await d.generate(baseReq());
185
353
 
186
- const call = runClaudeSessionMock.mock.calls[0]![0];
354
+ const call = firstRunRequest();
187
355
  expect(call.tokenId).toBe(1);
188
356
  });
189
357
 
@@ -193,7 +361,7 @@ describe("AnthropicDispatcher", () => {
193
361
 
194
362
  const result = await d.generate(baseReq({ model: "anthropic/claude-opus-4-8" }));
195
363
 
196
- const call = runClaudeSessionMock.mock.calls[0]![0];
364
+ const call = firstRunRequest();
197
365
  expect(call.model).toBe("claude-opus-4-8");
198
366
  expect(result.model).toBe("claude-opus-4-8");
199
367
  });
@@ -204,7 +372,7 @@ describe("AnthropicDispatcher", () => {
204
372
 
205
373
  const result = await d.generate(baseReq({ model: "anthropic/claude-sonnet-4-6[1m]" }));
206
374
 
207
- const call = runClaudeSessionMock.mock.calls[0]![0];
375
+ const call = firstRunRequest();
208
376
  expect(call.model).toBe("claude-sonnet-4-6");
209
377
  expect(result.model).toBe("claude-sonnet-4-6");
210
378
  });
@@ -224,7 +392,7 @@ describe("AnthropicDispatcher", () => {
224
392
 
225
393
  const result = await d.generate(baseReq({ model: undefined }));
226
394
 
227
- const call = runClaudeSessionMock.mock.calls[0]![0];
395
+ const call = firstRunRequest();
228
396
  expect(call.model).toBe(ANTHROPIC_DEFAULT_MODEL);
229
397
  expect(result.model).toBe(ANTHROPIC_DEFAULT_MODEL);
230
398
  });
@@ -257,7 +425,7 @@ describe("AnthropicDispatcher", () => {
257
425
 
258
426
  expect(deltas).toEqual(["부분"]);
259
427
  expect(completed).not.toBeNull();
260
- expect(runClaudeSessionMock.mock.calls[0]![0].includePartialMessages).toBe(true);
428
+ expect(firstRunRequest().includePartialMessages).toBe(true);
261
429
  });
262
430
 
263
431
  it("generateStream: Claude server error 는 onError 로 전달하고 complete 하지 않는다", async () => {
@@ -276,7 +444,7 @@ describe("AnthropicDispatcher", () => {
276
444
  expect(onDelta).not.toHaveBeenCalled();
277
445
  expect(onComplete).not.toHaveBeenCalled();
278
446
  expect(onError).toHaveBeenCalledWith(serverError);
279
- expect(runClaudeSessionMock.mock.calls[0]![0].includePartialMessages).toBe(true);
447
+ expect(firstRunRequest().includePartialMessages).toBe(true);
280
448
  });
281
449
 
282
450
  it("refresh: 만료 임박 토큰은 provider 포함해 refreshToken 호출, 새 access token 으로 세션 진행", async () => {
@@ -287,10 +455,10 @@ describe("AnthropicDispatcher", () => {
287
455
  await d.generate(baseReq());
288
456
 
289
457
  expect(refreshTokenMock).toHaveBeenCalledTimes(1);
290
- const refreshArg = refreshTokenMock.mock.calls[0]![0];
458
+ const refreshArg = firstRefreshTokenArg();
291
459
  expect(refreshArg.provider).toBe("anthropic");
292
460
  expect(refreshArg.id).toBe(1);
293
- expect(runClaudeSessionMock.mock.calls[0]![0].token).toBe("sk-ant-oat01-refreshed");
461
+ expect(firstRunRequest().token).toBe("sk-ant-oat01-refreshed");
294
462
  });
295
463
 
296
464
  it("refresh: 만료 여유 있으면 refresh 안 함, 기존 access token 사용", async () => {
@@ -300,7 +468,7 @@ describe("AnthropicDispatcher", () => {
300
468
  await d.generate(baseReq());
301
469
 
302
470
  expect(refreshTokenMock).not.toHaveBeenCalled();
303
- expect(runClaudeSessionMock.mock.calls[0]![0].token).toBe("sk-ant-oat01-test");
471
+ expect(firstRunRequest().token).toBe("sk-ant-oat01-test");
304
472
  });
305
473
 
306
474
  it("refresh 실패해도 기존 access token 으로 진행 — 요청을 죽이지 않음", async () => {
@@ -311,6 +479,6 @@ describe("AnthropicDispatcher", () => {
311
479
  const result = await d.generate(baseReq());
312
480
 
313
481
  expect(result.text).toBe("hello");
314
- expect(runClaudeSessionMock.mock.calls[0]![0].token).toBe("sk-ant-oat01-test");
482
+ expect(firstRunRequest().token).toBe("sk-ant-oat01-test");
315
483
  });
316
484
  });
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { getLogger } from "@logtape/logtape";
15
15
 
16
+ import { QuotaThresholdExceededError } from "../../../application/qgrid/qgrid.types";
16
17
  import { TokenModel } from "../../../application/token/token.model";
17
18
  import { type AnthropicCredentials } from "../../../application/token/token.types";
18
19
  import { getExpiresAt, getRefreshToken } from "../common/credentials";
@@ -23,6 +24,7 @@ import {
23
24
  type ProviderDispatcher,
24
25
  } from "../common/provider-dispatcher";
25
26
  import { assertSupportedOneMillionSuffix, canonicalAnthropicModel } from "./anthropic-constants";
27
+ import { readAnthropicQuotaUsage, type AnthropicQuotaUsageResult } from "./anthropic-quota";
26
28
  import { makeAnthropicWorkerId, runClaudeSession } from "./claude-session";
27
29
 
28
30
  const logger = getLogger(["qgrid", "anthropic-dispatcher"]);
@@ -37,6 +39,19 @@ interface PooledToken {
37
39
  id: number;
38
40
  name: string;
39
41
  credentials: AnthropicCredentials;
42
+ quotaThreshold?: number | null;
43
+ }
44
+
45
+ function quotaThresholdExceededMessage(
46
+ provider: string,
47
+ tokens: Array<{ tokenName: string; threshold: number }>,
48
+ ): string {
49
+ const details = tokens
50
+ .map((token) => `${token.tokenName} (threshold ${token.threshold}%)`)
51
+ .join(", ");
52
+ return details.length > 0
53
+ ? `All ${provider} tokens exceeded quota threshold: ${details}`
54
+ : `All ${provider} tokens exceeded quota threshold`;
40
55
  }
41
56
 
42
57
  export class AnthropicDispatcher implements ProviderDispatcher {
@@ -57,6 +72,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
57
72
  id: t.id,
58
73
  name: t.name,
59
74
  credentials: t.credentials as AnthropicCredentials,
75
+ quotaThreshold: t.quota_threshold,
60
76
  });
61
77
  logger.info(`worker spawned: ${t.name}`);
62
78
  }
@@ -80,7 +96,12 @@ export class AnthropicDispatcher implements ProviderDispatcher {
80
96
  // - DB 에 없는데 풀에 있는 토큰 → onTokenRemoved
81
97
  // - DB 에 있는 토큰 → 신규면 onTokenAdded, 기존이면 onTokenUpdated
82
98
  replaceTokens(
83
- rows: Array<{ id: number; name: string; credentials: AnthropicCredentials }>,
99
+ rows: Array<{
100
+ id: number;
101
+ name: string;
102
+ credentials: AnthropicCredentials;
103
+ quotaThreshold?: number | null;
104
+ }>,
84
105
  ): void {
85
106
  const next = new Set(rows.map((r) => r.id));
86
107
  // onTokenRemoved 가 tokenPool 을 delete 하므로 순회 중 수정을 피하려 키를 먼저 스냅샷한다.
@@ -89,18 +110,31 @@ export class AnthropicDispatcher implements ProviderDispatcher {
89
110
  if (!next.has(id)) this.onTokenRemoved(id);
90
111
  }
91
112
  for (const r of rows) {
92
- if (this.tokenPool.has(r.id)) this.onTokenUpdated(r.id, r.name, r.credentials);
93
- else this.onTokenAdded(r.id, r.name, r.credentials);
113
+ if (this.tokenPool.has(r.id)) {
114
+ this.onTokenUpdated(r.id, r.name, r.credentials, r.quotaThreshold);
115
+ } else {
116
+ this.onTokenAdded(r.id, r.name, r.credentials, r.quotaThreshold);
117
+ }
94
118
  }
95
119
  }
96
120
 
97
121
  // token events (token-subscriber 가 호출)
98
- onTokenAdded(id: number, name: string, credentials: AnthropicCredentials): void {
99
- this.tokenPool.set(id, { id, name, credentials });
122
+ onTokenAdded(
123
+ id: number,
124
+ name: string,
125
+ credentials: AnthropicCredentials,
126
+ quotaThreshold?: number | null,
127
+ ): void {
128
+ this.tokenPool.set(id, { id, name, credentials, quotaThreshold });
100
129
  }
101
130
 
102
- onTokenUpdated(id: number, name: string, credentials: AnthropicCredentials): void {
103
- this.tokenPool.set(id, { id, name, credentials });
131
+ onTokenUpdated(
132
+ id: number,
133
+ name: string,
134
+ credentials: AnthropicCredentials,
135
+ quotaThreshold?: number | null,
136
+ ): void {
137
+ this.tokenPool.set(id, { id, name, credentials, quotaThreshold });
104
138
  }
105
139
 
106
140
  onTokenRemoved(id: number): void {
@@ -113,16 +147,111 @@ export class AnthropicDispatcher implements ProviderDispatcher {
113
147
  }
114
148
 
115
149
  // 요청마다 least-used RR 로 토큰을 고른다(동점이면 rrIndex 로 회전).
116
- private selectToken(): PooledToken | null {
150
+ private async selectToken(): Promise<PooledToken | null> {
117
151
  const rows = [...this.tokenPool.values()];
118
152
  if (rows.length === 0) return null;
119
- const minCount = Math.min(...rows.map((r) => this.countOf(r.id)));
120
- const idle = rows.filter((r) => this.countOf(r.id) === minCount);
121
- const picked = idle[this.rrIndex % idle.length]!;
153
+ const { eligible, overThresholdTokens } = await this.filterEligibleTokens(rows);
154
+ if (eligible.length === 0) {
155
+ logger.warn("quota_threshold gate: all_exceeded", {
156
+ provider: "anthropic",
157
+ tokenCount: rows.length,
158
+ thresholdedTokenCount: rows.filter((r) => this.hasQuotaThreshold(r)).length,
159
+ overThresholdTokens,
160
+ reason: "all_exceeded",
161
+ });
162
+ throw new QuotaThresholdExceededError(
163
+ quotaThresholdExceededMessage("anthropic", overThresholdTokens),
164
+ );
165
+ }
166
+
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;
122
171
  this.rrIndex++;
123
172
  return this.charge(picked);
124
173
  }
125
174
 
175
+ private async filterEligibleTokens(rows: PooledToken[]): Promise<{
176
+ eligible: PooledToken[];
177
+ overThresholdTokens: Array<{ tokenName: string; threshold: number }>;
178
+ }> {
179
+ const thresholded = rows.filter((token) => this.hasQuotaThreshold(token));
180
+ if (thresholded.length === 0) return { eligible: rows, overThresholdTokens: [] };
181
+
182
+ const eligibleIds = new Set(
183
+ rows.filter((token) => !this.hasQuotaThreshold(token)).map((token) => token.id),
184
+ );
185
+ const overThresholdTokens: Array<{ tokenName: string; threshold: number }> = [];
186
+ const checks = await Promise.allSettled(
187
+ thresholded.map(async (token) => ({
188
+ token,
189
+ result: await readAnthropicQuotaUsage(token.credentials.accessToken),
190
+ })),
191
+ );
192
+
193
+ checks.forEach((check, index) => {
194
+ if (check.status === "rejected") {
195
+ const token = thresholded[index];
196
+ if (!token) return;
197
+ this.logQuotaLookupFailOpen(token, String(check.reason));
198
+ eligibleIds.add(token.id);
199
+ return;
200
+ }
201
+
202
+ const token = check.value.token;
203
+ const result = check.value.result;
204
+ if (result.kind === "lookup_failed") {
205
+ this.logQuotaLookupFailOpen(token, result.reason);
206
+ eligibleIds.add(token.id);
207
+ return;
208
+ }
209
+
210
+ if (result.utilizationPct >= token.quotaThreshold) {
211
+ this.logQuotaOverThreshold(token, result);
212
+ overThresholdTokens.push({ tokenName: token.name, threshold: token.quotaThreshold });
213
+ return;
214
+ }
215
+
216
+ eligibleIds.add(token.id);
217
+ });
218
+
219
+ return { eligible: rows.filter((token) => eligibleIds.has(token.id)), overThresholdTokens };
220
+ }
221
+
222
+ private hasQuotaThreshold(token: PooledToken): token is PooledToken & { quotaThreshold: number } {
223
+ return token.quotaThreshold !== undefined && token.quotaThreshold !== null;
224
+ }
225
+
226
+ private logQuotaOverThreshold(
227
+ token: PooledToken & { quotaThreshold: number },
228
+ result: Extract<AnthropicQuotaUsageResult, { kind: "ok" }>,
229
+ ): void {
230
+ logger.info("quota_threshold gate: over_threshold", {
231
+ tokenId: token.id,
232
+ tokenName: token.name,
233
+ provider: "anthropic",
234
+ threshold: token.quotaThreshold,
235
+ cachedUtilization: result.utilizationPct,
236
+ cacheAge: result.cacheAgeMs,
237
+ reason: "over_threshold",
238
+ });
239
+ }
240
+
241
+ private logQuotaLookupFailOpen(
242
+ token: PooledToken & { quotaThreshold: number },
243
+ reason: string,
244
+ ): void {
245
+ logger.warn("quota_threshold gate: lookup_fail_open", {
246
+ tokenId: token.id,
247
+ tokenName: token.name,
248
+ provider: "anthropic",
249
+ threshold: token.quotaThreshold,
250
+ reason: "lookup_fail_open",
251
+ lookupReason: reason,
252
+ });
253
+ }
254
+
126
255
  // 선택된 토큰의 사용 카운트를 await 전에 선반영(동시 요청이 다른 토큰을 고르도록).
127
256
  private charge(token: PooledToken): PooledToken {
128
257
  this.requestCounts.set(token.id, this.countOf(token.id) + 1);
@@ -157,7 +286,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
157
286
  const jsonSchema =
158
287
  req.outputSchema !== undefined ? JSON.stringify(req.outputSchema) : undefined;
159
288
 
160
- const token = this.selectToken();
289
+ const token = await this.selectToken();
161
290
  if (!token) throw new Error("No anthropic tokens available");
162
291
 
163
292
  const exec = async (): Promise<GenerateResult> => {
@@ -222,6 +351,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
222
351
  tokenName: token.name,
223
352
  usage: session.usage,
224
353
  durationMs: session.durationMs,
354
+ ttftMs: session.ttftMs,
225
355
  costUsd: session.costUsd,
226
356
  model,
227
357
  // systemHash 는 상위 issueConvContext 가 채운다(여기선 비운다).
@@ -0,0 +1,89 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { readAnthropicQuotaUsage } from "./anthropic-quota";
4
+
5
+ const { fetchUsageWithMetaMock } = vi.hoisted(() => ({
6
+ fetchUsageWithMetaMock: vi.fn(),
7
+ }));
8
+
9
+ vi.mock("../../../application/qgrid/oauth", () => ({
10
+ fetchUsageWithMeta: fetchUsageWithMetaMock,
11
+ }));
12
+
13
+ describe("readAnthropicQuotaUsage", () => {
14
+ beforeEach(() => {
15
+ vi.useFakeTimers();
16
+ vi.setSystemTime(new Date("2026-06-30T12:00:05.000Z"));
17
+ fetchUsageWithMetaMock.mockReset();
18
+ });
19
+
20
+ afterEach(() => {
21
+ vi.useRealTimers();
22
+ });
23
+
24
+ it("uses API utilization as 0..100 percent", async () => {
25
+ fetchUsageWithMetaMock.mockResolvedValueOnce({
26
+ data: { five_hour: { utilization: 17, resets_at: null } },
27
+ cachedAt: Date.now() - 1_250,
28
+ });
29
+
30
+ await expect(readAnthropicQuotaUsage("access-token")).resolves.toEqual({
31
+ kind: "ok",
32
+ utilizationPct: 17,
33
+ cacheAgeMs: 1_250,
34
+ });
35
+ });
36
+
37
+ it("keeps low percent utilization unchanged", async () => {
38
+ fetchUsageWithMetaMock.mockResolvedValueOnce({
39
+ data: { five_hour: { utilization: 1, resets_at: null } },
40
+ cachedAt: Date.now() - 500,
41
+ });
42
+
43
+ await expect(readAnthropicQuotaUsage("access-token")).resolves.toMatchObject({
44
+ kind: "ok",
45
+ utilizationPct: 1,
46
+ cacheAgeMs: 500,
47
+ });
48
+ });
49
+
50
+ it("treats utilization 0 as a successful lookup", async () => {
51
+ fetchUsageWithMetaMock.mockResolvedValueOnce({
52
+ data: { five_hour: { utilization: 0, resets_at: null } },
53
+ cachedAt: Date.now(),
54
+ });
55
+
56
+ await expect(readAnthropicQuotaUsage("access-token")).resolves.toMatchObject({
57
+ kind: "ok",
58
+ utilizationPct: 0,
59
+ });
60
+ });
61
+
62
+ it("converts API errors and rejected fetches into lookup_failed", async () => {
63
+ fetchUsageWithMetaMock.mockResolvedValueOnce({
64
+ data: { error: "rate limit API unavailable" },
65
+ cachedAt: Date.now(),
66
+ });
67
+ await expect(readAnthropicQuotaUsage("access-token")).resolves.toMatchObject({
68
+ kind: "lookup_failed",
69
+ reason: "rate limit API unavailable",
70
+ });
71
+
72
+ fetchUsageWithMetaMock.mockRejectedValueOnce(new Error("timeout"));
73
+ await expect(readAnthropicQuotaUsage("access-token")).resolves.toMatchObject({
74
+ kind: "lookup_failed",
75
+ reason: "timeout",
76
+ });
77
+ });
78
+
79
+ it("treats missing five_hour utilization as lookup_failed", async () => {
80
+ fetchUsageWithMetaMock.mockResolvedValueOnce({
81
+ data: { five_hour: { utilization: null, resets_at: null } },
82
+ cachedAt: Date.now(),
83
+ });
84
+
85
+ await expect(readAnthropicQuotaUsage("access-token")).resolves.toMatchObject({
86
+ kind: "lookup_failed",
87
+ });
88
+ });
89
+ });