pleumrouter 0.5.4 → 0.5.6
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 +3 -3
- package/bin/pleum.mjs +3 -2
- package/package.json +1 -1
- package/src/ansi.mjs +12 -0
- package/src/config.mjs +3 -2
- package/src/launch.mjs +66 -7
- package/src/locales/es.mjs +2 -2
- package/src/locales/ja.mjs +2 -2
- package/src/locales/ko.mjs +3 -3
- package/src/locales/zh.mjs +2 -2
- package/src/login.mjs +18 -8
- package/src/markdown.mjs +5 -2
- package/src/menu.mjs +4 -4
- package/src/models.mjs +41 -14
- package/src/recipes.mjs +20 -2
- package/src/run.mjs +213 -55
- package/src/settings.mjs +4 -4
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ cat error.log | npx pleumrouter run gpt-5.4-mini "원인은?" # 파이프
|
|
|
33
33
|
| `pleum login [--device]` | 브라우저로 로그인 → CLI가 API 키를 자동 발급·저장(`~/.pleum/config.json`). `--device`는 SSH·CI |
|
|
34
34
|
| `pleum logout` | 저장된 API 키만 삭제(언어·기본 모델 등 나머지 설정은 유지) |
|
|
35
35
|
| `pleum launch [tool] [--model id] [-- args]` | 에이전트 실행. `--` 뒤 인자는 도구로 그대로 전달. 도구 생략 시 지원 목록·설치 여부 표시 |
|
|
36
|
-
| `pleum run [model] ["프롬프트"]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시 화살표로 선택. `--skip-cache`(이번 요청 응답 캐시 스킵), `--tag a,b`(리포팅 태그, ≤10) |
|
|
36
|
+
| `pleum run [model] ["프롬프트"] [--attach image]` | 채팅 — 대화형 REPL, 1회 실행, stdin 파이프. 모델 생략 시 화살표로 선택. 이미지 업로드는 명시적으로 `--attach ./shot.png`(1회 실행) 또는 REPL의 `@./shot.png`를 사용한다. `--skip-cache`(이번 요청 응답 캐시 스킵), `--tag a,b`(리포팅 태그, ≤10) |
|
|
37
37
|
| `pleum models [query] [--json]` | 모델 목록(공개) — provider·컨텍스트·가격(₩/1M) 컬럼, `--json`은 스크립트용 |
|
|
38
38
|
| `pleum models <id> --info` | 모델 상세 — 가격·컨텍스트·provider별 지연/처리량/업타임·주간 사용량·형제 모델 |
|
|
39
39
|
| `pleum top [--period daily\|weekly\|monthly]` | 라우터 전체 모델 리더보드 + 제공자 점유율(공개 통계) |
|
|
@@ -64,7 +64,7 @@ cat error.log | npx pleumrouter run gpt-5.4-mini "원인은?" # 파이프
|
|
|
64
64
|
- 응답 후 비용이 현지 통화(한국 ₩)로 표시(스트리밍 마지막 청크 기준)
|
|
65
65
|
- **Ctrl+C**: 생성 중엔 1회로 중단(대화 유지), 대기 중엔 2초 내 2회로 종료
|
|
66
66
|
- 여러 줄 입력: `"""` 한 줄 → 본문 → `"""` 한 줄
|
|
67
|
-
- 이미지:
|
|
67
|
+
- 이미지: 명시적으로 첨부 — REPL에서는 `이 그림 설명해줘 @./shot.png`, 1회 실행에서는 `--attach ./shot.png`. 일반 프롬프트의 `./shot.png` 텍스트는 업로드되지 않는다. 첨부는 일반 파일·이미지 magic bytes·5 MiB/장(총 10 MiB)을 확인하고 심볼릭 링크는 거부한다.
|
|
68
68
|
- `↑` 로 지난 입력 재호출(히스토리는 `~/.pleum/history`에 영속)
|
|
69
69
|
- 색상 끄기: `NO_COLOR=1` (비TTY·파이프에선 자동 무색)
|
|
70
70
|
|
|
@@ -78,7 +78,7 @@ env(또는 격리된 `CODEX_HOME`/임시 설정 문자열)만 세팅하고 바
|
|
|
78
78
|
|
|
79
79
|
## 환경변수
|
|
80
80
|
|
|
81
|
-
- `PLEUM_BASE_URL` — 게이트웨이 루트 덮어쓰기(기본 `https://
|
|
81
|
+
- `PLEUM_BASE_URL` — 게이트웨이 루트 덮어쓰기(기본 `https://apirouter.pleum.ai`). 셀프호스트/스테이징용.
|
|
82
82
|
|
|
83
83
|
## 개발
|
|
84
84
|
|
package/bin/pleum.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { mainMenu } from "../src/menu.mjs";
|
|
|
15
15
|
import { lang } from "../src/lang.mjs";
|
|
16
16
|
import { RECIPES } from "../src/recipes.mjs";
|
|
17
17
|
import { t } from "../src/i18n.mjs";
|
|
18
|
+
import { sanitizeTerminalText } from "../src/ansi.mjs";
|
|
18
19
|
|
|
19
20
|
// 명령 구문(왼쪽)은 로케일과 무관 — 설명(오른쪽)만 번역, 폭은 가장 긴 구문 기준으로 계산.
|
|
20
21
|
function buildHelp() {
|
|
@@ -26,7 +27,7 @@ function buildHelp() {
|
|
|
26
27
|
["pleum launch [tool] [--model id]", t("Run a coding agent against PleumRouter")],
|
|
27
28
|
["", `(${Object.keys(RECIPES).join(" · ")})`],
|
|
28
29
|
["", t("omit the tool to see the supported list and install status")],
|
|
29
|
-
['pleum run [model] ["prompt"]', t("Terminal chat (REPL) / one-shot / pipe")],
|
|
30
|
+
['pleum run [model] ["prompt"] [--attach image]', t("Terminal chat (REPL) / one-shot / pipe")],
|
|
30
31
|
["", t("omit the model for an arrow-key picker; /help lists commands")],
|
|
31
32
|
["pleum models [query] [--json]", t("Model list (price, context included)")],
|
|
32
33
|
["pleum models <id> --info", t("Model detail (price, perf, per-provider uptime)")],
|
|
@@ -146,6 +147,6 @@ try {
|
|
|
146
147
|
process.exit(1);
|
|
147
148
|
}
|
|
148
149
|
} catch (e) {
|
|
149
|
-
console.error(`✗ ${e.message}`);
|
|
150
|
+
console.error(`✗ ${sanitizeTerminalText(e.message)}`);
|
|
150
151
|
process.exit(1);
|
|
151
152
|
}
|
package/package.json
CHANGED
package/src/ansi.mjs
CHANGED
|
@@ -55,6 +55,18 @@ export function stripAnsi(s) {
|
|
|
55
55
|
.replace(/\x1b\[[0-9;<]*[A-Za-z]/g, "");
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// 원격 응답/메타데이터는 신뢰하지 않는다. 터미널 상태를 바꾸는 ESC·C0/C1 제어문자는
|
|
59
|
+
// 제거하되, 텍스트 스트리밍에 필요한 LF와 TAB은 그대로 둔다. CSI/OSC 등의 본문까지
|
|
60
|
+
// 먼저 제거해 ESC만 떼었을 때 남는 `[2J` 같은 노이즈도 남기지 않는다.
|
|
61
|
+
export function sanitizeTerminalText(value) {
|
|
62
|
+
return String(value ?? "")
|
|
63
|
+
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, "") // OSC (title, hyperlink, clipboard…)
|
|
64
|
+
.replace(/\x1b[PX^_][\s\S]*?\x1b\\/g, "") // DCS, SOS, PM, APC
|
|
65
|
+
.replace(/(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g, "") // CSI + C1 CSI
|
|
66
|
+
.replace(/\x1b(?:[()][0-2A-Z0-9]|[=>][0-?]*|.)/g, "") // remaining ESC sequences
|
|
67
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
|
|
68
|
+
}
|
|
69
|
+
|
|
58
70
|
// OSC 8 클릭 가능한 하이퍼링크. 무색(파이프·NO_COLOR·dumb)이면 텍스트만.
|
|
59
71
|
export function hyperlink(url, text = url, level = style.level) {
|
|
60
72
|
return level ? `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\` : text;
|
package/src/config.mjs
CHANGED
|
@@ -3,8 +3,9 @@ import { join } from "node:path";
|
|
|
3
3
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { t } from "./i18n.mjs";
|
|
5
5
|
|
|
6
|
-
// 공개 게이트웨이
|
|
7
|
-
|
|
6
|
+
// 공개 게이트웨이 루트(canonical API). 셀프호스트/스테이징은 PLEUM_BASE_URL로 덮어쓴다.
|
|
7
|
+
// Compat: https://router.pleum.ai 도 180일+ 유지(프론트 /v1 rewrite). 신규 기본은 apirouter.
|
|
8
|
+
export const ROOT = (process.env.PLEUM_BASE_URL ?? "https://apirouter.pleum.ai")
|
|
8
9
|
.replace(/\/+$/, "")
|
|
9
10
|
.replace(/\/v1$/, ""); // 사용자가 실수로 /v1까지 붙여도 V1이 /v1/v1 이중 경로가 되지 않게 정리(L12)
|
|
10
11
|
// OpenAI 호환 엔드포인트는 /v1. (Claude Code만 루트를 쓴다 — recipes 참고)
|
package/src/launch.mjs
CHANGED
|
@@ -1,19 +1,69 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { join, delimiter } from "node:path";
|
|
4
|
+
import { join, delimiter, dirname, resolve } from "node:path";
|
|
5
5
|
import { requireKey } from "./config.mjs";
|
|
6
6
|
import { RECIPES, MANUAL, codexToml } from "./recipes.mjs";
|
|
7
7
|
import { t } from "./i18n.mjs";
|
|
8
|
+
import { sanitizeTerminalText } from "./ansi.mjs";
|
|
8
9
|
|
|
9
10
|
// PATH에서 실행 파일을 찾는다(설치 여부 표시용). menu.mjs도 사용.
|
|
10
11
|
export function onPath(bin) {
|
|
11
|
-
const exts = process.platform === "win32" ? [".
|
|
12
|
+
const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
|
|
12
13
|
return (process.env.PATH || "")
|
|
13
14
|
.split(delimiter)
|
|
14
15
|
.some((d) => d && exts.some((e) => existsSync(join(d, bin + e))));
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
// Windows는 .cmd/.bat 파일을 셸 없이 직접 실행할 수 없다. npm이 만드는 표준 .cmd
|
|
19
|
+
// shim은 실제 Node 엔트리포인트를 가리키므로 그 JS 파일을 현재 Node로 직접 실행한다.
|
|
20
|
+
// 임의의 batch script는 cmd.exe로 넘기지 않고 명시적으로 거부한다. 그래야 --model과
|
|
21
|
+
// `--` 패스스루 인자가 shell 문법으로 재해석될 수 없다.
|
|
22
|
+
function windowsCommandCandidates(bin, path, pathDelimiter) {
|
|
23
|
+
const hasPath = /[\\/]/.test(bin);
|
|
24
|
+
const hasExtension = /\.[^\\/]+$/.test(bin);
|
|
25
|
+
const names = hasExtension ? [bin] : [`${bin}.exe`, `${bin}.cmd`, `${bin}.bat`, bin];
|
|
26
|
+
const dirs = hasPath ? [""] : path.split(pathDelimiter).filter(Boolean);
|
|
27
|
+
return dirs.flatMap((dir) => names.map((name) => (dir ? join(dir, name) : name)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function nodeEntryFromWindowsShim(script, { read = readFileSync, exists = existsSync } = {}) {
|
|
31
|
+
let source;
|
|
32
|
+
try {
|
|
33
|
+
source = String(read(script, "utf8"));
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
// npm cmd-shim uses either %dp0% or %~dp0 followed by the package's JS entry.
|
|
38
|
+
const match = /"(?:%~dp0|%dp0%)[\\/]+([^"\r\n]+?\.(?:[cm]?js))"/i.exec(source);
|
|
39
|
+
if (!match) return null;
|
|
40
|
+
const entry = resolve(dirname(script), match[1].replace(/[\\/]+/g, "/"));
|
|
41
|
+
return exists(entry) ? entry : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 플랫폼·filesystem 주입으로 Windows 경로 정책을 Linux/macOS에서도 회귀 테스트한다.
|
|
45
|
+
export function resolveLaunchCommand(bin, args, {
|
|
46
|
+
platform = process.platform,
|
|
47
|
+
path = process.env.PATH || "",
|
|
48
|
+
delimiter: pathDelimiter = delimiter,
|
|
49
|
+
exists = existsSync,
|
|
50
|
+
read = readFileSync,
|
|
51
|
+
node = process.execPath,
|
|
52
|
+
} = {}) {
|
|
53
|
+
if (platform !== "win32") return { command: bin, args };
|
|
54
|
+
const found = windowsCommandCandidates(bin, path, pathDelimiter).find((candidate) => exists(candidate));
|
|
55
|
+
if (!found) return { command: bin, args };
|
|
56
|
+
if (!/\.(?:cmd|bat)$/i.test(found)) return { command: found, args };
|
|
57
|
+
|
|
58
|
+
const entry = nodeEntryFromWindowsShim(found, { read, exists });
|
|
59
|
+
if (!entry) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Windows command script '${bin}' cannot be launched safely. Install an .exe or a standard npm Node shim.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return { command: node, args: [entry, ...args] };
|
|
65
|
+
}
|
|
66
|
+
|
|
17
67
|
// `pleum launch`(도구 미지정) — 지원 도구 + 설치 여부를 안내.
|
|
18
68
|
export function launchHelp() {
|
|
19
69
|
console.log(t("Usage: pleum launch <tool> [--model id] [-- tool args]\n"));
|
|
@@ -60,13 +110,22 @@ export async function launch(tool, model, passthrough, { exit = true } = {}) {
|
|
|
60
110
|
|
|
61
111
|
const args = [...r.args(model), ...passthrough];
|
|
62
112
|
if (r.note) console.log(`ℹ ${t(r.note)}`);
|
|
63
|
-
console.log(`▶ ${t("Running {label}: {cmd}", { label: r.label, cmd: `${r.bin} ${args.join(" ")}` })}\n`);
|
|
113
|
+
console.log(`▶ ${t("Running {label}: {cmd}", { label: r.label, cmd: sanitizeTerminalText(`${r.bin} ${args.join(" ")}`) })}\n`);
|
|
114
|
+
|
|
115
|
+
let command;
|
|
116
|
+
try {
|
|
117
|
+
command = resolveLaunchCommand(r.bin, args);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
console.error(`✗ ${sanitizeTerminalText(e.message)}`);
|
|
120
|
+
if (exit) process.exit(1);
|
|
121
|
+
return 1;
|
|
122
|
+
}
|
|
64
123
|
|
|
65
124
|
return await new Promise((resolve) => {
|
|
66
|
-
const child = spawn(
|
|
125
|
+
const child = spawn(command.command, command.args, {
|
|
67
126
|
env,
|
|
68
127
|
stdio: "inherit",
|
|
69
|
-
shell:
|
|
128
|
+
shell: false,
|
|
70
129
|
});
|
|
71
130
|
child.on("error", (e) => {
|
|
72
131
|
if (e.code === "ENOENT") {
|
|
@@ -74,7 +133,7 @@ export async function launch(tool, model, passthrough, { exit = true } = {}) {
|
|
|
74
133
|
if (exit) process.exit(127);
|
|
75
134
|
return resolve(127);
|
|
76
135
|
}
|
|
77
|
-
console.error(`✗ ${e.message}`);
|
|
136
|
+
console.error(`✗ ${sanitizeTerminalText(e.message)}`);
|
|
78
137
|
if (exit) process.exit(1);
|
|
79
138
|
resolve(1);
|
|
80
139
|
});
|
package/src/locales/es.mjs
CHANGED
|
@@ -12,7 +12,7 @@ export const ES = {
|
|
|
12
12
|
"Account": "Cuenta",
|
|
13
13
|
"All models — price and context included": "Todos los modelos — precio y contexto incluidos",
|
|
14
14
|
"Anthropic's coding agent — supports subagents": "Agente de codificación de Anthropic — admite subagentes",
|
|
15
|
-
"Attaching and uploading {n} local image(s)
|
|
15
|
+
"Attaching and uploading {n} explicit local image(s).": "Adjuntando y subiendo {n} imagen(es) local(es) seleccionada(s) explícitamente.",
|
|
16
16
|
"Auto-run:": "Ejecución automática:",
|
|
17
17
|
"Autonomous dev agent": "Agente de desarrollo autónomo",
|
|
18
18
|
"Multi-provider terminal coding agent": "Agente de codificación de terminal multi-proveedor",
|
|
@@ -32,7 +32,7 @@ export const ES = {
|
|
|
32
32
|
"Choose a number, or search (Enter to cancel): ": "Elige un número, o busca (Enter para cancelar): ",
|
|
33
33
|
"Choose a shell": "Elegir una shell",
|
|
34
34
|
"Code": "Código",
|
|
35
|
-
"Commands:\n /help, /? this help\n /clear reset the conversation context (keeps the system prompt)\n /system [text] set the system prompt (no arg shows the current value)\n /model [id] switch models mid-session (no arg = arrow-key picker)\n /models [query] browse the model list\n /save [name] save the current session (default: last)\n /load [name] load a saved session\n /verbose toggle token/speed stats\n /exit, /quit quit\n Multi-line input: a line with \"\"\" → body → a line with \"\"\"\n Images:
|
|
35
|
+
"Commands:\n /help, /? this help\n /clear reset the conversation context (keeps the system prompt)\n /system [text] set the system prompt (no arg shows the current value)\n /model [id] switch models mid-session (no arg = arrow-key picker)\n /models [query] browse the model list\n /save [name] save the current session (default: last)\n /load [name] load a saved session\n /verbose toggle token/speed stats\n /exit, /quit quit\n Multi-line input: a line with \"\"\" → body → a line with \"\"\"\n Images: attach explicitly with @./shot.png (or --attach ./shot.png for one-shot commands)": "Comandos:\n /help, /? esta ayuda\n /clear reinicia el contexto de la conversación (conserva el system prompt)\n /system [text] define el system prompt (sin argumento muestra el valor actual)\n /model [id] cambia de modelo durante la sesión (sin argumento = selector con flechas)\n /models [query] explora la lista de modelos\n /save [name] guarda la sesión actual (por defecto: last)\n /load [name] carga una sesión guardada\n /verbose activa/desactiva estadísticas de tokens/velocidad\n /exit, /quit salir\n Entrada multilínea: una línea con \"\"\" → cuerpo → una línea con \"\"\"\n Imágenes: adjunta explícitamente con @./shot.png (o --attach ./shot.png para comandos de una sola ejecución)",
|
|
36
36
|
"Context cleared": "Contexto borrado",
|
|
37
37
|
"Ctrl+C again to quit (or /exit)": "Ctrl+C de nuevo para salir (o /exit)",
|
|
38
38
|
"Current language: {name}": "Idioma actual: {name}",
|
package/src/locales/ja.mjs
CHANGED
|
@@ -12,7 +12,7 @@ export const JA = {
|
|
|
12
12
|
"Account": "アカウント",
|
|
13
13
|
"All models — price and context included": "全モデル — 価格・コンテキスト込み",
|
|
14
14
|
"Anthropic's coding agent — supports subagents": "Anthropic のコーディングエージェント — サブエージェント対応",
|
|
15
|
-
"Attaching and uploading {n} local image(s)
|
|
15
|
+
"Attaching and uploading {n} explicit local image(s).": "明示的に選択したローカル画像 {n} 件を添付・アップロード中。",
|
|
16
16
|
"Auto-run:": "自動実行:",
|
|
17
17
|
"Autonomous dev agent": "自律開発エージェント",
|
|
18
18
|
"Multi-provider terminal coding agent": "マルチプロバイダー対応のターミナルコーディングエージェント",
|
|
@@ -32,7 +32,7 @@ export const JA = {
|
|
|
32
32
|
"Choose a number, or search (Enter to cancel): ": "番号を選択、または検索してください (Enter でキャンセル): ",
|
|
33
33
|
"Choose a shell": "シェルを選択",
|
|
34
34
|
"Code": "コード",
|
|
35
|
-
"Commands:\n /help, /? this help\n /clear reset the conversation context (keeps the system prompt)\n /system [text] set the system prompt (no arg shows the current value)\n /model [id] switch models mid-session (no arg = arrow-key picker)\n /models [query] browse the model list\n /save [name] save the current session (default: last)\n /load [name] load a saved session\n /verbose toggle token/speed stats\n /exit, /quit quit\n Multi-line input: a line with \"\"\" → body → a line with \"\"\"\n Images:
|
|
35
|
+
"Commands:\n /help, /? this help\n /clear reset the conversation context (keeps the system prompt)\n /system [text] set the system prompt (no arg shows the current value)\n /model [id] switch models mid-session (no arg = arrow-key picker)\n /models [query] browse the model list\n /save [name] save the current session (default: last)\n /load [name] load a saved session\n /verbose toggle token/speed stats\n /exit, /quit quit\n Multi-line input: a line with \"\"\" → body → a line with \"\"\"\n Images: attach explicitly with @./shot.png (or --attach ./shot.png for one-shot commands)": "コマンド:\n /help, /? このヘルプ\n /clear 会話コンテキストをリセット (システムプロンプトは保持)\n /system [text] システムプロンプトを設定 (引数なしで現在値を表示)\n /model [id] セッション中にモデルを切り替え (引数なし = 矢印キーで選択)\n /models [query] モデル一覧を閲覧\n /save [name] 現在のセッションを保存 (デフォルト: last)\n /load [name] 保存したセッションを読み込み\n /verbose トークン/速度統計の表示切替\n /exit, /quit 終了\n 複数行入力: \"\"\" の行 → 本文 → \"\"\" の行\n 画像: @./shot.png で明示的に添付 (1回実行では --attach ./shot.png)",
|
|
36
36
|
"Context cleared": "コンテキストをクリアしました",
|
|
37
37
|
"Ctrl+C again to quit (or /exit)": "もう一度 Ctrl+C で終了 (または /exit)",
|
|
38
38
|
"Current language: {name}": "現在の言語: {name}",
|
package/src/locales/ko.mjs
CHANGED
|
@@ -176,7 +176,7 @@ export const KO = {
|
|
|
176
176
|
[`Specify a model: pleum run <model> ["prompt"] (model selection isn't available on non-interactive stdin)`]:
|
|
177
177
|
`모델을 지정하세요: pleum run <model> ["프롬프트"] (비대화형 stdin에선 모델 선택 불가)`,
|
|
178
178
|
'Usage: pleum run <model> ["prompt"]\nModel list: pleum models': '사용법: pleum run <model> ["프롬프트"]\n모델 목록: pleum models',
|
|
179
|
-
"Attaching and uploading {n} local image(s)
|
|
179
|
+
"Attaching and uploading {n} explicit local image(s).": "명시적으로 선택한 로컬 이미지 {n}개를 첨부해 업로드합니다.",
|
|
180
180
|
"Goodbye.": "안녕히 가세요.",
|
|
181
181
|
"Ctrl+C again to quit (or /exit)": "한 번 더 Ctrl+C 하면 종료 (/exit 도 가능)",
|
|
182
182
|
'/help for commands · /exit to quit · wrap multi-line input in """ · Ctrl+C to stop generation':
|
|
@@ -192,7 +192,7 @@ export const KO = {
|
|
|
192
192
|
/verbose toggle token/speed stats
|
|
193
193
|
/exit, /quit quit
|
|
194
194
|
Multi-line input: a line with """ → body → a line with """
|
|
195
|
-
Images:
|
|
195
|
+
Images: attach explicitly with @./shot.png (or --attach ./shot.png for one-shot commands)`]: `명령:
|
|
196
196
|
/help, /? 이 도움말
|
|
197
197
|
/clear 대화 컨텍스트 초기화(시스템 프롬프트는 유지)
|
|
198
198
|
/system [텍스트] 시스템 프롬프트 설정(인자 없으면 현재값 표시)
|
|
@@ -203,7 +203,7 @@ export const KO = {
|
|
|
203
203
|
/verbose 토큰·속도 통계 표시 토글
|
|
204
204
|
/exit, /quit 종료
|
|
205
205
|
여러 줄 입력: """ 한 줄 → 본문 → """ 한 줄
|
|
206
|
-
이미지:
|
|
206
|
+
이미지: @./shot.png로 명시 첨부(1회 실행은 --attach ./shot.png)`,
|
|
207
207
|
"Context cleared": "컨텍스트 초기화",
|
|
208
208
|
"System prompt set": "시스템 프롬프트 설정",
|
|
209
209
|
"Current system prompt:": "현재 시스템 프롬프트:",
|
package/src/locales/zh.mjs
CHANGED
|
@@ -12,7 +12,7 @@ export const ZH = {
|
|
|
12
12
|
"Account": "账户",
|
|
13
13
|
"All models — price and context included": "所有模型 — 含价格与上下文",
|
|
14
14
|
"Anthropic's coding agent — supports subagents": "Anthropic 的编程代理 — 支持子代理",
|
|
15
|
-
"Attaching and uploading {n} local image(s)
|
|
15
|
+
"Attaching and uploading {n} explicit local image(s).": "正在附加并上传明确选择的 {n} 张本地图片。",
|
|
16
16
|
"Auto-run:": "自动运行:",
|
|
17
17
|
"Autonomous dev agent": "自主开发代理",
|
|
18
18
|
"Multi-provider terminal coding agent": "多提供商终端编程代理",
|
|
@@ -32,7 +32,7 @@ export const ZH = {
|
|
|
32
32
|
"Choose a number, or search (Enter to cancel): ": "选择编号,或搜索(Enter 取消): ",
|
|
33
33
|
"Choose a shell": "选择 shell",
|
|
34
34
|
"Code": "代码",
|
|
35
|
-
"Commands:\n /help, /? this help\n /clear reset the conversation context (keeps the system prompt)\n /system [text] set the system prompt (no arg shows the current value)\n /model [id] switch models mid-session (no arg = arrow-key picker)\n /models [query] browse the model list\n /save [name] save the current session (default: last)\n /load [name] load a saved session\n /verbose toggle token/speed stats\n /exit, /quit quit\n Multi-line input: a line with \"\"\" → body → a line with \"\"\"\n Images:
|
|
35
|
+
"Commands:\n /help, /? this help\n /clear reset the conversation context (keeps the system prompt)\n /system [text] set the system prompt (no arg shows the current value)\n /model [id] switch models mid-session (no arg = arrow-key picker)\n /models [query] browse the model list\n /save [name] save the current session (default: last)\n /load [name] load a saved session\n /verbose toggle token/speed stats\n /exit, /quit quit\n Multi-line input: a line with \"\"\" → body → a line with \"\"\"\n Images: attach explicitly with @./shot.png (or --attach ./shot.png for one-shot commands)": "命令:\n /help, /? 本帮助\n /clear 重置对话上下文(保留系统提示词)\n /system [text] 设置系统提示词(不带参数则显示当前值)\n /model [id] 会话中途切换模型(不带参数 = 方向键选择器)\n /models [query] 浏览模型列表\n /save [name] 保存当前会话(默认:last)\n /load [name] 加载已保存的会话\n /verbose 切换 token/速度统计\n /exit, /quit 退出\n 多行输入:一行 \"\"\" → 正文 → 一行 \"\"\"\n 图片:使用 @./shot.png 显式附加(单次命令使用 --attach ./shot.png)",
|
|
36
36
|
"Context cleared": "上下文已清除",
|
|
37
37
|
"Ctrl+C again to quit (or /exit)": "再次按 Ctrl+C 退出(或 /exit)",
|
|
38
38
|
"Current language: {name}": "当前语言:{name}",
|
package/src/login.mjs
CHANGED
|
@@ -3,14 +3,24 @@ 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
|
|
|
@@ -56,7 +66,7 @@ export async function login() {
|
|
|
56
66
|
server.listen(0, "127.0.0.1", () => {
|
|
57
67
|
const { port } = server.address();
|
|
58
68
|
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`);
|
|
69
|
+
console.log(`${t("Log in from your browser. If it doesn't open automatically, open this URL:")}\n ${sanitizeTerminalText(authUrl)}\n`);
|
|
60
70
|
openBrowser(authUrl);
|
|
61
71
|
});
|
|
62
72
|
|
|
@@ -81,8 +91,8 @@ export async function loginDevice() {
|
|
|
81
91
|
const { device_code, user_code, verification_url, interval, expires_in } = await res.json();
|
|
82
92
|
|
|
83
93
|
console.log(
|
|
84
|
-
`\n ${t("Code")}: \x1b[1;32m${user_code}\x1b[0m\n` +
|
|
85
|
-
` URL: ${verification_url}\n\n` +
|
|
94
|
+
`\n ${t("Code")}: \x1b[1;32m${sanitizeTerminalText(user_code)}\x1b[0m\n` +
|
|
95
|
+
` URL: ${sanitizeTerminalText(verification_url)}\n\n` +
|
|
86
96
|
` ${t("Log in at the URL above, then enter the code (within {min} min).", { min: expires_in / 60 })}\n`
|
|
87
97
|
);
|
|
88
98
|
|
|
@@ -98,7 +108,7 @@ export async function loginDevice() {
|
|
|
98
108
|
if (!poll.ok) {
|
|
99
109
|
const e = await poll.json().catch(() => ({}));
|
|
100
110
|
if (e.detail === "expired_token") { console.error(t("The code has expired.")); process.exit(1); }
|
|
101
|
-
console.error(t("Error:"), e.detail ?? poll.status);
|
|
111
|
+
console.error(t("Error:"), sanitizeTerminalText(e.detail ?? poll.status));
|
|
102
112
|
process.exit(1);
|
|
103
113
|
}
|
|
104
114
|
const { key } = await poll.json();
|
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,16 +131,31 @@ 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
|
-
if (m.supports_thinking)
|
|
145
|
+
if (m.supports_thinking) {
|
|
146
|
+
facts.push([
|
|
147
|
+
"thinking",
|
|
148
|
+
m.thinking_forced
|
|
149
|
+
? t("forced (can't be turned off)")
|
|
150
|
+
: t("supported (reasoning_effort: minimal|none|low|medium|high|xhigh|max)"),
|
|
151
|
+
]);
|
|
152
|
+
}
|
|
153
|
+
if (Array.isArray(m.supported_parameters) && m.supported_parameters.includes("service_tier")) {
|
|
154
|
+
facts.push(["service_tier", "auto|default|flex|background|priority"]);
|
|
155
|
+
}
|
|
156
|
+
if (Array.isArray(m.supported_parameters) && m.supported_parameters.includes("reasoning_mode")) {
|
|
157
|
+
facts.push(["reasoning_mode", "standard|pro"]);
|
|
158
|
+
}
|
|
132
159
|
for (const [k, v] of facts) lines.push(`${styler.dim(padDisplay(k, 10))} ${v}`);
|
|
133
160
|
|
|
134
161
|
const u = m.usage;
|
|
@@ -147,19 +174,19 @@ export function renderModelInfo(m, styler = style) {
|
|
|
147
174
|
const [i1m, o1m] = fmtPrice(p);
|
|
148
175
|
lines.push(
|
|
149
176
|
[
|
|
150
|
-
styler.plum(padDisplay(p.provider, 12)),
|
|
177
|
+
styler.plum(padDisplay(safeModelText(p.provider), 12)),
|
|
151
178
|
padDisplay(i1m, 10),
|
|
152
179
|
padDisplay(o1m, 10),
|
|
153
180
|
padDisplay(fmtCtx(p.context_window), 5),
|
|
154
181
|
padDisplay(p.latency_ms != null ? `${Math.round(p.latency_ms)}ms` : "—", 8),
|
|
155
182
|
padDisplay(p.throughput_tps != null ? `${Math.round(p.throughput_tps)}/s` : "—", 6),
|
|
156
|
-
p.uptime_percent != null ? `${p.uptime_percent}%` : "—",
|
|
183
|
+
p.uptime_percent != null ? `${safeModelText(p.uptime_percent)}%` : "—",
|
|
157
184
|
].join(" "),
|
|
158
185
|
);
|
|
159
186
|
}
|
|
160
187
|
}
|
|
161
188
|
|
|
162
|
-
const sibs = (m.siblings || []).map((s) => s.id);
|
|
189
|
+
const sibs = (m.siblings || []).map((s) => safeModelText(s.id));
|
|
163
190
|
if (sibs.length) {
|
|
164
191
|
lines.push("");
|
|
165
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
|
`;
|
package/src/run.mjs
CHANGED
|
@@ -1,21 +1,57 @@
|
|
|
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
4
|
import { V1, requireKey, HISTORY_FILE, SESSIONS_DIR } from "./config.mjs";
|
|
5
|
-
import { fetchModels, formatTable, matchModels } from "./models.mjs";
|
|
6
|
-
import { style, divider } from "./ansi.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);
|
|
12
20
|
|
|
13
|
-
|
|
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;
|
|
39
|
+
|
|
40
|
+
// `pleum run [model] ["프롬프트"...] [--verbose] [--skip-cache] [--tag a,b]
|
|
41
|
+
// [--attach image] [--reasoning-effort LEVEL] [--service-tier TIER] [--reasoning-mode MODE]`
|
|
14
42
|
// 인자 파싱(순수 함수 — 테스트 대상).
|
|
43
|
+
const REASONING_EFFORTS = new Set(["minimal", "none", "low", "medium", "high", "xhigh", "max"]);
|
|
44
|
+
const SERVICE_TIERS = new Set(["auto", "default", "flex", "background", "priority"]);
|
|
45
|
+
const REASONING_MODES = new Set(["standard", "pro"]);
|
|
46
|
+
|
|
15
47
|
export function parseRunArgs(rest = []) {
|
|
16
48
|
let verbose = false;
|
|
17
49
|
let skipCache = false;
|
|
18
50
|
let tags = [];
|
|
51
|
+
let reasoningEffort = null;
|
|
52
|
+
let serviceTier = null;
|
|
53
|
+
let reasoningMode = null;
|
|
54
|
+
const attachments = [];
|
|
19
55
|
const pos = [];
|
|
20
56
|
for (let i = 0; i < rest.length; i++) {
|
|
21
57
|
const a = rest[i];
|
|
@@ -27,21 +63,101 @@ export function parseRunArgs(rest = []) {
|
|
|
27
63
|
tags = v.split(",").map((s) => s.trim()).filter(Boolean).slice(0, 10);
|
|
28
64
|
i++;
|
|
29
65
|
}
|
|
66
|
+
} else if (a === "--reasoning-effort") {
|
|
67
|
+
const v = rest[i + 1];
|
|
68
|
+
if (typeof v === "string" && REASONING_EFFORTS.has(v)) {
|
|
69
|
+
reasoningEffort = v;
|
|
70
|
+
i++;
|
|
71
|
+
}
|
|
72
|
+
} else if (a === "--service-tier") {
|
|
73
|
+
const v = rest[i + 1];
|
|
74
|
+
if (typeof v === "string" && SERVICE_TIERS.has(v)) {
|
|
75
|
+
serviceTier = v;
|
|
76
|
+
i++;
|
|
77
|
+
}
|
|
78
|
+
} else if (a === "--reasoning-mode") {
|
|
79
|
+
const v = rest[i + 1];
|
|
80
|
+
if (typeof v === "string" && REASONING_MODES.has(v)) {
|
|
81
|
+
reasoningMode = v;
|
|
82
|
+
i++;
|
|
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++;
|
|
30
89
|
} else pos.push(a);
|
|
31
90
|
}
|
|
32
|
-
return {
|
|
91
|
+
return {
|
|
92
|
+
model: pos[0],
|
|
93
|
+
prompt: pos.slice(1).join(" "),
|
|
94
|
+
verbose,
|
|
95
|
+
skipCache,
|
|
96
|
+
tags,
|
|
97
|
+
reasoningEffort,
|
|
98
|
+
serviceTier,
|
|
99
|
+
reasoningMode,
|
|
100
|
+
attachments,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function imageFormat(path) {
|
|
105
|
+
const ext = /\.([a-z0-9]+)$/i.exec(path)?.[1]?.toLowerCase();
|
|
106
|
+
return ext ? IMAGE_FORMATS[ext] : null;
|
|
33
107
|
}
|
|
34
108
|
|
|
35
|
-
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
+
}
|
|
40
152
|
const parts = [{ type: "text", text }];
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
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);
|
|
45
161
|
}
|
|
46
162
|
return parts;
|
|
47
163
|
}
|
|
@@ -51,17 +167,17 @@ export function formatStats({ cost, usage, ms, tokPerSec, verbose }) {
|
|
|
51
167
|
const bits = [];
|
|
52
168
|
if (cost != null && cost !== "") bits.push(`₩${fmtCost(cost)}`);
|
|
53
169
|
if (verbose && usage) {
|
|
54
|
-
bits.push(`↑${usage.prompt_tokens ?? "?"} ↓${usage.completion_tokens ?? "?"} tok`);
|
|
170
|
+
bits.push(`↑${sanitizeTerminalText(usage.prompt_tokens ?? "?")} ↓${sanitizeTerminalText(usage.completion_tokens ?? "?")} tok`);
|
|
55
171
|
if (tokPerSec) bits.push(`${tokPerSec.toFixed(1)} tok/s`);
|
|
56
172
|
}
|
|
57
|
-
if (verbose) bits.push(`${ms}ms`);
|
|
173
|
+
if (verbose) bits.push(`${sanitizeTerminalText(ms)}ms`);
|
|
58
174
|
return bits.length ? ` (${bits.join(" · ")})` : "";
|
|
59
175
|
}
|
|
60
176
|
|
|
61
177
|
// 비용 표시: 숫자면 소수 2자리까지(후행 0 제거), 문자열은 그대로.
|
|
62
178
|
function fmtCost(v) {
|
|
63
179
|
const n = Number(v);
|
|
64
|
-
return Number.isFinite(n) ? String(+n.toFixed(2)) :
|
|
180
|
+
return Number.isFinite(n) ? String(+n.toFixed(2)) : sanitizeTerminalText(v);
|
|
65
181
|
}
|
|
66
182
|
|
|
67
183
|
function isAbort(e) {
|
|
@@ -70,11 +186,22 @@ function isAbort(e) {
|
|
|
70
186
|
|
|
71
187
|
export async function run(rest) {
|
|
72
188
|
const args = Array.isArray(rest) ? rest : rest == null ? [] : [rest];
|
|
73
|
-
const {
|
|
189
|
+
const {
|
|
190
|
+
verbose,
|
|
191
|
+
prompt: inlinePrompt,
|
|
192
|
+
model: argModel,
|
|
193
|
+
skipCache,
|
|
194
|
+
tags,
|
|
195
|
+
reasoningEffort,
|
|
196
|
+
serviceTier,
|
|
197
|
+
reasoningMode,
|
|
198
|
+
attachments,
|
|
199
|
+
} = parseRunArgs(args);
|
|
74
200
|
let model = argModel;
|
|
75
201
|
const piped = await readStdin();
|
|
76
|
-
const oneShot = Boolean(inlinePrompt || piped);
|
|
202
|
+
const oneShot = Boolean(inlinePrompt || piped || attachments.length);
|
|
77
203
|
const key = requireKey();
|
|
204
|
+
const callOpts = { tags, skipCache, reasoningEffort, serviceTier, reasoningMode };
|
|
78
205
|
|
|
79
206
|
if (!model && !oneShot) {
|
|
80
207
|
if (!process.stdin.isTTY) {
|
|
@@ -92,26 +219,31 @@ export async function run(rest) {
|
|
|
92
219
|
|
|
93
220
|
if (oneShot) {
|
|
94
221
|
const prompt = [inlinePrompt, piped].filter(Boolean).join("\n\n");
|
|
95
|
-
const content = buildContent(prompt);
|
|
96
|
-
if (Array.isArray(content)) {
|
|
97
|
-
const n = content.filter((p) => p.type === "image_url").length;
|
|
98
|
-
// 프롬프트 내 로컬 이미지 경로를 자동 첨부·업송하므로 조용히 올리지 않고 명시 고지한다(M8).
|
|
99
|
-
console.error(`ℹ ${t("Attaching and uploading {n} local image(s) from the prompt.", { n })}`);
|
|
100
|
-
}
|
|
101
|
-
const messages = [{ role: "user", content }];
|
|
102
222
|
try {
|
|
103
|
-
|
|
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 }];
|
|
229
|
+
await streamChat(key, model, messages, { verbose, quiet: true, ...callOpts });
|
|
104
230
|
} catch (e) {
|
|
105
|
-
console.error(`${style.red("✗")} ${e.message}`);
|
|
231
|
+
console.error(`${style.red("✗")} ${sanitizeTerminalText(e.message)}`);
|
|
106
232
|
process.exit(1);
|
|
107
233
|
}
|
|
108
234
|
process.exit(0);
|
|
109
235
|
}
|
|
110
236
|
|
|
111
|
-
await repl(key, model, verbose,
|
|
237
|
+
await repl(key, model, verbose, callOpts);
|
|
112
238
|
}
|
|
113
239
|
|
|
114
|
-
async function repl(key, initialModel, initialVerbose, {
|
|
240
|
+
async function repl(key, initialModel, initialVerbose, {
|
|
241
|
+
tags = [],
|
|
242
|
+
skipCache = false,
|
|
243
|
+
reasoningEffort = null,
|
|
244
|
+
serviceTier = null,
|
|
245
|
+
reasoningMode = null,
|
|
246
|
+
} = {}) {
|
|
115
247
|
let model = initialModel;
|
|
116
248
|
let system = "";
|
|
117
249
|
let verbose = initialVerbose;
|
|
@@ -123,17 +255,28 @@ async function repl(key, initialModel, initialVerbose, { tags = [], skipCache =
|
|
|
123
255
|
let rl;
|
|
124
256
|
|
|
125
257
|
const send = async (text) => {
|
|
126
|
-
|
|
127
|
-
const req = system ? [{ role: "system", content: system }, ...messages] : messages;
|
|
128
|
-
const ac = new AbortController();
|
|
129
|
-
currentAbort = ac;
|
|
258
|
+
let added = false;
|
|
130
259
|
try {
|
|
131
|
-
const
|
|
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;
|
|
266
|
+
const reply = await streamChat(key, model, req, {
|
|
267
|
+
verbose,
|
|
268
|
+
signal: ac.signal,
|
|
269
|
+
tags,
|
|
270
|
+
skipCache,
|
|
271
|
+
reasoningEffort,
|
|
272
|
+
serviceTier,
|
|
273
|
+
reasoningMode,
|
|
274
|
+
});
|
|
132
275
|
if (reply) messages.push({ role: "assistant", content: reply });
|
|
133
276
|
else messages.pop(); // 응답 없음(즉시 중단 등) — user 턴 되돌림
|
|
134
277
|
} catch (e) {
|
|
135
|
-
console.error(`\n${style.red("✗")} ${e.message}`);
|
|
136
|
-
messages.pop(); // 실패한 user 턴 제거
|
|
278
|
+
console.error(`\n${style.red("✗")} ${sanitizeTerminalText(e.message)}`);
|
|
279
|
+
if (added) messages.pop(); // 실패한 user 턴 제거
|
|
137
280
|
} finally {
|
|
138
281
|
currentAbort = null;
|
|
139
282
|
}
|
|
@@ -251,7 +394,7 @@ async function repl(key, initialModel, initialVerbose, { tags = [], skipCache =
|
|
|
251
394
|
};
|
|
252
395
|
|
|
253
396
|
makeRl(loadHistory());
|
|
254
|
-
console.log(`${style.bold(style.plum("PleumRouter"))} ${style.dim("·")} ${model}`);
|
|
397
|
+
console.log(`${style.bold(style.plum("PleumRouter"))} ${style.dim("·")} ${safeModelText(model)}`);
|
|
255
398
|
console.log(style.dim(t('/help for commands · /exit to quit · wrap multi-line input in """ · Ctrl+C to stop generation')));
|
|
256
399
|
console.log(divider());
|
|
257
400
|
rl.prompt();
|
|
@@ -270,7 +413,7 @@ const SLASH_HELP = `Commands:
|
|
|
270
413
|
/verbose toggle token/speed stats
|
|
271
414
|
/exit, /quit quit
|
|
272
415
|
Multi-line input: a line with """ → body → a line with """
|
|
273
|
-
Images:
|
|
416
|
+
Images: attach explicitly with @./shot.png (or --attach ./shot.png for one-shot commands)`;
|
|
274
417
|
|
|
275
418
|
async function handleSlash(text, ctx) {
|
|
276
419
|
const [cmd] = text.slice(1).split(/\s+/);
|
|
@@ -295,17 +438,17 @@ async function handleSlash(text, ctx) {
|
|
|
295
438
|
case "model":
|
|
296
439
|
if (arg) {
|
|
297
440
|
ctx.model = arg;
|
|
298
|
-
console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(arg)}`);
|
|
441
|
+
console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(safeModelText(arg))}`);
|
|
299
442
|
} else if (ctx.pickModel) {
|
|
300
443
|
const picked = await ctx.pickModel();
|
|
301
444
|
if (picked) {
|
|
302
445
|
ctx.model = picked;
|
|
303
|
-
console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(picked)}`);
|
|
446
|
+
console.log(`${style.green("✓")} ${t("Switched model →")} ${style.plum(safeModelText(picked))}`);
|
|
304
447
|
} else {
|
|
305
|
-
console.log(`${t("Current model:")} ${style.plum(ctx.model)}`);
|
|
448
|
+
console.log(`${t("Current model:")} ${style.plum(safeModelText(ctx.model))}`);
|
|
306
449
|
}
|
|
307
450
|
} else {
|
|
308
|
-
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")}`);
|
|
309
452
|
}
|
|
310
453
|
break;
|
|
311
454
|
case "models":
|
|
@@ -314,7 +457,7 @@ async function handleSlash(text, ctx) {
|
|
|
314
457
|
const rows = matchModels(models, arg);
|
|
315
458
|
console.log(rows.length ? formatTable(rows) : t("No matching models"));
|
|
316
459
|
} catch (e) {
|
|
317
|
-
console.error(`${style.red("✗")} ${e.message}`);
|
|
460
|
+
console.error(`${style.red("✗")} ${sanitizeTerminalText(e.message)}`);
|
|
318
461
|
}
|
|
319
462
|
break;
|
|
320
463
|
case "save":
|
|
@@ -322,13 +465,13 @@ async function handleSlash(text, ctx) {
|
|
|
322
465
|
const name = saveSession(arg || "last", ctx.snapshot());
|
|
323
466
|
console.log(`${style.green("✓")} ${t("Session saved:")} ${name}`);
|
|
324
467
|
} catch (e) {
|
|
325
|
-
console.error(`${style.red("✗")} ${t("Save failed: {error}", { error: e.message })}`);
|
|
468
|
+
console.error(`${style.red("✗")} ${t("Save failed: {error}", { error: sanitizeTerminalText(e.message) })}`);
|
|
326
469
|
}
|
|
327
470
|
break;
|
|
328
471
|
case "load":
|
|
329
472
|
try {
|
|
330
473
|
ctx.restore(loadSession(arg || "last"));
|
|
331
|
-
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) })}`);
|
|
332
475
|
} catch {
|
|
333
476
|
console.error(`${style.red("✗")} ${t("No such session: {name}", { name: arg || "last" })}`);
|
|
334
477
|
}
|
|
@@ -344,9 +487,19 @@ async function handleSlash(text, ctx) {
|
|
|
344
487
|
|
|
345
488
|
// SSE 스트리밍 채팅. fetchImpl 주입 가능(테스트용 — 네트워크 없이 SSE 파싱 검증).
|
|
346
489
|
// 비용은 마지막 SSE 청크의 cost.krw — 스트리밍 응답엔 X-Cost-Krw 헤더가 없다(백엔드 계약).
|
|
347
|
-
export async function streamChat(key, model, messages, {
|
|
490
|
+
export async function streamChat(key, model, messages, {
|
|
491
|
+
verbose = false,
|
|
492
|
+
quiet = false,
|
|
493
|
+
signal,
|
|
494
|
+
tags = [],
|
|
495
|
+
skipCache = false,
|
|
496
|
+
reasoningEffort = null,
|
|
497
|
+
serviceTier = null,
|
|
498
|
+
reasoningMode = null,
|
|
499
|
+
fetchImpl = fetch,
|
|
500
|
+
} = {}) {
|
|
348
501
|
const t0 = Date.now();
|
|
349
|
-
const sp = spinner(t("{model} thinking…", { model }));
|
|
502
|
+
const sp = spinner(t("{model} thinking…", { model: sanitizeTerminalText(model) }));
|
|
350
503
|
let res;
|
|
351
504
|
try {
|
|
352
505
|
// 게이트웨이 패리티(0095): X-Pleum-Skip-Cache 헤더 + bounded tags 본문.
|
|
@@ -354,6 +507,9 @@ export async function streamChat(key, model, messages, { verbose = false, quiet
|
|
|
354
507
|
if (skipCache) headers["X-Pleum-Skip-Cache"] = "1";
|
|
355
508
|
const bodyObj = { model, messages, stream: true, stream_options: { include_usage: true } };
|
|
356
509
|
if (tags.length > 0) bodyObj.tags = tags.slice(0, 10);
|
|
510
|
+
if (reasoningEffort) bodyObj.reasoning_effort = reasoningEffort;
|
|
511
|
+
if (serviceTier) bodyObj.service_tier = serviceTier;
|
|
512
|
+
if (reasoningMode) bodyObj.reasoning_mode = reasoningMode;
|
|
357
513
|
res = await fetchImpl(`${V1}/chat/completions`, {
|
|
358
514
|
method: "POST",
|
|
359
515
|
headers,
|
|
@@ -371,7 +527,7 @@ export async function streamChat(key, model, messages, { verbose = false, quiet
|
|
|
371
527
|
if (!res.ok) {
|
|
372
528
|
sp.stop();
|
|
373
529
|
const body = await res.text().catch(() => "");
|
|
374
|
-
throw new Error(`${res.status} ${body.slice(0, 300)}`);
|
|
530
|
+
throw new Error(`${res.status} ${sanitizeTerminalText(body.slice(0, 300))}`);
|
|
375
531
|
}
|
|
376
532
|
|
|
377
533
|
const md = createMarkdownStream({ prefix: quiet ? "" : `${style.plum("bot")}${style.dim(">")} ` });
|
|
@@ -398,12 +554,14 @@ export async function streamChat(key, model, messages, { verbose = false, quiet
|
|
|
398
554
|
if (j.cost) cost = j.cost; // 마지막 청크 — {usd, krw, fx_rate, markup_rate}
|
|
399
555
|
const delta = j.choices?.[0]?.delta?.content;
|
|
400
556
|
if (delta) {
|
|
557
|
+
const safeDelta = sanitizeTerminalText(delta);
|
|
558
|
+
if (!safeDelta) continue;
|
|
401
559
|
if (!firstAt) {
|
|
402
560
|
firstAt = Date.now();
|
|
403
561
|
sp.stop();
|
|
404
562
|
}
|
|
405
|
-
md.push(
|
|
406
|
-
full +=
|
|
563
|
+
md.push(safeDelta);
|
|
564
|
+
full += safeDelta;
|
|
407
565
|
}
|
|
408
566
|
} catch {
|
|
409
567
|
// 부분 청크 — 무시
|
|
@@ -440,14 +598,14 @@ async function pickModel() {
|
|
|
440
598
|
try {
|
|
441
599
|
models = await fetchModels();
|
|
442
600
|
} catch (e) {
|
|
443
|
-
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) })}`);
|
|
444
602
|
return null;
|
|
445
603
|
}
|
|
446
604
|
if (!models.length) return null;
|
|
447
605
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
448
606
|
return select(models, {
|
|
449
607
|
message: t("Select a model"),
|
|
450
|
-
formatItem: (m) =>
|
|
608
|
+
formatItem: (m) => formatModelLabel(m),
|
|
451
609
|
matches: matchModels,
|
|
452
610
|
limit: 12,
|
|
453
611
|
}).then((m) => m?.id ?? null);
|
|
@@ -464,7 +622,7 @@ async function pickModelNumbered(models) {
|
|
|
464
622
|
for (;;) {
|
|
465
623
|
const shown = pool.slice(0, 30);
|
|
466
624
|
shown.forEach((m, i) =>
|
|
467
|
-
console.log(` ${String(i + 1).padStart(2)}. ${m
|
|
625
|
+
console.log(` ${String(i + 1).padStart(2)}. ${formatModelLabel(m, { dim: (s) => `(${s})` })}`),
|
|
468
626
|
);
|
|
469
627
|
if (pool.length > shown.length) console.log(` ${t("… {n} more — narrow with a search term", { n: pool.length - shown.length })}`);
|
|
470
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";
|
|
@@ -82,12 +82,12 @@ async function pickDefaultModelFor(item) {
|
|
|
82
82
|
try {
|
|
83
83
|
models = await fetchModels();
|
|
84
84
|
} catch (e) {
|
|
85
|
-
console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: e.message })}`);
|
|
85
|
+
console.log(`${style.red("✗")} ${t("Failed to fetch model list: {error}", { error: sanitizeTerminalText(e.message) })}`);
|
|
86
86
|
return undefined;
|
|
87
87
|
}
|
|
88
88
|
const picked = await select([CLEAR_OPTION, ...models], {
|
|
89
89
|
message: `${item.title} — ${t("select a model")}`,
|
|
90
|
-
formatItem: (x) => (x.isClear ? style.dim(t("Use default (clear)")) :
|
|
90
|
+
formatItem: (x) => (x.isClear ? style.dim(t("Use default (clear)")) : formatModelLabel(x)),
|
|
91
91
|
matches: matchModels, // 쿼리 입력 시 CLEAR_OPTION은 자연히 필터링됨(id/display_name 불일치) — 별도 처리 불요
|
|
92
92
|
limit: 12,
|
|
93
93
|
});
|