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/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(`✗ ${e.message}`);
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, model, verbose) {
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
- console.log(`PleumRouter · ${model}\n/help 으로 명령, /exit 로 종료. 여러 줄은 """ 로 감싸기.\n`);
102
- rl.prompt();
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
- rl.on("line", async (line) => {
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
- rl.on("close", () => {
204
+ const onClose = () => {
205
+ if (pickerActive) return;
180
206
  saveHistory(rl.history);
181
- console.log("\n안녕히 가세요.");
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, ...rest] = text.slice(1).split(/\s+/);
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(`✓ 모델 전환 → ${arg}`);
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 q = arg.toLowerCase();
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(`✗ ${e.message}`);
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(`✓ 세션 저장: ${name}`);
309
+ console.log(`${style.green("✓")} 세션 저장: ${name}`);
243
310
  } catch (e) {
244
- console.error(`✗ 저장 실패: ${e.message}`);
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(`✓ 세션 불러옴 (모델: ${ctx.model})`);
317
+ console.log(`${style.green("✓")} 세션 불러옴 (모델: ${ctx.model})`);
251
318
  } catch {
252
- console.error(`✗ 세션 없음: ${arg || "last"}`);
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(`✓ 통계 ${ctx.verbose ? "켜짐" : "꺼짐"}`);
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
- async function streamChat(key, model, messages, { verbose = false, quiet = false } = {}) {
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 res = await fetch(`${V1}/chat/completions`, {
267
- method: "POST",
268
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
269
- body: JSON.stringify({ model, messages, stream: true, stream_options: { include_usage: true } }),
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
- if (!quiet) process.stdout.write("bot> ");
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
- for await (const chunk of res.body) {
283
- buf += decoder.decode(chunk, { stream: true });
284
- const lines = buf.split("\n");
285
- buf = lines.pop() ?? "";
286
- for (const l of lines) {
287
- const s = l.trim();
288
- if (!s.startsWith("data:")) continue;
289
- const data = s.slice(5).trim();
290
- if (data === "[DONE]") continue;
291
- try {
292
- const j = JSON.parse(data);
293
- if (j.usage) usage = j.usage;
294
- const delta = j.choices?.[0]?.delta?.content;
295
- if (delta) {
296
- if (!firstAt) firstAt = Date.now();
297
- process.stdout.write(delta);
298
- full += delta;
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
- process.stdout.write("\n");
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 cost = res.headers.get("x-cost-krw");
311
- // 파이프 출력(quiet)은 stdout을 깨끗이 — 통계는 verbose일 때만 stderr로.
312
- const line = formatStats({ cost: quiet ? null : cost, usage, ms, tokPerSec, verbose });
313
- if (line) (quiet ? console.error : console.log)(line);
314
- else if (quiet && verbose && cost) console.error(` (₩${cost})`);
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
- // 번호 선택 + 검색어 좁히기. ponytail: 화살표 raw-mode는 후속(SSH/파이프 엣지케이스 회피).
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(`✗ 모델 목록 조회 실패: ${e.message}`);
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 q = ans.toLowerCase();
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
+ }