llm-api-gateway-cli 1.0.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/.env.example +10 -0
- package/README.md +1127 -0
- package/cli-agent.js +666 -0
- package/cli-anthropic.js +236 -0
- package/cli-claude-code.js +317 -0
- package/cli-openai.js +212 -0
- package/completions/_llm-api-gateway-cli +65 -0
- package/completions/llm-api-gateway-cli.bash +64 -0
- package/completions/llm-api-gateway-cli.fish +43 -0
- package/images/chat.png +0 -0
- package/images/settings.png +0 -0
- package/images/task.png +0 -0
- package/lib/agent.js +607 -0
- package/lib/commands.js +468 -0
- package/lib/common.js +196 -0
- package/lib/config.js +70 -0
- package/lib/configcmd.js +230 -0
- package/lib/hub.js +1494 -0
- package/lib/jsonstore.js +49 -0
- package/lib/mcp.js +375 -0
- package/lib/memory.js +109 -0
- package/lib/plandoc.js +178 -0
- package/lib/pricing.js +52 -0
- package/lib/runner.js +234 -0
- package/lib/runstore.js +96 -0
- package/lib/secrets.js +198 -0
- package/lib/sessionstore.js +269 -0
- package/lib/settings.js +517 -0
- package/lib/tasksession.js +594 -0
- package/lib/taskstore.js +740 -0
- package/lib/tools.js +927 -0
- package/package.json +55 -0
- package/public/app.js +1055 -0
- package/public/index.html +167 -0
- package/public/manual.css +215 -0
- package/public/manual.html +381 -0
- package/public/manual.js +186 -0
- package/public/models.js +121 -0
- package/public/render.js +250 -0
- package/public/styles.css +955 -0
- package/public/task-slash.js +493 -0
- package/public/task.css +739 -0
- package/public/task.html +220 -0
- package/public/task.js +3127 -0
- package/public/theme.js +91 -0
- package/public/tint.js +261 -0
- package/scripts/install.ps1 +537 -0
- package/scripts/install.sh +510 -0
- package/server.js +14 -0
- package/task-server.js +15 -0
package/lib/config.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一配置解析(M1.5)
|
|
3
|
+
*
|
|
4
|
+
* 在这之前,四个 CLI 各写一份 .env 加载、参数解析、密钥读取与默认模型:
|
|
5
|
+
* cli-openai 认 OPENAI_API_KEY、cli-anthropic 认 ANTHROPIC_API_KEY、
|
|
6
|
+
* cli-claude-code 认 ANTHROPIC_AUTH_TOKEN,同一个 .env 换个入口就报「缺少密钥」。
|
|
7
|
+
* 这里把「--flag > 环境变量 > .env > 默认值」的优先级收成一处,
|
|
8
|
+
* 所有 CLI 都走 resolveConfig,保证问「密钥从哪来」只有一种答案。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { resolveKey, resolveBaseUrl, strArg } from './common.js';
|
|
12
|
+
|
|
13
|
+
/** 缺密钥时的统一提示(各 CLI 文案一致,避免用户按提示改了还是不通) */
|
|
14
|
+
export const KEY_HINT =
|
|
15
|
+
'缺少 sk- 密钥:可在 Web 界面「设置」里填,或执行 gateway-agent config set key sk-xxx(存 credentials.json,仅本用户可读);'
|
|
16
|
+
+ '也可以用 --key sk-xxx、环境变量 SK / GATEWAY_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY,或在 .env 中写 GATEWAY_KEY=sk-xxx';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 数字参数:非法值(NaN / 布尔 / 空串)一律返回 undefined。
|
|
20
|
+
* 之前直接 Number(v) 会把 --temperature abc 变成 NaN 塞给上游,报错信息很难看懂。
|
|
21
|
+
*/
|
|
22
|
+
export function numArg(v) {
|
|
23
|
+
if (v === undefined || v === null || typeof v === 'boolean' || v === '') return undefined;
|
|
24
|
+
const n = Number(v);
|
|
25
|
+
return Number.isFinite(n) ? n : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 归一化配置。
|
|
30
|
+
*
|
|
31
|
+
* @param {object} args parseArgs 的产物
|
|
32
|
+
* @param {object=} opts
|
|
33
|
+
* @param {string=} opts.defaultModel 模型默认值(各 CLI 不同:如 cli-anthropic 也用它)
|
|
34
|
+
* @param {number=} opts.defaultMaxTokens 最大 token 默认值(cli-anthropic 需要 4096)
|
|
35
|
+
* @param {object=} opts.env 环境变量来源,测试可注入
|
|
36
|
+
* @returns {{key:string, baseUrl:string, model:string, workDir:string,
|
|
37
|
+
* temperature:number|undefined, maxTokens:number|undefined, system:string|undefined}}
|
|
38
|
+
*/
|
|
39
|
+
export function resolveConfig(args = {}, { defaultModel = '', defaultMaxTokens, env = process.env } = {}) {
|
|
40
|
+
const maxTokens = numArg(args['max-tokens']) ?? defaultMaxTokens;
|
|
41
|
+
return {
|
|
42
|
+
key: resolveKey(args, env),
|
|
43
|
+
baseUrl: resolveBaseUrl(args, env),
|
|
44
|
+
// 顺序:--model > GATEWAY_MODEL > 该入口的默认模型
|
|
45
|
+
model: strArg(args.model) || strArg(env.GATEWAY_MODEL) || defaultModel,
|
|
46
|
+
workDir: strArg(args.cwd) || strArg(args['work-dir']) || '',
|
|
47
|
+
temperature: numArg(args.temperature),
|
|
48
|
+
maxTokens: Number.isFinite(maxTokens) && maxTokens > 0 ? maxTokens : undefined,
|
|
49
|
+
system: strArg(args.system) || undefined,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 把异常压成一行可读中文(上游错误、HTTP 状态、普通异常都覆盖) */
|
|
54
|
+
export function errorMessage(e) {
|
|
55
|
+
const status = e?.status ?? e?.statusCode ?? '';
|
|
56
|
+
const message = e?.error?.message || e?.message || String(e);
|
|
57
|
+
return `[错误]${status ? ` HTTP ${status}` : ''}: ${message}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 统一的失败出口。
|
|
62
|
+
*
|
|
63
|
+
* 关键点:交互(REPL)里出错**不该杀进程** —— 一次请求失败就把整个会话结束掉,
|
|
64
|
+
* 用户得重新进来重新交代上下文。所以交互循环内传 exit:false,只打印并继续;
|
|
65
|
+
* 非交互(脚本/管道)保留退出码语义,否则 shell 判断不出成功失败。
|
|
66
|
+
*/
|
|
67
|
+
export function fail(e, { exit = true, stream = process.stderr } = {}) {
|
|
68
|
+
stream.write(`${errorMessage(e)}\n`);
|
|
69
|
+
if (exit) process.exit(1);
|
|
70
|
+
}
|
package/lib/configcmd.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gateway-agent config …` 与 REPL 里的 `/config` 共用这一份实现。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不让两处各写一遍:这两条路最容易漂 —— 命令行能改、页面改不了,
|
|
5
|
+
* 或者一边校验严格一边宽松。校验与读写统一走 lib/settings.js,
|
|
6
|
+
* 这里只负责「把结果排成人能看的样子」。
|
|
7
|
+
*
|
|
8
|
+
* 最要紧的一条是**「来源」列**:用户改了配置却没生效,八成是被环境变量或命令行
|
|
9
|
+
* flag 盖住了。与其让他反复改文件试,不如直接告诉他当前生效值来自哪里。
|
|
10
|
+
*
|
|
11
|
+
* 密钥(`key`)走**另一条路**:它不进 config.json,而是写 `<数据根>/credentials.json`(仅属主可读),
|
|
12
|
+
* 所以 `set key` 不是「被拒绝」,而是「写到另一个文件」。命令行的说法与 Web 设置面板完全一致 ——
|
|
13
|
+
* 这也是「不用手写 .env 就能用」的落点。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
resolveSettings,
|
|
18
|
+
writeSettings,
|
|
19
|
+
unsetSettings,
|
|
20
|
+
settingsFilePath,
|
|
21
|
+
getByPath,
|
|
22
|
+
defaultValueOf,
|
|
23
|
+
unknownKeyError,
|
|
24
|
+
SETTINGS_SCHEMA,
|
|
25
|
+
} from './settings.js';
|
|
26
|
+
import { clearSecret, keyRow, secretFilePath, secretSourceText, writeSecret } from './secrets.js';
|
|
27
|
+
|
|
28
|
+
const SOURCE_LABEL = { flag: '命令行', env: '环境变量', file: '配置文件', default: '内置默认' };
|
|
29
|
+
/** 密钥的来源只有三层(它不在 config.json 里),所以单独一套标签 */
|
|
30
|
+
const SECRET_SOURCE_LABEL = { flag: '命令行 --key', env: '环境变量', file: '密钥文件', default: '未配置' };
|
|
31
|
+
|
|
32
|
+
/** CLI 传进来的值是字符串;这里粗切一下,`set system 你是助手 请简短` 的剩余部分都算值 */
|
|
33
|
+
export function splitConfigArg(arg) {
|
|
34
|
+
const text = typeof arg === 'string' ? arg.trim() : '';
|
|
35
|
+
if (!text) return [];
|
|
36
|
+
return text.split(/\s+/);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const show = (v) => {
|
|
40
|
+
if (v === null || v === undefined) return '(未设)';
|
|
41
|
+
if (Array.isArray(v)) return v.length ? `${v.length} 项` : '(空)';
|
|
42
|
+
if (typeof v === 'string') return v.length > 40 ? `${v.slice(0, 39)}…` : v || '(空串)';
|
|
43
|
+
return String(v);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** 当前生效值是不是被更高优先级盖住了 —— `set` 之后最容易踩的坑 */
|
|
47
|
+
function overrideWarning(resolved, key) {
|
|
48
|
+
const source = resolved.sources[key];
|
|
49
|
+
const entry = SETTINGS_SCHEMA.find((e) => e.key === key);
|
|
50
|
+
if (source === 'flag') return `注意:当前生效值来自命令行参数(${entry?.flag || key}),它优先于配置文件,你写的值现在不会生效。`;
|
|
51
|
+
if (source === 'env') return `注意:当前生效值来自环境变量 ${entry?.env},它优先于配置文件,你写的值现在不会生效。`;
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 执行一条 config 命令。
|
|
57
|
+
* @returns {{code:number, out:string, err:string}}
|
|
58
|
+
*/
|
|
59
|
+
export function configCommand(argv = [], { env = process.env, file = settingsFilePath(env), secretFile = secretFilePath(env) } = {}) {
|
|
60
|
+
const [action = 'list', key, ...rest] = argv;
|
|
61
|
+
const resolved = () => resolveSettings({ file, env, args: null });
|
|
62
|
+
const secretRow = () => keyRow({ env, file: secretFile, args: null });
|
|
63
|
+
|
|
64
|
+
if (action === 'help' || action === '--help' || action === '-h') return { code: 0, out: configHelp(), err: '' };
|
|
65
|
+
|
|
66
|
+
if (action === 'path') {
|
|
67
|
+
const r = resolved();
|
|
68
|
+
const s = secretRow();
|
|
69
|
+
return {
|
|
70
|
+
code: 0,
|
|
71
|
+
out:
|
|
72
|
+
`${r.file}\n${r.exists ? '(已存在)' : '(还没有这份文件;set 一次就会创建)'}\n` +
|
|
73
|
+
`${s.file}\n${s.hasKey ? `(已存在,${s.mask},来自${secretSourceText(s.source)})` : '(还没有;config set key sk-xxx 或页面设置里填)'}\n`,
|
|
74
|
+
err: '',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (action === 'list' || action === 'ls') {
|
|
79
|
+
const r = resolved();
|
|
80
|
+
// 密钥行排在最前:它是「能不能用」的前提,而它不在 config.json 里,单独标出来免得用户去文件里翻
|
|
81
|
+
const secret = secretRow();
|
|
82
|
+
const rows = [
|
|
83
|
+
['配置项', '当前值', '来源'],
|
|
84
|
+
['key', secret.hasKey ? `${secret.mask}(存 ${secret.file})` : '(未配置)', SECRET_SOURCE_LABEL[secret.source] || secret.source],
|
|
85
|
+
...SETTINGS_SCHEMA.map((e) => {
|
|
86
|
+
const row = [e.key, show(getByPath(r.values, e.key)), SOURCE_LABEL[r.sources[e.key]] || r.sources[e.key]];
|
|
87
|
+
if (e.restart) row[0] = `${e.key} *`;
|
|
88
|
+
return row;
|
|
89
|
+
}),
|
|
90
|
+
];
|
|
91
|
+
const w = [0, 1, 2].map((i) => Math.max(...rows.map((row) => [...row[i]].length)));
|
|
92
|
+
const body = rows.map((row, i) => row.map((c, ci) => pad(c, w[ci])).join(' ').trimEnd()).join('\n');
|
|
93
|
+
const warns = [...r.warnings, ...[secret.warning].filter(Boolean)];
|
|
94
|
+
const warn = warns.length ? `\n\n[警告]\n${warns.map((x) => ` ${x}`).join('\n')}` : '';
|
|
95
|
+
return {
|
|
96
|
+
code: 0,
|
|
97
|
+
out:
|
|
98
|
+
`${body}\n\n` +
|
|
99
|
+
`来源:命令行 > 环境变量 > 配置文件 > 内置默认。带 * 的改了要重启服务才生效。\n` +
|
|
100
|
+
`配置文件:${r.file}${r.exists ? '' : '(还没有,用 config set 创建)'}\n` +
|
|
101
|
+
`密钥不入配置文件:key 存在 ${secret.file}(仅本用户可读),环境变量 / --key 给的密钥优先于它。\n` +
|
|
102
|
+
`改某项:gateway-agent config set <键> <值>;改回来:config unset <键>${warn}\n`,
|
|
103
|
+
err: '',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (action === 'get') {
|
|
108
|
+
if (!key) return { code: 1, out: '', err: '用法:gateway-agent config get <键>\n' };
|
|
109
|
+
if (key === 'key') {
|
|
110
|
+
const s = secretRow();
|
|
111
|
+
const lines = [
|
|
112
|
+
// 明文永不回显:命令行输出经常被贴进 issue / 日志
|
|
113
|
+
`key = ${s.hasKey ? s.mask : '(未配置)'}`,
|
|
114
|
+
` 来源 ${SECRET_SOURCE_LABEL[s.source] || s.source}`,
|
|
115
|
+
` 存放 ${s.file}${s.fileMode ? `(权限 ${s.fileMode.toString(8)})` : ''}`,
|
|
116
|
+
' 说明 密钥不写进 config.json;这里只回掩码,明文请回网关后台重新生成',
|
|
117
|
+
' 环境变量 SK / GATEWAY_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY',
|
|
118
|
+
' 命令行 --key',
|
|
119
|
+
s.warning ? ` 警告 ${s.warning}` : '',
|
|
120
|
+
].filter(Boolean);
|
|
121
|
+
return { code: 0, out: `${lines.join('\n')}\n`, err: '' };
|
|
122
|
+
}
|
|
123
|
+
const entry = SETTINGS_SCHEMA.find((e) => e.key === key);
|
|
124
|
+
if (!entry) return { code: 1, out: '', err: `${unknownKeyError(key)}\n` };
|
|
125
|
+
const r = resolved();
|
|
126
|
+
const lines = [
|
|
127
|
+
`${key} = ${JSON.stringify(getByPath(r.values, key))}`,
|
|
128
|
+
` 来源 ${SOURCE_LABEL[r.sources[key]] || r.sources[key]}`,
|
|
129
|
+
` 内置默认 ${JSON.stringify(defaultValueOf(entry))}`,
|
|
130
|
+
` 说明 ${entry.desc}`,
|
|
131
|
+
entry.env ? ` 环境变量 ${entry.env}` : '',
|
|
132
|
+
entry.flag ? ` 命令行 ${entry.flag}` : '',
|
|
133
|
+
entry.restart ? ' 注意 改这项需要重启服务才生效' : '',
|
|
134
|
+
].filter(Boolean);
|
|
135
|
+
return { code: 0, out: `${lines.join('\n')}\n`, err: '' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (action === 'set') {
|
|
139
|
+
if (!key || rest.length === 0) return { code: 1, out: '', err: '用法:gateway-agent config set <键> <值>(字符串带空格时整段都要写上)\n' };
|
|
140
|
+
const value = rest.join(' ');
|
|
141
|
+
// 密钥:写 credentials.json,不碰 config.json(红线没变,只是换了落点)
|
|
142
|
+
if (key === 'key') {
|
|
143
|
+
try {
|
|
144
|
+
const w = writeSecret(value, { file: secretFile });
|
|
145
|
+
const s = secretRow();
|
|
146
|
+
const override = s.source === 'env' || s.source === 'flag'
|
|
147
|
+
? '\n注意:当前进程的密钥来自环境变量 / --key,它优先于这个文件,你写的值现在不会生效。'
|
|
148
|
+
: '';
|
|
149
|
+
const perm = w.mode ? `(权限 ${w.mode.toString(8)})` : '(Windows:靠用户目录 ACL)';
|
|
150
|
+
return {
|
|
151
|
+
code: 0,
|
|
152
|
+
out: `已写入 ${w.file}${perm}:key = ${s.mask}${w.warning ? `\n${w.warning}` : ''}${override}\n`,
|
|
153
|
+
err: '',
|
|
154
|
+
};
|
|
155
|
+
} catch (e) {
|
|
156
|
+
return { code: 1, out: '', err: `${e.message}\n` };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const res = writeSettings({ [key]: value }, { file });
|
|
161
|
+
const r = resolved();
|
|
162
|
+
const warn = overrideWarning(r, key);
|
|
163
|
+
const entry = SETTINGS_SCHEMA.find((e) => e.key === key);
|
|
164
|
+
const restart = entry?.restart ? '\n(这项要重启服务才生效)' : '';
|
|
165
|
+
const shown = show(getByPath(r.values, key));
|
|
166
|
+
return {
|
|
167
|
+
code: 0,
|
|
168
|
+
out: `已写入 ${res.file}:${key} = ${shown}${restart}${warn ? `\n${warn}` : ''}\n`,
|
|
169
|
+
err: '',
|
|
170
|
+
};
|
|
171
|
+
} catch (e) {
|
|
172
|
+
return { code: 1, out: '', err: `${e.message}\n` };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (action === 'unset' || action === 'rm') {
|
|
177
|
+
if (!key) return { code: 1, out: '', err: '用法:gateway-agent config unset <键>\n' };
|
|
178
|
+
if (key === 'key') {
|
|
179
|
+
const cleared = clearSecret({ file: secretFile });
|
|
180
|
+
const s = secretRow();
|
|
181
|
+
const still = s.hasKey ? `当前生效的仍是${SECRET_SOURCE_LABEL[s.source] || s.source}的密钥:${s.mask}` : '现在没有可用的密钥了。';
|
|
182
|
+
return {
|
|
183
|
+
code: 0,
|
|
184
|
+
out: `${cleared.removed ? `已删掉 ${cleared.file},` : `本来就没有 ${cleared.file},`}${still}\n`,
|
|
185
|
+
err: '',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const res = unsetSettings([key], { file });
|
|
190
|
+
const r = resolved();
|
|
191
|
+
const now = show(getByPath(r.values, key));
|
|
192
|
+
const warn = overrideWarning(r, key);
|
|
193
|
+
if (!res.removed.length) {
|
|
194
|
+
return { code: 0, out: `${key} 本来就没在配置文件里,现在用的仍是${SOURCE_LABEL[r.sources[key]] || r.sources[key]}的值:${now}${warn ? `\n${warn}` : ''}\n`, err: '' };
|
|
195
|
+
}
|
|
196
|
+
return { code: 0, out: `已从 ${res.file} 删掉 ${key},现在生效的是:${now}${warn ? `\n${warn}` : ''}\n`, err: '' };
|
|
197
|
+
} catch (e) {
|
|
198
|
+
return { code: 1, out: '', err: `${e.message}\n` };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { code: 1, out: '', err: `未知子命令 ${action}\n\n${configHelp()}` };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 中文对齐:按显示宽度补齐(中文算两格),否则表格会歪 */
|
|
206
|
+
function pad(text, width) {
|
|
207
|
+
const s = String(text);
|
|
208
|
+
const w = [...s].reduce((n, ch) => n + (ch.charCodeAt(0) > 0x2e80 ? 2 : 1), 0);
|
|
209
|
+
return s + ' '.repeat(Math.max(0, width - w));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function configHelp() {
|
|
213
|
+
return `用法:gateway-agent config <子命令>
|
|
214
|
+
|
|
215
|
+
list 列出所有配置项、当前值与来源(默认)
|
|
216
|
+
get <键> 查看某一项(含默认值、说明、对应的环境变量与命令行选项)
|
|
217
|
+
set <键> <值> 写入配置文件(原子写;写错会明确报错并列出可用键)
|
|
218
|
+
unset <键> 删掉某一项,回到内置默认
|
|
219
|
+
path 打印配置文件与密钥文件的路径
|
|
220
|
+
|
|
221
|
+
配置文件的路径可用环境变量 LLM_GATEWAY_CONFIG 覆盖。
|
|
222
|
+
优先级:命令行 > 环境变量 > 配置文件 > 内置默认 —— list 的「来源」列就是告诉你当前生效值来自哪一层。
|
|
223
|
+
|
|
224
|
+
密钥(key)走另一条路:config set key sk-xxx 会写 <数据根>/credentials.json(仅本用户可读),
|
|
225
|
+
**不写进 config.json**;命令行 / 环境变量 / .env 给的密钥优先于它。get key 只回掩码,明文不回显。
|
|
226
|
+
Web 界面「设置」里的密钥输入框写的是同一个文件 —— 命令行与界面不会各存一处。
|
|
227
|
+
|
|
228
|
+
REPL 里同一套:/config、/config set <键> <值>、/config get <键>、/config unset <键>
|
|
229
|
+
`;
|
|
230
|
+
}
|