@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.
- package/README.ko.md +2 -0
- package/README.md +2 -0
- package/bundle/dist/application/qgrid/oauth.js +22 -3
- package/bundle/dist/application/qgrid/qgrid.frame.js +104 -34
- package/bundle/dist/application/qgrid/qgrid.types.js +5 -2
- package/bundle/dist/application/qgrid/token-death.js +52 -0
- package/bundle/dist/application/queries.generated.js +2 -2
- package/bundle/dist/application/request-log/request-log.model.js +69 -27
- package/bundle/dist/application/sonamu.generated.js +20 -3
- package/bundle/dist/application/sonamu.generated.sso.js +18 -2
- package/bundle/dist/application/token/token.model.js +42 -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/utils/providers/openai/openai-refresh.js +12 -1
- package/bundle/dist/utils/slack-notify.js +65 -0
- package/bundle/src/application/qgrid/oauth.ts +23 -1
- package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +133 -27
- package/bundle/src/application/qgrid/qgrid.frame.ts +132 -66
- package/bundle/src/application/qgrid/qgrid.types.ts +3 -0
- package/bundle/src/application/qgrid/token-death.test.ts +120 -0
- package/bundle/src/application/qgrid/token-death.ts +67 -0
- package/bundle/src/application/queries.generated.ts +2 -2
- package/bundle/src/application/request-log/request-log.entity.json +20 -6
- package/bundle/src/application/request-log/request-log.model.test.ts +153 -42
- package/bundle/src/application/request-log/request-log.model.ts +86 -40
- package/bundle/src/application/sonamu.generated.http +22 -1
- package/bundle/src/application/sonamu.generated.sso.ts +16 -0
- package/bundle/src/application/sonamu.generated.ts +16 -1
- package/bundle/src/application/token/token.model.test.ts +39 -0
- package/bundle/src/application/token/token.model.ts +77 -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/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-2cAgaLOm.css +1 -0
- package/bundle/web-dist/client/assets/{index-YowQRE2y.js → index-BgWzzjvj.js} +3 -3
- package/bundle/web-dist/client/assets/logs-Lyv8OfHE.js +1 -0
- package/bundle/web-dist/client/assets/{monit-B51yXcqk.js → monit-BSa5lSZp.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-BqaUiBc0.js → routes-Cp0re-Nn.js} +2 -2
- package/bundle/web-dist/client/assets/{services.generated-D3MH3EfY.js → services.generated-B-dMUtEu.js} +1 -1
- package/bundle/web-dist/client/assets/{show-LAFY13v6.js → show-C1jEPhuS.js} +1 -1
- package/bundle/web-dist/client/assets/tokens-1e66MAC0.js +1 -0
- package/bundle/web-dist/client/assets/{use-update-token-mutation-CLhv6SUL.js → use-update-token-mutation-DueEppqt.js} +1 -1
- package/bundle/web-dist/client/index.html +2 -2
- package/bundle/web-dist/server/assets/{logs-Dbk2Ig2z.js → logs-CwoOsveV.js} +11 -4
- package/bundle/web-dist/server/assets/{monit-Hm9dIPhx.js → monit-DIsEsDJN.js} +1 -1
- package/bundle/web-dist/server/assets/{routes-DztxeUjk.js → routes-Bts-KJ9W.js} +7 -3
- package/bundle/web-dist/server/assets/{services.generated-1Kcul41h.js → services.generated-Bh7Vs8Ds.js} +16 -7
- package/bundle/web-dist/server/assets/{show-BHUSw7qv.js → show-DJfaCNUw.js} +1 -1
- package/bundle/web-dist/server/assets/{tokens-DHU9lUT2.js → tokens-BA4vwDat.js} +215 -52
- package/bundle/web-dist/server/assets/{use-update-token-mutation-axqnye4L.js → use-update-token-mutation-BQRzEPy2.js} +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +6 -6
- package/package.json +1 -1
- package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +16 -1
- package/bundle/web-dist/client/assets/index-GA9b-zy4.css +0 -1
- package/bundle/web-dist/client/assets/logs-o8mm530D.js +0 -1
- 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 = (
|
|
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"]);
|
|
@@ -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
|
-
|
|
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", () => {
|
|
@@ -195,21 +195,15 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
195
195
|
return rows[0] ?? null;
|
|
196
196
|
}
|
|
197
197
|
|
|
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
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
|
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
|
-
},
|
|
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
|
-
?
|
|
437
|
+
?params[num]=24
|
|
438
|
+
¶ms[page]=1
|
|
439
|
+
¶ms[search]=id
|
|
440
|
+
¶ms[keyword]=KEYWORD
|
|
441
|
+
¶ms[orderBy]=id-desc
|
|
442
|
+
¶ms[queryMode]=both
|
|
443
|
+
¶ms[id]=0
|
|
444
|
+
¶ms[sonamuFilter]=unknown-custom
|
|
445
|
+
¶ms[token_name]=
|
|
446
|
+
¶ms[project_name]=
|
|
447
|
+
¶ms[model_name]=
|
|
448
|
+
¶ms[project_name_is_null]=false
|
|
449
|
+
¶ms[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
|
|