dsh-cost-meter 1.5.5
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/LICENSE +21 -0
- package/README.en.md +316 -0
- package/README.md +320 -0
- package/cordis.patch.yml +6 -0
- package/docs/provider-pricing.json +777 -0
- package/lib/backfill.js +405 -0
- package/lib/client.js +4116 -0
- package/lib/coding-plans.js +346 -0
- package/lib/custom-balance.js +147 -0
- package/lib/index.js +975 -0
- package/lib/pricing.js +799 -0
- package/lib/store.js +990 -0
- package/lib/typert.host.js +402 -0
- package/package.json +76 -0
package/lib/store.js
ADDED
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 账本存储:每日聚合、会话聚合、配置持久化($DSH_HOME/storages/cost-meter/ledger.json)。
|
|
3
|
+
*
|
|
4
|
+
* 所有金额字段均为美元;币种换算只发生在展示层。写入采用临时文件 +
|
|
5
|
+
* 原子重命名,并做防抖;账本按 config.historyDays 保留最近 N 天。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_PEAK_EFFECTIVE_AT,
|
|
13
|
+
DEFAULT_PEAK_WINDOWS,
|
|
14
|
+
DEFAULT_PRICE_TABLE,
|
|
15
|
+
DEFAULT_PROVIDER_PRICE_TABLE,
|
|
16
|
+
costOf,
|
|
17
|
+
normalizePrice,
|
|
18
|
+
priceEntryFor,
|
|
19
|
+
providerPriceEntryFor,
|
|
20
|
+
} from './pricing.js'
|
|
21
|
+
|
|
22
|
+
const LEDGER_VERSION = 1
|
|
23
|
+
const MAX_SESSIONS_PER_DAY = 200
|
|
24
|
+
const DEFAULT_HISTORY_DAYS = 180
|
|
25
|
+
|
|
26
|
+
/** 默认配置(首次启动;之后持久化副本优先)。 */
|
|
27
|
+
export function defaultConfig() {
|
|
28
|
+
return {
|
|
29
|
+
locale: 'auto', // 界面语言:auto(跟随浏览器) | zh(中文) | en(English)
|
|
30
|
+
position: 'dock', // 会话费用显示位置:dock(输入区下方) | header(会话标题栏) | off
|
|
31
|
+
sidebar: true, // 侧边栏底部显示当日费用
|
|
32
|
+
currency: 'CNY', // CNY | USD | EUR | custom
|
|
33
|
+
symbol: '¥',
|
|
34
|
+
decimals: 4,
|
|
35
|
+
exchangeRate: 7.2, // 展示层:美元 → 币种汇率
|
|
36
|
+
peakEnabled: true, // 启用峰谷计价
|
|
37
|
+
peakEffectiveAt: DEFAULT_PEAK_EFFECTIVE_AT,
|
|
38
|
+
peakWindows: DEFAULT_PEAK_WINDOWS.map(w => ({ ...w })),
|
|
39
|
+
peakNotice: true, // 峰时高价时段显著提示(侧边栏预算框/今日费用/设置页预算面板)
|
|
40
|
+
peakStyle: 'compact', // 峰谷时段条样式:compact(简洁单行/竖向同构) | classic(经典分段/胶囊芯片)
|
|
41
|
+
priceMatch: 'auto', // 未知模型名自动匹配价格表:auto(去后缀/前缀/家族相似) | exact(仅精确)
|
|
42
|
+
priceOverrides: {}, // 手动匹配覆盖:{ 'provider:modelId': '同provider模型 | provider:模型 | deepseek:__default__' }
|
|
43
|
+
priceTableDisplay: {}, // 费用设置直接显示(按模型):键 'provider:modelId' → 布尔;缺省 = DeepSeek 模型直接显示、第三方收入拓展价格表(含 DeepSeek 模型也可逐模型收入)
|
|
44
|
+
prices: {
|
|
45
|
+
models: Object.fromEntries(
|
|
46
|
+
Object.entries(DEFAULT_PRICE_TABLE.models).map(([id, entry]) => [id, { ...entry }]),
|
|
47
|
+
),
|
|
48
|
+
default: { ...DEFAULT_PRICE_TABLE.default },
|
|
49
|
+
providers: Object.fromEntries(
|
|
50
|
+
Object.entries(DEFAULT_PROVIDER_PRICE_TABLE).map(([provider, table]) => [provider, {
|
|
51
|
+
models: Object.fromEntries(Object.entries(table.models).map(([id, entry]) => [id, { ...entry }])),
|
|
52
|
+
}]),
|
|
53
|
+
),
|
|
54
|
+
},
|
|
55
|
+
budget: {
|
|
56
|
+
enabled: false, // 启用预算
|
|
57
|
+
amount: 100, // 预算额度(按显示币种)
|
|
58
|
+
period: 'month', // day(今日) | month(本月) | all(累计) | custom(自定义区间)
|
|
59
|
+
customStart: null, // custom 周期开始日期(YYYY-MM-DD)
|
|
60
|
+
customEnd: null, // custom 周期结束日期(YYYY-MM-DD,空 = 今日)
|
|
61
|
+
detail: true, // 预算图框详细信息:今日费用与占预算% + 已用/额度行
|
|
62
|
+
},
|
|
63
|
+
codingPlans: {
|
|
64
|
+
// 各家 coding plan 额度查询(默认关闭;开启后按 Key 发现链查询并在设置页展示)。
|
|
65
|
+
anthropic: { enabled: false, display: 'settings', refreshMinutes: 15, apiKey: '' },
|
|
66
|
+
zai: { enabled: false, display: 'settings', refreshMinutes: 15, apiKey: '' },
|
|
67
|
+
minimax: { enabled: false, display: 'settings', refreshMinutes: 15, apiKey: '' },
|
|
68
|
+
},
|
|
69
|
+
balance: {
|
|
70
|
+
display: 'both', // 余额显示位置:sidebar(主页面侧边栏) | settings(设置页) | both | off
|
|
71
|
+
refreshMinutes: 5, // 余额自动刷新间隔(分钟)
|
|
72
|
+
showProgressBar: false, // 全局:侧边栏余额以三段进度条展示(蓝=余额,橙=当日,灰=已用)
|
|
73
|
+
budgetCap: null, // 可选:手动额度上限;留空则优先用 API 返回的 max_budget;仍无则整条蓝色
|
|
74
|
+
reconcile: true, // 余额差交叉校验:官方余额当日变动与本地账本今日合计偏差超阈值时提示
|
|
75
|
+
},
|
|
76
|
+
goQuota: {
|
|
77
|
+
enabled: true, // 启用 OpenCode Go 订阅额度读取与显示(像预算开关一样的总开关)
|
|
78
|
+
display: 'both', // OpenCode Go 订阅额度显示位置:sidebar | settings | both | off
|
|
79
|
+
refreshMinutes: 15, // 额度自动刷新间隔(分钟)
|
|
80
|
+
apiKey: '', // 可选:自定义 API Key;空 = 自动发现(DSH 凭据库 OPENCODE_GO_API_KEY → 环境变量 → opencode auth.json)
|
|
81
|
+
main: 'rolling', // 图框主档位:rolling(滚动5小时) | weekly(本周) | monthly(本月)
|
|
82
|
+
detail: true, // Go 图框详细信息:其余两档行 + 重置时间行
|
|
83
|
+
},
|
|
84
|
+
customBalance: {
|
|
85
|
+
enabled: false,
|
|
86
|
+
label: '',
|
|
87
|
+
labelEn: '',
|
|
88
|
+
display: 'both',
|
|
89
|
+
unit: 'USD',
|
|
90
|
+
refreshMinutes: 15,
|
|
91
|
+
request: {
|
|
92
|
+
url: '',
|
|
93
|
+
method: 'GET',
|
|
94
|
+
headers: {},
|
|
95
|
+
},
|
|
96
|
+
extract: {
|
|
97
|
+
remaining: { op: 'subtract', paths: ['info.max_budget', 'info.spend'] },
|
|
98
|
+
maxBudget: 'info.max_budget',
|
|
99
|
+
spend: 'info.spend',
|
|
100
|
+
unit: 'USD',
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
corner: {
|
|
104
|
+
enabled: false, // 右下角(composer dock)显示 Go 额度 / 预算 chips
|
|
105
|
+
goRolling: true, // 滚动 5 小时额度
|
|
106
|
+
goWeekly: true, // 本周额度
|
|
107
|
+
goMonthly: true, // 本月额度
|
|
108
|
+
budget: true, // 预算已用%
|
|
109
|
+
},
|
|
110
|
+
usage: {
|
|
111
|
+
position: 'cost', // Token 用量统计显示位置:cost(费用设置) | general(通用设置) | section(独立分节)
|
|
112
|
+
},
|
|
113
|
+
historyDays: DEFAULT_HISTORY_DAYS,
|
|
114
|
+
fetchedAt: null, // 最近一次官方价格同步时间(ISO)
|
|
115
|
+
priceSource: 'bundled', // bundled | official
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const CONFIG_KEYS = Object.keys(defaultConfig())
|
|
120
|
+
|
|
121
|
+
/** 本地日期键(宿主机时区)。 */
|
|
122
|
+
export function localDayKey(ms) {
|
|
123
|
+
const d = new Date(ms)
|
|
124
|
+
const pad = n => String(n).padStart(2, '0')
|
|
125
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function zeroDay(date) {
|
|
129
|
+
return { date, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, calls: 0, cost: 0, byProviderModel: {}, sessions: [] }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function zeroSession(id) {
|
|
133
|
+
return { id, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, calls: 0, cost: 0, byProviderModel: {} }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 账本数值清洗:非有限/负数(含历史版本写入的 null)一律归 0,防止污染聚合并击穿 Typert strict codec。 */
|
|
137
|
+
function sanitizeNum(value) {
|
|
138
|
+
const n = Number(value)
|
|
139
|
+
return Number.isFinite(n) && n > 0 ? n : 0
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 归一化一条用量聚合记录(day/session/byProviderModel 条目):桶字段补齐为有限非负数。 */
|
|
143
|
+
function sanitizeBuckets(target) {
|
|
144
|
+
if (target === null || typeof target !== 'object') return null
|
|
145
|
+
for (const key of ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning', 'calls', 'cost']) {
|
|
146
|
+
target[key] = sanitizeNum(target[key])
|
|
147
|
+
}
|
|
148
|
+
return target
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 清洗已持久化的每日记录(加载边界一次性修复):
|
|
153
|
+
* 历史版本曾写入 `reasoning: null` 等非法数值,会导致 Typert strict 状态
|
|
154
|
+
* codec 拒绝整个 getState 结果(账本不可用 / 额度刷新连带失败)。
|
|
155
|
+
*/
|
|
156
|
+
function sanitizeDays(days) {
|
|
157
|
+
for (const day of Object.values(days)) {
|
|
158
|
+
if (sanitizeBuckets(day) === null) continue
|
|
159
|
+
day.byProviderModel = day.byProviderModel !== null && typeof day.byProviderModel === 'object' && !Array.isArray(day.byProviderModel)
|
|
160
|
+
? day.byProviderModel
|
|
161
|
+
: {}
|
|
162
|
+
for (const [key, entry] of Object.entries(day.byProviderModel)) {
|
|
163
|
+
if (sanitizeBuckets(entry) === null) delete day.byProviderModel[key]
|
|
164
|
+
}
|
|
165
|
+
if (!Array.isArray(day.sessions)) {
|
|
166
|
+
day.sessions = []
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
for (const session of day.sessions) {
|
|
170
|
+
if (sanitizeBuckets(session) === null) continue
|
|
171
|
+
session.id = typeof session.id === 'string' ? session.id : ''
|
|
172
|
+
session.byProviderModel = session.byProviderModel !== null && typeof session.byProviderModel === 'object' && !Array.isArray(session.byProviderModel)
|
|
173
|
+
? session.byProviderModel
|
|
174
|
+
: {}
|
|
175
|
+
for (const [key, entry] of Object.entries(session.byProviderModel)) {
|
|
176
|
+
if (sanitizeBuckets(entry) === null) delete session.byProviderModel[key]
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return days
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 深合并两层对象(仅用于配置与价格表补丁)。 */
|
|
184
|
+
function mergeDeep(base, patch) {
|
|
185
|
+
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) return patch === undefined ? base : patch
|
|
186
|
+
const out = { ...base }
|
|
187
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
188
|
+
const current = out[key]
|
|
189
|
+
out[key] = current !== null && typeof current === 'object' && !Array.isArray(current)
|
|
190
|
+
&& value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
191
|
+
? mergeDeep(current, value)
|
|
192
|
+
: value
|
|
193
|
+
}
|
|
194
|
+
return out
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 配置校验错误文案(中/英)。
|
|
199
|
+
*/
|
|
200
|
+
const VALIDATION_MESSAGES = {
|
|
201
|
+
zh: {
|
|
202
|
+
patchObject: '配置补丁必须是对象',
|
|
203
|
+
unknownKey: '未知配置项 "{key}"',
|
|
204
|
+
position: 'position 必须是 dock / header / off',
|
|
205
|
+
sidebar: 'sidebar 必须是布尔值',
|
|
206
|
+
currency: 'currency 非法',
|
|
207
|
+
symbol: 'symbol 非法',
|
|
208
|
+
decimals: 'decimals 必须是 0-10 的整数',
|
|
209
|
+
exchangeRate: 'exchangeRate 必须为正数',
|
|
210
|
+
peakEnabled: 'peakEnabled 必须是布尔值',
|
|
211
|
+
peakEffectiveAt: 'peakEffectiveAt 非法',
|
|
212
|
+
peakWindows: 'peakWindows 必须是数组',
|
|
213
|
+
peakNotice: 'peakNotice 必须是布尔值',
|
|
214
|
+
peakStyle: 'peakStyle 必须是 compact / classic',
|
|
215
|
+
priceMatch: 'priceMatch 必须是 auto / exact',
|
|
216
|
+
priceOverrides: 'priceOverrides 必须是字符串→字符串映射',
|
|
217
|
+
historyDays: 'historyDays 必须是 7-3650 的整数',
|
|
218
|
+
locale: 'locale 必须是 auto / zh / en',
|
|
219
|
+
budget: 'budget 非法',
|
|
220
|
+
budgetEnabled: 'budget.enabled 必须是布尔值',
|
|
221
|
+
budgetAmount: 'budget.amount 必须为非负数',
|
|
222
|
+
budgetPeriod: 'budget.period 必须是 day / month / all / custom',
|
|
223
|
+
budgetDate: 'budget.{field} 必须是 YYYY-MM-DD 日期或 null',
|
|
224
|
+
budgetCustomStart: 'budget 为 custom 周期时必须设置开始日期',
|
|
225
|
+
budgetCustomEnd: 'budget.customEnd 不能早于 customStart',
|
|
226
|
+
budgetDetail: 'budget.detail 必须是布尔值',
|
|
227
|
+
balance: 'balance 非法',
|
|
228
|
+
balanceDisplay: 'balance.display 必须是 sidebar / settings / both / off',
|
|
229
|
+
balanceRefresh: 'balance.refreshMinutes 必须是 1-1440 的整数',
|
|
230
|
+
balanceShowBar: 'balance.showProgressBar 必须是布尔值',
|
|
231
|
+
balanceReconcile: 'balance.reconcile 必须是布尔值',
|
|
232
|
+
balanceBudgetCap: 'balance.budgetCap 必须是非负数或 null',
|
|
233
|
+
goQuota: 'goQuota 非法',
|
|
234
|
+
customBalance: 'customBalance 非法',
|
|
235
|
+
customBalanceEnabled: 'customBalance.enabled 必须是布尔值',
|
|
236
|
+
customBalanceDisplay: 'customBalance.display 必须是 sidebar / settings / both / off',
|
|
237
|
+
customBalanceRefresh: 'customBalance.refreshMinutes 必须是 1-1440 的整数',
|
|
238
|
+
customBalanceLabel: 'customBalance.label 必须是字符串',
|
|
239
|
+
customBalanceLabelEn: 'customBalance.labelEn 必须是字符串',
|
|
240
|
+
customBalanceUnit: 'customBalance.unit 必须是 USD / CNY / EUR',
|
|
241
|
+
customBalanceRequest: 'customBalance.request.url 必须是非空字符串',
|
|
242
|
+
customBalanceHeaders: 'customBalance.request.headers 必须是字符串→字符串映射',
|
|
243
|
+
customBalanceExtract: 'customBalance.extract 必须是对象',
|
|
244
|
+
goQuotaEnabled: 'goQuota.enabled 必须是布尔值',
|
|
245
|
+
goQuotaDisplay: 'goQuota.display 必须是 sidebar / settings / both / off',
|
|
246
|
+
goQuotaRefresh: 'goQuota.refreshMinutes 必须是 1-1440 的整数',
|
|
247
|
+
goQuotaKey: 'goQuota.apiKey 必须是字符串',
|
|
248
|
+
goQuotaMain: 'goQuota.main 必须是 rolling / weekly / monthly',
|
|
249
|
+
goQuotaDetail: 'goQuota.detail 必须是布尔值',
|
|
250
|
+
corner: 'corner 非法',
|
|
251
|
+
cornerEnabled: 'corner.enabled 必须是布尔值',
|
|
252
|
+
cornerFlag: 'corner.{field} 必须是布尔值',
|
|
253
|
+
usage: 'usage 非法',
|
|
254
|
+
usagePosition: 'usage.position 必须是 cost / general / section',
|
|
255
|
+
prices: 'prices 非法',
|
|
256
|
+
pricesModels: 'prices.models 非法',
|
|
257
|
+
pricesProviders: 'prices.providers 非法',
|
|
258
|
+
modelPrice: '模型 "{id}" 的价格非法',
|
|
259
|
+
pricesDefault: 'prices.default 非法',
|
|
260
|
+
},
|
|
261
|
+
en: {
|
|
262
|
+
patchObject: 'Config patch must be an object',
|
|
263
|
+
unknownKey: 'Unknown config key "{key}"',
|
|
264
|
+
position: 'position must be dock / header / off',
|
|
265
|
+
sidebar: 'sidebar must be a boolean',
|
|
266
|
+
currency: 'Invalid currency',
|
|
267
|
+
symbol: 'Invalid symbol',
|
|
268
|
+
decimals: 'decimals must be an integer from 0 to 10',
|
|
269
|
+
exchangeRate: 'exchangeRate must be a positive number',
|
|
270
|
+
peakEnabled: 'peakEnabled must be a boolean',
|
|
271
|
+
peakEffectiveAt: 'Invalid peakEffectiveAt',
|
|
272
|
+
peakWindows: 'peakWindows must be an array',
|
|
273
|
+
peakNotice: 'peakNotice must be a boolean',
|
|
274
|
+
peakStyle: 'peakStyle must be compact / classic',
|
|
275
|
+
priceMatch: 'priceMatch must be auto / exact',
|
|
276
|
+
priceOverrides: 'priceOverrides must be a string→string map',
|
|
277
|
+
historyDays: 'historyDays must be an integer from 7 to 3650',
|
|
278
|
+
locale: 'locale must be auto / zh / en',
|
|
279
|
+
budget: 'Invalid budget',
|
|
280
|
+
budgetEnabled: 'budget.enabled must be a boolean',
|
|
281
|
+
budgetAmount: 'budget.amount must be a non-negative number',
|
|
282
|
+
budgetPeriod: 'budget.period must be day / month / all / custom',
|
|
283
|
+
budgetDate: 'budget.{field} must be a YYYY-MM-DD date or null',
|
|
284
|
+
budgetCustomStart: 'budget.customStart is required for the custom period',
|
|
285
|
+
budgetCustomEnd: 'budget.customEnd cannot be earlier than customStart',
|
|
286
|
+
budgetDetail: 'budget.detail must be a boolean',
|
|
287
|
+
balance: 'Invalid balance',
|
|
288
|
+
balanceDisplay: 'balance.display must be sidebar / settings / both / off',
|
|
289
|
+
balanceRefresh: 'balance.refreshMinutes must be an integer from 1 to 1440',
|
|
290
|
+
balanceShowBar: 'balance.showProgressBar must be a boolean',
|
|
291
|
+
balanceReconcile: 'balance.reconcile must be a boolean',
|
|
292
|
+
balanceBudgetCap: 'balance.budgetCap must be a non-negative number or null',
|
|
293
|
+
goQuota: 'Invalid goQuota',
|
|
294
|
+
customBalance: 'Invalid customBalance',
|
|
295
|
+
customBalanceEnabled: 'customBalance.enabled must be a boolean',
|
|
296
|
+
customBalanceDisplay: 'customBalance.display must be sidebar / settings / both / off',
|
|
297
|
+
customBalanceRefresh: 'customBalance.refreshMinutes must be an integer from 1 to 1440',
|
|
298
|
+
customBalanceLabel: 'customBalance.label must be a string',
|
|
299
|
+
customBalanceLabelEn: 'customBalance.labelEn must be a string',
|
|
300
|
+
customBalanceUnit: 'customBalance.unit must be USD / CNY / EUR',
|
|
301
|
+
customBalanceRequest: 'customBalance.request.url must be a non-empty string',
|
|
302
|
+
customBalanceHeaders: 'customBalance.request.headers must be a string→string map',
|
|
303
|
+
customBalanceExtract: 'customBalance.extract must be an object',
|
|
304
|
+
goQuotaEnabled: 'goQuota.enabled must be a boolean',
|
|
305
|
+
goQuotaDisplay: 'goQuota.display must be sidebar / settings / both / off',
|
|
306
|
+
goQuotaRefresh: 'goQuota.refreshMinutes must be an integer from 1 to 1440',
|
|
307
|
+
goQuotaKey: 'goQuota.apiKey must be a string',
|
|
308
|
+
goQuotaMain: 'goQuota.main must be rolling / weekly / monthly',
|
|
309
|
+
goQuotaDetail: 'goQuota.detail must be a boolean',
|
|
310
|
+
corner: 'Invalid corner',
|
|
311
|
+
cornerEnabled: 'corner.enabled must be a boolean',
|
|
312
|
+
cornerFlag: 'corner.{field} must be a boolean',
|
|
313
|
+
usage: 'Invalid usage',
|
|
314
|
+
usagePosition: 'usage.position must be cost / general / section',
|
|
315
|
+
prices: 'Invalid prices',
|
|
316
|
+
pricesModels: 'Invalid prices.models',
|
|
317
|
+
pricesProviders: 'Invalid prices.providers',
|
|
318
|
+
modelPrice: 'Invalid price for model "{id}"',
|
|
319
|
+
pricesDefault: 'Invalid prices.default',
|
|
320
|
+
},
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** 取校验文案(zh/en)。 */
|
|
324
|
+
function vmsg(locale, code, vars) {
|
|
325
|
+
const dict = locale === 'en' ? VALIDATION_MESSAGES.en : VALIDATION_MESSAGES.zh
|
|
326
|
+
let text = dict[code] ?? code
|
|
327
|
+
if (vars) for (const key of Object.keys(vars)) text = text.split(`{${key}}`).join(String(vars[key]))
|
|
328
|
+
return text
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** 校验文案语言:补丁内显式指定优先,否则沿用当前配置。 */
|
|
332
|
+
function patchLocale(current, patch) {
|
|
333
|
+
if (patch !== null && typeof patch === 'object' && (patch.locale === 'zh' || patch.locale === 'en')) return patch.locale
|
|
334
|
+
return current?.locale === 'en' ? 'en' : 'zh'
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* 校验并应用一份配置补丁,返回 { config, errors }。
|
|
339
|
+
* 未知键、非法值都会报错且整体不落盘;合法补丁深合并后持久化。
|
|
340
|
+
* @param current - 当前配置。
|
|
341
|
+
* @param patch - 客户端提交的补丁(JSON)。
|
|
342
|
+
*/
|
|
343
|
+
export function applyConfigPatch(current, patch) {
|
|
344
|
+
const locale = patchLocale(current, patch)
|
|
345
|
+
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
346
|
+
return { config: current, errors: [vmsg(locale, 'patchObject')] }
|
|
347
|
+
}
|
|
348
|
+
const errors = []
|
|
349
|
+
for (const key of Object.keys(patch)) {
|
|
350
|
+
if (!CONFIG_KEYS.includes(key)) errors.push(vmsg(locale, 'unknownKey', { key }))
|
|
351
|
+
}
|
|
352
|
+
if (errors.length > 0) return { config: current, errors }
|
|
353
|
+
const candidate = mergeDeep(current, patch)
|
|
354
|
+
// prices.models 是可编辑列表:客户端提交完整列表时必须按替换语义处理,
|
|
355
|
+
// 否则 mergeDeep 会把已删除的旧模型重新合并回来。
|
|
356
|
+
if (patch.prices !== null && typeof patch.prices === 'object' && !Array.isArray(patch.prices)
|
|
357
|
+
&& patch.prices.models !== null && typeof patch.prices.models === 'object' && !Array.isArray(patch.prices.models)) {
|
|
358
|
+
candidate.prices.models = patch.prices.models
|
|
359
|
+
}
|
|
360
|
+
// 逐项校验。
|
|
361
|
+
if (!['auto', 'zh', 'en'].includes(candidate.locale)) errors.push(vmsg(locale, 'locale'))
|
|
362
|
+
if (candidate.codingPlans === null || typeof candidate.codingPlans !== 'object' || Array.isArray(candidate.codingPlans)) {
|
|
363
|
+
candidate.codingPlans = {}
|
|
364
|
+
}
|
|
365
|
+
// codingPlans 逐项清洗:只保留已知提供商;字段非法则回退默认值(凭据只发往各家官方端点)。
|
|
366
|
+
for (const [id, raw] of Object.entries(candidate.codingPlans)) {
|
|
367
|
+
if (!['anthropic', 'zai', 'minimax'].includes(id) || raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
368
|
+
delete candidate.codingPlans[id]
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
const entry = raw
|
|
372
|
+
entry.enabled = entry.enabled === true
|
|
373
|
+
entry.display = ['sidebar', 'settings', 'both', 'off'].includes(entry.display) ? entry.display : 'settings'
|
|
374
|
+
const minutes = Number(entry.refreshMinutes)
|
|
375
|
+
entry.refreshMinutes = Number.isFinite(minutes) && minutes >= 1 && minutes <= 1440 ? minutes : 15
|
|
376
|
+
entry.apiKey = typeof entry.apiKey === 'string' ? entry.apiKey : ''
|
|
377
|
+
}
|
|
378
|
+
if (!['dock', 'header', 'off'].includes(candidate.position)) errors.push(vmsg(locale, 'position'))
|
|
379
|
+
if (typeof candidate.sidebar !== 'boolean') errors.push(vmsg(locale, 'sidebar'))
|
|
380
|
+
if (typeof candidate.currency !== 'string' || candidate.currency.length === 0) errors.push(vmsg(locale, 'currency'))
|
|
381
|
+
if (typeof candidate.symbol !== 'string') errors.push(vmsg(locale, 'symbol'))
|
|
382
|
+
const decimals = Number(candidate.decimals)
|
|
383
|
+
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 10) errors.push(vmsg(locale, 'decimals'))
|
|
384
|
+
const rate = Number(candidate.exchangeRate)
|
|
385
|
+
if (!Number.isFinite(rate) || rate <= 0) errors.push(vmsg(locale, 'exchangeRate'))
|
|
386
|
+
if (typeof candidate.peakEnabled !== 'boolean') errors.push(vmsg(locale, 'peakEnabled'))
|
|
387
|
+
if (typeof candidate.peakEffectiveAt !== 'string') errors.push(vmsg(locale, 'peakEffectiveAt'))
|
|
388
|
+
if (!Array.isArray(candidate.peakWindows)) errors.push(vmsg(locale, 'peakWindows'))
|
|
389
|
+
if (typeof candidate.peakNotice !== 'boolean') errors.push(vmsg(locale, 'peakNotice'))
|
|
390
|
+
if (candidate.peakStyle !== 'compact' && candidate.peakStyle !== 'classic') errors.push(vmsg(locale, 'peakStyle'))
|
|
391
|
+
if (candidate.priceMatch !== 'auto' && candidate.priceMatch !== 'exact') errors.push(vmsg(locale, 'priceMatch'))
|
|
392
|
+
const overrides = candidate.priceOverrides
|
|
393
|
+
if (overrides === null || typeof overrides !== 'object' || Array.isArray(overrides)
|
|
394
|
+
|| Object.entries(overrides).some(([k, v]) => typeof k !== 'string' || typeof v !== 'string')) {
|
|
395
|
+
errors.push(vmsg(locale, 'priceOverrides'))
|
|
396
|
+
}
|
|
397
|
+
// priceTableDisplay:'provider:modelId' → 布尔;纯展示开关不影响挂载与计费,非法值定向收敛不报错。
|
|
398
|
+
const tableDisplay = candidate.priceTableDisplay
|
|
399
|
+
if (tableDisplay === null || typeof tableDisplay !== 'object' || Array.isArray(tableDisplay)) {
|
|
400
|
+
candidate.priceTableDisplay = {}
|
|
401
|
+
} else {
|
|
402
|
+
for (const [provider, value] of Object.entries(tableDisplay)) tableDisplay[provider] = value === true
|
|
403
|
+
}
|
|
404
|
+
const historyDays = Number(candidate.historyDays)
|
|
405
|
+
if (!Number.isInteger(historyDays) || historyDays < 7 || historyDays > 3650) errors.push(vmsg(locale, 'historyDays'))
|
|
406
|
+
// 预算校验。
|
|
407
|
+
const budget = candidate.budget
|
|
408
|
+
if (budget === null || typeof budget !== 'object' || Array.isArray(budget)) {
|
|
409
|
+
errors.push(vmsg(locale, 'budget'))
|
|
410
|
+
} else {
|
|
411
|
+
if (typeof budget.enabled !== 'boolean') errors.push(vmsg(locale, 'budgetEnabled'))
|
|
412
|
+
if (typeof budget.detail !== 'boolean') errors.push(vmsg(locale, 'budgetDetail'))
|
|
413
|
+
const amount = Number(budget.amount)
|
|
414
|
+
if (!Number.isFinite(amount) || amount < 0) errors.push(vmsg(locale, 'budgetAmount'))
|
|
415
|
+
else budget.amount = amount
|
|
416
|
+
if (!['day', 'month', 'all', 'custom'].includes(budget.period)) errors.push(vmsg(locale, 'budgetPeriod'))
|
|
417
|
+
const dateKey = /^\d{4}-\d{2}-\d{2}$/
|
|
418
|
+
for (const field of ['customStart', 'customEnd']) {
|
|
419
|
+
const value = budget[field]
|
|
420
|
+
if (value !== null && (typeof value !== 'string' || !dateKey.test(value))) {
|
|
421
|
+
errors.push(vmsg(locale, 'budgetDate', { field }))
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (budget.period === 'custom') {
|
|
425
|
+
if (budget.customStart === null || typeof budget.customStart !== 'string') {
|
|
426
|
+
errors.push(vmsg(locale, 'budgetCustomStart'))
|
|
427
|
+
} else if (typeof budget.customEnd === 'string' && budget.customEnd < budget.customStart) {
|
|
428
|
+
errors.push(vmsg(locale, 'budgetCustomEnd'))
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// 余额显示校验。
|
|
433
|
+
const balance = candidate.balance
|
|
434
|
+
if (balance === null || typeof balance !== 'object' || Array.isArray(balance)) {
|
|
435
|
+
errors.push(vmsg(locale, 'balance'))
|
|
436
|
+
} else {
|
|
437
|
+
if (!['sidebar', 'settings', 'both', 'off'].includes(balance.display)) errors.push(vmsg(locale, 'balanceDisplay'))
|
|
438
|
+
const refreshMinutes = Number(balance.refreshMinutes)
|
|
439
|
+
if (!Number.isInteger(refreshMinutes) || refreshMinutes < 1 || refreshMinutes > 1440) errors.push(vmsg(locale, 'balanceRefresh'))
|
|
440
|
+
else balance.refreshMinutes = refreshMinutes
|
|
441
|
+
if (balance.showProgressBar !== undefined && typeof balance.showProgressBar !== 'boolean') errors.push(vmsg(locale, 'balanceShowBar'))
|
|
442
|
+
if (balance.reconcile !== undefined && typeof balance.reconcile !== 'boolean') errors.push(vmsg(locale, 'balanceReconcile'))
|
|
443
|
+
if (balance.budgetCap !== undefined && balance.budgetCap !== null) {
|
|
444
|
+
const cap = Number(balance.budgetCap)
|
|
445
|
+
if (!Number.isFinite(cap) || cap < 0) errors.push(vmsg(locale, 'balanceBudgetCap'))
|
|
446
|
+
else balance.budgetCap = cap > 0 ? cap : null
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// 自定义 Provider 余额校验。
|
|
450
|
+
const customBalance = candidate.customBalance
|
|
451
|
+
if (customBalance !== undefined) {
|
|
452
|
+
if (customBalance === null || typeof customBalance !== 'object' || Array.isArray(customBalance)) {
|
|
453
|
+
errors.push(vmsg(locale, 'customBalance'))
|
|
454
|
+
} else {
|
|
455
|
+
if (typeof customBalance.enabled !== 'boolean') errors.push(vmsg(locale, 'customBalanceEnabled'))
|
|
456
|
+
if (!['sidebar', 'settings', 'both', 'off'].includes(customBalance.display)) errors.push(vmsg(locale, 'customBalanceDisplay'))
|
|
457
|
+
const refreshMinutes = Number(customBalance.refreshMinutes)
|
|
458
|
+
if (!Number.isInteger(refreshMinutes) || refreshMinutes < 1 || refreshMinutes > 1440) errors.push(vmsg(locale, 'customBalanceRefresh'))
|
|
459
|
+
else customBalance.refreshMinutes = refreshMinutes
|
|
460
|
+
if (typeof customBalance.label !== 'string') errors.push(vmsg(locale, 'customBalanceLabel'))
|
|
461
|
+
if (customBalance.labelEn !== undefined && typeof customBalance.labelEn !== 'string') errors.push(vmsg(locale, 'customBalanceLabelEn'))
|
|
462
|
+
if (customBalance.unit !== undefined && !['USD', 'CNY', 'EUR'].includes(customBalance.unit)) errors.push(vmsg(locale, 'customBalanceUnit'))
|
|
463
|
+
const request = customBalance.request
|
|
464
|
+
// url 仅在启用时必填:默认禁用状态下不能阻断其它配置项的保存。
|
|
465
|
+
if (request === null || typeof request !== 'object' || Array.isArray(request) || typeof request.url !== 'string' || (customBalance.enabled === true && request.url.length === 0)) {
|
|
466
|
+
errors.push(vmsg(locale, 'customBalanceRequest'))
|
|
467
|
+
} else if (request.headers !== undefined && (request.headers === null || typeof request.headers !== 'object' || Array.isArray(request.headers)
|
|
468
|
+
|| Object.values(request.headers).some(value => typeof value !== 'string'))) {
|
|
469
|
+
// 值非字符串会击穿 typert strict configSchema(z.record(string, string)),导致整个 getState 被拒。
|
|
470
|
+
errors.push(vmsg(locale, 'customBalanceHeaders'))
|
|
471
|
+
}
|
|
472
|
+
const extract = customBalance.extract
|
|
473
|
+
if (extract === null || typeof extract !== 'object' || Array.isArray(extract)) errors.push(vmsg(locale, 'customBalanceExtract'))
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// OpenCode Go 订阅额度显示校验。
|
|
477
|
+
const goQuota = candidate.goQuota
|
|
478
|
+
if (goQuota === null || typeof goQuota !== 'object' || Array.isArray(goQuota)) {
|
|
479
|
+
errors.push(vmsg(locale, 'goQuota'))
|
|
480
|
+
} else {
|
|
481
|
+
if (typeof goQuota.enabled !== 'boolean') errors.push(vmsg(locale, 'goQuotaEnabled'))
|
|
482
|
+
if (!['sidebar', 'settings', 'both', 'off'].includes(goQuota.display)) errors.push(vmsg(locale, 'goQuotaDisplay'))
|
|
483
|
+
const refreshMinutes = Number(goQuota.refreshMinutes)
|
|
484
|
+
if (!Number.isInteger(refreshMinutes) || refreshMinutes < 1 || refreshMinutes > 1440) errors.push(vmsg(locale, 'goQuotaRefresh'))
|
|
485
|
+
else goQuota.refreshMinutes = refreshMinutes
|
|
486
|
+
if (typeof goQuota.apiKey !== 'string') errors.push(vmsg(locale, 'goQuotaKey'))
|
|
487
|
+
if (!['rolling', 'weekly', 'monthly'].includes(goQuota.main)) errors.push(vmsg(locale, 'goQuotaMain'))
|
|
488
|
+
if (typeof goQuota.detail !== 'boolean') errors.push(vmsg(locale, 'goQuotaDetail'))
|
|
489
|
+
}
|
|
490
|
+
// 右下角(dock)显示校验。
|
|
491
|
+
const corner = candidate.corner
|
|
492
|
+
if (corner === null || typeof corner !== 'object' || Array.isArray(corner)) {
|
|
493
|
+
errors.push(vmsg(locale, 'corner'))
|
|
494
|
+
} else {
|
|
495
|
+
if (typeof corner.enabled !== 'boolean') errors.push(vmsg(locale, 'cornerEnabled'))
|
|
496
|
+
for (const field of ['goRolling', 'goWeekly', 'goMonthly', 'budget']) {
|
|
497
|
+
if (typeof corner[field] !== 'boolean') errors.push(vmsg(locale, 'cornerFlag', { field }))
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
// Token 用量统计显示位置校验。
|
|
501
|
+
const usage = candidate.usage
|
|
502
|
+
if (usage === null || typeof usage !== 'object' || Array.isArray(usage)) {
|
|
503
|
+
errors.push(vmsg(locale, 'usage'))
|
|
504
|
+
} else {
|
|
505
|
+
if (!['cost', 'general', 'section'].includes(usage.position)) errors.push(vmsg(locale, 'usagePosition'))
|
|
506
|
+
}
|
|
507
|
+
// 价格表规范化。
|
|
508
|
+
const prices = candidate.prices
|
|
509
|
+
if (prices === null || typeof prices !== 'object') {
|
|
510
|
+
errors.push(vmsg(locale, 'prices'))
|
|
511
|
+
} else {
|
|
512
|
+
if (prices.models === null || typeof prices.models !== 'object' || Array.isArray(prices.models)) {
|
|
513
|
+
errors.push(vmsg(locale, 'pricesModels'))
|
|
514
|
+
} else {
|
|
515
|
+
for (const [id, raw] of Object.entries(prices.models)) {
|
|
516
|
+
const entry = normalizePrice(raw)
|
|
517
|
+
if (entry === null) errors.push(vmsg(locale, 'modelPrice', { id }))
|
|
518
|
+
else prices.models[id] = entry
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const def = normalizePrice(prices.default)
|
|
522
|
+
if (def === null) errors.push(vmsg(locale, 'pricesDefault'))
|
|
523
|
+
else prices.default = def
|
|
524
|
+
if (prices.providers !== undefined) {
|
|
525
|
+
if (prices.providers === null || typeof prices.providers !== 'object' || Array.isArray(prices.providers)) {
|
|
526
|
+
errors.push(vmsg(locale, 'pricesProviders'))
|
|
527
|
+
} else {
|
|
528
|
+
for (const [provider, providerTable] of Object.entries(prices.providers)) {
|
|
529
|
+
if (providerTable === null || typeof providerTable !== 'object' || Array.isArray(providerTable)
|
|
530
|
+
|| providerTable.models === null || typeof providerTable.models !== 'object' || Array.isArray(providerTable.models)) {
|
|
531
|
+
errors.push(vmsg(locale, 'pricesProviders'))
|
|
532
|
+
continue
|
|
533
|
+
}
|
|
534
|
+
for (const [id, raw] of Object.entries(providerTable.models)) {
|
|
535
|
+
if (normalizePrice(raw) === null) errors.push(vmsg(locale, 'modelPrice', { id: `${provider}:${id}` }))
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (errors.length > 0) return { config: current, errors }
|
|
542
|
+
return { config: candidate, errors: [] }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* 加载边界配置清洗:历史/手改账本可能含非法类型值,若直接下发会击穿
|
|
547
|
+
* strict codec 导致整个 getState 被拒(「账本不可用」)。按默认值的类型
|
|
548
|
+
* 逐项回落,枚举/嵌套对象做定向收敛;清洗后的配置随下次落盘覆盖。
|
|
549
|
+
*/
|
|
550
|
+
export function sanitizeConfig(raw) {
|
|
551
|
+
const base = defaultConfig()
|
|
552
|
+
const cfg = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
|
|
553
|
+
const out = mergeDeep(base, cfg)
|
|
554
|
+
const isNum = v => typeof v === 'number' && Number.isFinite(v)
|
|
555
|
+
const oneOf = (v, list, fallback) => (typeof v === 'string' && list.includes(v) ? v : fallback)
|
|
556
|
+
// 顶层标量:类型不符回落默认。
|
|
557
|
+
for (const [key, def] of Object.entries(base)) {
|
|
558
|
+
const v = out[key]
|
|
559
|
+
const t = typeof def
|
|
560
|
+
if (t === 'boolean' && typeof v !== 'boolean') out[key] = def
|
|
561
|
+
else if (t === 'number' && !isNum(v)) out[key] = def
|
|
562
|
+
else if (t === 'string' && typeof v !== 'string') out[key] = def
|
|
563
|
+
else if (t === 'object' && def !== null && (v === null || typeof v !== 'object' || Array.isArray(v) !== Array.isArray(def))) out[key] = def
|
|
564
|
+
}
|
|
565
|
+
// 枚举收敛。
|
|
566
|
+
out.locale = oneOf(out.locale, ['auto', 'zh', 'en'], 'auto')
|
|
567
|
+
out.position = oneOf(out.position, ['dock', 'header', 'off'], 'dock')
|
|
568
|
+
out.peakStyle = oneOf(out.peakStyle, ['compact', 'classic'], 'compact')
|
|
569
|
+
out.priceMatch = oneOf(out.priceMatch, ['auto', 'exact'], 'auto')
|
|
570
|
+
out.decimals = Math.max(0, Math.min(10, Math.floor(Number(out.decimals) || 0)))
|
|
571
|
+
out.historyDays = Math.max(7, Math.min(3650, Math.floor(Number(out.historyDays) || 180)))
|
|
572
|
+
if (!isNum(out.exchangeRate) || out.exchangeRate <= 0) out.exchangeRate = base.exchangeRate
|
|
573
|
+
if (!Array.isArray(out.peakWindows)) out.peakWindows = base.peakWindows
|
|
574
|
+
else out.peakWindows = out.peakWindows.filter(w => w !== null && typeof w === 'object' && isNum(Number(w.start)) && isNum(Number(w.end)))
|
|
575
|
+
// priceOverrides:仅保留字符串→字符串。
|
|
576
|
+
const overrides = {}
|
|
577
|
+
if (out.priceOverrides !== null && typeof out.priceOverrides === 'object') {
|
|
578
|
+
for (const [k, v] of Object.entries(out.priceOverrides)) if (typeof k === 'string' && typeof v === 'string') overrides[k] = v
|
|
579
|
+
}
|
|
580
|
+
out.priceOverrides = overrides
|
|
581
|
+
// priceTableDisplay:仅保留布尔值;非法值收敛为 false(即收入拓展价格表)。
|
|
582
|
+
const tableDisplay = {}
|
|
583
|
+
if (out.priceTableDisplay !== null && typeof out.priceTableDisplay === 'object' && !Array.isArray(out.priceTableDisplay)) {
|
|
584
|
+
for (const [k, v] of Object.entries(out.priceTableDisplay)) {
|
|
585
|
+
if (typeof k === 'string') tableDisplay[k] = typeof v === 'boolean' ? v : base.priceTableDisplay[k] === true
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
out.priceTableDisplay = { ...base.priceTableDisplay, ...tableDisplay }
|
|
589
|
+
// 嵌套面板配置:数值/枚举/布尔定向收敛。
|
|
590
|
+
const budget = out.budget
|
|
591
|
+
budget.enabled = budget.enabled === true
|
|
592
|
+
budget.amount = isNum(budget.amount) && budget.amount >= 0 ? budget.amount : 100
|
|
593
|
+
budget.period = oneOf(budget.period, ['day', 'month', 'all', 'custom'], 'month')
|
|
594
|
+
budget.customStart = typeof budget.customStart === 'string' ? budget.customStart : null
|
|
595
|
+
budget.customEnd = typeof budget.customEnd === 'string' ? budget.customEnd : null
|
|
596
|
+
budget.detail = budget.detail !== false
|
|
597
|
+
const balance = out.balance
|
|
598
|
+
balance.display = oneOf(balance.display, ['sidebar', 'settings', 'both', 'off'], 'both')
|
|
599
|
+
balance.refreshMinutes = Math.min(1440, Math.max(1, Math.floor(Number(balance.refreshMinutes) || 5)))
|
|
600
|
+
if (balance.showProgressBar === undefined) {
|
|
601
|
+
balance.showProgressBar = out.customBalance?.showProgressBar !== undefined
|
|
602
|
+
? out.customBalance.showProgressBar === true
|
|
603
|
+
: base.balance.showProgressBar === true
|
|
604
|
+
}
|
|
605
|
+
balance.showProgressBar = balance.showProgressBar === true
|
|
606
|
+
balance.reconcile = balance.reconcile !== false
|
|
607
|
+
const cap = Number(balance.budgetCap)
|
|
608
|
+
balance.budgetCap = Number.isFinite(cap) && cap > 0 ? cap : null
|
|
609
|
+
const goQuota = out.goQuota
|
|
610
|
+
goQuota.enabled = goQuota.enabled === true
|
|
611
|
+
goQuota.display = oneOf(goQuota.display, ['sidebar', 'settings', 'both', 'off'], 'both')
|
|
612
|
+
goQuota.refreshMinutes = Math.min(1440, Math.max(1, Math.floor(Number(goQuota.refreshMinutes) || 15)))
|
|
613
|
+
goQuota.apiKey = typeof goQuota.apiKey === 'string' ? goQuota.apiKey : ''
|
|
614
|
+
goQuota.main = oneOf(goQuota.main, ['rolling', 'weekly', 'monthly'], 'rolling')
|
|
615
|
+
goQuota.detail = goQuota.detail !== false
|
|
616
|
+
const customBalance = out.customBalance ?? base.customBalance
|
|
617
|
+
customBalance.enabled = customBalance.enabled === true
|
|
618
|
+
customBalance.display = oneOf(customBalance.display, ['sidebar', 'settings', 'both', 'off'], 'both')
|
|
619
|
+
customBalance.refreshMinutes = Math.min(1440, Math.max(1, Math.floor(Number(customBalance.refreshMinutes) || 15)))
|
|
620
|
+
customBalance.label = typeof customBalance.label === 'string' ? customBalance.label : base.customBalance.label
|
|
621
|
+
customBalance.labelEn = typeof customBalance.labelEn === 'string' ? customBalance.labelEn : base.customBalance.labelEn
|
|
622
|
+
customBalance.unit = oneOf(customBalance.unit, ['USD', 'CNY', 'EUR'], base.customBalance.unit)
|
|
623
|
+
if (customBalance.request === null || typeof customBalance.request !== 'object' || Array.isArray(customBalance.request)) {
|
|
624
|
+
customBalance.request = { ...base.customBalance.request }
|
|
625
|
+
} else {
|
|
626
|
+
// 加载边界清洗:url/method 回落字符串,headers 只保留字符串→字符串(手改账本防击穿 strict codec)。
|
|
627
|
+
const cleanedHeaders = {}
|
|
628
|
+
for (const [key, value] of Object.entries(customBalance.request.headers ?? {})) {
|
|
629
|
+
if (typeof key === 'string' && typeof value === 'string') cleanedHeaders[key] = value
|
|
630
|
+
}
|
|
631
|
+
customBalance.request = {
|
|
632
|
+
...base.customBalance.request,
|
|
633
|
+
...customBalance.request,
|
|
634
|
+
url: typeof customBalance.request.url === 'string' ? customBalance.request.url : '',
|
|
635
|
+
method: typeof customBalance.request.method === 'string' ? customBalance.request.method : 'GET',
|
|
636
|
+
headers: {
|
|
637
|
+
...(base.customBalance.request?.headers ?? {}),
|
|
638
|
+
...cleanedHeaders,
|
|
639
|
+
},
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (customBalance.extract === null || typeof customBalance.extract !== 'object' || Array.isArray(customBalance.extract)) {
|
|
643
|
+
customBalance.extract = { ...base.customBalance.extract }
|
|
644
|
+
} else {
|
|
645
|
+
customBalance.extract = { ...base.customBalance.extract, ...customBalance.extract }
|
|
646
|
+
}
|
|
647
|
+
out.customBalance = customBalance
|
|
648
|
+
const corner = out.corner
|
|
649
|
+
for (const key of ['enabled', 'goRolling', 'goWeekly', 'goMonthly', 'budget']) corner[key] = corner[key] === true || (corner[key] !== false && key !== 'enabled')
|
|
650
|
+
// codingPlans:逐家收敛(非法条目整家回落默认)。
|
|
651
|
+
const plans = {}
|
|
652
|
+
if (out.codingPlans !== null && typeof out.codingPlans === 'object') {
|
|
653
|
+
for (const [id, entry] of Object.entries(out.codingPlans)) {
|
|
654
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
|
|
655
|
+
plans[id] = {
|
|
656
|
+
enabled: entry.enabled === true,
|
|
657
|
+
display: oneOf(entry.display, ['sidebar', 'settings', 'both', 'off'], 'settings'),
|
|
658
|
+
refreshMinutes: Math.min(1440, Math.max(1, Math.floor(Number(entry.refreshMinutes) || 15))),
|
|
659
|
+
apiKey: typeof entry.apiKey === 'string' ? entry.apiKey : '',
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
out.codingPlans = plans
|
|
664
|
+
// 目录迁移(v1.5.2):opencode-go 中的 DeepSeek V4 模型与官方主表重复,以官方为准,从旧账本挂载中剔除。
|
|
665
|
+
const goModels = out.prices?.providers?.['opencode-go']?.models
|
|
666
|
+
if (goModels !== null && typeof goModels === 'object') {
|
|
667
|
+
delete goModels['deepseek-v4-flash']
|
|
668
|
+
delete goModels['deepseek-v4-pro']
|
|
669
|
+
}
|
|
670
|
+
return out
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* 账本状态容器。所有聚合写内存,持久化走防抖原子写。
|
|
675
|
+
*/
|
|
676
|
+
export class Ledger {
|
|
677
|
+
/**
|
|
678
|
+
* @param config - 初始配置(默认值或已持久化配置)。
|
|
679
|
+
* @param days - 已持久化的每日记录对象(date → day)。
|
|
680
|
+
* @param path - 账本文件路径。
|
|
681
|
+
*/
|
|
682
|
+
constructor(config, days, path) {
|
|
683
|
+
this.config = config
|
|
684
|
+
this.days = days
|
|
685
|
+
this.path = path
|
|
686
|
+
this.writeTimer = null
|
|
687
|
+
this.closed = false
|
|
688
|
+
this.pendingWrite = false
|
|
689
|
+
// 余额差对账参考点({ date, total, granted, topped, at }),由 open() 载入、flush() 落盘。
|
|
690
|
+
this.balanceRef = null
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** 在 $DSH_HOME 下创建/加载账本。 */
|
|
694
|
+
static open() {
|
|
695
|
+
const root = join(resolveDshHome(), 'storages', 'cost-meter')
|
|
696
|
+
const path = join(root, 'ledger.json')
|
|
697
|
+
let config = defaultConfig()
|
|
698
|
+
let days = {}
|
|
699
|
+
let balanceRef = null
|
|
700
|
+
try {
|
|
701
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
702
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
703
|
+
if (parsed.version !== LEDGER_VERSION) {
|
|
704
|
+
console.warn(`[dsh-cost-meter] 账本版本 ${String(parsed.version)} 不受支持,按空账本启动`)
|
|
705
|
+
} else {
|
|
706
|
+
const cfg = typeof parsed.config === 'object' && parsed.config !== null ? parsed.config : {}
|
|
707
|
+
// 新版本新增的配置键用默认值补齐;非法值清洗回落,防止击穿 strict codec。
|
|
708
|
+
config = sanitizeConfig(cfg)
|
|
709
|
+
if (parsed.days !== null && typeof parsed.days === 'object' && !Array.isArray(parsed.days)) {
|
|
710
|
+
// 旧账本可能含 reasoning: null 等非法数值:清洗后再入内存,并触发回写覆盖。
|
|
711
|
+
days = sanitizeDays(parsed.days)
|
|
712
|
+
}
|
|
713
|
+
// 余额差对账参考点(形状不对则丢弃,重新打基准)。
|
|
714
|
+
const ref = parsed.balanceRef
|
|
715
|
+
if (ref !== null && typeof ref === 'object' && typeof ref.date === 'string'
|
|
716
|
+
&& Number.isFinite(ref.total) && Number.isFinite(ref.granted) && Number.isFinite(ref.topped)) {
|
|
717
|
+
balanceRef = { date: ref.date, total: ref.total, granted: ref.granted, topped: ref.topped, at: Number(ref.at) || 0 }
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
} catch (error) {
|
|
722
|
+
if (error?.code !== 'ENOENT') {
|
|
723
|
+
console.warn(`[dsh-cost-meter] 账本读取失败,按空账本启动: ${String(error?.message ?? error)}`)
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
const ledger = new Ledger(config, days, path)
|
|
727
|
+
ledger.balanceRef = balanceRef
|
|
728
|
+
return ledger
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* 记入一次模型调用的用量。
|
|
733
|
+
* @param tokens - { input, output, cacheRead, cacheWrite }。
|
|
734
|
+
* @param modelId - 请求模型 id。
|
|
735
|
+
* @param sessionId - 会话 id(可能缺失,例如无会话的辅助调用)。
|
|
736
|
+
* @param atMs - 计费时刻(epoch ms)。
|
|
737
|
+
*/
|
|
738
|
+
account(tokens, modelId, sessionId, atMs, provider) {
|
|
739
|
+
if (this.closed) return
|
|
740
|
+
const resolved = providerPriceEntryFor(provider, modelId, this.config.prices, {
|
|
741
|
+
mode: this.config.priceMatch === 'exact' ? 'exact' : 'auto',
|
|
742
|
+
overrides: this.config.priceOverrides,
|
|
743
|
+
})
|
|
744
|
+
const entry = resolved.entry ?? { cacheHit: 0, cacheMiss: 0, output: 0 }
|
|
745
|
+
const peak = {
|
|
746
|
+
enabled: resolved.billingMode === 'deepseek-peak' && this.config.peakEnabled === true,
|
|
747
|
+
effectiveAtMs: Date.parse(this.config.peakEffectiveAt),
|
|
748
|
+
windows: this.config.peakWindows,
|
|
749
|
+
}
|
|
750
|
+
const cost = resolved.priced ? costOf(tokens, entry, atMs, peak) : 0
|
|
751
|
+
// 归一化各桶 token 数:非有限/负数一律按 0 处理,防止污染账本聚合。
|
|
752
|
+
const num = value => {
|
|
753
|
+
const n = Number(value)
|
|
754
|
+
return Number.isFinite(n) && n > 0 ? n : 0
|
|
755
|
+
}
|
|
756
|
+
const buckets = {
|
|
757
|
+
input: num(tokens?.input),
|
|
758
|
+
output: num(tokens?.output),
|
|
759
|
+
cacheRead: num(tokens?.cacheRead),
|
|
760
|
+
cacheWrite: num(tokens?.cacheWrite),
|
|
761
|
+
reasoning: num(tokens?.reasoning),
|
|
762
|
+
}
|
|
763
|
+
const date = localDayKey(atMs)
|
|
764
|
+
let day = this.days[date]
|
|
765
|
+
if (day === undefined || day === null || typeof day !== 'object') {
|
|
766
|
+
day = zeroDay(date)
|
|
767
|
+
this.days[date] = day
|
|
768
|
+
}
|
|
769
|
+
day.input += buckets.input
|
|
770
|
+
day.output += buckets.output
|
|
771
|
+
day.cacheRead += buckets.cacheRead
|
|
772
|
+
day.cacheWrite += buckets.cacheWrite
|
|
773
|
+
day.reasoning += buckets.reasoning
|
|
774
|
+
day.calls += 1
|
|
775
|
+
day.cost += cost
|
|
776
|
+
const providerKey = `${typeof provider === 'string' && provider.length > 0 ? provider : 'deepseek'}:${String(modelId ?? 'default')}`
|
|
777
|
+
day.byProviderModel = day.byProviderModel ?? {}
|
|
778
|
+
const dayProvider = day.byProviderModel[providerKey] ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, calls: 0, cost: 0 }
|
|
779
|
+
day.byProviderModel[providerKey] = {
|
|
780
|
+
input: dayProvider.input + buckets.input,
|
|
781
|
+
output: dayProvider.output + buckets.output,
|
|
782
|
+
cacheRead: dayProvider.cacheRead + buckets.cacheRead,
|
|
783
|
+
cacheWrite: dayProvider.cacheWrite + buckets.cacheWrite,
|
|
784
|
+
reasoning: dayProvider.reasoning + buckets.reasoning,
|
|
785
|
+
calls: dayProvider.calls + 1,
|
|
786
|
+
cost: dayProvider.cost + cost,
|
|
787
|
+
}
|
|
788
|
+
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
|
789
|
+
let sessions = Array.isArray(day.sessions) ? day.sessions : []
|
|
790
|
+
let session = sessions.find(s => s.id === sessionId)
|
|
791
|
+
if (session === undefined) {
|
|
792
|
+
session = zeroSession(sessionId)
|
|
793
|
+
sessions.push(session)
|
|
794
|
+
if (sessions.length > MAX_SESSIONS_PER_DAY) sessions = sessions.slice(-MAX_SESSIONS_PER_DAY)
|
|
795
|
+
day.sessions = sessions
|
|
796
|
+
}
|
|
797
|
+
session.input += buckets.input
|
|
798
|
+
session.output += buckets.output
|
|
799
|
+
session.cacheRead += buckets.cacheRead
|
|
800
|
+
session.cacheWrite += buckets.cacheWrite
|
|
801
|
+
session.reasoning += buckets.reasoning
|
|
802
|
+
session.calls += 1
|
|
803
|
+
session.cost += cost
|
|
804
|
+
session.byProviderModel = session.byProviderModel ?? {}
|
|
805
|
+
const sessionProvider = session.byProviderModel[providerKey] ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, calls: 0, cost: 0 }
|
|
806
|
+
session.byProviderModel[providerKey] = {
|
|
807
|
+
input: sessionProvider.input + buckets.input,
|
|
808
|
+
output: sessionProvider.output + buckets.output,
|
|
809
|
+
cacheRead: sessionProvider.cacheRead + buckets.cacheRead,
|
|
810
|
+
cacheWrite: sessionProvider.cacheWrite + buckets.cacheWrite,
|
|
811
|
+
reasoning: sessionProvider.reasoning + buckets.reasoning,
|
|
812
|
+
calls: sessionProvider.calls + 1,
|
|
813
|
+
cost: sessionProvider.cost + cost,
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
this.prune()
|
|
817
|
+
this.scheduleWrite()
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** 清理超出保留天数的记录。 */
|
|
821
|
+
prune() {
|
|
822
|
+
const keep = Math.max(7, Math.min(3650, Number(this.config.historyDays) || DEFAULT_HISTORY_DAYS))
|
|
823
|
+
const keys = Object.keys(this.days).sort()
|
|
824
|
+
while (keys.length > keep) delete this.days[keys.shift()]
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
scheduleWrite() {
|
|
828
|
+
this.pendingWrite = true
|
|
829
|
+
if (this.writeTimer !== null) return
|
|
830
|
+
this.writeTimer = setTimeout(() => {
|
|
831
|
+
this.writeTimer = null
|
|
832
|
+
this.flush()
|
|
833
|
+
}, 2000)
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** 立即落盘(原子写)。 */
|
|
837
|
+
flush() {
|
|
838
|
+
if (!this.pendingWrite || this.closed) return
|
|
839
|
+
this.pendingWrite = false
|
|
840
|
+
try {
|
|
841
|
+
mkdirSync(dirname(this.path), { recursive: true })
|
|
842
|
+
const tmp = `${this.path}.tmp`
|
|
843
|
+
writeFileSync(tmp, JSON.stringify({ version: LEDGER_VERSION, config: this.config, days: this.days, balanceRef: this.balanceRef ?? null }), 'utf8')
|
|
844
|
+
renameSync(tmp, this.path)
|
|
845
|
+
} catch (error) {
|
|
846
|
+
console.warn(`[dsh-cost-meter] 账本写入失败: ${String(error?.message ?? error)}`)
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** 停止后续写入并最终落盘(插件卸载/进程退出)。 */
|
|
851
|
+
close() {
|
|
852
|
+
this.closed = true
|
|
853
|
+
if (this.writeTimer !== null) {
|
|
854
|
+
clearTimeout(this.writeTimer)
|
|
855
|
+
this.writeTimer = null
|
|
856
|
+
}
|
|
857
|
+
this.flush()
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** 聚合某前缀(如 '2026-08')的全部天。 */
|
|
861
|
+
sumDays(prefix) {
|
|
862
|
+
const total = zeroDay(prefix === undefined ? 'total' : prefix)
|
|
863
|
+
for (const [date, day] of Object.entries(this.days)) {
|
|
864
|
+
if (prefix !== undefined && !date.startsWith(prefix)) continue
|
|
865
|
+
total.input += day.input ?? 0
|
|
866
|
+
total.output += day.output ?? 0
|
|
867
|
+
total.cacheRead += day.cacheRead ?? 0
|
|
868
|
+
total.cacheWrite += day.cacheWrite ?? 0
|
|
869
|
+
total.reasoning += day.reasoning ?? 0
|
|
870
|
+
total.calls += day.calls ?? 0
|
|
871
|
+
total.cost += day.cost ?? 0
|
|
872
|
+
for (const [key, value] of Object.entries(day.byProviderModel ?? {})) {
|
|
873
|
+
const current = total.byProviderModel[key] ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, calls: 0, cost: 0 }
|
|
874
|
+
total.byProviderModel[key] = {
|
|
875
|
+
input: current.input + (value.input ?? 0), output: current.output + (value.output ?? 0),
|
|
876
|
+
cacheRead: current.cacheRead + (value.cacheRead ?? 0), cacheWrite: current.cacheWrite + (value.cacheWrite ?? 0),
|
|
877
|
+
reasoning: current.reasoning + (value.reasoning ?? 0),
|
|
878
|
+
calls: current.calls + (value.calls ?? 0), cost: current.cost + (value.cost ?? 0),
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
total.date = prefix === undefined ? 'total' : prefix
|
|
883
|
+
return total
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* 聚合自定义日期区间 [startKey, endKey](含两端,YYYY-MM-DD 字典序)。
|
|
888
|
+
* @param startKey - 起始日期键。
|
|
889
|
+
* @param endKey - 结束日期键。
|
|
890
|
+
* @returns 区间聚合(仅数字字段,date 为区间键)。
|
|
891
|
+
*/
|
|
892
|
+
sumRange(startKey, endKey) {
|
|
893
|
+
const total = zeroDay(`${startKey}..${endKey}`)
|
|
894
|
+
if (typeof startKey !== 'string' || typeof endKey !== 'string') return total
|
|
895
|
+
for (const [date, day] of Object.entries(this.days)) {
|
|
896
|
+
if (date < startKey || date > endKey) continue
|
|
897
|
+
total.input += day.input ?? 0
|
|
898
|
+
total.output += day.output ?? 0
|
|
899
|
+
total.cacheRead += day.cacheRead ?? 0
|
|
900
|
+
total.cacheWrite += day.cacheWrite ?? 0
|
|
901
|
+
total.reasoning += day.reasoning ?? 0
|
|
902
|
+
total.calls += day.calls ?? 0
|
|
903
|
+
total.cost += day.cost ?? 0
|
|
904
|
+
for (const [key, value] of Object.entries(day.byProviderModel ?? {})) {
|
|
905
|
+
const current = total.byProviderModel[key] ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, calls: 0, cost: 0 }
|
|
906
|
+
total.byProviderModel[key] = {
|
|
907
|
+
input: current.input + (value.input ?? 0), output: current.output + (value.output ?? 0),
|
|
908
|
+
cacheRead: current.cacheRead + (value.cacheRead ?? 0), cacheWrite: current.cacheWrite + (value.cacheWrite ?? 0),
|
|
909
|
+
reasoning: current.reasoning + (value.reasoning ?? 0),
|
|
910
|
+
calls: current.calls + (value.calls ?? 0), cost: current.cost + (value.cost ?? 0),
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return total
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/** 今日记录(可能为空)。 */
|
|
918
|
+
today() {
|
|
919
|
+
const date = localDayKey(Date.now())
|
|
920
|
+
const day = this.days[date]
|
|
921
|
+
return day === undefined ? zeroDay(date) : this.copyDay(day)
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** 历史列表(降序,轻量副本,不含会话明细)。 */
|
|
925
|
+
history(limit = 60) {
|
|
926
|
+
return Object.keys(this.days)
|
|
927
|
+
.sort()
|
|
928
|
+
.reverse()
|
|
929
|
+
.slice(0, limit)
|
|
930
|
+
.map(date => this.copyDay(this.days[date], true))
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
copyDay(day, withoutSessions = false) {
|
|
934
|
+
const sessions = withoutSessions || !Array.isArray(day.sessions)
|
|
935
|
+
? []
|
|
936
|
+
: day.sessions.slice().sort((a, b) => b.cost - a.cost).map(s => ({ ...s }))
|
|
937
|
+
return {
|
|
938
|
+
date: String(day.date),
|
|
939
|
+
input: day.input ?? 0,
|
|
940
|
+
output: day.output ?? 0,
|
|
941
|
+
cacheRead: day.cacheRead ?? 0,
|
|
942
|
+
cacheWrite: day.cacheWrite ?? 0,
|
|
943
|
+
reasoning: day.reasoning ?? 0,
|
|
944
|
+
calls: day.calls ?? 0,
|
|
945
|
+
cost: day.cost ?? 0,
|
|
946
|
+
byProviderModel: day.byProviderModel ?? {},
|
|
947
|
+
sessions,
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* 余额差交叉校验(issue #18 讨论):用官方余额当日变动反推消费,与本地账本今日合计比对。
|
|
954
|
+
* 仅当余额确实减少时才对账——订阅/Coding Plan 消费不动官方余额,若强行用余额差
|
|
955
|
+
* 替代今日费用会把订阅用户全天消费归零;充值/额度结构变动时重置参考点防误判。
|
|
956
|
+
* @param prevRef - 上一参考点({ date, total, granted, topped, at })或 null。
|
|
957
|
+
* @param balance - 本次拉取结果({ totalBalance, grantedBalance, toppedUpBalance })。
|
|
958
|
+
* @param todayCost - 本地账本今日合计费用。
|
|
959
|
+
* @param dayKey - 本地日期键(YYYY-MM-DD)。
|
|
960
|
+
* @param nowMs - 当前时刻。
|
|
961
|
+
* @returns {{ ref, event }} event.kind ∈ baseline | structure-reset | flat | ok | drift(drift 携带 spent/todayCost)。
|
|
962
|
+
*/
|
|
963
|
+
export function reconcileBalanceDelta(prevRef, balance, todayCost, dayKey, nowMs) {
|
|
964
|
+
if (balance === null || typeof balance !== 'object' || !Number.isFinite(balance.totalBalance)) {
|
|
965
|
+
return { ref: prevRef ?? null, event: null }
|
|
966
|
+
}
|
|
967
|
+
const snap = {
|
|
968
|
+
date: dayKey,
|
|
969
|
+
total: balance.totalBalance,
|
|
970
|
+
granted: Number.isFinite(balance.grantedBalance) ? balance.grantedBalance : 0,
|
|
971
|
+
topped: Number.isFinite(balance.toppedUpBalance) ? balance.toppedUpBalance : 0,
|
|
972
|
+
at: nowMs,
|
|
973
|
+
}
|
|
974
|
+
// 新的一天(或首次/参考点形状异常):打基准,不对账。
|
|
975
|
+
if (prevRef === null || typeof prevRef !== 'object' || prevRef.date !== dayKey) {
|
|
976
|
+
return { ref: snap, event: { kind: 'baseline' } }
|
|
977
|
+
}
|
|
978
|
+
// 充值/额度授予变动:充值与授信只会让分项余额增加(消费只会减少),分项变大则旧参考点失效,重置不告警。
|
|
979
|
+
if (snap.granted > prevRef.granted + 0.009 || snap.topped > prevRef.topped + 0.009) {
|
|
980
|
+
return { ref: snap, event: { kind: 'structure-reset' } }
|
|
981
|
+
}
|
|
982
|
+
const spent = prevRef.total - snap.total
|
|
983
|
+
// 余额未减少:无法对账(可能整天走订阅扣费),静默。
|
|
984
|
+
if (spent <= 0.009) return { ref: prevRef, event: { kind: 'flat' } }
|
|
985
|
+
const dev = Math.abs(spent - todayCost)
|
|
986
|
+
const threshold = Math.max(0.3, 0.15 * Math.max(spent, todayCost))
|
|
987
|
+
if (dev > threshold) return { ref: prevRef, event: { kind: 'drift', spent, todayCost } }
|
|
988
|
+
// ok/drift 都保留当日首次基准,后续拉取继续与早间基线比对。
|
|
989
|
+
return { ref: prevRef, event: { kind: 'ok', spent, todayCost } }
|
|
990
|
+
}
|