@yunzhi519/maas-helper 0.1.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/README.md +65 -0
- package/bin/yunzhi-maas-helper.js +522 -0
- package/lib/api.js +114 -0
- package/lib/claude.js +120 -0
- package/lib/codex.js +239 -0
- package/lib/opencode.js +214 -0
- package/lib/store.js +37 -0
- package/package.json +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# yunzhi-maas-helper
|
|
2
|
+
|
|
3
|
+
> **Powered by MiniRelax** · 苏州云养智健人工智能科技有限公司
|
|
4
|
+
|
|
5
|
+
云智 Token Hub(MaaS)编码工具配置助手。运行不带参数的 `yunzhi-helper`进入交互菜单,完成 API Key 配置与验证(自动调用 `/v1/models`)、模型列表自动获取、OpenCode / Claude Code / Codex 一键配置。
|
|
6
|
+
|
|
7
|
+
- 平台:云智 Token Hub — https://maas.piteyun.com/
|
|
8
|
+
- OpenAI 兼容端点:`https://maas.piteyun.com/v1`
|
|
9
|
+
- 要求:Node.js >= 18
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# 发布后(暂未发布)
|
|
15
|
+
npm install -g @yunzhi519/maas-helper@latest
|
|
16
|
+
|
|
17
|
+
# 当前内部阶段:从源码安装
|
|
18
|
+
cd yunzhi-maas-helper
|
|
19
|
+
npm install -g .
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 使用
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
yunzhi-helper # 交互菜单
|
|
26
|
+
yunzhi-helper --version # 版本
|
|
27
|
+
yunzhi-helper --help # 帮助
|
|
28
|
+
```
|
|
29
|
+
```bash
|
|
30
|
+
yunzhi-helper
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
主菜单:
|
|
34
|
+
|
|
35
|
+
1. **输入/配置 API Key** — 隐藏输入,调用 `GET /v1/models` 实时验证
|
|
36
|
+
2. **配置编码工具** — 两个工具的管理菜单:
|
|
37
|
+
- **OpenCode**:配置模型(主模型/小模型,列表自动获取)、刷新模型列表、配置装载/配置刷新
|
|
38
|
+
- **Claude Code**:配置模型(Haiku/Sonnet/Opus/Fable 四角色)、配置装载/配置刷新、清除网关配置(恢复官方登录)
|
|
39
|
+
- **Codex**:内置 Responses API 支持探测;平台上线 `/v1/responses` 后即可一键写入 `config.toml` + `auth.json`(当前 Codex 0.154+ 已移除 `wire_api = "chat"`,云智尚未提供 Responses API,详见教程)
|
|
40
|
+
3. **测试真实请求** — 向 `/v1/chat/completions` 发起一次真实调用
|
|
41
|
+
4. **查看当前配置**
|
|
42
|
+
|
|
43
|
+
推荐模型:OpenCode 主模型 `glm-5.3` / 小模型 `glm-5.3-flash`;Claude Code 四角色 Haiku `glm-5.3-flash`、Sonnet `glm-5.3`、Opus `kimi-k2.7`、Fable `qwen3.7-max`(均可在实时列表中改选)。
|
|
44
|
+
|
|
45
|
+
## 配置文件位置
|
|
46
|
+
|
|
47
|
+
| 内容 | 位置 |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| Helper 自身配置(含明文 Key,勿提交仓库) | `~/.yunzhi-maas-helper/config.json` |
|
|
50
|
+
| OpenCode 公开配置(Provider/端点/模型) | `~/.config/opencode/opencode.jsonc` |
|
|
51
|
+
| OpenCode 凭据(权限 0600) | `~/.local/share/opencode/auth.json` |
|
|
52
|
+
| Claude Code 环境变量(端点/密钥/四模型) | `~/.claude/settings.json` |
|
|
53
|
+
| Claude Code onboarding 标记 | `~/.claude.json` |
|
|
54
|
+
|
|
55
|
+
## 教程
|
|
56
|
+
|
|
57
|
+
- OpenCode:[`docs/yunzhi-maas-helper-opencode-tutorial.md`](../docs/yunzhi-maas-helper-opencode-tutorial.md)
|
|
58
|
+
- Claude Code:[`docs/yunzhi-maas-helper-claude-code-tutorial.md`](../docs/yunzhi-maas-helper-claude-code-tutorial.md)
|
|
59
|
+
- Codex:[`docs/yunzhi-maas-helper-codex-tutorial.md`](../docs/yunzhi-maas-helper-codex-tutorial.md)(⚠️ 当前需平台上线 Responses API)
|
|
60
|
+
|
|
61
|
+
> 端点区分:OpenCode 用 OpenAI 兼容端点 `https://maas.piteyun.com/v1`;Claude Code 用 Anthropic 根端点 `https://maas.piteyun.com`(Claude Code 自动拼接 `/v1/messages`);Codex 需要 OpenAI Responses API(`/v1/responses`,暂未上线)。三枚端点共用同一枚 API Key。
|
|
62
|
+
|
|
63
|
+
## 密钥安全
|
|
64
|
+
|
|
65
|
+
不要把完整 API Key 粘贴到 Markdown、截图、终端录屏、Git 仓库或公共聊天中。密钥泄露后请立即到控制台删除并重新创建。
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* yunzhi-maas-helper - 云智 Token Hub (MaaS) 编码工具配置助手
|
|
6
|
+
* Powered by MiniRelax · 苏州云养智健人工智能科技有限公司
|
|
7
|
+
*
|
|
8
|
+
* 用法:不带参数运行 `yunzhi-helper` 进入交互菜单。
|
|
9
|
+
* 本教程中的密钥、模型和工具配置都从该菜单完成。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const readline = require('readline');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const { BASE_URL, MODEL_META, RECOMMENDED, maskKey, fetchModels, testChat } = require('../lib/api');
|
|
16
|
+
const store = require('../lib/store');
|
|
17
|
+
const opencode = require('../lib/opencode');
|
|
18
|
+
const claudecode = require('../lib/claude');
|
|
19
|
+
const codexcli = require('../lib/codex');
|
|
20
|
+
|
|
21
|
+
const VERSION = require('../package.json').version;
|
|
22
|
+
|
|
23
|
+
/** 企业品牌信息(MiniRelax · 苏州云养智健) */
|
|
24
|
+
const BRAND = {
|
|
25
|
+
name: 'MiniRelax',
|
|
26
|
+
vendor: '苏州云养智健人工智能科技有限公司',
|
|
27
|
+
product: 'yunzhi-maas-helper',
|
|
28
|
+
banner: [
|
|
29
|
+
'███╗ ███╗██╗███╗ ██╗██╗ ██████╗ ███████╗██╗ █████╗ ██╗ ██╗',
|
|
30
|
+
'████╗ ████║██║████╗ ██║██║ ██╔══██╗██╔════╝██║ ██╔══██╗╚██╗██╔╝',
|
|
31
|
+
'██╔████╔██║██║██╔██╗ ██║██║ ██████╔╝█████╗ ██║ ███████║ ╚███╔╝ ',
|
|
32
|
+
'██║╚██╔╝██║██║██║╚██╗██║██║ ██╔══██╗██╔══╝ ██║ ██╔══██║ ██╔██╗ ',
|
|
33
|
+
'██║ ╚═╝ ██║██║██║ ╚████║██║ ██║ ██║███████╗███████╗██║ ██║██╔╝ ██╗',
|
|
34
|
+
'╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝',
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function printBanner() {
|
|
39
|
+
const wide = process.stdout.columns || 80;
|
|
40
|
+
console.log('');
|
|
41
|
+
if (wide >= 78) {
|
|
42
|
+
for (const line of BRAND.banner) console.log('\x1b[36m' + line + '\x1b[0m');
|
|
43
|
+
} else {
|
|
44
|
+
console.log('\x1b[36m' + C.bold(BRAND.name) + '\x1b[0m');
|
|
45
|
+
}
|
|
46
|
+
console.log(C.dim(` ${BRAND.product} v${VERSION} · 云智 Token Hub · ${BASE_URL}`));
|
|
47
|
+
console.log(C.dim(` Powered by ${BRAND.name} · ${BRAND.vendor}`));
|
|
48
|
+
console.log('');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const C = {
|
|
52
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
53
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
54
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
55
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
56
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
57
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function makeRl() {
|
|
61
|
+
return readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function ask(rl, prompt) {
|
|
65
|
+
return new Promise((resolve) => rl.question(prompt, (a) => resolve(a.trim())));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 隐藏输入(不回显),跨平台:逐 keypress 收集 */
|
|
69
|
+
function hiddenInput(promptText) {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
process.stdout.write(promptText);
|
|
72
|
+
readline.emitKeypressEvents(process.stdin);
|
|
73
|
+
const isTTY = process.stdin.isTTY;
|
|
74
|
+
if (isTTY) process.stdin.setRawMode(true);
|
|
75
|
+
let value = '';
|
|
76
|
+
const onKeypress = (str, key) => {
|
|
77
|
+
if (!key) return;
|
|
78
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
79
|
+
cleanup();
|
|
80
|
+
process.stdout.write('\n');
|
|
81
|
+
resolve(value);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (key.name === 'backspace') {
|
|
85
|
+
value = value.slice(0, -1);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (key.ctrl && (key.name === 'c' || key.name === 'd')) {
|
|
89
|
+
cleanup();
|
|
90
|
+
process.stdout.write('\n');
|
|
91
|
+
process.exit(130);
|
|
92
|
+
}
|
|
93
|
+
if (!key.ctrl && !key.meta && str && str >= ' ') {
|
|
94
|
+
value += str;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
function cleanup() {
|
|
98
|
+
process.stdin.removeListener('keypress', onKeypress);
|
|
99
|
+
if (isTTY) process.stdin.setRawMode(false);
|
|
100
|
+
process.stdin.pause();
|
|
101
|
+
}
|
|
102
|
+
process.stdin.on('keypress', onKeypress);
|
|
103
|
+
if (isTTY) process.stdin.resume();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 通用编号菜单;返回选中的 key */
|
|
108
|
+
async function pick(rl, title, options) {
|
|
109
|
+
console.log('\n' + C.bold(title));
|
|
110
|
+
options.forEach((o, i) => {
|
|
111
|
+
const meta = o.meta ? C.dim(' — ' + o.meta) : '';
|
|
112
|
+
console.log(` ${i + 1}. ${o.label}${meta}`);
|
|
113
|
+
});
|
|
114
|
+
const ans = await ask(rl, `\n请输入编号 (1-${options.length}, q 返回): `);
|
|
115
|
+
if (ans.toLowerCase() === 'q' || ans === '') return null;
|
|
116
|
+
const idx = parseInt(ans, 10) - 1;
|
|
117
|
+
if (Number.isNaN(idx) || idx < 0 || idx >= options.length) {
|
|
118
|
+
console.log(C.yellow('无效输入,请重试。'));
|
|
119
|
+
return await pick(rl, title, options);
|
|
120
|
+
}
|
|
121
|
+
return options[idx];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* ---------- 主流程 ---------- */
|
|
125
|
+
|
|
126
|
+
async function configureApiKey(rl) {
|
|
127
|
+
const cfg = store.read();
|
|
128
|
+
const isUpdate = Boolean(cfg.apiKey);
|
|
129
|
+
console.log('');
|
|
130
|
+
console.log(
|
|
131
|
+
isUpdate
|
|
132
|
+
? `当前 API Key: ${C.green(maskKey(cfg.apiKey))}`
|
|
133
|
+
: '尚未保存 API Key。'
|
|
134
|
+
);
|
|
135
|
+
console.log(`请先登录云智 Token Hub 控制台,在 ${C.cyan('API 密钥管理')} 创建并复制密钥。`);
|
|
136
|
+
console.log(C.dim(`密钥用途可填写 "Coding Helper - OpenCode 教程"。端点:${BASE_URL}`));
|
|
137
|
+
const key = await hiddenInput('\n请在这里输入您的 API key, 回车键确认: ');
|
|
138
|
+
if (!key) {
|
|
139
|
+
console.log(C.yellow('未输入任何内容,已取消。'));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
console.log(C.dim(`正在调用 ${BASE_URL}/models 验证密钥...`));
|
|
143
|
+
const result = await fetchModels(key);
|
|
144
|
+
if (!result.ok) {
|
|
145
|
+
console.log(C.red(`✗ 验证失败:${result.message}`));
|
|
146
|
+
console.log(C.dim('常见原因:复制了遮罩文本、密钥已过期、密钥被删除。请回控制台重新复制或创建。'));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const next = {
|
|
150
|
+
...cfg,
|
|
151
|
+
apiKey: key,
|
|
152
|
+
apiKeyMask: maskKey(key),
|
|
153
|
+
baseURL: BASE_URL,
|
|
154
|
+
verifiedAt: new Date().toISOString(),
|
|
155
|
+
modelCount: result.models.length,
|
|
156
|
+
};
|
|
157
|
+
store.write(next);
|
|
158
|
+
console.log(C.green('✓ 设置成功'));
|
|
159
|
+
console.log(` API Key 状态: ${C.green('已设置')} (${maskKey(key)})`);
|
|
160
|
+
console.log(` 可用模型: ${result.models.length} 个 端点: ${BASE_URL}`);
|
|
161
|
+
console.log(C.dim(`Helper 配置已保存到 ${store.HELPER_CONFIG},请勿提交到代码仓库。`));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function selectModels(rl, models) {
|
|
165
|
+
const cfg = store.read();
|
|
166
|
+
const label = (id) => {
|
|
167
|
+
const meta = MODEL_META[id];
|
|
168
|
+
return meta ? `${id} ${C.dim(`(${meta.vendor} · ${meta.desc})`)}` : id;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const mainOpts = models.map((id) => ({ key: id, label: label(id) }));
|
|
172
|
+
const mainPick = await pick(
|
|
173
|
+
rl,
|
|
174
|
+
'选择主模型(日常编码、推理和复杂任务)',
|
|
175
|
+
mainOpts
|
|
176
|
+
);
|
|
177
|
+
if (!mainPick) return null;
|
|
178
|
+
const mainModel = mainPick.key;
|
|
179
|
+
|
|
180
|
+
const smallOpts = models.map((id) => ({ key: id, label: label(id) }));
|
|
181
|
+
const smallPick = await pick(rl, '选择小模型 (快速/轻量,辅助任务)', smallOpts);
|
|
182
|
+
if (!smallPick) return null;
|
|
183
|
+
const smallModel = smallPick.key;
|
|
184
|
+
|
|
185
|
+
console.log('');
|
|
186
|
+
console.log(` 主模型: ${C.cyan(mainModel)}${mainModel === RECOMMENDED.main ? C.green(' ← 推荐默认') : ''}`);
|
|
187
|
+
console.log(` 小模型: ${C.cyan(smallModel)}${smallModel === RECOMMENDED.small ? C.green(' ← 推荐默认') : ''}`);
|
|
188
|
+
|
|
189
|
+
const ans = await ask(rl, '\n是否立即将配置应用到 OpenCode? (Y/n): ');
|
|
190
|
+
if (ans && ans.toLowerCase() !== 'y') {
|
|
191
|
+
console.log(C.yellow('已取消,未写入 OpenCode 配置。'));
|
|
192
|
+
return { mainModel, smallModel, applied: false };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const res = opencode.applyConfig({
|
|
196
|
+
apiKey: cfg.apiKey,
|
|
197
|
+
models,
|
|
198
|
+
mainModel,
|
|
199
|
+
smallModel,
|
|
200
|
+
});
|
|
201
|
+
console.log(C.green('✓ 配置已同步'));
|
|
202
|
+
console.log(C.dim(` Provider、端点和模型 → ${res.configFile}`));
|
|
203
|
+
console.log(C.dim(` X-AIO 风格凭据分离:API Key → ${res.authFile}`));
|
|
204
|
+
if (res.legacyKeyRemoved) {
|
|
205
|
+
console.log(C.yellow(' 已从公开配置移除旧版内嵌 apiKey,凭据现在仅保存在 auth.json。'));
|
|
206
|
+
}
|
|
207
|
+
return { mainModel, smallModel, applied: true };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function opencodeMenu(rl) {
|
|
211
|
+
for (;;) {
|
|
212
|
+
const cfg = store.read();
|
|
213
|
+
const st = opencode.status();
|
|
214
|
+
const keyMatch = cfg.apiKey && st.hasKey;
|
|
215
|
+
|
|
216
|
+
console.log('\n' + C.bold('── OpenCode 管理 ──'));
|
|
217
|
+
console.log(` API Key: ${cfg.apiKey ? C.green(maskKey(cfg.apiKey)) : C.red('未设置')}${keyMatch ? C.green(' (与 Helper 一致)') : ''}`);
|
|
218
|
+
console.log(` 端点: ${st.baseURL || C.dim('未配置')}`);
|
|
219
|
+
console.log(` 主模型: ${st.mainModel || C.dim('未配置')}`);
|
|
220
|
+
console.log(` 小模型: ${st.smallModel || C.dim('未配置')}`);
|
|
221
|
+
console.log(` 模型目录: ${st.modelCount ? C.green(st.modelCount + ' 个') : C.dim('空')}`);
|
|
222
|
+
console.log(` 状态: ${st.hasProvider && keyMatch ? C.green('配置已同步') : C.yellow('待配置/不一致')}`);
|
|
223
|
+
|
|
224
|
+
const act = await pick(rl, 'OpenCode 管理菜单', [
|
|
225
|
+
{ key: 'models', label: '配置模型 - (选择主模型/小模型)' },
|
|
226
|
+
{ key: 'refresh', label: '刷新模型列表 - (更新可用模型到配置)' },
|
|
227
|
+
{ key: 'reload', label: '配置装载/配置刷新' },
|
|
228
|
+
{ key: 'back', label: '返回主菜单' },
|
|
229
|
+
]);
|
|
230
|
+
if (!act || act.key === 'back') return;
|
|
231
|
+
|
|
232
|
+
if (act.key === 'models') {
|
|
233
|
+
if (!cfg.apiKey) {
|
|
234
|
+
console.log(C.red('请先在主菜单配置 API Key。'));
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const result = await fetchModels(cfg.apiKey);
|
|
238
|
+
if (!result.ok) {
|
|
239
|
+
console.log(C.red(`模型列表获取失败:${result.message}`));
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
await selectModels(rl, result.models);
|
|
243
|
+
} else if (act.key === 'refresh') {
|
|
244
|
+
if (!cfg.apiKey) {
|
|
245
|
+
console.log(C.red('请先在主菜单配置 API Key。'));
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const result = await fetchModels(cfg.apiKey);
|
|
249
|
+
if (!result.ok) {
|
|
250
|
+
console.log(C.red(`模型列表获取失败:${result.message}`));
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const r = opencode.refreshModels(result.models);
|
|
254
|
+
if (r.ok) {
|
|
255
|
+
console.log(C.green(`✓ 模型列表已刷新(${result.models.length} 个)→ ${r.file}`));
|
|
256
|
+
console.log(C.dim('刷新只更新可选模型目录,不改变当前主模型/小模型。'));
|
|
257
|
+
} else {
|
|
258
|
+
console.log(C.yellow(r.message));
|
|
259
|
+
}
|
|
260
|
+
} else if (act.key === 'reload') {
|
|
261
|
+
const st2 = opencode.status();
|
|
262
|
+
if (!st2.hasProvider) {
|
|
263
|
+
console.log(C.yellow('尚未配置 yunzhi Provider。请先选择“配置模型”完成初始配置。'));
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (st2.baseURL !== BASE_URL) {
|
|
267
|
+
const r = opencode.normalizeEndpoint();
|
|
268
|
+
console.log(C.green(`✓ 端点已规范化为 ${BASE_URL} → ${r.file}`));
|
|
269
|
+
} else {
|
|
270
|
+
console.log(C.green('✓ 配置装载完成,端点与 Provider 元数据已是最新。'));
|
|
271
|
+
}
|
|
272
|
+
console.log(C.dim('请重新启动 OpenCode TUI 以加载最新配置。'));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function claudeMenu(rl) {
|
|
278
|
+
for (;;) {
|
|
279
|
+
const cfg = store.read();
|
|
280
|
+
const st = claudecode.status();
|
|
281
|
+
|
|
282
|
+
console.log('\n' + C.bold('── Claude Code 管理 ──'));
|
|
283
|
+
console.log(` API Key: ${cfg.apiKey ? C.green(maskKey(cfg.apiKey)) : C.red('未设置')}`);
|
|
284
|
+
console.log(` 端点: ${st.baseURL || C.dim('未配置')}`);
|
|
285
|
+
for (const role of ['haiku', 'sonnet', 'opus', 'fable']) {
|
|
286
|
+
console.log(` ${claudecode.ROLE_LABELS[role].padEnd(16)} ${st.models[role] || C.dim('未配置')}`);
|
|
287
|
+
}
|
|
288
|
+
console.log(` 状态: ${st.hasToken && st.models.haiku && st.models.sonnet ? C.green('配置已同步') : C.yellow('待配置/不一致')}`);
|
|
289
|
+
|
|
290
|
+
const act = await pick(rl, 'Claude Code 管理菜单', [
|
|
291
|
+
{ key: 'models', label: '配置模型 - (选择 Haiku/Sonnet/Opus/Fable 模型)' },
|
|
292
|
+
{ key: 'reload', label: '配置装载/配置刷新 - (应用配置并清除旧路径)' },
|
|
293
|
+
{ key: 'clear', label: '清除网关配置 - (恢复 Claude Code 官方登录)' },
|
|
294
|
+
{ key: 'back', label: '返回主菜单' },
|
|
295
|
+
]);
|
|
296
|
+
if (!act || act.key === 'back') return;
|
|
297
|
+
|
|
298
|
+
if (act.key === 'models') {
|
|
299
|
+
if (!cfg.apiKey) {
|
|
300
|
+
console.log(C.red('请先在主菜单配置 API Key。'));
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const result = await fetchModels(cfg.apiKey);
|
|
304
|
+
if (!result.ok) {
|
|
305
|
+
console.log(C.red(`模型列表获取失败:${result.message}`));
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const label = (id) => {
|
|
309
|
+
const meta = MODEL_META[id];
|
|
310
|
+
return meta ? `${id} ${C.dim(`(${meta.vendor} · ${meta.desc})`)}` : id;
|
|
311
|
+
};
|
|
312
|
+
const models = {};
|
|
313
|
+
for (const role of ['haiku', 'sonnet', 'opus', 'fable']) {
|
|
314
|
+
const opts = result.models.map((id) => ({ key: id, label: label(id) }));
|
|
315
|
+
const picked = await pick(rl, `选择 ${claudecode.ROLE_LABELS[role]}`, opts);
|
|
316
|
+
if (!picked) {
|
|
317
|
+
console.log(C.yellow('已取消,未写入 Claude Code 配置。'));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
models[role] = picked.key;
|
|
321
|
+
}
|
|
322
|
+
console.log('');
|
|
323
|
+
for (const role of ['haiku', 'sonnet', 'opus', 'fable']) {
|
|
324
|
+
const rec = claudecode.RECOMMENDED_ROLES[role] === models[role] ? C.green(' ← 推荐默认') : '';
|
|
325
|
+
console.log(` ${claudecode.ROLE_LABELS[role].padEnd(16)} ${C.cyan(models[role])}${rec}`);
|
|
326
|
+
}
|
|
327
|
+
const ans = await ask(rl, '\n是否立即将配置应用到 Claude Code? (Y/n): ');
|
|
328
|
+
if (ans && ans.toLowerCase() !== 'y') {
|
|
329
|
+
console.log(C.yellow('已取消。'));
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const r = claudecode.applyConfig({ apiKey: cfg.apiKey, models });
|
|
333
|
+
console.log(C.green('✓ 配置已同步'));
|
|
334
|
+
console.log(C.dim(` 环境变量(端点/密钥/四模型) → ${r.settingsFile}`));
|
|
335
|
+
console.log(C.dim(` onboarding 标记 → ${r.claudeJsonFile}`));
|
|
336
|
+
} else if (act.key === 'reload') {
|
|
337
|
+
const st2 = claudecode.status();
|
|
338
|
+
if (!st2.hasToken) {
|
|
339
|
+
console.log(C.yellow('尚未配置 Claude Code。请先选择“配置模型”完成初始配置。'));
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (st2.baseURL === claudecode.ANTHROPIC_BASE_URL) {
|
|
343
|
+
console.log(C.green(`✓ 配置装载完成,端点已是 ${claudecode.ANTHROPIC_BASE_URL}(根端点,无 /v1 路径)。`));
|
|
344
|
+
} else {
|
|
345
|
+
console.log(C.yellow(`当前端点为 ${st2.baseURL},请通过“配置模型”重新应用以规范化。`));
|
|
346
|
+
}
|
|
347
|
+
console.log(C.dim('请完全退出并重新启动 Claude Code 后运行 /status 核对。'));
|
|
348
|
+
} else if (act.key === 'clear') {
|
|
349
|
+
const r = claudecode.clearGatewayEnv();
|
|
350
|
+
if (r.ok) {
|
|
351
|
+
console.log(C.green(`✓ 已移除 ${r.removed.length} 个 ANTHROPIC_* 环境变量,Claude Code 将恢复官方登录。`));
|
|
352
|
+
} else {
|
|
353
|
+
console.log(C.yellow('未找到可清除的配置。'));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function codexMenu(rl) {
|
|
360
|
+
const cfg = store.read();
|
|
361
|
+
const st = codexcli.status();
|
|
362
|
+
|
|
363
|
+
console.log('\n' + C.bold('── Codex 管理 ──'));
|
|
364
|
+
console.log(` CODEX_HOME: ${C.dim(codexcli.codexHome())}`);
|
|
365
|
+
console.log(` Provider: ${st.hasProvider ? C.green(codexcli.PROVIDER_ID) : C.dim('未配置')}`);
|
|
366
|
+
console.log(` API 端点: ${st.baseUrl || C.dim('未配置')}`);
|
|
367
|
+
console.log(` 请求协议: ${st.wireApi ? C.cyan(st.wireApi) : C.dim('未配置')}`);
|
|
368
|
+
console.log(` 模型: ${st.model || C.dim('未配置')}`);
|
|
369
|
+
console.log(` API Key: ${st.hasKey ? C.green(st.keyMask) : C.red('未设置')}`);
|
|
370
|
+
|
|
371
|
+
const probe = await codexcli.probeResponsesSupport(cfg.apiKey);
|
|
372
|
+
if (!probe.supported) {
|
|
373
|
+
console.log('');
|
|
374
|
+
console.log(C.red('⚠ 平台暂不支持 OpenAI Responses API(Codex 必需):' + probe.detail));
|
|
375
|
+
console.log(C.yellow(' Codex CLI 0.15x 已移除 wire_api = "chat",当前无法接入云智。'));
|
|
376
|
+
console.log(C.dim(' 待平台上线 /v1/responses 后,本菜单的配置功能即可使用。'));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (!probe.usable) {
|
|
380
|
+
console.log('');
|
|
381
|
+
console.log(C.yellow('⚠ Responses API 已上线,但 Codex 关键路径暂不可用:' + probe.detail));
|
|
382
|
+
console.log(C.dim(' Codex 始终以「数组 input + 流式」请求,该组合当前被网关上游拒绝(upstream_error)。'));
|
|
383
|
+
console.log(C.dim(' 修复后重新进入本菜单即可配置;也可先用「测试真实请求」验证网关状态。'));
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
console.log(C.green(`✓ 平台 Responses API 可用(${probe.detail})`));
|
|
387
|
+
|
|
388
|
+
const act = await pick(rl, 'Codex 管理菜单', [
|
|
389
|
+
{ key: 'models', label: '配置模型 - (选择模型)' },
|
|
390
|
+
{ key: 'back', label: '返回主菜单' },
|
|
391
|
+
]);
|
|
392
|
+
if (!act || act.key === 'back') return;
|
|
393
|
+
|
|
394
|
+
if (act.key === 'models') {
|
|
395
|
+
if (!cfg.apiKey) {
|
|
396
|
+
console.log(C.red('请先在主菜单配置 API Key。'));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const result = await fetchModels(cfg.apiKey);
|
|
400
|
+
if (!result.ok) {
|
|
401
|
+
console.log(C.red(`模型列表获取失败:${result.message}`));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const label = (id) => {
|
|
405
|
+
const meta = MODEL_META[id];
|
|
406
|
+
return meta ? `${id} ${C.dim(`(${meta.vendor} · ${meta.desc})`)}` : id;
|
|
407
|
+
};
|
|
408
|
+
const picked = await pick(rl, '选择模型(推荐 glm-5.3)', result.models.map((id) => ({ key: id, label: label(id) })));
|
|
409
|
+
if (!picked) {
|
|
410
|
+
console.log(C.yellow('已取消。'));
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const ans = await ask(rl, '\n是否立即将配置应用到 Codex? (Y/n): ');
|
|
414
|
+
if (ans && ans.toLowerCase() !== 'y') {
|
|
415
|
+
console.log(C.yellow('已取消。'));
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const r = codexcli.applyConfig({ apiKey: cfg.apiKey, model: picked.key });
|
|
419
|
+
console.log(C.green('✓ 配置已同步'));
|
|
420
|
+
console.log(C.dim(` Provider、端点和模型 → ${r.configFile} (wire_api = "responses")`));
|
|
421
|
+
console.log(C.dim(` 凭据 → ${r.authFile} (0600, cli_auth_credentials_store = "file")`));
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function testRequest(rl) {
|
|
426
|
+
const cfg = store.read();
|
|
427
|
+
const st = opencode.status();
|
|
428
|
+
const model = st.mainModel && st.mainModel.split('/')[1];
|
|
429
|
+
if (!cfg.apiKey) {
|
|
430
|
+
console.log(C.red('请先在主菜单配置 API Key。'));
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const useModel = model || (await ask(rl, '请输入要测试的模型 ID: '));
|
|
434
|
+
if (!useModel) return;
|
|
435
|
+
console.log(C.dim(`正在向 ${BASE_URL}/chat/completions 发起真实请求 (model: ${useModel})...`));
|
|
436
|
+
const r = await testChat(cfg.apiKey, useModel);
|
|
437
|
+
if (r.ok) {
|
|
438
|
+
console.log(C.green('✓ 真实请求成功,模型回复:') + r.reply);
|
|
439
|
+
} else {
|
|
440
|
+
console.log(C.red(`✗ 请求失败:${r.message}`));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function showStatus() {
|
|
445
|
+
const cfg = store.read();
|
|
446
|
+
const st = opencode.status();
|
|
447
|
+
const cs = claudecode.status();
|
|
448
|
+
const cx = codexcli.status();
|
|
449
|
+
console.log('\n' + C.bold('── 当前配置 ──'));
|
|
450
|
+
console.log(`Helper 配置文件: ${store.HELPER_CONFIG}`);
|
|
451
|
+
console.log(`API Key: ${cfg.apiKey ? C.green('已设置 ' + maskKey(cfg.apiKey)) : C.red('未设置')}`);
|
|
452
|
+
console.log(`端点: ${cfg.baseURL || C.dim('未记录')}`);
|
|
453
|
+
console.log(`OpenCode 配置: ${st.configFile}`);
|
|
454
|
+
console.log(`OpenCode 主模型: ${st.mainModel || C.dim('未配置')}`);
|
|
455
|
+
console.log(`OpenCode 小模型: ${st.smallModel || C.dim('未配置')}`);
|
|
456
|
+
console.log(`Claude Code 配置: ${cs.settingsFile} ${cs.hasToken ? C.green('(已配置)') : C.dim('(未配置)')}`);
|
|
457
|
+
console.log(`Codex 配置: ${cx.configFile} ${cx.hasProvider ? C.green('(已配置)') : C.dim('(未配置)')}`);
|
|
458
|
+
console.log(C.dim(`Powered by ${BRAND.name} · ${BRAND.vendor}`));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function main() {
|
|
462
|
+
const argv = process.argv.slice(2);
|
|
463
|
+
if (argv.includes('--version') || argv.includes('-v')) {
|
|
464
|
+
console.log(`${BRAND.product} v${VERSION} · Powered by ${BRAND.name}`);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
468
|
+
printBanner();
|
|
469
|
+
console.log('用法: yunzhi-helper 不带参数,进入交互配置菜单');
|
|
470
|
+
console.log(' yunzhi-helper --version 查看版本');
|
|
471
|
+
console.log(' yunzhi-helper --help 显示本帮助');
|
|
472
|
+
console.log('');
|
|
473
|
+
console.log('配置编码工具: OpenCode / Claude Code / Codex(菜单内完成)');
|
|
474
|
+
console.log(C.dim(`Powered by ${BRAND.name} · ${BRAND.vendor}`));
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (!process.stdin.isTTY) {
|
|
478
|
+
console.error('请在交互式终端中运行: yunzhi-helper');
|
|
479
|
+
process.exit(1);
|
|
480
|
+
}
|
|
481
|
+
printBanner();
|
|
482
|
+
const rl = makeRl();
|
|
483
|
+
try {
|
|
484
|
+
for (;;) {
|
|
485
|
+
const cfg = store.read();
|
|
486
|
+
const act = await pick(rl, `── ${BRAND.product} 主菜单 ──`, [
|
|
487
|
+
{
|
|
488
|
+
key: 'key',
|
|
489
|
+
label: cfg.apiKey ? '配置 API Key' : '输入 API Key',
|
|
490
|
+
meta: cfg.apiKey ? maskKey(cfg.apiKey) : '尚未保存密钥',
|
|
491
|
+
},
|
|
492
|
+
{ key: 'tool', label: '配置编码工具', meta: 'OpenCode / Claude Code / Codex' },
|
|
493
|
+
{ key: 'test', label: '测试真实请求', meta: 'chat/completions' },
|
|
494
|
+
{ key: 'status', label: '查看当前配置' },
|
|
495
|
+
{ key: 'exit', label: '退出' },
|
|
496
|
+
]);
|
|
497
|
+
if (!act || act.key === 'exit') break;
|
|
498
|
+
if (act.key === 'key') await configureApiKey(rl);
|
|
499
|
+
if (act.key === 'tool') {
|
|
500
|
+
const tool = await pick(rl, '选择编码工具', [
|
|
501
|
+
{ key: 'opencode', label: 'OpenCode' },
|
|
502
|
+
{ key: 'claude', label: 'Claude Code' },
|
|
503
|
+
{ key: 'codex', label: 'Codex', meta: '需平台 Responses API' },
|
|
504
|
+
]);
|
|
505
|
+
if (tool && tool.key === 'opencode') await opencodeMenu(rl);
|
|
506
|
+
if (tool && tool.key === 'claude') await claudeMenu(rl);
|
|
507
|
+
if (tool && tool.key === 'codex') await codexMenu(rl);
|
|
508
|
+
}
|
|
509
|
+
if (act.key === 'test') await testRequest(rl);
|
|
510
|
+
if (act.key === 'status') showStatus();
|
|
511
|
+
}
|
|
512
|
+
} finally {
|
|
513
|
+
rl.close();
|
|
514
|
+
}
|
|
515
|
+
console.log(C.dim(`\nPowered by ${BRAND.name} · ${BRAND.vendor}`));
|
|
516
|
+
console.log(C.dim('再见。'));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
main().catch((err) => {
|
|
520
|
+
console.error(C.red(`发生错误:${err && err.stack ? err.stack : err}`));
|
|
521
|
+
process.exit(1);
|
|
522
|
+
});
|
package/lib/api.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* lib/api.js - 云智 Token Hub API 访问层
|
|
5
|
+
* 端点:https://maas.piteyun.com/v1(OpenAI 兼容)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const BASE_URL = 'https://maas.piteyun.com/v1';
|
|
9
|
+
|
|
10
|
+
/** 内置模型元数据(来自模型广场,用于菜单展示;未收录模型将回退为通用展示) */
|
|
11
|
+
const MODEL_META = {
|
|
12
|
+
'glm-5.3': { vendor: '智谱 AI', desc: '旗舰模型,编码/推理能力强' },
|
|
13
|
+
'glm-5.3-flash': { vendor: '智谱 AI', desc: '快速轻量,适合辅助任务' },
|
|
14
|
+
'kimi-k2.7': { vendor: '月之暗面 Kimi', desc: '长上下文通用模型' },
|
|
15
|
+
'qwen3.7-max': { vendor: '阿里通义千问', desc: '通义旗舰' },
|
|
16
|
+
'qwen3.8-flash-free': { vendor: '阿里通义千问', desc: '无限火力可用' },
|
|
17
|
+
'deepseek-v4-flash-0731': { vendor: 'DeepSeek', desc: '快速,支持缓存计价' },
|
|
18
|
+
'deepseek-v4-flash-free': { vendor: 'DeepSeek', desc: '无限火力可用' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** 教程推荐默认模型(非强制) */
|
|
22
|
+
const RECOMMENDED = {
|
|
23
|
+
main: 'glm-5.3',
|
|
24
|
+
small: 'glm-5.3-flash',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function maskKey(key) {
|
|
28
|
+
if (!key || key.length < 12) return '••••••••';
|
|
29
|
+
return key.slice(0, 6) + '••••...' + key.slice(-4);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 用 API Key 请求模型列表,同时完成密钥验证。
|
|
34
|
+
* @returns {Promise<{ok: true, models: string[]} | {ok: false, status?: number, message: string}>}
|
|
35
|
+
*/
|
|
36
|
+
async function fetchModels(apiKey, { timeoutMs = 15000 } = {}) {
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
39
|
+
try {
|
|
40
|
+
const resp = await fetch(`${BASE_URL}/models`, {
|
|
41
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
42
|
+
signal: controller.signal,
|
|
43
|
+
});
|
|
44
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
45
|
+
return { ok: false, status: resp.status, message: 'API Key 无效或已过期' };
|
|
46
|
+
}
|
|
47
|
+
if (!resp.ok) {
|
|
48
|
+
return { ok: false, status: resp.status, message: `服务返回 HTTP ${resp.status}` };
|
|
49
|
+
}
|
|
50
|
+
const body = await resp.json();
|
|
51
|
+
const ids = Array.isArray(body && body.data)
|
|
52
|
+
? body.data.map((m) => (typeof m === 'string' ? m : m && m.id)).filter(Boolean)
|
|
53
|
+
: [];
|
|
54
|
+
if (!ids.length) {
|
|
55
|
+
return { ok: false, status: resp.status, message: '密钥有效,但模型列表为空' };
|
|
56
|
+
}
|
|
57
|
+
return { ok: true, models: ids.sort() };
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err && err.name === 'AbortError') {
|
|
60
|
+
return { ok: false, message: `请求超时(${timeoutMs / 1000}s),请检查网络` };
|
|
61
|
+
}
|
|
62
|
+
return { ok: false, message: `无法连接 ${BASE_URL}:${err && err.message}` };
|
|
63
|
+
} finally {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 发起一次真实 chat 请求验证端到端可用。
|
|
70
|
+
* @returns {Promise<{ok: true, reply: string} | {ok: false, message: string}>}
|
|
71
|
+
*/
|
|
72
|
+
async function testChat(apiKey, model, { timeoutMs = 60000 } = {}) {
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
75
|
+
try {
|
|
76
|
+
const resp = await fetch(`${BASE_URL}/chat/completions`, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: {
|
|
79
|
+
Authorization: `Bearer ${apiKey}`,
|
|
80
|
+
'Content-Type': 'application/json',
|
|
81
|
+
},
|
|
82
|
+
body: JSON.stringify({
|
|
83
|
+
model,
|
|
84
|
+
messages: [
|
|
85
|
+
{ role: 'user', content: '只回复"配置成功",不要创建、修改或删除任何文件。' },
|
|
86
|
+
],
|
|
87
|
+
stream: false,
|
|
88
|
+
}),
|
|
89
|
+
signal: controller.signal,
|
|
90
|
+
});
|
|
91
|
+
if (!resp.ok) {
|
|
92
|
+
const text = await resp.text().catch(() => '');
|
|
93
|
+
return { ok: false, message: `HTTP ${resp.status} ${text.slice(0, 200)}`.trim() };
|
|
94
|
+
}
|
|
95
|
+
const body = await resp.json();
|
|
96
|
+
// 云智网关非流式响应为包裹结构 {code, message, data:{choices:[...]}},标准结构则直接使用
|
|
97
|
+
const payload = body && body.data && body.data.choices ? body.data : body;
|
|
98
|
+
const message = payload && payload.choices && payload.choices[0] && payload.choices[0].message;
|
|
99
|
+
const reply = message ? String(message.content || '').trim() : '';
|
|
100
|
+
if (!reply && message && message.reasoning_content) {
|
|
101
|
+
return { ok: true, reply: '(模型仅输出推理内容,未给出最终文本)' };
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, reply };
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (err && err.name === 'AbortError') {
|
|
106
|
+
return { ok: false, message: `请求超时(${timeoutMs / 1000}s)` };
|
|
107
|
+
}
|
|
108
|
+
return { ok: false, message: `请求失败:${err && err.message}` };
|
|
109
|
+
} finally {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { BASE_URL, MODEL_META, RECOMMENDED, maskKey, fetchModels, testChat };
|
package/lib/claude.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* lib/claude.js - Claude Code 配置读写
|
|
5
|
+
* Claude Code 通过环境变量接入网关:
|
|
6
|
+
* ANTHROPIC_BASE_URL = Anthropic 根端点(不带 /v1,Claude Code 自行拼接 /v1/messages)
|
|
7
|
+
* ANTHROPIC_AUTH_TOKEN = 云智 API Key
|
|
8
|
+
* ANTHROPIC_MODEL = 默认(Sonnet 槽位)模型
|
|
9
|
+
* ANTHROPIC_SMALL_FAST_MODEL = 小模型(Haiku 槽位)
|
|
10
|
+
* ANTHROPIC_DEFAULT_HAIKU/SONNET/OPUS/FABLE_MODEL = 四角色显式映射
|
|
11
|
+
* 配置写入 ~/.claude/settings.json(保留其他字段),并在 ~/.claude.json 中完成 onboarding 标记。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
|
|
18
|
+
const ANTHROPIC_BASE_URL = 'https://maas.piteyun.com';
|
|
19
|
+
|
|
20
|
+
const CLAUDE_DIR = path.join(os.homedir(), '.claude');
|
|
21
|
+
const CLAUDE_SETTINGS = path.join(CLAUDE_DIR, 'settings.json');
|
|
22
|
+
const CLAUDE_JSON = path.join(os.homedir(), '.claude.json');
|
|
23
|
+
|
|
24
|
+
/** 教程推荐默认四角色映射(可从实时列表改选其他模型) */
|
|
25
|
+
const RECOMMENDED_ROLES = {
|
|
26
|
+
haiku: 'glm-5.3-flash',
|
|
27
|
+
sonnet: 'glm-5.3',
|
|
28
|
+
opus: 'kimi-k2.7',
|
|
29
|
+
fable: 'qwen3.7-max',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const ROLE_LABELS = {
|
|
33
|
+
haiku: 'Haiku (快速/轻量)',
|
|
34
|
+
sonnet: 'Sonnet (日常主力)',
|
|
35
|
+
opus: 'Opus (复杂任务)',
|
|
36
|
+
fable: 'Fable (长程/规划)',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function readJsonSafe(file) {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writeJson(file, obj) {
|
|
48
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
49
|
+
fs.writeFileSync(file, JSON.stringify(obj, null, 2) + '\n', 'utf8');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 应用配置到 Claude Code。
|
|
54
|
+
* @returns {{settingsFile: string, claudeJsonFile: string}}
|
|
55
|
+
*/
|
|
56
|
+
function applyConfig({ apiKey, models }) {
|
|
57
|
+
// models: { haiku, sonnet, opus, fable }
|
|
58
|
+
const settings = readJsonSafe(CLAUDE_SETTINGS) || {};
|
|
59
|
+
settings.env = Object.assign({}, settings.env, {
|
|
60
|
+
ANTHROPIC_BASE_URL,
|
|
61
|
+
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
62
|
+
ANTHROPIC_MODEL: models.sonnet,
|
|
63
|
+
ANTHROPIC_SMALL_FAST_MODEL: models.haiku,
|
|
64
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
|
|
65
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
|
|
66
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
|
|
67
|
+
ANTHROPIC_DEFAULT_FABLE_MODEL: models.fable,
|
|
68
|
+
});
|
|
69
|
+
writeJson(CLAUDE_SETTINGS, settings);
|
|
70
|
+
|
|
71
|
+
// onboarding 标记(合并写入,不覆盖其他字段)
|
|
72
|
+
const claudeJson = readJsonSafe(CLAUDE_JSON) || {};
|
|
73
|
+
claudeJson.hasCompletedOnboarding = true;
|
|
74
|
+
writeJson(CLAUDE_JSON, claudeJson);
|
|
75
|
+
|
|
76
|
+
return { settingsFile: CLAUDE_SETTINGS, claudeJsonFile: CLAUDE_JSON };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 清除网关 env(恢复官方登录方式时使用) */
|
|
80
|
+
function clearGatewayEnv() {
|
|
81
|
+
const settings = readJsonSafe(CLAUDE_SETTINGS);
|
|
82
|
+
if (!settings || !settings.env) return { ok: false };
|
|
83
|
+
const keys = Object.keys(settings.env).filter((k) => k.startsWith('ANTHROPIC_'));
|
|
84
|
+
for (const k of keys) delete settings.env[k];
|
|
85
|
+
writeJson(CLAUDE_SETTINGS, settings);
|
|
86
|
+
return { ok: true, removed: keys };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function status() {
|
|
90
|
+
const settings = readJsonSafe(CLAUDE_SETTINGS) || {};
|
|
91
|
+
const env = settings.env || {};
|
|
92
|
+
const claudeJson = readJsonSafe(CLAUDE_JSON) || {};
|
|
93
|
+
return {
|
|
94
|
+
baseURL: env.ANTHROPIC_BASE_URL || null,
|
|
95
|
+
hasToken: Boolean(env.ANTHROPIC_AUTH_TOKEN),
|
|
96
|
+
tokenMask: env.ANTHROPIC_AUTH_TOKEN
|
|
97
|
+
? env.ANTHROPIC_AUTH_TOKEN.slice(0, 6) + '••••...' + env.ANTHROPIC_AUTH_TOKEN.slice(-4)
|
|
98
|
+
: null,
|
|
99
|
+
models: {
|
|
100
|
+
haiku: env.ANTHROPIC_DEFAULT_HAIKU_MODEL || env.ANTHROPIC_SMALL_FAST_MODEL || null,
|
|
101
|
+
sonnet: env.ANTHROPIC_DEFAULT_SONNET_MODEL || env.ANTHROPIC_MODEL || null,
|
|
102
|
+
opus: env.ANTHROPIC_DEFAULT_OPUS_MODEL || null,
|
|
103
|
+
fable: env.ANTHROPIC_DEFAULT_FABLE_MODEL || null,
|
|
104
|
+
},
|
|
105
|
+
onboarding: claudeJson.hasCompletedOnboarding === true,
|
|
106
|
+
settingsFile: CLAUDE_SETTINGS,
|
|
107
|
+
claudeJsonFile: CLAUDE_JSON,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = {
|
|
112
|
+
ANTHROPIC_BASE_URL,
|
|
113
|
+
CLAUDE_SETTINGS,
|
|
114
|
+
CLAUDE_JSON,
|
|
115
|
+
RECOMMENDED_ROLES,
|
|
116
|
+
ROLE_LABELS,
|
|
117
|
+
applyConfig,
|
|
118
|
+
clearGatewayEnv,
|
|
119
|
+
status,
|
|
120
|
+
};
|
package/lib/codex.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* lib/codex.js - Codex CLI 配置读写
|
|
5
|
+
*
|
|
6
|
+
* Codex 自定义 Provider 走 $CODEX_HOME/config.toml(TOML)+ auth.json(凭据)。
|
|
7
|
+
* ⚠️ 平台现状(2026-09-13 实测):云智 Token Hub 当前仅提供 OpenAI Chat Completions
|
|
8
|
+
* (/v1/chat/completions);OpenAI Responses API(/v1/responses)返回 404。
|
|
9
|
+
* 而 Codex CLI 0.154.0 起已移除 wire_api = "chat"(报错 "chat is no longer supported",
|
|
10
|
+
* 参见 github.com/openai/codex/discussions/7782),自定义 Provider 强制 wire_api = "responses"。
|
|
11
|
+
* 因此本模块提供:
|
|
12
|
+
* - probeResponsesSupport(): 探测平台是否已上线 Responses API
|
|
13
|
+
* - applyConfig(): 平台支持时写入完整 Provider(wire_api = "responses")
|
|
14
|
+
* 配置分离:公开配置 → config.toml;凭据 → auth.json(0600);并设置
|
|
15
|
+
* cli_auth_credentials_store = "file",确保 Codex 读取 auth.json 而不是 keyring。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
|
|
22
|
+
const { BASE_URL } = require('./api');
|
|
23
|
+
|
|
24
|
+
const PROVIDER_ID = 'yunzhi';
|
|
25
|
+
|
|
26
|
+
function codexHome() {
|
|
27
|
+
return process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function configPath() {
|
|
31
|
+
return path.join(codexHome(), 'config.toml');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function authPath() {
|
|
35
|
+
return path.join(codexHome(), 'auth.json');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 探测平台 Responses API 支持情况(两级):
|
|
40
|
+
* 1. 路由是否存在(JSON 响应 vs HTML 兜底页 —— 云智 404 是 200+text/html,必须查 content-type)
|
|
41
|
+
* 2. Codex 关键路径是否可用:数组 input + stream:true(Codex 始终发此组合;
|
|
42
|
+
* 2026-09-13 实测网关对该组合返回 upstream_error,字符串 input 则正常)
|
|
43
|
+
* @returns {Promise<{supported: boolean, usable: boolean, detail: string}>}
|
|
44
|
+
*/
|
|
45
|
+
async function probeResponsesSupport(apiKey) {
|
|
46
|
+
const post = (body) => fetch(`${BASE_URL}/responses`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: {
|
|
49
|
+
'Content-Type': 'application/json',
|
|
50
|
+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
51
|
+
},
|
|
52
|
+
body: JSON.stringify(body),
|
|
53
|
+
});
|
|
54
|
+
try {
|
|
55
|
+
// 第一级:路由探测(非流式,最小请求)
|
|
56
|
+
const r1 = await post({ model: 'glm-5.3-flash', input: 'ping', stream: false });
|
|
57
|
+
const ct1 = (r1.headers.get('content-type') || '').toLowerCase();
|
|
58
|
+
if (ct1.includes('text/html') || r1.status === 404) {
|
|
59
|
+
return { supported: false, usable: false, detail: `${BASE_URL}/responses 返回 HTML 兜底页,平台尚未提供 OpenAI Responses API` };
|
|
60
|
+
}
|
|
61
|
+
if (!ct1.includes('json')) {
|
|
62
|
+
return { supported: false, usable: false, detail: `探测响应异常(${ct1})` };
|
|
63
|
+
}
|
|
64
|
+
// 第二级:Codex 关键路径(数组 input + 流式)
|
|
65
|
+
let usable = true;
|
|
66
|
+
let flowDetail = '';
|
|
67
|
+
try {
|
|
68
|
+
const r2 = await post({
|
|
69
|
+
model: 'glm-5.3-flash',
|
|
70
|
+
input: [{ role: 'user', content: [{ type: 'input_text', text: 'ping' }] }],
|
|
71
|
+
stream: true,
|
|
72
|
+
});
|
|
73
|
+
const ct2 = (r2.headers.get('content-type') || '').toLowerCase();
|
|
74
|
+
const text2 = await r2.text();
|
|
75
|
+
if (ct2.includes('text/html')) {
|
|
76
|
+
usable = false; flowDetail = '流式返回 HTML 兜底页';
|
|
77
|
+
} else if (text2.includes('upstream_error') || text2.includes('"type":"error"')) {
|
|
78
|
+
usable = false; flowDetail = '数组 input + 流式返回 upstream_error';
|
|
79
|
+
}
|
|
80
|
+
} catch (e2) {
|
|
81
|
+
usable = false; flowDetail = `流式探测请求失败:${e2 && e2.message}`;
|
|
82
|
+
}
|
|
83
|
+
if (!usable) {
|
|
84
|
+
return {
|
|
85
|
+
supported: true, usable: false,
|
|
86
|
+
detail: `Responses API 已上线,但 Codex 关键路径不可用(${flowDetail})——待网关修复数组 input + 流式组合`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return { supported: true, usable: true, detail: '端点与 Codex 关键路径(数组 input + 流式)均正常' };
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return { supported: false, usable: false, detail: `探测请求失败:${err && err.message}` };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ---------- TOML 读写 ---------- */
|
|
96
|
+
|
|
97
|
+
/** 读取顶层简单键值(key = "value") */
|
|
98
|
+
function readTopLevelScalar(text, key) {
|
|
99
|
+
const re = new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, 'm');
|
|
100
|
+
const m = text.match(re);
|
|
101
|
+
return m ? m[1] : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 替换或追加一个 [[section]] 块。
|
|
106
|
+
* sectionHeader 形如 "[model_providers.yunzhi]";
|
|
107
|
+
* blockText 为完整块文本(含 header 行)。
|
|
108
|
+
*/
|
|
109
|
+
function upsertSection(text, sectionHeader, blockText) {
|
|
110
|
+
const escaped = sectionHeader.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
111
|
+
const re = new RegExp(`(^|\\n)\\s*${escaped}\\s*\\n[\\s\\S]*?(?=\\n\\s*\\[|\\n*$)`, '');
|
|
112
|
+
const m = text.match(re);
|
|
113
|
+
if (m) {
|
|
114
|
+
return text.replace(re, `$1${blockText}\n`);
|
|
115
|
+
}
|
|
116
|
+
const sep = text.trim().length ? '\n\n' : '';
|
|
117
|
+
return text.trimEnd() + sep + blockText + '\n';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 设置/替换顶层字符串键(插入到文件最前,或替换已有行) */
|
|
121
|
+
function setTopLevelString(text, key, value) {
|
|
122
|
+
const re = new RegExp(`^\\s*${key}\\s*=\\s*"[^"]*"\\s*$`, 'm');
|
|
123
|
+
if (re.test(text)) {
|
|
124
|
+
return text.replace(re, `${key} = "${value}"`);
|
|
125
|
+
}
|
|
126
|
+
return `${key} = "${value}"\n` + text;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function writeAtomic(file, content) {
|
|
130
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
131
|
+
const tmp = file + '.tmp-' + process.pid;
|
|
132
|
+
fs.writeFileSync(tmp, content, 'utf8');
|
|
133
|
+
fs.renameSync(tmp, file);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 应用 Codex 配置(要求平台 Responses API 已上线且关键路径可用)。
|
|
138
|
+
* 认证:Codex 自定义 Provider 通过 env_key 指定的环境变量读取密钥
|
|
139
|
+
* (实测 auth.json 的 OPENAI_API_KEY 对自定义 Provider 不生效,返回 401 missing API key)。
|
|
140
|
+
* @param {{apiKey: string, model: string}} p
|
|
141
|
+
*/
|
|
142
|
+
function applyConfig({ apiKey, model }) {
|
|
143
|
+
const home = codexHome();
|
|
144
|
+
const cfgFile = configPath();
|
|
145
|
+
const authFile = authPath();
|
|
146
|
+
let toml = '';
|
|
147
|
+
try {
|
|
148
|
+
toml = fs.readFileSync(cfgFile, 'utf8');
|
|
149
|
+
} catch {
|
|
150
|
+
/* 新建 */
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
toml = setTopLevelString(toml, 'model', model);
|
|
154
|
+
toml = setTopLevelString(toml, 'model_provider', PROVIDER_ID);
|
|
155
|
+
toml = setTopLevelString(toml, 'cli_auth_credentials_store', 'file');
|
|
156
|
+
|
|
157
|
+
const block = [
|
|
158
|
+
`[model_providers.${PROVIDER_ID}]`,
|
|
159
|
+
`name = "Yunzhi Token Hub (MaaS)"`,
|
|
160
|
+
`base_url = "${BASE_URL}"`,
|
|
161
|
+
`wire_api = "responses"`,
|
|
162
|
+
`env_key = "YUNZHI_API_KEY"`,
|
|
163
|
+
].join('\n');
|
|
164
|
+
toml = upsertSection(toml, `[model_providers.${PROVIDER_ID}]`, block);
|
|
165
|
+
writeAtomic(cfgFile, toml);
|
|
166
|
+
|
|
167
|
+
// auth.json 同步保存(部分工具读取),并提示设置环境变量
|
|
168
|
+
let auth = {};
|
|
169
|
+
try {
|
|
170
|
+
auth = JSON.parse(fs.readFileSync(authFile, 'utf8')) || {};
|
|
171
|
+
} catch {
|
|
172
|
+
/* 新建 */
|
|
173
|
+
}
|
|
174
|
+
auth.OPENAI_API_KEY = apiKey;
|
|
175
|
+
writeAtomic(authFile, JSON.stringify(auth, null, 2) + '\n');
|
|
176
|
+
try {
|
|
177
|
+
fs.chmodSync(authFile, 0o600);
|
|
178
|
+
} catch {
|
|
179
|
+
/* noop on Windows */
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
configFile: cfgFile,
|
|
184
|
+
authFile,
|
|
185
|
+
envHint: `Codex 通过环境变量 YUNZHI_API_KEY 读取密钥(env_key 已写入配置),请将其加入 shell 配置或系统环境变量`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function status() {
|
|
190
|
+
const cfgFile = configPath();
|
|
191
|
+
const authFile = authPath();
|
|
192
|
+
let toml = '';
|
|
193
|
+
try {
|
|
194
|
+
toml = fs.readFileSync(cfgFile, 'utf8');
|
|
195
|
+
} catch {
|
|
196
|
+
toml = '';
|
|
197
|
+
}
|
|
198
|
+
let auth = {};
|
|
199
|
+
try {
|
|
200
|
+
auth = JSON.parse(fs.readFileSync(authFile, 'utf8')) || {};
|
|
201
|
+
} catch {
|
|
202
|
+
/* 无凭据 */
|
|
203
|
+
}
|
|
204
|
+
const providerBlock = toml.includes(`[model_providers.${PROVIDER_ID}]`);
|
|
205
|
+
// 只在 yunzhi 块内读取 base_url / wire_api,避免误读其他 Provider 的同名键
|
|
206
|
+
let blockText = '';
|
|
207
|
+
if (providerBlock) {
|
|
208
|
+
const blockRe = new RegExp(`\\[model_providers\\.${PROVIDER_ID}\\][\\s\\S]*?(?=\\n\\s*\\[|$)`);
|
|
209
|
+
blockText = (toml.match(blockRe) || [''])[0];
|
|
210
|
+
}
|
|
211
|
+
const baseUrlMatch = blockText.match(/^base_url\s*=\s*"([^"]*)"\s*$/m);
|
|
212
|
+
const wireApiMatch = blockText.match(/^wire_api\s*=\s*"([^"]*)"\s*$/m);
|
|
213
|
+
return {
|
|
214
|
+
hasProvider: providerBlock,
|
|
215
|
+
wireApi: wireApiMatch ? wireApiMatch[1] : null,
|
|
216
|
+
baseUrl: providerBlock && baseUrlMatch ? baseUrlMatch[1] : null,
|
|
217
|
+
model: readTopLevelScalar(toml, 'model'),
|
|
218
|
+
modelProvider: readTopLevelScalar(toml, 'model_provider'),
|
|
219
|
+
hasKey: Boolean(auth.OPENAI_API_KEY),
|
|
220
|
+
keyMask: auth.OPENAI_API_KEY
|
|
221
|
+
? auth.OPENAI_API_KEY.slice(0, 6) + '••••...' + auth.OPENAI_API_KEY.slice(-4)
|
|
222
|
+
: null,
|
|
223
|
+
configFile: cfgFile,
|
|
224
|
+
authFile,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = {
|
|
229
|
+
PROVIDER_ID,
|
|
230
|
+
codexHome,
|
|
231
|
+
configPath,
|
|
232
|
+
authPath,
|
|
233
|
+
probeResponsesSupport,
|
|
234
|
+
applyConfig,
|
|
235
|
+
status,
|
|
236
|
+
readTopLevelScalar,
|
|
237
|
+
upsertSection,
|
|
238
|
+
setTopLevelString,
|
|
239
|
+
};
|
package/lib/opencode.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* lib/opencode.js - OpenCode 配置读写
|
|
5
|
+
* 公开配置(Provider、端点、模型):~/.config/opencode/opencode.jsonc
|
|
6
|
+
* 凭据(API Key): ~/.local/share/opencode/auth.json
|
|
7
|
+
* 两者分开保存,凭据文件权限应为 0600(POSIX)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
|
|
14
|
+
const { BASE_URL } = require('./api');
|
|
15
|
+
|
|
16
|
+
const PROVIDER_ID = 'yunzhi';
|
|
17
|
+
const OPENCODE_DIR = path.join(os.homedir(), '.config', 'opencode');
|
|
18
|
+
const OPENCODE_CONFIG = path.join(OPENCODE_DIR, 'opencode.jsonc');
|
|
19
|
+
const OPENCODE_AUTH_DIR = path.join(os.homedir(), '.local', 'share', 'opencode');
|
|
20
|
+
const OPENCODE_AUTH = path.join(OPENCODE_AUTH_DIR, 'auth.json');
|
|
21
|
+
|
|
22
|
+
/* ---------- 简易 JSONC 解析(跳过字符串内的 // 与 /* */,容忍尾逗号) ---------- */
|
|
23
|
+
|
|
24
|
+
function stripJsonComments(text) {
|
|
25
|
+
let out = '';
|
|
26
|
+
let i = 0;
|
|
27
|
+
let inStr = false;
|
|
28
|
+
let strCh = '';
|
|
29
|
+
while (i < text.length) {
|
|
30
|
+
const c = text[i];
|
|
31
|
+
const next = text[i + 1];
|
|
32
|
+
if (inStr) {
|
|
33
|
+
out += c;
|
|
34
|
+
if (c === '\\') {
|
|
35
|
+
out += next || '';
|
|
36
|
+
i += 2;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (c === strCh) inStr = false;
|
|
40
|
+
i += 1;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (c === '"' || c === "'") {
|
|
44
|
+
inStr = true;
|
|
45
|
+
strCh = c;
|
|
46
|
+
out += c;
|
|
47
|
+
i += 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (c === '/' && next === '/') {
|
|
51
|
+
while (i < text.length && text[i] !== '\n') i += 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (c === '/' && next === '*') {
|
|
55
|
+
i += 2;
|
|
56
|
+
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
|
|
57
|
+
i += 2;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
out += c;
|
|
61
|
+
i += 1;
|
|
62
|
+
}
|
|
63
|
+
// 去尾逗号
|
|
64
|
+
return out.replace(/,(\s*[}\]])/g, '$1');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readJsonc(file) {
|
|
68
|
+
try {
|
|
69
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
70
|
+
return JSON.parse(stripJsonComments(raw));
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readOpenCodeConfig() {
|
|
77
|
+
// 优先 opencode.jsonc,其次旧版 opencode.json
|
|
78
|
+
const jsonc = readJsonc(OPENCODE_CONFIG);
|
|
79
|
+
if (jsonc) return { config: jsonc, file: OPENCODE_CONFIG };
|
|
80
|
+
const legacy = readJsonc(path.join(OPENCODE_DIR, 'opencode.json'));
|
|
81
|
+
if (legacy) return { config: legacy, file: path.join(OPENCODE_DIR, 'opencode.json') };
|
|
82
|
+
return { config: {}, file: OPENCODE_CONFIG };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readAuth() {
|
|
86
|
+
try {
|
|
87
|
+
const raw = fs.readFileSync(OPENCODE_AUTH, 'utf8');
|
|
88
|
+
const obj = JSON.parse(raw);
|
|
89
|
+
return obj && typeof obj === 'object' ? obj : {};
|
|
90
|
+
} catch {
|
|
91
|
+
return {};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ---------- 写入 ---------- */
|
|
96
|
+
|
|
97
|
+
function writeOpenCodeConfig(cfg, file) {
|
|
98
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
99
|
+
// 注意:重写会丢失原有 JSONC 注释(与 xaio-chelper 行为一致)
|
|
100
|
+
fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function writeAuth(auth) {
|
|
104
|
+
fs.mkdirSync(OPENCODE_AUTH_DIR, { recursive: true });
|
|
105
|
+
fs.writeFileSync(OPENCODE_AUTH, JSON.stringify(auth, null, 2) + '\n', 'utf8');
|
|
106
|
+
try {
|
|
107
|
+
fs.chmodSync(OPENCODE_AUTH, 0o600);
|
|
108
|
+
} catch {
|
|
109
|
+
/* noop on Windows */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 构造 yunzhi provider 块 */
|
|
114
|
+
function buildProvider(models, mainModel, smallModel) {
|
|
115
|
+
const modelsBlock = {};
|
|
116
|
+
for (const id of models) {
|
|
117
|
+
modelsBlock[id] = { name: id };
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
npm: '@ai-sdk/openai-compatible',
|
|
121
|
+
name: 'Yunzhi Token Hub (MaaS)',
|
|
122
|
+
options: { baseURL: BASE_URL },
|
|
123
|
+
models: modelsBlock,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 应用配置:写入 provider + 主/小模型到 opencode 配置,写入凭据到 auth.json。
|
|
129
|
+
* 返回实际写入的文件路径与是否发生旧 key 清理。
|
|
130
|
+
*/
|
|
131
|
+
function applyConfig({ apiKey, models, mainModel, smallModel }) {
|
|
132
|
+
const { config, file } = readOpenCodeConfig();
|
|
133
|
+
|
|
134
|
+
// 清理旧版把 apiKey 放在公开配置里的写法
|
|
135
|
+
let legacyKeyRemoved = false;
|
|
136
|
+
if (
|
|
137
|
+
config.provider &&
|
|
138
|
+
config.provider[PROVIDER_ID] &&
|
|
139
|
+
config.provider[PROVIDER_ID].options &&
|
|
140
|
+
'apiKey' in config.provider[PROVIDER_ID].options
|
|
141
|
+
) {
|
|
142
|
+
delete config.provider[PROVIDER_ID].options.apiKey;
|
|
143
|
+
legacyKeyRemoved = true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
config.$schema = config.$schema || 'https://opencode.ai/config.json';
|
|
147
|
+
config.provider = config.provider || {};
|
|
148
|
+
config.provider[PROVIDER_ID] = buildProvider(models);
|
|
149
|
+
config.model = `${PROVIDER_ID}/${mainModel}`;
|
|
150
|
+
if (smallModel) config.small_model = `${PROVIDER_ID}/${smallModel}`;
|
|
151
|
+
|
|
152
|
+
writeOpenCodeConfig(config, file);
|
|
153
|
+
|
|
154
|
+
const auth = readAuth();
|
|
155
|
+
auth[PROVIDER_ID] = { type: 'api', key: apiKey };
|
|
156
|
+
writeAuth(auth);
|
|
157
|
+
|
|
158
|
+
return { configFile: file, authFile: OPENCODE_AUTH, legacyKeyRemoved };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 仅刷新模型目录,不改动当前主/小模型 */
|
|
162
|
+
function refreshModels(models) {
|
|
163
|
+
const { config, file } = readOpenCodeConfig();
|
|
164
|
+
if (!config.provider || !config.provider[PROVIDER_ID]) {
|
|
165
|
+
return { ok: false, message: '尚未配置 yunzhi Provider,请先在“配置模型”中完成初始配置' };
|
|
166
|
+
}
|
|
167
|
+
config.provider[PROVIDER_ID].models = buildProvider(models).models;
|
|
168
|
+
writeOpenCodeConfig(config, file);
|
|
169
|
+
return { ok: true, file };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 仅规范化端点与 Provider 元数据,不触碰凭据与模型选择 */
|
|
173
|
+
function normalizeEndpoint() {
|
|
174
|
+
const { config, file } = readOpenCodeConfig();
|
|
175
|
+
if (!config.provider || !config.provider[PROVIDER_ID]) {
|
|
176
|
+
return { ok: false };
|
|
177
|
+
}
|
|
178
|
+
config.provider[PROVIDER_ID].options = config.provider[PROVIDER_ID].options || {};
|
|
179
|
+
config.provider[PROVIDER_ID].options.baseURL = BASE_URL;
|
|
180
|
+
config.provider[PROVIDER_ID].name = 'Yunzhi Token Hub (MaaS)';
|
|
181
|
+
writeOpenCodeConfig(config, file);
|
|
182
|
+
return { ok: true, file };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 读取当前配置状态,用于菜单展示 */
|
|
186
|
+
function status() {
|
|
187
|
+
const { config, file } = readOpenCodeConfig();
|
|
188
|
+
const auth = readAuth();
|
|
189
|
+
const provider = config.provider && config.provider[PROVIDER_ID];
|
|
190
|
+
const hasKey = Boolean(auth[PROVIDER_ID] && auth[PROVIDER_ID].key);
|
|
191
|
+
return {
|
|
192
|
+
hasProvider: Boolean(provider),
|
|
193
|
+
baseURL: provider && provider.options ? provider.options.baseURL : null,
|
|
194
|
+
modelCount: provider && provider.models ? Object.keys(provider.models).length : 0,
|
|
195
|
+
mainModel: config.model || null,
|
|
196
|
+
smallModel: config.small_model || null,
|
|
197
|
+
hasKey,
|
|
198
|
+
configFile: file,
|
|
199
|
+
authFile: OPENCODE_AUTH,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = {
|
|
204
|
+
PROVIDER_ID,
|
|
205
|
+
OPENCODE_CONFIG,
|
|
206
|
+
OPENCODE_AUTH,
|
|
207
|
+
readOpenCodeConfig,
|
|
208
|
+
readAuth,
|
|
209
|
+
applyConfig,
|
|
210
|
+
refreshModels,
|
|
211
|
+
normalizeEndpoint,
|
|
212
|
+
status,
|
|
213
|
+
stripJsonComments,
|
|
214
|
+
};
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* lib/store.js - Helper 自身配置读写
|
|
5
|
+
* 位置:~/.yunzhi-maas-helper/config.json
|
|
6
|
+
* 注意:该文件包含明文 API Key,不要提交到代码仓库。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
|
|
13
|
+
const HELPER_DIR = path.join(os.homedir(), '.yunzhi-maas-helper');
|
|
14
|
+
const HELPER_CONFIG = path.join(HELPER_DIR, 'config.json');
|
|
15
|
+
|
|
16
|
+
function read() {
|
|
17
|
+
try {
|
|
18
|
+
const raw = fs.readFileSync(HELPER_CONFIG, 'utf8');
|
|
19
|
+
const obj = JSON.parse(raw);
|
|
20
|
+
return obj && typeof obj === 'object' ? obj : {};
|
|
21
|
+
} catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function write(cfg) {
|
|
27
|
+
fs.mkdirSync(HELPER_DIR, { recursive: true });
|
|
28
|
+
fs.writeFileSync(HELPER_CONFIG, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
|
29
|
+
// 仅 POSIX 生效;Windows 上忽略失败
|
|
30
|
+
try {
|
|
31
|
+
fs.chmodSync(HELPER_CONFIG, 0o600);
|
|
32
|
+
} catch {
|
|
33
|
+
/* noop on Windows */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { HELPER_DIR, HELPER_CONFIG, read, write };
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yunzhi519/maas-helper",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "yunzhi-maas-helper - 云智 Token Hub (MaaS) 编码工具配置助手,一键配置 OpenCode / Claude Code / Codex 的 API Key、端点与模型。Powered by MiniRelax",
|
|
5
|
+
"bin": {
|
|
6
|
+
"yunzhi-helper": "bin/yunzhi-maas-helper.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "bin/yunzhi-maas-helper.js",
|
|
9
|
+
"type": "commonjs",
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=18"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"yunzhi",
|
|
15
|
+
"maas",
|
|
16
|
+
"token-hub",
|
|
17
|
+
"opencode",
|
|
18
|
+
"claude-code",
|
|
19
|
+
"codex",
|
|
20
|
+
"minirelax",
|
|
21
|
+
"cli"
|
|
22
|
+
],
|
|
23
|
+
"author": "MiniRelax (苏州云养智健人工智能科技有限公司)",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"files": [
|
|
26
|
+
"bin",
|
|
27
|
+
"lib",
|
|
28
|
+
"README.md"
|
|
29
|
+
]
|
|
30
|
+
}
|