sintbot 1.4.16 → 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/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 }