@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
|
@@ -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
|
###
|
|
@@ -76,12 +76,28 @@ export const requestLogSubsetQueries = {
|
|
|
76
76
|
is_image_generation: "request_logs.is_image_generation",
|
|
77
77
|
});
|
|
78
78
|
},
|
|
79
|
+
C: (qbWrapper: PuriWrapper<DatabaseSchemaExtend>) => {
|
|
80
|
+
return qbWrapper.from("request_logs").select({
|
|
81
|
+
id: "request_logs.id",
|
|
82
|
+
token_name: "request_logs.token_name",
|
|
83
|
+
model_name: "request_logs.model_name",
|
|
84
|
+
input_tokens: "request_logs.input_tokens",
|
|
85
|
+
output_tokens: "request_logs.output_tokens",
|
|
86
|
+
cache_read_tokens: "request_logs.cache_read_tokens",
|
|
87
|
+
cache_creation_tokens: "request_logs.cache_creation_tokens",
|
|
88
|
+
cache_creation_5m_tokens: "request_logs.cache_creation_5m_tokens",
|
|
89
|
+
cache_creation_1h_tokens: "request_logs.cache_creation_1h_tokens",
|
|
90
|
+
cost_usd: "request_logs.cost_usd",
|
|
91
|
+
cost_source: "request_logs.cost_source",
|
|
92
|
+
});
|
|
93
|
+
},
|
|
79
94
|
};
|
|
80
95
|
|
|
81
96
|
// LoaderQuery: RequestLog
|
|
82
97
|
export const requestLogLoaderQueries = {
|
|
83
98
|
A: [],
|
|
84
99
|
P: [],
|
|
100
|
+
C: [],
|
|
85
101
|
} as const satisfies PuriLoaderQueries<RequestLogSubsetKey>;
|
|
86
102
|
|
|
87
103
|
// SubsetQuery: RequestLogStep
|
|
@@ -353,11 +353,26 @@ export const RequestLogSubsetP = z.object({
|
|
|
353
353
|
is_image_generation: z.boolean(),
|
|
354
354
|
});
|
|
355
355
|
export type RequestLogSubsetP = z.infer<typeof RequestLogSubsetP>;
|
|
356
|
+
export const RequestLogSubsetC = z.object({
|
|
357
|
+
id: z.int(),
|
|
358
|
+
token_name: z.string().max(100).nullable(),
|
|
359
|
+
model_name: z.string().max(255).nullable(),
|
|
360
|
+
input_tokens: z.int(),
|
|
361
|
+
output_tokens: z.int(),
|
|
362
|
+
cache_read_tokens: z.int(),
|
|
363
|
+
cache_creation_tokens: z.int(),
|
|
364
|
+
cache_creation_5m_tokens: z.int().nullable(),
|
|
365
|
+
cache_creation_1h_tokens: z.int().nullable(),
|
|
366
|
+
cost_usd: z.int().nullable(),
|
|
367
|
+
cost_source: z.string().max(20).nullable(),
|
|
368
|
+
});
|
|
369
|
+
export type RequestLogSubsetC = z.infer<typeof RequestLogSubsetC>;
|
|
356
370
|
export type RequestLogSubsetMapping = {
|
|
357
371
|
A: RequestLogSubsetA;
|
|
358
372
|
P: RequestLogSubsetP;
|
|
373
|
+
C: RequestLogSubsetC;
|
|
359
374
|
};
|
|
360
|
-
export const RequestLogSubsetKey = z.enum(["A", "P"]);
|
|
375
|
+
export const RequestLogSubsetKey = z.enum(["A", "P", "C"]);
|
|
361
376
|
export type RequestLogSubsetKey = z.infer<typeof RequestLogSubsetKey>;
|
|
362
377
|
|
|
363
378
|
// Subsets: RequestLogStep
|
|
@@ -110,3 +110,42 @@ describe("TokenModel.updateFields", () => {
|
|
|
110
110
|
expect(update).toHaveBeenCalledWith({ weight: 4 });
|
|
111
111
|
});
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
describe("TokenModel.deactivateIfActive", () => {
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
vi.restoreAllMocks();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
function mockKnex(responses: unknown[]) {
|
|
120
|
+
const raw = vi.fn();
|
|
121
|
+
for (const response of responses) raw.mockResolvedValueOnce(response);
|
|
122
|
+
vi.spyOn(TokenModel as unknown as { getPuri: (mode: "w") => unknown }, "getPuri").mockReturnValue(
|
|
123
|
+
{ knex: { raw } } as unknown as MockPuri,
|
|
124
|
+
);
|
|
125
|
+
return raw;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
it("deactivates in one statement that carries the last-token guard", async () => {
|
|
129
|
+
const raw = mockKnex([{ rowCount: 1 }]);
|
|
130
|
+
|
|
131
|
+
await expect(TokenModel.deactivateIfActive(7)).resolves.toBe(true);
|
|
132
|
+
expect(raw).toHaveBeenCalledTimes(1);
|
|
133
|
+
const sql = raw.mock.calls[0]![0] as string;
|
|
134
|
+
expect(sql).toContain("UPDATE tokens SET active = false");
|
|
135
|
+
expect(sql).toContain("count(*)");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("returns false without a diagnostic read when the token is already inactive", async () => {
|
|
139
|
+
const raw = mockKnex([{ rowCount: 0 }, { rows: [] }]);
|
|
140
|
+
|
|
141
|
+
await expect(TokenModel.deactivateIfActive(7)).resolves.toBe(false);
|
|
142
|
+
expect(raw).toHaveBeenCalledTimes(2);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("refuses to deactivate the provider's last active token", async () => {
|
|
146
|
+
const raw = mockKnex([{ rowCount: 0 }, { rows: [{ provider: "anthropic" }] }]);
|
|
147
|
+
|
|
148
|
+
await expect(TokenModel.deactivateIfActive(7)).resolves.toBe(false);
|
|
149
|
+
expect(raw).toHaveBeenCalledTimes(2);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getLogger } from "@logtape/logtape";
|
|
1
2
|
import {
|
|
2
3
|
api,
|
|
3
4
|
asArray,
|
|
@@ -13,6 +14,8 @@ import { type TokenSubsetKey, type TokenSubsetMapping } from "../sonamu.generate
|
|
|
13
14
|
import { tokenLoaderQueries, tokenSubsetQueries } from "../sonamu.generated.sso";
|
|
14
15
|
import { type TokenListParams, type TokenSaveParams } from "./token.types";
|
|
15
16
|
|
|
17
|
+
const logger = getLogger(["qgrid", "token"]);
|
|
18
|
+
|
|
16
19
|
const DEFAULT_QUOTA_THRESHOLD = 80;
|
|
17
20
|
const DEFAULT_WEIGHT = 1;
|
|
18
21
|
|
|
@@ -173,6 +176,80 @@ class TokenModelClass extends BaseModelClass<
|
|
|
173
176
|
return wdb.transaction((trx) => trx.table("tokens").where("id", id).update(fields));
|
|
174
177
|
}
|
|
175
178
|
|
|
179
|
+
/**
|
|
180
|
+
* 세션 만료 토큰을 라우팅에서 제외한다. `active=true` 조건부 갱신이므로 공유 DB 를 쓰는
|
|
181
|
+
* 여러 인스턴스가 동시에 같은 만료를 감지해도 true 를 받는 프로세스는 하나뿐이다
|
|
182
|
+
* (호출부의 알림 발송 게이트).
|
|
183
|
+
*
|
|
184
|
+
* 단, 대상이 해당 provider 의 마지막 활성 토큰이면 갱신하지 않고 false 를 반환한다.
|
|
185
|
+
* client_id 취소나 OAuth 계약 변경처럼 전 토큰이 동시에 실패하는 상황에서 풀이
|
|
186
|
+
* 통째로 비는 것을 막는 안전판이다.
|
|
187
|
+
*/
|
|
188
|
+
/**
|
|
189
|
+
* 같은 계정의 기존 토큰을 지우고 새로 저장한다(로그인·재로그인 공통 경로).
|
|
190
|
+
*
|
|
191
|
+
* @returns 처음 들어온 계정인지 — 호출부의 신규 등록 알림 게이트. 재로그인은
|
|
192
|
+
* 기존 토큰을 대체하는 것이라 알릴 사건이 아니다.
|
|
193
|
+
*
|
|
194
|
+
* provider 응답에 계정 식별자가 없으면 dedup 자체가 불가능하므로 경고만 남기고
|
|
195
|
+
* 중복 제거 없이 저장한다(죽은 row 가 남는다). 이때는 기존 토큰 유무를 알 수 없어
|
|
196
|
+
* 신규로 보지 않는다 — 재로그인을 신규로 잘못 알리는 편보다 조용한 편이 낫다.
|
|
197
|
+
*/
|
|
198
|
+
async replaceByAccount(
|
|
199
|
+
provider: string,
|
|
200
|
+
accountId: string | undefined,
|
|
201
|
+
saveParams: TokenSaveParams,
|
|
202
|
+
): Promise<{ isNew: boolean }> {
|
|
203
|
+
let isNew = false;
|
|
204
|
+
if (accountId) {
|
|
205
|
+
const oldEntries = await this.findByAccountIdentifier("A", provider, accountId);
|
|
206
|
+
if (oldEntries.length > 0) {
|
|
207
|
+
await this.del(oldEntries.map((o) => o.id));
|
|
208
|
+
} else {
|
|
209
|
+
isNew = true;
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
logger.warn(
|
|
213
|
+
`${provider} login without account identifier: dedup skipped for ${saveParams.name ?? "unnamed"}`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await this.save([saveParams]);
|
|
218
|
+
return { isNew };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async deactivateIfActive(id: number): Promise<boolean> {
|
|
222
|
+
const wdb = this.getPuri("w");
|
|
223
|
+
|
|
224
|
+
// 활성 여부와 "마지막 남은 토큰인가"를 한 statement 안에서 판정한다. 두 조건을
|
|
225
|
+
// 따로 읽고 나중에 갱신하면 그 사이에 다른 인스턴스가 같은 provider 의 토큰을
|
|
226
|
+
// 비활성화해 풀이 통째로 빌 수 있다.
|
|
227
|
+
const updated = await wdb.knex.raw<{ rowCount: number }>(
|
|
228
|
+
`UPDATE tokens SET active = false
|
|
229
|
+
WHERE id = ? AND active = true
|
|
230
|
+
AND (SELECT count(*) FROM tokens peer
|
|
231
|
+
WHERE peer.provider = tokens.provider AND peer.active) > 1`,
|
|
232
|
+
[id],
|
|
233
|
+
);
|
|
234
|
+
if ((updated.rowCount ?? 0) > 0) return true;
|
|
235
|
+
|
|
236
|
+
// 갱신되지 않은 이유는 둘 중 하나다: 이미 비활성이거나(정상 경쟁 결과), 마지막
|
|
237
|
+
// 활성 토큰이거나. 후자는 systemic 실패 신호라 남겨야 하므로 이때만 한 번 더 읽는다.
|
|
238
|
+
const survivor = (
|
|
239
|
+
await wdb.knex.raw<{ rows: { provider: string }[] }>(
|
|
240
|
+
"SELECT provider FROM tokens WHERE id = ? AND active = true",
|
|
241
|
+
[id],
|
|
242
|
+
)
|
|
243
|
+
).rows[0];
|
|
244
|
+
if (survivor) {
|
|
245
|
+
logger.error(
|
|
246
|
+
`refusing to auto-deactivate token ${id}: last active ${survivor.provider} token — ` +
|
|
247
|
+
`systemic refresh failure suspected, keeping the pool non-empty`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
|
|
176
253
|
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
177
254
|
async reorder(ids: number[]): Promise<{ done: boolean }> {
|
|
178
255
|
const wdb = this.getPuri("w");
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* legacy request_logs 의 비용을 확정값으로 백필한다.
|
|
3
|
+
*
|
|
4
|
+
* 배경: `cost_source` 가 없는 row 는 조회할 때마다 앱이 현재 가격표로 재계산해 왔다
|
|
5
|
+
* (`normalizeLegacyAnthropicRow`). 입력이 고정이라 결과도 항상 같은데, 96만 행 테이블에서
|
|
6
|
+
* 23.6만 행을 매 요청 앱으로 끌어오는 비용만 남는다. 한 번 계산해 저장하면 총비용 집계가
|
|
7
|
+
* 단일 SQL SUM 으로 끝난다.
|
|
8
|
+
*
|
|
9
|
+
* 계산식은 앱의 `normalizedUsageForCost` + `calculateCostUsd` 를 그대로 옮긴 것이다.
|
|
10
|
+
* 두 로직이 갈라지면 백필된 값과 신규 저장값의 기준이 달라지므로, 가격표를 바꿀 때는
|
|
11
|
+
* 이 마이그레이션이 아니라 앱 코드만 바꾸면 된다(이미 확정된 값은 유지되는 것이 계약).
|
|
12
|
+
*
|
|
13
|
+
* 되돌리기: down 은 `cost_source='backfilled'` 인 row 의 표식만 제거해 재계산 대상으로
|
|
14
|
+
* 되돌린다. 원본 `cost_usd` 는 복원되지 않는다 — 되돌릴 수 없는 쓰기다. 덮이는 값은
|
|
15
|
+
* NULL 이었거나, 음수였거나, 구버전 가격표로 계산된 값이고, 조회 경로는 이들을 그대로
|
|
16
|
+
* 보여준 적이 없다(항상 현재 가격표로 재계산해 표시). 그래서 화면에 보이던 총액은
|
|
17
|
+
* 백필 전후로 동일하며, 실질 손실은 없다.
|
|
18
|
+
*/
|
|
19
|
+
import { type Knex } from "knex";
|
|
20
|
+
|
|
21
|
+
// 마이그레이션 러너는 tsconfig 의 bundler 해석을 쓰지 않는 순수 ESM 로더라 확장자가 필요하다.
|
|
22
|
+
import { calculateCostUsd } from "../utils/providers/common/model-cost.js";
|
|
23
|
+
|
|
24
|
+
const MICRO_USD = 1_000_000;
|
|
25
|
+
const CHUNK_SIZE = 5_000;
|
|
26
|
+
|
|
27
|
+
type LegacyRow = {
|
|
28
|
+
id: number;
|
|
29
|
+
token_name: string | null;
|
|
30
|
+
model_name: string | null;
|
|
31
|
+
input_tokens: number;
|
|
32
|
+
output_tokens: number;
|
|
33
|
+
cache_read_tokens: number;
|
|
34
|
+
cache_creation_tokens: number;
|
|
35
|
+
cache_creation_5m_tokens: number | null;
|
|
36
|
+
cache_creation_1h_tokens: number | null;
|
|
37
|
+
cost_usd: number | null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function isAnthropicRow(row: LegacyRow): boolean {
|
|
41
|
+
return (
|
|
42
|
+
row.token_name?.startsWith("anthropic/") === true ||
|
|
43
|
+
row.model_name?.startsWith("claude-") === true ||
|
|
44
|
+
row.model_name?.startsWith("anthropic/claude-") === true
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function backfilledCostMicroUsd(row: LegacyRow): number {
|
|
49
|
+
if (!row.model_name) return Math.max(row.cost_usd ?? 0, 0);
|
|
50
|
+
|
|
51
|
+
// Anthropic 은 input/cache_read/cache_creation 을 상호 배타적으로 보고한다. 과거 저장 경로가
|
|
52
|
+
// 이를 합치지 않아 input_tokens 가 작게 남은 row 를 복원한다.
|
|
53
|
+
const splitInput =
|
|
54
|
+
isAnthropicRow(row) && row.input_tokens < row.cache_read_tokens + row.cache_creation_tokens;
|
|
55
|
+
const inputTokens = splitInput
|
|
56
|
+
? row.input_tokens + row.cache_read_tokens + row.cache_creation_tokens
|
|
57
|
+
: row.input_tokens;
|
|
58
|
+
|
|
59
|
+
const model = row.model_name.includes("/") ? row.model_name.split("/").pop()! : row.model_name;
|
|
60
|
+
const usd = calculateCostUsd(model, {
|
|
61
|
+
inputTokens,
|
|
62
|
+
outputTokens: row.output_tokens,
|
|
63
|
+
cachedInputTokens: row.cache_read_tokens,
|
|
64
|
+
cacheCreationInputTokens: row.cache_creation_tokens,
|
|
65
|
+
...(row.cache_creation_5m_tokens !== null
|
|
66
|
+
? { cacheCreationInputTokens5m: row.cache_creation_5m_tokens }
|
|
67
|
+
: {}),
|
|
68
|
+
...(row.cache_creation_1h_tokens !== null
|
|
69
|
+
? { cacheCreationInputTokens1h: row.cache_creation_1h_tokens }
|
|
70
|
+
: {}),
|
|
71
|
+
});
|
|
72
|
+
return Math.max(Math.round(usd * MICRO_USD), 0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function up(knex: Knex): Promise<void> {
|
|
76
|
+
let lastId = 0;
|
|
77
|
+
let processed = 0;
|
|
78
|
+
|
|
79
|
+
for (;;) {
|
|
80
|
+
const { rows } = await knex.raw<{ rows: LegacyRow[] }>(
|
|
81
|
+
`SELECT id, token_name, model_name, input_tokens, output_tokens,
|
|
82
|
+
cache_read_tokens, cache_creation_tokens,
|
|
83
|
+
cache_creation_5m_tokens, cache_creation_1h_tokens, cost_usd
|
|
84
|
+
FROM request_logs
|
|
85
|
+
WHERE (cost_source IS NULL OR cost_usd IS NULL) AND id > ?
|
|
86
|
+
ORDER BY id
|
|
87
|
+
LIMIT ?`,
|
|
88
|
+
[lastId, CHUNK_SIZE],
|
|
89
|
+
);
|
|
90
|
+
if (rows.length === 0) break;
|
|
91
|
+
|
|
92
|
+
// chunk 당 한 번의 UPDATE ... FROM (VALUES ...) 로 왕복을 줄인다.
|
|
93
|
+
const values = rows.map((row) => `(${row.id}, ${backfilledCostMicroUsd(row)})`).join(",");
|
|
94
|
+
await knex.raw(
|
|
95
|
+
`UPDATE request_logs AS t
|
|
96
|
+
SET cost_usd = v.cost_usd, cost_source = 'backfilled'
|
|
97
|
+
FROM (VALUES ${values}) AS v(id, cost_usd)
|
|
98
|
+
WHERE t.id = v.id`,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
lastId = rows[rows.length - 1]!.id;
|
|
102
|
+
processed += rows.length;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.log(`[backfill] request_logs cost backfilled: ${processed} rows`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function down(knex: Knex): Promise<void> {
|
|
109
|
+
await knex.raw(`UPDATE request_logs SET cost_source = NULL WHERE cost_source = 'backfilled'`);
|
|
110
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Knex } from "knex";
|
|
2
|
+
|
|
3
|
+
export async function up(knex: Knex): Promise<void> {
|
|
4
|
+
await knex.raw(
|
|
5
|
+
`CREATE INDEX request_logs_project_name_cost_usd_index ON request_logs USING btree(project_name ASC NULLS LAST, cost_usd ASC NULLS LAST);`,
|
|
6
|
+
);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function down(knex: Knex): Promise<void> {
|
|
10
|
+
await knex.schema.alterTable("request_logs", (table) => {
|
|
11
|
+
table.dropIndex(["project_name", "cost_usd"], "request_logs_project_name_cost_usd_index");
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { getLogger } from "@logtape/logtape";
|
|
10
10
|
|
|
11
|
+
import { deactivateAuthDeadToken } from "../../../application/qgrid/token-death";
|
|
11
12
|
import { TokenModel } from "../../../application/token/token.model";
|
|
12
13
|
import { type OpenAICredentials } from "../../../application/token/token.types";
|
|
13
14
|
|
|
@@ -15,6 +16,13 @@ const logger = getLogger(["qgrid", "openai-refresh"]);
|
|
|
15
16
|
|
|
16
17
|
import { OPENAI_CLIENT_ID, OPENAI_TOKEN_URL } from "./openai-constants";
|
|
17
18
|
|
|
19
|
+
// 재로그인 외 복구 불가한 refresh 실패 코드. 어느 것이든 토큰이 죽었다는 확정 신호다.
|
|
20
|
+
const PERMANENT_FAILURE_CODES = new Set([
|
|
21
|
+
"refresh_token_expired",
|
|
22
|
+
"refresh_token_reused",
|
|
23
|
+
"refresh_token_invalidated",
|
|
24
|
+
]);
|
|
25
|
+
|
|
18
26
|
// per-token inflight promise dedup + minimum interval
|
|
19
27
|
const inflightRefresh = new Map<number, Promise<RefreshResult>>();
|
|
20
28
|
const lastRefreshTime = new Map<number, number>();
|
|
@@ -100,6 +108,15 @@ async function doRefresh(tokenId: number): Promise<RefreshResult> {
|
|
|
100
108
|
`OpenAI refresh FAILED token=${token.name}(id=${tokenId}) status=${resp.status} ` +
|
|
101
109
|
`code=${errorCode ?? "?"} body=${body}`,
|
|
102
110
|
);
|
|
111
|
+
// 영구 실패는 errorCode 로만 판정한다 — status 를 함께 요구하면 provider 가
|
|
112
|
+
// 상태코드를 바꿀 때 판정이 조용히 멎는다.
|
|
113
|
+
if (errorCode && PERMANENT_FAILURE_CODES.has(errorCode)) {
|
|
114
|
+
await deactivateAuthDeadToken(
|
|
115
|
+
{ id: tokenId, name: token.name, provider: "openai" },
|
|
116
|
+
creds.refreshToken,
|
|
117
|
+
`openai:${errorCode}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
103
120
|
throw new Error(`OpenAI refresh failed: ${resp.status} ${body}`);
|
|
104
121
|
}
|
|
105
122
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { notifySlack, SLACK_COLOR } from "./slack-notify";
|
|
4
|
+
|
|
5
|
+
describe("notifySlack", () => {
|
|
6
|
+
const savedToken = process.env.SLACK_BOT_TOKEN;
|
|
7
|
+
const savedChannel = process.env.SLACK_CHANNEL_ID;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
process.env.SLACK_BOT_TOKEN = "xoxb-test";
|
|
11
|
+
process.env.SLACK_CHANNEL_ID = "C123";
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
if (savedToken === undefined) delete process.env.SLACK_BOT_TOKEN;
|
|
16
|
+
else process.env.SLACK_BOT_TOKEN = savedToken;
|
|
17
|
+
if (savedChannel === undefined) delete process.env.SLACK_CHANNEL_ID;
|
|
18
|
+
else process.env.SLACK_CHANNEL_ID = savedChannel;
|
|
19
|
+
vi.restoreAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("posts the message to the configured channel", async () => {
|
|
23
|
+
const fetchMock = vi
|
|
24
|
+
.spyOn(globalThis, "fetch")
|
|
25
|
+
.mockResolvedValue(new Response(JSON.stringify({ ok: true })));
|
|
26
|
+
|
|
27
|
+
await notifySlack({
|
|
28
|
+
title: "토큰 추가",
|
|
29
|
+
subject: "anthropic/test-token",
|
|
30
|
+
context: "anthropic · 요청 처리에 사용됩니다",
|
|
31
|
+
color: SLACK_COLOR.good,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
35
|
+
const [url, init] = fetchMock.mock.calls[0]!;
|
|
36
|
+
expect(url).toBe("https://slack.com/api/chat.postMessage");
|
|
37
|
+
expect(JSON.parse((init as RequestInit).body as string)).toEqual({
|
|
38
|
+
channel: "C123",
|
|
39
|
+
// blocks 만 보내면 푸시 알림이 빈칸으로 뜨므로 대체 텍스트가 함께 나가야 한다.
|
|
40
|
+
text: "토큰 추가 — anthropic/test-token",
|
|
41
|
+
attachments: [
|
|
42
|
+
{
|
|
43
|
+
color: SLACK_COLOR.good,
|
|
44
|
+
blocks: [
|
|
45
|
+
{
|
|
46
|
+
type: "section",
|
|
47
|
+
text: { type: "mrkdwn", text: "*토큰 추가* `anthropic/test-token`" },
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
type: "context",
|
|
51
|
+
elements: [{ type: "mrkdwn", text: "anthropic · 요청 처리에 사용됩니다" }],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
expect((init as RequestInit).signal).toBeDefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("omits the context block and color when they are not given", async () => {
|
|
61
|
+
const fetchMock = vi
|
|
62
|
+
.spyOn(globalThis, "fetch")
|
|
63
|
+
.mockResolvedValue(new Response(JSON.stringify({ ok: true })));
|
|
64
|
+
|
|
65
|
+
await notifySlack({ title: "제목만" });
|
|
66
|
+
|
|
67
|
+
const body = JSON.parse(
|
|
68
|
+
(fetchMock.mock.calls[0]![1] as RequestInit).body as string,
|
|
69
|
+
) as { text: string; attachments: [{ color?: string; blocks: unknown[] }] };
|
|
70
|
+
expect(body.text).toBe("제목만");
|
|
71
|
+
expect(body.attachments[0].color).toBeUndefined();
|
|
72
|
+
expect(body.attachments[0].blocks).toHaveLength(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("skips the request entirely when env is not configured", async () => {
|
|
76
|
+
delete process.env.SLACK_BOT_TOKEN;
|
|
77
|
+
const fetchMock = vi.spyOn(globalThis, "fetch");
|
|
78
|
+
|
|
79
|
+
await expect(notifySlack({ title: "hello" })).resolves.toBeUndefined();
|
|
80
|
+
|
|
81
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("warns without throwing when Slack replies 200 with ok:false", async () => {
|
|
85
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
86
|
+
new Response(JSON.stringify({ ok: false, error: "not_in_channel" })),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
await expect(notifySlack({ title: "hello" })).resolves.toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("swallows network and timeout failures", async () => {
|
|
93
|
+
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timed out"));
|
|
94
|
+
|
|
95
|
+
await expect(notifySlack({ title: "hello" })).resolves.toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
});
|