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.
- package/CHANGELOG.en.json +50 -0
- package/CHANGELOG.json +50 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/sintbot-v2.js +104 -4
- package/docs/AI-ASSISTED-INSTALL.md +4 -4
- package/docs/install.md +46 -10
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +4 -4
- package/docs/zh-CN/install.md +46 -10
- package/install.sh +1 -1
- package/package.json +10 -3
- package/src/agent/claudecode.js +5 -2
- package/src/agent/claudemodels.js +79 -8
- package/src/agent/claudeusage.js +188 -3
- package/src/agent/codexapp.js +4 -3
- package/src/agent/codexcode.js +1 -1
- package/src/agent/envconflicts.js +74 -0
- package/src/agent/privatecore.js +65 -4
- package/src/agent/privatecore.wasm +0 -0
- package/src/c2configcontrol.js +29 -18
- package/src/c2console.js +22 -6
- package/src/c2install.js +1 -1
- package/src/c2multigroupservice.js +22 -2
- package/src/connect.js +488 -0
- package/src/core/brain.js +117 -0
- package/src/core/c2inputrejection.js +19 -6
- package/src/core/c2runnerinput.js +7 -2
- package/src/core/modelcmd.js +132 -0
- package/src/core/modelname.js +61 -0
- package/src/core/v2engine.js +304 -47
- package/src/groupbrain.js +135 -0
- package/src/modelcatalog.js +122 -0
- package/src/platform/feishu.js +39 -7
- package/src/platform/modelcard.js +103 -0
- package/src/ui/console.html +273 -147
- package/src/ui/serverbase.js +174 -17
- package/src/usage.js +131 -38
- package/src/modelcontext.js +0 -13
package/src/ui/serverbase.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1029
|
-
|
|
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
|
-
|
|
2343
|
-
|
|
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
|
|
2355
|
-
|
|
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
|
|
4
|
-
//
|
|
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
|
-
|
|
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(),
|
|
34
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs)
|
|
12
35
|
try {
|
|
13
|
-
const r = await
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 (
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
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 //
|
|
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
|
-
|
|
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 }
|
package/src/modelcontext.js
DELETED
|
@@ -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 }
|