@luchenglong/cli-bridge 0.2.10 → 0.2.12
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/CHANGELOG.md +60 -0
- package/PROVIDER-CONTRACT.md +22 -4
- package/dynamic/claude.host.js +232 -33
- package/dynamic/codex.host.js +7 -1
- package/mcp/cli-bridge-mcp.mjs +1 -1
- package/package.json +1 -1
- package/providers/claude-endpoint.mjs +132 -0
- package/providers/claude-models.mjs +61 -25
- package/providers/claude.mjs +37 -8
- package/providers/codex.mjs +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,66 @@ GitHub Release(`scripts/verify.mjs` 在 CI 与 prepack 双重锁一致)。
|
|
|
9
9
|
> - CodeBuddy 线:[docs/history/codebuddy/CHANGELOG.md](docs/history/codebuddy/CHANGELOG.md)(终版 1.6.2)
|
|
10
10
|
> - MiMo 线:[docs/history/mimo/CHANGELOG.md](docs/history/mimo/CHANGELOG.md)(终版 1.2.9)
|
|
11
11
|
|
|
12
|
+
## [0.2.12] - 2026-10-20
|
|
13
|
+
|
|
14
|
+
> 起因是用户问「现在所有 CLI 都是默认无需用户点确认的模式吗」。核对后发现 **codex 不是** ——
|
|
15
|
+
> 它此前完全依赖用户自己的 `~/.codex/config.toml`。
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **codex 在非交互场景可能等一个没人能点的批准,一路挂到超时**。`codex exec` 没有 TTY,
|
|
19
|
+
此前 provider 的 `approvalPolicy` 默认留空 → 不传 `-c approval_policy` → 完全由用户的
|
|
20
|
+
`~/.codex/config.toml` 决定。本机因为恰好写了 `approval_policy = "never"`(实测 stderr 报
|
|
21
|
+
`approval: never`)才没炸;换台机器或用户改配置就会静默变成"每次都等批准"。
|
|
22
|
+
现在:用户显式配了就用;没配则在 accept-edits/bypass 语义下**强制**
|
|
23
|
+
`-c approval_policy="never"`(plan 态不注入,保留人工确认的语义)。
|
|
24
|
+
四家免批准方式现已一致且显式:
|
|
25
|
+
agy `--dangerously-skip-permissions --mode accept-edits` / claude
|
|
26
|
+
`--dangerously-skip-permissions` / codex `-c approval_policy="never"` / opencode `--auto`。
|
|
27
|
+
|
|
28
|
+
### Added(验收工具,不参与 `node --test`)
|
|
29
|
+
- `tests/e2e/cli-connectivity.mjs`:逐家真实跑**两轮**,第一轮要求记住暗号、第二轮续聊同一
|
|
30
|
+
会话问暗号。只有暗号回来才算"持续沟通正常"——**只看有没有回 sessionId 是不够的**。
|
|
31
|
+
四家实测全通过(agy `--conversation` / claude `--resume` / codex `exec resume` /
|
|
32
|
+
opencode `--session`)。顺带修了脚本里 `opencode` 直接写裸名导致的 ENOENT(真名
|
|
33
|
+
`opencode-cli.exe`,装在 `ai.opencode.desktop\cli\<ver>\`、不在 PATH)。
|
|
34
|
+
- `tests/e2e/lamp-live-check.mjs`:用 preset 形态 + 模拟宿主 ctx 真跑一次任务,捕获
|
|
35
|
+
`<id>/status` 事件,验证「灯会亮」。四家均实测 `running → ok`。
|
|
36
|
+
过程中踩到一个**桩自身的坑**(值得记下):模拟宿主 `subprocess.spawn` 时必须遵守
|
|
37
|
+
`spec.stdio.stdin === 'ignore'`。桩里不管 stdio 时 stdin 是一直开着的 pipe,CLI 会等 stdin,
|
|
38
|
+
最终被打超时杀死 → `exitCode=null` → 明明答对了却被判 `CLI_ERROR`。
|
|
39
|
+
对照实验:`stdin=ignore` → exit 0/SUCCESS;`stdin=pipe 不关` → exit null/CLI_ERROR。
|
|
40
|
+
|
|
41
|
+
## [0.2.11] - 2026-10-20
|
|
42
|
+
|
|
43
|
+
> 用户反馈:「claude 我是用 cc-switch 改的配置,你现在获取的模型不对」。查证后不是列表写错,
|
|
44
|
+
> 而是**整条链路**有两处判断错误 —— 本版修复。
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
- **claude 直连 cc-switch 的 proxy 端点必失败**(这是根本原因)。cc-switch 的 **proxy 模式**
|
|
48
|
+
把 Claude 指向第三方网关(本机实测 `https://opencode.ai/zen/go`),该网关要求自家 session 头
|
|
49
|
+
(缺则 `400 MissingSessionID`),而 claude CLI 不会发这个头。同一配置实测对照:
|
|
50
|
+
- 直连 → `exit=1, API Error: 400 MissingSessionID`
|
|
51
|
+
- 改指 cc-switch 本地代理 `http://127.0.0.1:15721` → `exit=0, "OK"`
|
|
52
|
+
现在桥接用 `--settings '{"env":{"ANTHROPIC_BASE_URL":"<proxy>"}}'` **只覆盖 base_url**,
|
|
53
|
+
**绝不改写** `~/.claude/settings.json`(那是 cc-switch 的领地,改它下次切换即被打回)。
|
|
54
|
+
新增设置项:`endpointMode`(`auto` 探活代理 / `proxy` 强制,探不到**明确报错** / `direct`
|
|
55
|
+
强制直连)、`proxyUrl`(默认 `127.0.0.1:15721`)。面板会显示当前实际走法。
|
|
56
|
+
- **模型清单漏掉了用户实际在用的模型**。原先只认「无效模型触发的服务端 400 报文」,而实测:
|
|
57
|
+
- 服务端只回**基础名** `deepseek-flash, deepseek-v4-pro`;
|
|
58
|
+
- 用户配置的 `deepseek-v4-pro[1M]` / `deepseek-v4-flash` 逐个实测**都 200 可用**;
|
|
59
|
+
- 且走本地代理时网关对任何模型名都放行(连探测用的假模型名都回显),**永远不产生 400 报文**
|
|
60
|
+
—— 只靠探测时这份清单等于空。
|
|
61
|
+
现在改为**配置来源 ∪ 服务端报文**,各带 `source` 标注(`configured` 在前、`account-live` 补充),
|
|
62
|
+
面板标签会写明「来自哪个 ANTHROPIC_*_MODEL」或「[服务端清单]」。
|
|
63
|
+
**红线不变**:来源必须如实标注,配置来源绝不冒充服务端权威。
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
- `providers/claude-endpoint.mjs`(新片):端点解析与代理探活(`node -e` 做 HTTP GET,
|
|
67
|
+
结果按 URL 缓存 30s,因为 buildArgv/meta 都是同步回调,Node 无同步 HTTP)。
|
|
68
|
+
- 测试改为**密闭**:claude 的模型测试与 MCP 测试此前会读本机真实 `~/.claude/settings.json`
|
|
69
|
+
(实测期望 2 个却出现 5 个),现统一注入 `CLAUDE_CONFIG_DIR` 指向临时目录;
|
|
70
|
+
buildArgv 断言注入 `probeProxy`,与本机代理是否在跑解耦。
|
|
71
|
+
|
|
12
72
|
## [0.2.10] - 2026-10-20
|
|
13
73
|
|
|
14
74
|
> ⚠️ **0.2.9 的 dynamic 沙箱形态在 opencode / mimo 两家是坏的**,本版修复。若你在用这两家的
|
package/PROVIDER-CONTRACT.md
CHANGED
|
@@ -287,12 +287,30 @@ export function check(env) → true | throw // --che
|
|
|
287
287
|
- **成功判定陷阱**:`is_error=true` 时退出码可能是 0 也可能是 1,且 `subtype` 仍可能是
|
|
288
288
|
`"success"`(无效模型实测就是这个形状)。判定必须 `is_error === false && exitCode === 0`。
|
|
289
289
|
- 续聊:`--resume <session_id>` / `-c|--continue` / `--fork-session`。
|
|
290
|
-
-
|
|
291
|
-
|
|
292
|
-
`
|
|
293
|
-
|
|
290
|
+
- **模型清单 = 配置来源 ∪ 服务端报文**(0.2.11 起,两者各带 `source` 标注)。踩过的两个坑:
|
|
291
|
+
① **只信服务端 400 报文会给出「比用户配的还少」的清单** —— 实测 DeepSeek 端点只回
|
|
292
|
+
`deepseek-flash, deepseek-v4-pro` 两个基础名,而用户配置的 `deepseek-v4-pro[1M]` /
|
|
293
|
+
`deepseek-v4-flash` 逐个实测都 200(带上下文后缀/命名变体,报文不列)。
|
|
294
|
+
② **走本地代理时探测会失效**:某些网关对任何模型名都放行(甚至回显探测用的假名),
|
|
295
|
+
永远不产生 400 报文 —— 此时若只靠探测就等于没有清单。
|
|
296
|
+
故 `~/.claude/settings.json` 的 `ANTHROPIC_MODEL` / `ANTHROPIC_DEFAULT_*_MODEL` 是**合法
|
|
297
|
+
清单来源之一**(`source: 'configured'`,排在前),服务端报文作补充(`source: 'account-live'`)。
|
|
298
|
+
**红线不变**:来源必须如实标注,配置来源绝不冒充服务端权威。
|
|
299
|
+
- **端点必须能走 cc-switch 本地代理**(0.2.11 起)。用户用 cc-switch 的 **proxy 模式** 把
|
|
300
|
+
Claude 指向第三方网关(例 `https://opencode.ai/zen/go`),该网关要求自家 session 头
|
|
301
|
+
(缺则 `400 MissingSessionID`),而 **claude CLI 不会发这个头 → 直连必然失败**。
|
|
302
|
+
实测对照:同一配置直连 → 400;改指 `http://127.0.0.1:15721`(cc-switch 本地代理)→ 200。
|
|
303
|
+
做法:`--settings '{"env":{"ANTHROPIC_BASE_URL":"<proxy>"}}'` 只覆盖 base_url,
|
|
304
|
+
**绝不改写** `~/.claude/settings.json`(那是 cc-switch 的领地,改它下次切换即被打回)。
|
|
305
|
+
设置项 `endpointMode`:`auto`(探活代理,活着就用)/ `proxy`(强制,探不到则**明确报错**)/
|
|
306
|
+
`direct`(强制直连)。代理地址 `proxyUrl` 默认 `http://127.0.0.1:15721`。
|
|
307
|
+
探活靠一次性 `node -e` 子进程做 HTTP GET(Node 无同步 HTTP),结果按 URL 缓存 30s。
|
|
294
308
|
- 权限:`-p` 无 TTY,任何需要交互批准的动作都会挂到超时,故 auto 模式映射为
|
|
295
309
|
`--dangerously-skip-permissions`(或 plan 态 `--permission-mode plan`)。
|
|
310
|
+
- **Claude Desktop 不是可执行后端**:它是 GUI、无 `-p` 式非交互入口(本机实测其配置目录为空 =
|
|
311
|
+
未安装);cc-switch 对它的 `/claude-desktop/v1/messages` 还需一个内部 gateway token
|
|
312
|
+
(db 里 `claude_desktop_gateway_token` 实测校验不过)。故它只能作为**配置来源**被读取,
|
|
313
|
+
不能作为 provider 的第二个"执行面"。
|
|
296
314
|
|
|
297
315
|
### codex(OpenAI Codex CLI,实测 0.98.0)
|
|
298
316
|
- 非交互:`codex exec [opts] <prompt>`;`--json` 出事件流,不加就是纯文本。
|
package/dynamic/claude.host.js
CHANGED
|
@@ -585,7 +585,8 @@ function createRunner(o) {
|
|
|
585
585
|
// - 模型清单只认服务端权威探测;拿不到 = ok:false + 可操作错误(绝不静默空数组/配置冒充)。
|
|
586
586
|
// import { createStatusEngine, createRunner, clampInt, isLimited } from '../core/bridge-kit.mjs'
|
|
587
587
|
// import { claudeExeCandidates, claudePathHint, resolveExePrefix, splitExeSpec, CLAUDE_BIN_ENV_VARS } from './claude-exe.mjs'
|
|
588
|
-
// import { PROBE_MODEL, parseSupportedModels, probeSupportedModels, readCachedModels, readConfiguredModels, listAccountModels, resetModelsCache } from './claude-models.mjs'
|
|
588
|
+
// import { PROBE_MODEL, parseSupportedModels, probeSupportedModels, readCachedModels, readConfiguredModels, listAccountModels, resetModelsCache, claudeConfigDir } from './claude-models.mjs'
|
|
589
|
+
// import { ENDPOINT_MODES, DEFAULT_PROXY_URL, resolveEndpoint, overlaySettingsJson, readConfiguredBaseUrl } from './claude-endpoint.mjs'
|
|
589
590
|
// 单行 re-export(build 内联时整行剥离;多行形态风险见 build.mjs 的 stripModuleSyntax 注释)。
|
|
590
591
|
|
|
591
592
|
function nowIso() { return new Date().toISOString() }
|
|
@@ -755,6 +756,12 @@ function defineProvider (deps = {}) {
|
|
|
755
756
|
else argv.push('--output-format', 'json')
|
|
756
757
|
if (stream && args.includePartialMessages) argv.push('--include-partial-messages')
|
|
757
758
|
const timeoutSec = clampInt(args.timeoutSec, 600, 10, 3600)
|
|
759
|
+
// 端点:cc-switch 的 proxy 模式把 Claude 指向第三方网关,而该网关要求自家 session 头
|
|
760
|
+
// (claude CLI 不会发)——**直连必然 400**,必须经 cc-switch 本地代理转发。
|
|
761
|
+
// 这里用 --settings 只覆盖 ANTHROPIC_BASE_URL,绝不改写用户的 ~/.claude/settings.json。
|
|
762
|
+
const ep = resolveEndpoint(currentSettings(), deps.env || {}, { probe: o.probeProxy, configDir: claudeConfigDir(deps.env || {}) })
|
|
763
|
+
if (ep.error) { const e = new Error(ep.error); e.code = 'ENDPOINT_UNAVAILABLE'; throw e }
|
|
764
|
+
if (ep.mode === 'proxy' && ep.baseUrl) argv.push('--settings', overlaySettingsJson(ep.baseUrl))
|
|
758
765
|
|
|
759
766
|
// 权限:auto → 跟随 DSH plan 状态;plan 用 CLI 的 plan 模式,其余走 bypass
|
|
760
767
|
//(-p 没有 TTY,任何交互批准都会挂到超时 —— 与 agy/codebuddy/opencode 同语义)。
|
|
@@ -992,6 +999,8 @@ function defineProvider (deps = {}) {
|
|
|
992
999
|
const cached = readCachedModels(e, { binPath: s.binPath })
|
|
993
1000
|
const options = (cached ? cached.ids : []).map((id) => ({ value: id, label: id }))
|
|
994
1001
|
const exeCandidates = claudeExeCandidates(e, '')
|
|
1002
|
+
// 端点实际走向(proxy / direct)——面板上如实显示,用户不用猜为什么请求失败。
|
|
1003
|
+
const epInfo = resolveEndpoint(s, e, { configDir: claudeConfigDir(e) })
|
|
995
1004
|
return {
|
|
996
1005
|
titleSuffix: 'Claude Code',
|
|
997
1006
|
fields: [
|
|
@@ -1015,6 +1024,20 @@ function defineProvider (deps = {}) {
|
|
|
1015
1024
|
key: 'maxBudgetUsd', label: '单次最大花费(美元)', type: 'text',
|
|
1016
1025
|
placeholder: '留空 = 不限制',
|
|
1017
1026
|
note: '非空时自动附加 --max-budget-usd;调用参数 maxBudgetUsd 可覆盖。'
|
|
1027
|
+
},
|
|
1028
|
+
{
|
|
1029
|
+
key: 'endpointMode', label: '端点模式', type: 'select',
|
|
1030
|
+
options: [
|
|
1031
|
+
{ value: 'auto', label: 'auto —— 代理活着就走代理,否则直连' },
|
|
1032
|
+
{ value: 'proxy', label: 'proxy —— 强制走本地代理' },
|
|
1033
|
+
{ value: 'direct', label: 'direct —— 强制直连配置的端点' }
|
|
1034
|
+
],
|
|
1035
|
+
note: epInfo.note + '。cc-switch 的 proxy 模式把 Claude 指向第三方网关,该网关要求自家 session 头而 claude CLI 不会发:直连必然 400,必须经 cc-switch 本地代理转发。'
|
|
1036
|
+
},
|
|
1037
|
+
{
|
|
1038
|
+
key: 'proxyUrl', label: '本地代理地址', type: 'text',
|
|
1039
|
+
placeholder: DEFAULT_PROXY_URL,
|
|
1040
|
+
note: 'cc-switch 的本地代理默认监听 127.0.0.1:15721。若你改过端口,填这里。桥接用 --settings 只覆盖 ANTHROPIC_BASE_URL,**不会改写** ~/.claude/settings.json。'
|
|
1018
1041
|
}
|
|
1019
1042
|
]
|
|
1020
1043
|
}
|
|
@@ -1032,24 +1055,30 @@ function defineProvider (deps = {}) {
|
|
|
1032
1055
|
async refreshModels(settings, env2) {
|
|
1033
1056
|
const e = (env2 && typeof env2 === 'object') ? env2 : env
|
|
1034
1057
|
const s = descriptor.settings.normalize(settings)
|
|
1058
|
+
// 探测必须走**同一个端点**:cc-switch proxy 模式下直连第三方网关会 400,那样探测
|
|
1059
|
+
// 永远拿不到东西(但配置清单仍应给出,见 listAccountModels 的合并逻辑)。
|
|
1060
|
+
const ep = resolveEndpoint(s, e, { configDir: claudeConfigDir(e) })
|
|
1035
1061
|
const got = await listAccountModels(e, {
|
|
1036
1062
|
binPath: s.binPath,
|
|
1063
|
+
baseUrl: ep.mode === 'proxy' ? ep.baseUrl : '',
|
|
1037
1064
|
timeoutMs: REFRESH_TIMEOUT_MS,
|
|
1038
1065
|
refresh: true
|
|
1039
1066
|
})
|
|
1040
1067
|
if (!got.ok) {
|
|
1041
|
-
return { ok: false, ids: [], source: '
|
|
1068
|
+
return { ok: false, ids: [], source: got.source || 'none', error: String(got.error || 'probe failed') + ' ' + REFRESH_HINT + ' 端点:' + ep.note }
|
|
1042
1069
|
}
|
|
1043
1070
|
const ids = (got.models || []).map((m) => m.id).filter(Boolean)
|
|
1044
1071
|
if (!ids.length) {
|
|
1045
|
-
|
|
1046
|
-
// 必须显式失败,绝不能让面板显示一个空清单当成功。
|
|
1047
|
-
return { ok: false, ids: [], source: 'account-live', error: '探测声称成功但未返回任何模型 id(内部不一致)。' + REFRESH_HINT }
|
|
1072
|
+
return { ok: false, ids: [], source: got.source || 'none', error: '探测声称成功但未返回任何模型 id(内部不一致)。' + REFRESH_HINT }
|
|
1048
1073
|
}
|
|
1049
|
-
// labels
|
|
1074
|
+
// labels 标出「来自配置」还是「服务端报文补充」,面板可据此区分来源。
|
|
1050
1075
|
const labels = {}
|
|
1051
|
-
for (const m of got.models)
|
|
1052
|
-
|
|
1076
|
+
for (const m of got.models) {
|
|
1077
|
+
labels[m.id] = m.id + (m.source === 'configured'
|
|
1078
|
+
? ' [' + String(m.configuredAs || 'settings.json') + ']'
|
|
1079
|
+
: ' [服务端清单]')
|
|
1080
|
+
}
|
|
1081
|
+
return { ok: true, ids, labels, source: got.source || 'configured', endpoint: ep.note }
|
|
1053
1082
|
},
|
|
1054
1083
|
diagnose: (settings, env2) => {
|
|
1055
1084
|
const s = settings && typeof settings === 'object' ? settings : {}
|
|
@@ -1123,6 +1152,140 @@ function defineProvider (deps = {}) {
|
|
|
1123
1152
|
return descriptor
|
|
1124
1153
|
}
|
|
1125
1154
|
|
|
1155
|
+
// ── inlined from providers/claude-endpoint.mjs ──
|
|
1156
|
+
// providers/claude-endpoint.mjs — claude 的端点解析(第三片)。
|
|
1157
|
+
//
|
|
1158
|
+
// 为什么需要这一层(真实事故,2026-10-20):
|
|
1159
|
+
// 用户用 cc-switch 管理 Claude 配置,其「proxy 模式」把 Claude 指向第三方网关
|
|
1160
|
+
// (例:ANTHROPIC_BASE_URL=https://opencode.ai/zen/go)。该网关要求自家 session 头
|
|
1161
|
+
// (`x-opencode-session` / MissingSessionID),claude CLI 不会发 → **直连必然 400**。
|
|
1162
|
+
// 能用是因为 cc-switch 在本机起了一个转发代理,由它补头/认证:
|
|
1163
|
+
// 实测 POST http://127.0.0.1:15721/v1/messages → 200;同一配置直连 → 400 MissingSessionID。
|
|
1164
|
+
// 所以「把 base_url 换成 cc-switch 代理」不是降级,而是这套配置**唯一可行**的访问方式。
|
|
1165
|
+
//
|
|
1166
|
+
// 注入方式:`--settings <json>` 覆盖 env.ANTHROPIC_BASE_URL,**不改用户的
|
|
1167
|
+
// ~/.claude/settings.json**(那是 cc-switch 的领地,改它会在下次切换时被覆盖,也可能
|
|
1168
|
+
// 破坏用户的 GUI 会话)。
|
|
1169
|
+
//
|
|
1170
|
+
// 探活采用「起一次 node 子进程做 HTTP GET」:buildArgv/meta 都是同步回调,Node 无同步
|
|
1171
|
+
// HTTP。结果按 URL 缓存 30s,避免每次调用都起进程。探不到就如实回落直连(并在诊断里
|
|
1172
|
+
// 说明"代理未响应"),绝不再假装已接管。
|
|
1173
|
+
|
|
1174
|
+
/** 默认的 cc-switch 本地代理地址(其 proxy_config.listen_port 实测为 15721)。 */
|
|
1175
|
+
const DEFAULT_PROXY_URL = 'http://127.0.0.1:15721'
|
|
1176
|
+
|
|
1177
|
+
/** 端点模式:auto = 代理活着就用它;proxy = 强制;direct = 强制直连。 */
|
|
1178
|
+
const ENDPOINT_MODES = ['auto', 'proxy', 'direct']
|
|
1179
|
+
|
|
1180
|
+
const PROXY_TTL_MS = 30 * 1000
|
|
1181
|
+
let proxyCache = { url: '', at: 0, alive: false, detail: '' }
|
|
1182
|
+
|
|
1183
|
+
/** 允许测试注入探测结果,避免每个用例都起子进程。 */
|
|
1184
|
+
function resetProxyCache() { proxyCache = { url: '', at: 0, alive: false, detail: '' } }
|
|
1185
|
+
|
|
1186
|
+
function builtin(name) {
|
|
1187
|
+
const p = globalThis.process
|
|
1188
|
+
try { return (p && typeof p.getBuiltinModule === 'function') ? p.getBuiltinModule(name) : null } catch (e) { return null }
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/** 探活子脚本:对 url 发 GET,任何 HTTP 响应都算活着(404 也算 —— cc-switch 根路径就是 404)。 */
|
|
1192
|
+
const PROBE_SCRIPT = [
|
|
1193
|
+
"const http=require('http'),https=require('https');",
|
|
1194
|
+
"const u=process.argv[1];",
|
|
1195
|
+
"const m=/^https:/i.test(u)?https:http;",
|
|
1196
|
+
"const r=m.get(u,{timeout:1500},(res)=>{res.resume();console.log('ALIVE '+res.statusCode);process.exit(0)});",
|
|
1197
|
+
"r.on('error',(e)=>{console.log('DEAD '+(e.code||e.message));process.exit(0)});",
|
|
1198
|
+
"r.on('timeout',()=>{console.log('DEAD timeout');try{r.destroy()}catch(e){};process.exit(0)});"
|
|
1199
|
+
].join('')
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* 同步探活(带 30s 缓存)。返回 { alive, detail }。
|
|
1203
|
+
* 起 `node -e` 子进程是唯一能在同步回调里做真实 HTTP 的办法;代价约 100-250ms,
|
|
1204
|
+
* 且被 URL 维度缓存 —— 只在「首次」或「缓存过期」时付。
|
|
1205
|
+
*/
|
|
1206
|
+
function detectProxySync(url, nowMs) {
|
|
1207
|
+
const target = String(url || '').trim()
|
|
1208
|
+
if (!target) return { alive: false, detail: 'no url' }
|
|
1209
|
+
const at = Number(nowMs) || Date.now()
|
|
1210
|
+
if (proxyCache.url === target && at - proxyCache.at < PROXY_TTL_MS) {
|
|
1211
|
+
return { alive: proxyCache.alive, detail: proxyCache.detail }
|
|
1212
|
+
}
|
|
1213
|
+
const child = builtin('node:child_process')
|
|
1214
|
+
const proc = globalThis.process
|
|
1215
|
+
let alive = false
|
|
1216
|
+
let detail = 'no child_process'
|
|
1217
|
+
if (child && typeof child.spawnSync === 'function' && proc && proc.execPath) {
|
|
1218
|
+
try {
|
|
1219
|
+
const r = child.spawnSync(proc.execPath, ['-e', PROBE_SCRIPT, target], {
|
|
1220
|
+
encoding: 'utf8', timeout: 4000, windowsHide: true,
|
|
1221
|
+
env: Object.assign({}, proc.env)
|
|
1222
|
+
})
|
|
1223
|
+
const out = String((r && r.stdout) || '')
|
|
1224
|
+
alive = /^ALIVE\b/m.test(out)
|
|
1225
|
+
detail = out.trim().split(/\r?\n/).pop() || (r && r.error && (r.error.code || r.error.message)) || 'unknown'
|
|
1226
|
+
} catch (e) { detail = 'probe threw: ' + String(e && e.message || e) }
|
|
1227
|
+
}
|
|
1228
|
+
proxyCache = { url: target, at, alive, detail }
|
|
1229
|
+
return { alive, detail }
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/** 读 ~/.claude/settings.json 里配置的 ANTHROPIC_BASE_URL(只读,不写)。 */
|
|
1233
|
+
function readConfiguredBaseUrl(env, configDir) {
|
|
1234
|
+
const fs = builtin('node:fs')
|
|
1235
|
+
const path = builtin('node:path')
|
|
1236
|
+
if (!fs || !path) return ''
|
|
1237
|
+
const dir = configDir || ((env && (env.CLAUDE_CONFIG_DIR || env.CLAUDE_HOME)) || '')
|
|
1238
|
+
if (!dir) return ''
|
|
1239
|
+
try {
|
|
1240
|
+
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf8'))
|
|
1241
|
+
const block = raw && raw.env && typeof raw.env === 'object' ? raw.env : {}
|
|
1242
|
+
return typeof block.ANTHROPIC_BASE_URL === 'string' ? block.ANTHROPIC_BASE_URL.trim() : ''
|
|
1243
|
+
} catch (e) { return '' }
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* 解析最终端点。
|
|
1248
|
+
* @returns {{ mode: 'proxy'|'direct', baseUrl: string, proxyUrl: string, proxyAlive: boolean,
|
|
1249
|
+
* configuredBaseUrl: string, note: string, error?: string }}
|
|
1250
|
+
*/
|
|
1251
|
+
function resolveEndpoint(settings, env, opts) {
|
|
1252
|
+
const o = opts || {}
|
|
1253
|
+
const s = settings && typeof settings === 'object' ? settings : {}
|
|
1254
|
+
const mode = ENDPOINT_MODES.indexOf(String(s.endpointMode || '')) >= 0 ? String(s.endpointMode) : 'auto'
|
|
1255
|
+
const proxyUrl = String(s.proxyUrl || DEFAULT_PROXY_URL).trim() || DEFAULT_PROXY_URL
|
|
1256
|
+
const configuredBaseUrl = readConfiguredBaseUrl(env, o.configDir)
|
|
1257
|
+
|
|
1258
|
+
if (mode === 'direct') {
|
|
1259
|
+
return { mode: 'direct', baseUrl: configuredBaseUrl, proxyUrl, proxyAlive: false, configuredBaseUrl, note: '强制直连(endpointMode=direct)' }
|
|
1260
|
+
}
|
|
1261
|
+
const det = o.probe ? o.probe(proxyUrl) : detectProxySync(proxyUrl)
|
|
1262
|
+
if (det.alive) {
|
|
1263
|
+
return {
|
|
1264
|
+
mode: 'proxy', baseUrl: proxyUrl, proxyUrl, proxyAlive: true, configuredBaseUrl,
|
|
1265
|
+
note: '经 cc-switch 本地代理 ' + proxyUrl + '(配置的端点 ' + (configuredBaseUrl || '(未读到)') + ' 需要代理补头)'
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
if (mode === 'proxy') {
|
|
1269
|
+
return {
|
|
1270
|
+
mode: 'proxy', baseUrl: proxyUrl, proxyUrl, proxyAlive: false, configuredBaseUrl,
|
|
1271
|
+
note: '强制走代理但探测失败:' + det.detail,
|
|
1272
|
+
error: 'cc-switch 本地代理 ' + proxyUrl + ' 无响应(' + det.detail + ')。请启动 cc-switch(含其本地代理),' +
|
|
1273
|
+
'或把「端点模式」改为 direct 直连,或在「代理地址」里填实际端口。'
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
return {
|
|
1277
|
+
mode: 'direct', baseUrl: configuredBaseUrl, proxyUrl, proxyAlive: false, configuredBaseUrl,
|
|
1278
|
+
note: '直连配置的端点 ' + (configuredBaseUrl || '(未读到)') + '(本地代理 ' + proxyUrl + ' 未响应:' + det.detail + ')。' +
|
|
1279
|
+
'若该端点来自 cc-switch 的 proxy 模式,请启动 cc-switch —— 否则会被网关以缺 session 头拒绝。'
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/** 把端点覆盖编成 claude 的 `--settings` JSON(只覆盖 base_url,不动其它键)。 */
|
|
1284
|
+
function overlaySettingsJson(baseUrl) {
|
|
1285
|
+
if (!baseUrl) return ''
|
|
1286
|
+
return JSON.stringify({ env: { ANTHROPIC_BASE_URL: String(baseUrl) } })
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1126
1289
|
// ── inlined from providers/claude-exe.mjs ──
|
|
1127
1290
|
// providers/claude-exe.mjs — claude 可执行文件探测/包装(无宿主依赖的纯逻辑片)。
|
|
1128
1291
|
//
|
|
@@ -1447,8 +1610,15 @@ function readConfiguredModels(env) {
|
|
|
1447
1610
|
}
|
|
1448
1611
|
|
|
1449
1612
|
/** 探测 argv(不含 exe 前缀):固定参数集,绝不拼装用户 prompt。 */
|
|
1450
|
-
|
|
1451
|
-
|
|
1613
|
+
/**
|
|
1614
|
+
* 探测用的 argv。`baseUrl` 非空时用 `--settings` 覆盖 ANTHROPIC_BASE_URL ——
|
|
1615
|
+
* 这是 cc-switch proxy 模式唯一可行的访问方式(直连第三方网关缺 session 头必 400)。
|
|
1616
|
+
* 只覆盖 base_url,不碰用户的 ~/.claude/settings.json。
|
|
1617
|
+
*/
|
|
1618
|
+
function probeArgv(baseUrl) {
|
|
1619
|
+
const argv = ['-p', 'hi', '--model', PROBE_MODEL, '--output-format', 'json', '--no-session-persistence']
|
|
1620
|
+
if (baseUrl) argv.push('--settings', JSON.stringify({ env: { ANTHROPIC_BASE_URL: String(baseUrl) } }))
|
|
1621
|
+
return argv
|
|
1452
1622
|
}
|
|
1453
1623
|
|
|
1454
1624
|
/** 探测失败时的可操作文案(区分「没找到 CLI」与「CLI 跑了但服务端没报清单」)。 */
|
|
@@ -1487,7 +1657,7 @@ function probeSupportedModels(env, opts) {
|
|
|
1487
1657
|
}
|
|
1488
1658
|
const exe = probeList[idx++]
|
|
1489
1659
|
const spec = splitExeSpec(exe)
|
|
1490
|
-
const argv = spec.prefixArgs.concat(probeArgv())
|
|
1660
|
+
const argv = spec.prefixArgs.concat(probeArgv(o.baseUrl))
|
|
1491
1661
|
let c
|
|
1492
1662
|
try {
|
|
1493
1663
|
const spawnCmd = spec.shell ? [spec.cmd].concat(argv).map(quoteShellArg).join(' ') : spec.cmd
|
|
@@ -1538,6 +1708,21 @@ function probeSupportedModels(env, opts) {
|
|
|
1538
1708
|
* @param {object} env
|
|
1539
1709
|
* @param {{binPath?: string, timeoutMs?: number, refresh?: boolean}} [opts]
|
|
1540
1710
|
*/
|
|
1711
|
+
/**
|
|
1712
|
+
* 列出可用模型 —— **合并两个来源**,而不是只认服务端报文。
|
|
1713
|
+
*
|
|
1714
|
+
* 为什么必须合并(真实事故):服务端「无效模型触发 400」只回吐**基础模型名**。
|
|
1715
|
+
* 实测 DeepSeek 的 Anthropic 兼容端点报 `deepseek-flash, deepseek-v4-pro`,
|
|
1716
|
+
* 但用户实际配置在用的 `deepseek-v4-pro[1M]` / `deepseek-v4-flash` 逐个实测都是 200 ——
|
|
1717
|
+
* 它们只是带上下文窗口后缀/命名变体,服务端报文不列。若只信报文,用户会看到一份
|
|
1718
|
+
* 「比我配的还少」的清单,等于给错答案。
|
|
1719
|
+
*
|
|
1720
|
+
* 因此:configured(`~/.claude/settings.json` 的 env,cc-switch 等工具写入的**最终生效**
|
|
1721
|
+
* 配置)+ account-live(服务端报文)取并集,逐项标注来源;configured 排在前面。
|
|
1722
|
+
*
|
|
1723
|
+
* 且 configured **不依赖探测成功** —— 端点若根本不支持 400 技巧(如某些代理对任何
|
|
1724
|
+
* 模型都先校验 session),我们仍能把用户配置的模型给出来,而不是返回空。
|
|
1725
|
+
*/
|
|
1541
1726
|
async function listAccountModels(env, opts) {
|
|
1542
1727
|
const o = opts || {}
|
|
1543
1728
|
const key = modelsCacheKey(env, o.binPath)
|
|
@@ -1545,33 +1730,47 @@ async function listAccountModels(env, opts) {
|
|
|
1545
1730
|
const probe = await cachedModels(key, () => probeSupportedModels(env, o))
|
|
1546
1731
|
const cfg = readConfiguredModels(env)
|
|
1547
1732
|
const configured = cfg.models || []
|
|
1548
|
-
|
|
1733
|
+
|
|
1734
|
+
// 大小写不敏感 + 忽略 [1m]/[1M] 这类后缀后做去重键,避免同一模型重复占两行。
|
|
1735
|
+
const dk = (id) => String(id).trim().toLowerCase().replace(/\[[^\]]*\]$/, '')
|
|
1736
|
+
const seen = new Set()
|
|
1737
|
+
const models = []
|
|
1738
|
+
for (const m of configured) {
|
|
1739
|
+
const k = dk(m.id)
|
|
1740
|
+
if (seen.has(k)) continue
|
|
1741
|
+
seen.add(k)
|
|
1742
|
+
models.push({ id: m.id, source: 'configured', configuredAs: m.key })
|
|
1743
|
+
}
|
|
1744
|
+
const probeIds = probe && probe.ok ? (probe.ids || []) : []
|
|
1745
|
+
for (const id of probeIds) {
|
|
1746
|
+
const k = dk(id)
|
|
1747
|
+
if (seen.has(k)) continue
|
|
1748
|
+
seen.add(k)
|
|
1749
|
+
models.push({ id, source: 'account-live', configuredAs: null })
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
if (!models.length) {
|
|
1549
1753
|
return {
|
|
1550
|
-
ok: false,
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
error: String(probe.error || 'probe failed'),
|
|
1555
|
-
diagnostic: {
|
|
1556
|
-
configured: configured,
|
|
1557
|
-
settingsPath: cfg.path,
|
|
1558
|
-
note: 'ANTHROPIC_*_MODEL 只是用户配置的别名映射,不是服务端可用全集;因此不作为 models 返回。'
|
|
1559
|
-
}
|
|
1754
|
+
ok: false, source: 'none', models: [], count: 0,
|
|
1755
|
+
error: String((probe && probe.error) || 'no configured models and probe returned nothing') +
|
|
1756
|
+
(cfg.error ? ';settings.json: ' + cfg.error : ''),
|
|
1757
|
+
diagnostic: { configured, settingsPath: cfg.path, probeError: (probe && probe.error) || null }
|
|
1560
1758
|
}
|
|
1561
1759
|
}
|
|
1562
|
-
|
|
1563
|
-
const
|
|
1564
|
-
|
|
1760
|
+
// source 汇总:两个来源都有效时标注合并,便于面板/工具如实显示。
|
|
1761
|
+
const source = (configured.length && probeIds.length) ? 'configured+account-live'
|
|
1762
|
+
: (configured.length ? 'configured' : 'account-live')
|
|
1565
1763
|
return {
|
|
1566
1764
|
ok: true,
|
|
1567
|
-
source
|
|
1568
|
-
models
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1765
|
+
source,
|
|
1766
|
+
models,
|
|
1767
|
+
count: models.length,
|
|
1768
|
+
ext: { exe: (probe && probe.exe) || null, mode: (probe && probe.mode) || null },
|
|
1769
|
+
diagnostic: {
|
|
1770
|
+
settingsPath: cfg.path,
|
|
1771
|
+
configured,
|
|
1772
|
+
probeError: (probe && !probe.ok) ? String(probe.error || 'probe failed') : null
|
|
1773
|
+
}
|
|
1575
1774
|
}
|
|
1576
1775
|
}
|
|
1577
1776
|
|
package/dynamic/codex.host.js
CHANGED
|
@@ -714,9 +714,15 @@ function defineProvider (deps = {}) {
|
|
|
714
714
|
if (resume) argv.push('-c', 'sandbox_mode="' + sandbox + '"')
|
|
715
715
|
else argv.push('-s', sandbox)
|
|
716
716
|
|
|
717
|
-
|
|
717
|
+
// 审批策略:非交互(-p/exec 无 TTY)场景**必须**显式给 never,否则 codex 会按自身
|
|
718
|
+
// 配置(或内置默认 on-request)请求批准 —— 没人能点,进程一路挂到超时。
|
|
719
|
+
// 此前这里默认留空、完全依赖用户 ~/.codex/config.toml(本机恰好写了 never 才没炸),
|
|
720
|
+
// 属于隐式依赖:换台机器或用户改配置就会静默变成"每次都等批准"。
|
|
721
|
+
// 现规则:用户显式配了就用;没配则 accept-edits/bypass 语义下强制 never。
|
|
722
|
+
const configuredApproval = (settings && settings.approvalPolicy && APPROVAL_POLICIES.indexOf(String(settings.approvalPolicy)) >= 0)
|
|
718
723
|
? String(settings.approvalPolicy)
|
|
719
724
|
: ''
|
|
725
|
+
const approval = configuredApproval || (mode === 'plan' ? '' : 'never')
|
|
720
726
|
// exec 没有 -a/--ask-for-approval;等价效果只能经 -c 覆盖(TOML 字符串值)。
|
|
721
727
|
if (approval) argv.push('-c', 'approval_policy="' + approval + '"')
|
|
722
728
|
|
package/mcp/cli-bridge-mcp.mjs
CHANGED
|
@@ -33,7 +33,7 @@ import * as claudeMcp from './providers/claude.mjs'
|
|
|
33
33
|
import * as codexMcp from './providers/codex.mjs'
|
|
34
34
|
|
|
35
35
|
const SERVER_NAME = 'cli-bridge-mcp'
|
|
36
|
-
const SERVER_VERSION = '0.2.
|
|
36
|
+
const SERVER_VERSION = '0.2.12'
|
|
37
37
|
const PROTOCOL = '2024-11-05'
|
|
38
38
|
|
|
39
39
|
const PROVIDERS = [agyMcp, codebuddyMcp, mimoMcp, opencodeMcp, claudeMcp, codexMcp]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luchenglong/cli-bridge",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
4
4
|
"description": "CLI Bridge for DeepSeek Harness (DSH): one package bridging six local coding-CLI agents — agy (Google Antigravity CLI), Tencent CodeBuddy Code, Xiaomi MiMo (MiMoCode), OpenCode, Claude Code, and OpenAI Codex CLI. Each provider keeps its own agent preset (cordis-agy / codebuddy-first / cordis-mimo / opencode-first / claude-first / codex-first), tools (<id>_run / <id>_continue / <id>_status, plus per-provider model listing), rate-limit/network fallback dialog, and zero-dependency MCP server, unified under a single home-level status light, one settings file, and one bundle patch. Merges agy-first-bridge, codebuddy-first-bridge and mimo-first-bridge.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// providers/claude-endpoint.mjs — claude 的端点解析(第三片)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要这一层(真实事故,2026-10-20):
|
|
4
|
+
// 用户用 cc-switch 管理 Claude 配置,其「proxy 模式」把 Claude 指向第三方网关
|
|
5
|
+
// (例:ANTHROPIC_BASE_URL=https://opencode.ai/zen/go)。该网关要求自家 session 头
|
|
6
|
+
// (`x-opencode-session` / MissingSessionID),claude CLI 不会发 → **直连必然 400**。
|
|
7
|
+
// 能用是因为 cc-switch 在本机起了一个转发代理,由它补头/认证:
|
|
8
|
+
// 实测 POST http://127.0.0.1:15721/v1/messages → 200;同一配置直连 → 400 MissingSessionID。
|
|
9
|
+
// 所以「把 base_url 换成 cc-switch 代理」不是降级,而是这套配置**唯一可行**的访问方式。
|
|
10
|
+
//
|
|
11
|
+
// 注入方式:`--settings <json>` 覆盖 env.ANTHROPIC_BASE_URL,**不改用户的
|
|
12
|
+
// ~/.claude/settings.json**(那是 cc-switch 的领地,改它会在下次切换时被覆盖,也可能
|
|
13
|
+
// 破坏用户的 GUI 会话)。
|
|
14
|
+
//
|
|
15
|
+
// 探活采用「起一次 node 子进程做 HTTP GET」:buildArgv/meta 都是同步回调,Node 无同步
|
|
16
|
+
// HTTP。结果按 URL 缓存 30s,避免每次调用都起进程。探不到就如实回落直连(并在诊断里
|
|
17
|
+
// 说明"代理未响应"),绝不再假装已接管。
|
|
18
|
+
|
|
19
|
+
/** 默认的 cc-switch 本地代理地址(其 proxy_config.listen_port 实测为 15721)。 */
|
|
20
|
+
export const DEFAULT_PROXY_URL = 'http://127.0.0.1:15721'
|
|
21
|
+
|
|
22
|
+
/** 端点模式:auto = 代理活着就用它;proxy = 强制;direct = 强制直连。 */
|
|
23
|
+
export const ENDPOINT_MODES = ['auto', 'proxy', 'direct']
|
|
24
|
+
|
|
25
|
+
const PROXY_TTL_MS = 30 * 1000
|
|
26
|
+
let proxyCache = { url: '', at: 0, alive: false, detail: '' }
|
|
27
|
+
|
|
28
|
+
/** 允许测试注入探测结果,避免每个用例都起子进程。 */
|
|
29
|
+
export function resetProxyCache() { proxyCache = { url: '', at: 0, alive: false, detail: '' } }
|
|
30
|
+
|
|
31
|
+
function builtin(name) {
|
|
32
|
+
const p = globalThis.process
|
|
33
|
+
try { return (p && typeof p.getBuiltinModule === 'function') ? p.getBuiltinModule(name) : null } catch (e) { return null }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 探活子脚本:对 url 发 GET,任何 HTTP 响应都算活着(404 也算 —— cc-switch 根路径就是 404)。 */
|
|
37
|
+
const PROBE_SCRIPT = [
|
|
38
|
+
"const http=require('http'),https=require('https');",
|
|
39
|
+
"const u=process.argv[1];",
|
|
40
|
+
"const m=/^https:/i.test(u)?https:http;",
|
|
41
|
+
"const r=m.get(u,{timeout:1500},(res)=>{res.resume();console.log('ALIVE '+res.statusCode);process.exit(0)});",
|
|
42
|
+
"r.on('error',(e)=>{console.log('DEAD '+(e.code||e.message));process.exit(0)});",
|
|
43
|
+
"r.on('timeout',()=>{console.log('DEAD timeout');try{r.destroy()}catch(e){};process.exit(0)});"
|
|
44
|
+
].join('')
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 同步探活(带 30s 缓存)。返回 { alive, detail }。
|
|
48
|
+
* 起 `node -e` 子进程是唯一能在同步回调里做真实 HTTP 的办法;代价约 100-250ms,
|
|
49
|
+
* 且被 URL 维度缓存 —— 只在「首次」或「缓存过期」时付。
|
|
50
|
+
*/
|
|
51
|
+
export function detectProxySync(url, nowMs) {
|
|
52
|
+
const target = String(url || '').trim()
|
|
53
|
+
if (!target) return { alive: false, detail: 'no url' }
|
|
54
|
+
const at = Number(nowMs) || Date.now()
|
|
55
|
+
if (proxyCache.url === target && at - proxyCache.at < PROXY_TTL_MS) {
|
|
56
|
+
return { alive: proxyCache.alive, detail: proxyCache.detail }
|
|
57
|
+
}
|
|
58
|
+
const child = builtin('node:child_process')
|
|
59
|
+
const proc = globalThis.process
|
|
60
|
+
let alive = false
|
|
61
|
+
let detail = 'no child_process'
|
|
62
|
+
if (child && typeof child.spawnSync === 'function' && proc && proc.execPath) {
|
|
63
|
+
try {
|
|
64
|
+
const r = child.spawnSync(proc.execPath, ['-e', PROBE_SCRIPT, target], {
|
|
65
|
+
encoding: 'utf8', timeout: 4000, windowsHide: true,
|
|
66
|
+
env: Object.assign({}, proc.env)
|
|
67
|
+
})
|
|
68
|
+
const out = String((r && r.stdout) || '')
|
|
69
|
+
alive = /^ALIVE\b/m.test(out)
|
|
70
|
+
detail = out.trim().split(/\r?\n/).pop() || (r && r.error && (r.error.code || r.error.message)) || 'unknown'
|
|
71
|
+
} catch (e) { detail = 'probe threw: ' + String(e && e.message || e) }
|
|
72
|
+
}
|
|
73
|
+
proxyCache = { url: target, at, alive, detail }
|
|
74
|
+
return { alive, detail }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 读 ~/.claude/settings.json 里配置的 ANTHROPIC_BASE_URL(只读,不写)。 */
|
|
78
|
+
export function readConfiguredBaseUrl(env, configDir) {
|
|
79
|
+
const fs = builtin('node:fs')
|
|
80
|
+
const path = builtin('node:path')
|
|
81
|
+
if (!fs || !path) return ''
|
|
82
|
+
const dir = configDir || ((env && (env.CLAUDE_CONFIG_DIR || env.CLAUDE_HOME)) || '')
|
|
83
|
+
if (!dir) return ''
|
|
84
|
+
try {
|
|
85
|
+
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf8'))
|
|
86
|
+
const block = raw && raw.env && typeof raw.env === 'object' ? raw.env : {}
|
|
87
|
+
return typeof block.ANTHROPIC_BASE_URL === 'string' ? block.ANTHROPIC_BASE_URL.trim() : ''
|
|
88
|
+
} catch (e) { return '' }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 解析最终端点。
|
|
93
|
+
* @returns {{ mode: 'proxy'|'direct', baseUrl: string, proxyUrl: string, proxyAlive: boolean,
|
|
94
|
+
* configuredBaseUrl: string, note: string, error?: string }}
|
|
95
|
+
*/
|
|
96
|
+
export function resolveEndpoint(settings, env, opts) {
|
|
97
|
+
const o = opts || {}
|
|
98
|
+
const s = settings && typeof settings === 'object' ? settings : {}
|
|
99
|
+
const mode = ENDPOINT_MODES.indexOf(String(s.endpointMode || '')) >= 0 ? String(s.endpointMode) : 'auto'
|
|
100
|
+
const proxyUrl = String(s.proxyUrl || DEFAULT_PROXY_URL).trim() || DEFAULT_PROXY_URL
|
|
101
|
+
const configuredBaseUrl = readConfiguredBaseUrl(env, o.configDir)
|
|
102
|
+
|
|
103
|
+
if (mode === 'direct') {
|
|
104
|
+
return { mode: 'direct', baseUrl: configuredBaseUrl, proxyUrl, proxyAlive: false, configuredBaseUrl, note: '强制直连(endpointMode=direct)' }
|
|
105
|
+
}
|
|
106
|
+
const det = o.probe ? o.probe(proxyUrl) : detectProxySync(proxyUrl)
|
|
107
|
+
if (det.alive) {
|
|
108
|
+
return {
|
|
109
|
+
mode: 'proxy', baseUrl: proxyUrl, proxyUrl, proxyAlive: true, configuredBaseUrl,
|
|
110
|
+
note: '经 cc-switch 本地代理 ' + proxyUrl + '(配置的端点 ' + (configuredBaseUrl || '(未读到)') + ' 需要代理补头)'
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (mode === 'proxy') {
|
|
114
|
+
return {
|
|
115
|
+
mode: 'proxy', baseUrl: proxyUrl, proxyUrl, proxyAlive: false, configuredBaseUrl,
|
|
116
|
+
note: '强制走代理但探测失败:' + det.detail,
|
|
117
|
+
error: 'cc-switch 本地代理 ' + proxyUrl + ' 无响应(' + det.detail + ')。请启动 cc-switch(含其本地代理),' +
|
|
118
|
+
'或把「端点模式」改为 direct 直连,或在「代理地址」里填实际端口。'
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
mode: 'direct', baseUrl: configuredBaseUrl, proxyUrl, proxyAlive: false, configuredBaseUrl,
|
|
123
|
+
note: '直连配置的端点 ' + (configuredBaseUrl || '(未读到)') + '(本地代理 ' + proxyUrl + ' 未响应:' + det.detail + ')。' +
|
|
124
|
+
'若该端点来自 cc-switch 的 proxy 模式,请启动 cc-switch —— 否则会被网关以缺 session 头拒绝。'
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 把端点覆盖编成 claude 的 `--settings` JSON(只覆盖 base_url,不动其它键)。 */
|
|
129
|
+
export function overlaySettingsJson(baseUrl) {
|
|
130
|
+
if (!baseUrl) return ''
|
|
131
|
+
return JSON.stringify({ env: { ANTHROPIC_BASE_URL: String(baseUrl) } })
|
|
132
|
+
}
|
|
@@ -209,8 +209,15 @@ export function readConfiguredModels(env) {
|
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
/** 探测 argv(不含 exe 前缀):固定参数集,绝不拼装用户 prompt。 */
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
/**
|
|
213
|
+
* 探测用的 argv。`baseUrl` 非空时用 `--settings` 覆盖 ANTHROPIC_BASE_URL ——
|
|
214
|
+
* 这是 cc-switch proxy 模式唯一可行的访问方式(直连第三方网关缺 session 头必 400)。
|
|
215
|
+
* 只覆盖 base_url,不碰用户的 ~/.claude/settings.json。
|
|
216
|
+
*/
|
|
217
|
+
export function probeArgv(baseUrl) {
|
|
218
|
+
const argv = ['-p', 'hi', '--model', PROBE_MODEL, '--output-format', 'json', '--no-session-persistence']
|
|
219
|
+
if (baseUrl) argv.push('--settings', JSON.stringify({ env: { ANTHROPIC_BASE_URL: String(baseUrl) } }))
|
|
220
|
+
return argv
|
|
214
221
|
}
|
|
215
222
|
|
|
216
223
|
/** 探测失败时的可操作文案(区分「没找到 CLI」与「CLI 跑了但服务端没报清单」)。 */
|
|
@@ -249,7 +256,7 @@ export function probeSupportedModels(env, opts) {
|
|
|
249
256
|
}
|
|
250
257
|
const exe = probeList[idx++]
|
|
251
258
|
const spec = splitExeSpec(exe)
|
|
252
|
-
const argv = spec.prefixArgs.concat(probeArgv())
|
|
259
|
+
const argv = spec.prefixArgs.concat(probeArgv(o.baseUrl))
|
|
253
260
|
let c
|
|
254
261
|
try {
|
|
255
262
|
const spawnCmd = spec.shell ? [spec.cmd].concat(argv).map(quoteShellArg).join(' ') : spec.cmd
|
|
@@ -300,6 +307,21 @@ export function probeSupportedModels(env, opts) {
|
|
|
300
307
|
* @param {object} env
|
|
301
308
|
* @param {{binPath?: string, timeoutMs?: number, refresh?: boolean}} [opts]
|
|
302
309
|
*/
|
|
310
|
+
/**
|
|
311
|
+
* 列出可用模型 —— **合并两个来源**,而不是只认服务端报文。
|
|
312
|
+
*
|
|
313
|
+
* 为什么必须合并(真实事故):服务端「无效模型触发 400」只回吐**基础模型名**。
|
|
314
|
+
* 实测 DeepSeek 的 Anthropic 兼容端点报 `deepseek-flash, deepseek-v4-pro`,
|
|
315
|
+
* 但用户实际配置在用的 `deepseek-v4-pro[1M]` / `deepseek-v4-flash` 逐个实测都是 200 ——
|
|
316
|
+
* 它们只是带上下文窗口后缀/命名变体,服务端报文不列。若只信报文,用户会看到一份
|
|
317
|
+
* 「比我配的还少」的清单,等于给错答案。
|
|
318
|
+
*
|
|
319
|
+
* 因此:configured(`~/.claude/settings.json` 的 env,cc-switch 等工具写入的**最终生效**
|
|
320
|
+
* 配置)+ account-live(服务端报文)取并集,逐项标注来源;configured 排在前面。
|
|
321
|
+
*
|
|
322
|
+
* 且 configured **不依赖探测成功** —— 端点若根本不支持 400 技巧(如某些代理对任何
|
|
323
|
+
* 模型都先校验 session),我们仍能把用户配置的模型给出来,而不是返回空。
|
|
324
|
+
*/
|
|
303
325
|
export async function listAccountModels(env, opts) {
|
|
304
326
|
const o = opts || {}
|
|
305
327
|
const key = modelsCacheKey(env, o.binPath)
|
|
@@ -307,32 +329,46 @@ export async function listAccountModels(env, opts) {
|
|
|
307
329
|
const probe = await cachedModels(key, () => probeSupportedModels(env, o))
|
|
308
330
|
const cfg = readConfiguredModels(env)
|
|
309
331
|
const configured = cfg.models || []
|
|
310
|
-
|
|
332
|
+
|
|
333
|
+
// 大小写不敏感 + 忽略 [1m]/[1M] 这类后缀后做去重键,避免同一模型重复占两行。
|
|
334
|
+
const dk = (id) => String(id).trim().toLowerCase().replace(/\[[^\]]*\]$/, '')
|
|
335
|
+
const seen = new Set()
|
|
336
|
+
const models = []
|
|
337
|
+
for (const m of configured) {
|
|
338
|
+
const k = dk(m.id)
|
|
339
|
+
if (seen.has(k)) continue
|
|
340
|
+
seen.add(k)
|
|
341
|
+
models.push({ id: m.id, source: 'configured', configuredAs: m.key })
|
|
342
|
+
}
|
|
343
|
+
const probeIds = probe && probe.ok ? (probe.ids || []) : []
|
|
344
|
+
for (const id of probeIds) {
|
|
345
|
+
const k = dk(id)
|
|
346
|
+
if (seen.has(k)) continue
|
|
347
|
+
seen.add(k)
|
|
348
|
+
models.push({ id, source: 'account-live', configuredAs: null })
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!models.length) {
|
|
311
352
|
return {
|
|
312
|
-
ok: false,
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
error: String(probe.error || 'probe failed'),
|
|
317
|
-
diagnostic: {
|
|
318
|
-
configured: configured,
|
|
319
|
-
settingsPath: cfg.path,
|
|
320
|
-
note: 'ANTHROPIC_*_MODEL 只是用户配置的别名映射,不是服务端可用全集;因此不作为 models 返回。'
|
|
321
|
-
}
|
|
353
|
+
ok: false, source: 'none', models: [], count: 0,
|
|
354
|
+
error: String((probe && probe.error) || 'no configured models and probe returned nothing') +
|
|
355
|
+
(cfg.error ? ';settings.json: ' + cfg.error : ''),
|
|
356
|
+
diagnostic: { configured, settingsPath: cfg.path, probeError: (probe && probe.error) || null }
|
|
322
357
|
}
|
|
323
358
|
}
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
359
|
+
// source 汇总:两个来源都有效时标注合并,便于面板/工具如实显示。
|
|
360
|
+
const source = (configured.length && probeIds.length) ? 'configured+account-live'
|
|
361
|
+
: (configured.length ? 'configured' : 'account-live')
|
|
327
362
|
return {
|
|
328
363
|
ok: true,
|
|
329
|
-
source
|
|
330
|
-
models
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
364
|
+
source,
|
|
365
|
+
models,
|
|
366
|
+
count: models.length,
|
|
367
|
+
ext: { exe: (probe && probe.exe) || null, mode: (probe && probe.mode) || null },
|
|
368
|
+
diagnostic: {
|
|
369
|
+
settingsPath: cfg.path,
|
|
370
|
+
configured,
|
|
371
|
+
probeError: (probe && !probe.ok) ? String(probe.error || 'probe failed') : null
|
|
372
|
+
}
|
|
337
373
|
}
|
|
338
374
|
}
|
package/providers/claude.mjs
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
// - 模型清单只认服务端权威探测;拿不到 = ok:false + 可操作错误(绝不静默空数组/配置冒充)。
|
|
22
22
|
import { createStatusEngine, createRunner, clampInt, isLimited } from '../core/bridge-kit.mjs'
|
|
23
23
|
import { claudeExeCandidates, claudePathHint, resolveExePrefix, splitExeSpec, CLAUDE_BIN_ENV_VARS } from './claude-exe.mjs'
|
|
24
|
-
import { PROBE_MODEL, parseSupportedModels, probeSupportedModels, readCachedModels, readConfiguredModels, listAccountModels, resetModelsCache } from './claude-models.mjs'
|
|
24
|
+
import { PROBE_MODEL, parseSupportedModels, probeSupportedModels, readCachedModels, readConfiguredModels, listAccountModels, resetModelsCache, claudeConfigDir } from './claude-models.mjs'
|
|
25
|
+
import { ENDPOINT_MODES, DEFAULT_PROXY_URL, resolveEndpoint, overlaySettingsJson, readConfiguredBaseUrl } from './claude-endpoint.mjs'
|
|
25
26
|
// 单行 re-export(build 内联时整行剥离;多行形态风险见 build.mjs 的 stripModuleSyntax 注释)。
|
|
26
27
|
export { claudeExeCandidates, claudePathHint, resolveExePrefix, splitExeSpec, PROBE_MODEL, parseSupportedModels, probeSupportedModels, readCachedModels, readConfiguredModels, listAccountModels, resetModelsCache }
|
|
27
28
|
|
|
@@ -192,6 +193,12 @@ export default function defineProvider(deps = {}) {
|
|
|
192
193
|
else argv.push('--output-format', 'json')
|
|
193
194
|
if (stream && args.includePartialMessages) argv.push('--include-partial-messages')
|
|
194
195
|
const timeoutSec = clampInt(args.timeoutSec, 600, 10, 3600)
|
|
196
|
+
// 端点:cc-switch 的 proxy 模式把 Claude 指向第三方网关,而该网关要求自家 session 头
|
|
197
|
+
// (claude CLI 不会发)——**直连必然 400**,必须经 cc-switch 本地代理转发。
|
|
198
|
+
// 这里用 --settings 只覆盖 ANTHROPIC_BASE_URL,绝不改写用户的 ~/.claude/settings.json。
|
|
199
|
+
const ep = resolveEndpoint(currentSettings(), deps.env || {}, { probe: o.probeProxy, configDir: claudeConfigDir(deps.env || {}) })
|
|
200
|
+
if (ep.error) { const e = new Error(ep.error); e.code = 'ENDPOINT_UNAVAILABLE'; throw e }
|
|
201
|
+
if (ep.mode === 'proxy' && ep.baseUrl) argv.push('--settings', overlaySettingsJson(ep.baseUrl))
|
|
195
202
|
|
|
196
203
|
// 权限:auto → 跟随 DSH plan 状态;plan 用 CLI 的 plan 模式,其余走 bypass
|
|
197
204
|
//(-p 没有 TTY,任何交互批准都会挂到超时 —— 与 agy/codebuddy/opencode 同语义)。
|
|
@@ -429,6 +436,8 @@ export default function defineProvider(deps = {}) {
|
|
|
429
436
|
const cached = readCachedModels(e, { binPath: s.binPath })
|
|
430
437
|
const options = (cached ? cached.ids : []).map((id) => ({ value: id, label: id }))
|
|
431
438
|
const exeCandidates = claudeExeCandidates(e, '')
|
|
439
|
+
// 端点实际走向(proxy / direct)——面板上如实显示,用户不用猜为什么请求失败。
|
|
440
|
+
const epInfo = resolveEndpoint(s, e, { configDir: claudeConfigDir(e) })
|
|
432
441
|
return {
|
|
433
442
|
titleSuffix: 'Claude Code',
|
|
434
443
|
fields: [
|
|
@@ -452,6 +461,20 @@ export default function defineProvider(deps = {}) {
|
|
|
452
461
|
key: 'maxBudgetUsd', label: '单次最大花费(美元)', type: 'text',
|
|
453
462
|
placeholder: '留空 = 不限制',
|
|
454
463
|
note: '非空时自动附加 --max-budget-usd;调用参数 maxBudgetUsd 可覆盖。'
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
key: 'endpointMode', label: '端点模式', type: 'select',
|
|
467
|
+
options: [
|
|
468
|
+
{ value: 'auto', label: 'auto —— 代理活着就走代理,否则直连' },
|
|
469
|
+
{ value: 'proxy', label: 'proxy —— 强制走本地代理' },
|
|
470
|
+
{ value: 'direct', label: 'direct —— 强制直连配置的端点' }
|
|
471
|
+
],
|
|
472
|
+
note: epInfo.note + '。cc-switch 的 proxy 模式把 Claude 指向第三方网关,该网关要求自家 session 头而 claude CLI 不会发:直连必然 400,必须经 cc-switch 本地代理转发。'
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
key: 'proxyUrl', label: '本地代理地址', type: 'text',
|
|
476
|
+
placeholder: DEFAULT_PROXY_URL,
|
|
477
|
+
note: 'cc-switch 的本地代理默认监听 127.0.0.1:15721。若你改过端口,填这里。桥接用 --settings 只覆盖 ANTHROPIC_BASE_URL,**不会改写** ~/.claude/settings.json。'
|
|
455
478
|
}
|
|
456
479
|
]
|
|
457
480
|
}
|
|
@@ -469,24 +492,30 @@ export default function defineProvider(deps = {}) {
|
|
|
469
492
|
async refreshModels(settings, env2) {
|
|
470
493
|
const e = (env2 && typeof env2 === 'object') ? env2 : env
|
|
471
494
|
const s = descriptor.settings.normalize(settings)
|
|
495
|
+
// 探测必须走**同一个端点**:cc-switch proxy 模式下直连第三方网关会 400,那样探测
|
|
496
|
+
// 永远拿不到东西(但配置清单仍应给出,见 listAccountModels 的合并逻辑)。
|
|
497
|
+
const ep = resolveEndpoint(s, e, { configDir: claudeConfigDir(e) })
|
|
472
498
|
const got = await listAccountModels(e, {
|
|
473
499
|
binPath: s.binPath,
|
|
500
|
+
baseUrl: ep.mode === 'proxy' ? ep.baseUrl : '',
|
|
474
501
|
timeoutMs: REFRESH_TIMEOUT_MS,
|
|
475
502
|
refresh: true
|
|
476
503
|
})
|
|
477
504
|
if (!got.ok) {
|
|
478
|
-
return { ok: false, ids: [], source: '
|
|
505
|
+
return { ok: false, ids: [], source: got.source || 'none', error: String(got.error || 'probe failed') + ' ' + REFRESH_HINT + ' 端点:' + ep.note }
|
|
479
506
|
}
|
|
480
507
|
const ids = (got.models || []).map((m) => m.id).filter(Boolean)
|
|
481
508
|
if (!ids.length) {
|
|
482
|
-
|
|
483
|
-
// 必须显式失败,绝不能让面板显示一个空清单当成功。
|
|
484
|
-
return { ok: false, ids: [], source: 'account-live', error: '探测声称成功但未返回任何模型 id(内部不一致)。' + REFRESH_HINT }
|
|
509
|
+
return { ok: false, ids: [], source: got.source || 'none', error: '探测声称成功但未返回任何模型 id(内部不一致)。' + REFRESH_HINT }
|
|
485
510
|
}
|
|
486
|
-
// labels
|
|
511
|
+
// labels 标出「来自配置」还是「服务端报文补充」,面板可据此区分来源。
|
|
487
512
|
const labels = {}
|
|
488
|
-
for (const m of got.models)
|
|
489
|
-
|
|
513
|
+
for (const m of got.models) {
|
|
514
|
+
labels[m.id] = m.id + (m.source === 'configured'
|
|
515
|
+
? ' [' + String(m.configuredAs || 'settings.json') + ']'
|
|
516
|
+
: ' [服务端清单]')
|
|
517
|
+
}
|
|
518
|
+
return { ok: true, ids, labels, source: got.source || 'configured', endpoint: ep.note }
|
|
490
519
|
},
|
|
491
520
|
diagnose: (settings, env2) => {
|
|
492
521
|
const s = settings && typeof settings === 'object' ? settings : {}
|
package/providers/codex.mjs
CHANGED
|
@@ -154,9 +154,15 @@ export default function defineProvider(deps = {}) {
|
|
|
154
154
|
if (resume) argv.push('-c', 'sandbox_mode="' + sandbox + '"')
|
|
155
155
|
else argv.push('-s', sandbox)
|
|
156
156
|
|
|
157
|
-
|
|
157
|
+
// 审批策略:非交互(-p/exec 无 TTY)场景**必须**显式给 never,否则 codex 会按自身
|
|
158
|
+
// 配置(或内置默认 on-request)请求批准 —— 没人能点,进程一路挂到超时。
|
|
159
|
+
// 此前这里默认留空、完全依赖用户 ~/.codex/config.toml(本机恰好写了 never 才没炸),
|
|
160
|
+
// 属于隐式依赖:换台机器或用户改配置就会静默变成"每次都等批准"。
|
|
161
|
+
// 现规则:用户显式配了就用;没配则 accept-edits/bypass 语义下强制 never。
|
|
162
|
+
const configuredApproval = (settings && settings.approvalPolicy && APPROVAL_POLICIES.indexOf(String(settings.approvalPolicy)) >= 0)
|
|
158
163
|
? String(settings.approvalPolicy)
|
|
159
164
|
: ''
|
|
165
|
+
const approval = configuredApproval || (mode === 'plan' ? '' : 'never')
|
|
160
166
|
// exec 没有 -a/--ask-for-approval;等价效果只能经 -c 覆盖(TOML 字符串值)。
|
|
161
167
|
if (approval) argv.push('-c', 'approval_policy="' + approval + '"')
|
|
162
168
|
|