@luchenglong/cli-bridge 0.2.9 → 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.
@@ -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, probeSupportedModelsSyncCached, 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() }
@@ -593,6 +594,19 @@ function nowIso() { return new Date().toISOString() }
593
594
  // 权限模式的用户可见取值(auto 由 DSH plan 状态决定;其余直通 CLI)。
594
595
  const MODE_ENUM = ['auto', 'plan', 'accept-edits', 'bypass']
595
596
 
597
+ // 手动拉取(契约 §3.2)的探测时限与失败指引。
598
+ // 失败文案必须「可操作」:面板只会把 error 原样显示给用户,没写下一步等于没帮上忙。
599
+ const REFRESH_TIMEOUT_MS = 30000
600
+ const REFRESH_HINT = '排查顺序:① 确认设置面板的「claude 可执行文件路径」或 CLAUDE_BIN 指向真实 claude(原生 .exe 优先);② 确认 ~/.claude/settings.json 的 env 里 ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN 指向可用端点(该面若未登录,先在终端跑一次 claude 完成登录);③ 再点一次「拉取可用模型」。'
601
+
602
+ /** 缓存年龄的短文案(面板 note 用;只描述事实,不假装新鲜)。 */
603
+ function ageText(ms) {
604
+ const min = Math.floor(Math.max(0, Number(ms) || 0) / 60000)
605
+ if (min < 1) return '刚刚拉取'
606
+ if (min < 60) return min + ' 分钟前拉取'
607
+ return Math.floor(min / 60) + ' 小时前拉取'
608
+ }
609
+
596
610
  // ── 三工具 schema ─────────────────────────────────────────────────────────────
597
611
  const RUN_PARAMS = {
598
612
  type: 'object',
@@ -742,6 +756,12 @@ function defineProvider (deps = {}) {
742
756
  else argv.push('--output-format', 'json')
743
757
  if (stream && args.includePartialMessages) argv.push('--include-partial-messages')
744
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))
745
765
 
746
766
  // 权限:auto → 跟随 DSH plan 状态;plan 用 CLI 的 plan 模式,其余走 bypass
747
767
  //(-p 没有 TTY,任何交互批准都会挂到超时 —— 与 agy/codebuddy/opencode 同语义)。
@@ -967,12 +987,20 @@ function defineProvider (deps = {}) {
967
987
  }
968
988
  return out
969
989
  },
