@xcanwin/manyoyo 8.7.0 → 8.7.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.
@@ -41,24 +41,41 @@ function resolveYoloCommand(yolo) {
41
41
  return adapter.yoloCommand;
42
42
  }
43
43
 
44
- // Codex 0.160 / 0.164 实测不认环境变量 OPENAI_BASE_URL,只认 `-c openai_base_url=...`。
45
- // 向导给 Codex 配“兼容服务”写的是环境变量,所以执行 codex 的命令要在容器里由 shell 展开出 -c:
46
- // 变量没设就一个参数都不多;地址只在容器里从环境变量读,不拼进命令文本(也就不会出现在日志和 ps 里)。
47
- // 只处理以 codex 开头(可带前置的 KEY=值)的命令;命令里已经写了 openai_base_url 就以用户的为准。
44
+ // Codex 0.164 实测:`exec` 不认环境变量 OPENAI_BASE_URL,也不读 OPENAI_API_KEY(DeepSeek 返回 401),
45
+ // 也不读 OPENAI_MODEL;要用自定义 model_provider(env_key 指定读哪个变量,wire_api=responses)。
46
+ // 向导给 Codex 配“兼容服务”写的是环境变量,所以执行 codex 的命令要在容器里由 shell 展开成 -c / --model:
47
+ // 变量没设就一个参数都不多;值只在容器里从环境变量读,不拼进命令文本(也就不会出现在日志和 ps 里)。
48
+ // 只处理以 codex 开头(可带前置的 KEY=值)的命令;命令里已经写了 openai_base_url / model_provider 就以用户的为准。
49
+ // 只有地址没有 OPENAI_API_KEY 时(Codex 用 ChatGPT 登录、地址指向 chatgpt.com 的后端)走内置 provider 换地址的老办法;
50
+ // 两个都有才用自定义 provider。老写法总是带上(对自定义 provider 无影响),这样没有 Key 的老配置不退化。
48
51
  const CODEX_BASE_URL_FLAGS = '${OPENAI_BASE_URL:+-c "openai_base_url=\\"$OPENAI_BASE_URL\\""}';
52
+ const CODEX_PROVIDER_FLAGS = '${OPENAI_BASE_URL:+${OPENAI_API_KEY:+-c model_provider=manyoyo -c model_providers.manyoyo.name=manyoyo -c "model_providers.manyoyo.base_url=\\"$OPENAI_BASE_URL\\"" -c model_providers.manyoyo.env_key=OPENAI_API_KEY -c model_providers.manyoyo.wire_api=responses}}';
53
+ const CODEX_MODEL_FLAGS = '${OPENAI_MODEL:+--model "$OPENAI_MODEL"}';
49
54
  // 有 exec / exec resume 子命令时插在它们后面(codex exec -c … --json …),保持 `codex exec` 连在一起
50
55
  const CODEX_COMMAND_START = /^((?:\s*[A-Za-z_][A-Za-z0-9_]*=(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]*)\s+)*\s*)codex(?:\s+exec(?:\s+resume)?)?(?=\s|$)/;
56
+ // codex 不允许 --model 出现两次,命令里已经指定了模型(会话里选的)就不再追加环境变量里的
57
+ const CODEX_HAS_MODEL = /(?:^|\s)(?:--model|-m)(?:[\s=]|$)|(?:^|\s)model=/;
51
58
 
59
+ // 我们自己拼进命令的系统说明(-c 'developer_instructions=…')里有任意文字,判断“用户有没有写过”时要先去掉
60
+ const CODEX_INSTRUCTIONS_FLAG = /-c\s+'developer_instructions=(?:[^']|'\\'')*'/g;
61
+
62
+ // 传入的应该是还带着 {prompt} 占位的模板(或没有提示词的命令):渲染进提示词之后再调用,用户的提示词会干扰“已经写过”的判断
52
63
  function withCodexBaseUrl(command) {
53
64
  const text = String(command || '');
54
- if (!text || /openai_base_url/.test(text)) return text;
65
+ if (!text) return text;
55
66
  const match = text.match(CODEX_COMMAND_START);
56
67
  if (!match) return text;
57
- return `${match[0]} ${CODEX_BASE_URL_FLAGS}${text.slice(match[0].length)}`;
68
+ const detect = text.replace(CODEX_INSTRUCTIONS_FLAG, ' ');
69
+ const parts = [];
70
+ if (!/openai_base_url|model_provider/.test(detect)) parts.push(CODEX_BASE_URL_FLAGS, CODEX_PROVIDER_FLAGS);
71
+ if (!CODEX_HAS_MODEL.test(detect)) parts.push(CODEX_MODEL_FLAGS);
72
+ if (!parts.length) return text;
73
+ return `${match[0]} ${parts.join(' ')}${text.slice(match[0].length)}`;
58
74
  }
