@cartanova/qgrid-cli 2.6.0 → 2.6.2

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 (59) hide show
  1. package/README.ko.md +2 -0
  2. package/README.md +2 -0
  3. package/bundle/dist/application/qgrid/oauth.js +22 -3
  4. package/bundle/dist/application/qgrid/qgrid.frame.js +104 -34
  5. package/bundle/dist/application/qgrid/qgrid.types.js +5 -2
  6. package/bundle/dist/application/qgrid/token-death.js +52 -0
  7. package/bundle/dist/application/queries.generated.js +2 -2
  8. package/bundle/dist/application/request-log/request-log.model.js +69 -27
  9. package/bundle/dist/application/sonamu.generated.js +20 -3
  10. package/bundle/dist/application/sonamu.generated.sso.js +18 -2
  11. package/bundle/dist/application/token/token.model.js +42 -1
  12. package/bundle/dist/migrations/20260807133553_manual__backfill_request_log_cost.js +49 -0
  13. package/bundle/dist/migrations/20260807145144_alter_request_logs_alter1.js +13 -0
  14. package/bundle/dist/utils/providers/openai/openai-refresh.js +12 -1
  15. package/bundle/dist/utils/slack-notify.js +65 -0
  16. package/bundle/src/application/qgrid/oauth.ts +23 -1
  17. package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +133 -27
  18. package/bundle/src/application/qgrid/qgrid.frame.ts +132 -66
  19. package/bundle/src/application/qgrid/qgrid.types.ts +3 -0
  20. package/bundle/src/application/qgrid/token-death.test.ts +120 -0
  21. package/bundle/src/application/qgrid/token-death.ts +67 -0
  22. package/bundle/src/application/queries.generated.ts +2 -2
  23. package/bundle/src/application/request-log/request-log.entity.json +20 -6
  24. package/bundle/src/application/request-log/request-log.model.test.ts +153 -42
  25. package/bundle/src/application/request-log/request-log.model.ts +86 -40
  26. package/bundle/src/application/sonamu.generated.http +22 -1
  27. package/bundle/src/application/sonamu.generated.sso.ts +16 -0
  28. package/bundle/src/application/sonamu.generated.ts +16 -1
  29. package/bundle/src/application/token/token.model.test.ts +39 -0
  30. package/bundle/src/application/token/token.model.ts +77 -0
  31. package/bundle/src/migrations/20260807133553_manual__backfill_request_log_cost.ts +110 -0
  32. package/bundle/src/migrations/20260807145144_alter_request_logs_alter1.ts +13 -0
  33. package/bundle/src/utils/providers/openai/openai-refresh.ts +17 -0
  34. package/bundle/src/utils/slack-notify.test.ts +97 -0
  35. package/bundle/src/utils/slack-notify.ts +74 -0
  36. package/bundle/web-dist/client/assets/index-2cAgaLOm.css +1 -0
  37. package/bundle/web-dist/client/assets/{index-YowQRE2y.js → index-BgWzzjvj.js} +3 -3
  38. package/bundle/web-dist/client/assets/logs-Lyv8OfHE.js +1 -0
  39. package/bundle/web-dist/client/assets/{monit-B51yXcqk.js → monit-BSa5lSZp.js} +1 -1
  40. package/bundle/web-dist/client/assets/{routes-BqaUiBc0.js → routes-Cp0re-Nn.js} +2 -2
  41. package/bundle/web-dist/client/assets/{services.generated-D3MH3EfY.js → services.generated-B-dMUtEu.js} +1 -1
  42. package/bundle/web-dist/client/assets/{show-LAFY13v6.js → show-C1jEPhuS.js} +1 -1
  43. package/bundle/web-dist/client/assets/tokens-1e66MAC0.js +1 -0
  44. package/bundle/web-dist/client/assets/{use-update-token-mutation-CLhv6SUL.js → use-update-token-mutation-DueEppqt.js} +1 -1
  45. package/bundle/web-dist/client/index.html +2 -2
  46. package/bundle/web-dist/server/assets/{logs-Dbk2Ig2z.js → logs-CwoOsveV.js} +11 -4
  47. package/bundle/web-dist/server/assets/{monit-Hm9dIPhx.js → monit-DIsEsDJN.js} +1 -1
  48. package/bundle/web-dist/server/assets/{routes-DztxeUjk.js → routes-Bts-KJ9W.js} +7 -3
  49. package/bundle/web-dist/server/assets/{services.generated-1Kcul41h.js → services.generated-Bh7Vs8Ds.js} +16 -7
  50. package/bundle/web-dist/server/assets/{show-BHUSw7qv.js → show-DJfaCNUw.js} +1 -1
  51. package/bundle/web-dist/server/assets/{tokens-DHU9lUT2.js → tokens-BA4vwDat.js} +215 -52
  52. package/bundle/web-dist/server/assets/{use-update-token-mutation-axqnye4L.js → use-update-token-mutation-BQRzEPy2.js} +1 -1
  53. package/bundle/web-dist/server/entry-server.generated.js +6 -6
  54. package/package.json +1 -1
  55. package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
  56. package/skills/qgrid/references/token-auth-quota-lifecycle.md +16 -1
  57. package/bundle/web-dist/client/assets/index-GA9b-zy4.css +0 -1
  58. package/bundle/web-dist/client/assets/logs-o8mm530D.js +0 -1
  59. package/bundle/web-dist/client/assets/tokens-B9Ig7-9Z.js +0 -1
