@luchenglong/cli-bridge 0.2.10 → 0.2.11

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 CHANGED
@@ -9,6 +9,37 @@ 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.11] - 2026-10-20
13
+
14
+ > 用户反馈:「claude 我是用 cc-switch 改的配置,你现在获取的模型不对」。查证后不是列表写错,
15
+ > 而是**整条链路**有两处判断错误 —— 本版修复。
16
+
17
+ ### Fixed
18
+ - **claude 直连 cc-switch 的 proxy 端点必失败**(这是根本原因)。cc-switch 的 **proxy 模式**
19
+ 把 Claude 指向第三方网关(本机实测 `https://opencode.ai/zen/go`),该网关要求自家 session 头
20
+ (缺则 `400 MissingSessionID`),而 claude CLI 不会发这个头。同一配置实测对照:
21
+ - 直连 → `exit=1, API Error: 400 MissingSessionID`
22
+ - 改指 cc-switch 本地代理 `http://127.0.0.1:15721` → `exit=0, "OK"`
23
+ 现在桥接用 `--settings '{"env":{"ANTHROPIC_BASE_URL":"<proxy>"}}'` **只覆盖 base_url**,
24
+ **绝不改写** `~/.claude/settings.json`(那是 cc-switch 的领地,改它下次切换即被打回)。
25
+ 新增设置项:`endpointMode`(`auto` 探活代理 / `proxy` 强制,探不到**明确报错** / `direct`
26
+ 强制直连)、`proxyUrl`(默认 `127.0.0.1:15721`)。面板会显示当前实际走法。
27
+ - **模型清单漏掉了用户实际在用的模型**。原先只认「无效模型触发的服务端 400 报文」,而实测:
28
+ - 服务端只回**基础名** `deepseek-flash, deepseek-v4-pro`;
29
+ - 用户配置的 `deepseek-v4-pro[1M]` / `deepseek-v4-flash` 逐个实测**都 200 可用**;
30
+ - 且走本地代理时网关对任何模型名都放行(连探测用的假模型名都回显),**永远不产生 400 报文**
31
+ —— 只靠探测时这份清单等于空。
32
+ 现在改为**配置来源 ∪ 服务端报文**,各带 `source` 标注(`configured` 在前、`account-live` 补充),
33
+ 面板标签会写明「来自哪个 ANTHROPIC_*_MODEL」或「[服务端清单]」。
34
+ **红线不变**:来源必须如实标注,配置来源绝不冒充服务端权威。
35
+
36
+ ### Changed
37
+ - `providers/claude-endpoint.mjs`(新片):端点解析与代理探活(`node -e` 做 HTTP GET,
38
+ 结果按 URL 缓存 30s,因为 buildArgv/meta 都是同步回调,Node 无同步 HTTP)。
39
+ - 测试改为**密闭**:claude 的模型测试与 MCP 测试此前会读本机真实 `~/.claude/settings.json`
40
+ (实测期望 2 个却出现 5 个),现统一注入 `CLAUDE_CONFIG_DIR` 指向临时目录;
41
+ buildArgv 断言注入 `probeProxy`,与本机代理是否在跑解耦。
42
+
12
43
  ## [0.2.10] - 2026-10-20
13
44
 
14
45
  > ⚠️ **0.2.9 的 dynamic 沙箱形态在 opencode / mimo 两家是坏的**,本版修复。若你在用这两家的
@@ -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
- - 模型清单:**唯一权威来源 = 无效模型触发的服务端 400 报文**
291
- `"The supported API model names are X, Y, ..."`。`~/.claude/settings.json` 的 env 块里
292
- `ANTHROPIC_DEFAULT_*_MODEL` / `ANTHROPIC_MODEL` 是**用户配置的别名映射,不是服务端可用
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` 出事件流,不加就是纯文本。
@@ -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: 'account-live', error: String(got.error || 'probe failed') + ' ' + REFRESH_HINT }
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
- // 走到这里说明探测自称成功却没给出任何 id —— 那是 bug,不是「无模型」,
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 只给「配置里还映射着某个 ANTHROPIC_*_MODEL」的项加注(纯展示,value 仍是 id)。
1074
+ // labels 标出「来自配置」还是「服务端报文补充」,面板可据此区分来源。
1050
1075
  const labels = {}
1051
- for (const m of got.models) if (m.configuredAs) labels[m.id] = m.id + ' [' + m.configuredAs + ']'
1052
- return { ok: true, ids, labels, source: 'account-live' }
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
- function probeArgv() {
1451
- return ['-p', 'hi', '--model', PROBE_MODEL, '--output-format', 'json', '--no-session-persistence']
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
- if (!probe.ok) {
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
- source: 'account-live',
1552
- models: [],
1553
- count: 0,
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
- const ids = probe.ids.slice()
1563
- const byId = new Map(configured.map((m) => [m.id, m]))
1564
- const byIdLoose = new Map(configured.map((m) => [String(m.id).toLowerCase().replace(/\[.*\]$/, ''), m]))
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: 'account-live',
1568
- models: ids.map((id) => {
1569
- const hit = byId.get(id) || byIdLoose.get(String(id).toLowerCase())
1570
- return { id, source: 'account-live', configuredAs: hit ? hit.key : null }
1571
- }),
1572
- count: ids.length,
1573
- ext: { exe: probe.exe || null, mode: probe.mode },
1574
- diagnostic: { settingsPath: cfg.path, configured: configured }
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
 
@@ -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.10'
36
+ const SERVER_VERSION = '0.2.11'
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.10",
3
+ "version": "0.2.11",
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
- export function probeArgv() {
213
- return ['-p', 'hi', '--model', PROBE_MODEL, '--output-format', 'json', '--no-session-persistence']
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
- if (!probe.ok) {
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
- source: 'account-live',
314
- models: [],
315
- count: 0,
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
- const ids = probe.ids.slice()
325
- const byId = new Map(configured.map((m) => [m.id, m]))
326
- const byIdLoose = new Map(configured.map((m) => [String(m.id).toLowerCase().replace(/\[.*\]$/, ''), m]))
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: 'account-live',
330
- models: ids.map((id) => {
331
- const hit = byId.get(id) || byIdLoose.get(String(id).toLowerCase())
332
- return { id, source: 'account-live', configuredAs: hit ? hit.key : null }
333
- }),
334
- count: ids.length,
335
- ext: { exe: probe.exe || null, mode: probe.mode },
336
- diagnostic: { settingsPath: cfg.path, configured: configured }
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
  }
@@ -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: 'account-live', error: String(got.error || 'probe failed') + ' ' + REFRESH_HINT }
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
- // 走到这里说明探测自称成功却没给出任何 id —— 那是 bug,不是「无模型」,
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 只给「配置里还映射着某个 ANTHROPIC_*_MODEL」的项加注(纯展示,value 仍是 id)。
511
+ // labels 标出「来自配置」还是「服务端报文补充」,面板可据此区分来源。
487
512
  const labels = {}
488
- for (const m of got.models) if (m.configuredAs) labels[m.id] = m.id + ' [' + m.configuredAs + ']'
489
- return { ok: true, ids, labels, source: 'account-live' }
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 : {}