@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.
- package/bundle/dist/application/qgrid/oauth.js +1 -1
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +167 -0
- package/bundle/dist/application/qgrid/qgrid.frame.js +45 -37
- package/bundle/dist/application/qgrid/qgrid.types.js +6 -16
- package/bundle/dist/application/queries.generated.js +2 -1
- package/bundle/dist/application/request-log/request-log.model.js +15 -2
- package/bundle/dist/application/request-log/request-log.types.js +6 -2
- package/bundle/dist/application/sonamu.generated.js +14 -6
- package/bundle/dist/application/sonamu.generated.sso.js +5 -2
- package/bundle/dist/i18n/sd.generated.js +7 -4
- package/bundle/dist/migrations/20260423180818_alter_request_logs_add1_alter1.js +17 -0
- package/bundle/dist/migrations/20260423182017_alter_request_logs_add2_drop1_alter1.js +19 -0
- package/bundle/dist/migrations/20260423183613_alter_request_logs_add1_alter1.js +16 -0
- package/bundle/dist/sonamu.config.js +6 -9
- package/bundle/src/application/qgrid/oauth.ts +1 -1
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +184 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +38 -51
- package/bundle/src/application/qgrid/qgrid.types.ts +3 -14
- package/bundle/src/application/queries.generated.ts +3 -0
- package/bundle/src/application/request-log/request-log.entity.json +33 -17
- package/bundle/src/application/request-log/request-log.model.ts +26 -2
- package/bundle/src/application/request-log/request-log.types.ts +6 -2
- package/bundle/src/application/sonamu.generated.http +17 -2
- package/bundle/src/application/sonamu.generated.sso.ts +9 -6
- package/bundle/src/application/sonamu.generated.ts +25 -5
- package/bundle/src/i18n/sd.generated.ts +6 -3
- package/bundle/src/migrations/20260423180818_alter_request_logs_add1_alter1.ts +18 -0
- package/bundle/src/migrations/20260423182017_alter_request_logs_add2_drop1_alter1.ts +20 -0
- package/bundle/src/migrations/20260423183613_alter_request_logs_add1_alter1.ts +15 -0
- package/bundle/src/sonamu.config.ts +4 -7
- package/bundle/web-dist/client/assets/index-BfL_bTVI.css +1 -0
- package/bundle/web-dist/client/assets/index-CMOHJBal.js +78 -0
- package/bundle/web-dist/client/assets/logs-B2pDXaji.js +1 -0
- package/bundle/web-dist/client/assets/{routes-CqzIoGYg.js → routes-fpJMuLAs.js} +1 -1
- package/bundle/web-dist/client/assets/{sd.generated-Duipc8HN.js → sd.generated-CIOZewtL.js} +1 -1
- package/bundle/web-dist/client/assets/services.generated-B2N0iPav.js +1 -0
- package/bundle/web-dist/client/assets/{show-BuMxiSr1.js → show-DFVBcmXm.js} +2 -2
- package/bundle/web-dist/client/assets/tokens--LyDCm10.js +1 -0
- package/bundle/web-dist/client/index.html +3 -3
- package/bundle/web-dist/server/assets/{copy-BPyN_0Dp.js → copy-CKCGxwII.js} +1 -1
- package/bundle/web-dist/server/assets/{lazyRouteComponent-Bmjwy0OA.js → lazyRouteComponent-BlvOfJIF.js} +2 -2
- package/bundle/web-dist/server/assets/{logs-BgcF7jVg.js → logs-BYIBD0yv.js} +172 -80
- package/bundle/web-dist/server/assets/{routes-DVA-nsZ-.js → routes-p2n4SfAB.js} +2 -2
- package/bundle/web-dist/server/assets/{sd.generated-CDVQy4CJ.js → sd.generated-CIg_jf8e.js} +6 -3
- package/bundle/web-dist/server/assets/{services.generated-DWyvb1vb.js → services.generated-DIeiDdqy.js} +21 -4
- package/bundle/web-dist/server/assets/{shim-CqVZY710.js → shim-Dq_Izqnc.js} +1 -1
- package/bundle/web-dist/server/assets/{show-BnKnfNFP.js → show-DZ_U1u-N.js} +30 -9
- package/bundle/web-dist/server/assets/{tokens-7z55hPRV.js → tokens-DOybPnNU.js} +19 -21
- package/bundle/web-dist/server/entry-server.generated.js +12 -8
- package/package.json +1 -1
- package/bundle/dist/application/qgrid/pool.js +0 -128
- package/bundle/dist/application/qgrid/worker.js +0 -189
- package/bundle/src/application/qgrid/pool.ts +0 -165
- package/bundle/src/application/qgrid/worker.ts +0 -240
- package/bundle/web-dist/client/assets/index-BRZCFaFz.css +0 -1
- package/bundle/web-dist/client/assets/index-BnqlZB1V.js +0 -78
- package/bundle/web-dist/client/assets/logs-KoJtGoEG.js +0 -1
- package/bundle/web-dist/client/assets/services.generated-Ccs6tru4.js +0 -1
- 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 {
|
|
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(
|
|
33
|
-
|
|
34
|
-
|
|
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:
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
+
QgridDispatcher.removeToken(token);
|
|
114
123
|
return { removed: true };
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
/**
|
|
118
|
-
*
|
|
119
127
|
* @param id token_id
|
|
120
|
-
* 토큰 활성화/비활성화 토글 (
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
await TokenModel.del(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
"
|
|
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": "토큰이름", "
|
|
93
|
+
"values": { "id": "ID", "token_name": "토큰이름", "user_prompt": "사용자 프롬프트" },
|
|
78
94
|
"cone": {
|
|
79
|
-
"note": "가능한 값: id, token_name,
|
|
95
|
+
"note": "가능한 값: id, token_name, user_prompt",
|
|
80
96
|
"values": {
|
|
81
97
|
"id": { "note": "ID" },
|
|
82
98
|
"token_name": { "note": "토큰이름" },
|
|
83
|
-
"
|
|
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 === "
|
|
91
|
-
qb.where("request_logs.
|
|
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
|
|
1
|
+
import { z } from "zod";
|
|
2
2
|
|
|
3
3
|
import { RequestLogBaseListParams, RequestLogBaseSchema } from "../sonamu.generated";
|
|
4
4
|
|
|
5
5
|
// RequestLog - ListParams
|
|
6
|
-
|
|
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
|