@@ -0,0 +1,120 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { SLACK_COLOR } from "../../utils/slack-notify";
4
+ import { deactivateAuthDeadToken, notifyTokenAdded } from "./token-death";
5
+
6
+ const { findOneMock, deactivateMock, notifySlackMock } = vi.hoisted(() => ({
7
+ findOneMock: vi.fn(),
8
+ deactivateMock: vi.fn(),
9
+ notifySlackMock: vi.fn(),
10
+ }));
11
+
12
+ vi.mock("../token/token.model", () => ({
13
+ TokenModel: { findOne: findOneMock, deactivateIfActive: deactivateMock },
14
+ }));
15
+
16
+ // SLACK_COLOR 는 상수라 가릴 이유가 없다 — 전송 함수만 스텁한다.
17
+ vi.mock("../../utils/slack-notify", async (importOriginal) => ({
18
+ ...(await importOriginal<typeof import("../../utils/slack-notify")>()),
19
+ notifySlack: notifySlackMock,
20
+ }));
21
+
22
+ const token = { id: 7, name: "anthropic/test-token", provider: "anthropic" };
23
+
24
+ function storedRefreshToken(refreshToken: string) {
25
+ return {
26
+ id: 7,
27
+ provider: "anthropic",
28
+ credentials: { accessToken: "at", refreshToken, expiresAt: 0, accountUuid: "acc-1" },
29
+ };
30
+ }
31
+
32
+ describe("deactivateAuthDeadToken", () => {
33
+ afterEach(() => {
34
+ findOneMock.mockReset();
35
+ deactivateMock.mockReset();
36
+ notifySlackMock.mockReset();
37
+ });
38
+
39
+ it("deactivates and notifies when the failed attempt used the current refresh token", async () => {
40
+ findOneMock.mockResolvedValue(storedRefreshToken("rt-current"));
41
+ deactivateMock.mockResolvedValue(true);
42
+
43
+ await expect(
44
+ deactivateAuthDeadToken(token, "rt-current", "anthropic:400"),
45
+ ).resolves.toBe(true);
46
+
47
+ expect(deactivateMock).toHaveBeenCalledWith(7);
48
+ expect(notifySlackMock).toHaveBeenCalledTimes(1);
49
+ const notification = notifySlackMock.mock.calls[0]![0] as {
50
+ subject: string;
51
+ context: string;
52
+ color: string;
53
+ };
54
+ expect(notification.subject).toBe("anthropic/test-token");
55
+ expect(notification.context).toContain("anthropic:400");
56
+ expect(notification.color).toBe(SLACK_COLOR.bad);
57
+ });
58
+
59
+ it("skips deactivation when the refresh token rotated since the failed attempt", async () => {
60
+ findOneMock.mockResolvedValue(storedRefreshToken("rt-rotated"));
61
+
62
+ await expect(deactivateAuthDeadToken(token, "rt-stale", "anthropic:400")).resolves.toBe(false);
63
+
64
+ expect(deactivateMock).not.toHaveBeenCalled();
65
+ expect(notifySlackMock).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it("does not notify when another process won the deactivation race", async () => {
69
+ findOneMock.mockResolvedValue(storedRefreshToken("rt-current"));
70
+ deactivateMock.mockResolvedValue(false);
71
+
72
+ await expect(deactivateAuthDeadToken(token, "rt-current", "anthropic:400")).resolves.toBe(
73
+ false,
74
+ );
75
+
76
+ expect(notifySlackMock).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it("does nothing when the token no longer exists", async () => {
80
+ findOneMock.mockResolvedValue(undefined);
81
+
82
+ await expect(deactivateAuthDeadToken(token, "rt-current", "anthropic:400")).resolves.toBe(
83
+ false,
84
+ );
85
+
86
+ expect(deactivateMock).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it("never puts the raw provider error body in the notification", async () => {
90
+ findOneMock.mockResolvedValue(storedRefreshToken("rt-current"));
91
+ deactivateMock.mockResolvedValue(true);
92
+
93
+ await deactivateAuthDeadToken(token, "rt-current", "anthropic:400");
94
+
95
+ // 필드가 늘어도 새는 곳이 생기지 않도록 페이로드 전체를 훑는다.
96
+ const payload = JSON.stringify(notifySlackMock.mock.calls[0]![0]);
97
+ expect(payload).not.toContain("sk-ant");
98
+ expect(payload).not.toContain("@");
99
+ });
100
+ });
101
+
102
+ describe("notifyTokenAdded", () => {
103
+ afterEach(() => {
104
+ notifySlackMock.mockReset();
105
+ });
106
+
107
+ it("sends one added notification naming the token and provider", () => {
108
+ notifyTokenAdded("anthropic/test-token", "anthropic");
109
+
110
+ expect(notifySlackMock).toHaveBeenCalledTimes(1);
111
+ const notification = notifySlackMock.mock.calls[0]![0] as {
112
+ subject: string;
113
+ context: string;
114
+ color: string;
115
+ };
116
+ expect(notification.subject).toBe("anthropic/test-token");
117
+ expect(notification.context).toContain("anthropic");
118
+ expect(notification.color).toBe(SLACK_COLOR.good);
119
+ });
120
+ });
@@ -0,0 +1,67 @@
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 = await TokenModel.deactivateIfActive(token.id);
46
+ if (!deactivated) return false;
47
+
48
+ logger.warn(`token deactivated (auth dead): ${token.name}(id=${token.id}) reason=${reasonCode}`);
49
+ void notifySlack({
50
+ title: "세션 만료",
51
+ subject: token.name,
52
+ context: `${token.provider} · ${reasonCode} · 재로그인이 필요합니다`,
53
+ color: SLACK_COLOR.bad,
54
+ });
55
+ return true;
56
+ }
57
+
58
+ /** 처음 보는 계정이 등록됐을 때. 재로그인(기존 토큰 교체)은 알리지 않는다. */
59
+ export function notifyTokenAdded(name: string, provider: string): void {
60
+ logger.info(`token added: ${name} (${provider})`);
61
+ void notifySlack({
62
+ title: "토큰 추가",
63
+ subject: name,
64
+ context: `${provider} · 요청 처리에 사용됩니다`,
65
+ color: SLACK_COLOR.good,
66
+ });
67
+ }
@@ -80,8 +80,8 @@ 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"]);
@@ -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,12 @@
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"
135
136
  }
136
137
  ],
137
138
  "subsets": {
@@ -190,6 +191,19 @@
190
191
  "status",
191
192
  "tool_call_count",
192
193
  "is_image_generation"
194
+ ],
195
+ "C": [
196
+ "id",
197
+ "token_name",
198
+ "model_name",
199
+ "input_tokens",
200
+ "output_tokens",
201
+ "cache_read_tokens",
202
+ "cache_creation_tokens",
203
+ "cache_creation_5m_tokens",
204
+ "cache_creation_1h_tokens",
205
+ "cost_usd",
206
+ "cost_source"
193
207
  ]
194
208
  },
195
209
  "enums": {
@@ -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", () => {
@@ -195,21 +195,15 @@ class RequestLogModelClass extends BaseModelClass<
195
195
  return rows[0] ?? null;
196
196
  }
197
197
 
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
-
198
+ /**
199
+ * 목록 필터의 단일 정의. findMany 와 totalCost 가 같은 조건을 보도록 공유한다 —
200
+ * 화면에서 필터를 걸면 행 수와 비용이 함께 좁혀져야 하는데, 두 쿼리가 각자 필터를
201
+ * 구현하면 한쪽에 축을 추가할 때 조용히 어긋난다.
202
+ */
203
+ private applyListFilters(
204
+ qb: ReturnType<RequestLogModelClass["getSubsetQueries"]>["qb"],
205
+ params: RequestLogListParams,
206
+ ): void {
213
207
  if (params.id) {
214
208
  qb.whereIn("request_logs.id", asArray(params.id));
215
209
  }
@@ -241,6 +235,24 @@ class RequestLogModelClass extends BaseModelClass<
241
235
  throw new BadRequestException(SD("error.unknownSearchField")(params.search));
242
236
  }
243
237
  }
238
+ }
239
+
240
+ @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"], resourceName: "RequestLogs" })
241
+ async findMany<T extends RequestLogSubsetKey, LP extends RequestLogListParams>(
242
+ subset: T,
243
+ rawParams?: LP,
244
+ ): Promise<ListResult<LP, RequestLogSubsetMapping[T]>> {
245
+ const params = {
246
+ num: 24,
247
+ page: 1,
248
+ search: "id" as const,
249
+ orderBy: "id-desc" as const,
250
+ ...rawParams,
251
+ } satisfies RequestLogListParams;
252
+
253
+ const { qb, onSubset: _ } = this.getSubsetQueries(subset);
254
+
255
+ this.applyListFilters(qb, params);
244
256
 
245
257
  // orderBy
246
258
  if (params.orderBy) {
@@ -254,6 +266,7 @@ class RequestLogModelClass extends BaseModelClass<
254
266
 
255
267
  const enhancers = this.createEnhancers({
256
268
  A: (row) => normalizeLegacyAnthropicRow(row),
269
+ C: (row) => normalizeLegacyAnthropicRow(row),
257
270
  });
258
271
 
259
272
  return this.executeSubsetQuery({
@@ -606,33 +619,65 @@ class RequestLogModelClass extends BaseModelClass<
606
619
  return (row?.max_step ?? -1) + 1;
607
620
  }
608
621
 
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 } : {})
622
+ /**
623
+ * 화면 표시용 총 비용. 목록과 같은 필터를 적용한다.
624
+ *
625
+ * 전체를 조건 없이 SUM 한 뒤, `cost_source` 가 없는 legacy row 의 차액만 보정한다.
626
+ * 조건 없는 SUM 이어야 `(project_name, cost_usd)` 커버링 인덱스가 Index Only Scan 을
627
+ * 탄다 — `cost_source IS NOT NULL` 을 걸면 그 컬럼이 인덱스에 없어 heap 을 다시 봐야
628
+ * 하므로 플래너가 인덱스를 버리고 seq scan 으로 돌아간다(실측 230ms → 814ms).
629
+ *
630
+ * legacy 보정을 남겨둔 이유: 백필로 기존 legacy 는 0 이 되었지만, 외부 로거가
631
+ * `AppendStepInput`/`FinishRunInput` 으로 costSource 없이 넣으면 다시 생길 수 있다.
632
+ * 그런 row 는 저장값 대신 현재 가격표 재계산값이 맞으므로 차액을 더한다.
633
+ *
634
+ * 필터 조건을 여기서 다시 구현하지 않고 `applyListFilters` 를 공유하는 것이 핵심이다 —
635
+ * 두 쿼리가 각자 필터를 가지면 화면에서 필터를 걸었을 때 행 수와 비용이 조용히 어긋난다.
636
+ */
637
+ async totalCost(rawParams: RequestLogListParams = { num: 0, page: 1 }): Promise<number> {
638
+ const params = {
639
+ num: 0,
640
+ page: 1,
641
+ search: "id" as const,
642
+ ...rawParams,
643
+ } satisfies RequestLogListParams;
644
+
645
+ const { qb: totalQb } = this.getSubsetQueries("C");
646
+ this.applyListFilters(totalQb, params);
647
+ // subset qb 는 컬럼 목록이 이미 SELECT 에 박혀 있어 집계를 얹으면 GROUP BY 에러가 난다.
648
+ // executeCountQuery 와 같은 방식으로 select 를 비우고 집계만 남긴다.
649
+ const stored = await totalQb
650
+ .clear("select")
651
+ // 음수 저장값(과거 버그)은 0 으로 눌러서 더한다 — JS 쪽 Math.max 와 같은 규칙.
652
+ .select({
653
+ total: Puri.rawNumber("COALESCE(SUM(GREATEST(request_logs.cost_usd, 0)), 0)"),
654
+ })
655
+ .first();
656
+
657
+ const { qb: legacyQb } = this.getSubsetQueries("C");
658
+ this.applyListFilters(legacyQb, params);
659
+ const legacyRows = await legacyQb
660
+ .whereRaw("request_logs.cost_source IS NULL OR request_logs.cost_usd IS NULL")
661
+ .clear("select")
616
662
  .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",
663
+ token_name: "request_logs.token_name",
664
+ model_name: "request_logs.model_name",
665
+ input_tokens: "request_logs.input_tokens",
666
+ output_tokens: "request_logs.output_tokens",
667
+ cache_read_tokens: "request_logs.cache_read_tokens",
668
+ cache_creation_tokens: "request_logs.cache_creation_tokens",
669
+ cache_creation_5m_tokens: "request_logs.cache_creation_5m_tokens",
670
+ cache_creation_1h_tokens: "request_logs.cache_creation_1h_tokens",
671
+ cost_usd: "request_logs.cost_usd",
626
672
  });
