@cartanova/qgrid-cli 2.6.1 → 2.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.ko.md +2 -0
  2. package/README.md +2 -0
  3. package/bundle/dist/application/qgrid/oauth.js +21 -3
  4. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +40 -6
  5. package/bundle/dist/application/qgrid/qgrid.frame.js +47 -19
  6. package/bundle/dist/application/qgrid/qgrid.types.js +13 -3
  7. package/bundle/dist/application/qgrid/token-death.js +77 -0
  8. package/bundle/dist/application/queries.generated.js +3 -2
  9. package/bundle/dist/application/request-log/request-log.model.js +105 -31
  10. package/bundle/dist/application/request-log-step/request-log-step.model.js +1 -1
  11. package/bundle/dist/application/sonamu.generated.js +60 -5
  12. package/bundle/dist/application/sonamu.generated.sso.js +18 -2
  13. package/bundle/dist/application/token/token.model.js +53 -2
  14. package/bundle/dist/i18n/en.js +3 -1
  15. package/bundle/dist/i18n/ko.js +3 -1
  16. package/bundle/dist/i18n/sd.generated.js +18 -1
  17. package/bundle/dist/migrations/20260807133553_manual__backfill_request_log_cost.js +49 -0
  18. package/bundle/dist/migrations/20260807145144_alter_request_logs_alter1.js +13 -0
  19. package/bundle/dist/migrations/20260807164057_alter_request_logs_alter1.js +13 -0
  20. package/bundle/dist/server-error-handler.js +5 -3
  21. package/bundle/dist/sonamu.config.js +5 -1
  22. package/bundle/dist/utils/providers/openai/openai-refresh.js +12 -1
  23. package/bundle/dist/utils/slack-notify.js +65 -0
  24. package/bundle/src/application/qgrid/oauth.ts +20 -1
  25. package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +15 -3
  26. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +30 -2
  27. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +44 -6
  28. package/bundle/src/application/qgrid/qgrid.frame.ts +67 -47
  29. package/bundle/src/application/qgrid/qgrid.types.ts +10 -0
  30. package/bundle/src/application/qgrid/token-death.test.ts +150 -0
  31. package/bundle/src/application/qgrid/token-death.ts +96 -0
  32. package/bundle/src/application/queries.generated.ts +5 -2
  33. package/bundle/src/application/request-log/request-log.entity.json +46 -7
  34. package/bundle/src/application/request-log/request-log.model.test.ts +153 -42
  35. package/bundle/src/application/request-log/request-log.model.ts +136 -50
  36. package/bundle/src/application/sonamu.generated.http +19 -1
  37. package/bundle/src/application/sonamu.generated.sso.ts +16 -0
  38. package/bundle/src/application/sonamu.generated.ts +58 -3
  39. package/bundle/src/application/token/token.model.test.ts +49 -0
  40. package/bundle/src/application/token/token.model.ts +75 -0
  41. package/bundle/src/i18n/en.ts +3 -0
  42. package/bundle/src/i18n/ko.ts +3 -0
  43. package/bundle/src/i18n/sd.generated.ts +17 -0
  44. package/bundle/src/migrations/20260807133553_manual__backfill_request_log_cost.ts +110 -0
  45. package/bundle/src/migrations/20260807145144_alter_request_logs_alter1.ts +13 -0
  46. package/bundle/src/migrations/20260807164057_alter_request_logs_alter1.ts +13 -0
  47. package/bundle/src/server-error-handler.test.ts +58 -0
  48. package/bundle/src/server-error-handler.ts +16 -5
  49. package/bundle/src/sonamu.config.ts +6 -0
  50. package/bundle/src/utils/providers/openai/openai-refresh.ts +17 -0
  51. package/bundle/src/utils/slack-notify.test.ts +97 -0
  52. package/bundle/src/utils/slack-notify.ts +74 -0
  53. package/bundle/web-dist/client/assets/index-BGQSGMcK.css +1 -0
  54. package/bundle/web-dist/client/assets/index-CnNT0mdq.js +78 -0
  55. package/bundle/web-dist/client/assets/logs-DCY81UWb.js +1 -0
  56. package/bundle/web-dist/client/assets/{monit-CyqMmy1G.js → monit-D5OoAday.js} +1 -1
  57. package/bundle/web-dist/client/assets/routes-B4D9C86u.js +5 -0
  58. package/bundle/web-dist/client/assets/{sd.generated-CjW3rfJ8.js → sd.generated-DYdwSB2I.js} +1 -1
  59. package/bundle/web-dist/client/assets/{services.generated-BuT6Gp8V.js → services.generated-BsAvotjG.js} +1 -1
  60. package/bundle/web-dist/client/assets/{show-C5WxfoAt.js → show-BSX8ii7Z.js} +3 -3
  61. package/bundle/web-dist/client/assets/tokens-Gapk41cz.js +1 -0
  62. package/bundle/web-dist/client/assets/{use-update-token-mutation-DBkxL29Y.js → use-update-token-mutation-DqEpK7It.js} +1 -1
  63. package/bundle/web-dist/client/index.html +3 -3
  64. package/bundle/web-dist/server/assets/{lazyRouteComponent-PRxXWW3t.js → lazyRouteComponent-BaZaveNc.js} +1 -1
  65. package/bundle/web-dist/server/assets/{logs-D0z1Ghj1.js → logs-DMOw3SjD.js} +91 -42
  66. package/bundle/web-dist/server/assets/{monit-dBXxnAyI.js → monit-UmcqHroX.js} +2 -2
  67. package/bundle/web-dist/server/assets/{routes-B3p7fuwk.js → routes-Sv_4FjtH.js} +52 -46
  68. package/bundle/web-dist/server/assets/{sd.generated-ek-tz-7H.js → sd.generated-B0xqnHEo.js} +21 -0
  69. package/bundle/web-dist/server/assets/{services.generated-BNuxWs7K.js → services.generated-DqAKskAS.js} +23 -8
  70. package/bundle/web-dist/server/assets/{show-0iNioJ2I.js → show-C-oSdzHX.js} +31 -25
  71. package/bundle/web-dist/server/assets/{tokens-CvytQ9Xy.js → tokens-h-2A3wfD.js} +292 -191
  72. package/bundle/web-dist/server/assets/{use-update-token-mutation-BBCC2GKM.js → use-update-token-mutation-DHCaa7Ku.js} +1 -1
  73. package/bundle/web-dist/server/entry-server.generated.js +70 -29
  74. package/package.json +1 -1
  75. package/skills/qgrid/references/cli-env-and-server-boot.md +8 -0
  76. package/skills/qgrid/references/token-auth-quota-lifecycle.md +11 -0
  77. package/bundle/web-dist/client/assets/index-BGGBFmzE.js +0 -78
  78. package/bundle/web-dist/client/assets/index-GA9b-zy4.css +0 -1
  79. package/bundle/web-dist/client/assets/logs-Dh19u6Tk.js +0 -1
  80. package/bundle/web-dist/client/assets/routes-Cy0gCq8c.js +0 -5
  81. package/bundle/web-dist/client/assets/tokens-Ciolqw4l.js +0 -1
