sintbot 1.4.15 → 1.4.17

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.
@@ -24,6 +24,9 @@ const { codexCatalogWindows, windowForProvider } = require('../agent/modelwindow
24
24
  const claudestatus = require('../agent/claudestatus') // Claude 订阅登录态(卡片「检测登录」· doctor 同源)
25
25
  const claudemodels = require('../agent/claudemodels') // Claude 模型候选(仅登录后给)
26
26
  const claudeusage = require('../agent/claudeusage') // Claude 订阅额度(oauth/usage,cc-hud 同源;v0.11.3)
27
+ const { collectEnvConflicts } = require('../agent/envconflicts') // 模型卡「环境冲突」(B5):守护进程 env / settings.json / config.toml,只回键名
28
+ // 探不出来给空数组 —— 这行告警绝不能拖垮登录检测本身。
29
+ const envWarningsFor = (runner, provider) => { try { return collectEnvConflicts({ runner, provider }) } catch { return [] } }
27
30
  const codexAgent = require('../agent/codexcode') // codex --version 探针(概览健康,仅配了 codex provider 时)
28
31
  const { globalPlugins, codexPlugins, projectPlugins, mcpReachability } = require('../plugins')
29
32
  const gitrepo = require('../gitrepo') // 群项目 ↔ git 远端关联(「仓库地址」:init + origin,幂等)+ ls-remote 连通性探测
@@ -43,6 +46,9 @@ const { platformsConfigured, chatPlatform, inferChatPlatform, PLATFORMS } = requ
43
46
  const { feishuClient, fetchBotOpenId, fetchAppIdentity, fetchChats, createChat, fetchChatMembers, addChatMembers, deleteChat, sendChatText, larkErrMsg, feishuPrecheck, fetchAdminCandidates, resolveContactToOpenId, fetchOrgUsers } = require('../platform/feishu')
44
47
  const { slackPrecheck, fetchSlackChannels, sendSlackText } = require('../platform/slack') // Slack:控制台侧只用这三个助手(收发在 daemon 里)
45
48
  const { getUsage, refreshUsage } = require('../usage')
49
+ const brainMod = require('../core/brain') // a group's model and depth on one backend: the single resolver
50
+ const { applyBrainChange, auditRow } = require('../groupbrain')
51
+ const { effortsFor, fillPicks } = require('../core/modelcmd')
46
52
 
47
53
  // 反馈本默认落点(与 CLI / 群命令同一个文件 —— 三个入口一本账,不许各记各的)
48
54
  const DEFAULT_ISSUES_FILE = () => path.join(os.homedir(), '.sbot', 'issues.jsonl')
@@ -50,6 +56,11 @@ const DEFAULT_ISSUES_FILE = () => path.join(os.homedir(), '.sbot', 'issues.jsonl
50
56
  const DEFAULT_SESSIONS_FILE = () => path.join(os.homedir(), '.sbot', 'ui-sessions.json')
51
57
 
52
58
  const CONSOLE_HTML = path.join(__dirname, 'console.html')
59
+ // Model display names: the same function as group replies and cards, injected
60
+ // into the page (with a plain fallback when the page is opened directly).
61
+ const MODELNAME_SLOT = "/*__SBOT_MODELNAME__*/(s=>String(s==null?'':s))"
62
+ const MODELNAME_SRC = '(' + require('../core/modelname').modelDisplayName.toString() + ')'
63
+ const { knownModelNames } = require('../core/modelname')
53
64
  const LIVE_ACTIVITY_JS = fs.readFileSync(path.join(__dirname, 'liveactivity-client.js'), 'utf8')
54
65
  const BROWSER_HTML = path.join(__dirname, 'browser.html') // 接管浏览器页(自包含单文件,群成员凭一次性链接也能进)
55
66
  const takeover = require('./takeover') // 接管链接的票据与 cookie(纯逻辑)
@@ -311,6 +322,71 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
311
322
  // 悄悄换到另一个后端并重置会话(拖一下排序、加一个不相干的模型都会触发)⇒ 那里**只在默认模型
312
323
  // 已不存在**(文件和内置都没有这个名字)时补一个,其余一律不动;隐藏默认模型由写入方直接拒绝。
313
324
  // 返回 { from, to } = 这次自动改了默认模型(调用方据此如实报告),null = 没改。
325
+ // ── Backend · model · depth of one group (group card; the old provider route
326
+ // forwards here). The same change logic as the chat /model command
327
+ // (src/groupbrain.js). Only a backend change resets the session.
328
+ const catalogFor = async (name, prov) => {
329
+ const c = runtimePolicy && runtimePolicy.modelCatalog
330
+ if (!c || !prov) return null
331
+ try { return await c(name, prov) } catch { return null }
332
+ }
333
+ // The target backend of a change as the file says now ({} when unknown).
334
+ const brainTarget = (raw, chatId, change) => {
335
+ const g = raw.groups && raw.groups[chatId]
336
+ if (!g) return {}
337
+ const defName = raw.defaultProvider || DEFAULTS.defaultProvider
338
+ const home = g.provider || defName
339
+ const target = change.provider || home
340
+ return { g, defName, home, target, tProv: { ...DEFAULTS.providers, ...(raw.providers || {}) }[target] }
341
+ }
342
+ async function changeGroupBrain(chatId, change, by = 'console') {
343
+ // The model list may need a CLI handshake: fetch it first, then read,
344
+ // decide and write with no await in between, so a /model change made
345
+ // meanwhile is never overwritten by a stale copy.
346
+ const first = brainTarget(readRaw(), chatId, change)
347
+ const cat = first.tProv ? await catalogFor(first.target, first.tProv) : null
348
+ const raw = readRaw()
349
+ const { g, defName, home, target, tProv } = brainTarget(raw, chatId, change)
350
+ if (!g) return { status: 404, body: { error: 'no such group' } }
351
+ if (!tProv) return { status: 400, body: { error: `未知模型 '${target}'` } }
352
+ if (target !== first.target) return { status: 409, body: { error: '这个群的后端刚被改过,请刷新后再改' } }
353
+ if (change.provider && change.provider !== home && (tProv.hidden || (runtimePolicy && !runtimePolicy.providerSupported(tProv)))) {
354
+ return { status: 400, body: { error: `「${target}」已隐藏或不受支持,不能选` } }
355
+ }
356
+ const switching = target !== home
357
+ const tBrain = brainMod.groupBrain(g, target, tProv, { defaultProvider: defName })
358
+ let model = change.model, effort = change.effort
359
+ if (model !== undefined && model !== '' && tProv.baseUrl) return { status: 400, body: { error: '这个模型的型号是连接设置,请在「模型」页修改' } }
360
+ if (model !== undefined && model !== '' && !brainMod.isModel(model)) return { status: 400, body: { error: '型号格式不对' } }
361
+ if (effort !== undefined && effort !== '' && !brainMod.isEffort(effort)) return { status: 400, body: { error: '思考深度格式不对' } }
362
+ if (model === tBrain.model) model = undefined
363
+ if (effort === tBrain.effort && model === undefined) effort = undefined
364
+ const f = fillPicks({ switching, model, effort, currentModel: tBrain.model, currentEffort: tBrain.effort, third: !!tProv.baseUrl, cat })
365
+ model = f.model; effort = f.effort
366
+ const nextModel = model === undefined ? tBrain.model : model
367
+ if (effort) {
368
+ const known = cat && Array.isArray(cat.models) ? cat.models.find((m) => m.id === nextModel) : null
369
+ if (known && Array.isArray(known.efforts) && !known.efforts.length) return { status: 400, body: { error: '这个型号不支持调思考深度' } }
370
+ const effs = cat ? effortsFor(cat, nextModel) : []
371
+ if (change.effort !== undefined && effs.length && !effs.includes(effort)) return { status: 400, body: { error: `这个型号没有「${effort}」这档深度(可选:${effs.join(' · ')})` } }
372
+ }
373
+ if (tProv.runner === 'codex' && tProv.contextWindowTokens > 0 && nextModel) {
374
+ const maximum = codexCatalogWindows().get(nextModel)?.maxTokens
375
+ if (maximum && tProv.contextWindowTokens > maximum) return { status: 400, body: { error: `「${target}」的自定义上下文窗口超过这个型号的上限 ${maximum} token,先在「模型」页调小` } }
376
+ }
377
+ const r = applyBrainChange(raw, chatId, { provider: switching ? target : undefined, model, effort })
378
+ if (r.error) return { status: 400, body: { error: r.error } }
379
+ if (!r.changed) return { status: 200, body: { ...(await buildState()), brainChange: { changed: false } } }
380
+ try { writeRaw(raw) } catch (e) {
381
+ const m = (e && e.message) || String(e)
382
+ return { status: 400, body: { error: /settled|unresolved|未结/.test(m) ? '该群有任务进行中或记录未结账,等它结束后再改' : m } }
383
+ }
384
+ audit.append(auditRow(chatId, r, { source: 'console', by }))
385
+ const resets = runtimePolicy && runtimePolicy.appliedResets ? runtimePolicy.appliedResets() : []
386
+ return { status: 200, body: { ...(await buildState()), brainChange: { changed: true, switching: r.switching,
387
+ modelChanged: r.modelChanged, effortChanged: r.effortChanged, filled: f.filled, provider: target,
388
+ model: r.after.model, effort: r.after.effort, reset: resets.includes(chatId) } } }
389
+ }
314
390
  const settleDefault = (raw) => {
315
391
  if (!runtimePolicy) { fixDefault(raw); return null }
316
392
  const current = raw.defaultProvider || DEFAULTS.defaultProvider
@@ -625,6 +701,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
625
701
  health,
626
702
  providers,
627
703
  defaultProvider: raw.defaultProvider || cfg.defaultProvider,
704
+ modelNames: knownModelNames(), // official names registered from the live model lists
628
705
  groups,
629
706
  // 项目根目录:绑群弹窗的路径提示用 projectsRoots(生效值);设置页那张卡用 projectsRootsInfo
630
707
  //(每条根在不在 · 配置原文 · 被忽略的值与原因 · 哪些群在根外)。与围栏同一份判据。
@@ -660,7 +737,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
660
737
 
661
738
  leftovers: await leftoversState(),
662
739
 
663
- codexStatus: codexStatusCache ? { login: codexStatusCache.login, usage: codexStatusCache.usage } : null, // 前端加载回填,codex 卡片刷新不丢
740
+ codexStatus: codexStatusCache ? { login: codexStatusCache.login, usage: codexStatusCache.usage, envWarningsByProvider: codexStatusCache.envWarningsByProvider || {} } : null, // 前端加载回填,codex 卡片刷新不丢
664
741
  claudeStatus: claudeStatusCache ? claudeStatusCache.login : null, // 同上,Claude 订阅登录态
665
742
  // 服务器时区/时间(顶栏时钟):tz/offsetMin = bot 工作时区(cfg.timezone,默认北京)——
666
743
  // 群里/prompt 时间戳用的就是这个口径,运维看到的钟要和群里一致;hostTz 是主机系统时区
@@ -699,6 +776,14 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
699
776
  return state
700
777
  }
701
778
 
779
+ // A card without its own model (subscription) is tried with the catalog's
780
+ // recommendation, the same one a newly bound group starts with.
781
+ async function recommendedModel(name, provider) {
782
+ if (!provider || provider.baseUrl) return undefined
783
+ const cat = await catalogFor(name, provider)
784
+ return (cat && cat.recommended && cat.recommended.model) || undefined
785
+ }
786
+
702
787
  async function testProvider(name, prompt) {
703
788
  const cfg = loadConfig(configPath); applyProxyEnv(cfg)
704
789
  const provider = cfg.providers[name]
@@ -718,7 +803,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
718
803
  bin: cfg.agent.bin,
719
804
  prompt: prompt || verify.TRIAL_PROMPT,
720
805
  provider,
721
- model: provider.model,
806
+ model: provider.model || await recommendedModel(name, provider),
722
807
  permissionMode: cfg.agent.permissionMode,
723
808
  signal: ac.signal, // a one-line reply — needs no tools, so no allow list
724
809
  })
@@ -728,7 +813,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
728
813
  lease?.assertWritable()
729
814
  const ok = !(result && result.isError)
730
815
  verify.recordTurn(provider, ok, (result && result.text) || '', vopts) // doctor / 概览据此报「能用」或「key 不对」
731
- return { ok, model: provider.model, reply: (result && result.text) || '(no text)', ms: Date.now() - t0 }
816
+ return { ok, model: provider.model || '', reply: (result && result.text) || '(no text)', ms: Date.now() - t0 }
732
817
  } catch (e) {
733
818
  closed ||= isClosedRunnerError(e)
734
819
  if (!lease) verify.recordTurn(provider, false, (e && e.message) || String(e), vopts)
@@ -873,7 +958,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
873
958
  // 隧道对端哪天被写成 127.0.0.1:4600,也不会退化成「公网全员免密」;伪造这两个头只会让自己失去免密,方向安全。
874
959
  const localBypass = !loopbackBind && isLoopbackReq(req) && !req.headers['x-forwarded-for'] && !req.headers['x-forwarded-proto']
875
960
  const injectTok = (authConfigured() && !localBypass) ? '' : token
876
- return fs.readFile(CONSOLE_HTML, 'utf8', (e, html) => e ? (res.writeHead(500), res.end('console.html missing')) : (res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }), res.end(html.replace('__SBOT_TOKEN__', injectTok).replace('__SBOT_VERSION__', PKG_VERSION).replace('__SBOT_FEISHU_LOGIN__', feishuLoginInfo().ready ? '1' : '').replace('/*__LIVE_ACTIVITY__*/', () => LIVE_ACTIVITY_JS))))
961
+ return fs.readFile(CONSOLE_HTML, 'utf8', (e, html) => e ? (res.writeHead(500), res.end('console.html missing')) : (res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }), res.end(html.replace('__SBOT_TOKEN__', injectTok).replace('__SBOT_VERSION__', PKG_VERSION).replace('__SBOT_FEISHU_LOGIN__', feishuLoginInfo().ready ? '1' : '').replace('/*__LIVE_ACTIVITY__*/', () => LIVE_ACTIVITY_JS).replace(MODELNAME_SLOT, () => MODELNAME_SRC))))
877
962
  }
