pleumrouter 0.2.2 → 0.3.0
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 +10 -2
- package/bin/pleum.mjs +19 -1
- package/package.json +1 -1
- package/src/ansi.mjs +95 -0
- package/src/balance.mjs +12 -5
- package/src/completion.mjs +1 -1
- package/src/health.mjs +55 -0
- package/src/interactive.mjs +145 -0
- package/src/markdown.mjs +91 -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/README.md
CHANGED
|
@@ -32,9 +32,13 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
|
|
|
32
32
|
| `pleum login [--device]` | 브라우저로 로그인 → CLI가 API 키를 자동 발급·저장(`~/.pleum/config.json`). `--device`는 SSH·CI |
|
|
33
33
|
| `pleum logout` | 저장된 키 삭제 |
|
|
34
34
|
| `pleum launch [tool] [--model id] [-- args]` | 에이전트 실행. `--` 뒤 인자는 도구로 그대로 전달. 도구 생략 시 지원 목록·설치 여부 표시 |
|
|
35
|
-
| `pleum run [model] ["프롬프트"]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시
|
|
35
|
+
| `pleum run [model] ["프롬프트"]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시 화살표로 선택 |
|
|
36
36
|
| `pleum models [query] [--json]` | 모델 목록(공개) — provider·컨텍스트·가격(₩/1M) 컬럼, `--json`은 스크립트용 |
|
|
37
|
+
| `pleum models <id> --info` | 모델 상세 — 가격·컨텍스트·provider별 지연/처리량/업타임·주간 사용량·형제 모델 |
|
|
38
|
+
| `pleum top [--period daily\|weekly\|monthly]` | 라우터 전체 모델 리더보드 + 제공자 점유율(공개 통계) |
|
|
37
39
|
| `pleum balance` | 크레딧 잔액 조회 |
|
|
40
|
+
| `pleum usage [--by model\|provider\|api_key\|day] [--days N]` | 내 지출 리포트(막대 차트) — 최신 로그인 키 필요(`usage:read`) |
|
|
41
|
+
| `pleum health` | 게이트웨이 연결·DB·Redis·환율·로그인 상태 점검(셀프호스트 진단용) |
|
|
38
42
|
| `pleum completion <zsh\|bash>` | 셸 자동완성 스크립트 출력 |
|
|
39
43
|
| `pleum upgrade` | 최신 버전으로 재설치 |
|
|
40
44
|
| `pleum version` | 버전 출력 |
|
|
@@ -46,7 +50,7 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
|
|
|
46
50
|
| 명령 | 설명 |
|
|
47
51
|
|------|------|
|
|
48
52
|
| `/help`, `/?` | 명령 도움말 |
|
|
49
|
-
| `/model [id]` | **세션 유지한 채 모델 전환**(
|
|
53
|
+
| `/model [id]` | **세션 유지한 채 모델 전환**(인자 없으면 화살표 셀렉터) |
|
|
50
54
|
| `/system [텍스트]` | 시스템 프롬프트 설정/확인 |
|
|
51
55
|
| `/clear` | 대화 컨텍스트 초기화 |
|
|
52
56
|
| `/models [검색어]` | 모델 목록 보기 |
|
|
@@ -54,9 +58,13 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
|
|
|
54
58
|
| `/verbose` | 토큰·속도 통계 토글 |
|
|
55
59
|
| `/exit`, `/quit` | 종료 |
|
|
56
60
|
|
|
61
|
+
- 응답은 **마크다운 스트리밍 렌더**(굵게·코드블록 하이라이트·리스트) — 파이프로 빼면 자동으로 원문 그대로
|
|
62
|
+
- 응답 후 비용이 PLM으로 표시(스트리밍 마지막 청크 기준)
|
|
63
|
+
- **Ctrl+C**: 생성 중엔 1회로 중단(대화 유지), 대기 중엔 2초 내 2회로 종료
|
|
57
64
|
- 여러 줄 입력: `"""` 한 줄 → 본문 → `"""` 한 줄
|
|
58
65
|
- 이미지: 메시지에 이미지 파일 경로를 그대로 포함 (예: `이 그림 설명해줘 ./shot.png`)
|
|
59
66
|
- `↑` 로 지난 입력 재호출(히스토리는 `~/.pleum/history`에 영속)
|
|
67
|
+
- 색상 끄기: `NO_COLOR=1` (비TTY·파이프에선 자동 무색)
|
|
60
68
|
|
|
61
69
|
## 자동 실행 지원 도구
|
|
62
70
|
|
package/bin/pleum.mjs
CHANGED
|
@@ -5,6 +5,9 @@ import { launch, launchHelp } from "../src/launch.mjs";
|
|
|
5
5
|
import { run } from "../src/run.mjs";
|
|
6
6
|
import { listModels } from "../src/models.mjs";
|
|
7
7
|
import { balance } from "../src/balance.mjs";
|
|
8
|
+
import { usage } from "../src/usage.mjs";
|
|
9
|
+
import { top } from "../src/top.mjs";
|
|
10
|
+
import { health } from "../src/health.mjs";
|
|
8
11
|
import { completion } from "../src/completion.mjs";
|
|
9
12
|
import { upgrade } from "../src/upgrade.mjs";
|
|
10
13
|
import { clearConfig } from "../src/config.mjs";
|
|
@@ -18,9 +21,13 @@ const HELP = `pleum — PleumRouter 터미널 CLI
|
|
|
18
21
|
(claude · aider · codex · goose · openhands)
|
|
19
22
|
도구 생략 시 지원 목록·설치 여부 표시
|
|
20
23
|
pleum run [model] ["프롬프트"] 터미널 채팅(REPL) / 1회 실행 / 파이프
|
|
21
|
-
모델 생략 시
|
|
24
|
+
모델 생략 시 화살표로 선택, /help 로 명령
|
|
22
25
|
pleum models [query] [--json] 모델 목록(가격·컨텍스트 포함)
|
|
26
|
+
pleum models <id> --info 모델 상세(가격·성능·provider별 uptime)
|
|
27
|
+
pleum top [--period weekly] 모델 리더보드·제공자 점유율(daily|weekly|monthly)
|
|
23
28
|
pleum balance 크레딧 잔액 조회
|
|
29
|
+
pleum usage [--by model] [--days 30] 내 지출 리포트(model|provider|api_key|day)
|
|
30
|
+
pleum health 게이트웨이 연결·로그인 상태 점검
|
|
24
31
|
pleum completion <zsh|bash> 셸 자동완성 스크립트 출력
|
|
25
32
|
pleum upgrade 최신 버전으로 재설치
|
|
26
33
|
pleum version 버전 출력
|
|
@@ -31,6 +38,8 @@ const HELP = `pleum — PleumRouter 터미널 CLI
|
|
|
31
38
|
pleum run gpt-4.1 # 대화형
|
|
32
39
|
pleum run gpt-4.1 "한 줄로 요약: ..." # 1회 실행
|
|
33
40
|
cat error.log | pleum run gpt-4.1 "원인은?" # 파이프
|
|
41
|
+
pleum models deepseek/deepseek-chat --info # 모델 상세
|
|
42
|
+
pleum usage --by day --days 7 # 최근 1주 일별 지출
|
|
34
43
|
pleum login --device # SSH/CI 환경
|
|
35
44
|
|
|
36
45
|
base_url 덮어쓰기: PLEUM_BASE_URL 환경변수`;
|
|
@@ -84,6 +93,15 @@ try {
|
|
|
84
93
|
case "balance":
|
|
85
94
|
await balance();
|
|
86
95
|
break;
|
|
96
|
+
case "usage":
|
|
97
|
+
await usage(rest);
|
|
98
|
+
break;
|
|
99
|
+
case "top":
|
|
100
|
+
await top(rest);
|
|
101
|
+
break;
|
|
102
|
+
case "health":
|
|
103
|
+
await health();
|
|
104
|
+
break;
|
|
87
105
|
case "completion":
|
|
88
106
|
completion(rest[0]);
|
|
89
107
|
break;
|
package/package.json
CHANGED
package/src/ansi.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// 색상/스타일 프리미티브 — zero-dep. NO_COLOR·비TTY·dumb 터미널이면 자동 무색.
|
|
2
|
+
// 브랜드 컬러는 frontend globals.css의 --plum-bright(#936c89) 계열.
|
|
3
|
+
|
|
4
|
+
// 색상 지원 레벨: 0=무색, 1=16색, 2=256색, 3=truecolor. env/stream 주입 가능(테스트용).
|
|
5
|
+
export function detectColorLevel(env = process.env, stream = process.stdout) {
|
|
6
|
+
if (env.NO_COLOR != null) return 0;
|
|
7
|
+
if (env.FORCE_COLOR != null) {
|
|
8
|
+
const n = Number(env.FORCE_COLOR);
|
|
9
|
+
return Number.isFinite(n) ? Math.max(0, Math.min(3, n)) : 3;
|
|
10
|
+
}
|
|
11
|
+
if (!stream.isTTY || env.TERM === "dumb") return 0;
|
|
12
|
+
if (env.COLORTERM === "truecolor" || env.COLORTERM === "24bit") return 3;
|
|
13
|
+
if (/-256(color)?$/.test(env.TERM || "")) return 2;
|
|
14
|
+
return 1;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PLUM = [147, 108, 137]; // #936c89 — 다크 터미널에서 가독성 있는 브랜드 보라
|
|
18
|
+
|
|
19
|
+
// RGB → xterm 256색 큐브 근사(16 + 36r + 6g + b, 각 축 0–5 양자화).
|
|
20
|
+
function rgbTo256([r, g, b]) {
|
|
21
|
+
const q = (v) => Math.round((v / 255) * 5);
|
|
22
|
+
return 16 + 36 * q(r) + 6 * q(g) + q(b);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 각 wrap은 \x1b[0m 완전 리셋 — 중첩 보존보다 색 누수 방지 우선(zero-dep 트레이드오프).
|
|
26
|
+
export function createStyler(level = detectColorLevel()) {
|
|
27
|
+
const wrap = (open) => (s) => (level ? `${open}${s}\x1b[0m` : String(s));
|
|
28
|
+
const rgb = (c, fallback16) =>
|
|
29
|
+
level >= 3
|
|
30
|
+
? wrap(`\x1b[38;2;${c.join(";")}m`)
|
|
31
|
+
: level === 2
|
|
32
|
+
? wrap(`\x1b[38;5;${rgbTo256(c)}m`)
|
|
33
|
+
: wrap(`\x1b[${fallback16}m`);
|
|
34
|
+
return {
|
|
35
|
+
level,
|
|
36
|
+
bold: wrap("\x1b[1m"),
|
|
37
|
+
dim: wrap("\x1b[2m"),
|
|
38
|
+
italic: wrap("\x1b[3m"),
|
|
39
|
+
plum: rgb(PLUM, 35), // 16색 폴백=마젠타
|
|
40
|
+
green: wrap("\x1b[32m"),
|
|
41
|
+
red: wrap("\x1b[31m"),
|
|
42
|
+
yellow: wrap("\x1b[33m"),
|
|
43
|
+
cyan: wrap("\x1b[36m"),
|
|
44
|
+
gray: wrap("\x1b[90m"),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// CLI 런타임 싱글턴 — 파이프/NO_COLOR면 모든 함수가 항등이라 호출부 분기 불필요.
|
|
49
|
+
export const style = createStyler();
|
|
50
|
+
|
|
51
|
+
export function stripAnsi(s) {
|
|
52
|
+
// eslint 불필요 — CSI 시퀀스만 제거하면 충분(우리가 쓰는 코드 전부 CSI).
|
|
53
|
+
return String(s).replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 터미널 표시 폭 — CJK(한글 등) 전각 문자는 2칸. 정렬·줄수 계산용.
|
|
57
|
+
export function displayWidth(s) {
|
|
58
|
+
let w = 0;
|
|
59
|
+
for (const ch of stripAnsi(s)) {
|
|
60
|
+
const c = ch.codePointAt(0);
|
|
61
|
+
const wide =
|
|
62
|
+
c >= 0x1100 &&
|
|
63
|
+
(c <= 0x115f || // 한글 자모
|
|
64
|
+
(c >= 0x2e80 && c <= 0xa4cf) || // CJK 부수·한자·가나
|
|
65
|
+
(c >= 0xac00 && c <= 0xd7a3) || // 한글 음절
|
|
66
|
+
(c >= 0xf900 && c <= 0xfaff) ||
|
|
67
|
+
(c >= 0xfe30 && c <= 0xfe4f) ||
|
|
68
|
+
(c >= 0xff00 && c <= 0xff60) || // 전각 영숫자
|
|
69
|
+
(c >= 0xffe0 && c <= 0xffe6) ||
|
|
70
|
+
(c >= 0x20000 && c <= 0x3fffd));
|
|
71
|
+
w += wide ? 2 : 1;
|
|
72
|
+
}
|
|
73
|
+
return w;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 표시 폭 기준 오른쪽 패딩 — ANSI·전각 섞여도 컬럼이 맞는다(String.padEnd 대체).
|
|
77
|
+
export function padDisplay(s, width) {
|
|
78
|
+
const pad = width - displayWidth(s);
|
|
79
|
+
return pad > 0 ? s + " ".repeat(pad) : s;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function terminalWidth(fallback = 80) {
|
|
83
|
+
return process.stdout.columns || fallback;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function divider(width = Math.min(terminalWidth(), 60), styler = style) {
|
|
87
|
+
return styler.gray("─".repeat(Math.max(1, width)));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// "█████░░░░░" 수평 막대(순수 함수) — usage/top 공용.
|
|
91
|
+
export function barChart(value, max, width = 20) {
|
|
92
|
+
if (!Number.isFinite(value) || !max || max <= 0) return "░".repeat(width);
|
|
93
|
+
const filled = Math.round(Math.min(Math.max(value / max, 0), 1) * width);
|
|
94
|
+
return "█".repeat(filled) + "░".repeat(Math.max(0, width - filled));
|
|
95
|
+
}
|
package/src/balance.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { V1 } from "./config.mjs";
|
|
2
|
-
import {
|
|
1
|
+
import { V1, requireKey } from "./config.mjs";
|
|
2
|
+
import { style } from "./ansi.mjs";
|
|
3
3
|
|
|
4
4
|
export async function balance() {
|
|
5
5
|
const key = requireKey();
|
|
@@ -8,11 +8,18 @@ export async function balance() {
|
|
|
8
8
|
});
|
|
9
9
|
if (!res.ok) {
|
|
10
10
|
const body = await res.text().catch(() => "");
|
|
11
|
-
console.error(`잔액 조회 실패 (HTTP ${res.status})${body ? `: ${body}` : ""}`);
|
|
11
|
+
console.error(`잔액 조회 실패 (HTTP ${res.status})${body ? `: ${body.slice(0, 200)}` : ""}`);
|
|
12
|
+
if (res.status === 401) {
|
|
13
|
+
console.error(style.dim("서버가 아직 API 키 잔액 조회를 지원하지 않거나 키가 만료됐을 수 있습니다 — pleum login 재시도."));
|
|
14
|
+
}
|
|
12
15
|
process.exit(1);
|
|
13
16
|
}
|
|
14
17
|
const data = await res.json();
|
|
15
18
|
const plm = data.balance_krw;
|
|
16
|
-
const usd = data.balance_usd != null ? ` / $${data.balance_usd.toFixed(4)}` : "";
|
|
17
|
-
console.log(`잔액: ${plm.toLocaleString("ko-KR")} PLM${usd}`);
|
|
19
|
+
const usd = data.balance_usd != null ? style.dim(` / $${data.balance_usd.toFixed(4)}`) : "";
|
|
20
|
+
console.log(`잔액: ${style.bold(style.plum(`${plm.toLocaleString("ko-KR")} PLM`))}${usd}`);
|
|
21
|
+
const threshold = data.auto_charge_threshold_krw;
|
|
22
|
+
if (threshold != null && plm < threshold) {
|
|
23
|
+
console.log(style.yellow(`⚠ 잔액이 자동충전 임계값(${threshold.toLocaleString("ko-KR")} PLM) 아래입니다.`));
|
|
24
|
+
}
|
|
18
25
|
}
|
package/src/completion.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Shell 자동완성 스크립트 출력. 명령·도구는 정적, 모델명은 `pleum models`에서 동적으로 채운다.
|
|
2
2
|
// 설치: pleum completion zsh >> ~/.zshrc · pleum completion bash >> ~/.bashrc
|
|
3
|
-
const COMMANDS = "login logout launch run models balance version upgrade completion help";
|
|
3
|
+
const COMMANDS = "login logout launch run models top balance usage health version upgrade completion help";
|
|
4
4
|
const TOOLS = "claude aider codex goose openhands opencode crush";
|
|
5
5
|
|
|
6
6
|
function bash() {
|
package/src/health.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// `pleum health` — 게이트웨이 연결성 + 로그인 상태 한눈에.
|
|
2
|
+
// 셀프호스트/스테이징(PLEUM_BASE_URL) 점검·고객지원 트리아지용. 공개 /v1/health 재사용.
|
|
3
|
+
|
|
4
|
+
import { ROOT, V1, readConfig } from "./config.mjs";
|
|
5
|
+
import { style, divider } from "./ansi.mjs";
|
|
6
|
+
|
|
7
|
+
const ok = (s) => `${style.green("●")} ${s}`;
|
|
8
|
+
const bad = (s) => `${style.red("●")} ${s}`;
|
|
9
|
+
const warn = (s) => `${style.yellow("●")} ${s}`;
|
|
10
|
+
|
|
11
|
+
// health 응답 + 로컬 상태 → 표시 문자열(순수 함수 — fixture 테스트 대상).
|
|
12
|
+
export function renderHealth({ root, health, error, ms, keyPrefix }, styler = style) {
|
|
13
|
+
const lines = [];
|
|
14
|
+
lines.push(`${styler.bold(styler.plum("pleum health"))} ${styler.dim(`· ${root}`)}`);
|
|
15
|
+
lines.push(divider(undefined, styler));
|
|
16
|
+
if (error) {
|
|
17
|
+
lines.push(bad(`게이트웨이 연결 실패: ${error}`));
|
|
18
|
+
} else {
|
|
19
|
+
const isOk = (v) => v === "ok" || v === true; // 백엔드는 "ok" 문자열
|
|
20
|
+
lines.push((isOk(health.status) ? ok : warn)(`게이트웨이 ${health.status}${ms != null ? styler.dim(` (${ms}ms)`) : ""}`));
|
|
21
|
+
if (health.db != null) lines.push((isOk(health.db) ? ok : bad)(`DB ${isOk(health.db) ? "정상" : health.db}`));
|
|
22
|
+
if (health.redis != null) lines.push((isOk(health.redis) ? ok : bad)(`Redis ${isOk(health.redis) ? "정상" : health.redis}`));
|
|
23
|
+
if (health.providers_configured != null) lines.push(ok(`providers ${health.providers_configured}개 구성됨`));
|
|
24
|
+
const fx = health.fx_rate;
|
|
25
|
+
if (fx != null) {
|
|
26
|
+
const rate = typeof fx === "object" ? fx.rate : fx; // {rate, applied_rate, source}
|
|
27
|
+
const src = typeof fx === "object" && fx.source ? styler.dim(` (${fx.source})`) : "";
|
|
28
|
+
if (rate != null) lines.push(ok(`환율 USD/KRW ${rate}${src}`));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
lines.push(
|
|
32
|
+
keyPrefix
|
|
33
|
+
? ok(`로그인됨 ${styler.dim(`(키 ${keyPrefix}…)`)}`)
|
|
34
|
+
: warn(`로그인 안 됨 ${styler.dim("— pleum login")}`),
|
|
35
|
+
);
|
|
36
|
+
return lines.join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function health() {
|
|
40
|
+
const cfg = readConfig();
|
|
41
|
+
const keyPrefix = cfg?.key ? cfg.key.slice(0, 8) : null;
|
|
42
|
+
const t0 = Date.now();
|
|
43
|
+
let data = null;
|
|
44
|
+
let error = null;
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetch(`${V1}/health`);
|
|
47
|
+
if (!res.ok) error = `HTTP ${res.status}`;
|
|
48
|
+
else data = await res.json();
|
|
49
|
+
} catch (e) {
|
|
50
|
+
error = e.message;
|
|
51
|
+
}
|
|
52
|
+
const ms = Date.now() - t0;
|
|
53
|
+
console.log(renderHealth({ root: ROOT, health: data, error, ms, keyPrefix }));
|
|
54
|
+
if (error) process.exit(1);
|
|
55
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// 스피너 + raw-mode 화살표 셀렉터 — zero-dep 라인 플로우 인터랙션.
|
|
2
|
+
|
|
3
|
+
import { style, displayWidth, terminalWidth } from "./ansi.mjs";
|
|
4
|
+
|
|
5
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
6
|
+
|
|
7
|
+
// 브레일 스피너. 기본 stderr — stdout 파이프를 더럽히지 않는다. 비TTY면 no-op.
|
|
8
|
+
export function spinner(text, { stream = process.stderr, intervalMs = 80 } = {}) {
|
|
9
|
+
if (!stream.isTTY) return { update() {}, stop() {} };
|
|
10
|
+
let i = 0;
|
|
11
|
+
let msg = text;
|
|
12
|
+
let stopped = false;
|
|
13
|
+
stream.write("\x1b[?25l"); // 커서 숨김
|
|
14
|
+
const draw = () => stream.write(`\r\x1b[2K${style.plum(FRAMES[i++ % FRAMES.length])} ${style.dim(msg)}`);
|
|
15
|
+
draw();
|
|
16
|
+
const t = setInterval(draw, intervalMs);
|
|
17
|
+
return {
|
|
18
|
+
update(m) {
|
|
19
|
+
msg = m;
|
|
20
|
+
},
|
|
21
|
+
stop() {
|
|
22
|
+
if (stopped) return; // 멱등 — 중복 stop이 이후 출력 줄을 지우지 않게
|
|
23
|
+
stopped = true;
|
|
24
|
+
clearInterval(t);
|
|
25
|
+
stream.write("\r\x1b[2K\x1b[?25h");
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// raw-mode 입력 청크 → 키 이벤트 배열(순수 함수 — 테스트 대상).
|
|
31
|
+
// 화살표(\x1b[A/B)와 일반 문자가 한 청크에 섞여 와도 순서대로 분해한다.
|
|
32
|
+
// 청크 끝의 외톨이 ESC는 escape(취소)로 처리 — 실터미널은 화살표 3바이트를
|
|
33
|
+
// 한 이벤트로 보내므로 실사용에서 오인될 일은 거의 없다.
|
|
34
|
+
export function parseKeys(chunk) {
|
|
35
|
+
const out = [];
|
|
36
|
+
for (let i = 0; i < chunk.length; ) {
|
|
37
|
+
const ch = chunk[i];
|
|
38
|
+
if (ch === "\x1b") {
|
|
39
|
+
if (chunk[i + 1] === "[") {
|
|
40
|
+
const rest = chunk.slice(i + 2);
|
|
41
|
+
const m = /^([0-9;]*)([A-Za-z~])/.exec(rest);
|
|
42
|
+
if (m) {
|
|
43
|
+
const code = m[2];
|
|
44
|
+
if (code === "A") out.push({ type: "up" });
|
|
45
|
+
else if (code === "B") out.push({ type: "down" });
|
|
46
|
+
else out.push({ type: "unknown" }); // 좌우 화살표 등은 무시
|
|
47
|
+
i += 2 + m[1].length + 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
out.push({ type: "escape" });
|
|
52
|
+
i += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (ch === "\r" || ch === "\n") out.push({ type: "enter" });
|
|
56
|
+
else if (ch === "\x03") out.push({ type: "ctrlc" });
|
|
57
|
+
else if (ch === "\x7f" || ch === "\b") out.push({ type: "backspace" });
|
|
58
|
+
else if (ch >= " ") out.push({ type: "char", value: ch });
|
|
59
|
+
else out.push({ type: "unknown" });
|
|
60
|
+
i += 1;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 화살표 이동 · 타이핑 필터 · Enter 선택 · Esc/Ctrl+C 취소(null).
|
|
66
|
+
// 비TTY면 즉시 null — 호출자가 기존 번호 피커 등으로 폴백한다.
|
|
67
|
+
// process.exit는 절대 호출하지 않는다(취소 처리는 호출자 몫).
|
|
68
|
+
export async function select(items, {
|
|
69
|
+
message = "선택",
|
|
70
|
+
formatItem = String,
|
|
71
|
+
matches = (all, q) => (q ? all.filter((it) => String(formatItem(it)).toLowerCase().includes(q.toLowerCase())) : all),
|
|
72
|
+
limit = 10,
|
|
73
|
+
input = process.stdin,
|
|
74
|
+
output = process.stdout,
|
|
75
|
+
} = {}) {
|
|
76
|
+
if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") return null;
|
|
77
|
+
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
let query = "";
|
|
80
|
+
let cursor = 0;
|
|
81
|
+
let lastRows = 0;
|
|
82
|
+
|
|
83
|
+
const rowsFor = (s) => Math.max(1, Math.ceil(displayWidth(s) / Math.max(terminalWidth(), 1)));
|
|
84
|
+
const erase = () => {
|
|
85
|
+
if (!lastRows) return;
|
|
86
|
+
output.write("\r" + (lastRows > 1 ? `\x1b[${lastRows - 1}A` : "") + "\x1b[0J");
|
|
87
|
+
lastRows = 0;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const render = () => {
|
|
91
|
+
const pool = matches(items, query);
|
|
92
|
+
if (cursor >= pool.length) cursor = Math.max(0, pool.length - 1);
|
|
93
|
+
const shown = pool.slice(0, limit);
|
|
94
|
+
const lines = [
|
|
95
|
+
`${style.plum("?")} ${style.bold(message)} ${style.dim("타이핑=필터 · ↑↓=이동 · Enter=선택 · Esc=취소")}`,
|
|
96
|
+
`${style.gray("›")} ${query}`,
|
|
97
|
+
...shown.map((it, i) =>
|
|
98
|
+
i === cursor ? `${style.plum("❯")} ${style.bold(formatItem(it))}` : ` ${style.dim(formatItem(it))}`,
|
|
99
|
+
),
|
|
100
|
+
];
|
|
101
|
+
if (pool.length > shown.length) lines.push(style.dim(` … ${pool.length - shown.length}개 더 — 타이핑으로 좁히기`));
|
|
102
|
+
if (!pool.length) lines.push(style.dim(" 일치 없음"));
|
|
103
|
+
erase();
|
|
104
|
+
const frame = lines.join("\n");
|
|
105
|
+
output.write(frame);
|
|
106
|
+
lastRows = lines.reduce((n, l) => n + rowsFor(l), 0);
|
|
107
|
+
return pool;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const finish = (value) => {
|
|
111
|
+
input.off("data", onData);
|
|
112
|
+
input.setRawMode(false);
|
|
113
|
+
input.pause();
|
|
114
|
+
output.write("\x1b[?25h");
|
|
115
|
+
erase();
|
|
116
|
+
if (value != null) output.write(`${style.green("✓")} ${formatItem(value)}\n`);
|
|
117
|
+
resolve(value);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const onData = (chunk) => {
|
|
121
|
+
for (const key of parseKeys(chunk)) {
|
|
122
|
+
if (key.type === "ctrlc" || key.type === "escape") return finish(null);
|
|
123
|
+
if (key.type === "enter") {
|
|
124
|
+
const pool = matches(items, query);
|
|
125
|
+
return finish(pool[cursor] ?? null);
|
|
126
|
+
}
|
|
127
|
+
if (key.type === "up") cursor = Math.max(0, cursor - 1);
|
|
128
|
+
else if (key.type === "down") cursor = Math.min(limit - 1, cursor + 1);
|
|
129
|
+
else if (key.type === "backspace") query = query.slice(0, -1);
|
|
130
|
+
else if (key.type === "char") {
|
|
131
|
+
query += key.value;
|
|
132
|
+
cursor = 0;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
render();
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
input.setEncoding("utf8"); // 멀티바이트(한글) 조각 자동 재조립
|
|
139
|
+
input.setRawMode(true);
|
|
140
|
+
input.resume();
|
|
141
|
+
output.write("\x1b[?25l");
|
|
142
|
+
input.on("data", onData);
|
|
143
|
+
render();
|
|
144
|
+
});
|
|
145
|
+
}
|
package/src/markdown.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// 스트리밍 마크다운 렌더러 — 라인 플로우(풀스크린 아님).
|
|
2
|
+
// 전략: \n이 도착해 완결된 줄은 스타일 입혀 확정 출력(재렌더 없음), 아직 안 끝난
|
|
3
|
+
// 마지막 줄만 매 push마다 지우고 다시 그린다. 비TTY(파이프)는 완전 패스스루.
|
|
4
|
+
|
|
5
|
+
import { style, displayWidth, terminalWidth } from "./ansi.mjs";
|
|
6
|
+
|
|
7
|
+
// ```lang 펜스 감지(순수 함수). 열기/닫기 구분은 호출자의 inFence 상태가 한다.
|
|
8
|
+
export function detectFence(line) {
|
|
9
|
+
const m = /^\s*```(\S*)\s*$/.exec(line);
|
|
10
|
+
return m ? { lang: m[1] } : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// 인라인 마크다운: `code` → cyan, **bold**, *italic*. 코드 조각 먼저 치환해
|
|
14
|
+
// 코드 안의 별표가 bold로 오인되는 흔한 케이스를 줄인다.
|
|
15
|
+
export function renderInline(text, styler = style) {
|
|
16
|
+
return text
|
|
17
|
+
.replace(/`([^`]+)`/g, (_, c) => styler.cyan(c))
|
|
18
|
+
.replace(/\*\*([^*]+)\*\*/g, (_, c) => styler.bold(c))
|
|
19
|
+
.replace(/(^|[^*])\*([^*\s][^*]*)\*(?!\*)/g, (_, pre, c) => pre + styler.italic(c));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 완결된 한 줄 렌더(순수 함수). state.inFence는 이 줄 "이전"의 펜스 상태.
|
|
23
|
+
export function renderLine(line, state = { inFence: false }, styler = style) {
|
|
24
|
+
const fence = detectFence(line);
|
|
25
|
+
if (fence) {
|
|
26
|
+
return state.inFence ? styler.gray("└─") : styler.gray("┌─ ") + styler.dim(fence.lang || "code");
|
|
27
|
+
}
|
|
28
|
+
if (state.inFence) return styler.gray("│ ") + line; // 펜스 안은 마크다운 해석 안 함
|
|
29
|
+
const h = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
30
|
+
if (h) return styler.bold(styler.plum(h[2]));
|
|
31
|
+
const q = /^\s*>\s?(.*)$/.exec(line);
|
|
32
|
+
if (q) return styler.gray("┃ ") + styler.dim(renderInline(q[1], styler));
|
|
33
|
+
const li = /^(\s*)([-*+]|\d+\.)\s+(.*)$/.exec(line);
|
|
34
|
+
if (li) {
|
|
35
|
+
const marker = /^[-*+]$/.test(li[2]) ? "•" : li[2];
|
|
36
|
+
return `${li[1]}${styler.plum(marker)} ${renderInline(li[3], styler)}`;
|
|
37
|
+
}
|
|
38
|
+
return renderInline(line, styler);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 스트리밍 렌더러 팩토리. write/styler/interactive 주입 가능(테스트용).
|
|
42
|
+
export function createMarkdownStream({
|
|
43
|
+
write = (s) => process.stdout.write(s),
|
|
44
|
+
styler = style,
|
|
45
|
+
interactive = process.stdout.isTTY,
|
|
46
|
+
prefix = "",
|
|
47
|
+
} = {}) {
|
|
48
|
+
let buf = "";
|
|
49
|
+
let inFence = false;
|
|
50
|
+
let firstLine = true; // prefix는 첫 줄에만
|
|
51
|
+
let previewRows = 0; // 직전 미완성 줄 렌더가 점유한 시각적 행 수(soft-wrap 포함)
|
|
52
|
+
|
|
53
|
+
const rowsFor = (s) => Math.max(1, Math.ceil(displayWidth(s) / Math.max(terminalWidth(), 1)));
|
|
54
|
+
|
|
55
|
+
const erasePreview = () => {
|
|
56
|
+
if (!previewRows) return;
|
|
57
|
+
write("\r" + (previewRows > 1 ? `\x1b[${previewRows - 1}A` : "") + "\x1b[0J");
|
|
58
|
+
previewRows = 0;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const commit = (line) => {
|
|
62
|
+
const rendered = (firstLine ? prefix : "") + renderLine(line, { inFence }, styler);
|
|
63
|
+
if (detectFence(line)) inFence = !inFence;
|
|
64
|
+
write(rendered + "\n");
|
|
65
|
+
firstLine = false;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
push(delta) {
|
|
70
|
+
if (!interactive) return void write(delta);
|
|
71
|
+
buf += delta;
|
|
72
|
+
const lines = buf.split("\n");
|
|
73
|
+
buf = lines.pop() ?? "";
|
|
74
|
+
if (lines.length || buf) erasePreview();
|
|
75
|
+
for (const line of lines) commit(line);
|
|
76
|
+
if (buf) {
|
|
77
|
+
const rendered = (firstLine ? prefix : "") + renderLine(buf, { inFence }, styler);
|
|
78
|
+
write(rendered);
|
|
79
|
+
previewRows = rowsFor(rendered);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
finish() {
|
|
83
|
+
if (!interactive) return void write("\n"); // 기존 파이프 동작 유지(항상 개행 종결)
|
|
84
|
+
erasePreview();
|
|
85
|
+
if (buf) {
|
|
86
|
+
commit(buf);
|
|
87
|
+
buf = "";
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
package/src/models.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { V1 } from "./config.mjs";
|
|
2
|
+
import { style, padDisplay, divider } from "./ansi.mjs";
|
|
2
3
|
|
|
3
4
|
// GET /v1/models 는 공개(인증 불필요). 슬러그는 provider/model 형식.
|
|
4
5
|
export async function fetchModels() {
|
|
@@ -8,6 +9,15 @@ export async function fetchModels() {
|
|
|
8
9
|
return data.data ?? [];
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
// id/표시명 부분일치 필터 — run 피커·REPL /models·목록이 공유하는 단일 구현.
|
|
13
|
+
export function matchModels(models, query) {
|
|
14
|
+
const q = (query || "").toLowerCase();
|
|
15
|
+
if (!q) return models;
|
|
16
|
+
return models.filter(
|
|
17
|
+
(m) => m.id.toLowerCase().includes(q) || (m.display_name || "").toLowerCase().includes(q),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
11
21
|
// 컨텍스트 길이를 사람이 읽기 좋게: 128000→128K, 1000000→1M.
|
|
12
22
|
export function fmtCtx(n) {
|
|
13
23
|
if (!n) return "—";
|
|
@@ -32,7 +42,8 @@ export function fmtPrice(model) {
|
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
// 정렬된 컬럼 테이블 — 라우터의 강점(수백 모델·가격·컨텍스트)을 한눈에.
|
|
35
|
-
|
|
45
|
+
// 패딩은 표시 폭 기준(padDisplay) — 색·전각 문자가 섞여도 컬럼이 맞는다.
|
|
46
|
+
export function formatTable(models, styler = style) {
|
|
36
47
|
const headers = ["MODEL", "PROVIDER", "MODAL", "CTX", "IN/1M", "OUT/1M"];
|
|
37
48
|
const rows = models.map((m) => {
|
|
38
49
|
const [pin, pout] = fmtPrice(m);
|
|
@@ -46,29 +57,40 @@ export function formatTable(models) {
|
|
|
46
57
|
];
|
|
47
58
|
});
|
|
48
59
|
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
49
|
-
const
|
|
50
|
-
|
|
60
|
+
const fmtRow = (r, paint) =>
|
|
61
|
+
r.map((c, i) => paint(padDisplay(c, widths[i]), i)).join(" ").trimEnd();
|
|
62
|
+
const head = fmtRow(headers, (c) => styler.dim(c));
|
|
63
|
+
const body = rows.map((r) => fmtRow(r, (c, i) => (i === 0 ? styler.plum(c) : c)));
|
|
64
|
+
return [head, ...body].join("\n");
|
|
51
65
|
}
|
|
52
66
|
|
|
53
|
-
// `pleum models [query] [--json]` 인자 파싱.
|
|
67
|
+
// `pleum models [query] [--json] [--info]` 인자 파싱.
|
|
54
68
|
export function parseModelsArgs(rest = []) {
|
|
55
69
|
let json = false;
|
|
70
|
+
let info = false;
|
|
56
71
|
const pos = [];
|
|
57
72
|
for (const a of rest) {
|
|
58
73
|
if (a === "--json") json = true;
|
|
74
|
+
else if (a === "--info" || a === "-i") info = true;
|
|
59
75
|
else pos.push(a);
|
|
60
76
|
}
|
|
61
|
-
return { json, query: pos[0] };
|
|
77
|
+
return { json, info, query: pos[0] };
|
|
62
78
|
}
|
|
63
79
|
|
|
64
80
|
export async function listModels(rest) {
|
|
65
81
|
// 하위호환: 문자열 하나가 와도 query로 처리.
|
|
66
|
-
const { json, query } = Array.isArray(rest)
|
|
82
|
+
const { json, info, query } = Array.isArray(rest)
|
|
83
|
+
? parseModelsArgs(rest)
|
|
84
|
+
: { json: false, info: false, query: rest };
|
|
85
|
+
if (info) {
|
|
86
|
+
if (!query) {
|
|
87
|
+
console.error("사용법: pleum models <model-id> --info");
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
return showModelInfo(query, { json });
|
|
91
|
+
}
|
|
67
92
|
const models = await fetchModels();
|
|
68
|
-
const
|
|
69
|
-
const rows = models.filter(
|
|
70
|
-
(m) => !q || m.id.toLowerCase().includes(q) || (m.display_name || "").toLowerCase().includes(q),
|
|
71
|
-
);
|
|
93
|
+
const rows = matchModels(models, query);
|
|
72
94
|
if (json) {
|
|
73
95
|
console.log(JSON.stringify(rows, null, 2));
|
|
74
96
|
return;
|
|
@@ -78,5 +100,74 @@ export async function listModels(rest) {
|
|
|
78
100
|
return;
|
|
79
101
|
}
|
|
80
102
|
console.log(formatTable(rows));
|
|
81
|
-
console.log(`\n${rows.length} models · 가격=토큰 1M당(표시환율 기준, 실청구는 장중 환율로 소폭 상이)`);
|
|
103
|
+
console.log(style.dim(`\n${rows.length} models · 가격=토큰 1M당(표시환율 기준, 실청구는 장중 환율로 소폭 상이)`));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── 모델 상세(ollama show 대응) — GET /v1/models/{id} ────────────────────
|
|
107
|
+
// id는 provider/model 형식이라 슬래시를 그대로 둔다(백엔드 :path 컨버터).
|
|
108
|
+
export async function fetchModelDetail(id) {
|
|
109
|
+
const res = await fetch(`${V1}/models/${id}`);
|
|
110
|
+
if (res.status === 404) throw new Error(`모델을 찾을 수 없음: ${id} (목록: pleum models)`);
|
|
111
|
+
if (!res.ok) throw new Error(`모델 상세 조회 실패 (${res.status})`);
|
|
112
|
+
return res.json();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const fmtNum = (n) => (n == null ? "—" : Number(n).toLocaleString("en-US"));
|
|
116
|
+
|
|
117
|
+
// 상세 응답 → 표시 문자열(순수 함수 — fixture 테스트 대상).
|
|
118
|
+
export function renderModelInfo(m, styler = style) {
|
|
119
|
+
const [pin, pout] = fmtPrice(m);
|
|
120
|
+
const lines = [];
|
|
121
|
+
lines.push(`${styler.bold(styler.plum(m.id))}${m.display_name ? ` ${styler.dim(m.display_name)}` : ""}`);
|
|
122
|
+
lines.push(divider(undefined, styler));
|
|
123
|
+
const facts = [
|
|
124
|
+
["maker", m.maker || "—"],
|
|
125
|
+
["modality", m.modality || "text"],
|
|
126
|
+
["context", fmtCtx(m.context_length || m.context_window)],
|
|
127
|
+
["max output", fmtCtx(m.max_output_tokens || m.max_output)],
|
|
128
|
+
["가격/1M", `입력 ${pin} · 출력 ${pout} ${styler.dim(`(마크업 ${m.effective_markup_percent ?? 0}% 포함)`)}`],
|
|
129
|
+
];
|
|
130
|
+
if (m.supports_thinking) facts.push(["thinking", m.thinking_forced ? "강제(끌 수 없음)" : "지원"]);
|
|
131
|
+
for (const [k, v] of facts) lines.push(`${styler.dim(padDisplay(k, 10))} ${v}`);
|
|
132
|
+
|
|
133
|
+
const u = m.usage;
|
|
134
|
+
if (u) {
|
|
135
|
+
lines.push(
|
|
136
|
+
`${styler.dim(padDisplay("사용량", 10))} 주간 ${fmtNum(u.weekly_calls)} calls · ${fmtNum(u.weekly_tokens)} tok` +
|
|
137
|
+
styler.dim(` (누적 ${fmtNum(u.total_calls)} calls)`),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const provs = m.providers || [];
|
|
142
|
+
if (provs.length) {
|
|
143
|
+
lines.push("");
|
|
144
|
+
lines.push(styler.dim("PROVIDER IN/1M OUT/1M CTX LATENCY TPS UPTIME"));
|
|
145
|
+
for (const p of provs) {
|
|
146
|
+
const [i1m, o1m] = fmtPrice(p);
|
|
147
|
+
lines.push(
|
|
148
|
+
[
|
|
149
|
+
styler.plum(padDisplay(p.provider, 12)),
|
|
150
|
+
padDisplay(i1m, 10),
|
|
151
|
+
padDisplay(o1m, 10),
|
|
152
|
+
padDisplay(fmtCtx(p.context_window), 5),
|
|
153
|
+
padDisplay(p.latency_ms != null ? `${Math.round(p.latency_ms)}ms` : "—", 8),
|
|
154
|
+
padDisplay(p.throughput_tps != null ? `${Math.round(p.throughput_tps)}/s` : "—", 6),
|
|
155
|
+
p.uptime_percent != null ? `${p.uptime_percent}%` : "—",
|
|
156
|
+
].join(" "),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const sibs = (m.siblings || []).map((s) => s.id);
|
|
162
|
+
if (sibs.length) {
|
|
163
|
+
lines.push("");
|
|
164
|
+
lines.push(`${styler.dim("같은 제공자:")} ${sibs.join(styler.dim(" · "))}`);
|
|
165
|
+
}
|
|
166
|
+
return lines.join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function showModelInfo(id, { json = false } = {}) {
|
|
170
|
+
const detail = await fetchModelDetail(id);
|
|
171
|
+
if (json) return void console.log(JSON.stringify(detail, null, 2));
|
|
172
|
+
console.log(renderModelInfo(detail));
|
|
82
173
|
}
|
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
|
+
}
|