@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.
Files changed (81) hide show
  1. package/README.ko.md +2 -0
  2. package/README.md +2 -0
  3. package/bundle/dist/application/qgrid/oauth.js +21 -3
  4. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +40 -6
  5. package/bundle/dist/application/qgrid/qgrid.frame.js +47 -19
  6. package/bundle/dist/application/qgrid/qgrid.types.js +13 -3
  7. package/bundle/dist/application/qgrid/token-death.js +77 -0
  8. package/bundle/dist/application/queries.generated.js +3 -2
  9. package/bundle/dist/application/request-log/request-log.model.js +105 -31
  10. package/bundle/dist/application/request-log-step/request-log-step.model.js +1 -1
  11. package/bundle/dist/application/sonamu.generated.js +60 -5
  12. package/bundle/dist/application/sonamu.generated.sso.js +18 -2
  13. package/bundle/dist/application/token/token.model.js +53 -2
  14. package/bundle/dist/i18n/en.js +3 -1
  15. package/bundle/dist/i18n/ko.js +3 -1
  16. package/bundle/dist/i18n/sd.generated.js +18 -1
  17. package/bundle/dist/migrations/20260807133553_manual__backfill_request_log_cost.js +49 -0
  18. package/bundle/dist/migrations/20260807145144_alter_request_logs_alter1.js +13 -0
  19. package/bundle/dist/migrations/20260807164057_alter_request_logs_alter1.js +13 -0
  20. package/bundle/dist/server-error-handler.js +5 -3
  21. package/bundle/dist/sonamu.config.js +5 -1
  22. package/bundle/dist/utils/providers/openai/openai-refresh.js +12 -1
  23. package/bundle/dist/utils/slack-notify.js +65 -0
  24. package/bundle/src/application/qgrid/oauth.ts +20 -1
  25. package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +15 -3
  26. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +30 -2
  27. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +44 -6
  28. package/bundle/src/application/qgrid/qgrid.frame.ts +67 -47
  29. package/bundle/src/application/qgrid/qgrid.types.ts +10 -0
  30. package/bundle/src/application/qgrid/token-death.test.ts +150 -0
  31. package/bundle/src/application/qgrid/token-death.ts +96 -0
  32. package/bundle/src/application/queries.generated.ts +5 -2
  33. package/bundle/src/application/request-log/request-log.entity.json +46 -7
  34. package/bundle/src/application/request-log/request-log.model.test.ts +153 -42
  35. package/bundle/src/application/request-log/request-log.model.ts +136 -50
  36. package/bundle/src/application/sonamu.generated.http +19 -1
  37. package/bundle/src/application/sonamu.generated.sso.ts +16 -0
  38. package/bundle/src/application/sonamu.generated.ts +58 -3
  39. package/bundle/src/application/token/token.model.test.ts +49 -0
  40. package/bundle/src/application/token/token.model.ts +75 -0
  41. package/bundle/src/i18n/en.ts +3 -0
  42. package/bundle/src/i18n/ko.ts +3 -0
  43. package/bundle/src/i18n/sd.generated.ts +17 -0
  44. package/bundle/src/migrations/20260807133553_manual__backfill_request_log_cost.ts +110 -0
  45. package/bundle/src/migrations/20260807145144_alter_request_logs_alter1.ts +13 -0
  46. package/bundle/src/migrations/20260807164057_alter_request_logs_alter1.ts +13 -0
  47. package/bundle/src/server-error-handler.test.ts +58 -0
  48. package/bundle/src/server-error-handler.ts +16 -5
  49. package/bundle/src/sonamu.config.ts +6 -0
  50. package/bundle/src/utils/providers/openai/openai-refresh.ts +17 -0
  51. package/bundle/src/utils/slack-notify.test.ts +97 -0
  52. package/bundle/src/utils/slack-notify.ts +74 -0
  53. package/bundle/web-dist/client/assets/index-BGQSGMcK.css +1 -0
  54. package/bundle/web-dist/client/assets/index-CnNT0mdq.js +78 -0
  55. package/bundle/web-dist/client/assets/logs-DCY81UWb.js +1 -0
  56. package/bundle/web-dist/client/assets/{monit-CyqMmy1G.js → monit-D5OoAday.js} +1 -1
  57. package/bundle/web-dist/client/assets/routes-B4D9C86u.js +5 -0
  58. package/bundle/web-dist/client/assets/{sd.generated-CjW3rfJ8.js → sd.generated-DYdwSB2I.js} +1 -1
  59. package/bundle/web-dist/client/assets/{services.generated-BuT6Gp8V.js → services.generated-BsAvotjG.js} +1 -1
  60. package/bundle/web-dist/client/assets/{show-C5WxfoAt.js → show-BSX8ii7Z.js} +3 -3
  61. package/bundle/web-dist/client/assets/tokens-Gapk41cz.js +1 -0
  62. package/bundle/web-dist/client/assets/{use-update-token-mutation-DBkxL29Y.js → use-update-token-mutation-DqEpK7It.js} +1 -1
  63. package/bundle/web-dist/client/index.html +3 -3
  64. package/bundle/web-dist/server/assets/{lazyRouteComponent-PRxXWW3t.js → lazyRouteComponent-BaZaveNc.js} +1 -1
  65. package/bundle/web-dist/server/assets/{logs-D0z1Ghj1.js → logs-DMOw3SjD.js} +91 -42
  66. package/bundle/web-dist/server/assets/{monit-dBXxnAyI.js → monit-UmcqHroX.js} +2 -2
  67. package/bundle/web-dist/server/assets/{routes-B3p7fuwk.js → routes-Sv_4FjtH.js} +52 -46
  68. package/bundle/web-dist/server/assets/{sd.generated-ek-tz-7H.js → sd.generated-B0xqnHEo.js} +21 -0
  69. package/bundle/web-dist/server/assets/{services.generated-BNuxWs7K.js → services.generated-DqAKskAS.js} +23 -8
  70. package/bundle/web-dist/server/assets/{show-0iNioJ2I.js → show-C-oSdzHX.js} +31 -25
  71. package/bundle/web-dist/server/assets/{tokens-CvytQ9Xy.js → tokens-h-2A3wfD.js} +292 -191
  72. package/bundle/web-dist/server/assets/{use-update-token-mutation-BBCC2GKM.js → use-update-token-mutation-DHCaa7Ku.js} +1 -1
  73. package/bundle/web-dist/server/entry-server.generated.js +70 -29
  74. package/package.json +1 -1
  75. package/skills/qgrid/references/cli-env-and-server-boot.md +8 -0
  76. package/skills/qgrid/references/token-auth-quota-lifecycle.md +11 -0
  77. package/bundle/web-dist/client/assets/index-BGGBFmzE.js +0 -78
  78. package/bundle/web-dist/client/assets/index-GA9b-zy4.css +0 -1
  79. package/bundle/web-dist/client/assets/logs-Dh19u6Tk.js +0 -1
  80. package/bundle/web-dist/client/assets/routes-Cy0gCq8c.js +0 -5
  81. 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 = (tokenName?: string): SSRQuery =>