627
673
 
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
- }
674
+ const storedTotal = Number(stored?.total ?? 0) / MICRO_USD;
675
+
676
+ // legacy row 는 위 SUM 에 저장값으로 이미 포함되어 있다. 재계산값과의 차액만 더한다.
677
+ return legacyRows.reduce((sum, row) => {
678
+ const alreadySummed = Math.max(row.cost_usd ?? 0, 0) / MICRO_USD;
634
679
  const usage = normalizedUsageForCost(row);
635
- if (!usage.model) return sum + Math.max(row.cost_usd ?? 0, 0) / MICRO_USD;
680
+ if (!usage.model) return sum;
636
681
  return (
637
682
  sum +
638
683
  calculateCostUsd(usage.model, {
@@ -642,9 +687,10 @@ class RequestLogModelClass extends BaseModelClass<
642
687
  cacheCreationInputTokens: usage.cacheCreationInputTokens,
643
688
  cacheCreationInputTokens5m: usage.cacheCreationInputTokens5m,
644
689
  cacheCreationInputTokens1h: usage.cacheCreationInputTokens1h,
645
- })
690
+ }) -
691
+ alreadySummed
646
692
  );
647
- }, 0);
693
+ }, storedTotal);
648
694
  }
649
695
 
650
696
  async distinctProjectNames(): Promise<string[]> {
@@ -434,7 +434,19 @@ Content-Type: application/json
434
434
  ###
435
435
 
436
436
  GET {{baseUrl}}/api/qgrid/totalCost
437
- ?tokenName=TOKENNAME
437
+ ?params[num]=24
438
+ &params[page]=1
439
+ &params[search]=id
440
+ &params[keyword]=KEYWORD
441
+ &params[orderBy]=id-desc
442
+ &params[queryMode]=both
443
+ &params[id]=0
444
+ &params[sonamuFilter]=unknown-custom
445
+ &params[token_name]=
446
+ &params[project_name]=
447
+ &params[model_name]=
448
+ &params[project_name_is_null]=false
449
+ &params[project_name_is_not_null]=false
438
450
  Content-Type: application/json
439
451
 
440
452
  ###
@@ -500,6 +512,15 @@ Content-Type: application/json
500
512
 
501
513
  ###
502
514
 
515
+ POST {{baseUrl}}/api/qgrid/oauthComplete
516
+ Content-Type: application/json
517
+
518
+ {
519
+ "pastedCode": "PASTEDCODE"
520
+ }
521
+
522
+ ###
523
+
503
524
  POST {{baseUrl}}/api/qgrid/oauthStartOpenAI
504
525
  Content-Type: application/json
505
526