@xcanwin/manyoyo 8.7.0 → 8.7.2

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.
@@ -0,0 +1,363 @@
1
+ 'use strict';
2
+
3
+ const JSON5 = require('json5');
4
+ const { isDeepStrictEqual } = require('util');
5
+
6
+ // 「模型服务」:一套模型服务 = 一个 runs.<名>(Key 写在它的 env 里),不新增配置结构。
7
+ // 这里只放纯逻辑:认出已有的服务、给出“这个 Key 也能给另一个 Agent 用”的建议、构造要写入的配置改动;
8
+ // 读写文件与接口在 lib/web/model-services.js,写盘走 lib/setup-config + lib/secure-file。
9
+ const { AGENT_ENV_SCHEMA } = require('./init-config');
10
+ const { parseEnvEntry } = require('./runtime-normalizers');
11
+ const { providersForAgent, findProviderByBaseUrl } = require('./provider-presets');
12
+ const { identifyRunAgent, buildAgentRunProfile } = require('./setup');
13
+ const { upsertValueByPath, removeValueByPath } = require('./json5-text-edit');
14
+
15
+ // 界面上说清楚是命令行工具(不是网页版 / 桌面版)
16
+ const AGENT_DISPLAY_LABELS = { claude: 'Claude Code CLI', codex: 'Codex CLI' };
17
+ function agentLabelOf(agent) {
18
+ return AGENT_DISPLAY_LABELS[agent] || AGENT_ENV_SCHEMA[agent].label;
19
+ }
20
+
21
+ const RUN_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,39}$/;
22
+ // 订阅登录用的长期令牌;保存它时要删掉同一个运行配置里会盖过订阅的 API Key 类变量(Claude Code 同时有 Key 时优先用 Key)
23
+ const CLAUDE_OAUTH_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
24
+ const CLAUDE_API_KEYS = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY'];
25
+
26
+ // Claude Code 服务额外可编辑的模型变量(档位别名、子 Agent)与压缩窗口
27
+ // 顺序即强弱:Fable(顶级)> Opus(高级)> Sonnet(标准)> Haiku(轻量)
28
+ const CLAUDE_MODEL_KEYS = ['ANTHROPIC_DEFAULT_FABLE_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL'];
29
+ // 自动压缩窗口:纯数字(以后模型的上下文可能更大,不写死上限)
30
+ const AUTO_COMPACT_PATTERN = /^[1-9][0-9]{4,9}$/;
31
+ const AUTO_COMPACT_KEY = 'CLAUDE_CODE_AUTO_COMPACT_WINDOW';
32
+
33
+ function isPlainObject(value) {
34
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
35
+ }
36
+
37
+ function envText(env, key) {
38
+ const value = isPlainObject(env) ? env[key] : undefined;
39
+ return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '';
40
+ }
41
+
42
+ function keyTail(secret) {
43
+ const text = String(secret || '');
44
+ // 太短的 Key 末 4 位已经泄露了一大半,只显示省略号
45
+ return text.length >= 12 ? `…${text.slice(-4)}` : (text ? '…' : '');
46
+ }
47
+
48
+ // 这个运行配置里实际生效的凭据变量:按 schema 的优先顺序取第一个非空的
49
+ function findKey(spec, env) {
50
+ for (const name of spec.requiredAnyOf) {
51
+ const value = envText(env, name);
52
+ if (value) return { name, value };
53
+ }
54
+ return null;
55
+ }
56
+
57
+ /** 列出已有的模型服务;响应里只有 Key 的末 4 位,绝不带出 Key 本身 */
58
+ function listModelServices(config) {
59
+ const runs = isPlainObject(config && config.runs) ? config.runs : {};
60
+ const services = [];
61
+ for (const [name, run] of Object.entries(runs)) {
62
+ const agent = identifyRunAgent(name, run);
63
+ if (!agent) continue;
64
+ const spec = AGENT_ENV_SCHEMA[agent];
65
+ const env = isPlainObject(run) ? run.env : null;
66
+ const key = findKey(spec, env);
67
+ if (!key) continue;
68
+ const baseUrl = envText(env, spec.baseUrlKey);
69
+ const provider = findProviderByBaseUrl(agent, baseUrl);
70
+ const subscription = key.name === CLAUDE_OAUTH_KEY;
71
+ // 这家服务必需的附加变量里,运行配置里还没有(或值不对)的:界面据此提示“一键修复”
72
+ const missingExtraEnv = {};
73
+ if (provider && !subscription) {
74
+ for (const [extraKey, extraValue] of Object.entries(provider.extraEnv || {})) {
75
+ if (envText(env, extraKey) !== extraValue) missingExtraEnv[extraKey] = extraValue;
76
+ }
77
+ }
78
+ const claudeExtra = agent === 'claude'
79
+ ? {
80
+ models: Object.fromEntries(CLAUDE_MODEL_KEYS.map(key => [key, envText(env, key)])),
81
+ autoCompactWindow: envText(env, AUTO_COMPACT_KEY)
82
+ }
83
+ : {};
84
+ services.push({
85
+ name,
86
+ agent,
87
+ agentLabel: agentLabelOf(agent),
88
+ providerId: provider ? provider.id : '',
89
+ providerLabel: subscription ? '订阅登录' : (provider ? provider.label : (baseUrl ? '自定义地址' : '官方')),
90
+ authMode: subscription ? 'subscription' : 'key',
91
+ baseUrl,
92
+ keyEnv: key.name,
93
+ keyTail: keyTail(key.value),
94
+ model: envText(env, spec.modelKey),
95
+ missingExtraEnv,
96
+ ...claudeExtra
97
+ });
98
+ }
99
+ return services;
100
+ }
101
+
102
+ function keyValueOf(config, name) {
103
+ const run = config.runs && config.runs[name];
104
+ const agent = identifyRunAgent(name, run);
105
+ const found = agent ? findKey(AGENT_ENV_SCHEMA[agent], run.env) : null;
106
+ return found ? found.value : '';
107
+ }
108
+
109
+ /** “这个 Key 也能给另一个 Agent 用”:同一服务商在另一个 Agent 上有预设、且还没有用同一个 Key 的服务 */
110
+ function suggestReuse(config) {
111
+ const services = listModelServices(config);
112
+ const seen = new Set(services.map(item => `${item.agent}|${item.providerId}|${keyValueOf(config, item.name)}`));
113
+ const suggestions = [];
114
+ for (const item of services) {
115
+ if (!item.providerId || item.authMode !== 'key') continue;
116
+ const secret = keyValueOf(config, item.name);
117
+ for (const target of ['claude', 'codex']) {
118
+ if (target === item.agent) continue;
119
+ if (!providersForAgent(target).some(p => p.id === item.providerId)) continue;
120
+ const id = `${target}|${item.providerId}|${secret}`;
121
+ if (seen.has(id)) continue;
122
+ seen.add(id);
123
+ suggestions.push({
124
+ from: item.name,
125
+ agent: target,
126
+ providerId: item.providerId,
127
+ label: `${item.providerLabel} 的 Key 也能给 ${agentLabelOf(target)} 用`
128
+ });
129
+ }
130
+ }
131
+ return suggestions;
132
+ }
133
+
134
+ function uniqueRunName(runs, base) {
135
+ const taken = isPlainObject(runs) ? runs : {};
136
+ const root = String(base).replace(/[^A-Za-z0-9_.-]/g, '-').replace(/^[^A-Za-z0-9]+/, '').slice(0, 36) || 'model';
137
+ if (!taken[root]) return root;
138
+ for (let i = 2; i < 1000; i += 1) {
139
+ if (!taken[`${root}-${i}`]) return `${root}-${i}`;
140
+ }
141
+ throw new Error('无法生成不重名的名字');
142
+ }
143
+
144
+ function assertRunName(name) {
145
+ if (!RUN_NAME_PATTERN.test(String(name || ''))) {
146
+ throw new Error('名字只能包含字母、数字、下划线、点和横线,且以字母或数字开头,最长 40 个字符');
147
+ }
148
+ }
149
+
150
+ function presetFor(agent, providerId) {
151
+ const preset = providersForAgent(agent).find(item => item.id === providerId);
152
+ if (!preset) throw new Error(`${agentLabelOf(agent)} 没有这个服务商的预设`);
153
+ return preset;
154
+ }
155
+
156
+ /**
157
+ * 新增一套服务,返回要写入的 { name, profile };绝不覆盖已有的运行配置。
158
+ * input:{ agent, providerId | baseUrl, key, model?, name?, subscription? }
159
+ */
160
+ function buildNewService(config, input, options = {}) {
161
+ const agent = String(input.agent || '').trim();
162
+ const spec = AGENT_ENV_SCHEMA[agent];
163
+ if (!spec) throw new Error(`不支持的 Agent: ${agent}`);
164
+ const runs = isPlainObject(config && config.runs) ? config.runs : {};
165
+ const secret = String(input.key || '').trim();
166
+ if (!secret) throw new Error(input.subscription ? '请粘贴订阅令牌' : '请填写 API Key');
167
+
168
+ const env = {};
169
+ let baseName = agent;
170
+ if (input.subscription) {
171
+ if (agent !== 'claude') throw new Error('订阅登录令牌目前只支持 Claude Code');
172
+ env[CLAUDE_OAUTH_KEY] = secret;
173
+ baseName = `${agent}-subscription`;
174
+ } else if (input.providerId) {
175
+ const preset = presetFor(agent, String(input.providerId));
176
+ env[preset.keyEnv] = secret;
177
+ env[spec.baseUrlKey] = preset.baseUrl;
178
+ const model = String(input.model || '').trim() || preset.defaultModel;
179
+ if (model) env[spec.modelKey] = model;
180
+ Object.assign(env, preset.extraEnv);
181
+ baseName = `${agent}-${preset.id}`;
182
+ } else {
183
+ const baseUrl = String(input.baseUrl || '').trim();
184
+ if (baseUrl && !/^https?:\/\//i.test(baseUrl)) throw new Error('Base URL 必须以 http:// 或 https:// 开头');
185
+ env[spec.requiredAnyOf.find(name => !name.includes('OAUTH')) || spec.requiredAnyOf[0]] = secret;
186
+ if (baseUrl) env[spec.baseUrlKey] = baseUrl;
187
+ const model = String(input.model || '').trim();
188
+ if (model) env[spec.modelKey] = model;
189
+ baseName = `${agent}-custom`;
190
+ }
191
+ if (agent === 'claude') {
192
+ const models = isPlainObject(input.models) ? input.models : {};
193
+ for (const [key, raw] of Object.entries(models)) {
194
+ if (!CLAUDE_MODEL_KEYS.includes(key)) throw new Error(`不支持的模型变量: ${key}`);
195
+ const value = String(raw === null || raw === undefined ? '' : raw).trim();
196
+ if (value) {
197
+ parseEnvEntry(`${key}=${value}`);
198
+ env[key] = value;
199
+ }
200
+ }
201
+ const window = String(input.autoCompactWindow === undefined || input.autoCompactWindow === null ? '' : input.autoCompactWindow).trim();
202
+ if (window) {
203
+ if (!AUTO_COMPACT_PATTERN.test(window)) throw new Error('自动压缩的上下文大小要填纯数字,例如 1000000');
204
+ env[AUTO_COMPACT_KEY] = window;
205
+ }
206
+ }
207
+ if (agent === 'codex' && env[spec.baseUrlKey] && !env[spec.modelKey]) {
208
+ throw new Error('Codex CLI 接兼容服务必须填写模型名');
209
+ }
210
+ const name = input.name ? String(input.name).trim() : uniqueRunName(runs, baseName);
211
+ assertRunName(name);
212
+ if (runs[name]) throw new Error(`已有名为 ${name} 的运行配置,请换一个名字`);
213
+ const profile = buildAgentRunProfile(agent, env);
214
+ profile.containerName = `my-${name}-{now}`;
215
+ return { name, profile };
216
+ }
217
+
218
+ /** 把已有服务的 Key 复用给另一个 Agent(同一服务商)。返回 { name, profile } */
219
+ function buildReuseService(config, fromName, targetAgent, options = {}) {
220
+ const services = listModelServices(config);
221
+ const source = services.find(item => item.name === fromName);
222
+ if (!source) throw new Error(`找不到模型服务: ${fromName}`);
223
+ if (!source.providerId || source.authMode !== 'key') throw new Error('这个服务不能直接复用给别的 Agent');
224
+ const built = buildNewService(config, {
225
+ agent: targetAgent,
226
+ providerId: source.providerId,
227
+ key: keyValueOf(config, fromName)
228
+ }, options);
229
+ const sourceRun = config.runs[fromName];
230
+ // 原来那套指定过自己的目录就照抄;是默认工作目录根(老版本写进去的)就不抄
231
+ const sourceHost = typeof sourceRun.hostPath === 'string' ? sourceRun.hostPath.replace(/\/+$/, '') : '';
232
+ const defaultRoot = String(options.defaultHostPath || '').replace(/\/+$/, '');
233
+ if (sourceHost && sourceHost !== defaultRoot && !built.profile.hostPath) {
234
+ built.profile.hostPath = sourceRun.hostPath;
235
+ }
236
+ return built;
237
+ }
238
+
239
+ /**
240
+ * 编辑已有服务:key 留空表示不改。返回配置改动列表 ops(set / remove,路径从根开始)。
241
+ * 订阅令牌:换成订阅令牌时删掉同一个运行配置里的 API Key 类变量和 Base URL。
242
+ */
243
+ function buildServiceEdit(config, name, patch) {
244
+ const runs = isPlainObject(config && config.runs) ? config.runs : {};
245
+ const run = runs[name];
246
+ const agent = identifyRunAgent(name, run);
247
+ if (!agent) throw new Error(`找不到模型服务: ${name}`);
248
+ const spec = AGENT_ENV_SCHEMA[agent];
249
+ const current = findKey(spec, run.env);
250
+ const ops = [];
251
+ const setEnv = (key, value) => {
252
+ parseEnvEntry(`${key}=${value}`);
253
+ ops.push({ type: 'set', path: ['runs', name, 'env', key], value: String(value) });
254
+ };
255
+ if (patch.applyPresetExtras) {
256
+ const service = listModelServices(config).find(item => item.name === name);
257
+ for (const [key, value] of Object.entries(service ? service.missingExtraEnv : {})) setEnv(key, value);
258
+ }
259
+ const secret = String(patch.key || '').trim();
260
+ if (secret) {
261
+ if (patch.subscription) {
262
+ if (agent !== 'claude') throw new Error('订阅登录令牌目前只支持 Claude Code');
263
+ setEnv(CLAUDE_OAUTH_KEY, secret);
264
+ [...CLAUDE_API_KEYS, spec.baseUrlKey].forEach(key => {
265
+ if (isPlainObject(run.env) && key in run.env) ops.push({ type: 'remove', path: ['runs', name, 'env', key] });
266
+ });
267
+ } else {
268
+ setEnv(current && current.name !== CLAUDE_OAUTH_KEY ? current.name : spec.requiredAnyOf.find(n => !n.includes('OAUTH')), secret);
269
+ }
270
+ }
271
+ if (patch.baseUrl !== undefined) {
272
+ const baseUrl = String(patch.baseUrl).trim();
273
+ if (baseUrl && !/^https?:\/\//i.test(baseUrl)) throw new Error('Base URL 必须以 http:// 或 https:// 开头');
274
+ if (baseUrl) setEnv(spec.baseUrlKey, baseUrl);
275
+ else if (isPlainObject(run.env) && spec.baseUrlKey in run.env) ops.push({ type: 'remove', path: ['runs', name, 'env', spec.baseUrlKey] });
276
+ }
277
+ if (patch.model !== undefined) {
278
+ const model = String(patch.model).trim();
279
+ if (model) setEnv(spec.modelKey, model);
280
+ else if (isPlainObject(run.env) && spec.modelKey in run.env) ops.push({ type: 'remove', path: ['runs', name, 'env', spec.modelKey] });
281
+ }
282
+ if (patch.models !== undefined || patch.autoCompactWindow !== undefined) {
283
+ if (agent !== 'claude') throw new Error('档位模型和自动压缩只支持 Claude Code');
284
+ const hasKey = key => isPlainObject(run.env) && key in run.env;
285
+ const models = isPlainObject(patch.models) ? patch.models : {};
286
+ for (const [key, raw] of Object.entries(models)) {
287
+ if (!CLAUDE_MODEL_KEYS.includes(key)) throw new Error(`不支持的模型变量: ${key}`);
288
+ const value = String(raw === null || raw === undefined ? '' : raw).trim();
289
+ if (value) setEnv(key, value);
290
+ else if (hasKey(key)) ops.push({ type: 'remove', path: ['runs', name, 'env', key] });
291
+ }
292
+ if (patch.autoCompactWindow !== undefined) {
293
+ const window = String(patch.autoCompactWindow === null ? '' : patch.autoCompactWindow).trim();
294
+ if (window) {
295
+ if (!AUTO_COMPACT_PATTERN.test(window)) throw new Error('自动压缩的上下文大小要填纯数字,例如 1000000');
296
+ setEnv(AUTO_COMPACT_KEY, window);
297
+ } else if (hasKey(AUTO_COMPACT_KEY)) {
298
+ ops.push({ type: 'remove', path: ['runs', name, 'env', AUTO_COMPACT_KEY] });
299
+ }
300
+ }
301
+ }
302
+ // Codex 有地址就必须有模型(不会从环境变量读默认模型,缺了请求直接报错)
303
+ if (agent === 'codex' && ops.some(op => [spec.baseUrlKey, spec.modelKey].includes(op.path[op.path.length - 1]))) {
304
+ const after = { ...(isPlainObject(run.env) ? run.env : {}) };
305
+ for (const op of ops) {
306
+ const key = op.path[op.path.length - 1];
307
+ if (op.type === 'set') after[key] = op.value;
308
+ else delete after[key];
309
+ }
310
+ if (envText(after, spec.baseUrlKey) && !envText(after, spec.modelKey)) throw new Error('Codex CLI 接兼容服务必须填写模型名');
311
+ }
312
+ return { agent, ops };
313
+ }
314
+
315
+ // 把改动列表落到配置原文上(局部替换,保留注释和其它字段)
316
+ function applyConfigOps(baseRaw, ops) {
317
+ let raw = baseRaw;
318
+ for (const op of ops) {
319
+ raw = op.type === 'remove'
320
+ ? removeValueByPath(raw, op.path)
321
+ : upsertValueByPath(raw, op.path, JSON.stringify(op.value, null, 4));
322
+ }
323
+ return raw;
324
+ }
325
+
326
+ function valueAtPath(root, pathParts) {
327
+ let node = root;
328
+ for (const key of pathParts) {
329
+ if (node === null || typeof node !== 'object' || !Object.prototype.hasOwnProperty.call(node, key)) return undefined;
330
+ node = node[key];
331
+ }
332
+ return node;
333
+ }
334
+
335
+ /**
336
+ * 写盘前核对:把改动落到原文后重新解析,每条 set 必须读回同样的值、每条 remove 必须读不到。
337
+ * 局部文本替换遇到带转义的键名等边角情况可能“静默没改到”或在前面多插一个同名键,不核对就会回报成功却没生效。
338
+ */
339
+ function assertOpsApplied(nextRaw, ops) {
340
+ const parsed = JSON5.parse(nextRaw);
341
+ for (const op of ops) {
342
+ const actual = valueAtPath(parsed, op.path);
343
+ if (op.type === 'remove' ? actual !== undefined : !isDeepStrictEqual(actual, JSON.parse(JSON.stringify(op.value)))) {
344
+ throw new Error(`配置里 ${op.path.join('.')} 没能${op.type === 'remove' ? '删除' : '写入'},请在「显示 JSON 设置」里手动修改`);
345
+ }
346
+ }
347
+ }
348
+
349
+ module.exports = {
350
+ agentLabelOf,
351
+ assertOpsApplied,
352
+ RUN_NAME_PATTERN,
353
+ listModelServices,
354
+ suggestReuse,
355
+ buildNewService,
356
+ buildReuseService,
357
+ buildServiceEdit,
358
+ applyConfigOps,
359
+ assertRunName,
360
+ keyValueOf,
361
+ uniqueRunName,
362
+ keyTail
363
+ };
@@ -4,71 +4,87 @@
4
4
  // 只收录实测可用(Base URL 不带 Key 请求得到 401)且官方文档给了接入方式的服务商。
5
5
  // claude:Anthropic 兼容接口;codex:Responses 接口(只收官方文档确认支持 Responses 的)。
6
6
  // models 只作“模型”输入框的占位提示,不会自动写入配置;defaultModel 是官方文档要求必须填的,用户可改。
7
- // Codex 不认环境变量 OPENAI_BASE_URL(0.160 / 0.164 实测),向导写的仍是环境变量,
8
- // 由 lib/agent-adapters 的 withCodexBaseUrl 在容器里展开成 `-c openai_base_url=…` 才生效。
7
+ // Codex 0.164 实测:exec 不认 OPENAI_BASE_URL / OPENAI_API_KEY / OPENAI_MODEL,向导写的仍是环境变量,
8
+ // 由 lib/agent-adapters 的 withCodexBaseUrl 在容器里展开成自定义 model_provider + --model 才生效,所以 Codex 条目必须有 defaultModel。
9
+ // extraEnv:这家服务必须附带的变量(写进同一个运行配置)。Claude Code 2.1.310 的“工具搜索”会把工具收进 ToolSearch,
10
+ // 智谱的兼容接口用不了(实测模型看不到 Bash),要 ENABLE_TOOL_SEARCH=false;DeepSeek 实测不需要。其余没有 Key 实测的先带上(代价只是多耗一点 token)。
9
11
  // 智谱的接口把鉴权失败写在 200 响应体里(`{"code":401,...}`),其余三家直接返回 HTTP 401。
10
12
  const PROVIDER_PRESETS = [
13
+ {
14
+ id: 'anthropic',
15
+ label: 'Anthropic(官方)',
16
+ docUrl: 'https://docs.claude.com/en/api/overview',
17
+ claude: { baseUrl: 'https://api.anthropic.com', keyEnv: 'ANTHROPIC_API_KEY' }
18
+ },
19
+ {
20
+ id: 'openai',
21
+ label: 'OpenAI(官方)',
22
+ docUrl: 'https://platform.openai.com/docs/api-reference/responses',
23
+ // 官方 Key 的用法没有实测过(没有 Key):模型名要自己填
24
+ codex: { baseUrl: 'https://api.openai.com/v1', keyEnv: 'OPENAI_API_KEY', models: ['gpt-5-codex'] }
25
+ },
11
26
  {
12
27
  id: 'deepseek',
13
28
  label: 'DeepSeek',
14
29
  docUrl: 'https://api-docs.deepseek.com/guides/coding_agents',
15
- claude: { baseUrl: 'https://api.deepseek.com/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN', models: ['deepseek-flash[1m]'], defaultModel: 'deepseek-flash[1m]' }
30
+ claude: { baseUrl: 'https://api.deepseek.com/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN', models: ['deepseek-flash[1m]'], defaultModel: 'deepseek-flash[1m]' },
31
+ codex: { baseUrl: 'https://api.deepseek.com', keyEnv: 'OPENAI_API_KEY', models: ['deepseek-flash'], defaultModel: 'deepseek-flash' }
16
32
  },
17
33
  {
18
34
  id: 'zhipu',
19
35
  label: '智谱 GLM',
20
36
  docUrl: 'https://docs.bigmodel.cn/cn/coding-plan/tool/claude',
21
- claude: { baseUrl: 'https://open.bigmodel.cn/api/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN' },
22
- codex: { baseUrl: 'https://open.bigmodel.cn/api/v1', keyEnv: 'OPENAI_API_KEY', docUrl: 'https://docs.bigmodel.cn/cn/coding-plan/tool/codex' }
37
+ claude: { baseUrl: 'https://open.bigmodel.cn/api/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN', extraEnv: { ENABLE_TOOL_SEARCH: 'false' } },
38
+ codex: { baseUrl: 'https://open.bigmodel.cn/api/v1', keyEnv: 'OPENAI_API_KEY', models: ['glm-5.3'], defaultModel: 'glm-5.3', docUrl: 'https://docs.bigmodel.cn/cn/coding-plan/tool/codex' }
23
39
  },
24
40
  {
25
41
  id: 'zai',
26
42
  label: 'Z.ai(智谱国际)',
27
43
  docUrl: 'https://docs.z.ai/devpack/tool/claude',
28
- claude: { baseUrl: 'https://api.z.ai/api/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN' }
44
+ claude: { baseUrl: 'https://api.z.ai/api/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN', extraEnv: { ENABLE_TOOL_SEARCH: 'false' } }
29
45
  },
30
46
  {
31
47
  id: 'kimi',
32
48
  label: 'Kimi',
33
49
  docUrl: 'https://platform.kimi.com/docs/guide/claude-code-kimi.md',
34
- claude: { baseUrl: 'https://api.moonshot.cn/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN' },
50
+ claude: { baseUrl: 'https://api.moonshot.cn/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN', extraEnv: { ENABLE_TOOL_SEARCH: 'false' } },
35
51
  codex: { baseUrl: 'https://api.moonshot.cn/v1', keyEnv: 'OPENAI_API_KEY', docUrl: 'https://platform.kimi.com/docs/guide/codex-kimi.md' }
36
52
  },
37
53
  {
38
54
  id: 'kimi-code',
39
55
  label: 'Kimi Code(订阅,海外)',
40
56
  docUrl: 'https://www.kimi.com/code/docs/en/third-party-tools/claude-code.html',
41
- claude: { baseUrl: 'https://api.kimi.ai/coding/', keyEnv: 'ANTHROPIC_API_KEY' }
57
+ claude: { baseUrl: 'https://api.kimi.ai/coding/', keyEnv: 'ANTHROPIC_API_KEY', extraEnv: { ENABLE_TOOL_SEARCH: 'false' } }
42
58
  },
43
59
  {
44
60
  id: 'aliyun',
45
61
  label: '阿里云百炼',
46
62
  docUrl: 'https://help.aliyun.com/zh/model-studio/claude-code',
47
- claude: { baseUrl: 'https://coding.dashscope.aliyuncs.com/apps/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN' }
63
+ claude: { baseUrl: 'https://coding.dashscope.aliyuncs.com/apps/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN', extraEnv: { ENABLE_TOOL_SEARCH: 'false' } }
48
64
  },
49
65
  {
50
66
  id: 'tencent',
51
67
  label: '腾讯云',
52
68
  docUrl: 'https://cloud.tencent.com/document/product/1823/130097',
53
- claude: { baseUrl: 'https://api.lkeap.cloud.tencent.com/coding/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN', models: ['glm-5'] }
69
+ claude: { baseUrl: 'https://api.lkeap.cloud.tencent.com/coding/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN', models: ['glm-5'], extraEnv: { ENABLE_TOOL_SEARCH: 'false' } }
54
70
  },
55
71
  {
56
72
  id: 'volcengine',
57
73
  label: '火山方舟',
58
74
  docUrl: 'https://www.volcengine.com/docs/82379/1928262',
59
- claude: { baseUrl: 'https://ark.cn-beijing.volces.com/api/coding', keyEnv: 'ANTHROPIC_AUTH_TOKEN' },
75
+ claude: { baseUrl: 'https://ark.cn-beijing.volces.com/api/coding', keyEnv: 'ANTHROPIC_AUTH_TOKEN', extraEnv: { ENABLE_TOOL_SEARCH: 'false' } },
60
76
  codex: { baseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', keyEnv: 'OPENAI_API_KEY' }
61
77
  },
62
78
  {
63
79
  id: 'minimax',
64
80
  label: 'MiniMax',
65
81
  docUrl: 'https://platform.minimax.cn/docs/token-plan/claude-code',
66
- claude: { baseUrl: 'https://api.minimax.cn/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN' },
82
+ claude: { baseUrl: 'https://api.minimax.cn/anthropic', keyEnv: 'ANTHROPIC_AUTH_TOKEN', extraEnv: { ENABLE_TOOL_SEARCH: 'false' } },
67
83
  codex: { baseUrl: 'https://api.minimax.cn/v1', keyEnv: 'OPENAI_API_KEY', docUrl: 'https://platform.minimax.cn/docs/token-plan/codex' }
68
84
  }
69
85
  ];
70
86
 
71
- /** 某个 Agent 可用的预设(扁平:{id, label, docUrl, baseUrl, keyEnv, models, defaultModel}) */
87
+ /** 某个 Agent 可用的预设(扁平:{id, label, docUrl, baseUrl, keyEnv, models, defaultModel, extraEnv}) */
72
88
  function providersForAgent(agentId) {
73
89
  return PROVIDER_PRESETS
74
90
  .filter(item => item[agentId])
@@ -79,8 +95,35 @@ function providersForAgent(agentId) {
79
95
  baseUrl: item[agentId].baseUrl,
80
96
  keyEnv: item[agentId].keyEnv,
81
97
  models: [...(item[agentId].models || [])],
82
- defaultModel: item[agentId].defaultModel || ''
98
+ defaultModel: item[agentId].defaultModel || '',
99
+ extraEnv: { ...(item[agentId].extraEnv || {}) }
83
100
  }));
84
101
  }
85
102
 
86
- module.exports = { PROVIDER_PRESETS, providersForAgent };
103
+ function normalizeUrl(value) {
104
+ return String(value || '').trim().replace(/\/+$/, '').toLowerCase();
105
+ }
106
+
107
+ /** 按 Base URL 找某个 Agent 的预设,对不上返回 null */
108
+ function findProviderByBaseUrl(agentId, baseUrl) {
109
+ const url = normalizeUrl(baseUrl);
110
+ if (!url) return null;
111
+ return providersForAgent(agentId).find(item => normalizeUrl(item.baseUrl) === url) || null;
112
+ }
113
+
114
+ /**
115
+ * Codex 接了第三方(env 里有 OPENAI_BASE_URL)时的模型下拉:只列这家的预设模型和当前模型。
116
+ * `codex app-server` 的目录永远是 OpenAI 的模型名,选了第三方会直接报错。不是第三方返回 null。
117
+ */
118
+ function thirdPartyCodexModels(env) {
119
+ const source = env && typeof env === 'object' ? env : {};
120
+ const baseUrl = typeof source.OPENAI_BASE_URL === 'string' ? source.OPENAI_BASE_URL.trim() : '';
121
+ if (!baseUrl) return null;
122
+ const provider = findProviderByBaseUrl('codex', baseUrl);
123
+ const names = [...(provider ? provider.models : [])];
124
+ const current = typeof source.OPENAI_MODEL === 'string' ? source.OPENAI_MODEL.trim() : '';
125
+ if (current && !names.includes(current)) names.push(current);
126
+ return names.map(value => ({ value, label: value, description: provider ? provider.label : '' }));
127
+ }
128
+
129
+ module.exports = { PROVIDER_PRESETS, providersForAgent, findProviderByBaseUrl, thirdPartyCodexModels };
package/lib/setup-cli.js CHANGED
@@ -10,6 +10,7 @@ const readline = require('readline');
10
10
  const { Writable } = require('stream');
11
11
  const JSON5 = require('json5');
12
12
  const { listSetupAgents, buildAgentRunProfile, validateSetupPassword, listMirrorPresets } = require('./setup');
13
+ const { uniqueRunName } = require('./model-services');
13
14
  const { baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw } = require('./setup-config');
14
15
  const { normalizeMirrors } = require('./runtime-normalizers');
15
16
  const { writeConfigFileSecure } = require('./secure-file');
@@ -155,6 +156,8 @@ async function runSetupCli(deps) {
155
156
  keyName = agent.requiredAnyOf[keyIndex];
156
157
  }
157
158
  let defaultModel = '';
159
+ // 名字里的服务商标记:选了预设用预设 id,官方 Key 是 official,自定义地址是 custom
160
+ let nameTag = modeIndex === 1 ? 'custom' : 'official';
158
161
  if (modeIndex === 1) {
159
162
  const providers = agent.providers || [];
160
163
  const baseUrlItems = [
@@ -168,6 +171,8 @@ async function runSetupCli(deps) {
168
171
  env[agent.baseUrlKey] = provider.baseUrl;
169
172
  keyName = provider.keyEnv;
170
173
  defaultModel = provider.defaultModel;
174
+ nameTag = provider.id;
175
+ Object.assign(env, provider.extraEnv || {});
171
176
  log(`官方说明:${provider.docUrl}`);
172
177
  } else if (baseIndex < providers.length + agent.baseUrlPresets.length) {
173
178
  env[agent.baseUrlKey] = agent.baseUrlPresets[baseIndex - providers.length].value;
@@ -188,7 +193,11 @@ async function runSetupCli(deps) {
188
193
  env[keyName] = key;
189
194
  const model = String(await prompter.ask(defaultModel
190
195
  ? `模型(${agent.modelKey},回车使用 ${defaultModel}): `
191
- : `模型(${agent.modelKey},可选,回车跳过): `)).trim() || defaultModel;
196
+ : `模型(${agent.modelKey},${agent.id === 'codex' && modeIndex === 1 ? '必填' : '可选,回车跳过'}): `)).trim() || defaultModel;
197
+ if (!model && agent.id === 'codex' && modeIndex === 1) {
198
+ log('Codex 接兼容服务必须指定模型,已退出,没有改动任何配置。');
199
+ return 1;
200
+ }
192
201
  if (model) env[agent.modelKey] = model;
193
202
 
194
203
  log('\n第 3 / 4 步 · 工作目录');
@@ -215,14 +224,18 @@ async function runSetupCli(deps) {
215
224
  const exists = fs.existsSync(configPath);
216
225
  const snapshot = { exists, raw: exists ? fs.readFileSync(configPath, 'utf-8') : '' };
217
226
  const parsed = exists && snapshot.raw.trim() ? JSON5.parse(snapshot.raw) : {};
218
- const existingRun = parsed.runs && typeof parsed.runs === 'object' ? parsed.runs[agent.id] : undefined;
219
- const profile = buildAgentRunProfile(agent.id, env, { existingRun, hostPath });
220
- let nextRaw = buildAgentConfigRaw(baseRawOf(snapshot), agent.id, profile);
227
+ // 已有同名运行配置时新增一套(名字带服务商),绝不覆盖;第一次配置仍叫 Agent 的名字
228
+ const runs = parsed.runs && typeof parsed.runs === 'object' ? parsed.runs : {};
229
+ const runName = runs[agent.id] ? uniqueRunName(runs, `${agent.id}-${nameTag}`) : agent.id;
230
+ // 默认工作目录根不写进运行配置:新建容器各自建 <根>/<时间> 子目录
231
+ const profile = buildAgentRunProfile(agent.id, env, { hostPath: hostPath === path.resolve(defaultWork) ? '' : hostPath });
232
+ if (runName !== agent.id) profile.containerName = `my-${runName}-{now}`;
233
+ let nextRaw = buildAgentConfigRaw(baseRawOf(snapshot), runName, profile);
221
234
  nextRaw = buildPasswordConfigRaw(nextRaw, password);
222
235
  if (shouldWriteMirrors(parsed, mirrors)) nextRaw = buildMirrorsConfigRaw(nextRaw, normalizeMirrors(mirrors));
223
236
  JSON5.parse(nextRaw);
224
237
  writeConfigFileSecure(configPath, nextRaw);
225
- log(`已保存到 ${configPath}(权限 0600):Agent ${agent.id}、工作目录 ${hostPath}、登录密码${Object.keys(mirrors).length > 0 ? '、软件源' : ''}。`);
238
+ log(`已保存到 ${configPath}(权限 0600):运行配置 ${runName}(${agent.id})、工作目录 ${hostPath}、登录密码${Object.keys(mirrors).length > 0 ? '、软件源' : ''}。`);
226
239
  } catch (error) {
227
240
  if (error instanceof InputClosedError) {
228
241
  log('输入已结束,没有改动任何配置。');
@@ -38,4 +38,9 @@ function buildBooleanConfigRaw(baseRaw, key, value) {
38
38
  return upsertValueByPath(baseRaw, [key], value ? 'true' : 'false');
39
39
  }
40
40
 
41
- module.exports = { buildBooleanConfigRaw, EMPTY_CONFIG_RAW, baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw, buildAgentInstructionsConfigRaw };
41
+ // 向导“跳过”要落盘,否则刷新后又弹
42
+ function buildSetupSkippedConfigRaw(baseRaw) {
43
+ return upsertValueByPath(baseRaw, ['serve', 'setupSkipped'], 'true');
44
+ }
45
+
46
+ module.exports = { buildSetupSkippedConfigRaw, buildBooleanConfigRaw, EMPTY_CONFIG_RAW, baseRawOf, buildAgentConfigRaw, buildPasswordConfigRaw, shouldWriteMirrors, buildMirrorsConfigRaw, buildAgentInstructionsConfigRaw };