878
963
  // 接管浏览器页。群成员的一次性链接是 /browser#k=<票>:票在 URL 片段里,服务端根本收不到 ⇒ 不进任何日志、链接预览的
879
964
  // 裸 GET 也抓不走;页面脚本读片段、人点「开始接管」后 POST /api/browser/claim 换 cookie。这里只分发页面:
@@ -951,7 +1036,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
951
1036
  codexModelsCache = { at: Date.now(), val }
952
1037
  return sendJSON(res, 200, val)
953
1038
  }
954
- if (p === '/api/usage' && req.method === 'GET') { const cfg = loadConfig(configPath); return sendJSON(res, 200, await getUsage(cfg.providers)) }
1039
+ if (p === '/api/usage' && req.method === 'GET') { const cfg = loadConfig(configPath); return sendJSON(res, 200, await getUsage(cfg.providers, { bin: cfg.agent.bin })) }
955
1040
  // 模型卡「额度」行的 ↻:只刷这一个 provider 且绕过两层缓存(见 usage.js refreshUsage)。
956
1041
  // 分工 = 整页 /api/usage 走 5 分钟缓存,手点才现拉;codex 走自家 /api/codex/verify 的 force。
957
1042
  if (p === '/api/usage/refresh' && req.method === 'POST') {
@@ -959,7 +1044,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
959
1044
  const cfg = loadConfig(configPath)
960
1045
  const prov = cfg.providers && cfg.providers[name]
961
1046
  if (!prov) return sendJSON(res, 400, { error: `未知模型 '${name}'` })
962
- return sendJSON(res, 200, { name, usage: await refreshUsage(name, prov) })
1047
+ return sendJSON(res, 200, { name, usage: await refreshUsage(name, prov, { bin: cfg.agent.bin }) })
963
1048
  }
