@cartanova/qgrid-cli 1.5.6 → 1.6.1

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 (59) hide show
  1. package/bundle/dist/application/qgrid/oauth.js +1 -1
  2. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +167 -0
  3. package/bundle/dist/application/qgrid/qgrid.frame.js +45 -37
  4. package/bundle/dist/application/qgrid/qgrid.types.js +6 -16
  5. package/bundle/dist/application/queries.generated.js +2 -1
  6. package/bundle/dist/application/request-log/request-log.model.js +15 -2
  7. package/bundle/dist/application/request-log/request-log.types.js +6 -2
  8. package/bundle/dist/application/sonamu.generated.js +14 -6
  9. package/bundle/dist/application/sonamu.generated.sso.js +5 -2
  10. package/bundle/dist/i18n/sd.generated.js +7 -4
  11. package/bundle/dist/migrations/20260423180818_alter_request_logs_add1_alter1.js +17 -0
  12. package/bundle/dist/migrations/20260423182017_alter_request_logs_add2_drop1_alter1.js +19 -0
  13. package/bundle/dist/migrations/20260423183613_alter_request_logs_add1_alter1.js +16 -0
  14. package/bundle/dist/sonamu.config.js +6 -9
  15. package/bundle/src/application/qgrid/oauth.ts +1 -1
  16. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +184 -0
  17. package/bundle/src/application/qgrid/qgrid.frame.ts +38 -51
  18. package/bundle/src/application/qgrid/qgrid.types.ts +3 -14
  19. package/bundle/src/application/queries.generated.ts +3 -0
  20. package/bundle/src/application/request-log/request-log.entity.json +33 -17
  21. package/bundle/src/application/request-log/request-log.model.ts +26 -2
  22. package/bundle/src/application/request-log/request-log.types.ts +6 -2
  23. package/bundle/src/application/sonamu.generated.http +17 -2
  24. package/bundle/src/application/sonamu.generated.sso.ts +9 -6
  25. package/bundle/src/application/sonamu.generated.ts +25 -5
  26. package/bundle/src/i18n/sd.generated.ts +6 -3
  27. package/bundle/src/migrations/20260423180818_alter_request_logs_add1_alter1.ts +18 -0
  28. package/bundle/src/migrations/20260423182017_alter_request_logs_add2_drop1_alter1.ts +20 -0
  29. package/bundle/src/migrations/20260423183613_alter_request_logs_add1_alter1.ts +15 -0
  30. package/bundle/src/sonamu.config.ts +4 -7
  31. package/bundle/web-dist/client/assets/index-BfL_bTVI.css +1 -0
  32. package/bundle/web-dist/client/assets/index-CMOHJBal.js +78 -0
  33. package/bundle/web-dist/client/assets/logs-B2pDXaji.js +1 -0
  34. package/bundle/web-dist/client/assets/{routes-CqzIoGYg.js → routes-fpJMuLAs.js} +1 -1
  35. package/bundle/web-dist/client/assets/{sd.generated-Duipc8HN.js → sd.generated-CIOZewtL.js} +1 -1
  36. package/bundle/web-dist/client/assets/services.generated-B2N0iPav.js +1 -0
  37. package/bundle/web-dist/client/assets/{show-BuMxiSr1.js → show-DFVBcmXm.js} +2 -2
  38. package/bundle/web-dist/client/assets/tokens--LyDCm10.js +1 -0
  39. package/bundle/web-dist/client/index.html +3 -3
  40. package/bundle/web-dist/server/assets/{copy-BPyN_0Dp.js → copy-CKCGxwII.js} +1 -1
  41. package/bundle/web-dist/server/assets/{lazyRouteComponent-Bmjwy0OA.js → lazyRouteComponent-BlvOfJIF.js} +2 -2
  42. package/bundle/web-dist/server/assets/{logs-BgcF7jVg.js → logs-BYIBD0yv.js} +172 -80
  43. package/bundle/web-dist/server/assets/{routes-DVA-nsZ-.js → routes-p2n4SfAB.js} +2 -2
  44. package/bundle/web-dist/server/assets/{sd.generated-CDVQy4CJ.js → sd.generated-CIg_jf8e.js} +6 -3
  45. package/bundle/web-dist/server/assets/{services.generated-DWyvb1vb.js → services.generated-DIeiDdqy.js} +21 -4
  46. package/bundle/web-dist/server/assets/{shim-CqVZY710.js → shim-Dq_Izqnc.js} +1 -1
  47. package/bundle/web-dist/server/assets/{show-BnKnfNFP.js → show-DZ_U1u-N.js} +30 -9
  48. package/bundle/web-dist/server/assets/{tokens-7z55hPRV.js → tokens-DOybPnNU.js} +19 -21
  49. package/bundle/web-dist/server/entry-server.generated.js +12 -8
  50. package/package.json +1 -1
  51. package/bundle/dist/application/qgrid/pool.js +0 -128
  52. package/bundle/dist/application/qgrid/worker.js +0 -189
  53. package/bundle/src/application/qgrid/pool.ts +0 -165
  54. package/bundle/src/application/qgrid/worker.ts +0 -240
  55. package/bundle/web-dist/client/assets/index-BRZCFaFz.css +0 -1
  56. package/bundle/web-dist/client/assets/index-BnqlZB1V.js +0 -78
  57. package/bundle/web-dist/client/assets/logs-KoJtGoEG.js +0 -1
  58. package/bundle/web-dist/client/assets/services.generated-Ccs6tru4.js +0 -1
  59. package/bundle/web-dist/client/assets/tokens--z-eVto5.js +0 -1
