@nvae/llmswitch 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -92,6 +92,21 @@ llms codex model --profile my-provider
92
92
 
93
93
  有 API Key 时会自动获取上游模型列表,也可手动输入模型 ID。
94
94
 
95
+ Claude Code 与 OpenCode 支持在默认模型之外再配一个轻量小模型,用于标题生成、总结这类低成本任务。选完默认模型后会多问一步「选择轻量小模型」,选「不设置」则沿用默认模型:
96
+
97
+ ```bash
98
+ # 交互选择(默认模型 → 启用模型 → 轻量小模型)
99
+ llms opencode model
100
+
101
+ # 直接指定,跳过小模型的交互
102
+ llms opencode model --small glm-4.5-air
103
+
104
+ # 清除已配置的小模型
105
+ llms opencode model --small ""
106
+ ```
107
+
108
+ 小模型会写入各工具自己的配置项:OpenCode 写顶层 `small_model`,Claude Code 写 `ANTHROPIC_SMALL_FAST_MODEL` / `ANTHROPIC_DEFAULT_HAIKU_MODEL`。Codex 没有对应开关,因此不提供该选项。
109
+
95
110
  ### 4. 启用配置
96
111
 
97
112
  ```bash
@@ -119,6 +134,12 @@ llms launch claude --profile my-provider --model claude-sonnet-4
119
134
 
120
135
  未指定 `--profile` 时,会自动选择包含目标模型的配置。
121
136
 
137
+ 命令行上给出的模型只作用于本次启动,不会改动已保存的供应商配置(避免打错一个模型名就把它变成默认模型)。要让它成为该供应商的新默认模型,显式加 `--save`:
138
+
139
+ ```bash
140
+ llms launch codex gpt-4.1 --save
141
+ ```
142
+
122
143
  ### 6. 预览执行计划
123
144
 
124
145
  ```bash
@@ -129,9 +150,26 @@ llms launch codex gpt-4.1 --dry-run
129
150
  llms launch codex gpt-4.1 --dry-run --json
130
151
  ```
131
152
 
132
- ### 7. 管理本地 Bridge
153
+ ### 7. 非交互使用(脚本 / CI)
133
154
 
134
- Claude Code 或 Codex 使用非原生协议时,会自动启动本地 Bridge。
155
+ 供应商管理除交互菜单外,也提供可脚本化的子命令。参数齐全时不会出现任何提示:
156
+
157
+ ```bash
158
+ # 添加并启用(--no-enable 则只保存不启用)
159
+ llms opencode provider add \
160
+ --base-url https://api.example.com/v1 --api-key sk-xxx \
161
+ --name myprov --model glm-4.6 --small glm-4.5-air --json
162
+
163
+ # 列出与删除
164
+ llms opencode provider list --json
165
+ llms opencode provider rm myprov --yes
166
+ ```
167
+
168
+ 所有交互式命令在 stdin 不是 TTY 时会立即报错并提示对应参数,而不是静默等待输入。
169
+
170
+ ### 8. 管理本地 Bridge
171
+
172
+ Claude Code、Codex 或 OpenCode 使用非原生协议时,会自动启动本地 Bridge。
135
173
 
136
174
  ```bash
137
175
  # 查看 Bridge 状态
@@ -141,12 +179,13 @@ llms bridge status
141
179
  llms bridge start
142
180
  llms bridge stop
143
181
 
144
- # 重载配置
182
+ # 重载配置(三个工具都支持)
145
183
  llms bridge reload claude
146
184
  llms bridge reload codex --profile my-provider
185
+ llms bridge reload opencode
147
186
  ```
148
187
 
149
- ### 8. 对外提供 AI 网关
188
+ ### 9. 对外提供 AI 网关
150
189
 
151
190
  Bridge 服务的是本机的 Claude Code / Codex / OpenCode。如果要让**第三方客户端**通过一个端口访问你配置的模型,用 gateway:
152
191
 
@@ -255,7 +294,7 @@ llms gateway key create --name partner \
255
294
  --daily-requests 5000
256
295
 
257
296
  llms gateway key list
258
- llms gateway key edit <id> --rate-limit 120 # 改限额/作用域/续期
297
+ llms gateway key edit <id> --rate-limit 120 --daily-requests 8000 # 改限额/作用域/续期
259
298
  llms gateway key rotate <id> # 换发明文,旧 Key 立即失效
260
299
  llms gateway key revoke <id>
261
300
  ```