964
1049
 
965
1050
  if (p === '/api/provider-key' && req.method === 'POST') {
@@ -975,7 +1060,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
975
1060
  }
976
1061
  // upsert a provider (add or edit — the ⚙ config panel + 添加模型 both use this)
977
1062
  if (p === '/api/providers/add' && req.method === 'POST') {
978
- const { name, baseUrl, model, apiKey, env, toolQuality, effort, reasoningEffort, autocompactTokens, contextWindowTokens, hidden, vision, modelClear, runner } = await body(req)
1063
+ let { name, baseUrl, model, apiKey, env, toolQuality, effort, reasoningEffort, autocompactTokens, contextWindowTokens, hidden, vision, modelClear, runner } = await body(req)
979
1064
  if (!name || !/^[\w.-]+$/.test(name)) return sendJSON(res, 400, { error: '名称必填,且只用字母/数字/._-' })
980
1065
  // 持久处理流程只跑 Codex / Claude Code:别的后端名不能被下面的白名单悄悄改写成 Claude 存进去。
981
1066
  if (runtimePolicy && runner !== undefined && runner !== '' && !runtimePolicy.providerSupported({ runner })) {
@@ -986,6 +1071,13 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
986
1071
  const prev = raw.providers[name] ? { model: raw.providers[name].model || '', re: raw.providers[name].reasoningEffort || '' } : null
987
1072
  const pr = { ...(raw.providers[name] || {}) }
988
1073
  if (baseUrl !== undefined) pr.baseUrl = baseUrl || undefined
1074
+ // Durable hosts: model and depth belong to each group (group card / /model).
1075
+ // A subscription card takes neither; a third-party endpoint keeps its
1076
+ // model as connection configuration and takes no depth.
1077
+ if (runtimePolicy) {
1078
+ effort = undefined; reasoningEffort = undefined
1079
+ if (!pr.baseUrl) { model = undefined; modelClear = undefined }
1080
+ }
989
1081
  if (model) pr.model = model
990
1082
  // 「默认(跟随 Claude Code)」= 不指定型号 ⇒ spawn 时不设 ANTHROPIC_MODEL,用 CLI 自己的默认。
991
1083
  // 空 model 与「没传 model」要分开,所以用显式的 modelClear,而不是 `model !== undefined`
@@ -1025,8 +1117,16 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1025
1117
  }
1026
1118
  if (pr.runner !== 'codex') delete pr.contextWindowTokens
1027
1119
  if (pr.contextWindowTokens > 0) {
1028
- const maximum = codexCatalogWindows().get(pr.model)?.maxTokens
1029
- if (maximum && pr.contextWindowTokens > maximum) return sendJSON(res, 400, { error: `上下文窗口超过当前模型目录上限 ${maximum} token` })
1120
+ // Check every model a group runs on this card (and the card's own, if any).
1121
+ const windows = codexCatalogWindows()
1122
+ const defName = raw.defaultProvider || DEFAULTS.defaultProvider
1123
+ const models = new Set([pr.model, ...Object.entries(raw.groups || {})
1124
+ .filter(([id, g]) => id !== 'oc_example_chat_id' && g && (g.provider || defName) === name)
1125
+ .map(([, g]) => brainMod.groupBrain(g, name, pr, { defaultProvider: defName }).model)].filter(Boolean))
1126
+ for (const m of models) {
1127
+ const maximum = windows.get(m)?.maxTokens
1128
+ if (maximum && pr.contextWindowTokens > maximum) return sendJSON(res, 400, { error: `上下文窗口超过型号 ${m} 的目录上限 ${maximum} token` })
1129
+ }
1030
1130
  }
1031
1131
  raw.providers[name] = pr
1032
1132
  const defaultChange = settleDefault(raw); writeRaw(raw)
@@ -1733,6 +1833,18 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1733
1833
  // Fail here, not at the first task: a binding to a missing
1734
1834
  // directory would only surface when a turn tries to run in it.
1735
1835
  if (!fs.existsSync(workdir)) return sendJSON(res, 400, { error: `workdir 不存在: ${workdir}` })
1836
+ // The model list is fetched before the read below, so no await sits
1837
+ // between reading and writing the file (a /model change made meanwhile
1838
+ // must not be overwritten).
1839
+ let preName = '', preCat = null
1840
+ if (runtimePolicy) {
1841
+ try {
1842
+ const r0 = readRaw()
1843
+ preName = provider || (r0.groups && r0.groups[chatId] && r0.groups[chatId].provider) || r0.defaultProvider || DEFAULTS.defaultProvider
1844
+ const p0 = { ...DEFAULTS.providers, ...(r0.providers || {}) }[preName]
1845
+ if (p0) preCat = await catalogFor(preName, p0)
1846
+ } catch { /* the read below reports a broken file */ }
1847
+ }
1736
1848
  const raw = readRaw(); raw.groups = raw.groups || {}; delete raw.groups.oc_example_chat_id
1737
1849
  // 已绑定的群重存:旧对象在下面整个展开再覆盖(见 next),这里只是取出来比目录有没有改
1738
1850
  const prev = raw.groups[chatId] || {}
@@ -1762,6 +1874,20 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1762
1874
  // ⚠ 不按「那个平台配了凭据没」拒绝:纯配置模式(先绑群、后填凭据)一直是允许的;没配的平台由 doctor 的群行报「收不到消息」
1763
1875
  next.platform = PLATFORMS.includes(platform) ? platform : chatPlatformOf(raw, chatId)
1764
1876
  raw.groups[chatId] = next
1877
+ if (runtimePolicy) {
1878
+ // A new binding or another backend starts with the recommended model
1879
+ // and depth where the group has not chosen (same rule as /model).
1880
+ const defName = raw.defaultProvider || DEFAULTS.defaultProvider
1881
+ const tName = next.provider || defName
1882
+ const tProv = { ...DEFAULTS.providers, ...(raw.providers || {}) }[tName]
1883
+ if (tProv && (isNewBinding || tName !== (prev.provider || defName))) {
1884
+ brainMod.migrateLegacyModel(next, defName)
1885
+ const b = brainMod.groupBrain(next, tName, tProv, { defaultProvider: defName })
1886
+ const f = fillPicks({ switching: true, currentModel: b.model, currentEffort: b.effort,
1887
+ third: !!tProv.baseUrl, cat: tName === preName ? preCat : null })
1888
+ if (f.model !== undefined || f.effort !== undefined) applyBrainChange(raw, chatId, { model: f.model, effort: f.effort })
1889
+ }
1890
+ }
1765
1891
  writeRaw(raw)
1766
1892
  // First-time binding: drop a confirmation into the group (best-effort) so a
1767
1893
  // fresh chat is immediately alive and members know how to use it.
@@ -2268,6 +2394,19 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2268
2394
  }
