@cartanova/qgrid-cli 2.6.1 → 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 +21 -3
- package/bundle/dist/application/qgrid/qgrid.frame.js +31 -18
- 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 +20 -1
- package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +18 -3
- package/bundle/src/application/qgrid/qgrid.frame.ts +58 -47
- 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 +13 -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-BGGBFmzE.js → index-BgWzzjvj.js} +3 -3
- package/bundle/web-dist/client/assets/logs-Lyv8OfHE.js +1 -0
- package/bundle/web-dist/client/assets/{monit-CyqMmy1G.js → monit-BSa5lSZp.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-Cy0gCq8c.js → routes-Cp0re-Nn.js} +2 -2
- package/bundle/web-dist/client/assets/{services.generated-BuT6Gp8V.js → services.generated-B-dMUtEu.js} +1 -1
- package/bundle/web-dist/client/assets/{show-C5WxfoAt.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-DBkxL29Y.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-D0z1Ghj1.js → logs-CwoOsveV.js} +11 -4
- package/bundle/web-dist/server/assets/{monit-dBXxnAyI.js → monit-DIsEsDJN.js} +1 -1
- package/bundle/web-dist/server/assets/{routes-B3p7fuwk.js → routes-Bts-KJ9W.js} +7 -3
- package/bundle/web-dist/server/assets/{services.generated-BNuxWs7K.js → services.generated-Bh7Vs8Ds.js} +7 -7
- package/bundle/web-dist/server/assets/{show-0iNioJ2I.js → show-DJfaCNUw.js} +1 -1
- package/bundle/web-dist/server/assets/{tokens-CvytQ9Xy.js → tokens-BA4vwDat.js} +206 -108
- package/bundle/web-dist/server/assets/{use-update-token-mutation-BBCC2GKM.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/token-auth-quota-lifecycle.md +11 -0
- 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/tokens-Ciolqw4l.js +0 -1
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "../../utils/providers/common/credentials";
|
|
14
14
|
import { CallerSchemaValidationError } from "../../utils/providers/common/schema-validation";
|
|
15
15
|
import { MICRO_USD, RequestLogModel } from "../request-log/request-log.model";
|
|
16
|
+
import { type RequestLogListParams } from "../request-log/request-log.types";
|
|
16
17
|
import { type TokenSubsetA } from "../sonamu.generated";
|
|
17
18
|
import { TokenModel, type TokenUpdateFields } from "../token/token.model";
|
|
18
19
|
import { TokenCredentials } from "../token/token.types";
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
fetchUsage,
|
|
25
26
|
generatePKCE,
|
|
26
27
|
refreshAccessToken,
|
|
28
|
+
RefreshFailedError,
|
|
27
29
|
} from "./oauth";
|
|
28
30
|
import {
|
|
29
31
|
afterQuery,
|
|
@@ -45,6 +47,7 @@ import {
|
|
|
45
47
|
type TokenStats,
|
|
46
48
|
type UsageResponse,
|
|
47
49
|
} from "./qgrid.types";
|
|
50
|
+
import { deactivateAuthDeadToken, notifyTokenAdded } from "./token-death";
|
|
48
51
|
import { ToolSchemaCompositionError } from "./tool-emulation-schema";
|
|
49
52
|
|
|
50
53
|
const pendingStreams = new Map<string, QueryInput>();
|
|
@@ -197,6 +200,9 @@ function createHttpDisconnectHandle(): {
|
|
|
197
200
|
const logger = getLogger(["qgrid"]);
|
|
198
201
|
const oauthLogger = getLogger(["qgrid", "oauth"]);
|
|
199
202
|
|
|
203
|
+
// per-token refresh dedup — 회전 겹침으로 인한 사망 오판을 막는다.
|
|
204
|
+
const inflightAnthropicRefresh = new Map<number, Promise<string>>();
|
|
205
|
+
|
|
200
206
|
class QgridFrameClass extends BaseFrameClass {
|
|
201
207
|
constructor() {
|
|
202
208
|
super("Qgrid");
|
|
@@ -464,9 +470,11 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
464
470
|
return QgridDispatcher.getStats();
|
|
465
471
|
}
|
|
466
472
|
|
|
473
|
+
// 화면의 목록 필터를 그대로 받아 같은 조건의 합계를 돌려준다 — 목록과 비용이
|
|
474
|
+
// 서로 다른 모수를 보면 사용자에게는 숫자가 어긋난 것으로 읽힌다.
|
|
467
475
|
@api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
|
|
468
|
-
async totalCost(
|
|
469
|
-
return { usd: await RequestLogModel.totalCost(
|
|
476
|
+
async totalCost(params?: RequestLogListParams): Promise<{ usd: number }> {
|
|
477
|
+
return { usd: await RequestLogModel.totalCost(params) };
|
|
470
478
|
}
|
|
471
479
|
|
|
472
480
|
@api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
|
|
@@ -601,29 +609,18 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
601
609
|
pending.redirectUri,
|
|
602
610
|
);
|
|
603
611
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
tokens.
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
await TokenModel.del(oldEntries.map((o) => o.id));
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
await TokenModel.save([
|
|
616
|
-
{
|
|
617
|
-
provider: "anthropic",
|
|
618
|
-
credentials: {
|
|
619
|
-
accessToken: tokens.accessToken,
|
|
620
|
-
refreshToken: tokens.refreshToken,
|
|
621
|
-
expiresAt: tokens.expiresAt ?? 0,
|
|
622
|
-
accountUuid: tokens.accountUuid ?? "",
|
|
623
|
-
},
|
|
624
|
-
name: pending.name,
|
|
612
|
+
const { isNew } = await TokenModel.replaceByAccount("anthropic", tokens.accountUuid, {
|
|
613
|
+
provider: "anthropic",
|
|
614
|
+
credentials: {
|
|
615
|
+
accessToken: tokens.accessToken,
|
|
616
|
+
refreshToken: tokens.refreshToken,
|
|
617
|
+
expiresAt: tokens.expiresAt ?? 0,
|
|
618
|
+
accountUuid: tokens.accountUuid ?? "",
|
|
625
619
|
},
|
|
626
|
-
|
|
620
|
+
name: pending.name,
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
if (isNew) notifyTokenAdded(pending.name, "anthropic");
|
|
627
624
|
}
|
|
628
625
|
|
|
629
626
|
async handleOAuthCallback(code: string, state: string, reply: FastifyReply): Promise<void> {
|
|
@@ -651,30 +648,19 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
651
648
|
QgridDispatcher.openaiDispatcher
|
|
652
649
|
.completeBrowserLogin()
|
|
653
650
|
.then(async (creds) => {
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
creds.
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
await TokenModel.save([
|
|
665
|
-
{
|
|
666
|
-
provider: "openai",
|
|
667
|
-
credentials: {
|
|
668
|
-
accessToken: creds.accessToken,
|
|
669
|
-
refreshToken: creds.refreshToken,
|
|
670
|
-
idToken: creds.idToken,
|
|
671
|
-
accessTokenExpiresAt: Date.now() + 10 * 24 * 3600 * 1000,
|
|
672
|
-
accountId: creds.accountId,
|
|
673
|
-
},
|
|
674
|
-
name,
|
|
651
|
+
const { isNew } = await TokenModel.replaceByAccount("openai", creds.accountId, {
|
|
652
|
+
provider: "openai",
|
|
653
|
+
credentials: {
|
|
654
|
+
accessToken: creds.accessToken,
|
|
655
|
+
refreshToken: creds.refreshToken,
|
|
656
|
+
idToken: creds.idToken,
|
|
657
|
+
accessTokenExpiresAt: Date.now() + 10 * 24 * 3600 * 1000,
|
|
658
|
+
accountId: creds.accountId,
|
|
675
659
|
},
|
|
676
|
-
|
|
660
|
+
name,
|
|
661
|
+
});
|
|
677
662
|
logger.info(`OpenAI token saved for ${name}`);
|
|
663
|
+
if (isNew) notifyTokenAdded(name, "openai");
|
|
678
664
|
})
|
|
679
665
|
.catch((e) => {
|
|
680
666
|
logger.warn(`OpenAI browser login failed: ${(e as Error).message}`);
|
|
@@ -754,11 +740,36 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
754
740
|
return convertAnthropicUsage(raw);
|
|
755
741
|
}
|
|
756
742
|
|
|
743
|
+
// 동시 refresh 가 회전을 겹치면 뒤늦은 호출이 이미 폐기된 refresh token 을 쓰게 되어
|
|
744
|
+
// 멀쩡한 토큰이 사망으로 오판된다. openai-refresh.ts 와 같은 per-token dedup 을 둔다.
|
|
757
745
|
async refreshToken(token: TokenSubsetA): Promise<string> {
|
|
746
|
+
const inflight = inflightAnthropicRefresh.get(token.id);
|
|
747
|
+
if (inflight) return inflight;
|
|
748
|
+
|
|
749
|
+
const promise = this.doRefreshToken(token);
|
|
750
|
+
inflightAnthropicRefresh.set(token.id, promise);
|
|
751
|
+
try {
|
|
752
|
+
return await promise;
|
|
753
|
+
} finally {
|
|
754
|
+
inflightAnthropicRefresh.delete(token.id);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
private async doRefreshToken(token: TokenSubsetA): Promise<string> {
|
|
758
759
|
const creds = token.credentials;
|
|
759
760
|
const rt = getRefreshToken(creds);
|
|
760
761
|
if (!rt) throw new Error("No refresh token");
|
|
761
|
-
|
|
762
|
+
|
|
763
|
+
let refreshed;
|
|
764
|
+
try {
|
|
765
|
+
refreshed = await refreshAccessToken(rt);
|
|
766
|
+
} catch (e) {
|
|
767
|
+
if (e instanceof RefreshFailedError && e.isAuthDead) {
|
|
768
|
+
await deactivateAuthDeadToken(token, rt, `anthropic:${e.status}`);
|
|
769
|
+
}
|
|
770
|
+
throw e;
|
|
771
|
+
}
|
|
772
|
+
|
|
762
773
|
const updated = TokenCredentials.parse({
|
|
763
774
|
...creds,
|
|
764
775
|
accessToken: refreshed.accessToken,
|
|
@@ -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", () => {
|