990
+ /**
991
+ * 设置面板元数据(契约 §3.2):**只读缓存、绝不探测**。
992
+ * 面板是同步回调,而探测要起子进程打网络(3-5s)——放在这里会把首屏拖慢,且探不到时
993
+ * 用户无法区分「没模型」与「没拉到」。故探测改为用户点「拉取可用模型」→
994
+ * settings.refreshModels() 显式触发;成功后写回同一份缓存,这里下次渲染直接命中。
995
+ */
970
996
  meta: (env2) => {
971
997
  const e = env2 && typeof env2 === 'object' ? env2 : {}
972
998
  const s = currentSettings()
973
- const probe = (function () { try { return probeSupportedModelsSyncCached(e, { binPath: s.binPath }) } catch (err) { return { ok: false, ids: [], error: String(err && err.message || err) } } })()
974
- const options = (probe.ok ? probe.ids : []).map((id) => ({ value: id, label: id }))
999
+ const cached = readCachedModels(e, { binPath: s.binPath })
1000
+ const options = (cached ? cached.ids : []).map((id) => ({ value: id, label: id }))
975
1001
  const exeCandidates = claudeExeCandidates(e, '')
1002
+ // 端点实际走向(proxy / direct)——面板上如实显示,用户不用猜为什么请求失败。
1003
+ const epInfo = resolveEndpoint(s, e, { configDir: claudeConfigDir(e) })
976
1004
  return {
977
1005
  titleSuffix: 'Claude Code',
978
1006
  fields: [
@@ -985,19 +1013,73 @@ function defineProvider (deps = {}) {
985
1013
  {
986
1014
  key: 'defaultModel', label: '默认模型', type: 'select', allowCustom: true,
987
1015
  options,
1016
+ // 面板据此渲染「手动拉取」按钮(契约 §3.2);meta() 自身永不探测。
1017
+ refresh: { label: '拉取可用模型' },
988
1018
  placeholder: '留空 = 由 claude 自身配置(~/.claude/settings.json)决定',
989
1019
  note: options.length
990
- ? '共 ' + options.length + ' 个服务端权威可用模型(探测所得)。调用时传 model 可覆盖本项。'
991
- : '未能获取服务端权威模型清单(探测失败:' + String(probe.error || 'unknown').slice(0, 140) + ')—— 下拉为空,但可手动输入模型名。配置里的 ANTHROPIC_*_MODEL 只是别名映射,不作为候选展示。'
1020
+ ? '共 ' + options.length + ' 个服务端权威可用模型(' + ageText(cached.ageMs) + ',源:' + cached.mode + ')。点「拉取可用模型」重新获取;调用时传 model 可覆盖本项。'
1021
+ : '还没拉取过(或上次没拉到):点「拉取可用模型」向端点取权威清单;空表示尚未拉取 / 该面未登录。可直接手动输入模型名 —— 配置里的 ANTHROPIC_*_MODEL 只是别名映射,不作为候选展示。'
992
1022
  },
993
1023
  {
994
1024
  key: 'maxBudgetUsd', label: '单次最大花费(美元)', type: 'text',
995
1025
  placeholder: '留空 = 不限制',
996
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。'
997
1041
  }
998
1042
  ]
999
1043
  }
1000
1044
  },
1045
+
1046
+ /**
1047
+ * 手动拉取(契约 §3.2):面板「拉取可用模型」按钮 → keystone
1048
+ * POST /cli-bridge/settings/models → 这里。这是本 provider 唯一的面板探测入口。
1049
+ *
1050
+ * 复用 listAccountModels(异步 probeSupportedModels + 配置诊断),不写第二套探测;
1051
+ * 其成功路径已把权威清单写回 modelsCache,随后 meta() 直接命中(无需再探测)。
1052
+ * 铁律:失败 = ok:false + 可操作错误 + 空 ids,绝不返回 {ok:true, ids:[]};
1053
+ * 也绝不把 settings.json 里的别名映射冒充成服务端可用全集。
1054
+ */
1055
+ async refreshModels(settings, env2) {
1056
+ const e = (env2 && typeof env2 === 'object') ? env2 : env
1057
+ const s = descriptor.settings.normalize(settings)
1058
+ // 探测必须走**同一个端点**:cc-switch proxy 模式下直连第三方网关会 400,那样探测
1059
+ // 永远拿不到东西(但配置清单仍应给出,见 listAccountModels 的合并逻辑)。
1060
+ const ep = resolveEndpoint(s, e, { configDir: claudeConfigDir(e) })
1061
+ const got = await listAccountModels(e, {
1062
+ binPath: s.binPath,
1063
+ baseUrl: ep.mode === 'proxy' ? ep.baseUrl : '',
1064
+ timeoutMs: REFRESH_TIMEOUT_MS,
1065
+ refresh: true
1066
+ })
1067
+ if (!got.ok) {
1068
+ return { ok: false, ids: [], source: got.source || 'none', error: String(got.error || 'probe failed') + ' ' + REFRESH_HINT + ' 端点:' + ep.note }
1069
+ }
1070
+ const ids = (got.models || []).map((m) => m.id).filter(Boolean)
1071
+ if (!ids.length) {
1072
+ return { ok: false, ids: [], source: got.source || 'none', error: '探测声称成功但未返回任何模型 id(内部不一致)。' + REFRESH_HINT }
1073
+ }
1074
+ // labels 标出「来自配置」还是「服务端报文补充」,面板可据此区分来源。
1075
+ const labels = {}
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 }
1082
+ },
1001
1083
  diagnose: (settings, env2) => {
1002
1084
  const s = settings && typeof settings === 'object' ? settings : {}
1003
1085
  const e = env2 && typeof env2 === 'object' ? env2 : {}
@@ -1070,6 +1152,140 @@ function defineProvider (deps = {}) {
1070
1152
  return descriptor
1071
1153
  }
1072
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
+
1073
1289
  // ── inlined from providers/claude-exe.mjs ──
1074
1290
  // providers/claude-exe.mjs — claude 可执行文件探测/包装(无宿主依赖的纯逻辑片)。
1075
1291
  //
@@ -1200,18 +1416,23 @@ function claudePathHint(env) {
1200
1416
  // ~/.claude/settings.json 的 env.ANTHROPIC_DEFAULT_*_MODEL 只是「别名映射」,
1201
1417
  // 不是服务端可用全集 —— 红线:绝不把它冒充成可用清单,也绝不静默返回空数组。
1202
1418
  //
1203
- // 落盘/缓存:面板(settings.meta)是同步回调,必须 spawnSync;探测一次约 3-5s,
1204
- // 故结果进 5 分钟缓存(与 codebuddy catalog 同策略),避免每次渲染都打网络。
1419
+ // 缓存与「谁探测」(契约 §3.2 手动拉取):
1420
+ // • settings.meta() 是同步回调,且**不再自动探测** —— 它只调 readCachedModels() 读这里的
1421
+ // 缓存(纯读、不 spawn、不联网);没命中就留空,由 note 提示用户点「拉取可用模型」。
1422
+ // • 探测只有一条路:settings.refreshModels() → 异步 probeSupportedModels()(用户显式触发)。
1423
+ // 成功后经 cachedModels() 写回同一个缓存,下次 meta() 直接命中 —— 面板首屏不再被拖慢。
1424
+ // • 5 分钟 TTL 只约束**工具调用**(claude_models 的重复调用);面板展示的是「上次拉取到的
1425
+ // 权威清单」,过期也照旧展示,但 note 会标注拉取时间(宁可显式标旧,也不让下拉莫名清空)。
1205
1426
  // import { claudeExeCandidates, claudeProbeCandidates, splitExeSpec, quoteShellArg } from './claude-exe.mjs'
1206
1427
  // 故意无效的探测模型名(服务端必然拒绝;保持带前缀,便于日志辨认来源)。
1207
1428
  const PROBE_MODEL = '__clb_probe_invalid__'
1208
1429
  const MODELS_TTL_MS = 5 * 60 * 1000
1209
1430
 
1210
- // 探测结果缓存:key(后端/端点标识) → { at, ids }
1431
+ // 探测结果缓存:key(后端/端点标识) → { at, value }
1211
1432
  const modelsCache = new Map()
1212
1433
 
1213
1434
  /** 清空探测缓存(测试与「模型变更后刷新」用)。 */
1214
- function resetModelsCache() { modelsCache.clear(); syncModelsCache.clear() }
1435
+ function resetModelsCache() { modelsCache.clear() }
1215
1436
 
1216
1437
  // 缓存键 = 候选可执行文件集合(同一台机器可能切换 CLAUDE_BIN / 面板路径,键必须跟着变,
1217
1438
  // 否则会把「上一个 exe 的探测结果」错配给新 exe)。
@@ -1220,8 +1441,18 @@ function candidateKey(env, binPath) {
1220
1441
  return list.length ? list.join('|') : 'claude'
1221
1442
  }
1222
1443
 
1444
+ /** 缓存的键(面板读缓存与工具探测必须用同一把键,否则写进去读不出来)。 */
1445
+ function modelsCacheKey(env, binPath) {
1446
+ return 'claude:' + candidateKey(env, binPath)
1447
+ }
1448
+
1449
+ /** 缓存里只可能存「成功的权威清单」;失败绝不入缓存(否则会把坏状态固化)。 */
1450
+ function isUsableProbe(v) {
1451
+ return !!(v && v.ok === true && Array.isArray(v.ids) && v.ids.length)
1452
+ }
1453
+
1223
1454
  /**
1224
- * 缓存包装:TTL 内直接返回上次成功结果;失败不写缓存(下次仍然重试,绝不把失败固化)。
1455
+ * 缓存包装(异步工具路径):TTL 内直接返回上次成功结果;失败不写缓存。
1225
1456
  * @param {string} key
1226
1457
  * @param {() => Promise<object>|object} load
1227
1458
  * @param {number} [nowMs]
@@ -1231,11 +1462,47 @@ function cachedModels(key, load, nowMs) {
1231
1462
  const hit = modelsCache.get(key)
1232
1463
  if (hit && at - hit.at < MODELS_TTL_MS) return hit.value
1233
1464
  return Promise.resolve().then(load).then((v) => {
1234
- if (v && v.ok && Array.isArray(v.ids) && v.ids.length) modelsCache.set(key, { at, value: v })
1465
+ if (isUsableProbe(v)) modelsCache.set(key, { at, value: v })
1235
1466
  return v
1236
1467
  })
1237
1468
  }
1238
1469
 
1470
+ /**
1471
+ * 只读缓存(settings.meta 专用):纯读、不 spawn、不联网。
1472
+ * 与 cachedModels 的区别:这里**不受 TTL 限制** —— 面板展示的是「上次真正拉取到的那份
1473
+ * 服务端清单」,附 ageMs 让 note 如实标注新旧;空/过期与否都不该凭空清空用户看到的下拉。
1474
+ *
1475
+ * 为什么要「精确键未命中 → 取最近一次成功结果」:keystone 渲染面板时只把 env 传给
1476
+ * meta(),**拿不到面板里保存的 binPath**(settingsView 里 createProvider 只带 env),
1477
+ * 而缓存键含候选 exe 集合 → 用户一旦填过 binPath,精确键必然错开,下拉会在拉取成功后
1478
+ * 仍然空着(看起来像按钮没起作用)。这不算降级:清单是 claude 自己 settings.json 里
1479
+ * 端点+凭据裁决的,与走哪个候选 exe 无关(exe 只决定探测能否跑起来),且 note 会带上
1480
+ * 「N 分钟前拉取 / 源 account-live」,用户看到的是真实的探测结果与真实的年龄。
1481
+ * @param {object} env
1482
+ * @param {{binPath?: string, nowMs?: number}} [opts]
1483
+ * @returns {{ids: string[], mode: string, exe: (string|null), at: number, ageMs: number}|null}
1484
+ */
1485
+ function readCachedModels(env, opts) {
1486
+ const o = opts || {}
1487
+ const at = Number(o.nowMs) || Date.now()
1488
+ const exact = modelsCache.get(modelsCacheKey(env, o.binPath))
1489
+ let hit = isUsableProbe(exact && exact.value) ? exact : null
1490
+ if (!hit) {
1491
+ for (const entry of modelsCache.values()) {
1492
+ if (!isUsableProbe(entry.value)) continue
1493
+ if (!hit || entry.at > hit.at) hit = entry
1494
+ }
1495
+ }
1496
+ if (!hit) return null
1497
+ return {
1498
+ ids: hit.value.ids.slice(),
1499
+ mode: hit.value.mode || 'account-live',
1500
+ exe: hit.value.exe || null,
1501
+ at: hit.at,
1502
+ ageMs: Math.max(0, at - hit.at)
1503
+ }
1504
+ }
1505
+
1239
1506
  /**
1240
1507
  * 从 400 报文里解析「服务端支持的模型名」。
1241
1508
  * 主格式(实测):`The supported API model names are A, B, but you passed Z.`
@@ -1343,8 +1610,15 @@ function readConfiguredModels(env) {
1343
1610
  }
1344
1611
 
1345
1612
  /** 探测 argv(不含 exe 前缀):固定参数集,绝不拼装用户 prompt。 */
1346
- function probeArgv() {
1347
- 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
1348
1622
  }
1349
1623
 
1350
1624
  /** 探测失败时的可操作文案(区分「没找到 CLI」与「CLI 跑了但服务端没报清单」)。 */
@@ -1356,68 +1630,8 @@ function probeFailureText(reasons, tried) {
1356
1630
  }
1357
1631
 
1358
1632
  /**
1359
- * 同步探测(供 settings.meta 面板下拉;无 spawnSync 能力 → 明确 ok:false)。
1360
- * @param {object} env
1361
- * @param {{binPath?: string, timeoutMs?: number}} [opts]
1362
- * @returns {{ok: boolean, ids: string[], mode: string, exe?: string, error?: string}}
1363
- */
1364
- function probeSupportedModelsSync(env, opts) {
1365
- const o = opts || {}
1366
- const child = (function () { try { const p = globalThis.process; return (p && typeof p.getBuiltinModule === 'function') ? p.getBuiltinModule('node:child_process') : null } catch (x) { return null } })()
1367
- if (!child || typeof child.spawnSync !== 'function') {
1368
- return { ok: false, ids: [], mode: 'account-live', error: 'no spawnSync capability in this host form' }
1369
- }
1370
- const cands = claudeProbeCandidates(env, o.binPath)
1371
- const probeList = cands.length ? cands : ['claude']
1372
- const timeout = Math.max(3000, Math.min(Number(o.timeoutMs) || 12000, 30000))
1373
- const reasons = []
1374
- const proc = globalThis.process
1375
- for (const exe of probeList) {
1376
- const spec = splitExeSpec(exe)
1377
- const argv = spec.prefixArgs.concat(probeArgv())
1378
- const cmd = spec.shell ? [spec.cmd].concat(argv).map(quoteShellArg).join(' ') : spec.cmd
1379
- const r = child.spawnSync(cmd, spec.shell ? [] : argv, {
1380
- encoding: 'utf8', timeout, windowsHide: true, shell: !!spec.shell,
1381
- env: Object.assign({}, (proc && proc.env) || {}, {})
1382
- })
1383
- if (!r || r.error) { reasons.push(exe + ': ' + String((r && r.error && (r.error.code || r.error.message)) || 'spawn failed')); continue }
1384
- const got = extractProbeIds(r.stdout, r.stderr)
1385
- if (got.ids && got.ids.length) return { ok: true, ids: got.ids, mode: 'account-live', exe }
1386
- const head = String(got.errorText || '').replace(/\s+/g, ' ').trim().slice(0, 160)
1387
- reasons.push(exe + ': exit ' + r.status + ',未解析出可用模型清单' + (head ? ' — ' + head : ''))
1388
- }
1389
- return { ok: false, ids: [], mode: 'account-live', error: probeFailureText(reasons, probeList.length) }
1390
- }
1391
-
1392
- // 同步探测缓存(面板每次渲染都会取,避免反复 spawnSync 打网络)。
1393
- const syncModelsCache = new Map()
1394
- // 失败结果的反向缓存 TTL:成功缓存 5 分钟;失败只缓存 60 秒。
1395
- // 为什么必须有反向缓存:面板是同步回调,CLI 缺失/未登录时一次 spawnSync 可能阻塞数秒,
1396
- // 每次渲染都重探会让设置面板卡顿。60 秒足够让用户装好 CLI/登录后立刻看到新结果,
1397
- // 又不会把「当时读不到」长期固化成假象(状态照旧 ok:false + 可操作错误,语义不变)。
1398
- const NEGATIVE_TTL_MS = 60 * 1000
1399
-
1400
- /**
1401
- * 带缓存的同步探测(settings.meta 专用;面板是同步回调,只能 spawnSync)。
1402
- * 成功/失败都进缓存(TTL 不同),失败语义保持不变:ok:false + 可操作错误。
1403
- * @returns {{ok: boolean, ids: string[], mode: string, exe?: string, error?: string}}
1404
- */
1405
- function probeSupportedModelsSyncCached(env, opts) {
1406
- const o = opts || {}
1407
- const key = 'sync:claude:' + candidateKey(env, o.binPath)
1408
- const at = Date.now()
1409
- const hit = syncModelsCache.get(key)
1410
- if (hit) {
1411
- const ttl = hit.value && hit.value.ok ? MODELS_TTL_MS : NEGATIVE_TTL_MS
1412
- if (at - hit.at < ttl) return hit.value
1413
- }
1414
- const v = probeSupportedModelsSync(env, o)
1415
- syncModelsCache.set(key, { at, value: v })
1416
- return v
1417
- }
1418
-
1419
- /**
1420
- * 异步探测(MCP / preset / dynamic 三形态共用):起一次真实 CLI 调用,解析 400 报文。
1633
+ * 异步探测(唯一探测实现:MCP / preset / dynamic 三形态 + 面板「拉取可用模型」共用):
1634
+ * 起一次真实 CLI 调用,解析 400 报文。
1421
1635
  * 铁律:只认探测结果;失败 = ok:false + 可操作错误,绝不回落到配置文件/静态表。
1422
1636
  * @param {object} env
1423
1637
  * @param {{binPath?: string, timeoutMs?: number}} [opts]
@@ -1443,7 +1657,7 @@ function probeSupportedModels(env, opts) {
1443
1657
  }
1444
1658
  const exe = probeList[idx++]
1445
1659
  const spec = splitExeSpec(exe)
1446
- const argv = spec.prefixArgs.concat(probeArgv())
1660
+ const argv = spec.prefixArgs.concat(probeArgv(o.baseUrl))
1447
1661
  let c
1448
1662
  try {
1449
1663
  const spawnCmd = spec.shell ? [spec.cmd].concat(argv).map(quoteShellArg).join(' ') : spec.cmd
@@ -1454,20 +1668,33 @@ function probeSupportedModels(env, opts) {
1454
1668
  let out = ''
1455
1669
  let err = ''
1456
1670
  const timer = setTimeout(() => { try { c.kill() } catch (e) { } }, Math.max(5000, Math.min(Number(o.timeoutMs) || 30000, 90000)))
1671
+ // 一个候选只允许结算一次:spawn 失败时 'error' 与 'close' 会先后都触发,
1672
+ // 两个回调各调一次 attempt() 会把后面的候选整个跳过(idx 多走一格)——
1673
+ // 实测后果是「binPath 填错 → 明明 CLAUDE_BIN 里那个能跑,探测却直接失败」。
1674
+ let settled = false
1675
+ const nextCandidate = (reason) => {
1676
+ if (settled) return
1677
+ settled = true
1678
+ clearTimeout(timer)
1679
+ if (reason) reasons.push(reason)
1680
+ attempt()
1681
+ }
1457
1682
  c.stdout.on('data', (d) => { out += d; if (out.length > 400000) out = out.slice(-200000) })
1458
1683
  c.stderr.on('data', (d) => { err += d; if (err.length > 200000) err = err.slice(-100000) })
1459
1684
  c.on('error', (e) => {
1460
- clearTimeout(timer)
1461
- reasons.push(exe + ': ' + String((e && e.code) || (e && e.message) || 'spawn error'))
1462
- attempt()
1685
+ nextCandidate(exe + ': ' + String((e && e.code) || (e && e.message) || 'spawn error'))
1463
1686
  })
1464
1687
  c.on('close', (code) => {
1465
- clearTimeout(timer)
1688
+ if (settled) return
1466
1689
  const got = extractProbeIds(out, err)
1467
- if (got.ids && got.ids.length) { resolve({ ok: true, ids: got.ids, mode: 'account-live', exe }); return }
1690
+ if (got.ids && got.ids.length) {
1691
+ settled = true
1692
+ clearTimeout(timer)
1693
+ resolve({ ok: true, ids: got.ids, mode: 'account-live', exe })
1694
+ return
1695
+ }
1468
1696
  const head = String(got.errorText || '').replace(/\s+/g, ' ').trim().slice(0, 160)
1469
- reasons.push(exe + ': exit ' + code + ',未解析出可用模型清单' + (head ? ' — ' + head : ''))
1470
- attempt()
1697
+ nextCandidate(exe + ': exit ' + code + ',未解析出可用模型清单' + (head ? ' — ' + head : ''))
1471
1698
  })
1472
1699
  }
1473
1700
  attempt()
@@ -1481,40 +1708,69 @@ function probeSupportedModels(env, opts) {
1481
1708
  * @param {object} env
1482
1709
  * @param {{binPath?: string, timeoutMs?: number, refresh?: boolean}} [opts]
1483
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
+ */
1484
1726
  async function listAccountModels(env, opts) {
1485
1727
  const o = opts || {}
1486
- const key = 'claude:' + candidateKey(env, o.binPath)
1728
+ const key = modelsCacheKey(env, o.binPath)
1487
1729
  if (o.refresh) modelsCache.delete(key)
1488
1730
  const probe = await cachedModels(key, () => probeSupportedModels(env, o))
1489
1731
  const cfg = readConfiguredModels(env)
1490
1732
  const configured = cfg.models || []
1491
- 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) {
1492
1753
  return {
1493
- ok: false,
1494
- source: 'account-live',
1495
- models: [],
1496
- count: 0,
1497
- error: String(probe.error || 'probe failed'),
1498
- diagnostic: {
1499
- configured: configured,
1500
- settingsPath: cfg.path,
1501
- note: 'ANTHROPIC_*_MODEL 只是用户配置的别名映射,不是服务端可用全集;因此不作为 models 返回。'
1502
- }
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 }
1503
1758
  }
1504
1759
  }
1505
- const ids = probe.ids.slice()
1506
- const byId = new Map(configured.map((m) => [m.id, m]))
1507
- 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')
1508
1763
  return {
1509
1764
  ok: true,
1510
- source: 'account-live',
1511
- models: ids.map((id) => {
1512
- const hit = byId.get(id) || byIdLoose.get(String(id).toLowerCase())
1513
- return { id, source: 'account-live', configuredAs: hit ? hit.key : null }
1514
- }),
1515
- count: ids.length,
1516
- ext: { exe: probe.exe || null, mode: probe.mode },
1517
- 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
+ }
1518
1774
  }
1519
1775
  }
1520
1776