2269
2395
  // real-time per-group model switch: persist to config (+ hot-reload) AND set
2270
2396
  // the live runtime override so it takes effect on the very next turn.
2397
+ if (p === '/api/groups/brain' && req.method === 'POST') {
2398
+ const { chatId, provider, model, effort } = await body(req)
2399
+ const pick = (v) => (v === undefined || v === null ? undefined : String(v))
2400
+ const out = await changeGroupBrain(chatId, { provider: pick(provider) || undefined, model: pick(model), effort: pick(effort) })
2401
+ return sendJSON(res, out.status, out.body)
2402
+ }
2403
+ if (p === '/api/groups/provider' && req.method === 'POST' && runtimePolicy) {
2404
+ // Older console pages: same writer as the group card.
2405
+ const { chatId, provider } = await body(req)
2406
+ const out = await changeGroupBrain(chatId, { provider: provider ? String(provider) : undefined })
2407
+ if (out.status === 200 && out.body.brainChange) out.body.switched = { chatId, provider, live: true }
2408
+ return sendJSON(res, out.status, out.body)
2409
+ }
2271
2410
  if (p === '/api/groups/provider' && req.method === 'POST') {
2272
2411
  const { chatId, provider } = await body(req); const raw = readRaw()
2273
2412
  if (!raw.groups || !raw.groups[chatId]) return sendJSON(res, 404, { error: 'no such group' })
@@ -2337,13 +2476,26 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2337
2476
  // 前端不显示额度行,不拖垮登录检测本身。
2338
2477
  let usage = null
2339
2478
  if (login && login.ok && login.loggedIn) {
2340
- try { if (force) claudeusage.clearCache(); usage = await claudeusage.usageLine() } catch { usage = null }
2479
+ try { if (force) claudeusage.clearCache(); usage = await claudeusage.usageLine({ bin: cfg.agent.bin }) } catch { usage = null }
2341
2480
  }
2342
- claudeStatusCache = { at: Date.now(), login: { ...login, usage } }
2343
- return sendJSON(res, 200, { ...login, usage })
2481
+ // 订阅卡 = 无 baseUrl、无 key 的直连形态;环境里的 ANTHROPIC_API_KEY 对它是合法鉴权,核心据此不报。
2482
+ const envWarnings = envWarningsFor('claude', { baseUrl: null, hasApiKey: false })
2483
+ claudeStatusCache = { at: Date.now(), login: { ...login, usage, envWarnings } }
2484
+ return sendJSON(res, 200, { ...login, usage, envWarnings })
2344
2485
  }
