@vfvrpq/llm-cli 1.1.0 → 1.2.1

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/lib/args.js ADDED
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ // 命令行参数解析与帮助生成(按入口的 features 开关生成对应选项)。
3
+ const { ApiError } = require("./errors");
4
+
5
+ const COMMON_ALIASES = {
6
+ "-m": "model", "--model": "model",
7
+ "--api-key": "api_key",
8
+ "--base-url": "base_url",
9
+ "--system": "system",
10
+ "-t": "temperature", "--temperature": "temperature",
11
+ "--max-tokens": "max_tokens",
12
+ "-f": "file", "--file": "file",
13
+ "-o": "output", "--output": "output",
14
+ "--resume": "resume",
15
+ };
16
+
17
+ function parseArgs(argv, features) {
18
+ const aliases = { ...COMMON_ALIASES };
19
+ if (features.provider) Object.assign(aliases, { "-p": "provider", "--provider": "provider" });
20
+ if (features.thinking) Object.assign(aliases, { "--thinking": "thinking" });
21
+
22
+ const opts = { _: [] };
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const a = argv[i];
25
+ if (a === "-h" || a === "--help") { opts.help = true; continue; }
26
+ if (a === "-V" || a === "--version") { opts.version = true; continue; }
27
+ if (a === "--no-stream") { opts.no_stream = true; continue; }
28
+ if (a === "--no-color") { opts.no_color = true; continue; }
29
+ if (a === "--debug") { opts.debug = true; continue; }
30
+ const key = aliases[a];
31
+ if (!key) {
32
+ if (a.startsWith("-")) throw new ApiError(`未知参数: ${a}`);
33
+ opts._.push(a);
34
+ continue;
35
+ }
36
+ const val = argv[++i];
37
+ if (val === undefined) throw new ApiError(`参数 ${a} 缺少值`);
38
+ if (key === "file") (opts.file = opts.file || []).push(val);
39
+ else opts[key] = val;
40
+ }
41
+ return opts;
42
+ }
43
+
44
+ function printHelp(entry) {
45
+ const f = entry.features;
46
+ const registry = entry.registry;
47
+ let providersBlock = "";
48
+ if (f.provider) {
49
+ const rows = Object.entries(registry)
50
+ .map(([id, pc]) => ` ${id.padEnd(13)} ${pc.label.padEnd(18)} ${pc.defaultModel || "(需 -m)"}`)
51
+ .join("\n");
52
+ providersBlock = `
53
+ 供应商 (-p/--provider,默认 ${Object.keys(registry)[0]},也可按模型名前缀自动推断):
54
+ ${rows}
55
+ `;
56
+ } else {
57
+ const pc = registry[entry.fixedProvider];
58
+ providersBlock = `
59
+ 供应商: ${entry.fixedProvider} (${pc.label}),默认端点 ${pc.defaultBase},默认模型 ${pc.defaultModel || "(需 -m)"}
60
+ `;
61
+ }
62
+
63
+ const options = [
64
+ " -m, --model <名称> 模型名(无默认模型的供应商必须指定)",
65
+ f.provider ? " -p, --provider <名称> 供应商" : null,
66
+ " --api-key <Key> 本次使用的 API Key(优先级最高)",
67
+ " --base-url <地址> 接口地址",
68
+ " --system <文本> system 提示词",
69
+ " -t, --temperature <值> 采样温度",
70
+ " --max-tokens <数量> 最大输出 token 数",
71
+ f.thinking ? " --thinking <on|off> 深度思考开关(仅智谱 GLM 生效)" : null,
72
+ " --no-stream 关闭流式输出",
73
+ " --no-color / --debug 关闭彩色 / 调试输出",
74
+ ].filter(Boolean).join("\n");
75
+
76
+ console.log(`${entry.name} ${entry.version} —— ${entry.tagline}
77
+
78
+ 用法: node ${entry.file} <command> [参数] [选项]
79
+
80
+ 命令:
81
+ ask 单次提问: node ${entry.file} ask "问题"
82
+ chat 多轮交互对话${f.provider ? "(支持 /provider /model /system /save)" : "(支持 /model /system /save)"}
83
+ models 列出当前账号可用的模型
84
+ config 管理本地配置: set/get/del${f.list ? "/list" : ""}/path
85
+ ${providersBlock}
86
+ 选项:
87
+ ${options}
88
+
89
+ 示例:
90
+ ${entry.examples.map((e) => ` ${e}`).join("\n")}`);
91
+ }
92
+
93
+ module.exports = { parseArgs, printHelp };
package/lib/chat.js ADDED
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ // 对话调用:payload 构建(thinking 门控)、流式/非流式执行与实时渲染。
3
+ const { ApiError } = require("./errors");
4
+ const { colors } = require("./colors");
5
+ const { STREAM_IDLE_MS, PLAIN_MS, TIMEOUT_REASON, isAbort, apiFetch, iterSSE } = require("./http");
6
+
7
+ const state = { currentAbort: null };
8
+
9
+ function buildPayload(rt, messages, args, ctx) {
10
+ const payload = { model: rt.model, messages, stream: !args.no_stream };
11
+ if (args.temperature != null) payload.temperature = Number(args.temperature);
12
+ if (args.max_tokens) payload.max_tokens = Number(args.max_tokens);
13
+ if (args.thinking != null) {
14
+ if (rt.pc.supportsThinking) {
15
+ payload.thinking = { type: args.thinking === "on" ? "enabled" : "disabled" };
16
+ } else {
17
+ console.error(`${colors.dim}(提示: --thinking 仅对智谱 GLM 生效,已忽略)${colors.reset}`);
18
+ }
19
+ }
20
+ if (args.debug) {
21
+ console.error(`[debug] ${rt.baseUrl.replace(/\/+$/, "")}/chat/completions`);
22
+ console.error(`[debug] ${JSON.stringify(payload)}`);
23
+ }
24
+ return payload;
25
+ }
26
+
27
+ async function chatCompletion(rt, payload) {
28
+ const url = rt.baseUrl.replace(/\/+$/, "") + "/chat/completions";
29
+ const stream = !!payload.stream;
30
+ const controller = new AbortController();
31
+ let timer = null;
32
+ const arm = () => {
33
+ clearTimeout(timer);
34
+ timer = setTimeout(() => controller.abort(TIMEOUT_REASON), stream ? STREAM_IDLE_MS : PLAIN_MS);
35
+ };
36
+ arm();
37
+ state.currentAbort = controller;
38
+ const contentParts = [];
39
+ const reasoningParts = [];
40
+ let usage = null;
41
+ try {
42
+ const resp = await apiFetch(url, rt.apiKey, payload, "POST", controller.signal, rt.hints);
43
+ if (stream) {
44
+ let inThinking = false;
45
+ for await (const data of iterSSE(resp.body)) {
46
+ arm();
47
+ let obj;
48
+ try { obj = JSON.parse(data); } catch { continue; }
49
+ if (obj.error) throw new ApiError("服务端返回错误:" + JSON.stringify(obj.error));
50
+ if (obj.usage) usage = obj.usage;
51
+ const choice = (obj.choices || [])[0];
52
+ if (!choice) continue;
53
+ const delta = choice.delta || {};
54
+ const rc = delta.reasoning_content;
55
+ if (rc) {
56
+ if (!inThinking) {
57
+ inThinking = true;
58
+ console.log(`${colors.dim}—— 思考 ——${colors.reset}`);
59
+ }
60
+ process.stdout.write(colors.dim + rc + colors.reset);
61
+ reasoningParts.push(rc);
62
+ }
63
+ const text = delta.content;
64
+ if (text) {
65
+ if (inThinking) {
66
+ inThinking = false;
67
+ console.log(`\n${colors.bold}—— 回答 ——${colors.reset}`);
68
+ }
69
+ process.stdout.write(text);
70
+ contentParts.push(text);
71
+ }
72
+ }
73
+ console.log();
74
+ } else {
75
+ const obj = await resp.json();
76
+ if (obj.error) throw new ApiError("服务端返回错误:" + JSON.stringify(obj.error));
77
+ const message = ((obj.choices || [])[0] || {}).message || {};
78
+ if (message.reasoning_content) {
79
+ console.log(`${colors.dim}—— 思考 ——\n${message.reasoning_content}${colors.reset}`);
80
+ reasoningParts.push(message.reasoning_content);
81
+ }
82
+ const content = message.content || "";
83
+ console.log(content);
84
+ contentParts.push(content);
85
+ usage = obj.usage || null;
86
+ }
87
+ } catch (err) {
88
+ if (isAbort(err)) {
89
+ if (controller.signal.reason === TIMEOUT_REASON) {
90
+ console.log(`\n${colors.dim}(等待数据超时,已中断)${colors.reset}`);
91
+ } else {
92
+ console.log(`\n${colors.dim}(已中断本次回复)${colors.reset}`);
93
+ }
94
+ } else {
95
+ throw err;
96
+ }
97
+ } finally {
98
+ clearTimeout(timer);
99
+ state.currentAbort = null;
100
+ }
101
+ return { content: contentParts.join(""), reasoning: reasoningParts.join(""), usage };
102
+ }
103
+
104
+ function printUsageLine(usage) {
105
+ if (usage && usage.prompt_tokens != null && usage.completion_tokens != null) {
106
+ console.log(`${colors.dim}[tokens] 输入 ${usage.prompt_tokens} · 输出 ${usage.completion_tokens}${colors.reset}`);
107
+ }
108
+ }
109
+
110
+ module.exports = { state, buildPayload, chatCompletion, printUsageLine };
package/lib/colors.js ADDED
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ // 终端颜色输出:仅在 TTY 且未显式禁用时启用 ANSI。
3
+ const colors = { dim: "", bold: "", red: "", green: "", cyan: "", reset: "" };
4
+
5
+ function enableColors(noColor) {
6
+ if (noColor || process.env.NO_COLOR || !process.stdout.isTTY) return;
7
+ colors.dim = "\x1b[2m";
8
+ colors.bold = "\x1b[1m";
9
+ colors.red = "\x1b[31m";
10
+ colors.green = "\x1b[32m";
11
+ colors.cyan = "\x1b[36m";
12
+ colors.reset = "\x1b[0m";
13
+ }
14
+
15
+ module.exports = { colors, enableColors };
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ // ask 子命令:单次提问(位置参数 / 管道 / 附带文件 / 保存回答)。
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { ApiError } = require("../errors");
6
+ const { colors } = require("../colors");
7
+ const { resolveRuntime, requireKey } = require("../registry");
8
+ const { buildPayload, chatCompletion, printUsageLine } = require("../chat");
9
+
10
+ function readQuestionFromFiles(files) {
11
+ return files.map((fp) => {
12
+ let text;
13
+ try {
14
+ text = fs.readFileSync(fp, "utf8");
15
+ } catch (e) {
16
+ throw new ApiError(`无法读取文件 ${fp}:${e.message}`);
17
+ }
18
+ return `文件 \`${path.basename(fp)}\` 内容:\n\`\`\`\n${text}\n\`\`\``;
19
+ });
20
+ }
21
+
22
+ async function ask(args, cfg, ctx) {
23
+ const rt = resolveRuntime(args, cfg, ctx);
24
+ requireKey(rt.apiKey, rt);
25
+
26
+ let question = args._.join(" ").trim();
27
+ if (!question && !process.stdin.isTTY) {
28
+ try { question = fs.readFileSync(0, "utf8").trim(); } catch {}
29
+ }
30
+ if (args.file && args.file.length) {
31
+ question = (question ? question + "\n\n" : "") + readQuestionFromFiles(args.file).join("\n\n");
32
+ }
33
+ if (!question) {
34
+ throw new ApiError(`请提供问题内容,例如:node ${ctx.entry.file} ask "你好"(或用管道传入;交互式多轮请用 chat 子命令)`);
35
+ }
36
+
37
+ const messages = [];
38
+ if (args.system) messages.push({ role: "system", content: args.system });
39
+ messages.push({ role: "user", content: question });
40
+
41
+ const { content, usage } = await chatCompletion(rt, buildPayload(rt, messages, args, ctx));
42
+ if (args.output) {
43
+ fs.mkdirSync(path.dirname(path.resolve(args.output)), { recursive: true });
44
+ fs.writeFileSync(args.output, content, "utf8");
45
+ console.log(`${colors.dim}回答已保存到 ${args.output}${colors.reset}`);
46
+ }
47
+ printUsageLine(usage);
48
+ }
49
+
50
+ module.exports = ask;
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ // chat 子命令:多轮交互对话(REPL + 斜杠命令 + Ctrl+C 中断单次回复)。
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const readline = require("node:readline");
6
+ const { ApiError } = require("../errors");
7
+ const { colors } = require("../colors");
8
+ const { resolveRuntime, requireKey, normalizeProvider } = require("../registry");
9
+ const { buildPayload, chatCompletion, printUsageLine, state } = require("../chat");
10
+
11
+ const SLASH_HELP = [
12
+ "命令:",
13
+ " /help 显示本帮助",
14
+ " /new 清空当前对话,重新开始",
15
+ " /provider <名称> 临时切换供应商",
16
+ " /model <名称> 临时切换模型",
17
+ " /system <文本> 设置/更新 system 提示词(不带文本则清除)",
18
+ " /save [路径] 保存当前对话记录为 JSON",
19
+ " /exit 退出(或 Ctrl+C / Ctrl+D)",
20
+ ].join("\n");
21
+
22
+ async function chat(args, cfg, ctx) {
23
+ const rt = resolveRuntime(args, cfg, ctx);
24
+ requireKey(rt.apiKey, rt);
25
+
26
+ let messages = args.system ? [{ role: "system", content: args.system }] : [];
27
+ if (args.resume) {
28
+ let data;
29
+ try {
30
+ data = JSON.parse(fs.readFileSync(args.resume, "utf8"));
31
+ } catch (e) {
32
+ throw new ApiError(`无法读取对话记录 ${args.resume}:${e.message}`);
33
+ }
34
+ messages = data.messages || messages;
35
+ if (data.model) rt.model = data.model;
36
+ }
37
+
38
+ console.log(`${colors.cyan}${ctx.entry.name} ${ctx.entry.version} · ${rt.provider}(${rt.label}) · 模型 ${rt.model} · ${rt.baseUrl}${colors.reset}`);
39
+ console.log(`${colors.dim}输入消息开始对话,/help 查看命令,/exit 退出。${colors.reset}`);
40
+
41
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
42
+ rl.setPrompt(`${colors.green}你${colors.reset} > `);
43
+ rl.prompt();
44
+
45
+ // 输入流结束(EOF)会自动关闭 readline,此时再 prompt 会抛 ERR_USE_AFTER_CLOSE
46
+ let rlClosed = false;
47
+ rl.on("close", () => { rlClosed = true; });
48
+ const safePrompt = () => { if (!rlClosed) rl.prompt(); };
49
+
50
+ // Ctrl+C:正在生成时中断本次回复,空闲时退出
51
+ rl.on("SIGINT", () => {
52
+ if (state.currentAbort) state.currentAbort.abort();
53
+ else rl.close();
54
+ });
55
+
56
+ try {
57
+ for await (const line of rl) {
58
+ const text = line.trim();
59
+ if (!text) { safePrompt(); continue; }
60
+
61
+ if (text.startsWith("/")) {
62
+ const cmd = text.split(/\s+/)[0].toLowerCase();
63
+ const rest = text.slice(cmd.length).trim();
64
+ if (["/exit", "/quit", "/q"].includes(cmd)) break;
65
+ else if (cmd === "/help") console.log(SLASH_HELP);
66
+ else if (cmd === "/new") {
67
+ messages = messages.filter((m) => m.role === "system");
68
+ console.log(`${colors.dim}已清空对话。${colors.reset}`);
69
+ } else if (cmd === "/provider") {
70
+ if (!ctx.features.provider) {
71
+ console.log(`${colors.dim}当前入口不支持切换供应商。${colors.reset}`);
72
+ } else if (rest) {
73
+ const p = normalizeProvider(rest, ctx.registry);
74
+ const pc = ctx.registry[p];
75
+ const conf = (cfg.providers && cfg.providers[p]) || {};
76
+ const k = process.env[pc.envKeys[0]] || conf.api_key;
77
+ if (!k) {
78
+ console.log(`${colors.red}[${p}] 未配置 Key:config set api-key --provider ${p}${colors.reset}`);
79
+ } else {
80
+ rt.provider = p;
81
+ rt.pc = pc;
82
+ rt.apiKey = k;
83
+ rt.baseUrl = conf.base_url || pc.defaultBase;
84
+ rt.model = conf.model || pc.defaultModel;
85
+ rt.hints = { ...rt.hints, ...(pc.hints || {}) };
86
+ if (!rt.model) console.log(`${colors.dim}注意:[${p}] 无默认模型,请用 /model 指定。${colors.reset}`);
87
+ console.log(`${colors.dim}已切换供应商: ${p} (${pc.label})${colors.reset}`);
88
+ }
89
+ } else {
90
+ console.log(`当前供应商: ${rt.provider} (${rt.label})`);
91
+ }
92
+ } else if (cmd === "/model") {
93
+ if (rest) { rt.model = rest; console.log(`${colors.dim}已切换模型: ${rt.model}${colors.reset}`); }
94
+ else console.log(`当前模型: ${rt.model}`);
95
+ } else if (cmd === "/system") {
96
+ messages = messages.filter((m) => m.role !== "system");
97
+ if (rest) {
98
+ messages.unshift({ role: "system", content: rest });
99
+ console.log(`${colors.dim}system 已设置。${colors.reset}`);
100
+ } else {
101
+ console.log(`${colors.dim}system 已清除。${colors.reset}`);
102
+ }
103
+ } else if (cmd === "/save") {
104
+ const file = rest || ctx.historyFile;
105
+ fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
106
+ fs.writeFileSync(file, JSON.stringify({ provider: rt.provider, model: rt.model, messages }, null, 2) + "\n", "utf8");
107
+ console.log(`${colors.dim}对话已保存到 ${file}${colors.reset}`);
108
+ } else {
109
+ console.log(`${colors.dim}未知命令 ${cmd},输入 /help 查看帮助。${colors.reset}`);
110
+ }
111
+ safePrompt();
112
+ continue;
113
+ }
114
+
115
+ if (!rt.model) {
116
+ console.log(`${colors.red}当前供应商没有默认模型,请先 /model <名称>。${colors.reset}`);
117
+ safePrompt();
118
+ continue;
119
+ }
120
+
121
+ messages.push({ role: "user", content: text });
122
+ try {
123
+ const { content, usage } = await chatCompletion(rt, buildPayload(rt, messages, args, ctx));
124
+ if (content.trim()) messages.push({ role: "assistant", content });
125
+ else messages.pop(); // 回复为空(如被中断),移除未完成回合的用户消息
126
+ printUsageLine(usage);
127
+ } catch (e) {
128
+ if (e instanceof ApiError) {
129
+ console.error(`${colors.red}${e.message}${colors.reset}`);
130
+ messages.pop(); // 回合失败,移除未送达的用户消息
131
+ } else {
132
+ throw e;
133
+ }
134
+ }
135
+ safePrompt();
136
+ }
137
+ } finally {
138
+ rl.close();
139
+ }
140
+ }
141
+
142
+ module.exports = chat;
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ // config 子命令:set/get/del/(list)/path。多供应商入口才支持 provider 项与 list。
3
+ const { ApiError } = require("../errors");
4
+ const { promptHidden } = require("../hidden");
5
+ const { mask, providerOf } = require("../registry");
6
+
7
+ async function configCmd(args, cfg, ctx) {
8
+ const [action, item, value] = args._;
9
+ const multi = ctx.features.provider;
10
+
11
+ if (action === "path") {
12
+ console.log(ctx.configFile);
13
+ return;
14
+ }
15
+
16
+ if (action === "list") {
17
+ if (!multi) throw new ApiError("用法: config set|get|del|path");
18
+ for (const [id, pc] of Object.entries(ctx.registry)) {
19
+ const conf = (cfg.providers && cfg.providers[id]) || {};
20
+ const configured = conf.api_key || process.env[pc.envKeys[0]];
21
+ const model = conf.model || pc.defaultModel || "(需 -m 指定)";
22
+ console.log(`${id.padEnd(13)} ${pc.label.padEnd(18)} key:${configured ? "已配" : "未配"} 默认模型: ${model}`);
23
+ }
24
+ console.log(`默认供应商: ${cfg.default_provider || Object.keys(ctx.registry)[0]}`);
25
+ return;
26
+ }
27
+
28
+ if (action === "get") {
29
+ if (multi) {
30
+ console.log(`配置文件 : ${ctx.configFile}`);
31
+ console.log(`默认供应商 : ${cfg.default_provider || Object.keys(ctx.registry)[0]}`);
32
+ for (const [id, pc] of Object.entries(ctx.registry)) {
33
+ const conf = (cfg.providers && cfg.providers[id]) || {};
34
+ if (!conf.api_key && !conf.model && !conf.base_url) continue;
35
+ console.log(`[${id}]`);
36
+ console.log(` api_key : ${mask(conf.api_key)}`);
37
+ console.log(` model : ${conf.model || `(默认 ${pc.defaultModel || "需 -m 指定"})`}`);
38
+ console.log(` base_url : ${conf.base_url || `(默认 ${pc.defaultBase})`}`);
39
+ }
40
+ } else {
41
+ const pc = ctx.registry[ctx.fixedProvider];
42
+ const conf = (cfg.providers && cfg.providers[ctx.fixedProvider]) || {};
43
+ console.log(`配置文件 : ${ctx.configFile}`);
44
+ console.log(`api_key : ${mask(conf.api_key)}`);
45
+ console.log(`model : ${conf.model || `(默认 ${pc.defaultModel})`}`);
46
+ console.log(`base_url : ${conf.base_url || `(默认 ${pc.defaultBase})`}`);
47
+ }
48
+ return;
49
+ }
50
+
51
+ const items = { "api-key": "api_key", model: "model", "base-url": "base_url" };
52
+
53
+ if (action === "set") {
54
+ if (item === "provider") {
55
+ if (!multi) throw new ApiError("支持设置: api-key / model / base-url");
56
+ const key = providerOf({ provider: value }, cfg, ctx);
57
+ cfg.default_provider = key;
58
+ ctx.saveCfg(cfg);
59
+ console.log(`已设置默认供应商: ${key}(${ctx.registry[key].label})`);
60
+ return;
61
+ }
62
+ if (!item || !(item in items)) {
63
+ throw new ApiError(multi ? "支持设置: api-key / model / base-url / provider" : "支持设置: api-key / model / base-url");
64
+ }
65
+ const provider = providerOf(args, cfg, ctx);
66
+ let v = value;
67
+ if (item === "api-key" && !v) v = await promptHidden(`请输入 [${provider}] 的 API Key(输入不会回显): `);
68
+ v = (v || "").trim();
69
+ if (!v) throw new ApiError(`缺少 ${item} 的值,例如: config set ${item} <值>${multi ? ` --provider ${provider}` : ""}`);
70
+ cfg.providers = cfg.providers || {};
71
+ cfg.providers[provider] = cfg.providers[provider] || {};
72
+ cfg.providers[provider][items[item]] = v;
73
+ ctx.saveCfg(cfg);
74
+ console.log(multi
75
+ ? `已保存到 [${provider}]。配置文件: ${ctx.configFile}`
76
+ : `已保存。配置文件: ${ctx.configFile}`);
77
+ return;
78
+ }
79
+
80
+ if (action === "del") {
81
+ if (!item || !(item in items)) throw new ApiError("支持删除: api-key / model / base-url");
82
+ const provider = providerOf(args, cfg, ctx);
83
+ if (cfg.providers && cfg.providers[provider]) delete cfg.providers[provider][items[item]];
84
+ ctx.saveCfg(cfg);
85
+ console.log(multi ? `已删除 [${provider}] 的 ${item}。` : "已删除。");
86
+ return;
87
+ }
88
+
89
+ throw new ApiError(multi ? "用法: config set|get|del|list|path" : "用法: config set|get|del|path");
90
+ }
91
+
92
+ module.exports = configCmd;
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ // models 子命令:GET /models 列出当前账号可用的模型。
3
+ const { ApiError } = require("../errors");
4
+ const { resolveRuntime, requireKey } = require("../registry");
5
+ const { TIMEOUT_REASON, isAbort, apiFetch } = require("../http");
6
+
7
+ async function models(args, cfg, ctx) {
8
+ const rt = resolveRuntime(args, cfg, ctx);
9
+ requireKey(rt.apiKey, rt);
10
+ const url = rt.baseUrl.replace(/\/+$/, "") + "/models";
11
+ const controller = new AbortController();
12
+ const timer = setTimeout(() => controller.abort(TIMEOUT_REASON), 60000);
13
+ try {
14
+ const resp = await apiFetch(url, rt.apiKey, null, "GET", controller.signal, rt.hints);
15
+ const obj = await resp.json();
16
+ const items = obj.data || [];
17
+ if (!items.length) {
18
+ console.log("服务端未返回模型列表。请直接用 -m 指定模型名,或查阅对应平台文档。");
19
+ return;
20
+ }
21
+ for (const it of items) console.log(typeof it === "string" ? it : it.id);
22
+ } catch (err) {
23
+ if (isAbort(err)) throw new ApiError("请求超时(>60s)。");
24
+ throw err;
25
+ } finally {
26
+ clearTimeout(timer);
27
+ }
28
+ }
29
+
30
+ module.exports = models;
package/lib/config.js ADDED
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ // 配置文件读写。单供应商入口沿用 1.x 的扁平结构,多供应商入口用嵌套结构。
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+
6
+ function loadJson(file) {
7
+ try {
8
+ return JSON.parse(fs.readFileSync(file, "utf8"));
9
+ } catch (e) {
10
+ if (e.code === "ENOENT") return {};
11
+ console.error(`警告:配置文件 ${file} 读取失败(${e.message}),将忽略已有配置。`);
12
+ return {};
13
+ }
14
+ }
15
+
16
+ function saveJson(file, obj) {
17
+ fs.mkdirSync(path.dirname(file), { recursive: true });
18
+ fs.writeFileSync(file, JSON.stringify(obj, null, 2) + "\n", "utf8");
19
+ if (process.platform !== "win32") {
20
+ try { fs.chmodSync(file, 0o600); } catch {}
21
+ }
22
+ }
23
+
24
+ // 把 1.x 单供应商的扁平配置 {api_key,...} 包装成统一的 {providers:{...}} 结构
25
+ function loadUnified(file, { singleProvider } = {}) {
26
+ const raw = loadJson(file);
27
+ if (singleProvider && !raw.providers && (raw.api_key || raw.model || raw.base_url)) {
28
+ return { default_provider: singleProvider, providers: { [singleProvider]: raw } };
29
+ }
30
+ return raw;
31
+ }
32
+
33
+ // 单供应商入口写回时保持扁平结构(与 1.x 配置文件完全兼容)
34
+ function saveUnified(file, cfg, { singleProvider } = {}) {
35
+ if (singleProvider) {
36
+ saveJson(file, (cfg.providers && cfg.providers[singleProvider]) || {});
37
+ return;
38
+ }
39
+ saveJson(file, cfg);
40
+ }
41
+
42
+ function resolveConfigPath({ configEnv, configDirName }) {
43
+ return path.join(process.env[configEnv] || path.join(require("node:os").homedir(), configDirName), "config.json");
44
+ }
45
+
46
+ function historyPathFor(configFile) {
47
+ return path.join(path.dirname(configFile), "chat-latest.json");
48
+ }
49
+
50
+ module.exports = { loadJson, saveJson, loadUnified, saveUnified, resolveConfigPath, historyPathFor };
package/lib/errors.js ADDED
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ // 错误类型与 HTTP 错误的用户可读映射。
3
+ class ApiError extends Error {}
4
+
5
+ const DEFAULT_HINTS = {
6
+ 401: "API Key 缺失、无效或未生效。请执行: config set api-key(多供应商时加 --provider)",
7
+ 403: "当前 Key 无权访问该模型,或账户余额不足。",
8
+ 404: "模型名或接口地址可能有误:用 -m/--model 指定模型,--base-url 指定接口地址。",
9
+ 402: "账户余额不足,请前往对应平台充值。",
10
+ 429: "请求过于频繁,或额度/资源包不足,请稍后再试。",
11
+ };
12
+
13
+ // 把 HTTP 错误响应转成带提示的可读消息;hints 可被供应商覆盖
14
+ async function friendlyHTTPError(resp, hints = DEFAULT_HINTS) {
15
+ let detail = "";
16
+ try {
17
+ const text = await resp.text();
18
+ try {
19
+ const obj = JSON.parse(text);
20
+ detail = (obj.error && (obj.error.message || obj.error.msg)) || text.trim();
21
+ } catch {
22
+ detail = text.trim();
23
+ }
24
+ } catch {}
25
+ let msg = `请求失败 [HTTP ${resp.status}]`;
26
+ if (detail) msg += `:${detail}`;
27
+ const hint = hints[resp.status];
28
+ if (hint) msg += `\n提示:${hint}`;
29
+ return msg;
30
+ }
31
+
32
+ module.exports = { ApiError, DEFAULT_HINTS, friendlyHTTPError };
package/lib/hidden.js ADDED
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ // 终端隐藏输入(保存 API Key 时回显关闭)。
3
+ const readline = require("node:readline");
4
+ const { Writable } = require("node:stream");
5
+
6
+ function promptHidden(question) {
7
+ return new Promise((resolve) => {
8
+ process.stderr.write(question);
9
+ const sink = new Writable({ write(_chunk, _enc, cb) { cb(); } });
10
+ const rl = readline.createInterface({ input: process.stdin, output: sink, terminal: true });
11
+ let done = false;
12
+ const finish = (answer) => {
13
+ if (done) return;
14
+ done = true;
15
+ rl.close();
16
+ process.stderr.write("\n");
17
+ resolve(String(answer || "").trim());
18
+ };
19
+ rl.on("close", () => finish(""));
20
+ rl.question("", finish);
21
+ });
22
+ }
23
+
24
+ module.exports = { promptHidden };