@@ -0,0 +1,184 @@
1
+ /**
2
+ * QgridDispatcher — OAuth 토큰 선택 + claude CLI fresh spawn 디스패처 싱글턴.
3
+ *
4
+ * - 매 요청마다 새 claude CLI 프로세스 spawn → 응답 후 종료
5
+ * - system 은 --append-system-prompt 로 분리 전달 (user turn 오염 방지)
6
+ * - least-used round-robin 으로 토큰 선택
7
+ * - QuotaError 는 그대로 상위 전파 (자동 failover 없음, UI 에서 수동 토글)
8
+ *
9
+ * env allowlist: PATH, TMPDIR, CLAUDE_CODE_OAUTH_TOKEN + CLAUDE_CODE_DISABLE_*
10
+ */
11
+ import { spawn } from "node:child_process";
12
+ import { mkdirSync } from "node:fs";
13
+
14
+ import { type CliResult, type QueryInput, type TokenStats } from "./qgrid.types";
15
+ import { maskToken, ProcessError, QuotaError, TimeoutError } from "./qgrid.types";
16
+
17
+ const DEFAULT_MODEL = "sonnet";
18
+ const DEFAULT_TIMEOUT_MS = 600_000;
19
+
20
+ class QgridDispatcherClass {
21
+ tokens = new Map<string, string>();
22
+ requestCounts = new Map<string, number>();
23
+ rrIndex = 0;
24
+
25
+ constructor() {
26
+ mkdirSync("/tmp/qgrid", { recursive: true });
27
+ }
28
+
29
+ getStats(): TokenStats[] {
30
+ return [...this.tokens.entries()].map(([token, name]) => ({
31
+ token,
32
+ name,
33
+ requests: this.requestCounts.get(token) ?? 0,
34
+ }));
35
+ }
36
+
37
+ selectToken(): { token: string; name: string } | null {
38
+ const entries = [...this.tokens.entries()];
39
+ if (entries.length === 0) return null;
40
+
41
+ const minCount = Math.min(...entries.map(([t]) => this.requestCounts.get(t) ?? 0));
42
+ const idle = entries.filter(([t]) => (this.requestCounts.get(t) ?? 0) === minCount);
43
+ const picked = idle[this.rrIndex % idle.length]!;
44
+ this.rrIndex++;
45
+ return { token: picked[0], name: picked[1] };
46
+ }
47
+
48
+ async query(input: QueryInput, timeoutMs?: number): Promise<CliResult> {
49
+ const sel = this.selectToken();
50
+ if (!sel) throw new QuotaError("No tokens available");
51
+
52
+ // await 전에 count 선반영. 병렬 요청이 동시에 도착해도 각자 다른 토큰을 고르도록.
53
+ this.requestCounts.set(sel.token, (this.requestCounts.get(sel.token) ?? 0) + 1);
54
+
55
+ console.log(`[qgrid] → ${sel.name} (model: ${input.model ?? DEFAULT_MODEL})`);
56
+
57
+ const result = await executeClaude(input, sel.token, timeoutMs ?? DEFAULT_TIMEOUT_MS);
58
+ return { ...result, tokenName: sel.name, model: input.model ?? DEFAULT_MODEL };
59
+ }
60
+
61
+ addToken(token: string, name: string): void {
62
+ if (this.tokens.has(token)) return;
63
+ this.tokens.set(token, name);
64
+ this.requestCounts.set(token, 0);
65
+ }
66
+
67
+ removeToken(token: string): void {
68
+ this.tokens.delete(token);
69
+ this.requestCounts.delete(token);
70
+ }
71
+
72
+ hasToken(token: string): boolean {
73
+ return this.tokens.has(token);
74
+ }
75
+ }
76
+
77
+ async function executeClaude(
78
+ input: QueryInput,
79
+ token: string,
80
+ timeoutMs: number,
81
+ ): Promise<CliResult> {
82
+ const model = input.model ?? DEFAULT_MODEL;
83
+ const timeout = input.timeout ?? timeoutMs;
84
+
85
+ const args: string[] = [
86
+ "-p",
87
+ "--output-format",
88
+ "stream-json",
89
+ "--verbose",
90
+ "--max-turns",
91
+ "1",
92
+ "--permission-mode",
93
+ "bypassPermissions",
94
+ "--setting-sources",
95
+ "project",
96
+ "--model",
97
+ model,
98
+ ];
99
+ if (input.system) {
100
+ args.push("--append-system-prompt", input.system);
101
+ }
102
+ args.push(input.prompt);
103
+
104
+ const env: NodeJS.ProcessEnv = {
105
+ PATH: process.env.PATH,
106
+ TMPDIR: process.env.TMPDIR,
107
+ CLAUDE_CODE_OAUTH_TOKEN: token,
108
+ CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1",
109
+ CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING: "1",
110
+ CLAUDE_CODE_DISABLE_1M_CONTEXT: "1",
111
+ };
112
+
113
+ return new Promise<CliResult>((resolve, reject) => {
114
+ const child = spawn("claude", args, {
115
+ stdio: ["ignore", "pipe", "ignore"],
116
+ env,
117
+ cwd: "/tmp/qgrid",
118
+ });
119
+
120
+ let buffer = "";
121
+ let settled = false;
122
+ const timer = setTimeout(() => {
123
+ if (settled) return;
124
+ settled = true;
125
+ child.kill();
126
+ reject(new TimeoutError(`Timeout after ${timeout / 1000}s (token: ${maskToken(token)})`));
127
+ }, timeout);
128
+
129
+ child.stdout?.on("data", (d: Buffer) => {
130
+ if (settled) return;
131
+ buffer += d.toString();
132
+ const lines = buffer.split("\n");
133
+ buffer = lines.pop() ?? "";
134
+ for (const line of lines) {
135
+ if (!line.trim()) continue;
136
+ try {
137
+ const j = JSON.parse(line);
138
+ if (j.type === "result" && !settled) {
139
+ let text: string = j.result ?? "";
140
+ text = text.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
141
+
142
+ if (text.startsWith("You've hit")) {
143
+ settled = true;
144
+ clearTimeout(timer);
145
+ reject(new QuotaError(`Quota exhausted (token: ${maskToken(token)})`));
146
+ return;
147
+ }
148
+
149
+ const u = j.usage ?? {};
150
+ settled = true;
151
+ clearTimeout(timer);
152
+ resolve({
153
+ text,
154
+ usage: {
155
+ input_tokens: u.input_tokens ?? 0,
156
+ output_tokens: u.output_tokens ?? 0,
157
+ cache_creation_input_tokens: u.cache_creation_input_tokens ?? 0,
158
+ cache_read_input_tokens: u.cache_read_input_tokens ?? 0,
159
+ },
160
+ durationMs: j.duration_ms ?? 0,
161
+ costUsd: j.total_cost_usd ?? 0,
162
+ });
163
+ }
164
+ } catch {}
165
+ }
166
+ });
167
+
168
+ child.on("close", () => {
169
+ if (settled) return;
170
+ settled = true;
171
+ clearTimeout(timer);
172
+ reject(new ProcessError(`CLI process closed without result (token: ${maskToken(token)})`));
173
+ });
174
+
175
+ child.on("error", (err) => {
176
+ if (settled) return;
177
+ settled = true;
178
+ clearTimeout(timer);
179
+ reject(new ProcessError(`CLI process error: ${err.message} (token: ${maskToken(token)})`));
180
+ });
181
+ });
182
+ }
183
+
184
+ export const QgridDispatcher = new QgridDispatcherClass();
@@ -11,7 +11,7 @@ import {
11
11
  generatePKCE,
12
12
  refreshAccessToken,
13
13
  } from "./oauth";
