@cartanova/qgrid-cli 2.6.2 → 2.6.5
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/expired-token-digest.js +78 -0
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +40 -6
- package/bundle/dist/application/qgrid/qgrid.frame.js +22 -7
- package/bundle/dist/application/qgrid/qgrid.types.js +13 -3
- package/bundle/dist/application/qgrid/token-death.js +29 -4
- package/bundle/dist/application/queries.generated.js +2 -1
- package/bundle/dist/application/request-log/request-log.model.js +37 -5
- package/bundle/dist/application/request-log-step/request-log-step.model.js +1 -1
- package/bundle/dist/application/sonamu.generated.js +41 -3
- package/bundle/dist/application/token/token.model.js +44 -14
- package/bundle/dist/i18n/en.js +3 -1
- package/bundle/dist/i18n/ko.js +3 -1
- package/bundle/dist/i18n/sd.generated.js +18 -1
- package/bundle/dist/migrations/20260807164057_alter_request_logs_alter1.js +13 -0
- package/bundle/dist/server-error-handler.js +5 -3
- package/bundle/dist/sonamu.config.js +8 -1
- package/bundle/dist/utils/slack-notify.js +2 -2
- package/bundle/src/application/qgrid/expired-token-digest.test.ts +49 -0
- package/bundle/src/application/qgrid/expired-token-digest.ts +93 -0
- package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +0 -3
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +30 -2
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +44 -6
- package/bundle/src/application/qgrid/qgrid.frame.ts +13 -4
- package/bundle/src/application/qgrid/qgrid.types.ts +10 -0
- package/bundle/src/application/qgrid/token-death.test.ts +33 -3
- package/bundle/src/application/qgrid/token-death.ts +32 -3
- package/bundle/src/application/queries.generated.ts +3 -0
- package/bundle/src/application/request-log/request-log.entity.json +26 -1
- package/bundle/src/application/request-log/request-log.model.ts +50 -10
- package/bundle/src/application/sonamu.generated.http +6 -0
- package/bundle/src/application/sonamu.generated.ts +42 -2
- package/bundle/src/application/token/token.model.test.ts +13 -3
- package/bundle/src/application/token/token.model.ts +34 -17
- package/bundle/src/i18n/en.ts +3 -0
- package/bundle/src/i18n/ko.ts +3 -0
- package/bundle/src/i18n/sd.generated.ts +17 -0
- package/bundle/src/migrations/20260807164057_alter_request_logs_alter1.ts +13 -0
- package/bundle/src/server-error-handler.test.ts +58 -0
- package/bundle/src/server-error-handler.ts +16 -5
- package/bundle/src/sonamu.config.ts +13 -0
- package/bundle/src/utils/slack-notify.test.ts +6 -4
- package/bundle/src/utils/slack-notify.ts +10 -3
- package/bundle/web-dist/client/assets/index-BGQSGMcK.css +1 -0
- package/bundle/web-dist/client/assets/index-DTy-s79t.js +78 -0
- package/bundle/web-dist/client/assets/logs-DYqUSqg-.js +1 -0
- package/bundle/web-dist/client/assets/{monit-BSa5lSZp.js → monit-GnFS0_Xa.js} +1 -1
- package/bundle/web-dist/client/assets/routes-CBwpn4d-.js +5 -0
- package/bundle/web-dist/client/assets/{sd.generated-CjW3rfJ8.js → sd.generated-DYdwSB2I.js} +1 -1
- package/bundle/web-dist/client/assets/{services.generated-B-dMUtEu.js → services.generated-BsAvotjG.js} +1 -1
- package/bundle/web-dist/client/assets/{show-C1jEPhuS.js → show-DDgukGuY.js} +3 -3
- package/bundle/web-dist/client/assets/{tokens-1e66MAC0.js → tokens-Co1wPrXX.js} +1 -1
- package/bundle/web-dist/client/assets/{use-update-token-mutation-DueEppqt.js → use-update-token-mutation-DqEpK7It.js} +1 -1
- package/bundle/web-dist/client/index.html +3 -3
- package/bundle/web-dist/server/assets/{lazyRouteComponent-PRxXWW3t.js → lazyRouteComponent-BaZaveNc.js} +1 -1
- package/bundle/web-dist/server/assets/{logs-CwoOsveV.js → logs-DMOw3SjD.js} +82 -40
- package/bundle/web-dist/server/assets/{monit-DIsEsDJN.js → monit-UmcqHroX.js} +2 -2
- package/bundle/web-dist/server/assets/{routes-Bts-KJ9W.js → routes-Sv_4FjtH.js} +47 -45
- package/bundle/web-dist/server/assets/{sd.generated-ek-tz-7H.js → sd.generated-B0xqnHEo.js} +21 -0
- package/bundle/web-dist/server/assets/{services.generated-Bh7Vs8Ds.js → services.generated-DqAKskAS.js} +16 -1
- package/bundle/web-dist/server/assets/{show-DJfaCNUw.js → show-C-oSdzHX.js} +31 -25
- package/bundle/web-dist/server/assets/{tokens-BA4vwDat.js → tokens-h-2A3wfD.js} +112 -109
- package/bundle/web-dist/server/assets/{use-update-token-mutation-BQRzEPy2.js → use-update-token-mutation-DHCaa7Ku.js} +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +70 -29
- package/package.json +1 -1
- package/skills/qgrid/references/cli-env-and-server-boot.md +8 -0
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +3 -2
- package/bundle/web-dist/client/assets/index-2cAgaLOm.css +0 -1
- package/bundle/web-dist/client/assets/index-BgWzzjvj.js +0 -78
- package/bundle/web-dist/client/assets/logs-Lyv8OfHE.js +0 -1
- package/bundle/web-dist/client/assets/routes-Cp0re-Nn.js +0 -5
|
@@ -86,6 +86,9 @@ export namespace QgridService {
|
|
|
86
86
|
export const projectNames = (): SSRQuery =>
|
|
87
87
|
createSSRQuery("QgridFrame", "projectNames", [], ["Qgrid", "projectNames"]);
|
|
88
88
|
|
|
89
|
+
export const modelNames = (): SSRQuery =>
|
|
90
|
+
createSSRQuery("QgridFrame", "modelNames", [], ["Qgrid", "modelNames"]);
|
|
91
|
+
|
|
89
92
|
export const usage = (tokenId?: number): SSRQuery =>
|
|
90
93
|
createSSRQuery("QgridFrame", "usage", [tokenId], ["Qgrid", "usage"]);
|
|
91
94
|
|
|
@@ -133,6 +133,12 @@
|
|
|
133
133
|
"name": "request_logs_project_name_cost_usd_index",
|
|
134
134
|
"columns": [{ "name": "project_name" }, { "name": "cost_usd" }],
|
|
135
135
|
"using": "btree"
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"type": "index",
|
|
139
|
+
"name": "request_logs_model_name_index",
|
|
140
|
+
"columns": [{ "name": "model_name" }],
|
|
141
|
+
"using": "btree"
|
|
136
142
|
}
|
|
137
143
|
],
|
|
138
144
|
"subsets": {
|
|
@@ -207,7 +213,26 @@
|
|
|
207
213
|
]
|
|
208
214
|
},
|
|
209
215
|
"enums": {
|
|
210
|
-
"RequestLogOrderBy": {
|
|
216
|
+
"RequestLogOrderBy": {
|
|
217
|
+
"id-desc": "ID최신순",
|
|
218
|
+
"id-asc": "ID오래된순",
|
|
219
|
+
"created_at-desc": "등록일시최신순",
|
|
220
|
+
"created_at-asc": "등록일시오래된순",
|
|
221
|
+
"ttft_ms-desc": "TTFT긴순",
|
|
222
|
+
"ttft_ms-asc": "TTFT짧은순",
|
|
223
|
+
"duration_ms-desc": "소요시간긴순",
|
|
224
|
+
"duration_ms-asc": "소요시간짧은순",
|
|
225
|
+
"input_tokens-desc": "입력토큰많은순",
|
|
226
|
+
"input_tokens-asc": "입력토큰적은순",
|
|
227
|
+
"output_tokens-desc": "출력토큰많은순",
|
|
228
|
+
"output_tokens-asc": "출력토큰적은순",
|
|
229
|
+
"cache_read_tokens-desc": "캐시읽기많은순",
|
|
230
|
+
"cache_read_tokens-asc": "캐시읽기적은순",
|
|
231
|
+
"cache_creation_tokens-desc": "캐시생성많은순",
|
|
232
|
+
"cache_creation_tokens-asc": "캐시생성적은순",
|
|
233
|
+
"cost_usd-desc": "비용높은순",
|
|
234
|
+
"cost_usd-asc": "비용낮은순"
|
|
235
|
+
},
|
|
211
236
|
"RequestLogSearchField": {
|
|
212
237
|
"id": "ID",
|
|
213
238
|
"token_name": "토큰이름",
|
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
asArray,
|
|
4
4
|
BadRequestException,
|
|
5
5
|
BaseModelClass,
|
|
6
|
-
exhaustive,
|
|
7
6
|
type ListResult,
|
|
8
7
|
NotFoundException,
|
|
9
8
|
Puri,
|
|
@@ -152,6 +151,43 @@ function normalizeLegacyAnthropicRow<T extends RequestLogUsageRow>(row: T): T {
|
|
|
152
151
|
return normalized;
|
|
153
152
|
}
|
|
154
153
|
|
|
154
|
+
/**
|
|
155
|
+
* `orderBy` enum 값(`컬럼-방향`)을 정렬 절로 옮긴다.
|
|
156
|
+
*
|
|
157
|
+
* enum 이 18 종이라 케이스를 일일이 적는 대신 파싱하되, 컬럼은 아래 목록으로 한정한다 —
|
|
158
|
+
* 문자열을 그대로 컬럼명에 넘기면 enum 이 바뀔 때 검증 없는 식별자가 SQL 로 흘러든다.
|
|
159
|
+
* 목록에 없는 값은 기본 정렬(id desc)로 떨어진다.
|
|
160
|
+
*/
|
|
161
|
+
const ORDERABLE_COLUMNS = {
|
|
162
|
+
id: "request_logs.id",
|
|
163
|
+
created_at: "request_logs.created_at",
|
|
164
|
+
ttft_ms: "request_logs.ttft_ms",
|
|
165
|
+
duration_ms: "request_logs.duration_ms",
|
|
166
|
+
input_tokens: "request_logs.input_tokens",
|
|
167
|
+
output_tokens: "request_logs.output_tokens",
|
|
168
|
+
cache_read_tokens: "request_logs.cache_read_tokens",
|
|
169
|
+
cache_creation_tokens: "request_logs.cache_creation_tokens",
|
|
170
|
+
cost_usd: "request_logs.cost_usd",
|
|
171
|
+
} as const;
|
|
172
|
+
|
|
173
|
+
function applyOrderBy(
|
|
174
|
+
qb: ReturnType<RequestLogModelClass["getSubsetQueries"]>["qb"],
|
|
175
|
+
orderBy: string,
|
|
176
|
+
): void {
|
|
177
|
+
const separator = orderBy.lastIndexOf("-");
|
|
178
|
+
const key = orderBy.slice(0, separator) as keyof typeof ORDERABLE_COLUMNS;
|
|
179
|
+
const direction = orderBy.slice(separator + 1) === "asc" ? "asc" : "desc";
|
|
180
|
+
const column = ORDERABLE_COLUMNS[key];
|
|
181
|
+
|
|
182
|
+
if (!column) {
|
|
183
|
+
qb.orderBy("request_logs.id", "desc");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
qb.orderBy(column, direction);
|
|
187
|
+
// 동점 행의 순서가 페이지마다 흔들리지 않게 고유 컬럼으로 tie-break 한다.
|
|
188
|
+
if (key !== "id") qb.orderBy("request_logs.id", "desc");
|
|
189
|
+
}
|
|
190
|
+
|
|
155
191
|
/*
|
|
156
192
|
RequestLog Model
|
|
157
193
|
*/
|
|
@@ -254,15 +290,7 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
254
290
|
|
|
255
291
|
this.applyListFilters(qb, params);
|
|
256
292
|
|
|
257
|
-
|
|
258
|
-
if (params.orderBy) {
|
|
259
|
-
// default orderBy
|
|
260
|
-
if (params.orderBy === "id-desc") {
|
|
261
|
-
qb.orderBy("request_logs.id", "desc");
|
|
262
|
-
} else {
|
|
263
|
-
exhaustive(params.orderBy);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
293
|
+
if (params.orderBy) applyOrderBy(qb, params.orderBy);
|
|
266
294
|
|
|
267
295
|
const enhancers = this.createEnhancers({
|
|
268
296
|
A: (row) => normalizeLegacyAnthropicRow(row),
|
|
@@ -705,6 +733,18 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
705
733
|
return rows.map((r) => r.project_name!);
|
|
706
734
|
}
|
|
707
735
|
|
|
736
|
+
async distinctModelNames(): Promise<string[]> {
|
|
737
|
+
const rows = await this.getPuri("r")
|
|
738
|
+
.from("request_logs")
|
|
739
|
+
.distinct("model_name")
|
|
740
|
+
.where("model_name", "!=", null)
|
|
741
|
+
.orderBy("model_name", "asc")
|
|
742
|
+
.select({
|
|
743
|
+
model_name: "model_name",
|
|
744
|
+
});
|
|
745
|
+
return rows.map((r) => r.model_name!);
|
|
746
|
+
}
|
|
747
|
+
|
|
708
748
|
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
709
749
|
async del(ids: number[]): Promise<number> {
|
|
710
750
|
const wdb = this.getPuri("w");
|
|
@@ -53,9 +53,49 @@ const ToolDefinitions = z.array(
|
|
|
53
53
|
type ToolDefinitions = z.infer<typeof ToolDefinitions>;
|
|
54
54
|
|
|
55
55
|
// Enums: RequestLog
|
|
56
|
-
export const RequestLogOrderBy = z
|
|
56
|
+
export const RequestLogOrderBy = z
|
|
57
|
+
.enum([
|
|
58
|
+
"id-desc",
|
|
59
|
+
"id-asc",
|
|
60
|
+
"created_at-desc",
|
|
61
|
+
"created_at-asc",
|
|
62
|
+
"ttft_ms-desc",
|
|
63
|
+
"ttft_ms-asc",
|
|
64
|
+
"duration_ms-desc",
|
|
65
|
+
"duration_ms-asc",
|
|
66
|
+
"input_tokens-desc",
|
|
67
|
+
"input_tokens-asc",
|
|
68
|
+
"output_tokens-desc",
|
|
69
|
+
"output_tokens-asc",
|
|
70
|
+
"cache_read_tokens-desc",
|
|
71
|
+
"cache_read_tokens-asc",
|
|
72
|
+
"cache_creation_tokens-desc",
|
|
73
|
+
"cache_creation_tokens-asc",
|
|
74
|
+
"cost_usd-desc",
|
|
75
|
+
"cost_usd-asc",
|
|
76
|
+
])
|
|
77
|
+
.describe("RequestLogOrderBy");
|
|
57
78
|
export type RequestLogOrderBy = z.infer<typeof RequestLogOrderBy>;
|
|
58
|
-
export const RequestLogOrderByLabel = {
|
|
79
|
+
export const RequestLogOrderByLabel = {
|
|
80
|
+
"id-desc": "ID최신순",
|
|
81
|
+
"id-asc": "ID오래된순",
|
|
82
|
+
"created_at-desc": "등록일시최신순",
|
|
83
|
+
"created_at-asc": "등록일시오래된순",
|
|
84
|
+
"ttft_ms-desc": "TTFT긴순",
|
|
85
|
+
"ttft_ms-asc": "TTFT짧은순",
|
|
86
|
+
"duration_ms-desc": "소요시간긴순",
|
|
87
|
+
"duration_ms-asc": "소요시간짧은순",
|
|
88
|
+
"input_tokens-desc": "입력토큰많은순",
|
|
89
|
+
"input_tokens-asc": "입력토큰적은순",
|
|
90
|
+
"output_tokens-desc": "출력토큰많은순",
|
|
91
|
+
"output_tokens-asc": "출력토큰적은순",
|
|
92
|
+
"cache_read_tokens-desc": "캐시읽기많은순",
|
|
93
|
+
"cache_read_tokens-asc": "캐시읽기적은순",
|
|
94
|
+
"cache_creation_tokens-desc": "캐시생성많은순",
|
|
95
|
+
"cache_creation_tokens-asc": "캐시생성적은순",
|
|
96
|
+
"cost_usd-desc": "비용높은순",
|
|
97
|
+
"cost_usd-asc": "비용낮은순",
|
|
98
|
+
};
|
|
59
99
|
export const RequestLogSearchField = z
|
|
60
100
|
.enum(["id", "token_name", "user_prompt"])
|
|
61
101
|
.describe("RequestLogSearchField");
|
|
@@ -128,7 +128,10 @@ describe("TokenModel.deactivateIfActive", () => {
|
|
|
128
128
|
it("deactivates in one statement that carries the last-token guard", async () => {
|
|
129
129
|
const raw = mockKnex([{ rowCount: 1 }]);
|
|
130
130
|
|
|
131
|
-
await expect(TokenModel.deactivateIfActive(7)).resolves.
|
|
131
|
+
await expect(TokenModel.deactivateIfActive(7)).resolves.toEqual({
|
|
132
|
+
deactivated: true,
|
|
133
|
+
keptAsLastActive: false,
|
|
134
|
+
});
|
|
132
135
|
expect(raw).toHaveBeenCalledTimes(1);
|
|
133
136
|
const sql = raw.mock.calls[0]![0] as string;
|
|
134
137
|
expect(sql).toContain("UPDATE tokens SET active = false");
|
|
@@ -138,14 +141,21 @@ describe("TokenModel.deactivateIfActive", () => {
|
|
|
138
141
|
it("returns false without a diagnostic read when the token is already inactive", async () => {
|
|
139
142
|
const raw = mockKnex([{ rowCount: 0 }, { rows: [] }]);
|
|
140
143
|
|
|
141
|
-
await expect(TokenModel.deactivateIfActive(7)).resolves.
|
|
144
|
+
await expect(TokenModel.deactivateIfActive(7)).resolves.toEqual({
|
|
145
|
+
deactivated: false,
|
|
146
|
+
keptAsLastActive: false,
|
|
147
|
+
});
|
|
142
148
|
expect(raw).toHaveBeenCalledTimes(2);
|
|
143
149
|
});
|
|
144
150
|
|
|
145
151
|
it("refuses to deactivate the provider's last active token", async () => {
|
|
146
152
|
const raw = mockKnex([{ rowCount: 0 }, { rows: [{ provider: "anthropic" }] }]);
|
|
147
153
|
|
|
148
|
-
|
|
154
|
+
// 마지막 활성 토큰은 살려두되, 호출부가 systemic 실패로 알릴 수 있게 구분해 알린다.
|
|
155
|
+
await expect(TokenModel.deactivateIfActive(7)).resolves.toEqual({
|
|
156
|
+
deactivated: false,
|
|
157
|
+
keptAsLastActive: true,
|
|
158
|
+
});
|
|
149
159
|
expect(raw).toHaveBeenCalledTimes(2);
|
|
150
160
|
});
|
|
151
161
|
});
|
|
@@ -146,6 +146,25 @@ class TokenModelClass extends BaseModelClass<
|
|
|
146
146
|
return result.rows;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* 라우팅에서 빠진 토큰들. 세션 만료로 자동 비활성화된 것과 수동으로 끈 것이 섞여 있다 —
|
|
151
|
+
* 둘 다 "재로그인하면 살아나는" 상태라 알림 대상으로는 같게 본다.
|
|
152
|
+
*/
|
|
153
|
+
async findInactive<T extends TokenSubsetKey>(subset: T): Promise<TokenSubsetMapping[T][]> {
|
|
154
|
+
const { qb } = this.getSubsetQueries(subset);
|
|
155
|
+
qb.where("tokens.active", false);
|
|
156
|
+
qb.orderBy("tokens.name", "asc");
|
|
157
|
+
const enhancers = this.createEnhancers({ A: (row) => ({ ...row }) });
|
|
158
|
+
const result = await this.executeSubsetQuery({
|
|
159
|
+
subset,
|
|
160
|
+
qb,
|
|
161
|
+
params: { num: 100, page: 1 },
|
|
162
|
+
enhancers,
|
|
163
|
+
debug: false,
|
|
164
|
+
});
|
|
165
|
+
return result.rows;
|
|
166
|
+
}
|
|
167
|
+
|
|
149
168
|
async findActive<T extends TokenSubsetKey>(subset: T): Promise<TokenSubsetMapping[T][]> {
|
|
150
169
|
const { qb } = this.getSubsetQueries(subset);
|
|
151
170
|
qb.where("tokens.active", true);
|
|
@@ -188,26 +207,17 @@ class TokenModelClass extends BaseModelClass<
|
|
|
188
207
|
/**
|
|
189
208
|
* 같은 계정의 기존 토큰을 지우고 새로 저장한다(로그인·재로그인 공통 경로).
|
|
190
209
|
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
* provider 응답에 계정 식별자가 없으면 dedup 자체가 불가능하므로 경고만 남기고
|
|
195
|
-
* 중복 제거 없이 저장한다(죽은 row 가 남는다). 이때는 기존 토큰 유무를 알 수 없어
|
|
196
|
-
* 신규로 보지 않는다 — 재로그인을 신규로 잘못 알리는 편보다 조용한 편이 낫다.
|
|
210
|
+
* provider 응답에 계정 식별자가 없으면 dedup 자체가 불가능하므로 경고만 남기고
|
|
211
|
+
* 중복 제거 없이 저장한다(죽은 row 가 남는다).
|
|
197
212
|
*/
|
|
198
213
|
async replaceByAccount(
|
|
199
214
|
provider: string,
|
|
200
215
|
accountId: string | undefined,
|
|
201
216
|
saveParams: TokenSaveParams,
|
|
202
|
-
): Promise<
|
|
203
|
-
let isNew = false;
|
|
217
|
+
): Promise<void> {
|
|
204
218
|
if (accountId) {
|
|
205
219
|
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
|
-
}
|
|
220
|
+
if (oldEntries.length > 0) await this.del(oldEntries.map((o) => o.id));
|
|
211
221
|
} else {
|
|
212
222
|
logger.warn(
|
|
213
223
|
`${provider} login without account identifier: dedup skipped for ${saveParams.name ?? "unnamed"}`,
|
|
@@ -215,10 +225,16 @@ class TokenModelClass extends BaseModelClass<
|
|
|
215
225
|
}
|
|
216
226
|
|
|
217
227
|
await this.save([saveParams]);
|
|
218
|
-
return { isNew };
|
|
219
228
|
}
|
|
220
229
|
|
|
221
|
-
|
|
230
|
+
/**
|
|
231
|
+
* @returns `deactivated` 는 이 호출이 실제로 비활성화를 수행했는지 — 호출부의 알림
|
|
232
|
+
* 게이트다. `keptAsLastActive` 는 마지막 활성 토큰이라 거부한 경우로, 사망 자체는
|
|
233
|
+
* 사실이지만 풀을 비우지 않으려고 살려둔 상태다(systemic 실패 신호).
|
|
234
|
+
*/
|
|
235
|
+
async deactivateIfActive(
|
|
236
|
+
id: number,
|
|
237
|
+
): Promise<{ deactivated: boolean; keptAsLastActive: boolean }> {
|
|
222
238
|
const wdb = this.getPuri("w");
|
|
223
239
|
|
|
224
240
|
// 활성 여부와 "마지막 남은 토큰인가"를 한 statement 안에서 판정한다. 두 조건을
|
|
@@ -231,7 +247,7 @@ class TokenModelClass extends BaseModelClass<
|
|
|
231
247
|
WHERE peer.provider = tokens.provider AND peer.active) > 1`,
|
|
232
248
|
[id],
|
|
233
249
|
);
|
|
234
|
-
if ((updated.rowCount ?? 0) > 0) return true;
|
|
250
|
+
if ((updated.rowCount ?? 0) > 0) return { deactivated: true, keptAsLastActive: false };
|
|
235
251
|
|
|
236
252
|
// 갱신되지 않은 이유는 둘 중 하나다: 이미 비활성이거나(정상 경쟁 결과), 마지막
|
|
237
253
|
// 활성 토큰이거나. 후자는 systemic 실패 신호라 남겨야 하므로 이때만 한 번 더 읽는다.
|
|
@@ -246,8 +262,9 @@ class TokenModelClass extends BaseModelClass<
|
|
|
246
262
|
`refusing to auto-deactivate token ${id}: last active ${survivor.provider} token — ` +
|
|
247
263
|
`systemic refresh failure suspected, keeping the pool non-empty`,
|
|
248
264
|
);
|
|
265
|
+
return { deactivated: false, keptAsLastActive: true };
|
|
249
266
|
}
|
|
250
|
-
return false;
|
|
267
|
+
return { deactivated: false, keptAsLastActive: false };
|
|
251
268
|
}
|
|
252
269
|
|
|
253
270
|
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
package/bundle/src/i18n/en.ts
CHANGED
|
@@ -47,6 +47,9 @@ export default {
|
|
|
47
47
|
"error.internalServerError": "Internal server error",
|
|
48
48
|
"error.notFound": "Not found",
|
|
49
49
|
"error.serviceUnavailable": "Service unavailable",
|
|
50
|
+
"qgrid.dispatcherFailed": (provider: string) => `${provider} dispatcher failed to initialize`,
|
|
51
|
+
"qgrid.dispatcherStarting": (provider: string) =>
|
|
52
|
+
`${provider} dispatcher is starting up. Please retry shortly`,
|
|
50
53
|
"error.unauthorized": "Unauthorized",
|
|
51
54
|
notFound: (name: string, id: number) => `${name} ID ${id} not found`,
|
|
52
55
|
"validation.email": "Invalid email format",
|
package/bundle/src/i18n/ko.ts
CHANGED
|
@@ -46,6 +46,9 @@ export default {
|
|
|
46
46
|
"error.internalServerError": "서버 오류가 발생했습니다",
|
|
47
47
|
"error.notFound": "찾을 수 없습니다",
|
|
48
48
|
"error.serviceUnavailable": "서비스를 사용할 수 없습니다",
|
|
49
|
+
"qgrid.dispatcherFailed": (provider: string) => `${provider} dispatcher 초기화에 실패했습니다`,
|
|
50
|
+
"qgrid.dispatcherStarting": (provider: string) =>
|
|
51
|
+
`${provider} dispatcher 기동 중입니다. 잠시 후 다시 시도해주세요`,
|
|
49
52
|
"error.unauthorized": "인증이 필요합니다",
|
|
50
53
|
"error.unknownSearchField": (field: string) => `구현되지 않은 검색 필드 ${field}`,
|
|
51
54
|
notFound: (name: string, id: number) => `존재하지 않는 ${name} ID ${id}`,
|
|
@@ -240,6 +240,23 @@ const entityLabels = {
|
|
|
240
240
|
"entity.RequestLog.tool_call_count": "tool call 횟수",
|
|
241
241
|
"entity.RequestLog.is_image_generation": "이미지 생성 turn 여부 (quota 귀속용)",
|
|
242
242
|
"enum.RequestLogOrderBy.id-desc": "ID최신순",
|
|
243
|
+
"enum.RequestLogOrderBy.id-asc": "ID오래된순",
|
|
244
|
+
"enum.RequestLogOrderBy.created_at-desc": "등록일시최신순",
|
|
245
|
+
"enum.RequestLogOrderBy.created_at-asc": "등록일시오래된순",
|
|
246
|
+
"enum.RequestLogOrderBy.ttft_ms-desc": "TTFT긴순",
|
|
247
|
+
"enum.RequestLogOrderBy.ttft_ms-asc": "TTFT짧은순",
|
|
248
|
+
"enum.RequestLogOrderBy.duration_ms-desc": "소요시간긴순",
|
|
249
|
+
"enum.RequestLogOrderBy.duration_ms-asc": "소요시간짧은순",
|
|
250
|
+
"enum.RequestLogOrderBy.input_tokens-desc": "입력토큰많은순",
|
|
251
|
+
"enum.RequestLogOrderBy.input_tokens-asc": "입력토큰적은순",
|
|
252
|
+
"enum.RequestLogOrderBy.output_tokens-desc": "출력토큰많은순",
|
|
253
|
+
"enum.RequestLogOrderBy.output_tokens-asc": "출력토큰적은순",
|
|
254
|
+
"enum.RequestLogOrderBy.cache_read_tokens-desc": "캐시읽기많은순",
|
|
255
|
+
"enum.RequestLogOrderBy.cache_read_tokens-asc": "캐시읽기적은순",
|
|
256
|
+
"enum.RequestLogOrderBy.cache_creation_tokens-desc": "캐시생성많은순",
|
|
257
|
+
"enum.RequestLogOrderBy.cache_creation_tokens-asc": "캐시생성적은순",
|
|
258
|
+
"enum.RequestLogOrderBy.cost_usd-desc": "비용높은순",
|
|
259
|
+
"enum.RequestLogOrderBy.cost_usd-asc": "비용낮은순",
|
|
243
260
|
"enum.RequestLogSearchField.id": "ID",
|
|
244
261
|
"enum.RequestLogSearchField.token_name": "토큰이름",
|
|
245
262
|
"enum.RequestLogSearchField.user_prompt": "사용자 프롬프트",
|
|
@@ -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_model_name_index ON request_logs USING btree(model_name 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(["model_name"], "request_logs_model_name_index");
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type FastifyReply } from "fastify";
|
|
2
|
+
import {
|
|
3
|
+
BadRequestException,
|
|
4
|
+
InternalServerErrorException,
|
|
5
|
+
ServiceUnavailableException,
|
|
6
|
+
} from "sonamu";
|
|
7
|
+
import { describe, expect, it, vi } from "vitest";
|
|
8
|
+
|
|
9
|
+
import { handleServerError } from "./server-error-handler";
|
|
10
|
+
|
|
11
|
+
function fakeReply() {
|
|
12
|
+
const reply = {
|
|
13
|
+
header: vi.fn(),
|
|
14
|
+
status: vi.fn(),
|
|
15
|
+
send: vi.fn(),
|
|
16
|
+
};
|
|
17
|
+
reply.status.mockReturnValue(reply);
|
|
18
|
+
return reply as unknown as FastifyReply & typeof reply;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("handleServerError", () => {
|
|
22
|
+
it("Sonamu 예외의 statusCode 를 그대로 쓴다", () => {
|
|
23
|
+
const reply = fakeReply();
|
|
24
|
+
|
|
25
|
+
handleServerError(new BadRequestException("bad" as never), reply);
|
|
26
|
+
|
|
27
|
+
expect(reply.status).toHaveBeenCalledWith(400);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("Sonamu 예외가 아니면 statusCode 를 신뢰하지 않고 500 으로 내린다", () => {
|
|
31
|
+
const reply = fakeReply();
|
|
32
|
+
// upstream fetch 오류처럼 statusCode 를 달고 오는 예외 — 그대로 에코하면 upstream 의
|
|
33
|
+
// 401 이 호출자 자신의 오류로 둔갑한다.
|
|
34
|
+
const upstream = Object.assign(new Error("upstream said 401"), { statusCode: 401 });
|
|
35
|
+
|
|
36
|
+
handleServerError(upstream, reply);
|
|
37
|
+
|
|
38
|
+
expect(reply.status).toHaveBeenCalledWith(500);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("503 에는 Retry-After 를 붙여 재시도 시점을 알린다", () => {
|
|
42
|
+
const reply = fakeReply();
|
|
43
|
+
|
|
44
|
+
handleServerError(new ServiceUnavailableException("starting up" as never), reply);
|
|
45
|
+
|
|
46
|
+
expect(reply.status).toHaveBeenCalledWith(503);
|
|
47
|
+
expect(reply.header).toHaveBeenCalledWith("Retry-After", expect.any(String));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("503 이 아닌 응답에는 Retry-After 를 붙이지 않는다", () => {
|
|
51
|
+
const reply = fakeReply();
|
|
52
|
+
|
|
53
|
+
handleServerError(new InternalServerErrorException("dead" as never), reply);
|
|
54
|
+
|
|
55
|
+
expect(reply.status).toHaveBeenCalledWith(500);
|
|
56
|
+
expect(reply.header).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -1,20 +1,31 @@
|
|
|
1
1
|
import { getLogger } from "@logtape/logtape";
|
|
2
2
|
import { type FastifyReply } from "fastify";
|
|
3
|
-
import {
|
|
3
|
+
import { SoException } from "sonamu";
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
5
|
+
// dispatcher 기동은 dev0 기준 1~2분 걸린다(워커 25개 × spawn 간격). 그보다 짧게 잡으면
|
|
6
|
+
// 재시도가 같은 503 을 다시 받는다.
|
|
7
|
+
const RETRY_AFTER_SECONDS = "30";
|
|
8
|
+
|
|
9
|
+
// statusCode 를 신뢰하는 범위를 Sonamu 예외로 한정한다. qgrid 는 프록시라 upstream HTTP
|
|
10
|
+
// 오류(fetch/undici 류)도 statusCode 를 달고 나오는데, 그걸 그대로 에코하면 upstream 의
|
|
11
|
+
// 401/429 가 호출자 자신의 오류로 둔갑한다. Sonamu 예외가 아니면 500.
|
|
12
|
+
//
|
|
13
|
+
// sonamu 의 isSoException 은 `statusCode !== undefined` 만 보는 duck-typing 이라 바로 그
|
|
14
|
+
// upstream 예외를 통과시킨다. 여기서는 instanceof 로 실제 계약을 확인한다.
|
|
8
15
|
export function handleServerError(error: Error, reply: FastifyReply): void {
|
|
9
16
|
getLogger(["qgrid"]).error(`${error}`);
|
|
10
17
|
const statusCode =
|
|
11
|
-
|
|
18
|
+
error instanceof SoException &&
|
|
12
19
|
Number.isInteger(error.statusCode) &&
|
|
13
20
|
error.statusCode >= 400 &&
|
|
14
21
|
error.statusCode <= 599
|
|
15
22
|
? error.statusCode
|
|
16
23
|
: 500;
|
|
17
24
|
|
|
25
|
+
// 503 은 "지금은 아니지만 곧 된다"는 뜻이라 재시도 시점을 함께 준다. 값이 없으면
|
|
26
|
+
// 클라이언트는 재시도 간격을 스스로 정해야 하고, 대개 즉시 재시도해 부하만 키운다.
|
|
27
|
+
if (statusCode === 503) reply.header("Retry-After", RETRY_AFTER_SECONDS);
|
|
28
|
+
|
|
18
29
|
reply.status(statusCode).send({
|
|
19
30
|
name: error.name,
|
|
20
31
|
message: error.message,
|
|
@@ -7,6 +7,10 @@ import { CachePresets, defineConfig } from "sonamu";
|
|
|
7
7
|
import { drivers as cacheDrivers, store } from "sonamu/cache";
|
|
8
8
|
|
|
9
9
|
import { monitLogSink } from "./application/monit/log-buffer";
|
|
10
|
+
import {
|
|
11
|
+
startExpiredTokenDigest,
|
|
12
|
+
stopExpiredTokenDigest,
|
|
13
|
+
} from "./application/qgrid/expired-token-digest";
|
|
10
14
|
import { QgridDispatcher } from "./application/qgrid/qgrid.dispatcher";
|
|
11
15
|
import { QgridFrame } from "./application/qgrid/qgrid.frame";
|
|
12
16
|
import { TokenSubscriber } from "./application/qgrid/token-subscriber";
|
|
@@ -179,11 +183,15 @@ export default defineConfig({
|
|
|
179
183
|
|
|
180
184
|
const started = await subscriber.start();
|
|
181
185
|
|
|
186
|
+
// 기동 상태를 남긴다 — HTTP 는 이미 열려 있어서, 준비 전 요청이 "잠시 후 되는지"
|
|
187
|
+
// "재시도해도 안 되는지"를 구분해 응답해야 한다(503 vs 500).
|
|
182
188
|
try {
|
|
183
189
|
const openaiDispatcher = new OpenAIDispatcher();
|
|
184
190
|
await openaiDispatcher.start();
|
|
185
191
|
QgridDispatcher.openaiDispatcher = openaiDispatcher;
|
|
192
|
+
QgridDispatcher.startupState.openai = "ready";
|
|
186
193
|
} catch (e) {
|
|
194
|
+
QgridDispatcher.startupState.openai = "failed";
|
|
187
195
|
log.warn(`openai dispatcher failed: ${(e as Error).message}`);
|
|
188
196
|
}
|
|
189
197
|
|
|
@@ -191,10 +199,14 @@ export default defineConfig({
|
|
|
191
199
|
const anthropicDispatcher = new AnthropicDispatcher();
|
|
192
200
|
await anthropicDispatcher.start();
|
|
193
201
|
QgridDispatcher.anthropicDispatcher = anthropicDispatcher;
|
|
202
|
+
QgridDispatcher.startupState.anthropic = "ready";
|
|
194
203
|
} catch (e) {
|
|
204
|
+
QgridDispatcher.startupState.anthropic = "failed";
|
|
195
205
|
log.warn(`anthropic dispatcher failed: ${(e as Error).message}`);
|
|
196
206
|
}
|
|
197
207
|
|
|
208
|
+
startExpiredTokenDigest();
|
|
209
|
+
|
|
198
210
|
const anthropicCount = QgridDispatcher.anthropicDispatcher?.tokenCount ?? 0;
|
|
199
211
|
const openaiReady = QgridDispatcher.openaiDispatcher?.readyWorkerCount ?? 0;
|
|
200
212
|
const openaiTotal = QgridDispatcher.openaiDispatcher?.workerCount ?? 0;
|
|
@@ -208,6 +220,7 @@ export default defineConfig({
|
|
|
208
220
|
},
|
|
209
221
|
onShutdown: async () => {
|
|
210
222
|
const log = getLogger(["qgrid", "startup"]);
|
|
223
|
+
stopExpiredTokenDigest();
|
|
211
224
|
if (QgridDispatcher.openaiDispatcher) {
|
|
212
225
|
await QgridDispatcher.openaiDispatcher.stop();
|
|
213
226
|
}
|
|
@@ -36,10 +36,10 @@ describe("notifySlack", () => {
|
|
|
36
36
|
expect(url).toBe("https://slack.com/api/chat.postMessage");
|
|
37
37
|
expect(JSON.parse((init as RequestInit).body as string)).toEqual({
|
|
38
38
|
channel: "C123",
|
|
39
|
-
// blocks 만 보내면 푸시 알림이 빈칸으로 뜨므로 대체 텍스트가 함께 나가야 한다.
|
|
40
|
-
text: "토큰 추가 — anthropic/test-token",
|
|
41
39
|
attachments: [
|
|
42
40
|
{
|
|
41
|
+
// 최상위 text 가 아니라 attachment fallback 이어야 본문 제목과 겹치지 않는다.
|
|
42
|
+
fallback: "토큰 추가 — anthropic/test-token",
|
|
43
43
|
color: SLACK_COLOR.good,
|
|
44
44
|
blocks: [
|
|
45
45
|
{
|
|
@@ -66,8 +66,10 @@ describe("notifySlack", () => {
|
|
|
66
66
|
|
|
67
67
|
const body = JSON.parse(
|
|
68
68
|
(fetchMock.mock.calls[0]![1] as RequestInit).body as string,
|
|
69
|
-
) as { text
|
|
70
|
-
|
|
69
|
+
) as { text?: string; attachments: [{ fallback: string; color?: string; blocks: unknown[] }] };
|
|
70
|
+
// 최상위 text 를 보내지 않아야 blocks 렌더 화면에서 제목이 중복되지 않는다.
|
|
71
|
+
expect(body.text).toBeUndefined();
|
|
72
|
+
expect(body.attachments[0].fallback).toBe("제목만");
|
|
71
73
|
expect(body.attachments[0].color).toBeUndefined();
|
|
72
74
|
expect(body.attachments[0].blocks).toHaveLength(1);
|
|
73
75
|
});
|
|
@@ -55,9 +55,16 @@ export async function notifySlack(notification: SlackNotification): Promise<void
|
|
|
55
55
|
},
|
|
56
56
|
body: JSON.stringify({
|
|
57
57
|
channel,
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
// fallback 은 attachment 안에 둔다. 최상위 text 로 보내면 blocks 가 정상 렌더되는
|
|
59
|
+
// 화면에서도 함께 출력돼 제목이 두 번 보인다. attachment fallback 은 blocks 를
|
|
60
|
+
// 못 그리는 클라이언트와 푸시 알림에서만 쓰인다.
|
|
61
|
+
attachments: [
|
|
62
|
+
{
|
|
63
|
+
fallback: subject ? `${title} — ${subject}` : title,
|
|
64
|
+
...(color ? { color } : {}),
|
|
65
|
+
blocks,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
61
68
|
}),
|
|
62
69
|
signal: AbortSignal.timeout(SLACK_TIMEOUT_MS),
|
|
63
70
|
});
|