@@ -434,7 +434,19 @@ Content-Type: application/json
434
434
  ###
435
435
 
436
436
  GET {{baseUrl}}/api/qgrid/totalCost
437
- ?tokenName=TOKENNAME
437
+ ?params[num]=24
438
+ &params[page]=1
439
+ &params[search]=id
440
+ &params[keyword]=KEYWORD
441
+ &params[orderBy]=id-desc
442
+ &params[queryMode]=both
443
+ &params[id]=0
444
+ &params[sonamuFilter]=unknown-custom
445
+ &params[token_name]=
446
+ &params[project_name]=
447
+ &params[model_name]=
448
+ &params[project_name_is_null]=false
449
+ &params[project_name_is_not_null]=false
438
450
  Content-Type: application/json
439
451
 
440
452
  ###
@@ -445,6 +457,12 @@ Content-Type: application/json
445
457
 
446
458
  ###
447
459
 
460
+ GET {{baseUrl}}/api/qgrid/modelNames
461
+ ?
462
+ Content-Type: application/json
463
+
464
+ ###
465
+
448
466
  POST {{baseUrl}}/api/qgrid/addToken
449
467
  Content-Type: application/json
450
468
 
@@ -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
@@ -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.enum(["id-desc"]).describe("RequestLogOrderBy");
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 = { "id-desc": "ID최신순" };
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");
@@ -353,11 +393,26 @@ export const RequestLogSubsetP = z.object({
353
393
  is_image_generation: z.boolean(),
354
394
  });
