pleumrouter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # pleumrouter
2
+
3
+ PleumRouter 공식 터미널 CLI. 쓰던 코딩 에이전트를 한 줄로 PleumRouter에 붙여 실행한다.
4
+
5
+ ```bash
6
+ npx pleumrouter login # 브라우저 로그인 + 키 자동 저장
7
+ npx pleumrouter launch claude --model claude-sonnet-4
8
+ npx pleumrouter launch codex --model gpt-4.1
9
+ npx pleumrouter run gpt-4.1 # 터미널 채팅
10
+ ```
11
+
12
+ ## 명령
13
+
14
+ | 명령 | 설명 |
15
+ |------|------|
16
+ | `pleum login` | 브라우저로 로그인 → CLI가 API 키를 자동 발급·저장(`~/.pleum/config.json`) |
17
+ | `pleum logout` | 저장된 키 삭제 |
18
+ | `pleum launch <tool> [--model id] [-- args]` | 에이전트 실행. `--` 뒤 인자는 도구로 그대로 전달 |
19
+ | `pleum run <model>` | 스트리밍 채팅 REPL |
20
+ | `pleum models [query]` | 모델 목록(공개) |
21
+
22
+ ## 자동 실행 지원 도구
23
+
24
+ `claude` · `aider` · `codex` · `goose` · `openhands` — env(또는 격리된 `CODEX_HOME`)만
25
+ 세팅하고 바로 실행한다. 사용자의 기존 설정 파일은 건드리지 않는다.
26
+
27
+ `opencode` · `crush` 는 provider 정의가 파일에 필요해 설정 스니펫만 안내한다.
28
+
29
+ ## 환경변수
30
+
31
+ - `PLEUM_BASE_URL` — 게이트웨이 루트 덮어쓰기(기본 `https://router.pleum.ai`). 셀프호스트/스테이징용.
32
+
33
+ ## 개발
34
+
35
+ ```bash
36
+ npm test # node --test (의존성 0)
37
+ npm link # 로컬에서 pleum 명령 노출
38
+ ```
package/bin/pleum.mjs ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ import { login, loginDevice } from "../src/login.mjs";
3
+ import { launch } from "../src/launch.mjs";
4
+ import { run } from "../src/run.mjs";
5
+ import { listModels } from "../src/models.mjs";
6
+ import { balance } from "../src/balance.mjs";
7
+ import { clearConfig } from "../src/config.mjs";
8
+
9
+ const HELP = `pleum — PleumRouter 터미널 CLI
10
+
11
+ pleum login [--device] 브라우저로 로그인 + 키 자동 저장
12
+ --device: 브라우저 없는 환경(SSH·CI)
13
+ pleum logout 저장된 키 삭제
14
+ pleum launch <tool> [--model id] 코딩 에이전트를 PleumRouter로 실행
15
+ (claude · aider · codex · goose · openhands)
16
+ pleum run <model> 터미널 채팅
17
+ pleum models [query] 모델 목록
18
+ pleum balance 크레딧 잔액 조회
19
+
20
+ 예:
21
+ pleum launch claude --model claude-sonnet-4
22
+ pleum launch codex --model gpt-4.1 -- --full-auto
23
+ pleum run gpt-4.1
24
+ pleum login --device # SSH/CI 환경
25
+
26
+ base_url 덮어쓰기: PLEUM_BASE_URL 환경변수`;
27
+
28
+ // `--model id`/`-m id`를 뽑고, 나머지는 도구로 그대로 넘긴다. `--` 이후는 전부 패스스루.
29
+ function parseLaunchArgs(args) {
30
+ const out = { model: undefined, passthrough: [] };
31
+ for (let i = 0; i < args.length; i++) {
32
+ if (args[i] === "--model" || args[i] === "-m") out.model = args[++i];
33
+ else if (args[i] === "--") {
34
+ out.passthrough.push(...args.slice(i + 1));
35
+ break;
36
+ } else out.passthrough.push(args[i]);
37
+ }
38
+ return out;
39
+ }
40
+
41
+ const [cmd, ...rest] = process.argv.slice(2);
42
+
43
+ try {
44
+ switch (cmd) {
45
+ case "login":
46
+ if (rest.includes("--device")) await loginDevice();
47
+ else await login();
48
+ break;
49
+ case "logout":
50
+ clearConfig();
51
+ console.log("✓ 로그아웃됨");
52
+ break;
53
+ case "launch": {
54
+ const tool = rest[0];
55
+ if (!tool) {
56
+ console.error("사용법: pleum launch <tool> [--model id]");
57
+ process.exit(1);
58
+ }
59
+ const { model, passthrough } = parseLaunchArgs(rest.slice(1));
60
+ await launch(tool, model, passthrough);
61
+ break;
62
+ }
63
+ case "run":
64
+ await run(rest[0]);
65
+ break;
66
+ case "models":
67
+ await listModels(rest[0]);
68
+ break;
69
+ case "balance":
70
+ await balance();
71
+ break;
72
+ case undefined:
73
+ case "-h":
74
+ case "--help":
75
+ case "help":
76
+ console.log(HELP);
77
+ break;
78
+ default:
79
+ console.error(`알 수 없는 명령: ${cmd}\n\n${HELP}`);
80
+ process.exit(1);
81
+ }
82
+ } catch (e) {
83
+ console.error(`✗ ${e.message}`);
84
+ process.exit(1);
85
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "pleumrouter",
3
+ "version": "0.1.0",
4
+ "description": "Official PleumRouter terminal CLI — launch any coding agent against PleumRouter.",
5
+ "type": "module",
6
+ "bin": {
7
+ "pleum": "./bin/pleum.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test"
19
+ },
20
+ "license": "MIT",
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "keywords": ["pleum", "ai", "llm", "openai", "claude", "coding-agent", "cli"],
25
+ "homepage": "https://router.pleum.ai/docs/cli",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/gachon-star-want/PleumRouter.git",
29
+ "directory": "cli"
30
+ }
31
+ }
@@ -0,0 +1,18 @@
1
+ import { V1 } from "./config.mjs";
2
+ import { requireKey } from "./config.mjs";
3
+
4
+ export async function balance() {
5
+ const key = requireKey();
6
+ const res = await fetch(`${V1}/credits`, {
7
+ headers: { Authorization: `Bearer ${key}` },
8
+ });
9
+ if (!res.ok) {
10
+ const body = await res.text().catch(() => "");
11
+ console.error(`잔액 조회 실패 (HTTP ${res.status})${body ? `: ${body}` : ""}`);
12
+ process.exit(1);
13
+ }
14
+ const data = await res.json();
15
+ const plm = data.balance_krw;
16
+ const usd = data.balance_usd != null ? ` / $${data.balance_usd.toFixed(4)}` : "";
17
+ console.log(`잔액: ${plm.toLocaleString("ko-KR")} PLM${usd}`);
18
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,42 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
4
+
5
+ // 공개 게이트웨이 루트. 셀프호스트/스테이징은 PLEUM_BASE_URL로 덮어쓴다.
6
+ export const ROOT = (process.env.PLEUM_BASE_URL ?? "https://router.pleum.ai").replace(/\/+$/, "");
7
+ // OpenAI 호환 엔드포인트는 /v1. (Claude Code만 루트를 쓴다 — recipes 참고)
8
+ export const V1 = `${ROOT}/v1`;
9
+
10
+ const DIR = join(homedir(), ".pleum");
11
+ const FILE = join(DIR, "config.json");
12
+
13
+ export function readConfig() {
14
+ try {
15
+ return JSON.parse(readFileSync(FILE, "utf8"));
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ export function writeConfig(cfg) {
22
+ mkdirSync(DIR, { recursive: true, mode: 0o700 });
23
+ writeFileSync(FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
24
+ }
25
+
26
+ export function clearConfig() {
27
+ try {
28
+ rmSync(FILE);
29
+ } catch {
30
+ // 이미 없음 — 무시
31
+ }
32
+ }
33
+
34
+ // 키가 필요한 명령의 공통 가드. 없으면 안내 후 종료.
35
+ export function requireKey() {
36
+ const cfg = readConfig();
37
+ if (!cfg?.key) {
38
+ console.error("로그인이 필요합니다. 먼저 실행하세요:\n pleum login");
39
+ process.exit(1);
40
+ }
41
+ return cfg.key;
42
+ }
package/src/launch.mjs ADDED
@@ -0,0 +1,61 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { requireKey } from "./config.mjs";
6
+ import { RECIPES, MANUAL, codexToml } from "./recipes.mjs";
7
+
8
+ export async function launch(tool, model, passthrough) {
9
+ const key = requireKey();
10
+ const r = RECIPES[tool];
11
+
12
+ if (!r) {
13
+ if (MANUAL[tool]) {
14
+ printManual(tool, key);
15
+ return;
16
+ }
17
+ console.error(
18
+ `지원하지 않는 도구: ${tool}\n` +
19
+ ` 자동 실행: ${Object.keys(RECIPES).join(", ")}\n` +
20
+ ` 설정 안내: ${Object.keys(MANUAL).join(", ")}`,
21
+ );
22
+ process.exit(1);
23
+ }
24
+
25
+ const env = { ...process.env, ...r.env(key) };
26
+
27
+ // Codex는 격리 CODEX_HOME에 config.toml을 써서 사용자 ~/.codex를 보존한다.
28
+ if (r.type === "codex-home") {
29
+ const home = join(homedir(), ".pleum", "codex-home");
30
+ mkdirSync(home, { recursive: true });
31
+ writeFileSync(join(home, "config.toml"), codexToml(model), { mode: 0o600 });
32
+ env.CODEX_HOME = home;
33
+ }
34
+
35
+ const args = [...r.args(model), ...passthrough];
36
+ if (r.note) console.log(`ℹ ${r.note}`);
37
+ console.log(`▶ ${r.label} 실행: ${r.bin} ${args.join(" ")}\n`);
38
+
39
+ const child = spawn(r.bin, args, {
40
+ env,
41
+ stdio: "inherit",
42
+ shell: process.platform === "win32",
43
+ });
44
+ child.on("error", (e) => {
45
+ if (e.code === "ENOENT") {
46
+ console.error(`✗ '${r.bin}'를 찾을 수 없습니다. 설치:\n ${r.install}`);
47
+ process.exit(127);
48
+ }
49
+ console.error(`✗ ${e.message}`);
50
+ process.exit(1);
51
+ });
52
+ child.on("exit", (code) => process.exit(code ?? 0));
53
+ }
54
+
55
+ function printManual(tool, key) {
56
+ const m = MANUAL[tool];
57
+ console.log(`${m.label}는 provider 정의가 파일에 필요해 자동 실행 대신 설정만 안내합니다.\n`);
58
+ console.log(`1) ${m.file} 에 추가:\n`);
59
+ console.log(m.snippet());
60
+ console.log(`\n2) 키 export 후 실행:\n export PLEUM_API_KEY=${key}\n`);
61
+ }
package/src/login.mjs ADDED
@@ -0,0 +1,110 @@
1
+ import { createServer } from "node:http";
2
+ import { randomUUID } from "node:crypto";
3
+ import { spawn } from "node:child_process";
4
+ import { ROOT, V1, writeConfig } from "./config.mjs";
5
+
6
+ function openBrowser(url) {
7
+ const cmd =
8
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
9
+ spawn(cmd, [url], {
10
+ stdio: "ignore",
11
+ detached: true,
12
+ shell: process.platform === "win32",
13
+ }).unref();
14
+ }
15
+
16
+ // 브라우저 핸드오프: 로컬 콜백 서버를 열고 대시보드 /cli-auth로 보낸다.
17
+ // 프론트가 로그인 확인 후 키를 발급해 127.0.0.1:PORT/cb 로 POST 한다(URL에 키 미노출).
18
+ export async function login() {
19
+ const state = randomUUID();
20
+
21
+ const key = await new Promise((resolve, reject) => {
22
+ const server = createServer((req, res) => {
23
+ // 로컬 콜백만 받으므로 CORS는 전부 허용(브라우저→127.0.0.1 cross-origin POST).
24
+ res.setHeader("Access-Control-Allow-Origin", "*");
25
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
26
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
27
+ if (req.method === "OPTIONS") {
28
+ res.writeHead(204).end();
29
+ return;
30
+ }
31
+ const url = new URL(req.url, "http://127.0.0.1");
32
+ if (url.pathname !== "/cb" || req.method !== "POST") {
33
+ res.writeHead(404).end();
34
+ return;
35
+ }
36
+ let body = "";
37
+ req.on("data", (c) => (body += c));
38
+ req.on("end", () => {
39
+ let payload = {};
40
+ try {
41
+ payload = JSON.parse(body);
42
+ } catch {
43
+ // 잘못된 본문 — 아래 검증에서 걸러짐
44
+ }
45
+ const ok = payload.state === state && typeof payload.key === "string" && payload.key;
46
+ res.writeHead(ok ? 200 : 400, { "Content-Type": "application/json" });
47
+ res.end(JSON.stringify({ ok: Boolean(ok) }));
48
+ server.close();
49
+ if (ok) resolve(payload.key);
50
+ else reject(new Error("state 불일치 또는 키 누락"));
51
+ });
52
+ });
53
+
54
+ server.on("error", reject);
55
+ server.listen(0, "127.0.0.1", () => {
56
+ const { port } = server.address();
57
+ const authUrl = `${ROOT}/cli-auth?port=${port}&state=${state}`;
58
+ console.log("브라우저에서 로그인하세요. 자동으로 안 열리면 아래 URL을 여세요:\n " + authUrl + "\n");
59
+ openBrowser(authUrl);
60
+ });
61
+
62
+ setTimeout(() => {
63
+ server.close();
64
+ reject(new Error("로그인 시간 초과(2분)"));
65
+ }, 120_000).unref();
66
+ });
67
+
68
+ writeConfig({ key, base_url: ROOT });
69
+ console.log("✓ 로그인 완료. 키를 ~/.pleum/config.json 에 저장했습니다.");
70
+ }
71
+
72
+ // device-code 로그인 — 브라우저 없는 환경(SSH·CI).
73
+ // 1) code 발급 → 2) 사용자가 /device에서 확인 → 3) CLI가 폴링으로 키 수신.
74
+ export async function loginDevice() {
75
+ const res = await fetch(`${V1}/auth/device/code`, { method: "POST" });
76
+ if (!res.ok) {
77
+ console.error("device code 발급 실패 (서버 응답 " + res.status + ")");
78
+ process.exit(1);
79
+ }
80
+ const { device_code, user_code, verification_url, interval, expires_in } = await res.json();
81
+
82
+ console.log(
83
+ `\n 코드: \x1b[1;32m${user_code}\x1b[0m\n` +
84
+ ` URL : ${verification_url}\n\n` +
85
+ ` 위 URL에서 로그인 후 코드를 입력하세요 (${expires_in / 60}분 내).\n`
86
+ );
87
+
88
+ const deadline = Date.now() + expires_in * 1000;
89
+ while (Date.now() < deadline) {
90
+ await new Promise((r) => setTimeout(r, interval * 1000));
91
+ const poll = await fetch(`${V1}/auth/device/token`, {
92
+ method: "POST",
93
+ headers: { "Content-Type": "application/json" },
94
+ body: JSON.stringify({ device_code }),
95
+ });
96
+ if (poll.status === 202) continue; // authorization_pending
97
+ if (!poll.ok) {
98
+ const e = await poll.json().catch(() => ({}));
99
+ if (e.detail === "expired_token") { console.error("코드가 만료되었습니다."); process.exit(1); }
100
+ console.error("오류:", e.detail ?? poll.status);
101
+ process.exit(1);
102
+ }
103
+ const { key } = await poll.json();
104
+ writeConfig({ key, base_url: ROOT });
105
+ console.log("✓ 로그인 완료. 키를 ~/.pleum/config.json 에 저장했습니다.");
106
+ return;
107
+ }
108
+ console.error("시간 초과: 코드가 만료되었습니다.");
109
+ process.exit(1);
110
+ }
package/src/models.mjs ADDED
@@ -0,0 +1,26 @@
1
+ import { V1 } from "./config.mjs";
2
+
3
+ // GET /v1/models 는 공개(인증 불필요). 슬러그는 provider/model 형식.
4
+ export async function fetchModels() {
5
+ const res = await fetch(`${V1}/models`);
6
+ if (!res.ok) throw new Error(`모델 목록 조회 실패 (${res.status})`);
7
+ const data = await res.json();
8
+ return data.data ?? [];
9
+ }
10
+
11
+ export async function listModels(query) {
12
+ const models = await fetchModels();
13
+ const q = (query || "").toLowerCase();
14
+ const rows = models.filter(
15
+ (m) =>
16
+ !q ||
17
+ m.id.toLowerCase().includes(q) ||
18
+ (m.display_name || "").toLowerCase().includes(q),
19
+ );
20
+ for (const m of rows) {
21
+ const parts = [m.id, m.modality || "text"];
22
+ if (m.display_name) parts.push(m.display_name);
23
+ console.log(parts.join("\t"));
24
+ }
25
+ console.log(`\n${rows.length} models`);
26
+ }
@@ -0,0 +1,103 @@
1
+ import { ROOT, V1 } from "./config.mjs";
2
+
3
+ // 도구별 설정의 진실 원천. 출처: frontend .../docs/cookbook/agent-integration.
4
+ // type "env" → 환경변수만 세팅하고 바로 실행.
5
+ // type "codex-home" → 격리된 CODEX_HOME에 config.toml을 써서 실행(사용자 ~/.codex 보존).
6
+ export const RECIPES = {
7
+ claude: {
8
+ label: "Claude Code",
9
+ bin: "claude",
10
+ type: "env",
11
+ install: "npm i -g @anthropic-ai/claude-code",
12
+ // 함정: ANTHROPIC_BASE_URL은 반드시 루트(/v1 없이) — CLI가 /v1/messages를 스스로 덧붙인다.
13
+ // /v1을 넣으면 /v1/v1/messages가 되어 실패.
14
+ env: (key) => ({
15
+ ANTHROPIC_BASE_URL: ROOT,
16
+ ANTHROPIC_API_KEY: key,
17
+ ANTHROPIC_AUTH_TOKEN: key,
18
+ }),
19
+ args: (m) => (m ? ["--model", `anthropic/${m}`] : []),
20
+ },
21
+ aider: {
22
+ label: "Aider",
23
+ bin: "aider",
24
+ type: "env",
25
+ install: "python -m pip install aider-install && aider-install",
26
+ env: (key) => ({ OPENAI_API_BASE: V1, OPENAI_API_KEY: key }),
27
+ args: (m) => (m ? ["--model", `openai/${m}`] : []),
28
+ },
29
+ goose: {
30
+ label: "Goose",
31
+ bin: "goose",
32
+ type: "env",
33
+ install: "https://block.github.io/goose/docs/getting-started/installation",
34
+ note: "Goose는 모델을 설정에서 지정합니다 — `goose configure`에서 openai/<id> 형식 사용.",
35
+ env: (key) => ({ OPENAI_API_BASE: V1, OPENAI_API_KEY: key }),
36
+ args: () => [],
37
+ },
38
+ openhands: {
39
+ label: "OpenHands",
40
+ bin: "openhands",
41
+ type: "env",
42
+ install: "python -m pip install openhands-ai",
43
+ note: "OpenHands는 모델명 앞에 openai/ 접두사를 붙여 설정하세요.",
44
+ env: (key) => ({ OPENAI_API_BASE: V1, OPENAI_API_KEY: key }),
45
+ args: () => [],
46
+ },
47
+ codex: {
48
+ label: "Codex CLI",
49
+ bin: "codex",
50
+ type: "codex-home",
51
+ install: "npm i -g @openai/codex",
52
+ env: (key) => ({ PLEUM_API_KEY: key }),
53
+ args: () => [],
54
+ },
55
+ };
56
+
57
+ // Codex는 Chat Completions가 제거돼 Responses API(wire_api="responses")만 지원.
58
+ // 사용자의 ~/.codex/config.toml을 건드리지 않도록 격리 CODEX_HOME에 이 파일을 쓴다.
59
+ export function codexToml(model) {
60
+ const m = model || "gpt-4.1";
61
+ return `# Generated by pleum CLI. Isolated CODEX_HOME — your ~/.codex is untouched.
62
+ model = "${m}"
63
+ model_provider = "pleum"
64
+
65
+ [model_providers.pleum]
66
+ name = "PleumRouter"
67
+ base_url = "${V1}"
68
+ env_key = "PLEUM_API_KEY"
69
+ wire_api = "responses"
70
+ `;
71
+ }
72
+
73
+ // provider 정의가 파일에 있어야 하는 도구 — 자동 실행 대신 설정 스니펫만 안내.
74
+ export const MANUAL = {
75
+ opencode: {
76
+ label: "OpenCode",
77
+ file: "opencode.json",
78
+ snippet: () => `{
79
+ "provider": {
80
+ "pleum": {
81
+ "npm": "@ai-sdk/openai-compatible",
82
+ "name": "PleumRouter",
83
+ "options": { "baseURL": "${V1}", "apiKey": "{env:PLEUM_API_KEY}" },
84
+ "models": { "gpt-4.1": {} }
85
+ }
86
+ }
87
+ }`,
88
+ },
89
+ crush: {
90
+ label: "Crush",
91
+ file: "crush.json",
92
+ snippet: () => `{
93
+ "providers": {
94
+ "pleum": {
95
+ "type": "openai-compat",
96
+ "base_url": "${V1}",
97
+ "api_key": "$PLEUM_API_KEY",
98
+ "models": [{ "id": "gpt-4.1", "name": "gpt-4.1" }]
99
+ }
100
+ }
101
+ }`,
102
+ },
103
+ };
package/src/run.mjs ADDED
@@ -0,0 +1,83 @@
1
+ import { createInterface } from "node:readline";
2
+ import { V1, requireKey } from "./config.mjs";
3
+
4
+ // 터미널 채팅 REPL — /v1/chat/completions 스트리밍을 토큰 단위로 출력.
5
+ export async function run(model) {
6
+ if (!model) {
7
+ console.error("사용법: pleum run <model>\n모델 목록: pleum models");
8
+ process.exit(1);
9
+ }
10
+ const key = requireKey();
11
+ const messages = [];
12
+ const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: "you> " });
13
+
14
+ console.log(`PleumRouter · ${model} · /exit 로 종료\n`);
15
+ rl.prompt();
16
+
17
+ rl.on("line", async (line) => {
18
+ const text = line.trim();
19
+ if (text === "/exit" || text === "/quit") {
20
+ rl.close();
21
+ return;
22
+ }
23
+ if (!text) {
24
+ rl.prompt();
25
+ return;
26
+ }
27
+ messages.push({ role: "user", content: text });
28
+ try {
29
+ const reply = await streamChat(key, model, messages);
30
+ messages.push({ role: "assistant", content: reply });
31
+ } catch (e) {
32
+ console.error(`\n✗ ${e.message}`);
33
+ messages.pop(); // 실패한 user 턴은 히스토리에서 제거
34
+ }
35
+ rl.prompt();
36
+ });
37
+
38
+ rl.on("close", () => {
39
+ console.log("\n안녕히 가세요.");
40
+ process.exit(0);
41
+ });
42
+ }
43
+
44
+ async function streamChat(key, model, messages) {
45
+ const res = await fetch(`${V1}/chat/completions`, {
46
+ method: "POST",
47
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
48
+ body: JSON.stringify({ model, messages, stream: true }),
49
+ });
50
+ if (!res.ok) {
51
+ const body = await res.text().catch(() => "");
52
+ throw new Error(`${res.status} ${body.slice(0, 300)}`);
53
+ }
54
+
55
+ process.stdout.write("bot> ");
56
+ let full = "";
57
+ let buf = "";
58
+ const decoder = new TextDecoder();
59
+ for await (const chunk of res.body) {
60
+ buf += decoder.decode(chunk, { stream: true });
61
+ const lines = buf.split("\n");
62
+ buf = lines.pop() ?? "";
63
+ for (const l of lines) {
64
+ const s = l.trim();
65
+ if (!s.startsWith("data:")) continue;
66
+ const data = s.slice(5).trim();
67
+ if (data === "[DONE]") continue;
68
+ try {
69
+ const delta = JSON.parse(data).choices?.[0]?.delta?.content;
70
+ if (delta) {
71
+ process.stdout.write(delta);
72
+ full += delta;
73
+ }
74
+ } catch {
75
+ // 부분 청크 — 무시
76
+ }
77
+ }
78
+ }
79
+ process.stdout.write("\n");
80
+ const cost = res.headers.get("x-cost-krw");
81
+ if (cost) console.log(` (₩${cost})`);
82
+ return full;
83
+ }