pleumrouter 0.2.2 → 0.3.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/README.md +11 -2
- package/bin/pleum.mjs +24 -1
- package/package.json +1 -1
- package/src/ansi.mjs +102 -0
- package/src/balance.mjs +12 -5
- package/src/completion.mjs +1 -1
- package/src/health.mjs +55 -0
- package/src/interactive.mjs +252 -0
- package/src/launch.mjs +24 -16
- package/src/markdown.mjs +91 -0
- package/src/menu.mjs +360 -0
- package/src/models.mjs +102 -11
- package/src/run.mjs +184 -75
- package/src/top.mjs +72 -0
- package/src/usage.mjs +69 -0
package/src/run.mjs
CHANGED
|
@@ -2,7 +2,10 @@ import { createInterface } from "node:readline";
|
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { V1, requireKey, HISTORY_FILE, SESSIONS_DIR } from "./config.mjs";
|
|
5
|
-
import { fetchModels, formatTable } from "./models.mjs";
|
|
5
|
+
import { fetchModels, formatTable, matchModels } from "./models.mjs";
|
|
6
|
+
import { style, divider } from "./ansi.mjs";
|
|
7
|
+
import { createMarkdownStream } from "./markdown.mjs";
|
|
8
|
+
import { spinner, select } from "./interactive.mjs";
|
|
6
9
|
|
|
7
10
|
const IMG_EXT = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
|
8
11
|
|
|
@@ -34,7 +37,7 @@ export function buildContent(text, exists = existsSync, read = readFileSync) {
|
|
|
34
37
|
// 응답 후 한 줄 통계(순수 함수 — 테스트 대상). verbose일 때만 토큰/속도 표시.
|
|
35
38
|
export function formatStats({ cost, usage, ms, tokPerSec, verbose }) {
|
|
36
39
|
const bits = [];
|
|
37
|
-
if (cost) bits.push(`₩${cost}`);
|
|
40
|
+
if (cost != null && cost !== "") bits.push(`₩${fmtCost(cost)}`);
|
|
38
41
|
if (verbose && usage) {
|
|
39
42
|
bits.push(`↑${usage.prompt_tokens ?? "?"} ↓${usage.completion_tokens ?? "?"} tok`);
|
|
40
43
|
if (tokPerSec) bits.push(`${tokPerSec.toFixed(1)} tok/s`);
|
|
@@ -43,6 +46,16 @@ export function formatStats({ cost, usage, ms, tokPerSec, verbose }) {
|
|
|
43
46
|
return bits.length ? ` (${bits.join(" · ")})` : "";
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
// 비용 표시: 숫자면 소수 2자리까지(후행 0 제거), 문자열은 그대로.
|
|
50
|
+
function fmtCost(v) {
|
|
51
|
+
const n = Number(v);
|
|
52
|
+
return Number.isFinite(n) ? String(+n.toFixed(2)) : String(v);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isAbort(e) {
|
|
56
|
+
return e?.name === "AbortError" || e?.code === "ABORT_ERR";
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
export async function run(rest) {
|
|
47
60
|
const args = Array.isArray(rest) ? rest : rest == null ? [] : [rest];
|
|
48
61
|
const { verbose, prompt: inlinePrompt, model: argModel } = parseRunArgs(args);
|
|
@@ -77,7 +90,7 @@ export async function run(rest) {
|
|
|
77
90
|
try {
|
|
78
91
|
await streamChat(key, model, messages, { verbose, quiet: true });
|
|
79
92
|
} catch (e) {
|
|
80
|
-
console.error(
|
|
93
|
+
console.error(`${style.red("✗")} ${e.message}`);
|
|
81
94
|
process.exit(1);
|
|
82
95
|
}
|
|
83
96
|
process.exit(0);
|
|
@@ -86,34 +99,35 @@ export async function run(rest) {
|
|
|
86
99
|
await repl(key, model, verbose);
|
|
87
100
|
}
|
|
88
101
|
|
|
89
|
-
async function repl(key,
|
|
102
|
+
async function repl(key, initialModel, initialVerbose) {
|
|
103
|
+
let model = initialModel;
|
|
90
104
|
let system = "";
|
|
105
|
+
let verbose = initialVerbose;
|
|
91
106
|
let messages = [];
|
|
92
|
-
const rl = createInterface({
|
|
93
|
-
input: process.stdin,
|
|
94
|
-
output: process.stdout,
|
|
95
|
-
prompt: "you> ",
|
|
96
|
-
history: loadHistory(),
|
|
97
|
-
historySize: 1000,
|
|
98
|
-
});
|
|
99
107
|
let multiline = null; // """ 펜스 진행 중이면 누적 배열
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
rl
|
|
108
|
+
let currentAbort = null; // 생성 중이면 AbortController
|
|
109
|
+
let lastSigint = 0;
|
|
110
|
+
let pickerActive = false; // /model 셀렉터가 rl을 잠시 닫는 동안 close 핸들러 무시
|
|
111
|
+
let rl;
|
|
103
112
|
|
|
104
113
|
const send = async (text) => {
|
|
105
114
|
messages.push({ role: "user", content: buildContent(text) });
|
|
106
115
|
const req = system ? [{ role: "system", content: system }, ...messages] : messages;
|
|
116
|
+
const ac = new AbortController();
|
|
117
|
+
currentAbort = ac;
|
|
107
118
|
try {
|
|
108
|
-
const reply = await streamChat(key, model, req, { verbose });
|
|
109
|
-
messages.push({ role: "assistant", content: reply });
|
|
119
|
+
const reply = await streamChat(key, model, req, { verbose, signal: ac.signal });
|
|
120
|
+
if (reply) messages.push({ role: "assistant", content: reply });
|
|
121
|
+
else messages.pop(); // 응답 없음(즉시 중단 등) — user 턴 되돌림
|
|
110
122
|
} catch (e) {
|
|
111
|
-
console.error(`\n✗ ${e.message}`);
|
|
123
|
+
console.error(`\n${style.red("✗")} ${e.message}`);
|
|
112
124
|
messages.pop(); // 실패한 user 턴 제거
|
|
125
|
+
} finally {
|
|
126
|
+
currentAbort = null;
|
|
113
127
|
}
|
|
114
128
|
};
|
|
115
129
|
|
|
116
|
-
|
|
130
|
+
const onLine = async (line) => {
|
|
117
131
|
if (multiline !== null) {
|
|
118
132
|
if (line.trim() === '"""') {
|
|
119
133
|
const text = multiline.join("\n");
|
|
@@ -164,6 +178,17 @@ async function repl(key, model, verbose) {
|
|
|
164
178
|
system = s.system ?? "";
|
|
165
179
|
messages = s.messages ?? [];
|
|
166
180
|
},
|
|
181
|
+
// 화살표 셀렉터는 stdin raw-mode가 필요해 readline과 공존 불가 —
|
|
182
|
+
// 히스토리를 보존한 채 rl을 닫았다가 선택 후 재생성한다.
|
|
183
|
+
pickModel: async () => {
|
|
184
|
+
pickerActive = true;
|
|
185
|
+
const history = rl.history;
|
|
186
|
+
rl.close();
|
|
187
|
+
const picked = await pickModel();
|
|
188
|
+
makeRl(history);
|
|
189
|
+
pickerActive = false;
|
|
190
|
+
return picked;
|
|
191
|
+
},
|
|
167
192
|
});
|
|
168
193
|
rl.prompt();
|
|
169
194
|
return;
|
|
@@ -174,20 +199,57 @@ async function repl(key, model, verbose) {
|
|
|
174
199
|
}
|
|
175
200
|
await send(text);
|
|
176
201
|
rl.prompt();
|
|
177
|
-
}
|
|
202
|
+
};
|
|
178
203
|
|
|
179
|
-
|
|
204
|
+
const onClose = () => {
|
|
205
|
+
if (pickerActive) return;
|
|
180
206
|
saveHistory(rl.history);
|
|
181
|
-
console.log("
|
|
207
|
+
console.log(`\n${style.dim("안녕히 가세요.")}`);
|
|
182
208
|
process.exit(0);
|
|
183
|
-
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Ctrl+C 인체공학: 생성 중 1회=중단(REPL 유지), idle에선 2초 내 2회=종료.
|
|
212
|
+
const onSigint = () => {
|
|
213
|
+
if (currentAbort) {
|
|
214
|
+
currentAbort.abort();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const now = Date.now();
|
|
218
|
+
if (now - lastSigint < 2000) {
|
|
219
|
+
rl.close();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
lastSigint = now;
|
|
223
|
+
console.log(`\n${style.dim("한 번 더 Ctrl+C 하면 종료 (/exit 도 가능)")}`);
|
|
224
|
+
rl.prompt();
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const makeRl = (history) => {
|
|
228
|
+
rl = createInterface({
|
|
229
|
+
input: process.stdin,
|
|
230
|
+
output: process.stdout,
|
|
231
|
+
prompt: `${style.plum("you")}${style.dim(">")} `,
|
|
232
|
+
history,
|
|
233
|
+
historySize: 1000,
|
|
234
|
+
});
|
|
235
|
+
rl.on("line", onLine);
|
|
236
|
+
rl.on("close", onClose);
|
|
237
|
+
rl.on("SIGINT", onSigint);
|
|
238
|
+
return rl;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
makeRl(loadHistory());
|
|
242
|
+
console.log(`${style.bold(style.plum("PleumRouter"))} ${style.dim("·")} ${model}`);
|
|
243
|
+
console.log(style.dim('/help 명령 · /exit 종료 · 여러 줄은 """ 감싸기 · 생성 중 Ctrl+C=중단'));
|
|
244
|
+
console.log(divider());
|
|
245
|
+
rl.prompt();
|
|
184
246
|
}
|
|
185
247
|
|
|
186
248
|
const SLASH_HELP = `명령:
|
|
187
249
|
/help, /? 이 도움말
|
|
188
250
|
/clear 대화 컨텍스트 초기화(시스템 프롬프트는 유지)
|
|
189
251
|
/system [텍스트] 시스템 프롬프트 설정(인자 없으면 현재값 표시)
|
|
190
|
-
/model [id] 세션 유지한 채 모델 전환(인자 없으면
|
|
252
|
+
/model [id] 세션 유지한 채 모델 전환(인자 없으면 화살표 선택)
|
|
191
253
|
/models [검색어] 모델 목록 보기
|
|
192
254
|
/save [이름] 현재 세션 저장(기본 last)
|
|
193
255
|
/load [이름] 세션 불러오기
|
|
@@ -197,7 +259,7 @@ const SLASH_HELP = `명령:
|
|
|
197
259
|
이미지: 메시지에 이미지 파일 경로를 그대로 포함(예: 이 그림 설명해줘 ./shot.png)`;
|
|
198
260
|
|
|
199
261
|
async function handleSlash(text, ctx) {
|
|
200
|
-
const [cmd
|
|
262
|
+
const [cmd] = text.slice(1).split(/\s+/);
|
|
201
263
|
const arg = text.slice(1 + cmd.length).trim();
|
|
202
264
|
switch (cmd) {
|
|
203
265
|
case "help":
|
|
@@ -206,12 +268,12 @@ async function handleSlash(text, ctx) {
|
|
|
206
268
|
break;
|
|
207
269
|
case "clear":
|
|
208
270
|
ctx.clear();
|
|
209
|
-
console.log("✓ 컨텍스트
|
|
271
|
+
console.log(`${style.green("✓")} 컨텍스트 초기화`);
|
|
210
272
|
break;
|
|
211
273
|
case "system":
|
|
212
274
|
if (arg) {
|
|
213
275
|
ctx.system = arg;
|
|
214
|
-
console.log("✓ 시스템 프롬프트
|
|
276
|
+
console.log(`${style.green("✓")} 시스템 프롬프트 설정`);
|
|
215
277
|
} else {
|
|
216
278
|
console.log(ctx.system ? `현재 시스템 프롬프트:\n${ctx.system}` : "시스템 프롬프트 없음");
|
|
217
279
|
}
|
|
@@ -219,7 +281,15 @@ async function handleSlash(text, ctx) {
|
|
|
219
281
|
case "model":
|
|
220
282
|
if (arg) {
|
|
221
283
|
ctx.model = arg;
|
|
222
|
-
console.log(
|
|
284
|
+
console.log(`${style.green("✓")} 모델 전환 → ${style.plum(arg)}`);
|
|
285
|
+
} else if (ctx.pickModel) {
|
|
286
|
+
const picked = await ctx.pickModel();
|
|
287
|
+
if (picked) {
|
|
288
|
+
ctx.model = picked;
|
|
289
|
+
console.log(`${style.green("✓")} 모델 전환 → ${style.plum(picked)}`);
|
|
290
|
+
} else {
|
|
291
|
+
console.log(`현재 모델: ${style.plum(ctx.model)}`);
|
|
292
|
+
}
|
|
223
293
|
} else {
|
|
224
294
|
console.log(`현재 모델: ${ctx.model}\n전환: /model <id> · 목록: /models`);
|
|
225
295
|
}
|
|
@@ -227,105 +297,147 @@ async function handleSlash(text, ctx) {
|
|
|
227
297
|
case "models":
|
|
228
298
|
try {
|
|
229
299
|
const models = await fetchModels();
|
|
230
|
-
const
|
|
231
|
-
const rows = models.filter(
|
|
232
|
-
(m) => !q || m.id.toLowerCase().includes(q) || (m.display_name || "").toLowerCase().includes(q),
|
|
233
|
-
);
|
|
300
|
+
const rows = matchModels(models, arg);
|
|
234
301
|
console.log(rows.length ? formatTable(rows) : "일치하는 모델 없음");
|
|
235
302
|
} catch (e) {
|
|
236
|
-
console.error(
|
|
303
|
+
console.error(`${style.red("✗")} ${e.message}`);
|
|
237
304
|
}
|
|
238
305
|
break;
|
|
239
306
|
case "save":
|
|
240
307
|
try {
|
|
241
308
|
const name = saveSession(arg || "last", ctx.snapshot());
|
|
242
|
-
console.log(
|
|
309
|
+
console.log(`${style.green("✓")} 세션 저장: ${name}`);
|
|
243
310
|
} catch (e) {
|
|
244
|
-
console.error(
|
|
311
|
+
console.error(`${style.red("✗")} 저장 실패: ${e.message}`);
|
|
245
312
|
}
|
|
246
313
|
break;
|
|
247
314
|
case "load":
|
|
248
315
|
try {
|
|
249
316
|
ctx.restore(loadSession(arg || "last"));
|
|
250
|
-
console.log(
|
|
317
|
+
console.log(`${style.green("✓")} 세션 불러옴 (모델: ${ctx.model})`);
|
|
251
318
|
} catch {
|
|
252
|
-
console.error(
|
|
319
|
+
console.error(`${style.red("✗")} 세션 없음: ${arg || "last"}`);
|
|
253
320
|
}
|
|
254
321
|
break;
|
|
255
322
|
case "verbose":
|
|
256
323
|
ctx.verbose = !ctx.verbose;
|
|
257
|
-
console.log(
|
|
324
|
+
console.log(`${style.green("✓")} 통계 ${ctx.verbose ? "켜짐" : "꺼짐"}`);
|
|
258
325
|
break;
|
|
259
326
|
default:
|
|
260
327
|
console.log(`알 수 없는 명령: /${cmd} (/help)`);
|
|
261
328
|
}
|
|
262
329
|
}
|
|
263
330
|
|
|
264
|
-
|
|
331
|
+
// SSE 스트리밍 채팅. fetchImpl 주입 가능(테스트용 — 네트워크 없이 SSE 파싱 검증).
|
|
332
|
+
// 비용은 마지막 SSE 청크의 cost.krw — 스트리밍 응답엔 X-Cost-Krw 헤더가 없다(백엔드 계약).
|
|
333
|
+
export async function streamChat(key, model, messages, { verbose = false, quiet = false, signal, fetchImpl = fetch } = {}) {
|
|
265
334
|
const t0 = Date.now();
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
335
|
+
const sp = spinner(`${model} 생각 중…`);
|
|
336
|
+
let res;
|
|
337
|
+
try {
|
|
338
|
+
res = await fetchImpl(`${V1}/chat/completions`, {
|
|
339
|
+
method: "POST",
|
|
340
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
|
|
341
|
+
body: JSON.stringify({ model, messages, stream: true, stream_options: { include_usage: true } }),
|
|
342
|
+
signal,
|
|
343
|
+
});
|
|
344
|
+
} catch (e) {
|
|
345
|
+
sp.stop();
|
|
346
|
+
if (isAbort(e)) {
|
|
347
|
+
console.error(style.dim("⏹ 중단됨"));
|
|
348
|
+
return "";
|
|
349
|
+
}
|
|
350
|
+
throw e;
|
|
351
|
+
}
|
|
271
352
|
if (!res.ok) {
|
|
353
|
+
sp.stop();
|
|
272
354
|
const body = await res.text().catch(() => "");
|
|
273
355
|
throw new Error(`${res.status} ${body.slice(0, 300)}`);
|
|
274
356
|
}
|
|
275
357
|
|
|
276
|
-
|
|
358
|
+
const md = createMarkdownStream({ prefix: quiet ? "" : `${style.plum("bot")}${style.dim(">")} ` });
|
|
277
359
|
let full = "";
|
|
278
360
|
let buf = "";
|
|
279
361
|
let usage = null;
|
|
362
|
+
let cost = null;
|
|
280
363
|
let firstAt = 0;
|
|
364
|
+
let aborted = false;
|
|
281
365
|
const decoder = new TextDecoder();
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
366
|
+
try {
|
|
367
|
+
for await (const chunk of res.body) {
|
|
368
|
+
buf += decoder.decode(chunk, { stream: true });
|
|
369
|
+
const lines = buf.split("\n");
|
|
370
|
+
buf = lines.pop() ?? "";
|
|
371
|
+
for (const l of lines) {
|
|
372
|
+
const s = l.trim();
|
|
373
|
+
if (!s.startsWith("data:")) continue;
|
|
374
|
+
const data = s.slice(5).trim();
|
|
375
|
+
if (data === "[DONE]") continue;
|
|
376
|
+
try {
|
|
377
|
+
const j = JSON.parse(data);
|
|
378
|
+
if (j.usage) usage = j.usage;
|
|
379
|
+
if (j.cost) cost = j.cost; // 마지막 청크 — {usd, krw, fx_rate, markup_rate}
|
|
380
|
+
const delta = j.choices?.[0]?.delta?.content;
|
|
381
|
+
if (delta) {
|
|
382
|
+
if (!firstAt) {
|
|
383
|
+
firstAt = Date.now();
|
|
384
|
+
sp.stop();
|
|
385
|
+
}
|
|
386
|
+
md.push(delta);
|
|
387
|
+
full += delta;
|
|
388
|
+
}
|
|
389
|
+
} catch {
|
|
390
|
+
// 부분 청크 — 무시
|
|
299
391
|
}
|
|
300
|
-
} catch {
|
|
301
|
-
// 부분 청크 — 무시
|
|
302
392
|
}
|
|
303
393
|
}
|
|
394
|
+
} catch (e) {
|
|
395
|
+
if (!isAbort(e)) {
|
|
396
|
+
sp.stop();
|
|
397
|
+
throw e;
|
|
398
|
+
}
|
|
399
|
+
aborted = true;
|
|
304
400
|
}
|
|
305
|
-
|
|
401
|
+
sp.stop();
|
|
402
|
+
md.finish();
|
|
403
|
+
if (aborted) console.error(style.dim("⏹ 생성 중단됨 — 부분 응답은 컨텍스트에 유지"));
|
|
306
404
|
|
|
307
405
|
const ms = Date.now() - t0;
|
|
308
406
|
const genSec = (Date.now() - (firstAt || t0)) / 1000;
|
|
309
407
|
const tokPerSec = usage?.completion_tokens && genSec > 0 ? usage.completion_tokens / genSec : null;
|
|
310
|
-
const
|
|
311
|
-
// 파이프
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
408
|
+
const krw = cost?.krw ?? null;
|
|
409
|
+
// 파이프(quiet)는 stdout을 깨끗이 유지 — 통계는 stderr로. 비용은 터미널(stderr TTY)
|
|
410
|
+
// 또는 verbose일 때 표시하고, 완전 리다이렉트된 스크립트 실행에선 침묵한다.
|
|
411
|
+
const showCost = !quiet || verbose || process.stderr.isTTY;
|
|
412
|
+
const line = formatStats({ cost: showCost ? krw : null, usage, ms, tokPerSec, verbose });
|
|
413
|
+
if (line) (quiet ? console.error : console.log)(style.dim(line));
|
|
315
414
|
return full;
|
|
316
415
|
}
|
|
317
416
|
|
|
318
417
|
// ── 인터랙티브 모델 피커 ──────────────────────────────────────────────
|
|
319
|
-
//
|
|
418
|
+
// 화살표 셀렉터(raw-mode) 우선, 비TTY 등으로 불가하면 번호 선택 폴백.
|
|
320
419
|
async function pickModel() {
|
|
321
420
|
let models;
|
|
322
421
|
try {
|
|
323
422
|
models = await fetchModels();
|
|
324
423
|
} catch (e) {
|
|
325
|
-
console.error(
|
|
424
|
+
console.error(`${style.red("✗")} 모델 목록 조회 실패: ${e.message}`);
|
|
326
425
|
return null;
|
|
327
426
|
}
|
|
328
427
|
if (!models.length) return null;
|
|
428
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
429
|
+
return select(models, {
|
|
430
|
+
message: "모델 선택",
|
|
431
|
+
formatItem: (m) => `${m.id}${m.display_name ? ` ${style.dim(m.display_name)}` : ""}`,
|
|
432
|
+
matches: matchModels,
|
|
433
|
+
limit: 12,
|
|
434
|
+
}).then((m) => m?.id ?? null);
|
|
435
|
+
}
|
|
436
|
+
return pickModelNumbered(models);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// 번호 선택 + 검색어 좁히기 — raw-mode가 안 되는 환경 폴백.
|
|
440
|
+
async function pickModelNumbered(models) {
|
|
329
441
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
330
442
|
const ask = (q) => new Promise((r) => rl.question(q, r));
|
|
331
443
|
let pool = models;
|
|
@@ -340,10 +452,7 @@ async function pickModel() {
|
|
|
340
452
|
if (!ans) return null;
|
|
341
453
|
const n = Number(ans);
|
|
342
454
|
if (Number.isInteger(n) && n >= 1 && n <= shown.length) return shown[n - 1].id;
|
|
343
|
-
const
|
|
344
|
-
const next = models.filter(
|
|
345
|
-
(m) => m.id.toLowerCase().includes(q) || (m.display_name || "").toLowerCase().includes(q),
|
|
346
|
-
);
|
|
455
|
+
const next = matchModels(models, ans);
|
|
347
456
|
pool = next.length ? next : models;
|
|
348
457
|
if (!next.length) console.log("일치 없음 — 전체 목록으로.");
|
|
349
458
|
}
|
package/src/top.mjs
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// `pleum top [--period daily|weekly|monthly]` — 공개 리더보드(/v1/stats/rankings).
|
|
2
|
+
// ollama ps의 "지금 뜨는 것" 포지션 — 로컬 프로세스가 아니라 라우터 전체의 핫한 모델.
|
|
3
|
+
|
|
4
|
+
import { V1 } from "./config.mjs";
|
|
5
|
+
import { style, padDisplay, barChart, divider } from "./ansi.mjs";
|
|
6
|
+
|
|
7
|
+
export function parseTopArgs(rest = []) {
|
|
8
|
+
let period = "weekly";
|
|
9
|
+
for (let i = 0; i < rest.length; i++) {
|
|
10
|
+
if (rest[i] === "--period" || rest[i] === "-p") period = rest[++i];
|
|
11
|
+
else if (["daily", "weekly", "monthly"].includes(rest[i])) period = rest[i];
|
|
12
|
+
}
|
|
13
|
+
if (!["daily", "weekly", "monthly"].includes(period)) {
|
|
14
|
+
throw new Error(`period는 daily|weekly|monthly 중 하나: ${period}`);
|
|
15
|
+
}
|
|
16
|
+
return { period };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const fmtTok = (n) => {
|
|
20
|
+
if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
|
|
21
|
+
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
|
22
|
+
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
|
|
23
|
+
return String(n);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// rankings 응답 → 표시 문자열(순수 함수 — fixture 테스트 대상).
|
|
27
|
+
export function renderTop(data, styler = style) {
|
|
28
|
+
const periodKo = { daily: "일간", weekly: "주간", monthly: "월간" }[data.period] || data.period;
|
|
29
|
+
const lines = [];
|
|
30
|
+
lines.push(`${styler.bold(styler.plum("모델 리더보드"))} ${styler.dim(`· ${periodKo} · 토큰 ${fmtTok(data.total_tokens ?? 0)}`)}`);
|
|
31
|
+
lines.push(divider(undefined, styler));
|
|
32
|
+
|
|
33
|
+
const board = data.leaderboard || [];
|
|
34
|
+
if (!board.length) {
|
|
35
|
+
lines.push(styler.dim("데이터 없음"));
|
|
36
|
+
} else {
|
|
37
|
+
const idW = Math.max(...board.map((r) => r.model.length), 5);
|
|
38
|
+
for (const r of board) {
|
|
39
|
+
const trend =
|
|
40
|
+
r.trend_pct == null
|
|
41
|
+
? styler.dim(" new")
|
|
42
|
+
: r.trend_pct >= 0
|
|
43
|
+
? styler.green(`▲${r.trend_pct}%`)
|
|
44
|
+
: styler.red(`▼${Math.abs(r.trend_pct)}%`);
|
|
45
|
+
lines.push(
|
|
46
|
+
`${styler.dim(String(r.rank).padStart(2))}. ${styler.plum(padDisplay(r.model, idW))} ` +
|
|
47
|
+
`${padDisplay(fmtTok(r.tokens), 7)} tok ${padDisplay(String(r.calls), 6)} calls ${trend}` +
|
|
48
|
+
styler.dim(` by ${r.maker}`),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const share = data.market_share || [];
|
|
54
|
+
if (share.length) {
|
|
55
|
+
lines.push("");
|
|
56
|
+
lines.push(styler.bold("제공자 점유율"));
|
|
57
|
+
const nameW = Math.max(...share.map((s) => s.provider.length), 8);
|
|
58
|
+
for (const s of share) {
|
|
59
|
+
lines.push(
|
|
60
|
+
`${padDisplay(s.provider, nameW)} ${styler.plum(barChart(s.share_pct, 100, 24))} ${String(s.share_pct).padStart(5)}%`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return lines.join("\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function top(rest) {
|
|
68
|
+
const { period } = parseTopArgs(rest);
|
|
69
|
+
const res = await fetch(`${V1}/stats/rankings?period=${period}`);
|
|
70
|
+
if (!res.ok) throw new Error(`리더보드 조회 실패 (${res.status})`);
|
|
71
|
+
console.log(renderTop(await res.json()));
|
|
72
|
+
}
|
package/src/usage.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// `pleum usage [--by model|provider|api_key|day] [--days N]` — 내 지출 리포트.
|
|
2
|
+
// GET /v1/usage/report (API 키 + usage:read 스코프 — CLI 로그인 키에 기본 동봉).
|
|
3
|
+
|
|
4
|
+
import { V1, requireKey } from "./config.mjs";
|
|
5
|
+
import { style, padDisplay, barChart, divider } from "./ansi.mjs";
|
|
6
|
+
|
|
7
|
+
const GROUPS = ["model", "provider", "api_key", "day"];
|
|
8
|
+
|
|
9
|
+
export function parseUsageArgs(rest = []) {
|
|
10
|
+
let by = "model";
|
|
11
|
+
let days = 30;
|
|
12
|
+
for (let i = 0; i < rest.length; i++) {
|
|
13
|
+
if (rest[i] === "--by") by = rest[++i];
|
|
14
|
+
else if (rest[i] === "--days") days = Number(rest[++i]);
|
|
15
|
+
}
|
|
16
|
+
if (!GROUPS.includes(by)) throw new Error(`--by 는 ${GROUPS.join("|")} 중 하나: ${by}`);
|
|
17
|
+
if (!Number.isInteger(days) || days < 1 || days > 365) throw new Error(`--days 는 1~365 정수: ${days}`);
|
|
18
|
+
return { by, days };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const fmtKrw = (n) => `₩${Math.round(n).toLocaleString("en-US")}`;
|
|
22
|
+
|
|
23
|
+
// report 응답 → 표시 문자열(순수 함수 — fixture 테스트 대상).
|
|
24
|
+
export function renderUsage(data, styler = style) {
|
|
25
|
+
const lines = [];
|
|
26
|
+
const byKo = { model: "모델별", provider: "제공자별", api_key: "키별", day: "일별" }[data.group_by] || data.group_by;
|
|
27
|
+
lines.push(`${styler.bold(styler.plum("사용량"))} ${styler.dim(`· ${byKo} · 최근 ${data.days}일`)}`);
|
|
28
|
+
lines.push(divider(undefined, styler));
|
|
29
|
+
const items = data.items || [];
|
|
30
|
+
if (!items.length) {
|
|
31
|
+
lines.push(styler.dim("사용 내역 없음"));
|
|
32
|
+
return lines.join("\n");
|
|
33
|
+
}
|
|
34
|
+
const max = Math.max(...items.map((i) => i.cost_krw), 1);
|
|
35
|
+
const keyW = Math.max(...items.map((i) => i.key.length), 4);
|
|
36
|
+
const total = { cost: 0, calls: 0 };
|
|
37
|
+
for (const it of items) {
|
|
38
|
+
total.cost += it.cost_krw;
|
|
39
|
+
total.calls += it.calls;
|
|
40
|
+
lines.push(
|
|
41
|
+
`${padDisplay(it.key, keyW)} ${styler.plum(barChart(it.cost_krw, max, 20))} ${padDisplay(fmtKrw(it.cost_krw), 10)}` +
|
|
42
|
+
styler.dim(` ↑${it.input_tokens.toLocaleString("en-US")} ↓${it.output_tokens.toLocaleString("en-US")} tok · ${it.calls} calls`),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
lines.push(divider(undefined, styler));
|
|
46
|
+
lines.push(`${padDisplay("합계", keyW)} ${" ".repeat(20)} ${styler.bold(fmtKrw(total.cost))}${styler.dim(` ${total.calls} calls`)}`);
|
|
47
|
+
return lines.join("\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function usage(rest) {
|
|
51
|
+
const { by, days } = parseUsageArgs(rest);
|
|
52
|
+
const key = requireKey();
|
|
53
|
+
const res = await fetch(`${V1}/usage/report?group_by=${by}&days=${days}`, {
|
|
54
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
55
|
+
});
|
|
56
|
+
if (res.status === 403) {
|
|
57
|
+
// 구버전 로그인 키(usage:read 스코프 없음) — 재로그인으로 새 키를 받으면 해결.
|
|
58
|
+
console.error(
|
|
59
|
+
`${style.red("✗")} 이 API 키에는 usage:read 권한이 없습니다.\n` +
|
|
60
|
+
` ${style.dim("pleum login 을 다시 실행해 새 키를 발급받으세요 (최신 키에는 기본 포함).")}`,
|
|
61
|
+
);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
const body = await res.text().catch(() => "");
|
|
66
|
+
throw new Error(`사용량 조회 실패 (${res.status})${body ? `: ${body.slice(0, 200)}` : ""}`);
|
|
67
|
+
}
|
|
68
|
+
console.log(renderUsage(await res.json()));
|
|
69
|
+
}
|