355
395
  export type RequestLogSubsetP = z.infer<typeof RequestLogSubsetP>;
396
+ export const RequestLogSubsetC = z.object({
397
+ id: z.int(),
398
+ token_name: z.string().max(100).nullable(),
399
+ model_name: z.string().max(255).nullable(),
400
+ input_tokens: z.int(),
401
+ output_tokens: z.int(),
402
+ cache_read_tokens: z.int(),
403
+ cache_creation_tokens: z.int(),
404
+ cache_creation_5m_tokens: z.int().nullable(),
405
+ cache_creation_1h_tokens: z.int().nullable(),
406
+ cost_usd: z.int().nullable(),
407
+ cost_source: z.string().max(20).nullable(),
408
+ });
409
+ export type RequestLogSubsetC = z.infer<typeof RequestLogSubsetC>;
356
410
  export type RequestLogSubsetMapping = {
357
411
  A: RequestLogSubsetA;
358
412
  P: RequestLogSubsetP;
413
+ C: RequestLogSubsetC;
359
414
  };
360
- export const RequestLogSubsetKey = z.enum(["A", "P"]);
415
+ export const RequestLogSubsetKey = z.enum(["A", "P", "C"]);
361
416
  export type RequestLogSubsetKey = z.infer<typeof RequestLogSubsetKey>;
362
417
 
363
418
  // Subsets: RequestLogStep
@@ -110,3 +110,52 @@ 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.toEqual({
132
+ deactivated: true,
133
+ keptAsLastActive: false,
134
+ });
135
+ expect(raw).toHaveBeenCalledTimes(1);
136
+ const sql = raw.mock.calls[0]![0] as string;
137
+ expect(sql).toContain("UPDATE tokens SET active = false");
138
+ expect(sql).toContain("count(*)");
139
+ });
140
+
141
+ it("returns false without a diagnostic read when the token is already inactive", async () => {
142
+ const raw = mockKnex([{ rowCount: 0 }, { rows: [] }]);
143
+
144
+ await expect(TokenModel.deactivateIfActive(7)).resolves.toEqual({
145
+ deactivated: false,
146
+ keptAsLastActive: false,
147
+ });
148
+ expect(raw).toHaveBeenCalledTimes(2);
149
+ });
150
+
151
+ it("refuses to deactivate the provider's last active token", async () => {
152
+ const raw = mockKnex([{ rowCount: 0 }, { rows: [{ provider: "anthropic" }] }]);
153
+
154
+ // 마지막 활성 토큰은 살려두되, 호출부가 systemic 실패로 알릴 수 있게 구분해 알린다.
155
+ await expect(TokenModel.deactivateIfActive(7)).resolves.toEqual({
156
+ deactivated: false,
157
+ keptAsLastActive: true,
158
+ });
159
+ expect(raw).toHaveBeenCalledTimes(2);
160
+ });
161
+ });
@@ -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,78 @@ 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
+ * provider 응답에 계정 식별자가 없으면 dedup 자체가 불가능하므로 경고만 남기고
192
+ * 중복 제거 없이 저장한다(죽은 row 가 남는다).
193
+ */
194
+ async replaceByAccount(
195
+ provider: string,
196
+ accountId: string | undefined,
197
+ saveParams: TokenSaveParams,
198
+ ): Promise<void> {
199
+ if (accountId) {
200
+ const oldEntries = await this.findByAccountIdentifier("A", provider, accountId);
201
+ if (oldEntries.length > 0) await this.del(oldEntries.map((o) => o.id));
202
+ } else {
203
+ logger.warn(
204
+ `${provider} login without account identifier: dedup skipped for ${saveParams.name ?? "unnamed"}`,
205
+ );
206
+ }
207
+
208
+ await this.save([saveParams]);
209
+ }
210
+
211
+ /**
212
+ * @returns `deactivated` 는 이 호출이 실제로 비활성화를 수행했는지 — 호출부의 알림
213
+ * 게이트다. `keptAsLastActive` 는 마지막 활성 토큰이라 거부한 경우로, 사망 자체는
214
+ * 사실이지만 풀을 비우지 않으려고 살려둔 상태다(systemic 실패 신호).
215
+ */
216
+ async deactivateIfActive(
217
+ id: number,
218
+ ): Promise<{ deactivated: boolean; keptAsLastActive: boolean }> {
219
+ const wdb = this.getPuri("w");
220
+
221
+ // 활성 여부와 "마지막 남은 토큰인가"를 한 statement 안에서 판정한다. 두 조건을
222
+ // 따로 읽고 나중에 갱신하면 그 사이에 다른 인스턴스가 같은 provider 의 토큰을
223
+ // 비활성화해 풀이 통째로 빌 수 있다.
224
+ const updated = await wdb.knex.raw<{ rowCount: number }>(
225
+ `UPDATE tokens SET active = false
226
+ WHERE id = ? AND active = true
227
+ AND (SELECT count(*) FROM tokens peer
228
+ WHERE peer.provider = tokens.provider AND peer.active) > 1`,
229
+ [id],
230
+ );
231
+ if ((updated.rowCount ?? 0) > 0) return { deactivated: true, keptAsLastActive: false };
232
+
233
+ // 갱신되지 않은 이유는 둘 중 하나다: 이미 비활성이거나(정상 경쟁 결과), 마지막
234
+ // 활성 토큰이거나. 후자는 systemic 실패 신호라 남겨야 하므로 이때만 한 번 더 읽는다.
235
+ const survivor = (
236
+ await wdb.knex.raw<{ rows: { provider: string }[] }>(
237
+ "SELECT provider FROM tokens WHERE id = ? AND active = true",
238
+ [id],
239
+ )
240
+ ).rows[0];
241
+ if (survivor) {
242
+ logger.error(
243
+ `refusing to auto-deactivate token ${id}: last active ${survivor.provider} token — ` +
244
+ `systemic refresh failure suspected, keeping the pool non-empty`,
245
+ );
246
+ return { deactivated: false, keptAsLastActive: true };
247
+ }
248
+ return { deactivated: false, keptAsLastActive: false };
249
+ }
250
+
176
251
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
177
252
  async reorder(ids: number[]): Promise<{ done: boolean }> {
178
253
  const wdb = this.getPuri("w");
@@ -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",
@@ -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,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
+ }
@@ -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 { isSoException } from "sonamu";
3
+ import { SoException } from "sonamu";
4
4
 
