dsh-whale-widget 0.3.0 → 0.3.2
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/PROVENANCE.md +35 -0
- package/README.md +29 -11
- package/assets/DSH2.png +0 -0
- package/assets/whale-widget.js +753 -133
- package/lib/accounting.mjs +187 -0
- package/lib/index.js +248 -207
- package/package.json +3 -2
package/lib/index.js
CHANGED
|
@@ -3,6 +3,11 @@ import os from 'node:os'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import dns from 'node:dns'
|
|
5
5
|
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
import {
|
|
8
|
+
preciseMoney, addMoney, sumMoney, beijingDay, dayOffset,
|
|
9
|
+
observeBalance, balanceSummary, daySummary, accountingDays, reconcileBalance,
|
|
10
|
+
} from './accounting.mjs'
|
|
6
11
|
|
|
7
12
|
// Node 的 fetch(undici) 可能优先 IPv6:个别域名(如 open.bigmodel.cn)有 AAAA 记录但本机 IPv6 不通,
|
|
8
13
|
// 会直接报 "fetch failed"(curl 会自动回退 IPv4,所以看起来正常)。统一改为 IPv4 优先。
|
|
@@ -70,6 +75,11 @@ const API_FILE_CANDIDATES = [
|
|
|
70
75
|
path.join(DSH_HOME, 'profiles', 'web', '.dshw-api.json'),
|
|
71
76
|
]
|
|
72
77
|
const API_BUILTIN_ID = 'deepseek'
|
|
78
|
+
// 「充值 / 余额校正」只属于固定的 DeepSeek(内置):模型条目下发 canAdjustBalance,路由层再校验一次。
|
|
79
|
+
// 新增厂商模板 / 手动新增的同名模型 / Kimi 等其它厂商都不会继承这个能力。
|
|
80
|
+
function canAdjustBuiltinBalance(model) {
|
|
81
|
+
return !!(model && model.id === API_BUILTIN_ID && model.builtin === true && model.provider === 'deepseek')
|
|
82
|
+
}
|
|
73
83
|
// 内置厂商模板:balance 描述「怎么取余额」。json 路径支持 a.b[0].c;scale 为取值后的乘数。
|
|
74
84
|
// 余额接口已按官方文档 / 社区参考核对(DeepSeek、OpenRouter、Kimi、阶跃、Novita、OpenAI 兼容中转站);
|
|
75
85
|
// 硅基流动的余额接口已官方下线(410),故改为「无余额接口 + /v1/models 探活」;
|
|
@@ -179,6 +189,24 @@ const API_TEMPLATES = {
|
|
|
179
189
|
},
|
|
180
190
|
probeUrl: 'https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains',
|
|
181
191
|
},
|
|
192
|
+
// OpenCode Go(订阅):一次返回 rolling / weekly / monthly 三个窗口,值为「已用% + 重置时间」
|
|
193
|
+
// (usage.rolling|weekly|monthly.{percent,resetsAt})。走多窗口路径 json.windows,见 fetchModelQuota。
|
|
194
|
+
// 鉴权只需 Authorization: Bearer <key>(2026-09 实测:x-api-key 单独使用返回 401,无需额外请求头)。
|
|
195
|
+
opencode_go: {
|
|
196
|
+
name: 'OpenCode Go(订阅)', currency: 'USD', keyRef: 'OPENCODE_GO_API_KEY', kind: 'quota',
|
|
197
|
+
quota: {
|
|
198
|
+
url: 'https://opencode.ai/zen/go/v1/usage',
|
|
199
|
+
auth: 'Bearer {key}',
|
|
200
|
+
json: {
|
|
201
|
+
windows: [
|
|
202
|
+
{ key: 'rolling', label: '5h', percent: 'usage.rolling.percent', resetAt: 'usage.rolling.resetsAt' },
|
|
203
|
+
{ key: 'weekly', label: '周', percent: 'usage.weekly.percent', resetAt: 'usage.weekly.resetsAt' },
|
|
204
|
+
{ key: 'monthly', label: '月', percent: 'usage.monthly.percent', resetAt: 'usage.monthly.resetsAt' },
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
probeUrl: 'https://opencode.ai/zen/go/v1/usage',
|
|
209
|
+
},
|
|
182
210
|
openai_compat: {
|
|
183
211
|
name: 'OpenAI 兼容中转站', currency: 'USD', keyRef: 'CUSTOM_API_KEY', needsBaseUrl: true,
|
|
184
212
|
// OneAPI / New API 一类网关的经典账单接口:额度(美元) + 已用(美分)
|
|
@@ -611,13 +639,10 @@ export default {
|
|
|
611
639
|
const input = Number(usage.inputTokens) || 0
|
|
612
640
|
const cache = Number(usage.cacheReadTokens) || 0
|
|
613
641
|
const output = Number(usage.outputTokens) || 0
|
|
614
|
-
const reasoning = Number(usage.reasoningTokens) || 0
|
|
615
642
|
// 口径修正(issue #89 / PR #83):dsh 保证 reasoningTokens ⊆ outputTokens
|
|
616
643
|
// (见 dsh-token-meter 的校验 reasoningTokens > outputTokens 即判非法),
|
|
617
644
|
// 所以 reasoning 不能再单独累加 —— 否则输出侧按输出价被重复计费(实测偏高约一倍)。
|
|
618
|
-
|
|
619
|
-
// 就把差额补回计费输出,避免另一种口径下少算
|
|
620
|
-
const outputBilled = reasoning > output ? output + reasoning : output
|
|
645
|
+
const outputBilled = output // DSH outputTokens already includes reasoningTokens.
|
|
621
646
|
const toks = input + cache + outputBilled
|
|
622
647
|
agg.tokens += toks
|
|
623
648
|
// 定价换算(CNY/百万 token;缓存命中=输入价,其余按各自档位),并按模型拆分
|
|
@@ -632,9 +657,9 @@ export default {
|
|
|
632
657
|
const meta = customPriceMetaFor(model)
|
|
633
658
|
if (meta && meta.cur === 'USD' && Number(meta.rate) > 0) costMsg = costMsg * Number(meta.rate)
|
|
634
659
|
} catch (err) {}
|
|
635
|
-
agg.cost
|
|
660
|
+
agg.cost = addMoney(agg.cost, costMsg)
|
|
636
661
|
if (model) {
|
|
637
|
-
agg.byModel[model] = (agg.byModel[model] || 0
|
|
662
|
+
agg.byModel[model] = addMoney(agg.byModel[model] || 0, costMsg)
|
|
638
663
|
agg.byModelTokens[model] = (agg.byModelTokens[model] || 0) + toks
|
|
639
664
|
}
|
|
640
665
|
agg.lastTs = Date.now()
|
|
@@ -726,12 +751,13 @@ export default {
|
|
|
726
751
|
return { ok: false, code: 'PARSE', error: '余额接口返回不是合法 JSON' }
|
|
727
752
|
}
|
|
728
753
|
const info = pickBalanceInfo(data && data.balance_infos)
|
|
729
|
-
if (!info || info.total_balance === undefined) {
|
|
754
|
+
if (!info || info.total_balance === undefined || !Number.isFinite(Number(info.total_balance))) {
|
|
730
755
|
return { ok: false, code: 'SHAPE', error: '余额接口返回结构异常' }
|
|
731
756
|
}
|
|
732
757
|
return {
|
|
733
758
|
ok: true,
|
|
734
759
|
totalBalance: Number(info.total_balance),
|
|
760
|
+
accountTag: createHash('sha256').update(String(cred.value)).digest('hex').slice(0, 24),
|
|
735
761
|
currency: String(info.currency || 'CNY'),
|
|
736
762
|
updatedAt: new Date().toISOString(),
|
|
737
763
|
}
|
|
@@ -745,27 +771,46 @@ export default {
|
|
|
745
771
|
}
|
|
746
772
|
}
|
|
747
773
|
|
|
748
|
-
function todayKey() {
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
774
|
+
function todayKey() { return beijingDay() }
|
|
775
|
+
// Windows indexers can briefly hold a just-written file. Retry sharing
|
|
776
|
+
// conflicts; never treat an unreadable existing ledger as an empty ledger.
|
|
777
|
+
function ledgerIo(operation) {
|
|
778
|
+
for (let attempt = 0; ; attempt++) {
|
|
779
|
+
try { return operation() } catch (err) {
|
|
780
|
+
if (attempt >= 5 || !['EBUSY', 'EPERM', 'EACCES'].includes(err.code)) throw err
|
|
781
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * (attempt + 1))
|
|
782
|
+
}
|
|
783
|
+
}
|
|
752
784
|
}
|
|
753
785
|
function readUsageLedger() {
|
|
754
786
|
for (const p of USAGE_FILE_CANDIDATES) {
|
|
755
787
|
try {
|
|
756
|
-
const parsed = JSON.parse(fs.readFileSync(p, 'utf8'))
|
|
788
|
+
const parsed = JSON.parse(ledgerIo(() => fs.readFileSync(p, 'utf8')))
|
|
757
789
|
if (parsed && typeof parsed === 'object' && typeof parsed.date === 'string') return parsed
|
|
758
|
-
|
|
790
|
+
throw new Error('账本结构异常,已停止写入以保护原记录')
|
|
791
|
+
} catch (err) { if (err.code !== 'ENOENT') throw err }
|
|
759
792
|
}
|
|
760
793
|
return { date: todayKey(), lastBalance: null, todayUsage: 0, history: {} }
|
|
761
794
|
}
|
|
762
795
|
function writeUsageLedger(led) {
|
|
763
796
|
const body = JSON.stringify(led)
|
|
764
797
|
for (const p of USAGE_FILE_CANDIDATES) {
|
|
798
|
+
const temp = p + '.tmp-' + process.pid
|
|
765
799
|
try {
|
|
766
|
-
fs.
|
|
800
|
+
if (fs.existsSync(p)) {
|
|
801
|
+
const existing = JSON.parse(ledgerIo(() => fs.readFileSync(p, 'utf8')))
|
|
802
|
+
if (!existing.accounting || existing.accounting.version !== 1) {
|
|
803
|
+
try { ledgerIo(() => fs.copyFileSync(p, p + '.before-recharge-fix.bak', fs.constants.COPYFILE_EXCL)) }
|
|
804
|
+
catch (err) { if (err.code !== 'EEXIST') throw err }
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
ledgerIo(() => fs.writeFileSync(temp, body, 'utf8'))
|
|
808
|
+
ledgerIo(() => fs.renameSync(temp, p))
|
|
767
809
|
return true
|
|
768
|
-
} catch (err) {
|
|
810
|
+
} catch (err) {
|
|
811
|
+
try { if (fs.existsSync(temp)) fs.unlinkSync(temp) } catch (cleanupErr) {}
|
|
812
|
+
if (err.code !== 'ENOENT') console.error('[whale-ledger] 账本保存失败:', err.code || err.message)
|
|
813
|
+
}
|
|
769
814
|
}
|
|
770
815
|
return false
|
|
771
816
|
}
|
|
@@ -828,26 +873,18 @@ export default {
|
|
|
828
873
|
return true
|
|
829
874
|
} catch (err) { return false }
|
|
830
875
|
}
|
|
831
|
-
function dayKeyOfDate(d) {
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
}
|
|
835
|
-
function dayKeyFromTs(ts) { return dayKeyOfDate(new Date(Number(ts))) }
|
|
836
|
-
function dayAdd(baseDayStr, delta) {
|
|
837
|
-
const d = new Date(baseDayStr + 'T00:00:00')
|
|
838
|
-
d.setDate(d.getDate() + delta)
|
|
839
|
-
return dayKeyOfDate(d)
|
|
840
|
-
}
|
|
841
|
-
function round2(x) { return Math.round((Number(x) || 0) * 100) / 100 }
|
|
876
|
+
function dayKeyOfDate(d) { return beijingDay(d.getTime()) }
|
|
877
|
+
function dayKeyFromTs(ts) { return beijingDay(Number(ts)) }
|
|
878
|
+
function dayAdd(baseDayStr, delta) { return dayOffset(baseDayStr, delta) }
|
|
842
879
|
// 每轮结算后追加一条用量事件(模型明细/7天/全部记录的来源;上限 8000 条)
|
|
843
880
|
function appendUsageEvent(ev) {
|
|
844
881
|
const led = readUsageLedger()
|
|
845
882
|
led.events = Array.isArray(led.events) ? led.events : []
|
|
846
883
|
led.events.push({
|
|
847
884
|
ts: Number(ev.ts) || Date.now(),
|
|
848
|
-
day: dayKeyFromTs(ev.ts),
|
|
885
|
+
day: dayKeyFromTs(Number(ev.ts) || Date.now()),
|
|
849
886
|
model: String(ev.model || '未知'),
|
|
850
|
-
cost:
|
|
887
|
+
cost: preciseMoney(Number(ev.cost) || 0),
|
|
851
888
|
tokens: Math.round(Number(ev.tokens) || 0),
|
|
852
889
|
})
|
|
853
890
|
// 保留策略:events 90 天 / 最多 2 万条(超期或超量归档到 .dshw-usage-archive.json)
|
|
@@ -858,88 +895,38 @@ export default {
|
|
|
858
895
|
function usageRecordsPayload() {
|
|
859
896
|
const led = readUsageLedger()
|
|
860
897
|
const events = Array.isArray(led.events) ? led.events : []
|
|
861
|
-
const
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
// 当日真实消费 = 零点起始余额 − 当前余额(官方按真实扣款计,事件漏记时用它兜底)
|
|
865
|
-
function realDelta() {
|
|
866
|
-
if (typeof led.dayStart === 'number' && typeof led.lastBalance === 'number') {
|
|
867
|
-
const d = led.dayStart - led.lastBalance
|
|
868
|
-
if (d > 0) return d
|
|
869
|
-
}
|
|
870
|
-
return 0
|
|
871
|
-
}
|
|
872
|
-
// 单日合计:事件合计优先,其次历史归档值;今天以真实余额差为准——
|
|
873
|
-
// 官方账单按真实扣款计,事件计价可能出现系统性偏高(如重复/口径差异),
|
|
874
|
-
// 而余额差(零点余额−当前余额)与官网几乎一致,故今日不再与事件取大。
|
|
875
|
-
function totalDay(d) {
|
|
876
|
-
if (d === today) {
|
|
877
|
-
const dd = realDelta()
|
|
878
|
-
if (dd > 0) return round2(dd)
|
|
879
|
-
// 余额差不可用(充值/退款/无基准)时退回事件/历史
|
|
880
|
-
let v = sumEv(d)
|
|
881
|
-
const h = history[d]
|
|
882
|
-
if (typeof h === 'number' && h > v) v = h
|
|
883
|
-
return round2(v)
|
|
884
|
-
}
|
|
885
|
-
let v = sumEv(d)
|
|
886
|
-
const h = history[d]
|
|
887
|
-
if (typeof h === 'number' && h > v) v = h
|
|
888
|
-
return round2(v)
|
|
889
|
-
}
|
|
890
|
-
function modelsFor(d) {
|
|
891
|
-
const map = {}
|
|
898
|
+
const today = todayKey()
|
|
899
|
+
function modelsFor(day) {
|
|
900
|
+
const map = new Map()
|
|
892
901
|
for (const e of events) {
|
|
893
|
-
if (e.day !==
|
|
894
|
-
const
|
|
895
|
-
map
|
|
896
|
-
}
|
|
897
|
-
const names = Object.keys(map).sort((a, b) => map[b] - map[a])
|
|
898
|
-
const tot = totalDay(d)
|
|
899
|
-
const evSum = names.reduce((s, k) => s + map[k], 0)
|
|
900
|
-
const arr = names.map((k) => ({ model: k, cost: map[k] }))
|
|
901
|
-
// 今天:模型明细需与合计一致——
|
|
902
|
-
// 事件合计 > 余额差(事件偏高)时按比例压缩到合计;
|
|
903
|
-
// 事件合计 < 合计(事件漏记/余额差兜底)时补一行「未入明细」。
|
|
904
|
-
if (d === today && evSum > 0.0001 && evSum > tot + 0.004) {
|
|
905
|
-
const k = tot / evSum
|
|
906
|
-
for (const it of arr) it.cost = round2(it.cost * k)
|
|
907
|
-
const now = arr.reduce((s, it) => s + it.cost, 0)
|
|
908
|
-
const diff = round2(tot - now)
|
|
909
|
-
if (Math.abs(diff) > 0.004) {
|
|
910
|
-
if (diff > 0) arr[0].cost = round2(arr[0].cost + diff)
|
|
911
|
-
else arr[0].cost = round2(Math.max(0, arr[0].cost + diff))
|
|
912
|
-
}
|
|
902
|
+
if (e.day !== day) continue
|
|
903
|
+
const name = String(e.model || '未知')
|
|
904
|
+
map.set(name, addMoney(map.get(name) || 0, Number(e.cost) || 0))
|
|
913
905
|
}
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
906
|
+
return Array.from(map, ([model, cost]) => ({ model, cost, source: 'events', currency: 'CNY' }))
|
|
907
|
+
.sort((a, b) => b.cost - a.cost)
|
|
908
|
+
}
|
|
909
|
+
function forDay(date) {
|
|
910
|
+
const summary = daySummary(led, date)
|
|
911
|
+
const models = modelsFor(date)
|
|
912
|
+
return {
|
|
913
|
+
...summary, date, total: summary.amount, models,
|
|
914
|
+
modelTotal: sumMoney(models.map(m => m.cost)), modelCurrency: 'CNY',
|
|
919
915
|
}
|
|
920
|
-
arr.sort((a, b) => b.cost - a.cost)
|
|
921
|
-
return arr
|
|
922
916
|
}
|
|
923
|
-
const
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
for (
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
total7 += t
|
|
930
|
-
days7.push({ date: d, total: round2(t), models: modelsFor(d) })
|
|
931
|
-
}
|
|
932
|
-
const daySet = {}
|
|
933
|
-
Object.keys(history).forEach((d) => { if (/^\d{4}-\d{2}-\d{2}$/.test(d)) daySet[d] = 1 })
|
|
934
|
-
events.forEach((e) => { if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) daySet[e.day] = 1 })
|
|
935
|
-
const allDays = Object.keys(daySet).sort().reverse().map((d) => ({ date: d, total: totalDay(d), models: modelsFor(d) }))
|
|
936
|
-
const latestEvents = events.slice().sort((a, b) => b.ts - a.ts).slice(0, 500)
|
|
917
|
+
const todayData = forDay(today)
|
|
918
|
+
const days7 = Array.from({ length: 7 }, (_, i) => forDay(dayAdd(today, -i)))
|
|
919
|
+
const total7ByCurrency = {}
|
|
920
|
+
for (const d of days7) total7ByCurrency[d.currency] = addMoney(total7ByCurrency[d.currency] || 0, d.total)
|
|
921
|
+
const days = new Set([today, ...Object.keys(led.history || {}), ...accountingDays(led)])
|
|
922
|
+
for (const e of events) if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) days.add(e.day)
|
|
937
923
|
return {
|
|
938
|
-
ok: true,
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
924
|
+
ok: true, version: '0.3.2', today: todayData, days7,
|
|
925
|
+
total7: total7ByCurrency[todayData.currency] || 0, total7Currency: todayData.currency, total7ByCurrency,
|
|
926
|
+
all: {
|
|
927
|
+
days: Array.from(days).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().map(forDay),
|
|
928
|
+
events: events.slice().sort((a, b) => b.ts - a.ts).slice(0, 500),
|
|
929
|
+
},
|
|
943
930
|
settings: readUsageSettings(),
|
|
944
931
|
}
|
|
945
932
|
}
|
|
@@ -1472,10 +1459,41 @@ export default {
|
|
|
1472
1459
|
if (j.resetAt) resetAt = pickJsonPath(data, j.resetAt)
|
|
1473
1460
|
if (j.resetAtMs) { const ms = num(pickJsonPath(data, j.resetAtMs)); if (ms !== null) resetAt = ms }
|
|
1474
1461
|
if (j.level) level = String(pickJsonPath(data, j.level) || '')
|
|
1475
|
-
|
|
1462
|
+
// v0.3.1:多窗口额度 —— 有些订阅额度接口一次返回多个窗口(如 OpenCode Go 的
|
|
1463
|
+
// rolling / weekly / monthly),每个窗口各有「已用% + 重置时间」。模板用
|
|
1464
|
+
// json.windows: [{ key, label, percent, resetAt }] 描述;这里归一成 windows 数组,
|
|
1465
|
+
// 并把第一个窗口回填成主窗口 usedPct / resetAt,保持原有单窗口链路的兼容。
|
|
1466
|
+
let windows = null
|
|
1467
|
+
if (Array.isArray(j.windows)) {
|
|
1468
|
+
const list = []
|
|
1469
|
+
for (const w of j.windows) {
|
|
1470
|
+
if (!w) continue
|
|
1471
|
+
let wp = w.percent ? num(pickJsonPath(data, w.percent)) : null
|
|
1472
|
+
if (wp !== null) wp = Math.max(0, Math.min(100, wp))
|
|
1473
|
+
let wr = null
|
|
1474
|
+
if (w.resetAt) {
|
|
1475
|
+
const rv = pickJsonPath(data, w.resetAt)
|
|
1476
|
+
if (rv !== undefined && rv !== null && rv !== '') wr = rv
|
|
1477
|
+
}
|
|
1478
|
+
if (wp === null && wr === null) continue
|
|
1479
|
+
list.push({ key: String(w.key || ''), label: String(w.label || ''), usedPct: wp, resetAt: wr })
|
|
1480
|
+
}
|
|
1481
|
+
if (list.length) windows = list
|
|
1482
|
+
}
|
|
1483
|
+
if (windows) {
|
|
1484
|
+
const w0 = windows[0]
|
|
1485
|
+
if (usedPct === null && w0.usedPct !== null) usedPct = w0.usedPct
|
|
1486
|
+
if (!resetAt && w0.resetAt !== null) resetAt = w0.resetAt
|
|
1487
|
+
if (weeklyUsedPct === null) {
|
|
1488
|
+
for (const w of windows) {
|
|
1489
|
+
if ((w.key === 'weekly' || w.label === '周') && w.usedPct !== null) { weeklyUsedPct = w.usedPct; break }
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
if (usedPct === null && remainPct === null && !resetAt && !windows) {
|
|
1476
1494
|
return { ok: false, code: 'PARSE', error: '额度接口返回无法解析(字段路径不匹配)' }
|
|
1477
1495
|
}
|
|
1478
|
-
return { ok: true, usedPct, remainPct, resetAt, level, weeklyUsedPct }
|
|
1496
|
+
return { ok: true, usedPct, remainPct, resetAt, level, weeklyUsedPct, windows }
|
|
1479
1497
|
} catch (err) {
|
|
1480
1498
|
return { ok: false, code: 'HTTP', error: '额度接口请求失败: ' + String((err && err.message) || err).slice(0, 140) }
|
|
1481
1499
|
}
|
|
@@ -1551,7 +1569,7 @@ export default {
|
|
|
1551
1569
|
if (!cur || cur.day !== day) cur = apiUsageNewDay(cur, day, todayKey())
|
|
1552
1570
|
if (typeof remaining === 'number' && isFinite(remaining)) {
|
|
1553
1571
|
if (cur.dayStart === null || cur.dayStart === undefined) cur.dayStart = remaining
|
|
1554
|
-
if (typeof cur.lastBalance === 'number' && remaining < cur.lastBalance
|
|
1572
|
+
if (typeof cur.lastBalance === 'number' && remaining < cur.lastBalance) cur.delta = addMoney(cur.delta, cur.lastBalance - remaining)
|
|
1555
1573
|
cur.lastBalance = remaining
|
|
1556
1574
|
}
|
|
1557
1575
|
reg.usage[id] = cur
|
|
@@ -1568,8 +1586,8 @@ export default {
|
|
|
1568
1586
|
const reg = readApiRegistry()
|
|
1569
1587
|
const cur = reg.usage && reg.usage[id]
|
|
1570
1588
|
if (!cur || cur.day !== todayKey()) return { amount: 0, source: 'none', currency: mcur }
|
|
1571
|
-
if (typeof cur.
|
|
1572
|
-
return { amount:
|
|
1589
|
+
if (typeof cur.lastBalance === 'number' && isFinite(cur.lastBalance)) return { amount: preciseMoney(cur.delta || 0), source: 'balance', currency: mcur }
|
|
1590
|
+
return { amount: preciseMoney(Number(cur.eventCost) || 0), source: 'events', currency: 'CNY' }
|
|
1573
1591
|
} catch (err) { return { amount: 0, source: 'none', currency: mcur } }
|
|
1574
1592
|
}
|
|
1575
1593
|
function apiUsageRaw(id) {
|
|
@@ -1625,7 +1643,7 @@ export default {
|
|
|
1625
1643
|
const day = todayKey()
|
|
1626
1644
|
let cur = reg.usage[hit.id]
|
|
1627
1645
|
if (!cur || cur.day !== day) cur = apiUsageNewDay(cur, day, todayKey())
|
|
1628
|
-
cur.eventCost =
|
|
1646
|
+
cur.eventCost = addMoney(Number(cur.eventCost) || 0, Number(cost) || 0)
|
|
1629
1647
|
cur.eventTokens = (Number(cur.eventTokens) || 0) + (Number(tokens) || 0)
|
|
1630
1648
|
// 累计量:额度(资源包/订阅)按它算已用;跨天不清零
|
|
1631
1649
|
const tk = Number(tokens) || 0
|
|
@@ -1811,6 +1829,7 @@ export default {
|
|
|
1811
1829
|
let entry = {
|
|
1812
1830
|
id: m.id, name: m.name, provider: m.provider, currency: m.currency,
|
|
1813
1831
|
keyRef: m.keyRef, builtin: !!m.builtin, baseUrl: m.baseUrl || '',
|
|
1832
|
+
canAdjustBalance: canAdjustBuiltinBalance(m),
|
|
1814
1833
|
matchIds: m.matchIds || [], settings: settings.models && settings.models[m.id] ? settings.models[m.id] : null,
|
|
1815
1834
|
price: m.price || null,
|
|
1816
1835
|
// 手动额度(订阅/资源包):总量、单位、已用、重置周期;前端据此显示额度模块
|
|
@@ -1838,9 +1857,13 @@ export default {
|
|
|
1838
1857
|
const p = await getBalance()
|
|
1839
1858
|
if (p && p.ok) {
|
|
1840
1859
|
entry.balance = Number(p.totalBalance)
|
|
1841
|
-
|
|
1842
|
-
entry.
|
|
1843
|
-
entry.
|
|
1860
|
+
const summary = daySummary(readUsageLedger(), todayKey())
|
|
1861
|
+
entry.currency = p.currency
|
|
1862
|
+
entry.todayUsage = summary.amount
|
|
1863
|
+
entry.todayUsageCurrency = summary.currency
|
|
1864
|
+
entry.usageSource = summary.source
|
|
1865
|
+
entry.usageLabel = summary.label
|
|
1866
|
+
entry.accounting = balanceSummary(readUsageLedger(), todayKey())
|
|
1844
1867
|
} else if (p) entry.error = p.error || p.code || '余额获取失败'
|
|
1845
1868
|
} catch (err) { entry.error = String((err && err.message) || err) }
|
|
1846
1869
|
} else {
|
|
@@ -1923,107 +1946,55 @@ export default {
|
|
|
1923
1946
|
}
|
|
1924
1947
|
}
|
|
1925
1948
|
|
|
1926
|
-
//
|
|
1927
|
-
//
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
const t = todayKey()
|
|
1932
|
-
let led = readUsageLedger()
|
|
1933
|
-
const events = Array.isArray(led.events) ? led.events : []
|
|
1934
|
-
function sumEv(d) { let s = 0; for (const e of events) if (e.day === d) s += Number(e.cost) || 0; return s }
|
|
1935
|
-
if (led.date !== t) {
|
|
1936
|
-
// 跨天:归档旧日真实合计(优先当日余额差;不可用/为0时退事件与历史),并重置当天基准
|
|
1937
|
-
const oldDay = led.date
|
|
1938
|
-
if (oldDay) {
|
|
1939
|
-
led.history = led.history || {}
|
|
1940
|
-
let oldTotal = 0
|
|
1941
|
-
if (typeof led.dayStart === 'number' && typeof led.lastBalance === 'number' && led.lastBalance < led.dayStart) {
|
|
1942
|
-
oldTotal = Math.max(oldTotal, led.dayStart - led.lastBalance)
|
|
1943
|
-
}
|
|
1944
|
-
oldTotal = Math.max(oldTotal, sumEv(oldDay))
|
|
1945
|
-
if (typeof led.todayUsage === 'number') oldTotal = Math.max(oldTotal, led.todayUsage)
|
|
1946
|
-
led.history[oldDay] = round2(oldTotal)
|
|
1947
|
-
}
|
|
1948
|
-
led.date = t
|
|
1949
|
-
led.dayStart = currentBalance
|
|
1950
|
-
led.todayUsage = 0
|
|
1951
|
-
led.lastBalance = currentBalance
|
|
1952
|
-
} else {
|
|
1953
|
-
// 兼容旧账本:当天尚无 dayStart 时,把已冻结的 lastBalance 视为当天起始余额
|
|
1954
|
-
if (typeof led.dayStart !== 'number' && typeof led.lastBalance === 'number') {
|
|
1955
|
-
led.dayStart = led.lastBalance
|
|
1956
|
-
}
|
|
1957
|
-
led.lastBalance = currentBalance
|
|
1958
|
-
}
|
|
1959
|
-
// 归档/裁剪统一由 pruneLedgerUsage 负责(history 保留 365 天,events 90 天/2 万条)
|
|
1949
|
+
// Each currency/key has its own timed observation window. An increase is
|
|
1950
|
+
// recorded separately and never subtracts previously observed consumption.
|
|
1951
|
+
function recordLedgerUsage(currentBalance, currency, scope, at) {
|
|
1952
|
+
const led = readUsageLedger()
|
|
1953
|
+
observeBalance(led, { balance: currentBalance, currency, scope, at })
|
|
1960
1954
|
pruneLedgerUsage(led)
|
|
1961
|
-
writeUsageLedger(led)
|
|
1955
|
+
if (!writeUsageLedger(led)) throw new Error('账本保存失败,请检查 DSH 数据目录写入权限')
|
|
1962
1956
|
return led
|
|
1963
1957
|
}
|
|
1964
1958
|
function normalizeUsageMode() {
|
|
1965
1959
|
return 'ledger' // 小鲸鱼记账为唯一记账方式
|
|
1966
1960
|
}
|
|
1967
|
-
|
|
1968
|
-
// 官方账单按真实扣款计,事件计价可能出现系统性偏高(14.49 vs 余额差 13.91 vs 官网 13.88),
|
|
1969
|
-
// 余额差与官网几乎一致;事件合计仅在余额差不可用(充值/退款/无基准)时作兜底。
|
|
1970
|
-
function ledgerTodayTotal(led) {
|
|
1971
|
-
const events = Array.isArray(led.events) ? led.events : []
|
|
1972
|
-
const t = dayKeyFromTs(Date.now())
|
|
1973
|
-
let s = 0
|
|
1974
|
-
for (const e of events) if (e.day === t) s += Number(e.cost) || 0
|
|
1975
|
-
if (typeof led.dayStart === 'number' && typeof led.lastBalance === 'number') {
|
|
1976
|
-
const d = led.dayStart - led.lastBalance
|
|
1977
|
-
if (d > 0.004) return round2(d)
|
|
1978
|
-
}
|
|
1979
|
-
return round2(s)
|
|
1980
|
-
}
|
|
1961
|
+
function ledgerTodayTotal(led) { return daySummary(led, todayKey()).amount }
|
|
1981
1962
|
|
|
1982
|
-
|
|
1983
|
-
const
|
|
1984
|
-
if (!payload.ok) {
|
|
1985
|
-
// 余额接口失败(含限流 429)时仍附上今日已用:事件优先,其次账本余额差
|
|
1986
|
-
const led = readUsageLedger()
|
|
1987
|
-
return { ...payload, todayUsage: ledgerTodayTotal(led), usageMode: 'ledger' }
|
|
1988
|
-
}
|
|
1989
|
-
recordLedgerUsage(Number(payload.totalBalance))
|
|
1990
|
-
// 必须在写入后重读账本:否则 todayUsage 会按“上一轮 lastBalance”计算,
|
|
1991
|
-
// 造成泡泡与用量记录(后者直接读文件)数值不一致(如 4.99 vs 5.05)
|
|
1963
|
+
function publicBalance(payload) {
|
|
1964
|
+
const { accountTag, ...visible } = payload
|
|
1992
1965
|
const led = readUsageLedger()
|
|
1993
|
-
const
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1966
|
+
const summary = daySummary(led, todayKey())
|
|
1967
|
+
return {
|
|
1968
|
+
...visible, version: '0.3.2', isPeak: isPeakTime(Math.floor(Date.now() / 1000)),
|
|
1969
|
+
todayUsage: summary.amount, todayUsageCurrency: summary.currency,
|
|
1970
|
+
usageSource: summary.source, usageLabel: summary.label, usageMode: 'ledger',
|
|
1971
|
+
accounting: balanceSummary(led, todayKey()),
|
|
1972
|
+
}
|
|
1998
1973
|
}
|
|
1999
1974
|
|
|
2000
|
-
function getBalance() {
|
|
1975
|
+
function getBalance(force = false) {
|
|
2001
1976
|
const now = Date.now()
|
|
2002
|
-
if (balanceCache && now - balanceCache.at < BALANCE_TTL_MS) {
|
|
2003
|
-
return Promise.resolve(balanceCache.payload)
|
|
1977
|
+
if (!force && balanceCache && now - balanceCache.at < BALANCE_TTL_MS) {
|
|
1978
|
+
return Promise.resolve(publicBalance(balanceCache.payload))
|
|
2004
1979
|
}
|
|
2005
1980
|
if (balanceInFlight) return balanceInFlight
|
|
2006
|
-
balanceInFlight =
|
|
1981
|
+
balanceInFlight = fetchBalance()
|
|
2007
1982
|
.then((payload) => {
|
|
2008
1983
|
if (payload.ok) {
|
|
2009
|
-
|
|
2010
|
-
|
|
1984
|
+
recordLedgerUsage(payload.totalBalance, payload.currency, payload.accountTag, Date.parse(payload.updatedAt))
|
|
1985
|
+
balanceCache = { at: Date.now(), payload }
|
|
1986
|
+
return publicBalance(payload)
|
|
2011
1987
|
}
|
|
2012
1988
|
if (payload.transient && balanceCache) {
|
|
2013
|
-
|
|
2014
|
-
return { ...balanceCache.payload, stale: true, error: payload.error }
|
|
1989
|
+
return { ...publicBalance(balanceCache.payload), stale: true, error: payload.error }
|
|
2015
1990
|
}
|
|
2016
|
-
|
|
2017
|
-
return payload
|
|
1991
|
+
return publicBalance(payload)
|
|
2018
1992
|
})
|
|
2019
1993
|
.catch((err) => ({
|
|
2020
|
-
ok: false,
|
|
2021
|
-
code: 'ERROR',
|
|
1994
|
+
ok: false, code: 'ERROR',
|
|
2022
1995
|
error: '余额服务异常: ' + String((err && err.message) || err).slice(0, 200),
|
|
2023
1996
|
}))
|
|
2024
|
-
.finally(() => {
|
|
2025
|
-
balanceInFlight = null
|
|
2026
|
-
})
|
|
1997
|
+
.finally(() => { balanceInFlight = null })
|
|
2027
1998
|
return balanceInFlight
|
|
2028
1999
|
}
|
|
2029
2000
|
|
|
@@ -2435,7 +2406,8 @@ export default {
|
|
|
2435
2406
|
path: '/dsh-whale/balance.json',
|
|
2436
2407
|
handler: async (req, res) => {
|
|
2437
2408
|
try {
|
|
2438
|
-
const
|
|
2409
|
+
const refresh = new URL(req.url || '/', 'http://localhost').searchParams.get('refresh') === '1'
|
|
2410
|
+
const payload = await getBalance(refresh)
|
|
2439
2411
|
res.writeHead(200, JSON_HEADERS)
|
|
2440
2412
|
res.end(JSON.stringify(payload))
|
|
2441
2413
|
} catch (err) {
|
|
@@ -2472,14 +2444,33 @@ export default {
|
|
|
2472
2444
|
res.end(JSON.stringify({ ok: false, error: 'missing scale' }))
|
|
2473
2445
|
return
|
|
2474
2446
|
}
|
|
2447
|
+
// issue #97:缺字段一律「沿用现有值」,绝不落默认 —— 否则任何不全的 PUT
|
|
2448
|
+
// (旧客户端 / 手写 curl / 加载竞态)都会把用户的设置洗成默认值。
|
|
2449
|
+
// 特别注意 scrollGapPx:读取默认是 17,而写入缺省曾是 0,会静默把避让宽度改掉。
|
|
2450
|
+
const old = readSizeConfig() || {}
|
|
2451
|
+
const pickB = (v, cur, dflt) => (typeof v === 'boolean' ? v : (typeof cur === 'boolean' ? cur : dflt))
|
|
2452
|
+
const pickN = (v, cur, dflt) => (typeof v === 'number' ? v : (typeof cur === 'number' ? cur : dflt))
|
|
2453
|
+
const pickS = (v, cur, dflt) => (typeof v === 'string' && v ? v : (typeof cur === 'string' && cur ? cur : dflt))
|
|
2475
2454
|
// 用量模式变化时让余额缓存失效,下次请求立即按新模式计算
|
|
2476
2455
|
if (typeof parsed.usageMode === 'string') {
|
|
2477
|
-
|
|
2478
|
-
if (!old || normalizeUsageMode(old.usageMode) !== normalizeUsageMode(parsed.usageMode)) {
|
|
2456
|
+
if (normalizeUsageMode(old.usageMode) !== normalizeUsageMode(parsed.usageMode)) {
|
|
2479
2457
|
balanceCache = null
|
|
2480
2458
|
}
|
|
2481
2459
|
}
|
|
2482
|
-
const result = writeSizeConfig(
|
|
2460
|
+
const result = writeSizeConfig(
|
|
2461
|
+
scale,
|
|
2462
|
+
pickB(parsed.sound, old.sound, true),
|
|
2463
|
+
pickN(parsed.vol, old.vol, 0.9),
|
|
2464
|
+
pickS(parsed.soundSet, old.soundSet, 'duck'),
|
|
2465
|
+
typeof parsed.usageMode === 'string' ? parsed.usageMode : old.usageMode,
|
|
2466
|
+
typeof parsed.peakMode === 'string' ? parsed.peakMode : old.peakMode,
|
|
2467
|
+
pickB(parsed.bubbleOn, old.bubbleOn, true),
|
|
2468
|
+
pickB(parsed.turnCostOn, old.turnCostOn, true),
|
|
2469
|
+
pickN(parsed.turnCostCloseMs, old.turnCostCloseMs, 5000),
|
|
2470
|
+
pickB(parsed.scrollGapOn, old.scrollGapOn, false),
|
|
2471
|
+
pickN(parsed.scrollGapPx, old.scrollGapPx, 17),
|
|
2472
|
+
pickB(parsed.menuBtnHide, old.menuBtnHide, false)
|
|
2473
|
+
)
|
|
2483
2474
|
res.writeHead(result.ok ? 200 : 500, JSON_HEADERS)
|
|
2484
2475
|
res.end(JSON.stringify(result))
|
|
2485
2476
|
} catch (err) {
|
|
@@ -2498,16 +2489,8 @@ export default {
|
|
|
2498
2489
|
path: '/dsh-whale/usage-records.json',
|
|
2499
2490
|
handler: async (req, res) => {
|
|
2500
2491
|
try {
|
|
2501
|
-
//
|
|
2502
|
-
//
|
|
2503
|
-
// 缓存太旧(挂件长时间未开)则跳过,避免把过期余额写回账本。
|
|
2504
|
-
try {
|
|
2505
|
-
if (balanceCache && balanceCache.payload && balanceCache.payload.ok &&
|
|
2506
|
-
isFinite(Number(balanceCache.payload.totalBalance)) &&
|
|
2507
|
-
Date.now() - balanceCache.at <= 70000) {
|
|
2508
|
-
recordLedgerUsage(Number(balanceCache.payload.totalBalance))
|
|
2509
|
-
}
|
|
2510
|
-
} catch (err) {}
|
|
2492
|
+
// Reports read the committed ledger. Replaying cached balance samples
|
|
2493
|
+
// here could roll back a correction or create a false midnight baseline.
|
|
2511
2494
|
res.writeHead(200, JSON_HEADERS)
|
|
2512
2495
|
res.end(JSON.stringify(usageRecordsPayload()))
|
|
2513
2496
|
} catch (err) {
|
|
@@ -2516,6 +2499,64 @@ export default {
|
|
|
2516
2499
|
}
|
|
2517
2500
|
},
|
|
2518
2501
|
}))
|
|
2502
|
+
disposers.push(registerRoute({
|
|
2503
|
+
kind: 'exact',
|
|
2504
|
+
path: '/dsh-whale/balance-adjustments.json',
|
|
2505
|
+
handler: async (req, res) => {
|
|
2506
|
+
try {
|
|
2507
|
+
// 先核对身份再碰余额或账本:必须是唯一且明确的 DeepSeek(内置)。
|
|
2508
|
+
// 新增厂商模板不会自动继承这个能力,也拒绝其它模型读写校正。
|
|
2509
|
+
const targetIds = new URL(req.url || '/', 'http://localhost').searchParams.getAll('modelId')
|
|
2510
|
+
const targetModelId = targetIds.length === 1 ? targetIds[0] : null
|
|
2511
|
+
if (targetModelId !== API_BUILTIN_ID || !canAdjustBuiltinBalance(apiModelById(targetModelId))) {
|
|
2512
|
+
const error = new Error('余额校正仅支持 DeepSeek(内置),请从该模型的设置菜单进入')
|
|
2513
|
+
error.status = 403
|
|
2514
|
+
throw error
|
|
2515
|
+
}
|
|
2516
|
+
if (req.method === 'PUT') {
|
|
2517
|
+
const input = JSON.parse(await readBodyMax(req, 8192))
|
|
2518
|
+
if (!input || typeof input !== 'object') throw new Error('校正内容无效')
|
|
2519
|
+
if (input.modelId !== targetModelId) {
|
|
2520
|
+
const error = new Error('校正请求的模型不匹配,仅支持 DeepSeek(内置)')
|
|
2521
|
+
error.status = 403
|
|
2522
|
+
throw error
|
|
2523
|
+
}
|
|
2524
|
+
if (input.day === todayKey()) {
|
|
2525
|
+
const fresh = await getBalance(true)
|
|
2526
|
+
if (!fresh.ok || fresh.stale) {
|
|
2527
|
+
res.writeHead(503, JSON_HEADERS)
|
|
2528
|
+
res.end(JSON.stringify({ ok: false, error: '暂时无法刷新余额,请稍后再保存校正' }))
|
|
2529
|
+
return
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
const led = readUsageLedger()
|
|
2533
|
+
const summary = reconcileBalance(led, input)
|
|
2534
|
+
if (!writeUsageLedger(led)) throw new Error('校正保存失败,请检查 DSH 数据目录写入权限')
|
|
2535
|
+
balanceCache = null
|
|
2536
|
+
res.writeHead(200, JSON_HEADERS)
|
|
2537
|
+
res.end(JSON.stringify({ ok: true, summary }))
|
|
2538
|
+
return
|
|
2539
|
+
}
|
|
2540
|
+
if (req.method !== 'GET' && req.method !== undefined) {
|
|
2541
|
+
res.writeHead(405, { ...JSON_HEADERS, Allow: 'GET, PUT' })
|
|
2542
|
+
res.end(JSON.stringify({ ok: false, error: '不支持的请求方法' }))
|
|
2543
|
+
return
|
|
2544
|
+
}
|
|
2545
|
+
const fresh = await getBalance(true)
|
|
2546
|
+
const led = readUsageLedger()
|
|
2547
|
+
const days = accountingDays(led).sort().reverse().map(day => balanceSummary(led, day))
|
|
2548
|
+
res.writeHead(200, JSON_HEADERS)
|
|
2549
|
+
res.end(JSON.stringify({
|
|
2550
|
+
ok: true, days, today: todayKey(), fresh: !!fresh.ok && !fresh.stale,
|
|
2551
|
+
error: !fresh.ok || fresh.stale ? (fresh.error || '余额暂未刷新') : null,
|
|
2552
|
+
}))
|
|
2553
|
+
} catch (err) {
|
|
2554
|
+
res.writeHead(err.status || 400, JSON_HEADERS)
|
|
2555
|
+
res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err).slice(0, 240) }))
|
|
2556
|
+
}
|
|
2557
|
+
},
|
|
2558
|
+
}))
|
|
2559
|
+
|
|
2519
2560
|
// 用量设置(任务结束音/余额预警/今日预算)
|
|
2520
2561
|
disposers.push(registerRoute({
|
|
2521
2562
|
kind: 'exact',
|