@@ -333,14 +372,14 @@ llms gateway logs --follow # 持续跟踪
333
372
 
334
373
  | 环境变量 | 默认 | 说明 |
335
374
  | --- | --- | --- |
336
- | `LLM_SWITCH_MAX_CONCURRENCY` | 16 | 最大并发请求数 |
375
+ | `LLM_SWITCH_MAX_CONCURRENCY` | 0(不限) | 最大并发请求数,超出返回 503;0 表示不限 |
337
376
  | `LLM_SWITCH_MAX_BODY_BYTES` | 16MB | 请求体上限 |
338
377
  | `LLM_SWITCH_MAX_RESPONSE_BYTES` | 32MB | 上游响应上限 |
339
378
  | `LLM_SWITCH_CONNECT_TIMEOUT_MS` | 30000 | 上游连接超时 |
340
379
  | `LLM_SWITCH_IDLE_TIMEOUT_MS` | 90000 | 流式空闲超时 |
341
380
  | `LLM_SWITCH_TOTAL_TIMEOUT_MS` | 600000 | 单请求总超时 |
342
381
 
343
- `llms gateway config show` 会一并显示当前生效值。
382
+ `llms gateway config show` 会一并显示当前生效值(加 `--json` 可拿结构化输出)。这些限额对 gateway 与 Bridge 同时生效。
344
383
 
345
384
  **对外暴露的安全要求**:默认只绑回环地址。绑到非回环地址必须显式传 `--allow-remote`,且至少存在一个有效 API Key,否则拒绝启动。
346
385
 
@@ -365,11 +404,12 @@ llms gateway config set --cors-origins https://app.example.com
365
404
  | `llms` | 选择工具并启动(未配置时自动引导) |
366
405
  | `llms <tool>` | 直接启动工具(未配置时自动引导完整链路) |
367
406
  | `llms setup [--tool <tool>]` | 显式引导配置(添加供应商 → 模型 → 启用 → 启动) |
368
- | `llms <tool> provider` | 管理供应商配置(添加、查看、编辑、删除) |
407
+ | `llms <tool> provider` | 交互管理供应商(添加、查看、编辑、删除) |
408
+ | `llms <tool> provider list/add/rm` | 非交互增删查(脚本 / CI 用) |
369
409
  | `llms <tool> use [name]` | 启用指定配置 |
370
410
  | `llms <tool> current` | 查看当前配置 |
371
411
  | `llms <tool> model` | 选择模型 |
372
- | `llms launch/run <tool> [model]` | 启动工具 |
412
+ | `llms launch/run <tool> [model]` | 启动工具(加 `--save` 才改默认模型) |
373
413
  | `llms bridge status` | 查看 Bridge 状态 |
374
414
  | `llms gateway start` | 启动对外 AI 网关 |
375
415
  | `llms gateway provider import` | 从工具配置导入网关供应商 |
@@ -399,10 +439,13 @@ llms launch --help
399
439
  | --- | --- |
400
440
  | llmswitch 配置 | `~/.config/llm-switch/` |
401
441
  | 网关供应商 / Key / 日志 | `~/.config/llm-switch/gateway/` |
442
+ | 写入前的自动备份 | `~/.config/llm-switch/<tool>/backups/`(每类保留最近 10 份) |
402
443
  | Claude Code | `~/.claude/settings.json` |
403
444
  | Codex | `~/.codex/config.toml` |
404
445
  | OpenCode | `~/.config/opencode/opencode.json` |
405
446
 
447
+ 修改这些工具的配置前会先备份原文件;备份含明文 API Key,因此按类别只保留最近 10 份。若目标配置文件语法损坏,命令会报出具体文件路径而不是崩溃。
448
+
406
449
  查看实际路径:
407
450
 