5
- // statusCode 를 신뢰하는 범위를 Sonamu 예외 계약(isSoException)으로 한정한다. qgrid 는 프록시라
6
- // upstream HTTP 오류(fetch/undici 류)도 statusCode 를 달고 나오는데, 그걸 duck-typing 으로
7
- // 그대로 에코하면 upstream 의 401/429 가 호출자 자신의 오류로 둔갑한다. Sonamu 예외가 아니면 500.
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
- isSoException(error) &&
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,
@@ -179,11 +179,15 @@ export default defineConfig({
179
179
 
180
180
  const started = await subscriber.start();
181
181
 
182
+ // 기동 상태를 남긴다 — HTTP 는 이미 열려 있어서, 준비 전 요청이 "잠시 후 되는지"
183
+ // "재시도해도 안 되는지"를 구분해 응답해야 한다(503 vs 500).
182
184
  try {
183
185
  const openaiDispatcher = new OpenAIDispatcher();
184
186
  await openaiDispatcher.start();
185
187
  QgridDispatcher.openaiDispatcher = openaiDispatcher;
188
+ QgridDispatcher.startupState.openai = "ready";
186
189
  } catch (e) {
190
+ QgridDispatcher.startupState.openai = "failed";
187
191
  log.warn(`openai dispatcher failed: ${(e as Error).message}`);
188
192
  }
189
193
 
@@ -191,7 +195,9 @@ export default defineConfig({
191
195
  const anthropicDispatcher = new AnthropicDispatcher();
192
196
  await anthropicDispatcher.start();
193
197
  QgridDispatcher.anthropicDispatcher = anthropicDispatcher;
198
+ QgridDispatcher.startupState.anthropic = "ready";
194
199
  } catch (e) {
200
+ QgridDispatcher.startupState.anthropic = "failed";
195
201
  log.warn(`anthropic dispatcher failed: ${(e as Error).message}`);
196
202
  }
197
203
 
@@ -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