84
- createSSRQuery("QgridFrame", "totalCost", [tokenName], ["Qgrid", "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": { "id-desc": "ID최신순" },
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
- it("uses exact stored cost for rows with a cost source", async () => {
370
- const chain = {
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
- select: vi.fn(async () => [
373
- {
374
- model_name: "claude-opus-4-8",
375
- input_tokens: 1_000_000,
376
- output_tokens: 1_000_000,
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
- chain.where.mockReturnValue(chain);
387
- const from = vi.fn(() => chain);
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 { getPuri: () => { from: typeof from } },
390
- "getPuri",
391
- ).mockReturnValue({ from });
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
- const chain = {
398
- where: vi.fn(),
399
- select: vi.fn(async () => [
400
- {
401
- model_name: "claude-sonnet-4-6",
402
- input_tokens: 100_000,
403
- output_tokens: 0,
404
- cache_read_tokens: 0,
405
- cache_creation_tokens: 80_000,
406
- cache_creation_5m_tokens: 30_000,
407
- cache_creation_1h_tokens: 50_000,
408
- cost_usd: null,
409
- cost_source: null,
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
- @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"], resourceName: "RequestLogs" })
199
- async findMany<T extends RequestLogSubsetKey, LP extends RequestLogListParams>(
200
- subset: T,
201
- rawParams?: LP,
202
- ): Promise<ListResult<LP, RequestLogSubsetMapping[T]>> {
203
- const params = {
204
- num: 24,
205
- page: 1,
206
- search: "id" as const,
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
- // orderBy
246
- if (params.orderBy) {
247
- // default orderBy
248
- if (params.orderBy === "id-desc") {
249
- qb.orderBy("request_logs.id", "desc");
250
- } else {
251
- exhaustive(params.orderBy);
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
- // Sonamu findMany는 subset 전체 컬럼(text 포함)을 페치하므로 비용 계산에 필요한 컬럼만 조회한다.
610
- async totalCost(params: { token_name?: string } = {}): Promise<number> {
611
- // 기존 cost_usd 에는 과거 Anthropic cache 계산 버그로 음수가 저장된 row 가 있을 수 있다.
612
- // 화면 집계는 저장값 sum 이 아니라 현재 계산식으로 usage 를 재계산해 과거 로그도 즉시 보정한다.
613
- const rows = await this.getPuri("r")
614
- .from("request_logs")
615
- .where(params.token_name ? { token_name: params.token_name } : {})
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
- model_name: "model_name",
618
- input_tokens: "input_tokens",
619
- output_tokens: "output_tokens",
620
- cache_read_tokens: "cache_read_tokens",
621
- cache_creation_tokens: "cache_creation_tokens",
622
- cache_creation_5m_tokens: "cache_creation_5m_tokens",
623
- cache_creation_1h_tokens: "cache_creation_1h_tokens",
624
- cost_usd: "cost_usd",
625
- cost_source: "cost_source",
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
- return rows.reduce((sum, row) => {
629
- // cost_source 가 있으면 새 계약으로 확정 저장된 값이다. 프로모션/가격 변경 이후에도
630
- // 당시 비용을 유지한다. NULL인 legacy row 만 현재 가격표로 보정한다.
631
- if (row.cost_source && row.cost_usd !== null) {
632
- return sum + Math.max(row.cost_usd, 0) / MICRO_USD;
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 + Math.max(row.cost_usd ?? 0, 0) / MICRO_USD;
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
- }, 0);
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");