2345
2486
  // Claude 模型下拉的候选。**未登录不给任何型号**(产品决定:没登录就别列)。
2346
2487
  // 已登录时 = 内置候选 + 本机账号的额外可选项;为什么不是官方实时清单,见 claudemodels.js 头部。
2488
+ // What one backend offers on the group card: models (official names,
2489
+ // supported depths), recommendation. models:null = a third-party
2490
+ // endpoint whose model is set on the model card.
2491
+ if (p === '/api/groups/model-options' && req.method === 'GET') {
2492
+ const name = url.searchParams.get('provider') || ''
2493
+ const raw = readRaw()
2494
+ const prov = { ...DEFAULTS.providers, ...(raw.providers || {}) }[name]
2495
+ if (!prov) return sendJSON(res, 404, { error: `未知模型 '${name}'` })
2496
+ const cat = await catalogFor(name, prov)
2497
+ return sendJSON(res, 200, cat || { ok: false, models: prov.baseUrl ? null : [], efforts: [], recommended: { model: '', effort: '' } })
2498
+ }
2347
2499
  if (p === '/api/claude/models' && req.method === 'GET') {
2348
2500
  const cfg = loadConfig(configPath)
2349
2501
  const login = await claudestatus.cachedLoginStatus(cfg.agent.bin)
@@ -2351,8 +2503,9 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2351
2503
  // 会把 usage 字段洗掉,而 /api/claude/verify 的 60s 去抖随后会把这份缺 usage 的缓存原样
2352
2504
  // 吐回去 —— 表现就是卡片上的额度行凭空消失,而登录态一切正常,没人查得出为什么。
2353
2505
  if (login && login.ok) {
2354
- const keep = claudeStatusCache && claudeStatusCache.login && claudeStatusCache.login.usage
2355
- claudeStatusCache = { at: Date.now(), login: keep ? { ...login, usage: keep } : login }
2506
+ const prev = (claudeStatusCache && claudeStatusCache.login) || {}
2507
+ const keep = prev.usage
2508
+ claudeStatusCache = { at: Date.now(), login: { ...login, ...(keep ? { usage: keep } : {}), ...(prev.envWarnings ? { envWarnings: prev.envWarnings } : {}) } }
2356
2509
  }
2357
2510
  return sendJSON(res, 200, { ...claudemodels.listModels({ loggedIn: !!(login && login.ok && login.loggedIn) }), login })
2358
2511
  }
@@ -2369,11 +2522,14 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2369
2522
  const probe = await codexstatus.cachedAuthProbe('codex')
2370
2523
  const file = codexstatus.readAuthFile()
2371
2524
  const d = codexstatus.describeLogin(probe, file)
2525
+ // B5:每张 codex 卡各算一份(baseUrl / key 不同,核心的判据不同);OPENAI_* / config.toml 的 model_provider,只有键名
2526
+ const envWarningsByProvider = Object.fromEntries(Object.entries(loadConfig(configPath).providers || {})
2527
+ .filter(([, x]) => x && x.runner === 'codex').map(([n, x]) => [n, envWarningsFor('codex', x)]))
2372
2528
  const login = { ok: true, verified: !!probe.verified, loggedIn: !!(probe.ok || (!probe.verified && file.exists && !file.expired)), dead: !!probe.dead, mode: file.mode || 'chatgpt', level: d.level, text: d.text }
2373
2529
  let usage = probe.usage || null
2374
2530
  if (!usage && !probe.dead && codexStatusCache && codexStatusCache.usage) usage = codexStatusCache.usage // 探不到时保住旧额度(同前端那条「同一份状态两个消费者」的教训)
2375
- codexStatusCache = { at: Date.now(), login, usage } // cached into /api/state so the card survives a page reload
2376
- return sendJSON(res, 200, { login, usage })
2531
+ codexStatusCache = { at: Date.now(), login, usage, envWarningsByProvider } // cached into /api/state so the card survives a page reload
2532
+ return sendJSON(res, 200, { login, usage, envWarningsByProvider })
2377
2533
  }
