pleumrouter 0.2.1 → 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 +24 -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/config.mjs +3 -1
- package/src/health.mjs +55 -0
- package/src/interactive.mjs +145 -0
- package/src/launch.mjs +1 -1
- package/src/markdown.mjs +91 -0
- package/src/models.mjs +102 -11
- package/src/run.mjs +196 -76
- package/src/top.mjs +72 -0
- package/src/usage.mjs +69 -0
package/README.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
PleumRouter 공식 터미널 CLI. 쓰던 코딩 에이전트를 한 줄로 PleumRouter에 붙여 실행한다.
|
|
4
4
|
|
|
5
|
+
## 설치
|
|
6
|
+
|
|
7
|
+
Node.js 18 이상 필요(순수 Node CLI라 별도 바이너리 없음).
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
curl -fsSL https://router.pleum.ai/install | sh # 원라이너(npm 전역 설치를 감쌈)
|
|
11
|
+
npm install -g pleumrouter # npm
|
|
12
|
+
bun install -g pleumrouter # bun
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
설치 없이 `npx pleumrouter <명령>` 으로 바로 실행할 수도 있다.
|
|
16
|
+
|
|
17
|
+
## 빠른 시작
|
|
18
|
+
|
|
5
19
|
```bash
|
|
6
20
|
npx pleumrouter login # 브라우저 로그인 + 키 자동 저장
|
|
7
21
|
npx pleumrouter launch claude --model claude-sonnet-4
|
|
@@ -18,9 +32,13 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
|
|
|
18
32
|
| `pleum login [--device]` | 브라우저로 로그인 → CLI가 API 키를 자동 발급·저장(`~/.pleum/config.json`). `--device`는 SSH·CI |
|
|
19
33
|
| `pleum logout` | 저장된 키 삭제 |
|
|
20
34
|
| `pleum launch [tool] [--model id] [-- args]` | 에이전트 실행. `--` 뒤 인자는 도구로 그대로 전달. 도구 생략 시 지원 목록·설치 여부 표시 |
|
|
21
|
-
| `pleum run [model] ["프롬프트"]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시
|
|
35
|
+
| `pleum run [model] ["프롬프트"]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시 화살표로 선택 |
|
|
22
36
|
| `pleum models [query] [--json]` | 모델 목록(공개) — provider·컨텍스트·가격(₩/1M) 컬럼, `--json`은 스크립트용 |
|
|
37
|
+
| `pleum models <id> --info` | 모델 상세 — 가격·컨텍스트·provider별 지연/처리량/업타임·주간 사용량·형제 모델 |
|
|
38
|
+
| `pleum top [--period daily\|weekly\|monthly]` | 라우터 전체 모델 리더보드 + 제공자 점유율(공개 통계) |
|
|
23
39
|
| `pleum balance` | 크레딧 잔액 조회 |
|
|
40
|
+
| `pleum usage [--by model\|provider\|api_key\|day] [--days N]` | 내 지출 리포트(막대 차트) — 최신 로그인 키 필요(`usage:read`) |
|
|
41
|
+
| `pleum health` | 게이트웨이 연결·DB·Redis·환율·로그인 상태 점검(셀프호스트 진단용) |
|
|
24
42
|
| `pleum completion <zsh\|bash>` | 셸 자동완성 스크립트 출력 |
|
|
25
43
|
| `pleum upgrade` | 최신 버전으로 재설치 |
|
|
26
44
|
| `pleum version` | 버전 출력 |
|
|
@@ -32,7 +50,7 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
|
|
|
32
50
|
| 명령 | 설명 |
|
|
33
51
|
|------|------|
|
|
34
52
|
| `/help`, `/?` | 명령 도움말 |
|
|
35
|
-
| `/model [id]` | **세션 유지한 채 모델 전환**(
|
|
53
|
+
| `/model [id]` | **세션 유지한 채 모델 전환**(인자 없으면 화살표 셀렉터) |
|
|
36
54
|
| `/system [텍스트]` | 시스템 프롬프트 설정/확인 |
|
|
37
55
|
| `/clear` | 대화 컨텍스트 초기화 |
|
|
38
56
|
| `/models [검색어]` | 모델 목록 보기 |
|
|
@@ -40,9 +58,13 @@ cat error.log | npx pleumrouter run gpt-4.1 "원인은?" # 파이프
|
|
|
40
58
|
| `/verbose` | 토큰·속도 통계 토글 |
|
|
41
59
|
| `/exit`, `/quit` | 종료 |
|
|
42
60
|
|
|
61
|
+
- 응답은 **마크다운 스트리밍 렌더**(굵게·코드블록 하이라이트·리스트) — 파이프로 빼면 자동으로 원문 그대로
|
|
62
|
+
- 응답 후 비용이 PLM으로 표시(스트리밍 마지막 청크 기준)
|
|
63
|
+
- **Ctrl+C**: 생성 중엔 1회로 중단(대화 유지), 대기 중엔 2초 내 2회로 종료
|
|
43
64
|
- 여러 줄 입력: `"""` 한 줄 → 본문 → `"""` 한 줄
|
|
44
65
|
- 이미지: 메시지에 이미지 파일 경로를 그대로 포함 (예: `이 그림 설명해줘 ./shot.png`)
|
|
45
66
|
- `↑` 로 지난 입력 재호출(히스토리는 `~/.pleum/history`에 영속)
|
|
67
|
+
- 색상 끄기: `NO_COLOR=1` (비TTY·파이프에선 자동 무색)
|
|
46
68
|
|
|
47
69
|
## 자동 실행 지원 도구
|
|
48
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/config.mjs
CHANGED
|
@@ -3,7 +3,9 @@ import { join } from "node:path";
|
|
|
3
3
|
import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
4
4
|
|
|
5
5
|
// 공개 게이트웨이 루트. 셀프호스트/스테이징은 PLEUM_BASE_URL로 덮어쓴다.
|
|
6
|
-
export const ROOT = (process.env.PLEUM_BASE_URL ?? "https://router.pleum.ai")
|
|
6
|
+
export const ROOT = (process.env.PLEUM_BASE_URL ?? "https://router.pleum.ai")
|
|
7
|
+
.replace(/\/+$/, "")
|
|
8
|
+
.replace(/\/v1$/, ""); // 사용자가 실수로 /v1까지 붙여도 V1이 /v1/v1 이중 경로가 되지 않게 정리(L12)
|
|
7
9
|
// OpenAI 호환 엔드포인트는 /v1. (Claude Code만 루트를 쓴다 — recipes 참고)
|
|
8
10
|
export const V1 = `${ROOT}/v1`;
|
|
9
11
|
|
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/launch.mjs
CHANGED
|
@@ -50,7 +50,7 @@ export async function launch(tool, model, passthrough) {
|
|
|
50
50
|
// Codex는 격리 CODEX_HOME에 config.toml을 써서 사용자 ~/.codex를 보존한다.
|
|
51
51
|
if (r.type === "codex-home") {
|
|
52
52
|
const home = join(homedir(), ".pleum", "codex-home");
|
|
53
|
-
mkdirSync(home, { recursive: true });
|
|
53
|
+
mkdirSync(home, { recursive: true, mode: 0o700 }); // 사용자 데이터 디렉토리 권한 제한(L11)
|
|
54
54
|
writeFileSync(join(home, "config.toml"), codexToml(model), { mode: 0o600 });
|
|
55
55
|
env.CODEX_HOME = home;
|
|
56
56
|
}
|
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);
|
|
@@ -52,6 +65,11 @@ export async function run(rest) {
|
|
|
52
65
|
const key = requireKey();
|
|
53
66
|
|
|
54
67
|
if (!model && !oneShot) {
|
|
68
|
+
if (!process.stdin.isTTY) {
|
|
69
|
+
// 비대화형(non-TTY) stdin이면 인터랙티브 모델 선택이 멈춰버리므로 명확히 안내하고 종료(L10).
|
|
70
|
+
console.error('모델을 지정하세요: pleum run <model> ["프롬프트"] (비대화형 stdin에선 모델 선택 불가)');
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
55
73
|
model = await pickModel(); // 인터랙티브 모델 선택
|
|
56
74
|
if (!model) process.exit(1);
|
|
57
75
|
}
|
|
@@ -62,11 +80,17 @@ export async function run(rest) {
|
|
|
62
80
|
|
|
63
81
|
if (oneShot) {
|
|
64
82
|
const prompt = [inlinePrompt, piped].filter(Boolean).join("\n\n");
|
|
65
|
-
const
|
|
83
|
+
const content = buildContent(prompt);
|
|
84
|
+
if (Array.isArray(content)) {
|
|
85
|
+
const n = content.filter((p) => p.type === "image_url").length;
|
|
86
|
+
// 프롬프트 내 로컬 이미지 경로를 자동 첨부·업로드하므로 조용히 올리지 않고 명시 고지한다(M8).
|
|
87
|
+
console.error(`ℹ 프롬프트의 로컬 이미지 ${n}개를 첨부해 업로드합니다.`);
|
|
88
|
+
}
|
|
89
|
+
const messages = [{ role: "user", content }];
|
|
66
90
|
try {
|
|
67
91
|
await streamChat(key, model, messages, { verbose, quiet: true });
|
|
68
92
|
} catch (e) {
|
|
69
|
-
console.error(
|
|
93
|
+
console.error(`${style.red("✗")} ${e.message}`);
|
|
70
94
|
process.exit(1);
|
|
71
95
|
}
|
|
72
96
|
process.exit(0);
|
|
@@ -75,34 +99,35 @@ export async function run(rest) {
|
|
|
75
99
|
await repl(key, model, verbose);
|
|
76
100
|
}
|
|
77
101
|
|
|
78
|
-
async function repl(key,
|
|
102
|
+
async function repl(key, initialModel, initialVerbose) {
|
|
103
|
+
let model = initialModel;
|
|
79
104
|
let system = "";
|
|
105
|
+
let verbose = initialVerbose;
|
|
80
106
|
let messages = [];
|
|
81
|
-
const rl = createInterface({
|
|
82
|
-
input: process.stdin,
|
|
83
|
-
output: process.stdout,
|
|
84
|
-
prompt: "you> ",
|
|
85
|
-
history: loadHistory(),
|
|
86
|
-
historySize: 1000,
|
|
87
|
-
});
|
|
88
107
|
let multiline = null; // """ 펜스 진행 중이면 누적 배열
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
rl
|
|
108
|
+
let currentAbort = null; // 생성 중이면 AbortController
|
|
109
|
+
let lastSigint = 0;
|
|
110
|
+
let pickerActive = false; // /model 셀렉터가 rl을 잠시 닫는 동안 close 핸들러 무시
|
|
111
|
+
let rl;
|
|
92
112
|
|
|
93
113
|
const send = async (text) => {
|
|
94
114
|
messages.push({ role: "user", content: buildContent(text) });
|
|
95
115
|
const req = system ? [{ role: "system", content: system }, ...messages] : messages;
|
|
116
|
+
const ac = new AbortController();
|
|
117
|
+
currentAbort = ac;
|
|
96
118
|
try {
|
|
97
|
-
const reply = await streamChat(key, model, req, { verbose });
|
|
98
|
-
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 턴 되돌림
|
|
99
122
|
} catch (e) {
|
|
100
|
-
console.error(`\n✗ ${e.message}`);
|
|
123
|
+
console.error(`\n${style.red("✗")} ${e.message}`);
|
|
101
124
|
messages.pop(); // 실패한 user 턴 제거
|
|
125
|
+
} finally {
|
|
126
|
+
currentAbort = null;
|
|
102
127
|
}
|
|
103
128
|
};
|
|
104
129
|
|
|
105
|
-
|
|
130
|
+
const onLine = async (line) => {
|
|
106
131
|
if (multiline !== null) {
|
|
107
132
|
if (line.trim() === '"""') {
|
|
108
133
|
const text = multiline.join("\n");
|
|
@@ -153,6 +178,17 @@ async function repl(key, model, verbose) {
|
|
|
153
178
|
system = s.system ?? "";
|
|
154
179
|
messages = s.messages ?? [];
|
|
155
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
|
+
},
|
|
156
192
|
});
|
|
157
193
|
rl.prompt();
|
|
158
194
|
return;
|
|
@@ -163,20 +199,57 @@ async function repl(key, model, verbose) {
|
|
|
163
199
|
}
|
|
164
200
|
await send(text);
|
|
165
201
|
rl.prompt();
|
|
166
|
-
}
|
|
202
|
+
};
|
|
167
203
|
|
|
168
|
-
|
|
204
|
+
const onClose = () => {
|
|
205
|
+
if (pickerActive) return;
|
|
169
206
|
saveHistory(rl.history);
|
|
170
|
-
console.log("
|
|
207
|
+
console.log(`\n${style.dim("안녕히 가세요.")}`);
|
|
171
208
|
process.exit(0);
|
|
172
|
-
}
|
|
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();
|
|
173
246
|
}
|
|
174
247
|
|
|
175
248
|
const SLASH_HELP = `명령:
|
|
176
249
|
/help, /? 이 도움말
|
|
177
250
|
/clear 대화 컨텍스트 초기화(시스템 프롬프트는 유지)
|
|
178
251
|
/system [텍스트] 시스템 프롬프트 설정(인자 없으면 현재값 표시)
|
|
179
|
-
/model [id] 세션 유지한 채 모델 전환(인자 없으면
|
|
252
|
+
/model [id] 세션 유지한 채 모델 전환(인자 없으면 화살표 선택)
|
|
180
253
|
/models [검색어] 모델 목록 보기
|
|
181
254
|
/save [이름] 현재 세션 저장(기본 last)
|
|
182
255
|
/load [이름] 세션 불러오기
|
|
@@ -186,7 +259,7 @@ const SLASH_HELP = `명령:
|
|
|
186
259
|
이미지: 메시지에 이미지 파일 경로를 그대로 포함(예: 이 그림 설명해줘 ./shot.png)`;
|
|
187
260
|
|
|
188
261
|
async function handleSlash(text, ctx) {
|
|
189
|
-
const [cmd
|
|
262
|
+
const [cmd] = text.slice(1).split(/\s+/);
|
|
190
263
|
const arg = text.slice(1 + cmd.length).trim();
|
|
191
264
|
switch (cmd) {
|
|
192
265
|
case "help":
|
|
@@ -195,12 +268,12 @@ async function handleSlash(text, ctx) {
|
|
|
195
268
|
break;
|
|
196
269
|
case "clear":
|
|
197
270
|
ctx.clear();
|
|
198
|
-
console.log("✓ 컨텍스트
|
|
271
|
+
console.log(`${style.green("✓")} 컨텍스트 초기화`);
|
|
199
272
|
break;
|
|
200
273
|
case "system":
|
|
201
274
|
if (arg) {
|
|
202
275
|
ctx.system = arg;
|
|
203
|
-
console.log("✓ 시스템 프롬프트
|
|
276
|
+
console.log(`${style.green("✓")} 시스템 프롬프트 설정`);
|
|
204
277
|
} else {
|
|
205
278
|
console.log(ctx.system ? `현재 시스템 프롬프트:\n${ctx.system}` : "시스템 프롬프트 없음");
|
|
206
279
|
}
|
|
@@ -208,7 +281,15 @@ async function handleSlash(text, ctx) {
|
|
|
208
281
|
case "model":
|
|
209
282
|
if (arg) {
|
|
210
283
|
ctx.model = arg;
|
|
211
|
-
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
|
+
}
|
|
212
293
|
} else {
|
|
213
294
|
console.log(`현재 모델: ${ctx.model}\n전환: /model <id> · 목록: /models`);
|
|
214
295
|
}
|
|
@@ -216,105 +297,147 @@ async function handleSlash(text, ctx) {
|
|
|
216
297
|
case "models":
|
|
217
298
|
try {
|
|
218
299
|
const models = await fetchModels();
|
|
219
|
-
const
|
|
220
|
-
const rows = models.filter(
|
|
221
|
-
(m) => !q || m.id.toLowerCase().includes(q) || (m.display_name || "").toLowerCase().includes(q),
|
|
222
|
-
);
|
|
300
|
+
const rows = matchModels(models, arg);
|
|
223
301
|
console.log(rows.length ? formatTable(rows) : "일치하는 모델 없음");
|
|
224
302
|
} catch (e) {
|
|
225
|
-
console.error(
|
|
303
|
+
console.error(`${style.red("✗")} ${e.message}`);
|
|
226
304
|
}
|
|
227
305
|
break;
|
|
228
306
|
case "save":
|
|
229
307
|
try {
|
|
230
308
|
const name = saveSession(arg || "last", ctx.snapshot());
|
|
231
|
-
console.log(
|
|
309
|
+
console.log(`${style.green("✓")} 세션 저장: ${name}`);
|
|
232
310
|
} catch (e) {
|
|
233
|
-
console.error(
|
|
311
|
+
console.error(`${style.red("✗")} 저장 실패: ${e.message}`);
|
|
234
312
|
}
|
|
235
313
|
break;
|
|
236
314
|
case "load":
|
|
237
315
|
try {
|
|
238
316
|
ctx.restore(loadSession(arg || "last"));
|
|
239
|
-
console.log(
|
|
317
|
+
console.log(`${style.green("✓")} 세션 불러옴 (모델: ${ctx.model})`);
|
|
240
318
|
} catch {
|
|
241
|
-
console.error(
|
|
319
|
+
console.error(`${style.red("✗")} 세션 없음: ${arg || "last"}`);
|
|
242
320
|
}
|
|
243
321
|
break;
|
|
244
322
|
case "verbose":
|
|
245
323
|
ctx.verbose = !ctx.verbose;
|
|
246
|
-
console.log(
|
|
324
|
+
console.log(`${style.green("✓")} 통계 ${ctx.verbose ? "켜짐" : "꺼짐"}`);
|
|
247
325
|
break;
|
|
248
326
|
default:
|
|
249
327
|
console.log(`알 수 없는 명령: /${cmd} (/help)`);
|
|
250
328
|
}
|
|
251
329
|
}
|
|
252
330
|
|
|
253
|
-
|
|
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 } = {}) {
|
|
254
334
|
const t0 = Date.now();
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
+
}
|
|
260
352
|
if (!res.ok) {
|
|
353
|
+
sp.stop();
|
|
261
354
|
const body = await res.text().catch(() => "");
|
|
262
355
|
throw new Error(`${res.status} ${body.slice(0, 300)}`);
|
|
263
356
|
}
|
|
264
357
|
|
|
265
|
-
|
|
358
|
+
const md = createMarkdownStream({ prefix: quiet ? "" : `${style.plum("bot")}${style.dim(">")} ` });
|
|
266
359
|
let full = "";
|
|
267
360
|
let buf = "";
|
|
268
361
|
let usage = null;
|
|
362
|
+
let cost = null;
|
|
269
363
|
let firstAt = 0;
|
|
364
|
+
let aborted = false;
|
|
270
365
|
const decoder = new TextDecoder();
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
+
// 부분 청크 — 무시
|
|
288
391
|
}
|
|
289
|
-
} catch {
|
|
290
|
-
// 부분 청크 — 무시
|
|
291
392
|
}
|
|
292
393
|
}
|
|
394
|
+
} catch (e) {
|
|
395
|
+
if (!isAbort(e)) {
|
|
396
|
+
sp.stop();
|
|
397
|
+
throw e;
|
|
398
|
+
}
|
|
399
|
+
aborted = true;
|
|
293
400
|
}
|
|
294
|
-
|
|
401
|
+
sp.stop();
|
|
402
|
+
md.finish();
|
|
403
|
+
if (aborted) console.error(style.dim("⏹ 생성 중단됨 — 부분 응답은 컨텍스트에 유지"));
|
|
295
404
|
|
|
296
405
|
const ms = Date.now() - t0;
|
|
297
406
|
const genSec = (Date.now() - (firstAt || t0)) / 1000;
|
|
298
407
|
const tokPerSec = usage?.completion_tokens && genSec > 0 ? usage.completion_tokens / genSec : null;
|
|
299
|
-
const
|
|
300
|
-
// 파이프
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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));
|
|
304
414
|
return full;
|
|
305
415
|
}
|
|
306
416
|
|
|
307
417
|
// ── 인터랙티브 모델 피커 ──────────────────────────────────────────────
|
|
308
|
-
//
|
|
418
|
+
// 화살표 셀렉터(raw-mode) 우선, 비TTY 등으로 불가하면 번호 선택 폴백.
|
|
309
419
|
async function pickModel() {
|
|
310
420
|
let models;
|
|
311
421
|
try {
|
|
312
422
|
models = await fetchModels();
|
|
313
423
|
} catch (e) {
|
|
314
|
-
console.error(
|
|
424
|
+
console.error(`${style.red("✗")} 모델 목록 조회 실패: ${e.message}`);
|
|
315
425
|
return null;
|
|
316
426
|
}
|
|
317
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) {
|
|
318
441
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
319
442
|
const ask = (q) => new Promise((r) => rl.question(q, r));
|
|
320
443
|
let pool = models;
|
|
@@ -329,10 +452,7 @@ async function pickModel() {
|
|
|
329
452
|
if (!ans) return null;
|
|
330
453
|
const n = Number(ans);
|
|
331
454
|
if (Number.isInteger(n) && n >= 1 && n <= shown.length) return shown[n - 1].id;
|
|
332
|
-
const
|
|
333
|
-
const next = models.filter(
|
|
334
|
-
(m) => m.id.toLowerCase().includes(q) || (m.display_name || "").toLowerCase().includes(q),
|
|
335
|
-
);
|
|
455
|
+
const next = matchModels(models, ans);
|
|
336
456
|
pool = next.length ? next : models;
|
|
337
457
|
if (!next.length) console.log("일치 없음 — 전체 목록으로.");
|
|
338
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
|
+
}
|