@nvae/llmswitch 1.0.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 +52 -9
- package/dist/adapters/claude.js +10 -10
- package/dist/adapters/codex.js +68 -16
- package/dist/adapters/opencode.js +45 -25
- package/dist/bridge/manager.js +8 -1
- package/dist/bridge/responses-to-chat-response.js +1 -1
- package/dist/bridge/runtime.js +40 -2
- package/dist/bridge/server.js +71 -26
- package/dist/bridge/state.js +59 -27
- package/dist/bridge/translate-response.js +98 -0
- package/dist/bridge/transport.js +39 -8
- package/dist/cli.js +18 -6
- package/dist/commands/bridge-cmd.js +15 -8
- package/dist/commands/gateway-cmd.js +112 -31
- package/dist/commands/home-cmd.js +20 -2
- package/dist/commands/launch-cmd.js +4 -0
- package/dist/commands/launch.js +36 -13
- package/dist/commands/prompts.js +96 -13
- package/dist/commands/tool.js +298 -134
- package/dist/gateway/keys.js +11 -2
- package/dist/gateway/rate-limit.js +8 -104
- package/dist/gateway/router.js +7 -1
- package/dist/gateway/server.js +1 -22
- package/dist/gateway/store.js +37 -4
- package/dist/gateway/usage.js +24 -4
- package/dist/index.js +0 -0
- package/dist/store/profiles.js +54 -14
- package/dist/types.js +12 -0
- package/dist/utils/display.js +71 -0
- package/dist/utils/fetch-models.js +26 -12
- package/dist/utils/file-lock.js +149 -0
- package/dist/utils/fs.js +187 -9
- package/dist/utils/model-metadata.js +54 -1
- package/package.json +7 -2
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.
|
|
153
|
+
### 7. 非交互使用(脚本 / CI)
|
|
133
154
|
|
|
134
|
-
|
|
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
|
-
###
|
|
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` |
|
|
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
|
package/dist/adapters/claude.js
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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(
|
|
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.
|
|
51
|
-
env.ANTHROPIC_SMALL_FAST_MODEL = profile.models.
|
|
52
|
-
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = profile.models.
|
|
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(
|
|
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)
|
package/dist/adapters/codex.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
226
|
+
...plainObjectAt(existing, "model_providers"),
|
|
184
227
|
};
|
|
185
228
|
if (profileName) {
|
|
186
229
|
const id = providerKey(profileName);
|
|
187
230
|
delete providers[id];
|
|
188
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
291
|
+
if (keysToRemove.size > 0 && keysToRemove.has(k))
|
|
242
292
|
continue;
|
|
243
|
-
if (
|
|
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 {
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
172
|
+
// opencode.json 与 auth.json 必须一起生效,否则会出现“选了供应商但没有凭据”的半更新状态。
|
|
161
173
|
const bridgeApiKey = bridgeConnection?.clientToken || profile.apiKey;
|
|
162
174
|
const nextAuth = buildOpenCodeAuth(readOpenCodeAuth(authPath), { ...profile, apiKey: bridgeApiKey });
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 || "",
|
package/dist/bridge/manager.js
CHANGED
|
@@ -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
|
|
339
|
+
if (entry?.custom && entry.customInput) {
|
|
340
340
|
out.push(chunk(state, {
|
|
341
341
|
tool_calls: [
|
|
342
342
|
{
|
package/dist/bridge/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)) {
|