2378
2534
  if (p === '/api/playground/run' && req.method === 'POST') {
2379
2535
  const { provider: provName, workdir, prompt, sessionId } = await body(req)
@@ -2396,7 +2552,8 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2396
2552
  bin: cfg.agent.bin,
2397
2553
  prompt,
2398
2554
  provider,
2399
- model: provider.model,
2555
+ model: (pgGroup && brainMod.groupBrain(pgGroup, provName, provider, { defaultProvider: cfg.defaultProvider }).model) || provider.model || await recommendedModel(provName, provider),
2556
+ effort: (pgGroup && brainMod.groupBrain(pgGroup, provName, provider, { defaultProvider: cfg.defaultProvider }).effort) || '',
2400
2557
  workdir: workdir || undefined,
2401
2558
  sessionId: sessionId || undefined,
2402
2559
  permissionMode: (pgGroup && pgGroup.permissionMode) || 'dontAsk', // 试跑继承匹配群的模式(无全局默认,unset ⇒ dontAsk)
package/src/usage.js CHANGED
@@ -1,19 +1,44 @@
1
+ 'use strict'
2
+
1
3
  // Per-provider account balance / plan usage — the numbers cc-hud surfaces, for
2
4
  // the console. Server-side (keys never reach the browser), best-effort, cached
3
- // 5 min, 2.5s timeout. Endpoints mirror cc-hud (deepseek / glm / minimax); other
4
- // providers (volcano coding-plan, Claude) have no public balance API → skipped.
5
+ // 5 min. The host only moves bytes: which endpoint, which headers, how to render.
6
+ // **Every response body is judged by the binary core** (`usage_parse`, ABI 36):
7
+ // HTTP status + body → windows / balance / one of five error kinds. The host never
8
+ // re-reads a provider's JSON shape, so one rule set serves the console, /hud and
9
+ // the quota alert alike.
10
+ //
11
+ // Common rules (2026-11-12, cc-switch takeaways B1):
12
+ // · 15 s per request · real HTTP status goes to the core (network error ⇒ null)
13
+ // · `credentials` ⇒ the card shows「凭据失效」instead of going blank
14
+ // · `transient` ⇒ keep the last good value of that provider for 10 min
15
+ // · `malformed` / `none` ⇒ null (nothing to show)
16
+ // · never log a response body or a key; the last-good cache is keyed by a key hash
17
+
18
+ const crypto = require('node:crypto')
19
+ const { usageParse } = require('./agent/privatecore')
5
20
 
6
21
  const CACHE = new Map() // provider name -> { val, ts }
7
22
  const TTL = 5 * 60 * 1000
23
+ const REQUEST_TIMEOUT_MS = 15_000
24
+ const LAST_GOOD_TTL_MS = 10 * 60 * 1000
25
+ const LAST_GOOD = new Map() // kind|baseUrl|sha256(key)[:16] -> { val, ts }
8
26
 
9
- async function fetchJson(url, key, ms = 2500) {
27
+ const CREDENTIALS_VALUE = Object.freeze({ label: '额度', value: '凭据失效' })
28
+
29
+ // One request → { status, body }. Network error / timeout ⇒ status null (the core
30
+ // files it under `transient`). Non-JSON bodies travel as text so the core can still
31
+ // say `malformed`. The body is handed to the core and dropped — never logged.
32
+ async function request(url, headers, { fetchImpl = globalThis.fetch, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
10
33
  const ac = new AbortController()
11
- const timer = setTimeout(() => ac.abort(), ms)
34
+ const timer = setTimeout(() => ac.abort(), timeoutMs)
12
35
  try {
13
- const r = await fetch(url, { headers: { Authorization: `Bearer ${key}` }, signal: ac.signal })
14
- if (!r.ok) return null
15
- return await r.json()
16
- } catch { return null } finally { clearTimeout(timer) }
36
+ const r = await fetchImpl(url, { headers, signal: ac.signal })
37
+ const text = await r.text()
38
+ let body = text
39
+ try { body = JSON.parse(text) } catch { /* 非 JSON:原文交给核心判 malformed */ }
40
+ return { status: r.status, body }
41
+ } catch { return { status: null, body: null } } finally { clearTimeout(timer) }
17
42
  }
18
43
 
19
44
  // baseUrl → 哪一家的余额接口(null = 这家根本没有公开余额接口,如火山 coding plan)。
@@ -25,40 +50,107 @@ const balanceKind = (base = '') =>
25
50
  : base.includes('minimax') ? 'minimax'
26
51
  : null
27
52
 
28
- async function providerUsage(provider) {
53
+ // 核心的 windows[] → 与 Claude 订阅额度同一种一行:`5h:42% │ 7d:10% (5d)`。
54
+ // 复用 claudeusage.formatClaudeUsage,窗口名映射:session=5h · weekly=7d · weekly_scoped=模型名。
55
+ function formatWindows(windows, now = Date.now()) {
56
+ const { formatClaudeUsage } = require('./agent/claudeusage')
57
+ const limits = (Array.isArray(windows) ? windows : []).map((w) => ({
58
+ kind: w.kind === 'weekly' ? 'weekly_all' : w.kind,
59
+ percent: w.percentUsed,
60
+ resetsAt: w.resetsAt,
61
+ scopeName: w.scopeName || '',
62
+ }))
63
+ return formatClaudeUsage(limits, now)
64
+ }
65
+
66
+ const CURRENCY_SIGN = { CNY: '¥', RMB: '¥', USD: '$' }
67
+ function formatBalance(balance) {
68
+ if (!balance || typeof balance.amount !== 'number' || !Number.isFinite(balance.amount)) return null
69
+ const sign = CURRENCY_SIGN[String(balance.currency || '').toUpperCase()]
70
+ return sign ? `${sign}${balance.amount.toFixed(2)}` : `${balance.amount.toFixed(2)} ${balance.currency || ''}`.trim()
71
+ }
72
+
73
+ // 核心的成功结果 → 卡片一行;窗口优先(额度),否则余额;两者皆无 ⇒ null。
74
+ function render(parsed, now) {
75
+ const windows = formatWindows(parsed.windows, now)
76
+ if (windows) return { label: '额度', value: windows }
77
+ const balance = formatBalance(parsed.balance)
78
+ if (balance) return { label: '余额', value: balance }
79
+ return null
80
+ }
81
+
82
+ // 每家的取数顺序。返回核心的 usage_parse 结果(成功或失败),不在这里解释。
83
+ async function probeGlm(base, key, io) {
84
+ const host = base.includes('api.z.ai') ? 'https://api.z.ai' : 'https://open.bigmodel.cn'
85
+ const now = io.now()
86
+ // coding plan 配额(5 小时 / 周窗口)。鉴权头不带 Bearer(智谱监控接口的口径)。
87
+ const quota = await request(`${host}/api/monitor/usage/quota/limit`,
88
+ { Authorization: key, 'Accept-Language': 'en-US,en' }, io)
89
+ const q = usageParse('glm', 'quota', quota.status, quota.body, now)
90
+ if (q.ok || (q.error !== 'noplan' && q.error !== 'credentials' && q.error !== 'transient')) return q
91
+ // 没有 coding plan ⇒ 按量付费账户,退回余额;配额接口拒绝/抖动时也问一次余额 —— 两个接口
92
+ // 鉴权口径未必相同,只有**两边都**说凭据不对才显示「凭据失效」;任一边抖动按瞬时算(保上次好值)。
93
+ const bal = await request(`${host}/api/biz/account/query-customer-account-report`, { Authorization: `Bearer ${key}` }, io)
94
+ const b = usageParse('glm', 'balance', bal.status, bal.body, io.now())
95
+ if (b.ok) return b
96
+ if (q.error === 'credentials' && b.error === 'credentials') return q
97
+ if (q.error === 'transient' || b.error === 'transient') return { ok: false, error: 'transient', detail: 'glm quota/balance transient' }
98
+ return b.error === 'credentials' ? { ok: false, error: 'malformed', detail: 'glm balance credentials without quota agreement' } : b
99
+ }
100
+
101
+ async function probeMinimax(base, key, io) {
102
+ const host = base.includes('minimaxi.com') ? 'https://api.minimaxi.com' : 'https://api.minimax.io'
103
+ const headers = { Authorization: `Bearer ${key}` }
104
+ const plan = await request(`${host}/v1/api/openplatform/coding_plan/remains`, headers, io)
105
+ const p = usageParse('minimax', 'quota', plan.status, plan.body, io.now())
106
+ if (p.ok || (p.error !== 'malformed' && p.error !== 'none')) return p
107
+ // 旧接口(Token Plan):形状相同,只在新接口认不得时退回
108
+ const legacy = await request(`${host}/v1/token_plan/remains`, headers, io)
109
+ return usageParse('minimax', 'quota', legacy.status, legacy.body, io.now())
110
+ }
111
+
112
+ async function probeDeepseek(_base, key, io) {
113
+ const r = await request('https://api.deepseek.com/user/balance', { Authorization: `Bearer ${key}` }, io)
114
+ return usageParse('deepseek', 'balance', r.status, r.body, io.now())
115
+ }
116
+
117
+ const PROBES = { glm: probeGlm, minimax: probeMinimax, deepseek: probeDeepseek }
118
+
119
+ const keyHash = (key) => crypto.createHash('sha256').update(String(key)).digest('hex').slice(0, 16)
120
+
121
+ // 单个 provider → { label, value } | null。`io`:bin(cfg.agent.bin,登录态 claude 的兜底要用)
122
+ // 与可测注入(fetchImpl / now / timeoutMs)。
123
+ async function providerUsage(provider, io = {}) {
29
124
  const key = provider.apiKey
30
125
  const base = provider.baseUrl || ''
31
126
  // Claude 订阅登录形态(无 baseUrl、非 codex):额度走 oauth/usage(cc-hud 同源通道,
32
- // v0.11.3;凭据取本机 Claude Code 登录态,拿不到/未登录 ⇒ null 不显示)。
127
+ // v0.11.3;凭据取本机 Claude Code 登录态,拿不到/未登录 ⇒ 走 claudeusage 的兜底,仍可能 null)。
33
128
  if (!base && provider.runner !== 'codex') {
34
- const v = await require('./agent/claudeusage').usageLine()
129
+ const v = await require('./agent/claudeusage').usageLine(io.bin ? { bin: io.bin } : {})
35
130
  return v ? { label: '订阅额度', value: v } : null
36
131
  }
37
132
  if (!key) return null
38
133
 
39
134
  const kind = balanceKind(base)
40
- if (kind === 'deepseek') {
41
- const d = await fetchJson('https://api.deepseek.com/user/balance', key)
42
- const t = d && d.balance_infos && d.balance_infos[0] && d.balance_infos[0].total_balance
43
- return t ? { label: '余额', value: `¥${t}` } : null
44
- }
45
- if (kind === 'glm') {
46
- const host = base.includes('api.z.ai') ? 'https://api.z.ai' : 'https://open.bigmodel.cn'
47
- const d = await fetchJson(`${host}/api/biz/account/query-customer-account-report`, key)
48
- if (d && d.code && d.code !== 200) return null
49
- const inner = (d && d.data) || d || {}
50
- const v = inner.balance != null ? inner.balance : inner.total_balance != null ? inner.total_balance : inner.amount
51
- if (v == null) return null
52
- return { label: '余额', value: typeof v === 'number' ? `¥${v.toFixed(2)}` : `¥${String(v).replace(/^¥/, '')}` }
135
+ const probe = PROBES[kind]
136
+ if (!probe) return null // volcano / claude: no public balance API
137
+ const now = typeof io.now === 'function' ? io.now : Date.now
138
+ const ctx = { fetchImpl: io.fetchImpl || globalThis.fetch, timeoutMs: io.timeoutMs || REQUEST_TIMEOUT_MS, now }
139
+ const goodKey = `${kind}|${base}|${keyHash(key)}`
140
+
141
+ const parsed = await probe(base, key, ctx)
142
+ if (parsed.ok) {
143
+ const v = render(parsed, now())
144
+ if (v) LAST_GOOD.set(goodKey, { val: v, ts: now() })
145
+ return v
53
146
  }
54
- if (kind === 'minimax') {
55
- const host = base.includes('minimaxi.com') ? 'https://api.minimaxi.com' : 'https://api.minimax.io'
56
- const d = await fetchJson(`${host}/v1/token_plan/remains`, key)
57
- const m = d && d.model_remains && (d.model_remains.find((x) => x.current_interval_status === 1) || d.model_remains[0])
58
- if (!m) return null
59
- return { label: '额度', value: `5h:${Math.min(100, Math.max(0, Math.round(100 - m.current_interval_remaining_percent)))}%` } // 口径统一为「已用」(v0.11.8);接口给的是剩余 ⇒ 换算
147
+ if (parsed.error === 'credentials') { LAST_GOOD.delete(goodKey); return { ...CREDENTIALS_VALUE } }
148
+ if (parsed.error === 'transient') {
149
+ const last = LAST_GOOD.get(goodKey)
150
+ if (last && now() - last.ts < LAST_GOOD_TTL_MS) return last.val
151
+ LAST_GOOD.delete(goodKey)
60
152
  }
61
- return null // volcano / claude: no public balance API
153
+ return null // malformed / none / stale transient
62
154
  }
63
155
 
64
156
  // 能不能给这个 provider 查额度 —— getUsage 与 refreshUsage 共用的单一判据(两处各写一份
@@ -83,13 +175,13 @@ const hasUsageApi = (p) => !!p && p.runner !== 'codex' && !(p.baseUrl && !p.apiK
83
175
  && (isClaudeLogin(p) || !!balanceKind(p.baseUrl || ''))
84
176
 
85
177
  // Fetch balances for all keyed providers (resolved apiKey). Returns { name: {label,value} }.
86
- async function getUsage(providers = {}) {
178
+ async function getUsage(providers = {}, { bin } = {}) {
87
179
  const out = {}
88
180
  await Promise.all(Object.entries(providers).map(async ([name, p]) => {
89
181
  if (!queryable(p)) return
90
182
  const c = CACHE.get(name)
91
183
  if (c && Date.now() - c.ts < TTL) { out[name] = c.val; return }
92
- const v = await providerUsage(p)
184
+ const v = await providerUsage(p, bin ? { bin } : {})
93
185
  if (v) { CACHE.set(name, { val: v, ts: Date.now() }); out[name] = v }
94
186
  else if (c) out[name] = c.val // stale fallback
95
187
  }))
@@ -100,12 +192,12 @@ async function getUsage(providers = {}) {
100
192
  // 本模块的 5 分钟 CACHE,以及登录态 claude 那条通道自己的 60s TTL(与 /api/claude/verify 同口径)。
101
193
  // ⚠ 拉不到时把上一次的值留在缓存里 —— 手点刷新失败不该让卡片上原本显示着的额度行凭空消失
102
194
  // (旧 ts 保留 ⇒ 它照样算过期,下一次 getUsage 仍会重拉)。probe/now 注入是为了可单测。
103
- async function refreshUsage(name, provider, { probe = providerUsage, now = Date.now } = {}) {
195
+ async function refreshUsage(name, provider, { probe = providerUsage, now = Date.now, bin } = {}) {
104
196
  if (!queryable(provider)) { CACHE.delete(name); return null }
105
197
  const prev = CACHE.get(name)
106
198
  CACHE.delete(name)
107
199
  if (isClaudeLogin(provider)) { try { require('./agent/claudeusage').clearCache() } catch { /* best-effort */ } }
108
- const v = await probe(provider).catch(() => null)
200
+ const v = await probe(provider, bin ? { bin } : {}).catch(() => null)
109
201
  if (v) CACHE.set(name, { val: v, ts: now() })
110
202
  else if (prev) CACHE.set(name, prev)
111
203
  return v
@@ -121,7 +213,8 @@ async function refreshUsage(name, provider, { probe = providerUsage, now = Date.
121
213
  // 而这两句话给群成员的下一步完全相反(一个别再问,一个过会儿再问)。
122
214
  // 缓存:**成功压 60s,失败只压 10s**(与 claudeusage 同口径)—— 失败也压一分钟,等于一次
123
215
  // 偶发超时让整整一分钟查不到;完全不压又让连点 /hud 直捅端点限流。
124
- function createUsageProbe({ codexUsage = async () => null, probe = providerUsage, ttl = 60_000, failTtl = 10_000, now = Date.now } = {}) {
216
+ // `bin` = cfg.agent.bin:登录态 claude 的额度兜底要起它(见 claudeusage),不传就是 PATH 上的 claude。
217
+ function createUsageProbe({ codexUsage = async () => null, probe = providerUsage, ttl = 60_000, failTtl = 10_000, now = Date.now, bin } = {}) {
125
218
  const cache = new Map() // provider name + '|' + baseUrl -> { v, ts }
126
219
  return async (name, provider) => {
127
220
  const isCodex = !!provider && provider.runner === 'codex'
@@ -134,11 +227,11 @@ function createUsageProbe({ codexUsage = async () => null, probe = providerUsage
134
227
  let v = null
135
228
  try {
136
229
  if (isCodex) v = await codexUsage(provider)
137
- else { const u = await probe(provider); v = u ? `${u.label} ${u.value}` : null }
230
+ else { const u = await probe(provider, bin ? { bin } : {}); v = u ? `${u.label} ${u.value}` : null }
138
231
  } catch { v = null }
139
232
  cache.set(key, { v: v || null, ts: now() })
140
233
  return { supported: true, value: v || null }
141
234
  }
142
235
  }
143
236
 
144
- module.exports = { getUsage, providerUsage, refreshUsage, queryable, hasUsageApi, createUsageProbe }
237
+ module.exports = { getUsage, providerUsage, refreshUsage, queryable, hasUsageApi, createUsageProbe, REQUEST_TIMEOUT_MS, LAST_GOOD_TTL_MS }
@@ -1,13 +0,0 @@
1
- 'use strict'
2
-
3
- // Configuration projection shared by chat, cards and the console. A configured
4
- // model is not proof of what an executor ultimately selected or fell back to.
5
- function modelContext(group = {}, provider = {}) {
6
- group ||= {}; provider ||= {}
7
- const environment = provider.runner === 'codex' ? '' : provider.env?.ANTHROPIC_MODEL
8
- const model = group.model || provider.model || environment || ''
9
- return { model, source: group.model ? 'group' : provider.model ? 'provider' : environment ? 'environment' : 'default',
10
- actualModel: null }
11
- }
12
-
13
- module.exports = { modelContext }