pleumrouter 0.5.5 → 0.5.7
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 +2 -2
- package/bin/pleum.mjs +14 -6
- package/package.json +1 -1
- package/src/ansi.mjs +12 -0
- package/src/balance.mjs +2 -6
- package/src/config.mjs +121 -7
- package/src/health.mjs +6 -2
- package/src/launch.mjs +68 -9
- package/src/locales/es.mjs +8 -2
- package/src/locales/ja.mjs +8 -2
- package/src/locales/ko.mjs +9 -3
- package/src/locales/zh.mjs +8 -2
- package/src/login.mjs +60 -19
- package/src/markdown.mjs +5 -2
- package/src/menu.mjs +4 -4
- package/src/models.mjs +27 -13
- package/src/recipes.mjs +20 -2
- package/src/run.mjs +136 -50
- package/src/settings.mjs +11 -7
- package/src/usage.mjs +3 -7
package/src/login.mjs
CHANGED
|
@@ -3,23 +3,50 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { ROOT, V1, readConfig, writeConfig, mergeLoginConfig } from "./config.mjs";
|
|
5
5
|
import { t } from "./i18n.mjs";
|
|
6
|
+
import { sanitizeTerminalText } from "./ansi.mjs";
|
|
7
|
+
|
|
8
|
+
// `start`는 Windows cmd.exe의 built-in이라 shell:true가 필요하다. rundll32는 같은
|
|
9
|
+
// 기본 브라우저 핸드오프를 제공하는 실제 실행 파일이므로 URL을 argv로 안전하게 넘긴다.
|
|
10
|
+
export function browserCommand(url, platform = process.platform) {
|
|
11
|
+
if (platform === "darwin") return { command: "open", args: [url] };
|
|
12
|
+
if (platform === "win32") {
|
|
13
|
+
return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
|
|
14
|
+
}
|
|
15
|
+
return { command: "xdg-open", args: [url] };
|
|
16
|
+
}
|
|
6
17
|
|
|
7
18
|
function openBrowser(url) {
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
spawn(cmd, [url], {
|
|
19
|
+
const { command, args } = browserCommand(url);
|
|
20
|
+
spawn(command, args, {
|
|
11
21
|
stdio: "ignore",
|
|
12
22
|
detached: true,
|
|
13
|
-
shell:
|
|
23
|
+
shell: false,
|
|
14
24
|
}).unref();
|
|
15
25
|
}
|
|
16
26
|
|
|
27
|
+
function saveTokens({ access_token, refresh_token, expires_in }) {
|
|
28
|
+
const existing = readConfig() || {};
|
|
29
|
+
const { key: _legacy, ...rest } = existing;
|
|
30
|
+
const token_expires_at =
|
|
31
|
+
typeof expires_in === "number"
|
|
32
|
+
? Date.now() + expires_in * 1000
|
|
33
|
+
: undefined;
|
|
34
|
+
writeConfig(
|
|
35
|
+
mergeLoginConfig(rest, {
|
|
36
|
+
access_token,
|
|
37
|
+
refresh_token,
|
|
38
|
+
token_expires_at,
|
|
39
|
+
base_url: ROOT,
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
17
44
|
// 브라우저 핸드오프: 로컬 콜백 서버를 열고 대시보드 /cli-auth로 보낸다.
|
|
18
|
-
// 프론트가 로그인 확인 후
|
|
45
|
+
// 프론트가 로그인 확인 후 JWT를 발급해 127.0.0.1:PORT/cb 로 POST 한다.
|
|
19
46
|
export async function login() {
|
|
20
47
|
const state = randomUUID();
|
|
21
48
|
|
|
22
|
-
const
|
|
49
|
+
const tokens = await new Promise((resolve, reject) => {
|
|
23
50
|
const server = createServer((req, res) => {
|
|
24
51
|
// 로컬 콜백만 받으므로 CORS는 전부 허용(브라우저→127.0.0.1 cross-origin POST).
|
|
25
52
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
@@ -43,12 +70,22 @@ export async function login() {
|
|
|
43
70
|
} catch {
|
|
44
71
|
// 잘못된 본문 — 아래 검증에서 걸러짐
|
|
45
72
|
}
|
|
46
|
-
const ok =
|
|
73
|
+
const ok =
|
|
74
|
+
payload.state === state &&
|
|
75
|
+
typeof payload.access_token === "string" &&
|
|
76
|
+
payload.access_token &&
|
|
77
|
+
typeof payload.refresh_token === "string" &&
|
|
78
|
+
payload.refresh_token;
|
|
47
79
|
res.writeHead(ok ? 200 : 400, { "Content-Type": "application/json" });
|
|
48
80
|
res.end(JSON.stringify({ ok: Boolean(ok) }));
|
|
49
81
|
server.close();
|
|
50
|
-
if (ok)
|
|
51
|
-
|
|
82
|
+
if (ok) {
|
|
83
|
+
resolve({
|
|
84
|
+
access_token: payload.access_token,
|
|
85
|
+
refresh_token: payload.refresh_token,
|
|
86
|
+
expires_in: payload.expires_in,
|
|
87
|
+
});
|
|
88
|
+
} else reject(new Error(t("State mismatch or missing key")));
|
|
52
89
|
});
|
|
53
90
|
});
|
|
54
91
|
|
|
@@ -56,7 +93,7 @@ export async function login() {
|
|
|
56
93
|
server.listen(0, "127.0.0.1", () => {
|
|
57
94
|
const { port } = server.address();
|
|
58
95
|
const authUrl = `${ROOT}/cli-auth?port=${port}&state=${state}`;
|
|
59
|
-
console.log(`${t("Log in from your browser. If it doesn't open automatically, open this URL:")}\n ${authUrl}\n`);
|
|
96
|
+
console.log(`${t("Log in from your browser. If it doesn't open automatically, open this URL:")}\n ${sanitizeTerminalText(authUrl)}\n`);
|
|
60
97
|
openBrowser(authUrl);
|
|
61
98
|
});
|
|
62
99
|
|
|
@@ -66,12 +103,12 @@ export async function login() {
|
|
|
66
103
|
}, 120_000).unref();
|
|
67
104
|
});
|
|
68
105
|
|
|
69
|
-
|
|
70
|
-
console.log(`✓ ${t("Logged in. Saved the
|
|
106
|
+
saveTokens(tokens);
|
|
107
|
+
console.log(`✓ ${t("Logged in. Saved the session to ~/.pleum/config.json.")}`);
|
|
71
108
|
}
|
|
72
109
|
|
|
73
110
|
// device-code 로그인 — 브라우저 없는 환경(SSH·CI).
|
|
74
|
-
// 1) code 발급 → 2) 사용자가 /device에서 확인 → 3) CLI가 폴링으로
|
|
111
|
+
// 1) code 발급 → 2) 사용자가 /device에서 확인 → 3) CLI가 폴링으로 JWT 수신.
|
|
75
112
|
export async function loginDevice() {
|
|
76
113
|
const res = await fetch(`${V1}/auth/device/code`, { method: "POST" });
|
|
77
114
|
if (!res.ok) {
|
|
@@ -81,8 +118,8 @@ export async function loginDevice() {
|
|
|
81
118
|
const { device_code, user_code, verification_url, interval, expires_in } = await res.json();
|
|
82
119
|
|
|
83
120
|
console.log(
|
|
84
|
-
`\n ${t("Code")}: \x1b[1;32m${user_code}\x1b[0m\n` +
|
|
85
|
-
` URL: ${verification_url}\n\n` +
|
|
121
|
+
`\n ${t("Code")}: \x1b[1;32m${sanitizeTerminalText(user_code)}\x1b[0m\n` +
|
|
122
|
+
` URL: ${sanitizeTerminalText(verification_url)}\n\n` +
|
|
86
123
|
` ${t("Log in at the URL above, then enter the code (within {min} min).", { min: expires_in / 60 })}\n`
|
|
87
124
|
);
|
|
88
125
|
|
|
@@ -98,12 +135,16 @@ export async function loginDevice() {
|
|
|
98
135
|
if (!poll.ok) {
|
|
99
136
|
const e = await poll.json().catch(() => ({}));
|
|
100
137
|
if (e.detail === "expired_token") { console.error(t("The code has expired.")); process.exit(1); }
|
|
101
|
-
console.error(t("Error:"), e.detail ?? poll.status);
|
|
138
|
+
console.error(t("Error:"), sanitizeTerminalText(e.detail ?? poll.status));
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
const data = await poll.json();
|
|
142
|
+
if (!data.access_token || !data.refresh_token) {
|
|
143
|
+
console.error(t("Error:"), "missing tokens");
|
|
102
144
|
process.exit(1);
|
|
103
145
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
console.log(`✓ ${t("Logged in. Saved the key to ~/.pleum/config.json.")}`);
|
|
146
|
+
saveTokens(data);
|
|
147
|
+
console.log(`✓ ${t("Logged in. Saved the session to ~/.pleum/config.json.")}`);
|
|
107
148
|
return;
|
|
108
149
|
}
|
|
109
150
|
console.error(t("Timed out: the code has expired."));
|
package/src/markdown.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// 전략: \n이 도착해 완결된 줄은 스타일 입혀 확정 출력(재렌더 없음), 아직 안 끝난
|
|
3
3
|
// 마지막 줄만 매 push마다 지우고 다시 그린다. 비TTY(파이프)는 완전 패스스루.
|
|
4
4
|
|
|
5
|
-
import { style, displayWidth, terminalWidth } from "./ansi.mjs";
|
|
5
|
+
import { style, displayWidth, terminalWidth, sanitizeTerminalText } from "./ansi.mjs";
|
|
6
6
|
|
|
7
7
|
// ```lang 펜스 감지(순수 함수). 열기/닫기 구분은 호출자의 inFence 상태가 한다.
|
|
8
8
|
export function detectFence(line) {
|
|
@@ -13,7 +13,7 @@ export function detectFence(line) {
|
|
|
13
13
|
// 인라인 마크다운: `code` → cyan, **bold**, *italic*. 코드 조각 먼저 치환해
|
|
14
14
|
// 코드 안의 별표가 bold로 오인되는 흔한 케이스를 줄인다.
|
|
15
15
|
export function renderInline(text, styler = style) {
|
|
16
|
-
return text
|
|
16
|
+
return sanitizeTerminalText(text)
|
|
17
17
|
.replace(/`([^`]+)`/g, (_, c) => styler.cyan(c))
|
|
18
18
|
.replace(/\*\*([^*]+)\*\*/g, (_, c) => styler.bold(c))
|
|
19
19
|
.replace(/(^|[^*])\*([^*\s][^*]*)\*(?!\*)/g, (_, pre, c) => pre + styler.italic(c));
|
|
@@ -21,6 +21,7 @@ export function renderInline(text, styler = style) {
|
|
|
21
21
|
|
|
22
22
|
// 완결된 한 줄 렌더(순수 함수). state.inFence는 이 줄 "이전"의 펜스 상태.
|
|
23
23
|
export function renderLine(line, state = { inFence: false }, styler = style) {
|
|
24
|
+
line = sanitizeTerminalText(line);
|
|
24
25
|
const fence = detectFence(line);
|
|
25
26
|
if (fence) {
|
|
26
27
|
return state.inFence ? styler.gray("└─") : styler.gray("┌─ ") + styler.dim(fence.lang || "code");
|
|
@@ -67,6 +68,8 @@ export function createMarkdownStream({
|
|
|
67
68
|
|
|
68
69
|
return {
|
|
69
70
|
push(delta) {
|
|
71
|
+
delta = sanitizeTerminalText(delta);
|
|
72
|
+
if (!delta) return;
|
|
70
73
|
if (!interactive) return void write(delta);
|
|
71
74
|
buf += delta;
|
|
72
75
|
const lines = buf.split("\n");
|
package/src/menu.mjs
CHANGED
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
import { readFileSync } from "node:fs";
|
|
12
12
|
import { spawn } from "node:child_process";
|
|
13
13
|
import { createInterface } from "node:readline";
|
|
14
|
-
import { style, displayWidth, terminalWidth, hyperlink } from "./ansi.mjs";
|
|
14
|
+
import { style, displayWidth, terminalWidth, hyperlink, sanitizeTerminalText } from "./ansi.mjs";
|
|
15
15
|
import { keyFeeder, select } from "./interactive.mjs";
|
|
16
16
|
import { readConfig, writeConfig } from "./config.mjs";
|
|
17
17
|
import { RECIPES, MANUAL } from "./recipes.mjs";
|
|
18
18
|
import { onPath, launch } from "./launch.mjs";
|
|
19
|
-
import { fetchModels, matchModels, listModels } from "./models.mjs";
|
|
19
|
+
import { fetchModels, matchModels, listModels, formatModelLabel } from "./models.mjs";
|
|
20
20
|
import { run } from "./run.mjs";
|
|
21
21
|
import { login } from "./login.mjs";
|
|
22
22
|
import { top } from "./top.mjs";
|
|
@@ -115,12 +115,12 @@ async function pickModelFor(item) {
|
|
|
115
115
|
try {
|
|
116
116
|
models = await fetchModels();
|
|
117
117
|
} catch (e) {
|
|
118
|
-
console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
|
|
118
|
+
console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: sanitizeTerminalText(e.message) })}`);
|
|
119
119
|
return undefined;
|
|
120
120
|
}
|
|
121
121
|
const m = await select(models, {
|
|
122
122
|
message: `${item.title} — ${t("select a model")}`,
|
|
123
|
-
formatItem: (x) =>
|
|
123
|
+
formatItem: (x) => formatModelLabel(x),
|
|
124
124
|
matches: matchModels,
|
|
125
125
|
limit: 12,
|
|
126
126
|
});
|
package/src/models.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { V1 } from "./config.mjs";
|
|
2
|
-
import { style, padDisplay, divider } from "./ansi.mjs";
|
|
2
|
+
import { style, padDisplay, divider, sanitizeTerminalText } from "./ansi.mjs";
|
|
3
3
|
import { t } from "./i18n.mjs";
|
|
4
4
|
|
|
5
5
|
// GET /v1/models 는 공개(인증 불필요). 슬러그는 provider/model 형식.
|
|
@@ -15,16 +15,28 @@ export function matchModels(models, query) {
|
|
|
15
15
|
const q = (query || "").toLowerCase();
|
|
16
16
|
if (!q) return models;
|
|
17
17
|
return models.filter(
|
|
18
|
-
(m) => m.id.toLowerCase().includes(q) || (m.display_name
|
|
18
|
+
(m) => String(m.id ?? "").toLowerCase().includes(q) || String(m.display_name ?? "").toLowerCase().includes(q),
|
|
19
19
|
);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Model registry data is remote input. Keep this boundary before any style/render operation so
|
|
23
|
+
// formatting codes emitted by the CLI itself are the only ANSI sequences written to a terminal.
|
|
24
|
+
export function safeModelText(value, fallback = "") {
|
|
25
|
+
return sanitizeTerminalText(value ?? fallback);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function formatModelLabel(model, styler = style) {
|
|
29
|
+
const id = safeModelText(model?.id);
|
|
30
|
+
const displayName = safeModelText(model?.display_name);
|
|
31
|
+
return `${id}${displayName ? ` ${styler.dim(displayName)}` : ""}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
// 컨텍스트 길이를 사람이 읽기 좋게: 128000→128K, 1000000→1M.
|
|
23
35
|
export function fmtCtx(n) {
|
|
24
36
|
if (!n) return "—";
|
|
25
37
|
if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`;
|
|
26
38
|
if (n >= 1000) return `${Math.round(n / 1000)}K`;
|
|
27
|
-
return
|
|
39
|
+
return safeModelText(n);
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
// pricing(_krw)는 토큰당 단가 문자열. 1M 토큰당으로 환산해 표시(KRW 우선, 없으면 USD).
|
|
@@ -49,9 +61,9 @@ export function formatTable(models, styler = style) {
|
|
|
49
61
|
const rows = models.map((m) => {
|
|
50
62
|
const [pin, pout] = fmtPrice(m);
|
|
51
63
|
return [
|
|
52
|
-
m.id,
|
|
53
|
-
m.provider || "",
|
|
54
|
-
m.modality || "text",
|
|
64
|
+
safeModelText(m.id),
|
|
65
|
+
safeModelText(m.provider || ""),
|
|
66
|
+
safeModelText(m.modality || "text"),
|
|
55
67
|
fmtCtx(m.context_length || m.context_window),
|
|
56
68
|
pin,
|
|
57
69
|
pout,
|
|
@@ -119,14 +131,16 @@ const fmtNum = (n) => (n == null ? "—" : Number(n).toLocaleString("en-US"));
|
|
|
119
131
|
export function renderModelInfo(m, styler = style) {
|
|
120
132
|
const [pin, pout] = fmtPrice(m);
|
|
121
133
|
const lines = [];
|
|
122
|
-
|
|
134
|
+
const id = safeModelText(m.id);
|
|
135
|
+
const displayName = safeModelText(m.display_name);
|
|
136
|
+
lines.push(`${styler.bold(styler.plum(id))}${displayName ? ` ${styler.dim(displayName)}` : ""}`);
|
|
123
137
|
lines.push(divider(undefined, styler));
|
|
124
138
|
const facts = [
|
|
125
|
-
["maker", m.maker || "—"],
|
|
126
|
-
["modality", m.modality || "text"],
|
|
139
|
+
["maker", safeModelText(m.maker || "—")],
|
|
140
|
+
["modality", safeModelText(m.modality || "text")],
|
|
127
141
|
["context", fmtCtx(m.context_length || m.context_window)],
|
|
128
142
|
["max output", fmtCtx(m.max_output_tokens || m.max_output)],
|
|
129
|
-
[t("price/1M"), `${t("in")} ${pin} · ${t("out")} ${pout} ${styler.dim(t("(incl. {pct}% markup)", { pct: m.effective_markup_percent
|
|
143
|
+
[t("price/1M"), `${t("in")} ${pin} · ${t("out")} ${pout} ${styler.dim(t("(incl. {pct}% markup)", { pct: safeModelText(m.effective_markup_percent, 0) }))}`],
|
|
130
144
|
];
|
|
131
145
|
if (m.supports_thinking) {
|
|
132
146
|
facts.push([
|
|
@@ -160,19 +174,19 @@ export function renderModelInfo(m, styler = style) {
|
|
|
160
174
|
const [i1m, o1m] = fmtPrice(p);
|
|
161
175
|
lines.push(
|
|
162
176
|
[
|
|
163
|
-
styler.plum(padDisplay(p.provider, 12)),
|
|
177
|
+
styler.plum(padDisplay(safeModelText(p.provider), 12)),
|
|
164
178
|
padDisplay(i1m, 10),
|
|
165
179
|
padDisplay(o1m, 10),
|
|
166
180
|
padDisplay(fmtCtx(p.context_window), 5),
|
|
167
181
|
padDisplay(p.latency_ms != null ? `${Math.round(p.latency_ms)}ms` : "—", 8),
|
|
168
182
|
padDisplay(p.throughput_tps != null ? `${Math.round(p.throughput_tps)}/s` : "—", 6),
|
|
169
|
-
p.uptime_percent != null ? `${p.uptime_percent}%` : "—",
|
|
183
|
+
p.uptime_percent != null ? `${safeModelText(p.uptime_percent)}%` : "—",
|
|
170
184
|
].join(" "),
|
|
171
185
|
);
|
|
172
186
|
}
|
|
173
187
|
}
|
|
174
188
|
|
|
175
|
-
const sibs = (m.siblings || []).map((s) => s.id);
|
|
189
|
+
const sibs = (m.siblings || []).map((s) => safeModelText(s.id));
|
|
176
190
|
if (sibs.length) {
|
|
177
191
|
lines.push("");
|
|
178
192
|
lines.push(`${styler.dim(t("Same provider:"))} ${sibs.join(styler.dim(" · "))}`);
|
package/src/recipes.mjs
CHANGED
|
@@ -169,15 +169,33 @@ export const RECIPES = {
|
|
|
169
169
|
|
|
170
170
|
// Codex는 Chat Completions가 제거돼 Responses API(wire_api="responses")만 지원.
|
|
171
171
|
// 사용자의 ~/.codex/config.toml을 건드리지 않도록 격리 CODEX_HOME에 이 파일을 쓴다.
|
|
172
|
+
// TOML basic string encoder. 모델명과 base_url은 CLI/env에서 오므로, 따옴표·개행이
|
|
173
|
+
// config의 새 키/테이블로 해석되지 않도록 모든 제어문자를 이스케이프한다.
|
|
174
|
+
export function tomlBasicString(value) {
|
|
175
|
+
const escapes = {
|
|
176
|
+
"\b": "\\b",
|
|
177
|
+
"\t": "\\t",
|
|
178
|
+
"\n": "\\n",
|
|
179
|
+
"\f": "\\f",
|
|
180
|
+
"\r": "\\r",
|
|
181
|
+
'"': '\\"',
|
|
182
|
+
"\\": "\\\\",
|
|
183
|
+
};
|
|
184
|
+
const escaped = String(value).replace(/[\u0000-\u001f\u007f"\\\\]/g, (ch) =>
|
|
185
|
+
escapes[ch] ?? `\\u${ch.codePointAt(0).toString(16).padStart(4, "0")}`,
|
|
186
|
+
);
|
|
187
|
+
return `"${escaped}"`;
|
|
188
|
+
}
|
|
189
|
+
|
|
172
190
|
export function codexToml(model) {
|
|
173
191
|
const m = model || "gpt-5.4-mini";
|
|
174
192
|
return `# Generated by pleum CLI. Isolated CODEX_HOME — your ~/.codex is untouched.
|
|
175
|
-
model =
|
|
193
|
+
model = ${tomlBasicString(m)}
|
|
176
194
|
model_provider = "pleum"
|
|
177
195
|
|
|
178
196
|
[model_providers.pleum]
|
|
179
197
|
name = "PleumRouter"
|
|
180
|
-
base_url =
|
|
198
|
+
base_url = ${tomlBasicString(V1)}
|
|
181
199
|
env_key = "PLEUM_API_KEY"
|
|
182
200
|
wire_api = "responses"
|
|
183
201
|
`;
|