@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.
- package/bundle/dist/application/qgrid/oauth.js +15 -12
- package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -1
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +2 -1
- package/bundle/dist/application/qgrid/qgrid.frame.js +23 -10
- package/bundle/dist/application/qgrid/qgrid.types.js +8 -2
- package/bundle/dist/application/qgrid/token-subscriber.js +16 -8
- package/bundle/dist/application/qgrid/token-trigger-setup.js +3 -2
- package/bundle/dist/application/request-log/request-log.model.js +12 -7
- package/bundle/dist/application/sonamu.generated.js +11 -3
- package/bundle/dist/application/sonamu.generated.sso.js +7 -2
- package/bundle/dist/application/token/token.model.js +10 -2
- package/bundle/dist/application/token/token.types.js +5 -3
- package/bundle/dist/i18n/sd.generated.js +4 -1
- package/bundle/dist/migrations/20260630223950_alter_tokens_add1_alter1.js +16 -0
- package/bundle/dist/migrations/20260701105327_alter_request_logs_add1_alter1.js +16 -0
- package/bundle/dist/migrations/20260701105328_alter_request_log_steps_add1_alter1.js +16 -0
- package/bundle/dist/sonamu.config.js +13 -6
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +99 -12
- package/bundle/dist/utils/providers/anthropic/anthropic-quota.js +31 -0
- package/bundle/dist/utils/providers/anthropic/claude-session.js +8 -3
- package/bundle/dist/utils/providers/common/ttft.js +25 -0
- package/bundle/dist/utils/providers/openai/codex-worker.js +35 -7
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +237 -39
- package/bundle/dist/utils/providers/openai/openai-quota.js +30 -0
- package/bundle/src/application/qgrid/oauth.ts +17 -7
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +96 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +16 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +1 -0
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +128 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +41 -20
- package/bundle/src/application/qgrid/qgrid.types.ts +6 -0
- package/bundle/src/application/qgrid/token-subscriber.test.ts +89 -0
- package/bundle/src/application/qgrid/token-subscriber.ts +40 -11
- package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -1
- package/bundle/src/application/qgrid/tool-emulation.test.ts +7 -3
- package/bundle/src/application/request-log/request-log.entity.json +3 -0
- package/bundle/src/application/request-log/request-log.model.test.ts +92 -0
- package/bundle/src/application/request-log/request-log.model.ts +20 -6
- package/bundle/src/application/request-log-step/request-log-step.entity.json +3 -0
- package/bundle/src/application/sonamu.generated.http +7 -2
- package/bundle/src/application/sonamu.generated.sso.ts +5 -0
- package/bundle/src/application/sonamu.generated.ts +11 -1
- package/bundle/src/application/token/token.entity.json +8 -2
- package/bundle/src/application/token/token.model.test.ts +69 -0
- package/bundle/src/application/token/token.model.ts +8 -1
- package/bundle/src/application/token/token.types.test.ts +34 -0
- package/bundle/src/application/token/token.types.ts +5 -0
- package/bundle/src/i18n/sd.generated.ts +3 -0
- package/bundle/src/migrations/20260630223950_alter_tokens_add1_alter1.ts +15 -0
- package/bundle/src/migrations/20260701105327_alter_request_logs_add1_alter1.ts +15 -0
- package/bundle/src/migrations/20260701105328_alter_request_log_steps_add1_alter1.ts +15 -0
- package/bundle/src/sonamu.config.ts +8 -1
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +183 -15
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +142 -12
- package/bundle/src/utils/providers/anthropic/anthropic-quota.test.ts +89 -0
- package/bundle/src/utils/providers/anthropic/anthropic-quota.ts +27 -0
- package/bundle/src/utils/providers/anthropic/claude-session.ts +7 -2
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -0
- package/bundle/src/utils/providers/common/ttft.test.ts +37 -0
- package/bundle/src/utils/providers/common/ttft.ts +26 -0
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +148 -0
- package/bundle/src/utils/providers/openai/codex-worker.ts +50 -8
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +378 -6
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +332 -37
- package/bundle/src/utils/providers/openai/openai-quota.test.ts +85 -0
- package/bundle/src/utils/providers/openai/openai-quota.ts +46 -0
- package/bundle/web-dist/client/assets/index-5WdFEqZT.css +1 -0
- package/bundle/web-dist/client/assets/{index-DeThumA3.js → index-jnUY7nJ-.js} +3 -3
- package/bundle/web-dist/client/assets/logs-B0pWpDrg.js +1 -0
- package/bundle/web-dist/client/assets/routes-7XhVuG7o.js +5 -0
- package/bundle/web-dist/client/assets/{sd.generated-7jzAQL2_.js → sd.generated-DXhjjL2f.js} +1 -1
- package/bundle/web-dist/client/assets/{services.generated-Bx1Eh7ot.js → services.generated-B7u0U1wC.js} +1 -1
- package/bundle/web-dist/client/assets/{show-C6OofoAL.js → show-CcjZ9nr7.js} +3 -3
- package/bundle/web-dist/client/assets/tokens-DlwAm0v4.js +1 -0
- package/bundle/web-dist/client/index.html +3 -3
- package/bundle/web-dist/server/assets/{lazyRouteComponent-BEaI4mOq.js → lazyRouteComponent-B0LkbiYf.js} +1 -2
- package/bundle/web-dist/server/assets/{logs-CHM0fhfO.js → logs-dLx6gMXT.js} +17 -5
- package/bundle/web-dist/server/assets/{routes-C9xFMJ_r.js → routes-DD3Hb9xO.js} +201 -13
- package/bundle/web-dist/server/assets/{sd.generated-BeUHLWKH.js → sd.generated-DdfpD_iN.js} +131 -1
- package/bundle/web-dist/server/assets/{services.generated-C39hdSv0.js → services.generated-D3ZCvIkt.js} +5 -4
- package/bundle/web-dist/server/assets/{show-BZWqH09U.js → show-BQjeyv6A.js} +76 -25
- package/bundle/web-dist/server/assets/{tokens-IPwv3pNB.js → tokens-CSOsBpP7.js} +4 -3
- package/bundle/web-dist/server/entry-server.generated.js +6 -7
- package/dist/cli.js +67 -15
- package/package.json +1 -1
- package/bundle/web-dist/client/assets/index-B5DSo5uH.css +0 -1
- package/bundle/web-dist/client/assets/logs-CtmOVH58.js +0 -1
- package/bundle/web-dist/client/assets/routes-Cfoe9J0I.js +0 -5
- package/bundle/web-dist/client/assets/tokens-BAZq8sTo.js +0 -1
- package/bundle/web-dist/server/assets/shim-B9ZrFt3Z.js +0 -129
- /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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
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 =
|
|
458
|
+
const refreshArg = firstRefreshTokenArg();
|
|
291
459
|
expect(refreshArg.provider).toBe("anthropic");
|
|
292
460
|
expect(refreshArg.id).toBe(1);
|
|
293
|
-
expect(
|
|
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(
|
|
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(
|
|
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<{
|
|
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))
|
|
93
|
-
|
|
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(
|
|
99
|
-
|
|
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(
|
|
103
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
});
|