14
- import { type ClaudePool, getPool } from "./pool";
14
+ import { QgridDispatcher } from "./qgrid.dispatcher";
15
15
  import {
16
16
  type CliResult,
17
17
  type HealthResponse,
@@ -20,7 +20,6 @@ import {
20
20
  type UsageResponse,
21
21
  } from "./qgrid.types";
22
22
 
23
- // PKCE 세션 메모리 저장 (state → { codeVerifier, name, redirectUri })
24
23
  const pendingOAuth = new Map<string, { codeVerifier: string; name: string; redirectUri: string }>();
25
24
 
26
25
  class QgridFrameClass extends BaseFrameClass {
@@ -29,15 +28,22 @@ class QgridFrameClass extends BaseFrameClass {
29
28
  }
30
29
 
31
30
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
32
- async query(prompt: string, system?: string, timeout?: number): Promise<CliResult> {
33
- const pool = getPool();
34
- const result = await pool.query({ system, prompt }, timeout);
31
+ async query(
32
+ prompt: string,
33
+ system?: string,
34
+ timeout?: number,
35
+ model?: string,
36
+ projectName?: string,
37
+ ): Promise<CliResult> {
38
+ const result = await QgridDispatcher.query({ system, prompt, model }, timeout);
35
39
 
36
- // 로그 기록 실패해도 쿼리 결과는 반환
37
40
  RequestLogModel.save([
38
41
  {
39
- token_name: pool.getLastUsedWorkerName(),
40
- query: system ? `[System]\n${system}\n\n[User]\n${prompt}` : prompt,
42
+ token_name: result.tokenName,
43
+ project_name: projectName && projectName.length > 0 ? projectName : null,
44
+ model_name: result.model ?? null,
45
+ user_prompt: prompt,
46
+ system_prompt: system ?? null,
41
47
  response: result.text,
42
48
  input_tokens: result.usage.input_tokens,
43
49
  output_tokens: result.usage.output_tokens,
@@ -53,7 +59,7 @@ class QgridFrameClass extends BaseFrameClass {
53
59
 
54
60
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
55
61
  async stats(): Promise<TokenStats[]> {
56
- return getPool().getStats();
62
+ return QgridDispatcher.getStats();
57
63
  }
58
64
 
59
65
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
@@ -61,6 +67,11 @@ class QgridFrameClass extends BaseFrameClass {
61
67
  return { usd: await RequestLogModel.totalCost({ token_name: tokenName }) };
62
68
  }
63
69
 
70
+ @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
71
+ async projectNames(): Promise<{ names: string[] }> {
72
+ return { names: await RequestLogModel.distinctProjectNames() };
73
+ }
74
+
64
75
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
65
76
  async addToken(token: string, name: string, refreshToken?: string): Promise<{ added: boolean }> {
66
77
  await TokenModel.save([
@@ -70,7 +81,7 @@ class QgridFrameClass extends BaseFrameClass {
70
81
  ...(refreshToken && refreshToken.length > 0 ? { refresh_token: refreshToken } : {}),
71
82
  },
72
83
  ]);
73
- getPool().createWorkers(token, name);
84
+ QgridDispatcher.addToken(token, name);
74
85
  return { added: true };
75
86
  }
76
87
 
@@ -81,7 +92,6 @@ class QgridFrameClass extends BaseFrameClass {
81
92
  newToken?: string,
82
93
  refreshToken?: string,
83
94
  ): Promise<{ updated: boolean }> {
84
- const pool = getPool();
85
95
  const entry = await TokenModel.findByToken("A", token);
86
96
  if (!entry) return { updated: false };
87
97
 
@@ -97,52 +107,44 @@ class QgridFrameClass extends BaseFrameClass {
97
107
  ]);
98
108
 
99
109
  if (hasNewToken) {
100
- pool.destroyWorkers(token);
101
- pool.createWorkers(newToken, name ?? entry.name);
110
+ QgridDispatcher.removeToken(token);
111
+ QgridDispatcher.addToken(newToken, name ?? entry.name);
102
112
  }
103
113
  return { updated: true };
104
114
  }
105
115
 
106
116
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
107
117
  async removeToken(token: string): Promise<{ removed: boolean }> {
108
- const pool = getPool();
109
- if (!pool.workers.has(token)) return { removed: false };
118
+ if (!QgridDispatcher.hasToken(token)) return { removed: false };
110
119
 
111
120
  const entry = await TokenModel.findByToken("A", token);
112
121
  if (entry) await TokenModel.del([entry.id]);
113
- pool.destroyWorkers(token);
122
+ QgridDispatcher.removeToken(token);
114
123
  return { removed: true };
115
124
  }
116
125
 
117
126
  /**
118
- *
119
127
  * @param id token_id
120
- * 토큰 활성화/비활성화 토글 (pool 워커 생성/제거 & DB의 active 필드 업데이트)
121
- *
128
+ * 토큰 활성화/비활성화 토글 (dispatcher 등록/제거 & DB의 active 필드 업데이트)
122
129
  * @returns 토큰 활성화 여부
123
130
  */
124
131
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
125
132
  async toggleToken(id: number): Promise<{ active: boolean }> {
126
- const pool = getPool();
127
133
  const entry = await TokenModel.findOne("A", { id });
128
134
  if (!entry) return { active: false };
129
135
 
130
136
  const newActive = !entry.active;
131
137
  await TokenModel.save([{ id, token: entry.token, active: newActive, name: entry.name }]);
132
138
 
133
- // 토큰의 새로운 상태가 활성화면/비활성화면
134
139
  if (newActive) {
135
- // 워커 생성
136
- pool.createWorkers(entry.token, entry.name);
140
+ QgridDispatcher.addToken(entry.token, entry.name);
137
141
  } else {
138
- // 워커 제거
139
- pool.destroyWorkers(entry.token);
142
+ QgridDispatcher.removeToken(entry.token);
140
143
  }
141
144
 
142
145
  return { active: newActive };
143
146
  }
144
147
 
145
- // OAuth 로그인: authUrl 생성 (프론트에서 이 API 호출 후 authUrl로 리다이렉트)
146
148
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
147
149
  async oauthStart(name: string): Promise<OAuthStartResult> {
148
150
  const { codeVerifier, codeChallenge, state } = generatePKCE();
@@ -151,16 +153,13 @@ class QgridFrameClass extends BaseFrameClass {
151
153
  const redirectUri = `http://localhost:${serverPort}/callback`;
152
154
  const authUrl = buildAuthUrl(codeChallenge, state, redirectUri);
153
155
 
154
- // PKCE를 메모리에 저장 (5분 TTL)
155
156
  pendingOAuth.set(state, { codeVerifier, name, redirectUri });
156
157
  setTimeout(() => pendingOAuth.delete(state), 300_000);
157
158
 
158
159
  return { authUrl };
159
160
  }
160
161
 
161
- // OAuth 콜백 처리 — sonamu.config.ts의 custom 라우트에서 호출됨
162
162
  async handleOAuthCallback(code: string, state: string, reply: FastifyReply): Promise<void> {
163
- const pool = getPool();
164
163
  const pending = pendingOAuth.get(state);
165
164
  if (!pending) {
166
165
  return reply.redirect("/?oauth=error&reason=invalid_state");
@@ -175,16 +174,14 @@ class QgridFrameClass extends BaseFrameClass {
175
174
  pending.redirectUri,
176
175
  );
177
176
 
178
- // 같은 계정의 이전 토큰이 있으면 교체
179
177
  if (tokens.accountUuid) {
180
178
  const oldEntries = await TokenModel.findByAccountUuid("A", tokens.accountUuid);
181
- for (const old of oldEntries) {
182
- pool.destroyWorkers(old.token);
183
- await TokenModel.del([old.id]);
179
+ if (oldEntries.length > 0) {
180
+ for (const old of oldEntries) QgridDispatcher.removeToken(old.token);
181
+ await TokenModel.del(oldEntries.map((o) => o.id));
184
182
  }
185
183
  }
186
184
 
187
- // 새 토큰 저장 + pool에 등록
188
185
  await TokenModel.save([
189
186
  {
190
187
  token: tokens.accessToken,
@@ -194,7 +191,7 @@ class QgridFrameClass extends BaseFrameClass {
194
191
  account_uuid: tokens.accountUuid,
195
192
  },
196
193
  ]);
197
- pool.createWorkers(tokens.accessToken, pending.name);
194
+ QgridDispatcher.addToken(tokens.accessToken, pending.name);
198
195
 
199
196
  return reply.redirect(`/?oauth=success&name=${encodeURIComponent(pending.name)}`);
200
197
  } catch (e) {
@@ -202,10 +199,8 @@ class QgridFrameClass extends BaseFrameClass {
202
199
  }
203
200
  }
204
201
 
205
- // 토큰 사용량 조회 (OAuth usage API)
206
202
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
207
203
  async usage(tokenName?: string): Promise<UsageResponse> {
208
- const pool = getPool();
209
204
  const { rows: allTokens } = await TokenModel.findMany("A");
210
205
  const entry = tokenName
211
206
  ? allTokens.find((e) => e.name === tokenName)
@@ -213,26 +208,23 @@ class QgridFrameClass extends BaseFrameClass {
213
208
 
214
209
  if (!entry) return { error: "NOT_FOUND" };
215
210
 
216
- // 만료 확인 + refresh 시도
217
211
  let accessToken = entry.token;
218
212
  const isExpired = entry.expires_at && Number(entry.expires_at) < Date.now();
219
213
 
220
214
  if (isExpired && entry.refresh_token) {
221
215
  try {
222
- accessToken = await this.refreshToken(pool, entry);
216
+ accessToken = await this.refreshToken(entry);
223
217
  } catch (e) {
224
218
  console.warn(`[usage] refresh failed for ${entry.name}: ${(e as Error).message}`);
225
219
  return { error: "re-login required" };
226
220
  }
227
221
  }
228
222
 
229
- // usage 호출
230
223
  const result = await fetchUsage(accessToken);
231
224
 
232
- // 인증 에러면 refresh_token으로 재시도
233
225
  if (result.error && entry.refresh_token) {
234
226
  try {
235
- accessToken = await this.refreshToken(pool, entry);
227
+ accessToken = await this.refreshToken(entry);
236
228
  return await fetchUsage(accessToken);
237
229
  } catch (e) {
238
230
  console.warn(`[usage] refresh failed for ${entry.name}: ${(e as Error).message}`);
@@ -243,7 +235,7 @@ class QgridFrameClass extends BaseFrameClass {
243
235
  return result;
244
236
  }
245
237
 
246
- async refreshToken(pool: ClaudePool, token: RefreshTokenParams): Promise<string> {
238
+ async refreshToken(token: RefreshTokenParams): Promise<string> {
247
239
  if (!token.refresh_token) throw new Error("No refresh token");
248
240
  const refreshed = await refreshAccessToken(token.refresh_token);
249
241
  await TokenModel.save([
@@ -255,19 +247,14 @@ class QgridFrameClass extends BaseFrameClass {
255
247
  name: token.name ?? "",
256
248
  },
257
249
  ]);
258
- pool.destroyWorkers(token.token);
259
- pool.createWorkers(refreshed.accessToken, token.name);
250
+ QgridDispatcher.removeToken(token.token);
251
+ QgridDispatcher.addToken(refreshed.accessToken, token.name);
260
252
  return refreshed.accessToken;
261
253
  }
262
254
 
263
255
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
264
256
  async health(): Promise<HealthResponse> {
265
- const pool = getPool();
266
- return {
267
- status: "ok",
268
- workers: [...pool.workers.values()].flat().length,
269
- activeTokens: pool.workers.size - pool.quotaExhausted.size,
270
- };
257
+ return { status: "ok", activeTokens: QgridDispatcher.tokens.size };
271
258
  }
272
259
  }
273
260
 
@@ -5,12 +5,15 @@ import { z } from "zod";
5
5
  export const QueryInput = z.object({
6
6
  system: z.string().optional(),
7
7
  prompt: z.string(),
8
+ model: z.string().optional(),
8
9
  timeout: z.number().optional(),
9
10
  });
10
11
  export type QueryInput = z.infer<typeof QueryInput>;
11
12
 
12
13
  export const CliResult = z.object({
13
14
  text: z.string(),
15
+ tokenName: z.string().optional(),
16
+ model: z.string().optional(),
14
17
  usage: z.object({
15
18
  input_tokens: z.number(),
16
19
  output_tokens: z.number(),
@@ -22,18 +25,6 @@ export const CliResult = z.object({
22
25
  });
23
26
  export type CliResult = z.infer<typeof CliResult>;
24
27
 
25
- // ─── Pool Config ───
26
-
27
- export const PoolConfig = z.object({
28
- tokens: z.array(z.object({ token: z.string(), name: z.string() })),
29
- size: z.number().optional(),
30
- model: z.string().optional(),
31
- timeout: z.number().optional(),
32
- cwd: z.string().optional(),
33
- maxCalls: z.number().optional(),
34
- });
35
- export type PoolConfig = z.infer<typeof PoolConfig>;
36
-
37
28
  // ─── Token Management ───
38
29
 
39
30
  export const AddTokenInput = z.object({
@@ -51,7 +42,6 @@ export const TokenStats = z.object({
51
42
  token: z.string(),
52
43
  name: z.string(),
53
44
  requests: z.number(),
54
- active: z.boolean(),
55
45
  });
56
46
  export type TokenStats = z.infer<typeof TokenStats>;
57
47
 
@@ -93,7 +83,6 @@ export type UsageResponse = z.infer<typeof UsageResponse>;
93
83
 
94
84
  export const HealthResponse = z.object({
95
85
  status: z.string(),
96
- workers: z.number(),
97
86
  activeTokens: z.number(),
98
87
  });
99
88
  export type HealthResponse = z.infer<typeof HealthResponse>;
@@ -55,6 +55,9 @@ export namespace QgridService {
55
55
  export const totalCost = (tokenName?: string): SSRQuery =>
56
56
  createSSRQuery("QgridFrame", "totalCost", [tokenName], ["Qgrid", "totalCost"]);
57
57
 
58
+ export const projectNames = (): SSRQuery =>
59
+ createSSRQuery("QgridFrame", "projectNames", [], ["Qgrid", "projectNames"]);
60
+
58
61
  export const usage = (tokenName?: string): SSRQuery =>
59
62
  createSSRQuery("QgridFrame", "usage", [tokenName], ["Qgrid", "usage"]);
60
63
 
@@ -15,12 +15,7 @@
15
15
  "fixtureGenerator": "faker.number.int({ \"min\": 1, \"max\": 1000 })"
16
16
  }
17
17
  },
18
- {
19
- "name": "created_at",
20
- "desc": "등록일시",
21
- "dbDefault": "CURRENT_TIMESTAMP",
22
- "type": "date"
23
- },
18
+ { "name": "created_at", "desc": "등록일시", "dbDefault": "CURRENT_TIMESTAMP", "type": "date" },
24
19
  {
25
20
  "name": "token_name",
26
21
  "toFilter": true,
@@ -28,19 +23,31 @@
28
23
  "type": "string",
29
24
  "length": 100
30
25
  },
31
- { "name": "query", "type": "string", "desc": "prompt 전문" },
26
+ {
27
+ "name": "project_name",
28
+ "toFilter": true,
29
+ "desc": "프로젝트 이름",
30
+ "type": "string",
31
+ "length": 50,
32
+ "nullable": true
33
+ },
34
+ {
35
+ "name": "model_name",
36
+ "toFilter": true,
37
+ "desc": "모델 이름",
38
+ "type": "string",
39
+ "length": 50,
40
+ "nullable": true
41
+ },
42
+ { "name": "user_prompt", "type": "string", "nullable": true, "desc": "사용자 프롬프트" },
43
+ { "name": "system_prompt", "type": "string", "nullable": true, "desc": "시스템 프롬프트" },
32
44
  { "name": "response", "desc": "llm 응답 전문", "type": "string" },
33
45
  { "name": "input_tokens", "type": "integer", "desc": "입력 토큰" },
34
46
  { "name": "output_tokens", "type": "integer", "desc": "출력 토큰" },
35
47
  { "name": "cache_read_tokens", "type": "integer", "desc": "캐시 읽기 토큰" },
36
48
  { "name": "cache_creation_tokens", "type": "integer", "desc": "캐시 생성 토큰" },
37
49
  { "name": "duration_ms", "type": "integer", "desc": "응답 소요시간(ms)" },
38
- {
39
- "name": "cost_usd",
40
- "type": "integer",
41
- "nullable": true,
42
- "desc": "API 비용"
43
- }
50
+ { "name": "cost_usd", "type": "integer", "nullable": true, "desc": "API 비용" }
44
51
  ],
45
52
  "indexes": [
46
53
  {
@@ -48,6 +55,12 @@
48
55
  "name": "request_logs_token_name_index",
49
56
  "columns": [{ "name": "token_name" }],
50
57
  "using": "btree"
58
+ },
59
+ {
60
+ "type": "index",
61
+ "name": "request_logs_project_name_index",
62
+ "columns": [{ "name": "project_name" }],
63
+ "using": "btree"
51
64
  }
52
65
  ],
53
66
  "subsets": {
@@ -56,7 +69,10 @@
56
69
  "id",
57
70
  "created_at",
58
71
  "token_name",
59
- "query",
72
+ "project_name",
73
+ "model_name",
74
+ "user_prompt",
75
+ "system_prompt",
60
76
  "response",
61
77
  "input_tokens",
62
78
  "output_tokens",
@@ -74,13 +90,13 @@
74
90
  "cone": { "note": "가능한 값: id-desc", "values": { "id-desc": { "note": "ID최신순" } } }
75
91
  },
76
92
  "RequestLogSearchField": {
77
- "values": { "id": "ID", "token_name": "토큰이름", "query": "쿼리" },
93
+ "values": { "id": "ID", "token_name": "토큰이름", "user_prompt": "사용자 프롬프트" },
78
94
  "cone": {
79
- "note": "가능한 값: id, token_name, query",
95
+ "note": "가능한 값: id, token_name, user_prompt",
80
96
  "values": {
81
97
  "id": { "note": "ID" },
82
98
  "token_name": { "note": "토큰이름" },
83
- "query": { "note": "쿼리" }
99
+ "user_prompt": { "note": "사용자 프롬프트" }
84
100
  }
85
101
  }
86
102
  }
@@ -82,13 +82,25 @@ class RequestLogModelClass extends BaseModelClass<
82
82
  qb.where("request_logs.token_name", "like", `${params.token_name}-%`);
83
83
  }
84
84
 
85
+ if (params.project_name_is_null) {
86
+ qb.where("request_logs.project_name", null);
87
+ } else if (params.project_name_is_not_null) {
88
+ qb.where("request_logs.project_name", "!=", null);
89
+ } else if (params.project_name) {
90
+ qb.where("request_logs.project_name", params.project_name);
91
+ }
92
+
93
+ if (params.model_name) {
94
+ qb.where("request_logs.model_name", params.model_name);
95
+ }
96
+
85
97
  if (params.search && params.keyword && params.keyword.length > 0) {
86
98
  if (params.search === "id") {
87
99
  qb.where("request_logs.id", Number(params.keyword));
88
100
  } else if (params.search === "token_name") {
89
101
  qb.where("request_logs.token_name", "like", `%${params.keyword}%`);
90
- } else if (params.search === "query") {
91
- qb.where("request_logs.query", "like", `%${params.keyword}%`);
102
+ } else if (params.search === "user_prompt") {
103
+ qb.where("request_logs.user_prompt", "like", `%${params.keyword}%`);
92
104
  } else {
93
105
  throw new BadRequestException(SD("error.unknownSearchField")(params.search));
94
106
  }
@@ -148,6 +160,18 @@ class RequestLogModelClass extends BaseModelClass<
148
160
  return Number(row?.sum ?? 0) / MICRO_USD;
149
161
  }
150
162
 
163
+ async distinctProjectNames(): Promise<string[]> {
164
+ const { rows } = await this.findMany("A", {
165
+ project_name_is_not_null: true,
166
+ num: 10_000,
167
+ page: 1,
168
+ queryMode: "list",
169
+ });
170
+ return [
171
+ ...new Set(rows.map((r) => r.project_name).filter((n): n is string => n !== null)),
172
+ ].toSorted();
173
+ }
174
+
151
175
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
152
176
  async del(ids: number[]): Promise<number> {
153
177
  const wdb = this.getPuri("w");
@@ -1,9 +1,13 @@
1
- import type { z } from "zod";
1
+ import { z } from "zod";
2
2
 
3
3
  import { RequestLogBaseListParams, RequestLogBaseSchema } from "../sonamu.generated";
4
4
 
5
5
  // RequestLog - ListParams
6
- export const RequestLogListParams = RequestLogBaseListParams;
6
+ // project_name NULL 분기: IS NULL만 조회(unassigned 필터) 또는 IS NOT NULL만(distinct 조회).
7
+ export const RequestLogListParams = RequestLogBaseListParams.extend({
8
+ project_name_is_null: z.boolean().optional(),
9
+ project_name_is_not_null: z.boolean().optional(),
10
+ });
7
11
  export type RequestLogListParams = z.infer<typeof RequestLogListParams>;
8
12
 
9
13
  // RequestLog - SaveParams