408
451
  ```bash
@@ -1,8 +1,7 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
1
  import { emptyProxy } from "../types.js";
3
2
  import { assertCompatible } from "../formats/compatibility.js";
4
3
  import { clearBridgeUpstream, ensureBridgeForProfile, profileNeedsBridge, } from "../bridge/manager.js";
5
- import { backupFile, atomicWriteFile } from "../utils/fs.js";
4
+ import { backupFile, atomicWriteFile, readStructuredFile, stringRecordAt, } from "../utils/fs.js";
6
5
  import { applyProxyToEnvRecord, clearProxyEnvKeys, } from "../utils/proxy.js";
7
6
  import { getBackupsDir, getClaudeSettingsPath, } from "../utils/paths.js";
8
7
  import { setActiveProfile } from "../store/profiles.js";
@@ -24,9 +23,10 @@ export const CLAUDE_MANAGED_ENV_KEYS = [
24
23
  "all_proxy",
25
24
  ];
26
25
  export function readClaudeSettings(path = getClaudeSettingsPath()) {
27
- if (!existsSync(path))
28
- return {};
29
- return JSON.parse(readFileSync(path, "utf8"));
26
+ return readStructuredFile(path, (text) => JSON.parse(text), {
27
+ label: "Claude Code 配置",
28
+ fallback: () => ({}),
29
+ });
30
30
  }
31
31
  function stripManagedEnv(env) {
32
32
  const next = { ...env };
@@ -37,7 +37,7 @@ function stripManagedEnv(env) {
37
37
  }
38
38
  export function buildClaudeSettings(existing, profile, effectiveBaseUrl, effectiveApiKey = profile.apiKey) {
39
39
  assertCompatible("claude", profile.apiFormat);
40
- const env = stripManagedEnv({ ...(existing.env || {}) });
40
+ const env = stripManagedEnv(stringRecordAt(existing, "env"));
41
41
  clearProxyEnvKeys(env);
42
42
  const needsBridge = profileNeedsBridge(profile);
43
43
  env.ANTHROPIC_BASE_URL = effectiveBaseUrl || profile.baseUrl;
@@ -47,9 +47,9 @@ export function buildClaudeSettings(existing, profile, effectiveBaseUrl, effecti
47
47
  env.ANTHROPIC_MODEL = profile.models.default;
48
48
  env.ANTHROPIC_DEFAULT_SONNET_MODEL = profile.models.default;
49
49
  env.ANTHROPIC_DEFAULT_OPUS_MODEL = profile.models.default;
50
- if (profile.models.fast) {
51
- env.ANTHROPIC_SMALL_FAST_MODEL = profile.models.fast;
52
- env.ANTHROPIC_DEFAULT_HAIKU_MODEL = profile.models.fast;
50
+ if (profile.models.smallModel) {
51
+ env.ANTHROPIC_SMALL_FAST_MODEL = profile.models.smallModel;
52
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL = profile.models.smallModel;
53
53
  }
54
54
  else {
55
55
  env.ANTHROPIC_SMALL_FAST_MODEL = profile.models.default;
@@ -103,7 +103,7 @@ export async function deactivateClaudeProfile() {
103
103
  const configPath = getClaudeSettingsPath();
104
104
  const existing = readClaudeSettings(configPath);
105
105
  const backupPath = backupFile(configPath, getBackupsDir("claude"), "settings");
106
- const env = stripManagedEnv({ ...(existing.env || {}) });
106
+ const env = stripManagedEnv(stringRecordAt(existing, "env"));
107
107
  clearProxyEnvKeys(env);
108
108
  const next = { ...existing, env };
109
109
  if (Object.keys(env).length === 0)
@@ -1,19 +1,47 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
2
3
  import { parse, stringify } from "smol-toml";
3
4
  import { emptyProxy } from "../types.js";
4
5
  import { assertCompatible } from "../formats/compatibility.js";
5
6
  import { normalizeBaseUrlForFormat } from "../utils/base-url.js";
6
- import { atomicWriteFile, backupFile, ensureDir } from "../utils/fs.js";
7
+ import { backupFile, ensureDir, plainObjectAt, readStructuredFile, writeFilesAtomically, } from "../utils/fs.js";
7
8
  import { buildProxyEnv } from "../utils/proxy.js";
8
9
  import { getBackupsDir, getCodexConfigPath, getCodexEnvPath, getCodexHome, } from "../utils/paths.js";
9
10
  import { setActiveProfile } from "../store/profiles.js";
10
11
  import { clearBridgeUpstream, ensureBridgeForProfile, profileNeedsBridge, } from "../bridge/manager.js";
12
+ /**
13
+ * Codex identifiers derived from a profile name.
14
+ *
15
+ * Profile names may contain `-` (see NAME_RE) but Codex provider keys and env
16
+ * var names are safest restricted to `[A-Za-z0-9_]`. Naively replacing `-` with
17
+ * `_` is not injective: `a-b` and `a_b` both collapse to `a_b`, so one profile
18
+ * would silently overwrite the other's provider block and API key. Append a
19
+ * short hash of the original name whenever the name contains a character that
20
+ * gets rewritten, which keeps distinct profiles distinct.
21
+ */
22
+ function sanitizeCodexId(name) {
23
+ const sanitized = name.replace(/[^a-zA-Z0-9_]/g, "_");
24
+ if (sanitized === name)
25
+ return sanitized;
26
+ const suffix = createHash("sha256")
27
+ .update(name)
28
+ .digest("hex")
29
+ .slice(0, 6);
30
+ return `${sanitized}_${suffix}`;
31
+ }
11
32
  function providerKey(name) {
33
+ return sanitizeCodexId(name);
34
+ }
35
+ /** Pre-fix form, kept only so stale entries can be cleaned up on apply. */
36
+ function legacyProviderKey(name) {
12
37
  return name.replace(/[^a-zA-Z0-9_]/g, "_");
13
38
  }
14
39
  export function envKeyName(profileName) {
15
- const key = providerKey(profileName).toUpperCase();
16
- return `LLM_SWITCH_${key}_API_KEY`;
40
+ return `LLM_SWITCH_${providerKey(profileName).toUpperCase()}_API_KEY`;
41
+ }
42
+ /** Pre-fix env var name; removed alongside the current one so no key lingers. */
43
+ export function legacyEnvKeyName(profileName) {
44
+ return `LLM_SWITCH_${legacyProviderKey(profileName).toUpperCase()}_API_KEY`;
17
45
  }
18
46
  /**
19
47
  * Model-level metadata Codex accepts in config.toml. Only `model_context_window`
@@ -30,19 +58,23 @@ function applyModelInfo(config, profile) {
30
58
  }
31
59
  }
32
60
  export function readCodexConfig(path = getCodexConfigPath()) {
33
- if (!existsSync(path))
34
- return {};
35
- return parse(readFileSync(path, "utf8"));
61
+ return readStructuredFile(path, (text) => parse(text), {
62
+ label: "Codex 配置",
63
+ fallback: () => ({}),
64
+ });
36
65
  }
37
66
  export function buildCodexConfig(existing, profile, effectiveBaseUrl) {
38
67
  assertCompatible("codex", profile.apiFormat);
39
68
  const id = providerKey(profile.name);
40
69
  const providers = {
41
- ...(existing.model_providers || {}),
70
+ ...plainObjectAt(existing, "model_providers"),
42
71
  };
43
72
  const providerBlock = {
44
73
  name: profile.displayName || profile.name,
45
74
  base_url: effectiveBaseUrl,
75
+ // 对 Codex 一律声明 responses:原生 openai-responses 上游直连,
76
+ // openai-chat 上游则由本地 bridge 转成 /v1/responses 后再交给 Codex,
77
+ // 两种情况下 Codex 看到的都是 Responses 协议。
46
78
  wire_api: "responses",
47
79
  env_key: envKeyName(profile.name),
48
80
  requires_openai_auth: false,
@@ -55,6 +87,10 @@ export function buildCodexConfig(existing, profile, effectiveBaseUrl) {
55
87
  }
56
88
  }
57
89
  providers[id] = providerBlock;
90
+ // 清掉旧命名方案留下的同名 provider 块(否则会与新键并存)。
91
+ const legacyId = legacyProviderKey(profile.name);
92
+ if (legacyId !== id)
93
+ delete providers[legacyId];
58
94
  const next = {
59
95
  ...existing,
60
96
  model: profile.models.default,
@@ -85,7 +121,11 @@ export function buildCodexEnvFile(existingContent, profile, effectiveApiKey = pr
85
121
  order.push(k);
86
122
  map.set(k, v);
87
123
  }
124
+ // 旧命名方案下的密钥行必须删掉,否则明文会一直留在 .env 里。
125
+ const legacyKeyName = legacyEnvKeyName(profile.name);
88
126
  const keyName = envKeyName(profile.name);
127
+ if (legacyKeyName !== keyName)
128
+ map.delete(legacyKeyName);
89
129
  if (!map.has(keyName))
90
130
  order.push(keyName);
91
131
  // When bridging, Codex talks to local bridge; key can be placeholder.
@@ -156,10 +196,13 @@ export async function applyCodexProfile(profile) {
156
196
  const existing = readCodexConfig(configPath);
157
197
  const backupPath = backupFile(configPath, getBackupsDir("codex"), "config");
158
198
  backupFile(envPath, getBackupsDir("codex"), "env");
199
+ // config.toml 与 .env 必须一起生效:先把两份内容都构造好,再作为一个单元写入。
159
200
  const next = buildCodexConfig(existing, profile, effectiveBaseUrl);
160
- atomicWriteFile(configPath, stringify(next) + "\n");
161
201
  const prevEnv = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
162
- atomicWriteFile(envPath, buildCodexEnvFile(prevEnv, profile, effectiveApiKey));
202
+ writeFilesAtomically([
203
+ { path: configPath, content: stringify(next) + "\n" },
204
+ { path: envPath, content: buildCodexEnvFile(prevEnv, profile, effectiveApiKey) },
205
+ ]);
163
206
  setActiveProfile("codex", profile.name);
164
207
  return {
165
208
  tool: "codex",
@@ -180,12 +223,15 @@ export async function deactivateCodexProfile(profileName) {
180
223
  backupFile(envPath, getBackupsDir("codex"), "env");
181
224
  const next = { ...existing };
182
225
  const providers = {
183
- ...(existing.model_providers || {}),
226
+ ...plainObjectAt(existing, "model_providers"),
184
227
  };
185
228
  if (profileName) {
186
229
  const id = providerKey(profileName);
187
230
  delete providers[id];
188
- if (next.model_provider === id) {
231
+ const legacyId = legacyProviderKey(profileName);
232
+ if (legacyId !== id)
233
+ delete providers[legacyId];
234
+ if (next.model_provider === id || next.model_provider === legacyId) {
189
235
  delete next.model_provider;
190
236
  delete next.model;
191
237
  delete next.model_context_window;
@@ -197,9 +243,11 @@ export async function deactivateCodexProfile(profileName) {
197
243
  delete next.model_context_window;
198
244
  }
199
245
  next.model_providers = providers;
200
- atomicWriteFile(configPath, stringify(next) + "\n");
201
246
  const prevEnv = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
202
- atomicWriteFile(envPath, stripCodexManagedEnv(prevEnv, profileName));
247
+ writeFilesAtomically([
248
+ { path: configPath, content: stringify(next) + "\n" },
249
+ { path: envPath, content: stripCodexManagedEnv(prevEnv, profileName) },
250
+ ]);
203
251
  await clearBridgeUpstream("codex");
204
252
  return {
205
253
  tool: "codex",
@@ -219,7 +267,9 @@ function stripCodexManagedEnv(existingContent, profileName) {
219
267
  "https_proxy",
220
268
  "all_proxy",
221
269
  ]);
222
- const keyToRemove = profileName ? envKeyName(profileName) : null;
270
+ const keysToRemove = new Set(profileName
271
+ ? [envKeyName(profileName), legacyEnvKeyName(profileName)]
272
+ : []);
223
273
  const out = [];
224
274
  for (const line of lines) {
225
275
  const trimmed = line.trim();
@@ -238,9 +288,11 @@ function stripCodexManagedEnv(existingContent, profileName) {
238
288
  const k = trimmed.slice(0, eq).trim();
239
289
  if (proxyKeys.has(k))
240
290
  continue;
241
- if (keyToRemove && k === keyToRemove)
291
+ if (keysToRemove.size > 0 && keysToRemove.has(k))
242
292
  continue;
243
- if (!keyToRemove && k.startsWith("LLM_SWITCH_") && k.endsWith("_API_KEY")) {
293
+ if (keysToRemove.size === 0 &&
294
+ k.startsWith("LLM_SWITCH_") &&
295
+ k.endsWith("_API_KEY")) {
244
296
  continue;
245
297
  }
246
298
  out.push(line);
@@ -1,8 +1,7 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
1
  import { dirname } from "node:path";
3
2
  import { assertCompatible } from "../formats/compatibility.js";
4
3
  import { normalizeBaseUrlForFormat } from "../utils/base-url.js";
5
- import { atomicWriteFile, backupFile, ensureDir } from "../utils/fs.js";
4
+ import { backupFile, ensureDir, plainObjectAt, readStructuredFile, stringRecordAt, writeFilesAtomically, } from "../utils/fs.js";
6
5
  import { applyProxyToEnvRecord, clearProxyEnvKeys } from "../utils/proxy.js";
7
6
  import { getBackupsDir, getOpenCodeAuthPath, getOpenCodeConfigDir, getOpenCodeConfigPath, } from "../utils/paths.js";
8
7
  import { setActiveProfile } from "../store/profiles.js";
@@ -21,15 +20,16 @@ function npmForFormat(format) {
21
20
  }
22
21
  }
23
22
  export function readOpenCodeConfig(path = getOpenCodeConfigPath()) {
24
- if (!existsSync(path)) {
25
- return { $schema: "https://opencode.ai/config.json" };
26
- }
27
- return JSON.parse(readFileSync(path, "utf8"));
23
+ return readStructuredFile(path, (text) => JSON.parse(text), {
24
+ label: "OpenCode 配置",
25
+ fallback: () => ({ $schema: "https://opencode.ai/config.json" }),
26
+ });
28
27
  }
29
28
  export function readOpenCodeAuth(path = getOpenCodeAuthPath()) {
30
- if (!existsSync(path))
31
- return {};
32
- return JSON.parse(readFileSync(path, "utf8"));
29
+ return readStructuredFile(path, (text) => JSON.parse(text), {
30
+ label: "OpenCode 凭据文件",
31
+ fallback: () => ({}),
32
+ });
33
33
  }
34
34
  /**
35
35
  * Build one model entry for the OpenCode provider block, filling in every
@@ -81,6 +81,9 @@ export function buildOpenCodeProviderBlock(profile, overrides) {
81
81
  if (!models[profile.models.default]) {
82
82
  models[profile.models.default] = buildOpenCodeModelEntry(profile.models.default, metaById[profile.models.default]);
83
83
  }
84
+ if (profile.models.smallModel && !models[profile.models.smallModel]) {
85
+ models[profile.models.smallModel] = buildOpenCodeModelEntry(profile.models.smallModel, metaById[profile.models.smallModel]);
86
+ }
84
87
  const options = {
85
88
  baseURL: overrides?.baseURL ||
86
89
  normalizeBaseUrlForFormat(profile.apiFormat, profile.baseUrl),
@@ -102,16 +105,12 @@ export function buildOpenCodeProviderBlock(profile, overrides) {
102
105
  export function buildOpenCodeConfig(existing, profile, overrides) {
103
106
  assertCompatible("opencode", profile.apiFormat);
104
107
  const id = providerId(profile.name);
105
- const providers = {
106
- ...(existing.provider || {}),
107
- };
108
+ const providers = { ...plainObjectAt(existing, "provider") };
108
109
  providers[id] = buildOpenCodeProviderBlock(profile, overrides);
109
110
  // Optional top-level env for proxy (OpenCode may pass through). When the
110
111
  // profile routes through the bridge, the upstream proxy is applied inside the
111
112
  // bridge; skip env-var proxy injection so OpenCode doesn't apply its own.
112
- const env = {
113
- ...(existing.env || {}),
114
- };
113
+ const env = stringRecordAt(existing, "env");
115
114
  clearProxyEnvKeys(env);
116
115
  if (!overrides) {
117
116
  applyProxyToEnvRecord(env, profile.proxy);
@@ -122,6 +121,15 @@ export function buildOpenCodeConfig(existing, profile, overrides) {
122
121
  provider: providers,
123
122
  model: `${id}/${profile.models.default}`,
124
123
  };
124
+ // `small_model` handles lightweight tasks (title generation, summaries).
125
+ // Only touch it when it is ours: a user-configured value pointing at another
126
+ // provider must survive.
127
+ if (profile.models.smallModel) {
128
+ next.small_model = `${id}/${profile.models.smallModel}`;
129
+ }
130
+ else if (ownsModelRef(existing.small_model, id)) {
131
+ delete next.small_model;
132
+ }
125
133
  if (Object.keys(env).length > 0) {
126
134
  next.env = env;
127
135
  }
@@ -130,6 +138,10 @@ export function buildOpenCodeConfig(existing, profile, overrides) {
130
138
  }
131
139
  return next;
132
140
  }
141
+ /** Whether an OpenCode `provider/model` reference belongs to the given provider id. */
142
+ function ownsModelRef(value, id) {
143
+ return typeof value === "string" && value.startsWith(`${id}/`);
144
+ }
133
145
  export function buildOpenCodeAuth(existing, profile) {
134
146
  const id = providerId(profile.name);
135
147
  const next = { ...existing };
@@ -157,10 +169,13 @@ export async function applyOpenCodeProfile(profile) {
157
169
  const nextConfig = buildOpenCodeConfig(existing, profile, bridgeConnection
158
170
  ? { baseURL: bridgeConnection.baseUrl, apiKey: bridgeConnection.clientToken }
159
171
  : undefined);
160
- atomicWriteFile(configPath, JSON.stringify(nextConfig, null, 2) + "\n");
172
+ // opencode.json auth.json 必须一起生效,否则会出现“选了供应商但没有凭据”的半更新状态。
161
173
  const bridgeApiKey = bridgeConnection?.clientToken || profile.apiKey;
162
174
  const nextAuth = buildOpenCodeAuth(readOpenCodeAuth(authPath), { ...profile, apiKey: bridgeApiKey });
163
- atomicWriteFile(authPath, JSON.stringify(nextAuth, null, 2) + "\n");
175
+ writeFilesAtomically([
176
+ { path: configPath, content: JSON.stringify(nextConfig, null, 2) + "\n" },
177
+ { path: authPath, content: JSON.stringify(nextAuth, null, 2) + "\n" },
178
+ ]);
164
179
  setActiveProfile("opencode", profile.name);
165
180
  return {
166
181
  tool: "opencode",
@@ -180,15 +195,11 @@ export function deactivateOpenCodeProfile(profileName) {
180
195
  const existing = readOpenCodeConfig(configPath);
181
196
  const backupPath = backupFile(configPath, getBackupsDir("opencode"), "opencode");
182
197
  backupFile(authPath, getBackupsDir("opencode"), "auth");
183
- const providers = {
184
- ...(existing.provider || {}),
185
- };
198
+ const providers = { ...plainObjectAt(existing, "provider") };
186
199
  const id = profileName ? providerId(profileName) : null;
187
200
  if (id)
188
201
  delete providers[id];
189
- const env = {
190
- ...(existing.env || {}),
191
- };
202
+ const env = stringRecordAt(existing, "env");
192
203
  clearProxyEnvKeys(env);
193
204
  const next = {
194
205
  ...existing,
@@ -197,19 +208,28 @@ export function deactivateOpenCodeProfile(profileName) {
197
208
  if (id && typeof existing.model === "string" && existing.model.startsWith(`${id}/`)) {
198
209
  delete next.model;
199
210
  }
211
+ if (id && ownsModelRef(existing.small_model, id)) {
212
+ delete next.small_model;
213
+ }
200
214
  if (Object.keys(env).length > 0)
201
215
  next.env = env;
202
216
  else
203
217
  delete next.env;
204
- atomicWriteFile(configPath, JSON.stringify(next, null, 2) + "\n");
218
+ const writes = [
219
+ { path: configPath, content: JSON.stringify(next, null, 2) + "\n" },
220
+ ];
205
221
  if (id) {
206
222
  const auth = readOpenCodeAuth(authPath);
207
223
  if (auth[id]) {
208
224
  const nextAuth = { ...auth };
209
225
  delete nextAuth[id];
210
- atomicWriteFile(authPath, JSON.stringify(nextAuth, null, 2) + "\n");
226
+ writes.push({
227
+ path: authPath,
228
+ content: JSON.stringify(nextAuth, null, 2) + "\n",
229
+ });
211
230
  }
212
231
  }
232
+ writeFilesAtomically(writes);
213
233
  return {
214
234
  tool: "opencode",
215
235
  profile: profileName || "",
@@ -225,8 +225,15 @@ export async function stopBridge() {
225
225
  if (!instance)
226
226
  return false;
227
227
  const probe = await probeBridge(state.listener.advertiseHost, state.listener.port);
228
+ // daemon 已经死了(端口不通且 pid 不在),但 state 里还留着实例记录。
229
+ // 之前这种情况会一直抛「无法验证身份」,导致 stop 永远失败、start 也走偏。
230
+ if (!probe.reachable && !isPidRunning(instance.pid)) {
231
+ updateBridgeState((current) => ({ ...current, instance: null, pid: null }));
232
+ return false;
233
+ }
228
234
  if (!probe.authenticated || probe.instanceId !== instance.id) {
229
- throw new BridgeControlError("无法验证 bridge 实例身份;为避免误杀,未发送任何进程信号。");
235
+ throw new BridgeControlError("无法验证 bridge 实例身份;为避免误杀,未发送任何进程信号。" +
236
+ "若确认该端口上的进程与本工具无关,请改用其他端口(LLM_SWITCH_BRIDGE_PORT)。");
230
237
  }
231
238
  let response;
232
239
  try {
@@ -336,7 +336,7 @@ export function responsesEventToChatChunks(event, state) {
336
336
  const itemId = String(item.id || "");
337
337
  const entry = toolEntryFor(state, itemId);
338
338
  // Non-streamed argument payloads only appear on the done event.
339
- if (entry && entry.custom && entry.customInput) {
339
+ if (entry?.custom && entry.customInput) {
340
340
  out.push(chunk(state, {
341
341
  tool_calls: [
342
342
  {
@@ -6,7 +6,9 @@ export const DEFAULT_BRIDGE_RUNTIME_LIMITS = Object.freeze({
6
6
  connectTimeoutMs: 30_000,
7
7
  idleTimeoutMs: 90_000,
8
8
  totalTimeoutMs: 600_000,
9
- maxConcurrency: 16,
9
+ // 默认不限并发:这些进程只服务本机的 Claude Code / Codex / OpenCode 或
10
+ // 自己签发 Key 的客户端,真正的瓶颈在上游而不在这里。需要保护时显式设值。
11
+ maxConcurrency: 0,
10
12
  rateLimitPerMinute: 120,
11
13
  });
12
14
  const RUNTIME_LIMIT_SPECS = [
@@ -49,7 +51,8 @@ const RUNTIME_LIMIT_SPECS = [
49
51
  {
50
52
  env: "LLM_SWITCH_MAX_CONCURRENCY",
51
53
  key: "maxConcurrency",
52
- min: 1,
54
+ // 0 = 不限并发(默认)。
55
+ min: 0,
53
56
  max: 128,
54
57
  },
55
58
  {
@@ -76,6 +79,41 @@ export function parseBridgeRuntimeLimits(env = process.env) {
76
79
  }
77
80
  return parsed;
78
81
  }
82
+ /**
83
+ * Bounded in-flight request counter shared by the bridge and the gateway.
84
+ * `LLM_SWITCH_MAX_CONCURRENCY` is documented as applying to both, so both must
85
+ * actually enforce it.
86
+ *
87
+ * A max of 0 (the default) means unlimited: requests are still counted so
88
+ * `gateway status` can report live concurrency, but none are ever rejected.
89
+ */
90
+ export class ConcurrencyGate {
91
+ max;
92
+ active = 0;
93
+ constructor(max) {
94
+ this.max = max;
95
+ }
96
+ get inFlight() {
97
+ return this.active;
98
+ }
99
+ /** Whether a ceiling is configured at all. */
100
+ get limited() {
101
+ return this.max > 0;
102
+ }
103
+ get limit() {
104
+ return this.max;
105
+ }
106
+ tryAcquire() {
107
+ if (this.limited && this.active >= this.max)
108
+ return false;
109
+ this.active += 1;
110
+ return true;
111
+ }
112
+ release() {
113
+ if (this.active > 0)
114
+ this.active -= 1;
115
+ }
116
+ }
79
117
  export function parseBridgePort(value) {
80
118
  const raw = typeof value === "number" ? String(value) : value;
81
119
  if (!/^\d+$/.test(raw)) {