@cartanova/qgrid-cli 2.6.1 → 2.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +2 -0
- package/README.md +2 -0
- package/bundle/dist/application/qgrid/oauth.js +21 -3
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +40 -6
- package/bundle/dist/application/qgrid/qgrid.frame.js +47 -19
- package/bundle/dist/application/qgrid/qgrid.types.js +13 -3
- package/bundle/dist/application/qgrid/token-death.js +77 -0
- package/bundle/dist/application/queries.generated.js +3 -2
- package/bundle/dist/application/request-log/request-log.model.js +105 -31
- package/bundle/dist/application/request-log-step/request-log-step.model.js +1 -1
- package/bundle/dist/application/sonamu.generated.js +60 -5
- package/bundle/dist/application/sonamu.generated.sso.js +18 -2
- package/bundle/dist/application/token/token.model.js +53 -2
- package/bundle/dist/i18n/en.js +3 -1
- package/bundle/dist/i18n/ko.js +3 -1
- package/bundle/dist/i18n/sd.generated.js +18 -1
- package/bundle/dist/migrations/20260807133553_manual__backfill_request_log_cost.js +49 -0
- package/bundle/dist/migrations/20260807145144_alter_request_logs_alter1.js +13 -0
- package/bundle/dist/migrations/20260807164057_alter_request_logs_alter1.js +13 -0
- package/bundle/dist/server-error-handler.js +5 -3
- package/bundle/dist/sonamu.config.js +5 -1
- package/bundle/dist/utils/providers/openai/openai-refresh.js +12 -1
- package/bundle/dist/utils/slack-notify.js +65 -0
- package/bundle/src/application/qgrid/oauth.ts +20 -1
- package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +15 -3
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +30 -2
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +44 -6
- package/bundle/src/application/qgrid/qgrid.frame.ts +67 -47
- package/bundle/src/application/qgrid/qgrid.types.ts +10 -0
- package/bundle/src/application/qgrid/token-death.test.ts +150 -0
- package/bundle/src/application/qgrid/token-death.ts +96 -0
- package/bundle/src/application/queries.generated.ts +5 -2
- package/bundle/src/application/request-log/request-log.entity.json +46 -7
- package/bundle/src/application/request-log/request-log.model.test.ts +153 -42
- package/bundle/src/application/request-log/request-log.model.ts +136 -50
- package/bundle/src/application/sonamu.generated.http +19 -1
- package/bundle/src/application/sonamu.generated.sso.ts +16 -0
- package/bundle/src/application/sonamu.generated.ts +58 -3
- package/bundle/src/application/token/token.model.test.ts +49 -0
- package/bundle/src/application/token/token.model.ts +75 -0
- package/bundle/src/i18n/en.ts +3 -0
- package/bundle/src/i18n/ko.ts +3 -0
- package/bundle/src/i18n/sd.generated.ts +17 -0
- package/bundle/src/migrations/20260807133553_manual__backfill_request_log_cost.ts +110 -0
- package/bundle/src/migrations/20260807145144_alter_request_logs_alter1.ts +13 -0
- package/bundle/src/migrations/20260807164057_alter_request_logs_alter1.ts +13 -0
- package/bundle/src/server-error-handler.test.ts +58 -0
- package/bundle/src/server-error-handler.ts +16 -5
- package/bundle/src/sonamu.config.ts +6 -0
- package/bundle/src/utils/providers/openai/openai-refresh.ts +17 -0
- package/bundle/src/utils/slack-notify.test.ts +97 -0
- package/bundle/src/utils/slack-notify.ts +74 -0
- package/bundle/web-dist/client/assets/index-BGQSGMcK.css +1 -0
- package/bundle/web-dist/client/assets/index-CnNT0mdq.js +78 -0
- package/bundle/web-dist/client/assets/logs-DCY81UWb.js +1 -0
- package/bundle/web-dist/client/assets/{monit-CyqMmy1G.js → monit-D5OoAday.js} +1 -1
- package/bundle/web-dist/client/assets/routes-B4D9C86u.js +5 -0
- package/bundle/web-dist/client/assets/{sd.generated-CjW3rfJ8.js → sd.generated-DYdwSB2I.js} +1 -1
- package/bundle/web-dist/client/assets/{services.generated-BuT6Gp8V.js → services.generated-BsAvotjG.js} +1 -1
- package/bundle/web-dist/client/assets/{show-C5WxfoAt.js → show-BSX8ii7Z.js} +3 -3
- package/bundle/web-dist/client/assets/tokens-Gapk41cz.js +1 -0
- package/bundle/web-dist/client/assets/{use-update-token-mutation-DBkxL29Y.js → use-update-token-mutation-DqEpK7It.js} +1 -1
- package/bundle/web-dist/client/index.html +3 -3
- package/bundle/web-dist/server/assets/{lazyRouteComponent-PRxXWW3t.js → lazyRouteComponent-BaZaveNc.js} +1 -1
- package/bundle/web-dist/server/assets/{logs-D0z1Ghj1.js → logs-DMOw3SjD.js} +91 -42
- package/bundle/web-dist/server/assets/{monit-dBXxnAyI.js → monit-UmcqHroX.js} +2 -2
- package/bundle/web-dist/server/assets/{routes-B3p7fuwk.js → routes-Sv_4FjtH.js} +52 -46
- package/bundle/web-dist/server/assets/{sd.generated-ek-tz-7H.js → sd.generated-B0xqnHEo.js} +21 -0
- package/bundle/web-dist/server/assets/{services.generated-BNuxWs7K.js → services.generated-DqAKskAS.js} +23 -8
- package/bundle/web-dist/server/assets/{show-0iNioJ2I.js → show-C-oSdzHX.js} +31 -25
- package/bundle/web-dist/server/assets/{tokens-CvytQ9Xy.js → tokens-h-2A3wfD.js} +292 -191
- package/bundle/web-dist/server/assets/{use-update-token-mutation-BBCC2GKM.js → use-update-token-mutation-DHCaa7Ku.js} +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +70 -29
- package/package.json +1 -1
- package/skills/qgrid/references/cli-env-and-server-boot.md +8 -0
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +11 -0
- package/bundle/web-dist/client/assets/index-BGGBFmzE.js +0 -78
- package/bundle/web-dist/client/assets/index-GA9b-zy4.css +0 -1
- package/bundle/web-dist/client/assets/logs-Dh19u6Tk.js +0 -1
- package/bundle/web-dist/client/assets/routes-Cy0gCq8c.js +0 -5
- package/bundle/web-dist/client/assets/tokens-Ciolqw4l.js +0 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 세션 만료(auth-dead) 토큰의 라우팅 제외 지점.
|
|
3
|
+
*
|
|
4
|
+
* refresh 가 재로그인 외 복구 불가한 실패를 반환한 순간에만 호출된다. 비활성화는
|
|
5
|
+
* `active=true` 조건부 갱신 1회이고, 그 갱신을 이긴 프로세스만 Slack 알림을 보낸다 —
|
|
6
|
+
* 공유 DB 를 쓰는 인스턴스가 몇이든 사건당 알림은 1건이다.
|
|
7
|
+
*/
|
|
8
|
+
import { getLogger } from "@logtape/logtape";
|
|
9
|
+
|
|
10
|
+
import { getRefreshToken } from "../../utils/providers/common/credentials";
|
|
11
|
+
import { notifySlack, SLACK_COLOR } from "../../utils/slack-notify";
|
|
12
|
+
import { TokenModel } from "../token/token.model";
|
|
13
|
+
|
|
14
|
+
const logger = getLogger(["qgrid", "token-death"]);
|
|
15
|
+
|
|
16
|
+
type DeadTokenRef = {
|
|
17
|
+
id: number;
|
|
18
|
+
name: string;
|
|
19
|
+
provider: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param usedRefreshToken 실패한 요청이 실제로 사용한 refresh token.
|
|
24
|
+
* refresh token 은 회전 저장되므로, 이미 회전된 옛 토큰으로 늦게 도착한 재시도가
|
|
25
|
+
* 400 을 받는 것은 정상 레이스다. DB 의 현재 값과 다르면 사망으로 보지 않는다.
|
|
26
|
+
* @param reasonCode `provider:code` 형태의 내부 코드. provider 응답 본문에는 계정 이메일이나
|
|
27
|
+
* 토큰 조각이 섞일 수 있어 raw 메시지를 그대로 알림에 싣지 않는다.
|
|
28
|
+
*/
|
|
29
|
+
export async function deactivateAuthDeadToken(
|
|
30
|
+
token: DeadTokenRef,
|
|
31
|
+
usedRefreshToken: string,
|
|
32
|
+
reasonCode: string,
|
|
33
|
+
): Promise<boolean> {
|
|
34
|
+
const current = await TokenModel.findOne("A", { id: token.id });
|
|
35
|
+
if (!current) return false;
|
|
36
|
+
|
|
37
|
+
const currentRefreshToken = getRefreshToken(current.credentials);
|
|
38
|
+
if (currentRefreshToken && currentRefreshToken !== usedRefreshToken) {
|
|
39
|
+
logger.info(
|
|
40
|
+
`skipping auth-death for ${token.name}(id=${token.id}): refresh token rotated since this attempt`,
|
|
41
|
+
);
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const { deactivated, keptAsLastActive } = await TokenModel.deactivateIfActive(token.id);
|
|
46
|
+
if (!deactivated) {
|
|
47
|
+
if (keptAsLastActive) notifyLastActiveTokenDying(token, reasonCode);
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
logger.warn(`token deactivated (auth dead): ${token.name}(id=${token.id}) reason=${reasonCode}`);
|
|
52
|
+
void notifySlack({
|
|
53
|
+
title: "세션 만료",
|
|
54
|
+
subject: token.name,
|
|
55
|
+
context: `${token.provider} · ${reasonCode} · 재로그인이 필요합니다`,
|
|
56
|
+
color: SLACK_COLOR.bad,
|
|
57
|
+
});
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 마지막 활성 토큰이 죽었을 때의 알림 쿨다운.
|
|
63
|
+
*
|
|
64
|
+
* 이 경우 토큰은 비활성화되지 않고 살아남으므로, 뒤따르는 요청마다 같은 실패를 반복해
|
|
65
|
+
* 알림이 폭주한다. 비활성화 성공 경로가 조건부 UPDATE 로 중복을 막는 것과 달리 여기엔
|
|
66
|
+
* 상태 변화가 없어 DB 로 억제할 수 없다 — provider 단위로 시간 창을 둔다.
|
|
67
|
+
*/
|
|
68
|
+
const LAST_ACTIVE_ALERT_COOLDOWN_MS = 30 * 60 * 1000;
|
|
69
|
+
const lastActiveAlertAt = new Map<string, number>();
|
|
70
|
+
|
|
71
|
+
function notifyLastActiveTokenDying(token: DeadTokenRef, reasonCode: string): void {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const previous = lastActiveAlertAt.get(token.provider);
|
|
74
|
+
if (previous !== undefined && now - previous < LAST_ACTIVE_ALERT_COOLDOWN_MS) return;
|
|
75
|
+
lastActiveAlertAt.set(token.provider, now);
|
|
76
|
+
|
|
77
|
+
void notifySlack({
|
|
78
|
+
title: "마지막 토큰 사망",
|
|
79
|
+
subject: token.name,
|
|
80
|
+
context:
|
|
81
|
+
`${token.provider} · ${reasonCode} · 풀이 비지 않도록 유지 중입니다. ` +
|
|
82
|
+
`전 토큰이 동시에 실패했다면 client_id 취소나 OAuth 계약 변경을 의심하세요`,
|
|
83
|
+
color: SLACK_COLOR.bad,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 로그인이 완료돼 토큰이 저장됐을 때. 신규·재로그인을 가리지 않는다. */
|
|
88
|
+
export function notifyTokenAdded(name: string, provider: string): void {
|
|
89
|
+
logger.info(`token added: ${name} (${provider})`);
|
|
90
|
+
void notifySlack({
|
|
91
|
+
title: "토큰 추가",
|
|
92
|
+
subject: name,
|
|
93
|
+
context: `${provider} · 요청 처리에 사용됩니다`,
|
|
94
|
+
color: SLACK_COLOR.good,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -80,12 +80,15 @@ export namespace QgridService {
|
|
|
80
80
|
export const stats = (): SSRQuery =>
|
|
81
81
|
createSSRQuery("QgridFrame", "stats", [], ["Qgrid", "stats"]);
|
|
82
82
|
|
|
83
|
-
export const totalCost = (
|
|
84
|
-
createSSRQuery("QgridFrame", "totalCost", [
|
|
83
|
+
export const totalCost = (params?: RequestLogListParams): SSRQuery =>
|
|
84
|
+
createSSRQuery("QgridFrame", "totalCost", [params], ["Qgrid", "totalCost"]);
|
|
85
85
|
|
|
86
86
|
export const projectNames = (): SSRQuery =>
|
|
87
87
|
createSSRQuery("QgridFrame", "projectNames", [], ["Qgrid", "projectNames"]);
|
|
88
88
|
|
|
89
|
+
export const modelNames = (): SSRQuery =>
|
|
90
|
+
createSSRQuery("QgridFrame", "modelNames", [], ["Qgrid", "modelNames"]);
|
|
91
|
+
|
|
89
92
|
export const usage = (tokenId?: number): SSRQuery =>
|
|
90
93
|
createSSRQuery("QgridFrame", "usage", [tokenId], ["Qgrid", "usage"]);
|
|
91
94
|
|
|
@@ -36,12 +36,7 @@
|
|
|
36
36
|
"length": 255,
|
|
37
37
|
"nullable": true
|
|
38
38
|
},
|
|
39
|
-
{
|
|
40
|
-
"name": "fallback_count",
|
|
41
|
-
"type": "integer",
|
|
42
|
-
"nullable": true,
|
|
43
|
-
"desc": "모델 fallback 횟수"
|
|
44
|
-
},
|
|
39
|
+
{ "name": "fallback_count", "type": "integer", "nullable": true, "desc": "모델 fallback 횟수" },
|
|
45
40
|
{ "name": "user_prompt", "type": "string", "nullable": true, "desc": "사용자 프롬프트" },
|
|
46
41
|
{ "name": "system_prompt", "type": "string", "nullable": true, "desc": "시스템 프롬프트" },
|
|
47
42
|
{ "name": "response", "desc": "llm 응답 전문", "type": "string", "dbDefault": "\"\"" },
|
|
@@ -132,6 +127,18 @@
|
|
|
132
127
|
"name": "request_logs_project_name_index",
|
|
133
128
|
"columns": [{ "name": "project_name" }],
|
|
134
129
|
"using": "btree"
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"type": "index",
|
|
133
|
+
"name": "request_logs_project_name_cost_usd_index",
|
|
134
|
+
"columns": [{ "name": "project_name" }, { "name": "cost_usd" }],
|
|
135
|
+
"using": "btree"
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"type": "index",
|
|
139
|
+
"name": "request_logs_model_name_index",
|
|
140
|
+
"columns": [{ "name": "model_name" }],
|
|
141
|
+
"using": "btree"
|
|
135
142
|
}
|
|
136
143
|
],
|
|
137
144
|
"subsets": {
|
|
@@ -190,10 +197,42 @@
|
|
|
190
197
|
"status",
|
|
191
198
|
"tool_call_count",
|
|
192
199
|
"is_image_generation"
|
|
200
|
+
],
|
|
201
|
+
"C": [
|
|
202
|
+
"id",
|
|
203
|
+
"token_name",
|
|
204
|
+
"model_name",
|
|
205
|
+
"input_tokens",
|
|
206
|
+
"output_tokens",
|
|
207
|
+
"cache_read_tokens",
|
|
208
|
+
"cache_creation_tokens",
|
|
209
|
+
"cache_creation_5m_tokens",
|
|
210
|
+
"cache_creation_1h_tokens",
|
|
211
|
+
"cost_usd",
|
|
212
|
+
"cost_source"
|
|
193
213
|
]
|
|
194
214
|
},
|
|
195
215
|
"enums": {
|
|
196
|
-
"RequestLogOrderBy": {
|
|
216
|
+
"RequestLogOrderBy": {
|
|
217
|
+
"id-desc": "ID최신순",
|
|
218
|
+
"id-asc": "ID오래된순",
|
|
219
|
+
"created_at-desc": "등록일시최신순",
|
|
220
|
+
"created_at-asc": "등록일시오래된순",
|
|
221
|
+
"ttft_ms-desc": "TTFT긴순",
|
|
222
|
+
"ttft_ms-asc": "TTFT짧은순",
|
|
223
|
+
"duration_ms-desc": "소요시간긴순",
|
|
224
|
+
"duration_ms-asc": "소요시간짧은순",
|
|
225
|
+
"input_tokens-desc": "입력토큰많은순",
|
|
226
|
+
"input_tokens-asc": "입력토큰적은순",
|
|
227
|
+
"output_tokens-desc": "출력토큰많은순",
|
|
228
|
+
"output_tokens-asc": "출력토큰적은순",
|
|
229
|
+
"cache_read_tokens-desc": "캐시읽기많은순",
|
|
230
|
+
"cache_read_tokens-asc": "캐시읽기적은순",
|
|
231
|
+
"cache_creation_tokens-desc": "캐시생성많은순",
|
|
232
|
+
"cache_creation_tokens-asc": "캐시생성적은순",
|
|
233
|
+
"cost_usd-desc": "비용높은순",
|
|
234
|
+
"cost_usd-asc": "비용낮은순"
|
|
235
|
+
},
|
|
197
236
|
"RequestLogSearchField": {
|
|
198
237
|
"id": "ID",
|
|
199
238
|
"token_name": "토큰이름",
|
|
@@ -366,59 +366,170 @@ describe("RequestLogModel cost provenance", () => {
|
|
|
366
366
|
vi.restoreAllMocks();
|
|
367
367
|
});
|
|
368
368
|
|
|
369
|
-
|
|
370
|
-
|
|
369
|
+
/**
|
|
370
|
+
* totalCost 는 확정 비용(SQL SUM)과 legacy 재계산(JS) 두 쿼리로 나뉜다.
|
|
371
|
+
* @param confirmedTotal SUM 쿼리가 돌려줄 micro-USD 합계
|
|
372
|
+
* @param legacyRows 재계산 대상 row 들
|
|
373
|
+
*/
|
|
374
|
+
function mockTotalCostQueries(confirmedTotal: number, legacyRows: unknown[]) {
|
|
375
|
+
// subset qb 는 SELECT 를 비우고(clear) 집계/축소된 컬럼만 다시 얹는다 — 구현과 같은 체인.
|
|
376
|
+
const sumChain = {
|
|
371
377
|
where: vi.fn(),
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
cache_read_tokens: 0,
|
|
378
|
-
cache_creation_tokens: 0,
|
|
379
|
-
cache_creation_5m_tokens: null,
|
|
380
|
-
cache_creation_1h_tokens: null,
|
|
381
|
-
cost_usd: 3_000,
|
|
382
|
-
cost_source: "provider",
|
|
383
|
-
},
|
|
384
|
-
]),
|
|
378
|
+
whereIn: vi.fn(),
|
|
379
|
+
whereRaw: vi.fn(),
|
|
380
|
+
clear: vi.fn(),
|
|
381
|
+
select: vi.fn(),
|
|
382
|
+
first: vi.fn(async () => ({ total: confirmedTotal })),
|
|
385
383
|
};
|
|
386
|
-
|
|
387
|
-
|
|
384
|
+
sumChain.where.mockReturnValue(sumChain);
|
|
385
|
+
sumChain.whereIn.mockReturnValue(sumChain);
|
|
386
|
+
sumChain.whereRaw.mockReturnValue(sumChain);
|
|
387
|
+
sumChain.clear.mockReturnValue(sumChain);
|
|
388
|
+
sumChain.select.mockReturnValue(sumChain);
|
|
389
|
+
|
|
390
|
+
const legacyChain = {
|
|
391
|
+
where: vi.fn(),
|
|
392
|
+
whereIn: vi.fn(),
|
|
393
|
+
whereRaw: vi.fn(),
|
|
394
|
+
clear: vi.fn(),
|
|
395
|
+
select: vi.fn(async () => legacyRows),
|
|
396
|
+
};
|
|
397
|
+
legacyChain.where.mockReturnValue(legacyChain);
|
|
398
|
+
legacyChain.whereIn.mockReturnValue(legacyChain);
|
|
399
|
+
legacyChain.whereRaw.mockReturnValue(legacyChain);
|
|
400
|
+
legacyChain.clear.mockReturnValue(legacyChain);
|
|
401
|
+
|
|
402
|
+
const getSubsetQueries = vi
|
|
403
|
+
.fn()
|
|
404
|
+
.mockReturnValueOnce({ qb: sumChain })
|
|
405
|
+
.mockReturnValueOnce({ qb: legacyChain });
|
|
388
406
|
vi.spyOn(
|
|
389
|
-
RequestLogModel as unknown as {
|
|
390
|
-
"
|
|
391
|
-
).
|
|
407
|
+
RequestLogModel as unknown as { getSubsetQueries: typeof getSubsetQueries },
|
|
408
|
+
"getSubsetQueries",
|
|
409
|
+
).mockImplementation(getSubsetQueries);
|
|
410
|
+
return { sumChain, legacyChain };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
it("uses exact stored cost for rows with a cost source", async () => {
|
|
414
|
+
mockTotalCostQueries(3_000, []);
|
|
392
415
|
|
|
393
416
|
await expect(RequestLogModel.totalCost()).resolves.toBe(0.003);
|
|
394
417
|
});
|
|
395
418
|
|
|
419
|
+
it("sums every row in SQL without a cost_source predicate", async () => {
|
|
420
|
+
const { sumChain, legacyChain } = mockTotalCostQueries(3_000, []);
|
|
421
|
+
|
|
422
|
+
await RequestLogModel.totalCost();
|
|
423
|
+
|
|
424
|
+
expect(sumChain.select).toHaveBeenCalledTimes(1);
|
|
425
|
+
// 조건을 걸면 cost_source 가 인덱스에 없어 커버링 인덱스가 무효화된다.
|
|
426
|
+
expect(sumChain.whereRaw).not.toHaveBeenCalled();
|
|
427
|
+
expect(legacyChain.whereRaw).toHaveBeenCalledWith(
|
|
428
|
+
"request_logs.cost_source IS NULL OR request_logs.cost_usd IS NULL",
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
|
|
396
432
|
it("keeps TTL-aware price-table recomputation for legacy rows", async () => {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
},
|
|
411
|
-
]),
|
|
412
|
-
};
|
|
413
|
-
chain.where.mockReturnValue(chain);
|
|
414
|
-
const from = vi.fn(() => chain);
|
|
415
|
-
vi.spyOn(
|
|
416
|
-
RequestLogModel as unknown as { getPuri: () => { from: typeof from } },
|
|
417
|
-
"getPuri",
|
|
418
|
-
).mockReturnValue({ from });
|
|
433
|
+
mockTotalCostQueries(0, [
|
|
434
|
+
{
|
|
435
|
+
model_name: "claude-sonnet-4-6",
|
|
436
|
+
input_tokens: 100_000,
|
|
437
|
+
output_tokens: 0,
|
|
438
|
+
cache_read_tokens: 0,
|
|
439
|
+
cache_creation_tokens: 80_000,
|
|
440
|
+
cache_creation_5m_tokens: 30_000,
|
|
441
|
+
cache_creation_1h_tokens: 50_000,
|
|
442
|
+
cost_usd: null,
|
|
443
|
+
cost_source: null,
|
|
444
|
+
},
|
|
445
|
+
]);
|
|
419
446
|
|
|
420
447
|
await expect(RequestLogModel.totalCost()).resolves.toBeCloseTo(0.4725, 10);
|
|
421
448
|
});
|
|
449
|
+
|
|
450
|
+
it("adds only the delta for legacy rows already counted in the SQL sum", async () => {
|
|
451
|
+
// 전체 SUM 이 저장값 0.2 를 이미 포함하므로, 재계산값 0.4725 와의 차액만 더해야 한다.
|
|
452
|
+
mockTotalCostQueries(200_000, [
|
|
453
|
+
{
|
|
454
|
+
model_name: "claude-sonnet-4-6",
|
|
455
|
+
input_tokens: 100_000,
|
|
456
|
+
output_tokens: 0,
|
|
457
|
+
cache_read_tokens: 0,
|
|
458
|
+
cache_creation_tokens: 80_000,
|
|
459
|
+
cache_creation_5m_tokens: 30_000,
|
|
460
|
+
cache_creation_1h_tokens: 50_000,
|
|
461
|
+
cost_usd: 200_000,
|
|
462
|
+
cost_source: null,
|
|
463
|
+
},
|
|
464
|
+
]);
|
|
465
|
+
|
|
466
|
+
await expect(RequestLogModel.totalCost()).resolves.toBeCloseTo(0.4725, 10);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("leaves the stored value untouched for legacy rows without a model name", async () => {
|
|
470
|
+
// 모델을 모르면 재계산할 수 없다. 저장값이 SUM 에 이미 들어갔으므로 보정하지 않는다.
|
|
471
|
+
mockTotalCostQueries(5_000, [
|
|
472
|
+
{
|
|
473
|
+
model_name: null,
|
|
474
|
+
input_tokens: 100,
|
|
475
|
+
output_tokens: 100,
|
|
476
|
+
cache_read_tokens: 0,
|
|
477
|
+
cache_creation_tokens: 0,
|
|
478
|
+
cache_creation_5m_tokens: null,
|
|
479
|
+
cache_creation_1h_tokens: null,
|
|
480
|
+
cost_usd: 5_000,
|
|
481
|
+
cost_source: null,
|
|
482
|
+
},
|
|
483
|
+
]);
|
|
484
|
+
|
|
485
|
+
await expect(RequestLogModel.totalCost()).resolves.toBeCloseTo(0.005, 10);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("applies list filters to both cost queries so totals match the filtered list", async () => {
|
|
489
|
+
const { sumChain, legacyChain } = mockTotalCostQueries(0, []);
|
|
490
|
+
|
|
491
|
+
await RequestLogModel.totalCost({
|
|
492
|
+
num: 0,
|
|
493
|
+
page: 1,
|
|
494
|
+
project_name: "deti",
|
|
495
|
+
token_name: "openai/yds",
|
|
496
|
+
model_name: "openai/gpt-5.5",
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
for (const chain of [sumChain, legacyChain]) {
|
|
500
|
+
expect(chain.where).toHaveBeenCalledWith("request_logs.project_name", "deti");
|
|
501
|
+
expect(chain.where).toHaveBeenCalledWith("request_logs.token_name", "openai/yds");
|
|
502
|
+
expect(chain.where).toHaveBeenCalledWith("request_logs.model_name", "openai/gpt-5.5");
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("applies the unassigned-project filter to both cost queries", async () => {
|
|
507
|
+
const { sumChain, legacyChain } = mockTotalCostQueries(0, []);
|
|
508
|
+
|
|
509
|
+
await RequestLogModel.totalCost({ num: 0, page: 1, project_name_is_null: true });
|
|
510
|
+
|
|
511
|
+
for (const chain of [sumChain, legacyChain]) {
|
|
512
|
+
expect(chain.where).toHaveBeenCalledWith("request_logs.project_name", null);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("combines the SQL-summed total with the legacy recomputation delta", async () => {
|
|
517
|
+
mockTotalCostQueries(3_000, [
|
|
518
|
+
{
|
|
519
|
+
model_name: "claude-sonnet-4-6",
|
|
520
|
+
input_tokens: 100_000,
|
|
521
|
+
output_tokens: 0,
|
|
522
|
+
cache_read_tokens: 0,
|
|
523
|
+
cache_creation_tokens: 80_000,
|
|
524
|
+
cache_creation_5m_tokens: 30_000,
|
|
525
|
+
cache_creation_1h_tokens: 50_000,
|
|
526
|
+
cost_usd: null,
|
|
527
|
+
cost_source: null,
|
|
528
|
+
},
|
|
529
|
+
]);
|
|
530
|
+
|
|
531
|
+
await expect(RequestLogModel.totalCost()).resolves.toBeCloseTo(0.4755, 10);
|
|
532
|
+
});
|
|
422
533
|
});
|
|
423
534
|
|
|
424
535
|
describe("RequestLogModel PostgreSQL run lock", () => {
|
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
asArray,
|
|
4
4
|
BadRequestException,
|
|
5
5
|
BaseModelClass,
|
|
6
|
-
exhaustive,
|
|
7
6
|
type ListResult,
|
|
8
7
|
NotFoundException,
|
|
9
8
|
Puri,
|
|
@@ -152,6 +151,43 @@ function normalizeLegacyAnthropicRow<T extends RequestLogUsageRow>(row: T): T {
|
|
|
152
151
|
return normalized;
|
|
153
152
|
}
|
|
154
153
|
|
|
154
|
+
/**
|
|
155
|
+
* `orderBy` enum 값(`컬럼-방향`)을 정렬 절로 옮긴다.
|
|
156
|
+
*
|
|
157
|
+
* enum 이 18 종이라 케이스를 일일이 적는 대신 파싱하되, 컬럼은 아래 목록으로 한정한다 —
|
|
158
|
+
* 문자열을 그대로 컬럼명에 넘기면 enum 이 바뀔 때 검증 없는 식별자가 SQL 로 흘러든다.
|
|
159
|
+
* 목록에 없는 값은 기본 정렬(id desc)로 떨어진다.
|
|
160
|
+
*/
|
|
161
|
+
const ORDERABLE_COLUMNS = {
|
|
162
|
+
id: "request_logs.id",
|
|
163
|
+
created_at: "request_logs.created_at",
|
|
164
|
+
ttft_ms: "request_logs.ttft_ms",
|
|
165
|
+
duration_ms: "request_logs.duration_ms",
|
|
166
|
+
input_tokens: "request_logs.input_tokens",
|
|
167
|
+
output_tokens: "request_logs.output_tokens",
|
|
168
|
+
cache_read_tokens: "request_logs.cache_read_tokens",
|
|
169
|
+
cache_creation_tokens: "request_logs.cache_creation_tokens",
|
|
170
|
+
cost_usd: "request_logs.cost_usd",
|
|
171
|
+
} as const;
|
|
172
|
+
|
|
173
|
+
function applyOrderBy(
|
|
174
|
+
qb: ReturnType<RequestLogModelClass["getSubsetQueries"]>["qb"],
|
|
175
|
+
orderBy: string,
|
|
176
|
+
): void {
|
|
177
|
+
const separator = orderBy.lastIndexOf("-");
|
|
178
|
+
const key = orderBy.slice(0, separator) as keyof typeof ORDERABLE_COLUMNS;
|
|
179
|
+
const direction = orderBy.slice(separator + 1) === "asc" ? "asc" : "desc";
|
|
180
|
+
const column = ORDERABLE_COLUMNS[key];
|
|
181
|
+
|
|
182
|
+
if (!column) {
|
|
183
|
+
qb.orderBy("request_logs.id", "desc");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
qb.orderBy(column, direction);
|
|
187
|
+
// 동점 행의 순서가 페이지마다 흔들리지 않게 고유 컬럼으로 tie-break 한다.
|
|
188
|
+
if (key !== "id") qb.orderBy("request_logs.id", "desc");
|
|
189
|
+
}
|
|
190
|
+
|
|
155
191
|
/*
|
|
156
192
|
RequestLog Model
|
|
157
193
|
*/
|
|
@@ -195,21 +231,15 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
195
231
|
return rows[0] ?? null;
|
|
196
232
|
}
|
|
197
233
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
orderBy: "id-desc" as const,
|
|
208
|
-
...rawParams,
|
|
209
|
-
} satisfies RequestLogListParams;
|
|
210
|
-
|
|
211
|
-
const { qb, onSubset: _ } = this.getSubsetQueries(subset);
|
|
212
|
-
|
|
234
|
+
/**
|
|
235
|
+
* 목록 필터의 단일 정의. findMany 와 totalCost 가 같은 조건을 보도록 공유한다 —
|
|
236
|
+
* 화면에서 필터를 걸면 행 수와 비용이 함께 좁혀져야 하는데, 두 쿼리가 각자 필터를
|
|
237
|
+
* 구현하면 한쪽에 축을 추가할 때 조용히 어긋난다.
|
|
238
|
+
*/
|
|
239
|
+
private applyListFilters(
|
|
240
|
+
qb: ReturnType<RequestLogModelClass["getSubsetQueries"]>["qb"],
|
|
241
|
+
params: RequestLogListParams,
|
|
242
|
+
): void {
|
|
213
243
|
if (params.id) {
|
|
214
244
|
qb.whereIn("request_logs.id", asArray(params.id));
|
|
215
245
|
}
|
|
@@ -241,19 +271,30 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
241
271
|
throw new BadRequestException(SD("error.unknownSearchField")(params.search));
|
|
242
272
|
}
|
|
243
273
|
}
|
|
274
|
+
}
|
|
244
275
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
276
|
+
@api({ httpMethod: "GET", clients: ["axios", "tanstack-query"], resourceName: "RequestLogs" })
|
|
277
|
+
async findMany<T extends RequestLogSubsetKey, LP extends RequestLogListParams>(
|
|
278
|
+
subset: T,
|
|
279
|
+
rawParams?: LP,
|
|
280
|
+
): Promise<ListResult<LP, RequestLogSubsetMapping[T]>> {
|
|
281
|
+
const params = {
|
|
282
|
+
num: 24,
|
|
283
|
+
page: 1,
|
|
284
|
+
search: "id" as const,
|
|
285
|
+
orderBy: "id-desc" as const,
|
|
286
|
+
...rawParams,
|
|
287
|
+
} satisfies RequestLogListParams;
|
|
288
|
+
|
|
289
|
+
const { qb, onSubset: _ } = this.getSubsetQueries(subset);
|
|
290
|
+
|
|
291
|
+
this.applyListFilters(qb, params);
|
|
292
|
+
|
|
293
|
+
if (params.orderBy) applyOrderBy(qb, params.orderBy);
|
|
254
294
|
|
|
255
295
|
const enhancers = this.createEnhancers({
|
|
256
296
|
A: (row) => normalizeLegacyAnthropicRow(row),
|
|
297
|
+
C: (row) => normalizeLegacyAnthropicRow(row),
|
|
257
298
|
});
|
|
258
299
|
|
|
259
300
|
return this.executeSubsetQuery({
|
|
@@ -606,33 +647,65 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
606
647
|
return (row?.max_step ?? -1) + 1;
|
|
607
648
|
}
|
|
608
649
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
650
|
+
/**
|
|
651
|
+
* 화면 표시용 총 비용. 목록과 같은 필터를 적용한다.
|
|
652
|
+
*
|
|
653
|
+
* 전체를 조건 없이 SUM 한 뒤, `cost_source` 가 없는 legacy row 의 차액만 보정한다.
|
|
654
|
+
* 조건 없는 SUM 이어야 `(project_name, cost_usd)` 커버링 인덱스가 Index Only Scan 을
|
|
655
|
+
* 탄다 — `cost_source IS NOT NULL` 을 걸면 그 컬럼이 인덱스에 없어 heap 을 다시 봐야
|
|
656
|
+
* 하므로 플래너가 인덱스를 버리고 seq scan 으로 돌아간다(실측 230ms → 814ms).
|
|
657
|
+
*
|
|
658
|
+
* legacy 보정을 남겨둔 이유: 백필로 기존 legacy 는 0 이 되었지만, 외부 로거가
|
|
659
|
+
* `AppendStepInput`/`FinishRunInput` 으로 costSource 없이 넣으면 다시 생길 수 있다.
|
|
660
|
+
* 그런 row 는 저장값 대신 현재 가격표 재계산값이 맞으므로 차액을 더한다.
|
|
661
|
+
*
|
|
662
|
+
* 필터 조건을 여기서 다시 구현하지 않고 `applyListFilters` 를 공유하는 것이 핵심이다 —
|
|
663
|
+
* 두 쿼리가 각자 필터를 가지면 화면에서 필터를 걸었을 때 행 수와 비용이 조용히 어긋난다.
|
|
664
|
+
*/
|
|
665
|
+
async totalCost(rawParams: RequestLogListParams = { num: 0, page: 1 }): Promise<number> {
|
|
666
|
+
const params = {
|
|
667
|
+
num: 0,
|
|
668
|
+
page: 1,
|
|
669
|
+
search: "id" as const,
|
|
670
|
+
...rawParams,
|
|
671
|
+
} satisfies RequestLogListParams;
|
|
672
|
+
|
|
673
|
+
const { qb: totalQb } = this.getSubsetQueries("C");
|
|
674
|
+
this.applyListFilters(totalQb, params);
|
|
675
|
+
// subset qb 는 컬럼 목록이 이미 SELECT 에 박혀 있어 집계를 얹으면 GROUP BY 에러가 난다.
|
|
676
|
+
// executeCountQuery 와 같은 방식으로 select 를 비우고 집계만 남긴다.
|
|
677
|
+
const stored = await totalQb
|
|
678
|
+
.clear("select")
|
|
679
|
+
// 음수 저장값(과거 버그)은 0 으로 눌러서 더한다 — JS 쪽 Math.max 와 같은 규칙.
|
|
616
680
|
.select({
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
681
|
+
total: Puri.rawNumber("COALESCE(SUM(GREATEST(request_logs.cost_usd, 0)), 0)"),
|
|
682
|
+
})
|
|
683
|
+
.first();
|
|
684
|
+
|
|
685
|
+
const { qb: legacyQb } = this.getSubsetQueries("C");
|
|
686
|
+
this.applyListFilters(legacyQb, params);
|
|
687
|
+
const legacyRows = await legacyQb
|
|
688
|
+
.whereRaw("request_logs.cost_source IS NULL OR request_logs.cost_usd IS NULL")
|
|
689
|
+
.clear("select")
|
|
690
|
+
.select({
|
|
691
|
+
token_name: "request_logs.token_name",
|
|
692
|
+
model_name: "request_logs.model_name",
|
|
693
|
+
input_tokens: "request_logs.input_tokens",
|
|
694
|
+
output_tokens: "request_logs.output_tokens",
|
|
695
|
+
cache_read_tokens: "request_logs.cache_read_tokens",
|
|
696
|
+
cache_creation_tokens: "request_logs.cache_creation_tokens",
|
|
697
|
+
cache_creation_5m_tokens: "request_logs.cache_creation_5m_tokens",
|
|
698
|
+
cache_creation_1h_tokens: "request_logs.cache_creation_1h_tokens",
|
|
699
|
+
cost_usd: "request_logs.cost_usd",
|
|
626
700
|
});
|
|
627
701
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
}
|
|
702
|
+
const storedTotal = Number(stored?.total ?? 0) / MICRO_USD;
|
|
703
|
+
|
|
704
|
+
// legacy row 는 위 SUM 에 저장값으로 이미 포함되어 있다. 재계산값과의 차액만 더한다.
|
|
705
|
+
return legacyRows.reduce((sum, row) => {
|
|
706
|
+
const alreadySummed = Math.max(row.cost_usd ?? 0, 0) / MICRO_USD;
|
|
634
707
|
const usage = normalizedUsageForCost(row);
|
|
635
|
-
if (!usage.model) return sum
|
|
708
|
+
if (!usage.model) return sum;
|
|
636
709
|
return (
|
|
637
710
|
sum +
|
|
638
711
|
calculateCostUsd(usage.model, {
|
|
@@ -642,9 +715,10 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
642
715
|
cacheCreationInputTokens: usage.cacheCreationInputTokens,
|
|
643
716
|
cacheCreationInputTokens5m: usage.cacheCreationInputTokens5m,
|
|
644
717
|
cacheCreationInputTokens1h: usage.cacheCreationInputTokens1h,
|
|
645
|
-
})
|
|
718
|
+
}) -
|
|
719
|
+
alreadySummed
|
|
646
720
|
);
|
|
647
|
-
},
|
|
721
|
+
}, storedTotal);
|
|
648
722
|
}
|
|
649
723
|
|
|
650
724
|
async distinctProjectNames(): Promise<string[]> {
|
|
@@ -659,6 +733,18 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
659
733
|
return rows.map((r) => r.project_name!);
|
|
660
734
|
}
|
|
661
735
|
|
|
736
|
+
async distinctModelNames(): Promise<string[]> {
|
|
737
|
+
const rows = await this.getPuri("r")
|
|
738
|
+
.from("request_logs")
|
|
739
|
+
.distinct("model_name")
|
|
740
|
+
.where("model_name", "!=", null)
|
|
741
|
+
.orderBy("model_name", "asc")
|
|
742
|
+
.select({
|
|
743
|
+
model_name: "model_name",
|
|
744
|
+
});
|
|
745
|
+
return rows.map((r) => r.model_name!);
|
|
746
|
+
}
|
|
747
|
+
|
|
662
748
|
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
663
749
|
async del(ids: number[]): Promise<number> {
|
|
664
750
|
const wdb = this.getPuri("w");
|