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/run.mjs
CHANGED
|
@@ -1,17 +1,44 @@
|
|
|
1
1
|
import { createInterface } from "node:readline";
|
|
2
|
-
import {
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, lstatSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { V1,
|
|
5
|
-
import { fetchModels, formatTable, matchModels } from "./models.mjs";
|
|
6
|
-
import { style, divider } from "./ansi.mjs";
|
|
4
|
+
import { V1, requireAuth, HISTORY_FILE, SESSIONS_DIR } from "./config.mjs";
|
|
5
|
+
import { fetchModels, formatTable, matchModels, formatModelLabel, safeModelText } from "./models.mjs";
|
|
6
|
+
import { style, divider, sanitizeTerminalText } from "./ansi.mjs";
|
|
7
7
|
import { createMarkdownStream } from "./markdown.mjs";
|
|
8
8
|
import { spinner, select } from "./interactive.mjs";
|
|
9
9
|
import { t } from "./i18n.mjs";
|
|
10
10
|
|
|
11
|
-
const
|
|
11
|
+
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
12
|
+
const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
|
|
13
|
+
const GIF87A_MAGIC = Buffer.from("GIF87a");
|
|
14
|
+
const GIF89A_MAGIC = Buffer.from("GIF89a");
|
|
15
|
+
const RIFF_MAGIC = Buffer.from("RIFF");
|
|
16
|
+
const WEBP_MAGIC = Buffer.from("WEBP");
|
|
17
|
+
const BMP_MAGIC = Buffer.from("BM");
|
|
18
|
+
const startsWith = (bytes, magic, offset = 0) =>
|
|
19
|
+
bytes.length >= offset + magic.length && bytes.subarray(offset, offset + magic.length).equals(magic);
|
|
20
|
+
|
|
21
|
+
const IMAGE_FORMATS = {
|
|
22
|
+
png: { mime: "image/png", matches: (bytes) => startsWith(bytes, PNG_MAGIC) },
|
|
23
|
+
jpg: { mime: "image/jpeg", matches: (bytes) => startsWith(bytes, JPEG_MAGIC) },
|
|
24
|
+
jpeg: { mime: "image/jpeg", matches: (bytes) => startsWith(bytes, JPEG_MAGIC) },
|
|
25
|
+
gif: {
|
|
26
|
+
mime: "image/gif",
|
|
27
|
+
matches: (bytes) => startsWith(bytes, GIF87A_MAGIC) || startsWith(bytes, GIF89A_MAGIC),
|
|
28
|
+
},
|
|
29
|
+
webp: {
|
|
30
|
+
mime: "image/webp",
|
|
31
|
+
matches: (bytes) => startsWith(bytes, RIFF_MAGIC) && startsWith(bytes, WEBP_MAGIC, 8),
|
|
32
|
+
},
|
|
33
|
+
bmp: { mime: "image/bmp", matches: (bytes) => startsWith(bytes, BMP_MAGIC) },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const MAX_IMAGE_ATTACHMENT_BYTES = 5 * 1024 * 1024;
|
|
37
|
+
const MAX_IMAGE_ATTACHMENTS = 4;
|
|
38
|
+
const MAX_TOTAL_IMAGE_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
12
39
|
|
|
13
40
|
// `pleum run [model] ["프롬프트"...] [--verbose] [--skip-cache] [--tag a,b]
|
|
14
|
-
// [--reasoning-effort LEVEL] [--service-tier TIER] [--reasoning-mode MODE]`
|
|
41
|
+
// [--attach image] [--reasoning-effort LEVEL] [--service-tier TIER] [--reasoning-mode MODE]`
|
|
15
42
|
// 인자 파싱(순수 함수 — 테스트 대상).
|
|
16
43
|
const REASONING_EFFORTS = new Set(["minimal", "none", "low", "medium", "high", "xhigh", "max"]);
|
|
17
44
|
const SERVICE_TIERS = new Set(["auto", "default", "flex", "background", "priority"]);
|
|
@@ -24,6 +51,7 @@ export function parseRunArgs(rest = []) {
|
|
|
24
51
|
let reasoningEffort = null;
|
|
25
52
|
let serviceTier = null;
|
|
26
53
|
let reasoningMode = null;
|
|
54
|
+
const attachments = [];
|
|
27
55
|
const pos = [];
|
|
28
56
|
for (let i = 0; i < rest.length; i++) {
|
|
29
57
|
const a = rest[i];
|
|
@@ -53,6 +81,11 @@ export function parseRunArgs(rest = []) {
|
|
|
53
81
|
reasoningMode = v;
|
|
54
82
|
i++;
|
|
55
83
|
}
|
|
84
|
+
} else if (a === "--attach" || a === "-a") {
|
|
85
|
+
const path = rest[i + 1];
|
|
86
|
+
if (typeof path !== "string" || !path) throw new Error("--attach requires an image path");
|
|
87
|
+
attachments.push(path);
|
|
88
|
+
i++;
|
|
56
89
|
} else pos.push(a);
|
|
57
90
|
}
|
|
58
91
|
return {
|
|
@@ -64,19 +97,67 @@ export function parseRunArgs(rest = []) {
|
|
|
64
97
|
reasoningEffort,
|
|
65
98
|
serviceTier,
|
|
66
99
|
reasoningMode,
|
|
100
|
+
attachments,
|
|
67
101
|
};
|
|
68
102
|
}
|
|
69
103
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
104
|
+
function imageFormat(path) {
|
|
105
|
+
const ext = /\.([a-z0-9]+)$/i.exec(path)?.[1]?.toLowerCase();
|
|
106
|
+
return ext ? IMAGE_FORMATS[ext] : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function inlineAttachmentPaths(text) {
|
|
110
|
+
// REPL에서도 안전하게 쓸 수 있도록 토큰 맨 앞 @만 명시 첨부 문법으로 해석한다.
|
|
111
|
+
// 일반적인 ./photo.png 문구와 @mention은 그대로 텍스트로 남는다.
|
|
112
|
+
return text
|
|
113
|
+
.split(/\s+/)
|
|
114
|
+
.filter((token) => token.startsWith("@") && imageFormat(token.slice(1)))
|
|
115
|
+
.map((token) => token.slice(1));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function imagePart(path, { lstat, read }) {
|
|
119
|
+
const format = imageFormat(path);
|
|
120
|
+
if (!format) throw new Error(`Unsupported image attachment: ${path}`);
|
|
121
|
+
|
|
122
|
+
let info;
|
|
123
|
+
try {
|
|
124
|
+
info = lstat(path);
|
|
125
|
+
} catch {
|
|
126
|
+
throw new Error(`Image attachment not found: ${path}`);
|
|
127
|
+
}
|
|
128
|
+
if (info.isSymbolicLink()) throw new Error(`Refusing symlink image attachment: ${path}`);
|
|
129
|
+
if (!info.isFile()) throw new Error(`Image attachment is not a regular file: ${path}`);
|
|
130
|
+
if (!Number.isSafeInteger(info.size) || info.size < 1) throw new Error(`Image attachment has an invalid size: ${path}`);
|
|
131
|
+
if (info.size > MAX_IMAGE_ATTACHMENT_BYTES) {
|
|
132
|
+
throw new Error(`Image attachment is too large (max ${MAX_IMAGE_ATTACHMENT_BYTES} bytes): ${path}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const bytes = Buffer.from(read(path));
|
|
136
|
+
if (bytes.length !== info.size) throw new Error(`Image attachment changed while reading: ${path}`);
|
|
137
|
+
if (bytes.length > MAX_IMAGE_ATTACHMENT_BYTES) {
|
|
138
|
+
throw new Error(`Image attachment is too large (max ${MAX_IMAGE_ATTACHMENT_BYTES} bytes): ${path}`);
|
|
139
|
+
}
|
|
140
|
+
if (!format.matches(bytes)) throw new Error(`Image attachment does not match its file extension: ${path}`);
|
|
141
|
+
return { type: "image_url", image_url: { url: `data:${format.mime};base64,${bytes.toString("base64")}` }, size: bytes.length };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Local files are uploaded only when explicitly selected with --attach or an @path REPL token.
|
|
145
|
+
// lstat/read injection keeps the filesystem checks focused and regression-testable.
|
|
146
|
+
export function buildContent(text, { attachments = [], lstat = lstatSync, read = readFileSync } = {}) {
|
|
147
|
+
const paths = [...new Set([...attachments, ...inlineAttachmentPaths(text)])];
|
|
148
|
+
if (!paths.length) return text;
|
|
149
|
+
if (paths.length > MAX_IMAGE_ATTACHMENTS) {
|
|
150
|
+
throw new Error(`Too many image attachments (max ${MAX_IMAGE_ATTACHMENTS})`);
|
|
151
|
+
}
|
|
75
152
|
const parts = [{ type: "text", text }];
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
153
|
+
let totalSize = 0;
|
|
154
|
+
for (const path of paths) {
|
|
155
|
+
const { size, ...part } = imagePart(path, { lstat, read });
|
|
156
|
+
totalSize += size;
|
|
157
|
+
if (totalSize > MAX_TOTAL_IMAGE_ATTACHMENT_BYTES) {
|
|
158
|
+
throw new Error(`Total image attachments are too large (max ${MAX_TOTAL_IMAGE_ATTACHMENT_BYTES} bytes)`);
|
|
159
|
+
}
|
|
160
|
+
parts.push(part);
|
|
80
161
|
}
|
|
81
162
|
return parts;
|
|
82
163
|
}
|
|
@@ -86,17 +167,17 @@ export function formatStats({ cost, usage, ms, tokPerSec, verbose }) {
|
|
|
86
167
|
const bits = [];
|
|
87
168
|
if (cost != null && cost !== "") bits.push(`₩${fmtCost(cost)}`);
|
|
88
169
|
if (verbose && usage) {
|
|
89
|
-
bits.push(`↑${usage.prompt_tokens ?? "?"} ↓${usage.completion_tokens ?? "?"} tok`);
|
|
170
|
+
bits.push(`↑${sanitizeTerminalText(usage.prompt_tokens ?? "?")} ↓${sanitizeTerminalText(usage.completion_tokens ?? "?")} tok`);
|
|
90
171
|
if (tokPerSec) bits.push(`${tokPerSec.toFixed(1)} tok/s`);
|
|
91
172
|
}
|
|
92
|
-
if (verbose) bits.push(`${ms}ms`);
|
|
173
|
+
if (verbose) bits.push(`${sanitizeTerminalText(ms)}ms`);
|
|
93
174
|
return bits.length ? ` (${bits.join(" · ")})` : "";
|
|
94
175
|
}
|
|
95
176
|
|
|
96
177
|
// 비용 표시: 숫자면 소수 2자리까지(후행 0 제거), 문자열은 그대로.
|
|
97
178
|
function fmtCost(v) {
|
|
98
179
|
const n = Number(v);
|
|
99
|
-
return Number.isFinite(n) ? String(+n.toFixed(2)) :
|
|
180
|
+
return Number.isFinite(n) ? String(+n.toFixed(2)) : sanitizeTerminalText(v);
|
|
100
181
|
}
|
|
101
182
|
|
|
102
183
|
function isAbort(e) {
|
|
@@ -114,11 +195,12 @@ export async function run(rest) {
|
|
|
114
195
|
reasoningEffort,
|
|
115
196
|
serviceTier,
|
|
116
197
|
reasoningMode,
|
|
198
|
+
attachments,
|
|
117
199
|
} = parseRunArgs(args);
|
|
118
200
|
let model = argModel;
|
|
119
201
|
const piped = await readStdin();
|
|
120
|
-
const oneShot = Boolean(inlinePrompt || piped);
|
|
121
|
-
const key =
|
|
202
|
+
const oneShot = Boolean(inlinePrompt || piped || attachments.length);
|
|
203
|
+
const key = await requireAuth();
|
|
122
204
|
const callOpts = { tags, skipCache, reasoningEffort, serviceTier, reasoningMode };
|
|
123
205
|
|
|
124
206
|
if (!model && !oneShot) {
|
|
@@ -137,17 +219,16 @@ export async function run(rest) {
|
|
|
137
219
|
|
|
138
220
|
if (oneShot) {
|
|
139
221
|
const prompt = [inlinePrompt, piped].filter(Boolean).join("\n\n");
|
|
140
|
-
const content = buildContent(prompt);
|
|
141
|
-
if (Array.isArray(content)) {
|
|
142
|
-
const n = content.filter((p) => p.type === "image_url").length;
|
|
143
|
-
// 프롬프트 내 로컬 이미지 경로를 자동 첨부·업송하므로 조용히 올리지 않고 명시 고지한다(M8).
|
|
144
|
-
console.error(`ℹ ${t("Attaching and uploading {n} local image(s) from the prompt.", { n })}`);
|
|
145
|
-
}
|
|
146
|
-
const messages = [{ role: "user", content }];
|
|
147
222
|
try {
|
|
223
|
+
const content = buildContent(prompt, { attachments });
|
|
224
|
+
if (Array.isArray(content)) {
|
|
225
|
+
const n = content.filter((p) => p.type === "image_url").length;
|
|
226
|
+
console.error(`ℹ ${t("Attaching and uploading {n} explicit local image(s).", { n })}`);
|
|
227
|
+
}
|
|
228
|
+
const messages = [{ role: "user", content }];
|
|
148
229
|
await streamChat(key, model, messages, { verbose, quiet: true, ...callOpts });
|
|
149
230
|
} catch (e) {
|
|
150
|
-
console.error(`${style.red("✗")} ${e.message}`);
|
|
231
|
+
console.error(`${style.red("✗")} ${sanitizeTerminalText(e.message)}`);
|
|
151
232
|
process.exit(1);
|
|
152
233
|
}
|
|
153
234
|
process.exit(0);
|
|
@@ -174,11 +255,14 @@ async function repl(key, initialModel, initialVerbose, {
|
|
|
174
255
|
let rl;
|
|
175
256
|
|
|
176
257
|
const send = async (text) => {
|
|
177
|
-
|
|
178
|
-
const req = system ? [{ role: "system", content: system }, ...messages] : messages;
|
|
179
|
-
const ac = new AbortController();
|
|
180
|
-
currentAbort = ac;
|
|
258
|
+
let added = false;
|
|
181
259
|
try {
|
|
260
|
+
const content = buildContent(text);
|
|
261
|
+
messages.push({ role: "user", content });
|
|
262
|
+
added = true;
|
|
263
|
+
const req = system ? [{ role: "system", content: system }, ...messages] : messages;
|
|
264
|
+
const ac = new AbortController();
|
|
265
|
+
currentAbort = ac;
|
|
182
266
|
const reply = await streamChat(key, model, req, {
|
|
183
267
|
verbose,
|
|
184
268
|
signal: ac.signal,
|
|
@@ -191,8 +275,8 @@ async function repl(key, initialModel, initialVerbose, {
|
|
|
191
275
|
if (reply) messages.push({ role: "assistant", content: reply });
|
|
192
276
|
else messages.pop(); // 응답 없음(즉시 중단 등) — user 턴 되돌림
|
|
193
277
|
} catch (e) {
|
|
194
|
-
console.error(`\n${style.red("✗")} ${e.message}`);
|
|
195
|
-
messages.pop(); // 실패한 user 턴 제거
|
|
278
|
+
console.error(`\n${style.red("✗")} ${sanitizeTerminalText(e.message)}`);
|
|
279
|
+
if (added) messages.pop(); // 실패한 user 턴 제거
|
|
196
280
|
} finally {
|
|
197
281
|
currentAbort = null;
|
|
198
282
|
}
|
|
@@ -310,7 +394,7 @@ async function repl(key, initialModel, initialVerbose, {
|
|
|
310
394
|
};
|
|
311
395
|
|
|
312
396
|
makeRl(loadHistory());
|
|
313
|
-
console.log(`${style.bold(style.plum("PleumRouter"))} ${style.dim("·")} ${model}`);
|
|
397
|
+
console.log(`${style.bold(style.plum("PleumRouter"))} ${style.dim("·")} ${safeModelText(model)}`);
|
|
314
398
|
console.log(style.dim(t('/help for commands · /exit to quit · wrap multi-line input in """ · Ctrl+C to stop generation')));
|
|
315
399
|
console.log(divider());
|
|
316
400
|
rl.prompt();
|
|
@@ -329,7 +413,7 @@ const SLASH_HELP = `Commands:
|
|
|
329
413
|
/verbose toggle token/speed stats
|
|
330
414
|
/exit, /quit quit
|
|
331
415
|
Multi-line input: a line with """ → body → a line with """
|
|
332
|
-
Images:
|
|
416
|
+
Images: attach explicitly with @./shot.png (or --attach ./shot.png for one-shot commands)`;
|
|
333
417
|
|
|
334
418
|
async function handleSlash(text, ctx) {
|
|
335
419
|
const [cmd] = text.slice(1).split(/\s+/);
|
|
@@ -354,17 +438,17 @@ async function handleSlash(text, ctx) {
|
|
|
354
438
|
case "model":
|
|
355
439
|
if (arg) {
|
|
356
440
|
ctx.model = arg;
|
|
357
|
-
console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(arg)}`);
|
|
441
|
+
console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(safeModelText(arg))}`);
|
|
358
442
|
} else if (ctx.pickModel) {
|
|
359
443
|
const picked = await ctx.pickModel();
|
|
360
444
|
if (picked) {
|
|
361
445
|
ctx.model = picked;
|
|
362
|
-
console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(picked)}`);
|
|
446
|
+
console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(safeModelText(picked))}`);
|
|
363
447
|
} else {
|
|
364
|
-
console.log(`${t("Current model:")} ${style.plum(ctx.model)}`);
|
|
448
|
+
console.log(`${t("Current model:")} ${style.plum(safeModelText(ctx.model))}`);
|
|
365
449
|
}
|
|
366
450
|
} else {
|
|
367
|
-
console.log(`${t("Current model:")} ${ctx.model}\n${t("switch: /model <id> · list: /models")}`);
|
|
451
|
+
console.log(`${t("Current model:")} ${safeModelText(ctx.model)}\n${t("switch: /model <id> · list: /models")}`);
|
|
368
452
|
}
|
|
369
453
|
break;
|
|
370
454
|
case "models":
|
|
@@ -373,7 +457,7 @@ async function handleSlash(text, ctx) {
|
|
|
373
457
|
const rows = matchModels(models, arg);
|
|
374
458
|
console.log(rows.length ? formatTable(rows) : t("No matching models"));
|
|
375
459
|
} catch (e) {
|
|
376
|
-
console.error(`${style.red("✗")} ${e.message}`);
|
|
460
|
+
console.error(`${style.red("✗")} ${sanitizeTerminalText(e.message)}`);
|
|
377
461
|
}
|
|
378
462
|
break;
|
|
379
463
|
case "save":
|
|
@@ -381,13 +465,13 @@ async function handleSlash(text, ctx) {
|
|
|
381
465
|
const name = saveSession(arg || "last", ctx.snapshot());
|
|
382
466
|
console.log(`${style.green("✓")} ${t("Session saved:")} ${name}`);
|
|
383
467
|
} catch (e) {
|
|
384
|
-
console.error(`${style.red("✗")} ${t("Save failed: {error}", { error: e.message })}`);
|
|
468
|
+
console.error(`${style.red("✗")} ${t("Save failed: {error}", { error: sanitizeTerminalText(e.message) })}`);
|
|
385
469
|
}
|
|
386
470
|
break;
|
|
387
471
|
case "load":
|
|
388
472
|
try {
|
|
389
473
|
ctx.restore(loadSession(arg || "last"));
|
|
390
|
-
console.log(`${style.green("✓")} ${t("Session loaded (model: {model})", { model: ctx.model })}`);
|
|
474
|
+
console.log(`${style.green("✓")} ${t("Session loaded (model: {model})", { model: safeModelText(ctx.model) })}`);
|
|
391
475
|
} catch {
|
|
392
476
|
console.error(`${style.red("✗")} ${t("No such session: {name}", { name: arg || "last" })}`);
|
|
393
477
|
}
|
|
@@ -415,7 +499,7 @@ export async function streamChat(key, model, messages, {
|
|
|
415
499
|
fetchImpl = fetch,
|
|
416
500
|
} = {}) {
|
|
417
501
|
const t0 = Date.now();
|
|
418
|
-
const sp = spinner(t("{model} thinking…", { model }));
|
|
502
|
+
const sp = spinner(t("{model} thinking…", { model: sanitizeTerminalText(model) }));
|
|
419
503
|
let res;
|
|
420
504
|
try {
|
|
421
505
|
// 게이트웨이 패리티(0095): X-Pleum-Skip-Cache 헤더 + bounded tags 본문.
|
|
@@ -443,7 +527,7 @@ export async function streamChat(key, model, messages, {
|
|
|
443
527
|
if (!res.ok) {
|
|
444
528
|
sp.stop();
|
|
445
529
|
const body = await res.text().catch(() => "");
|
|
446
|
-
throw new Error(`${res.status} ${body.slice(0, 300)}`);
|
|
530
|
+
throw new Error(`${res.status} ${sanitizeTerminalText(body.slice(0, 300))}`);
|
|
447
531
|
}
|
|
448
532
|
|
|
449
533
|
const md = createMarkdownStream({ prefix: quiet ? "" : `${style.plum("bot")}${style.dim(">")} ` });
|
|
@@ -470,12 +554,14 @@ export async function streamChat(key, model, messages, {
|
|
|
470
554
|
if (j.cost) cost = j.cost; // 마지막 청크 — {usd, krw, fx_rate, markup_rate}
|
|
471
555
|
const delta = j.choices?.[0]?.delta?.content;
|
|
472
556
|
if (delta) {
|
|
557
|
+
const safeDelta = sanitizeTerminalText(delta);
|
|
558
|
+
if (!safeDelta) continue;
|
|
473
559
|
if (!firstAt) {
|
|
474
560
|
firstAt = Date.now();
|
|
475
561
|
sp.stop();
|
|
476
562
|
}
|
|
477
|
-
md.push(
|
|
478
|
-
full +=
|
|
563
|
+
md.push(safeDelta);
|
|
564
|
+
full += safeDelta;
|
|
479
565
|
}
|
|
480
566
|
} catch {
|
|
481
567
|
// 부분 청크 — 무시
|
|
@@ -512,14 +598,14 @@ async function pickModel() {
|
|
|
512
598
|
try {
|
|
513
599
|
models = await fetchModels();
|
|
514
600
|
} catch (e) {
|
|
515
|
-
console.error(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
|
|
601
|
+
console.error(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: sanitizeTerminalText(e.message) })}`);
|
|
516
602
|
return null;
|
|
517
603
|
}
|
|
518
604
|
if (!models.length) return null;
|
|
519
605
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
520
606
|
return select(models, {
|
|
521
607
|
message: t("Select a model"),
|
|
522
|
-
formatItem: (m) =>
|
|
608
|
+
formatItem: (m) => formatModelLabel(m),
|
|
523
609
|
matches: matchModels,
|
|
524
610
|
limit: 12,
|
|
525
611
|
}).then((m) => m?.id ?? null);
|
|
@@ -536,7 +622,7 @@ async function pickModelNumbered(models) {
|
|
|
536
622
|
for (;;) {
|
|
537
623
|
const shown = pool.slice(0, 30);
|
|
538
624
|
shown.forEach((m, i) =>
|
|
539
|
-
console.log(` ${String(i + 1).padStart(2)}. ${m
|
|
625
|
+
console.log(` ${String(i + 1).padStart(2)}. ${formatModelLabel(m, { dim: (s) => `(${s})` })}`),
|
|
540
626
|
);
|
|
541
627
|
if (pool.length > shown.length) console.log(` ${t("… {n} more — narrow with a search term", { n: pool.length - shown.length })}`);
|
|
542
628
|
const ans = (await ask(`\n${t("Choose a number, or search (Enter to cancel): ")}`)).trim();
|
package/src/settings.mjs
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
// 즉시 참조하면 순환의 평가 순서에 따라 깨질 수 있다).
|
|
7
7
|
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
|
-
import { style } from "./ansi.mjs";
|
|
9
|
+
import { style, sanitizeTerminalText } from "./ansi.mjs";
|
|
10
10
|
import { select, confirm } from "./interactive.mjs";
|
|
11
11
|
import { readConfig, writeConfig, clearCredentials } from "./config.mjs";
|
|
12
12
|
import { getLocale, LANGS, t } from "./i18n.mjs";
|
|
13
13
|
import { RECIPES } from "./recipes.mjs";
|
|
14
|
-
import { fetchModels, matchModels } from "./models.mjs";
|
|
14
|
+
import { fetchModels, matchModels, formatModelLabel } from "./models.mjs";
|
|
15
15
|
import { login } from "./login.mjs";
|
|
16
16
|
import { upgrade } from "./upgrade.mjs";
|
|
17
17
|
import { completion } from "./completion.mjs";
|
|
@@ -26,7 +26,11 @@ export function buildSettingsItems({ lang, loggedIn, keyPrefix, version }) {
|
|
|
26
26
|
{
|
|
27
27
|
id: "settings:account",
|
|
28
28
|
title: t("Account"),
|
|
29
|
-
desc: loggedIn
|
|
29
|
+
desc: loggedIn
|
|
30
|
+
? keyPrefix === "oauth"
|
|
31
|
+
? `${t("Logged in")} (${t("OAuth session")})`
|
|
32
|
+
: `${t("Logged in")} (${t("key")} ${keyPrefix}…)`
|
|
33
|
+
: t("Not logged in"),
|
|
30
34
|
},
|
|
31
35
|
{ id: "settings:update", title: t("Update CLI"), desc: t("Current version: {version}", { version }) },
|
|
32
36
|
{ id: "settings:completion", title: t("Shell completion"), desc: t("Print a zsh/bash completion script") },
|
|
@@ -65,8 +69,8 @@ async function settingsSelect() {
|
|
|
65
69
|
const cfg = readConfig();
|
|
66
70
|
const items = buildSettingsItems({
|
|
67
71
|
lang: getLocale(),
|
|
68
|
-
loggedIn: Boolean(cfg?.key),
|
|
69
|
-
keyPrefix: cfg?.key ? cfg.key.slice(0, 8) : null,
|
|
72
|
+
loggedIn: Boolean(cfg?.access_token || cfg?.key),
|
|
73
|
+
keyPrefix: cfg?.access_token ? "oauth" : cfg?.key ? cfg.key.slice(0, 8) : null,
|
|
70
74
|
version: readVersion(),
|
|
71
75
|
});
|
|
72
76
|
return runList(items, {
|
|
@@ -82,12 +86,12 @@ async function pickDefaultModelFor(item) {
|
|
|
82
86
|
try {
|
|
83
87
|
models = await fetchModels();
|
|
84
88
|
} catch (e) {
|
|
85
|
-
console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
|
|
89
|
+
console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: sanitizeTerminalText(e.message) })}`);
|
|
86
90
|
return undefined;
|
|
87
91
|
}
|
|
88
92
|
const picked = await select([CLEAR_OPTION, ...models], {
|
|
89
93
|
message: `${item.title} — ${t("select a model")}`,
|
|
90
|
-
formatItem: (x) => (x.isClear ? style.dim(t("Use default (clear)")) :
|
|
94
|
+
formatItem: (x) => (x.isClear ? style.dim(t("Use default (clear)")) : formatModelLabel(x)),
|
|
91
95
|
matches: matchModels, // 쿼리 입력 시 CLEAR_OPTION은 자연히 필터링됨(id/display_name 불일치) — 별도 처리 불요
|
|
92
96
|
limit: 12,
|
|
93
97
|
});
|
package/src/usage.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `pleum usage [--by model|provider|api_key|day] [--days N]` — 내 지출 리포트.
|
|
2
|
-
// GET /v1/usage/report (
|
|
2
|
+
// GET /v1/usage/report (CLI OAuth JWT → 숨은 세션 키 scopes에 usage:read).
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { authFetch } from "./config.mjs";
|
|
5
5
|
import { style, padDisplay, barChart, divider } from "./ansi.mjs";
|
|
6
6
|
import { t } from "./i18n.mjs";
|
|
7
7
|
|
|
@@ -50,12 +50,8 @@ export function renderUsage(data, styler = style) {
|
|
|
50
50
|
|
|
51
51
|
export async function usage(rest) {
|
|
52
52
|
const { by, days } = parseUsageArgs(rest);
|
|
53
|
-
const
|
|
54
|
-
const res = await fetch(`${V1}/usage/report?group_by=${by}&days=${days}`, {
|
|
55
|
-
headers: { Authorization: `Bearer ${key}` },
|
|
56
|
-
});
|
|
53
|
+
const res = await authFetch(`/usage/report?group_by=${by}&days=${days}`);
|
|
57
54
|
if (res.status === 403) {
|
|
58
|
-
// 구버전 로그인 키(usage:read 스코프 없음) — 재로그인으로 새 키를 받으면 해결.
|
|
59
55
|
console.error(
|
|
60
56
|
`${style.red("✗")} ${t("This API key doesn't have usage:read permission.")}\n` +
|
|
61
57
|
` ${style.dim(t("Run pleum login again to get a new key (recent keys include it by default)."))}`,
|