59
75
 
60
76
  module.exports = {
61
77
  AGENT_ADAPTERS,
78
+ findAdapterByAlias,
62
79
  resolveYoloCommand,
63
80
  withCodexBaseUrl
64
81
  };
@@ -25,7 +25,8 @@ const AGENT_ENV_SCHEMA = {
25
25
  { name: 'ANTHROPIC_DEFAULT_OPUS_MODEL', description: 'Opus 档位对应的模型', secret: false },
26
26
  { name: 'ANTHROPIC_DEFAULT_SONNET_MODEL', description: 'Sonnet 档位对应的模型', secret: false },
27
27
  { name: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', description: 'Haiku 档位对应的模型', secret: false },
28
- { name: 'CLAUDE_CODE_SUBAGENT_MODEL', description: '子 Agent 使用的模型', secret: false }
28
+ { name: 'CLAUDE_CODE_SUBAGENT_MODEL', description: '子 Agent 使用的模型', secret: false },
29
+ { name: 'ENABLE_TOOL_SEARCH', description: '工具搜索(部分兼容服务要设为 false,否则模型调不了工具)', secret: false }
29
30
  ]
30
31
  },
31
32
  codex: {
@@ -481,6 +482,8 @@ async function initAgentConfigs(rawAgents, options = {}) {
481
482
 
482
483
  module.exports = {
483
484
  initAgentConfigs,
485
+ collectClaudeInitData,
486
+ collectCodexInitData,
484
487
  AGENT_ENV_SCHEMA,
485
488
  agentEnvKeys
486
489
  };
@@ -224,6 +224,56 @@ function findTopLevelPropertyValueRange(text, propertyName) {
224
224
  return findValueRangeByPath(text, [propertyName]);
225
225
  }
226
226
 
227
+ // 按路径删除一个属性(连同它所在行和行尾 // 注释);路径不存在就原样返回。
228
+ // 删掉最后一个属性时前一项的逗号会留下成为尾逗号,JSON5 允许。
229
+ function removeValueByPath(text, pathParts) {
230
+ if (!Array.isArray(pathParts) || pathParts.length === 0) {
231
+ throw new Error('path 不能为空');
232
+ }
233
+ let objectStart = findRootObjectStart(text);
234
+ if (objectStart === -1) return text;
235
+ for (let i = 0; i < pathParts.length - 1; i += 1) {
236
+ const range = findObjectPropertyValueRange(text, objectStart, pathParts[i]);
237
+ if (!range) return text;
238
+ const next = skipTrivia(text, range.start);
239
+ if (text[next] !== '{') return text;
240
+ objectStart = next;
241
+ }
242
+ const target = pathParts[pathParts.length - 1];
243
+ let cursor = skipTrivia(text, objectStart + 1);
244
+ while (cursor < text.length) {
245
+ cursor = skipTrivia(text, cursor);
246
+ if (text[cursor] === '}') return text;
247
+ const keyStart = cursor;
248
+ const token = readPropertyToken(text, cursor);
249
+ if (!token) return text;
250
+ cursor = skipTrivia(text, token.end);
251
+ if (text[cursor] !== ':') return text;
252
+ const valueStart = skipTrivia(text, cursor + 1);
253
+ const valueEnd = scanValueEnd(text, valueStart);
254
+ if (token.value === target) {
255
+ let end = valueEnd;
256
+ while (text[end] === ' ' || text[end] === '\t') end += 1;
257
+ if (text[end] === ',') end += 1;
258
+ while (text[end] === ' ' || text[end] === '\t') end += 1;
259
+ if (text[end] === '/' && text[end + 1] === '/') {
260
+ while (end < text.length && text[end] !== '\n') end += 1;
261
+ }
262
+ const lineStart = text.lastIndexOf('\n', keyStart - 1) + 1;
263
+ const onOwnLine = /^[ \t]*$/.test(text.slice(lineStart, keyStart));
264
+ if (onOwnLine && text[end] === '\n') end += 1;
265
+ return `${text.slice(0, onOwnLine ? lineStart : keyStart)}${text.slice(end)}`;
266
+ }
267
+ cursor = skipTrivia(text, valueEnd);
268
+ if (text[cursor] === ',') {
269
+ cursor += 1;
270
+ continue;
271
+ }
272
+ return text;
273
+ }
274
+ return text;
275
+ }
276
+
227
277
  function applyTextReplacements(text, replacements) {
228
278
  return replacements
229
279
  .slice()
@@ -313,5 +363,6 @@ module.exports = {
313
363
  findTopLevelPropertyValueRange,
314
364
  findValueRangeByPath,
315
365
  applyTextReplacements,
366
+ removeValueByPath,
316
367
  upsertValueByPath
317
368
  };
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ // 模型服务改了 Key / 地址 / 模型后,已有容器的 box/env 里还是创建时写进去的旧值。
4
+ // 这里按“env 里的 Key 等于旧 Key”找出还在用它的容器,并把新值写进它们的 box/env(每次 exec 都会重新读,下一轮对话生效,不用重建)。
5
+ // 只改指定的几个变量(整行替换,没有就追加,要删的删掉),其余行和注释原样保留。
6
+ const state = require('./container-state');
7
+ const { formatEnvValue, parseEnvText } = require('./env-text');
8
+ const { parseEnvEntry } = require('./runtime-normalizers');
9
+
10
+ /**
11
+ * containers:[{ name, id }](受管容器);返回 box/env 里 keyEnv 的值等于 keyValue 的那些。
12
+ * 服务有地址时容器里的地址也要相同:同一个 Key 常被用在不同的服务上(比如 Claude 和 Codex,或两个模型),不能误伤。
13
+ */
14
+ function findUsingContainers({ homeDir, containers, keyEnv, keyValue, baseUrlKey, baseUrl }) {
15
+ if (!keyEnv || !keyValue) return [];
16
+ return containers.filter(item => {
17
+ if (!state.stateExists(homeDir, item.id)) return false;
18
+ const { entries } = parseEnvText(state.readEnv(homeDir, item.id).text);
19
+ if (!entries.some(entry => entry.key === keyEnv && entry.value === keyValue)) return false;
20
+ return !baseUrlKey || !baseUrl || entries.some(entry => entry.key === baseUrlKey && entry.value === baseUrl);
21
+ });
22
+ }
23
+
24
+ /** 返回改写后的 env 文本;set:{ KEY: 新值 },remove:[KEY…] */
25
+ function rewriteEnvText(text, { set = {}, remove = [] }) {
26
+ const lines = String(text || '').split('\n');
27
+ const { entries } = parseEnvText(text);
28
+ const handled = new Set();
29
+ const drop = new Set();
30
+ entries.forEach(entry => {
31
+ if (Object.prototype.hasOwnProperty.call(set, entry.key)) {
32
+ lines[entry.line - 1] = `${entry.key}=${formatEnvValue(String(set[entry.key]))}`;
33
+ handled.add(entry.key);
34
+ } else if (remove.includes(entry.key)) {
35
+ drop.add(entry.line - 1);
36
+ }
37
+ });
38
+ let out = lines.filter((_, index) => !drop.has(index)).join('\n');
39
+ const missing = Object.keys(set).filter(key => !handled.has(key));
40
+ if (missing.length) {
41
+ if (out && !out.endsWith('\n')) out += '\n';
42
+ out += missing.map(key => `${key}=${formatEnvValue(String(set[key]))}`).join('\n');
43
+ }
44
+ return out && !out.endsWith('\n') ? `${out}\n` : out;
45
+ }
46
+
47
+ function applyToContainers({ homeDir, targets, set, remove }) {
48
+ const updated = [];
49
+ for (const item of targets) {
50
+ const current = state.readEnv(homeDir, item.id);
51
+ // 读到的 etag 随写随比,容器里同时改过就跳过这一个,不覆盖别人的修改
52
+ // 配置已经保存,一个容器写失败(冲突、写不进)只跳过它,不能让整个请求报错
53
+ try {
54
+ state.writeEnv(homeDir, item.id, rewriteEnvText(current.text, { set, remove }), { ifMatch: current.etag, parseEnvEntry });
55
+ updated.push(item.name);
56
+ } catch (e) {
57
+ // 跳过
58
+ }
59
+ }
60
+ return updated;
61
+ }
62
+
63
+ module.exports = { findUsingContainers, rewriteEnvText, applyToContainers };
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ // 「从本机导入」:把本机已有的 Claude / Codex 的 Key、地址、模型读出来,让用户预览后写成模型服务。
4
+ // 数据源与 `manyoyo init` 相同(环境变量、~/.claude/settings.json 的 env、~/.codex 的 OPENAI_API_KEY 与 config.toml),
5
+ // 但只提取 Key / 地址 / 模型,不加任何挂载(init 会把整个 ~/.codex 读写挂进容器),也不碰 OAuth 登录凭据。
6
+ const { AGENT_ENV_SCHEMA, collectClaudeInitData, collectCodexInitData } = require('./init-config');
7
+ const { findProviderByBaseUrl } = require('./provider-presets');
8
+ const { listModelServices, keyValueOf, uniqueRunName, keyTail, agentLabelOf } = require('./model-services');
9
+ const { buildAgentRunProfile } = require('./setup');
10
+
11
+ const IMPORT_AGENTS = [
12
+ { id: 'claude', collect: collectClaudeInitData, sources: '环境变量、~/.claude/settings.json' },
13
+ { id: 'codex', collect: collectCodexInitData, sources: '环境变量、~/.codex/auth.json、~/.codex/config.toml' }
14
+ ];
15
+
16
+ // 读不了文件时只当没有,不往终端打印
17
+ const SILENT_CTX = { colors: { YELLOW: '', NC: '' }, log: () => {} };
18
+
19
+ /**
20
+ * 扫描本机,返回候选(含内部 env,调用方不能原样下发给前端)。
21
+ * 每个候选:{ id, agent, env, baseUrl, model, secret, duplicate, invalid }
22
+ */
23
+ function scanLocalServices(homeDir, config) {
24
+ const existing = listModelServices(config);
25
+ const existingKeys = new Set(existing.map(item => `${item.agent}|${keyValueOf(config, item.name)}`));
26
+ const candidates = [];
27
+ for (const agent of IMPORT_AGENTS) {
28
+ const spec = AGENT_ENV_SCHEMA[agent.id];
29
+ const { values } = agent.collect(homeDir, SILENT_CTX);
30
+ const env = {};
31
+ for (const item of spec.env) {
32
+ const value = values[item.name];
33
+ // OAuth 令牌不导入:订阅登录只做引导
34
+ if (item.name.includes('OAUTH')) continue;
35
+ if (typeof value === 'string' && value.trim()) env[item.name] = value.trim();
36
+ }
37
+ const keyName = spec.requiredAnyOf.find(name => env[name]);
38
+ if (!keyName) continue;
39
+ const secret = env[keyName];
40
+ let invalid = '';
41
+ try {
42
+ buildAgentRunProfile(agent.id, env, {});
43
+ // Codex 不会从环境变量读默认模型,有地址没模型的配置导入后请求会直接报错
44
+ if (agent.id === 'codex' && env[spec.baseUrlKey] && !env[spec.modelKey]) throw new Error('Codex CLI 接兼容服务必须指定模型');
45
+ } catch (e) {
46
+ invalid = e.message;
47
+ }
48
+ candidates.push({
49
+ id: agent.id,
50
+ agent: agent.id,
51
+ env,
52
+ secret,
53
+ baseUrl: env[spec.baseUrlKey] || '',
54
+ model: env[spec.modelKey] || '',
55
+ duplicate: existingKeys.has(`${agent.id}|${secret}`),
56
+ invalid
57
+ });
58
+ }
59
+ return candidates;
60
+ }
61
+
62
+ /** 给前端预览用:只有 Key 末 4 位,没有 env 原文 */
63
+ function toPreview(candidates) {
64
+ return candidates.map(item => {
65
+ const provider = findProviderByBaseUrl(item.agent, item.baseUrl);
66
+ return {
67
+ id: item.id,
68
+ agent: item.agent,
69
+ agentLabel: agentLabelOf(item.agent),
70
+ providerLabel: provider ? provider.label : (item.baseUrl ? '自定义地址' : '官方'),
71
+ baseUrl: item.baseUrl,
72
+ model: item.model,
73
+ keyTail: keyTail(item.secret),
74
+ duplicate: item.duplicate,
75
+ invalid: item.invalid
76
+ };
77
+ });
78
+ }
79
+
80
+ /** 把选中的候选变成要写入的 [{ name, profile }];名字冲突就加数字,绝不覆盖已有的运行配置 */
81
+ function buildImportRuns(config, candidates, ids, options = {}) {
82
+ const runs = { ...(config && config.runs ? config.runs : {}) };
83
+ const built = [];
84
+ for (const id of [...new Set(ids)]) {
85
+ const item = candidates.find(candidate => candidate.id === id);
86
+ if (!item) throw new Error(`本机没有可导入的 ${id}`);
87
+ if (item.invalid) throw new Error(`${agentLabelOf(item.agent)} 的本机配置不能导入:${item.invalid}`);
88
+ const provider = findProviderByBaseUrl(item.agent, item.baseUrl);
89
+ const name = uniqueRunName(runs, `${item.agent}-${provider ? provider.id : 'local'}`);
90
+ const profile = buildAgentRunProfile(item.agent, item.env);
91
+ profile.containerName = `my-${name}-{now}`;
92
+ runs[name] = profile;
93
+ built.push({ name, profile });
94
+ }
95
+ return built;
96
+ }
97
+
98
+ module.exports = { IMPORT_AGENTS, scanLocalServices, toPreview, buildImportRuns };
@@ -0,0 +1,313 @@
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
+ function isPlainObject(value) {
27
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
28
+ }
29
+
30
+ function envText(env, key) {
31
+ const value = isPlainObject(env) ? env[key] : undefined;
32
+ return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '';
33
+ }
34
+
35
+ function keyTail(secret) {
36
+ const text = String(secret || '');
37
+ // 太短的 Key 末 4 位已经泄露了一大半,只显示省略号
38
+ return text.length >= 12 ? `…${text.slice(-4)}` : (text ? '…' : '');
39
+ }
40
+
41
+ // 这个运行配置里实际生效的凭据变量:按 schema 的优先顺序取第一个非空的
42
+ function findKey(spec, env) {
43
+ for (const name of spec.requiredAnyOf) {
44
+ const value = envText(env, name);
45
+ if (value) return { name, value };
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /** 列出已有的模型服务;响应里只有 Key 的末 4 位,绝不带出 Key 本身 */
51
+ function listModelServices(config) {
52
+ const runs = isPlainObject(config && config.runs) ? config.runs : {};
53
+ const services = [];
54
+ for (const [name, run] of Object.entries(runs)) {
55
+ const agent = identifyRunAgent(name, run);
56
+ if (!agent) continue;
57
+ const spec = AGENT_ENV_SCHEMA[agent];
58
+ const env = isPlainObject(run) ? run.env : null;
59
+ const key = findKey(spec, env);
60
+ if (!key) continue;
61
+ const baseUrl = envText(env, spec.baseUrlKey);
62
+ const provider = findProviderByBaseUrl(agent, baseUrl);
63
+ const subscription = key.name === CLAUDE_OAUTH_KEY;
64
+ // 这家服务必需的附加变量里,运行配置里还没有(或值不对)的:界面据此提示“一键修复”
65
+ const missingExtraEnv = {};
66
+ if (provider && !subscription) {
67
+ for (const [extraKey, extraValue] of Object.entries(provider.extraEnv || {})) {
68
+ if (envText(env, extraKey) !== extraValue) missingExtraEnv[extraKey] = extraValue;
69
+ }
70
+ }
71
+ services.push({
72
+ name,
73
+ agent,
74
+ agentLabel: agentLabelOf(agent),
75
+ providerId: provider ? provider.id : '',
76
+ providerLabel: subscription ? '订阅登录' : (provider ? provider.label : (baseUrl ? '自定义地址' : '官方')),
77
+ authMode: subscription ? 'subscription' : 'key',
78
+ baseUrl,
79
+ keyEnv: key.name,
80
+ keyTail: keyTail(key.value),
81
+ model: envText(env, spec.modelKey),
82
+ missingExtraEnv
83
+ });
84
+ }
85
+ return services;
86
+ }
87
+
88
+ function keyValueOf(config, name) {
89
+ const run = config.runs && config.runs[name];
90
+ const agent = identifyRunAgent(name, run);
91
+ const found = agent ? findKey(AGENT_ENV_SCHEMA[agent], run.env) : null;
92
+ return found ? found.value : '';
93
+ }
94
+
95
+ /** “这个 Key 也能给另一个 Agent 用”:同一服务商在另一个 Agent 上有预设、且还没有用同一个 Key 的服务 */
96
+ function suggestReuse(config) {
97
+ const services = listModelServices(config);
98
+ const seen = new Set(services.map(item => `${item.agent}|${item.providerId}|${keyValueOf(config, item.name)}`));
99
+ const suggestions = [];
100
+ for (const item of services) {
101
+ if (!item.providerId || item.authMode !== 'key') continue;
102
+ const secret = keyValueOf(config, item.name);
103
+ for (const target of ['claude', 'codex']) {
104
+ if (target === item.agent) continue;
105
+ if (!providersForAgent(target).some(p => p.id === item.providerId)) continue;
106
+ const id = `${target}|${item.providerId}|${secret}`;
107
+ if (seen.has(id)) continue;
108
+ seen.add(id);
109
+ suggestions.push({
110
+ from: item.name,
111
+ agent: target,
112
+ providerId: item.providerId,
113
+ label: `${item.providerLabel} 的 Key 也能给 ${agentLabelOf(target)} 用`
114
+ });
115
+ }
116
+ }
117
+ return suggestions;
118
+ }
119
+
120
+ function uniqueRunName(runs, base) {
121
+ const taken = isPlainObject(runs) ? runs : {};
122
+ const root = String(base).replace(/[^A-Za-z0-9_.-]/g, '-').replace(/^[^A-Za-z0-9]+/, '').slice(0, 36) || 'model';
123
+ if (!taken[root]) return root;
124
+ for (let i = 2; i < 1000; i += 1) {
125
+ if (!taken[`${root}-${i}`]) return `${root}-${i}`;
126
+ }
127
+ throw new Error('无法生成不重名的名字');
128
+ }
129
+
130
+ function assertRunName(name) {
131
+ if (!RUN_NAME_PATTERN.test(String(name || ''))) {
132
+ throw new Error('名字只能包含字母、数字、下划线、点和横线,且以字母或数字开头,最长 40 个字符');
133
+ }
134
+ }
135
+
136
+ function presetFor(agent, providerId) {
137
+ const preset = providersForAgent(agent).find(item => item.id === providerId);
138
+ if (!preset) throw new Error(`${agentLabelOf(agent)} 没有这个服务商的预设`);
139
+ return preset;
140
+ }
141
+
142
+ /**
143
+ * 新增一套服务,返回要写入的 { name, profile };绝不覆盖已有的运行配置。
144
+ * input:{ agent, providerId | baseUrl, key, model?, name?, subscription? }
145
+ */
146
+ function buildNewService(config, input, options = {}) {
147
+ const agent = String(input.agent || '').trim();
148
+ const spec = AGENT_ENV_SCHEMA[agent];
149
+ if (!spec) throw new Error(`不支持的 Agent: ${agent}`);
150
+ const runs = isPlainObject(config && config.runs) ? config.runs : {};
151
+ const secret = String(input.key || '').trim();
152
+ if (!secret) throw new Error(input.subscription ? '请粘贴订阅令牌' : '请填写 API Key');
153
+
154
+ const env = {};
155
+ let baseName = agent;
156
+ if (input.subscription) {
157
+ if (agent !== 'claude') throw new Error('订阅登录令牌目前只支持 Claude Code');
158
+ env[CLAUDE_OAUTH_KEY] = secret;
159
+ baseName = `${agent}-subscription`;
160
+ } else if (input.providerId) {
161
+ const preset = presetFor(agent, String(input.providerId));
162
+ env[preset.keyEnv] = secret;
163
+ env[spec.baseUrlKey] = preset.baseUrl;
164
+ const model = String(input.model || '').trim() || preset.defaultModel;
165
+ if (model) env[spec.modelKey] = model;
166
+ Object.assign(env, preset.extraEnv);
167
+ baseName = `${agent}-${preset.id}`;
168
+ } else {
169
+ const baseUrl = String(input.baseUrl || '').trim();
170
+ if (baseUrl && !/^https?:\/\//i.test(baseUrl)) throw new Error('Base URL 必须以 http:// 或 https:// 开头');
171
+ env[spec.requiredAnyOf.find(name => !name.includes('OAUTH')) || spec.requiredAnyOf[0]] = secret;
172
+ if (baseUrl) env[spec.baseUrlKey] = baseUrl;
173
+ const model = String(input.model || '').trim();
174
+ if (model) env[spec.modelKey] = model;
175
+ baseName = `${agent}-custom`;
176
+ }
177
+ if (agent === 'codex' && env[spec.baseUrlKey] && !env[spec.modelKey]) {
178
+ throw new Error('Codex CLI 接兼容服务必须填写模型名');
179
+ }
180
+ const name = input.name ? String(input.name).trim() : uniqueRunName(runs, baseName);
181
+ assertRunName(name);
182
+ if (runs[name]) throw new Error(`已有名为 ${name} 的运行配置,请换一个名字`);
183
+ const profile = buildAgentRunProfile(agent, env);
184
+ profile.containerName = `my-${name}-{now}`;
185
+ return { name, profile };
186
+ }
187
+
188
+ /** 把已有服务的 Key 复用给另一个 Agent(同一服务商)。返回 { name, profile } */
189
+ function buildReuseService(config, fromName, targetAgent, options = {}) {
190
+ const services = listModelServices(config);
191
+ const source = services.find(item => item.name === fromName);
192
+ if (!source) throw new Error(`找不到模型服务: ${fromName}`);
193
+ if (!source.providerId || source.authMode !== 'key') throw new Error('这个服务不能直接复用给别的 Agent');
194
+ const built = buildNewService(config, {
195
+ agent: targetAgent,
196
+ providerId: source.providerId,
197
+ key: keyValueOf(config, fromName)
198
+ }, options);
199
+ const sourceRun = config.runs[fromName];
200
+ // 原来那套指定过自己的目录就照抄;是默认工作目录根(老版本写进去的)就不抄
201
+ const sourceHost = typeof sourceRun.hostPath === 'string' ? sourceRun.hostPath.replace(/\/+$/, '') : '';
202
+ const defaultRoot = String(options.defaultHostPath || '').replace(/\/+$/, '');
203
+ if (sourceHost && sourceHost !== defaultRoot && !built.profile.hostPath) {
204
+ built.profile.hostPath = sourceRun.hostPath;
205
+ }
206
+ return built;
207
+ }
208
+
209
+ /**
210
+ * 编辑已有服务:key 留空表示不改。返回配置改动列表 ops(set / remove,路径从根开始)。
211
+ * 订阅令牌:换成订阅令牌时删掉同一个运行配置里的 API Key 类变量和 Base URL。
212
+ */
213
+ function buildServiceEdit(config, name, patch) {
214
+ const runs = isPlainObject(config && config.runs) ? config.runs : {};
215
+ const run = runs[name];
216
+ const agent = identifyRunAgent(name, run);
217
+ if (!agent) throw new Error(`找不到模型服务: ${name}`);
218
+ const spec = AGENT_ENV_SCHEMA[agent];
219
+ const current = findKey(spec, run.env);
220
+ const ops = [];
221
+ const setEnv = (key, value) => {
222
+ parseEnvEntry(`${key}=${value}`);
223
+ ops.push({ type: 'set', path: ['runs', name, 'env', key], value: String(value) });
224
+ };
225
+ if (patch.applyPresetExtras) {
226
+ const service = listModelServices(config).find(item => item.name === name);
227
+ for (const [key, value] of Object.entries(service ? service.missingExtraEnv : {})) setEnv(key, value);
228
+ }
229
+ const secret = String(patch.key || '').trim();
230
+ if (secret) {
231
+ if (patch.subscription) {
232
+ if (agent !== 'claude') throw new Error('订阅登录令牌目前只支持 Claude Code');
233
+ setEnv(CLAUDE_OAUTH_KEY, secret);
234
+ [...CLAUDE_API_KEYS, spec.baseUrlKey].forEach(key => {
235
+ if (isPlainObject(run.env) && key in run.env) ops.push({ type: 'remove', path: ['runs', name, 'env', key] });
236
+ });
237
+ } else {
238
+ setEnv(current && current.name !== CLAUDE_OAUTH_KEY ? current.name : spec.requiredAnyOf.find(n => !n.includes('OAUTH')), secret);
239
+ }
240
+ }
241
+ if (patch.baseUrl !== undefined) {
242
+ const baseUrl = String(patch.baseUrl).trim();
243
+ if (baseUrl && !/^https?:\/\//i.test(baseUrl)) throw new Error('Base URL 必须以 http:// 或 https:// 开头');
244
+ if (baseUrl) setEnv(spec.baseUrlKey, baseUrl);
245
+ else if (isPlainObject(run.env) && spec.baseUrlKey in run.env) ops.push({ type: 'remove', path: ['runs', name, 'env', spec.baseUrlKey] });
246
+ }
247
+ if (patch.model !== undefined) {
248
+ const model = String(patch.model).trim();
249
+ if (model) setEnv(spec.modelKey, model);
250
+ else if (isPlainObject(run.env) && spec.modelKey in run.env) ops.push({ type: 'remove', path: ['runs', name, 'env', spec.modelKey] });
251
+ }
252
+ // Codex 有地址就必须有模型(不会从环境变量读默认模型,缺了请求直接报错)
253
+ if (agent === 'codex' && ops.some(op => [spec.baseUrlKey, spec.modelKey].includes(op.path[op.path.length - 1]))) {
254
+ const after = { ...(isPlainObject(run.env) ? run.env : {}) };
255
+ for (const op of ops) {
256
+ const key = op.path[op.path.length - 1];
257
+ if (op.type === 'set') after[key] = op.value;
258
+ else delete after[key];
259
+ }
260
+ if (envText(after, spec.baseUrlKey) && !envText(after, spec.modelKey)) throw new Error('Codex CLI 接兼容服务必须填写模型名');
261
+ }
262
+ return { agent, ops };
263
+ }
264
+
265
+ // 把改动列表落到配置原文上(局部替换,保留注释和其它字段)
266
+ function applyConfigOps(baseRaw, ops) {
267
+ let raw = baseRaw;
268
+ for (const op of ops) {
269
+ raw = op.type === 'remove'
270
+ ? removeValueByPath(raw, op.path)
271
+ : upsertValueByPath(raw, op.path, JSON.stringify(op.value, null, 4));
272
+ }
273
+ return raw;
274
+ }
275
+
276
+ function valueAtPath(root, pathParts) {
277
+ let node = root;
278
+ for (const key of pathParts) {
279
+ if (node === null || typeof node !== 'object' || !Object.prototype.hasOwnProperty.call(node, key)) return undefined;
280
+ node = node[key];
281
+ }
282
+ return node;
283
+ }
284
+
285
+ /**
286
+ * 写盘前核对:把改动落到原文后重新解析,每条 set 必须读回同样的值、每条 remove 必须读不到。
287
+ * 局部文本替换遇到带转义的键名等边角情况可能“静默没改到”或在前面多插一个同名键,不核对就会回报成功却没生效。
288
+ */
289
+ function assertOpsApplied(nextRaw, ops) {
290
+ const parsed = JSON5.parse(nextRaw);
291
+ for (const op of ops) {
292
+ const actual = valueAtPath(parsed, op.path);
293
+ if (op.type === 'remove' ? actual !== undefined : !isDeepStrictEqual(actual, JSON.parse(JSON.stringify(op.value)))) {
294
+ throw new Error(`配置里 ${op.path.join('.')} 没能${op.type === 'remove' ? '删除' : '写入'},请在「显示 JSON 设置」里手动修改`);
295
+ }
296
+ }
297
+ }
298
+
299
+ module.exports = {
300
+ agentLabelOf,
301
+ assertOpsApplied,
302
+ RUN_NAME_PATTERN,
303
+ listModelServices,
304
+ suggestReuse,
305
+ buildNewService,
306
+ buildReuseService,
307
+ buildServiceEdit,
308
+ applyConfigOps,
309
+ assertRunName,
310
+ keyValueOf,
311
+ uniqueRunName,
312
+ keyTail
313
+ };