dsh-cost-meter 1.7.38 → 1.7.40
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/README.md +6 -6
- package/README.zh-CN.md +6 -6
- package/lib/aliyun-balance.js +7 -3
- package/lib/custom-balance.js +5 -3
- package/lib/external-usage.js +2 -1
- package/lib/index.js +118 -44
- package/lib/store.js +3 -0
- package/lib/usage-dedup.js +9 -5
- package/locale/en.json +3 -0
- package/locale/zh.json +6 -0
- package/package.json +18 -6
package/README.md
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
Per-conversation cost · daily totals · OpenCode Go subscription quota display · budget with usage percentage · official account balance · custom provider balance · balance progress bar · history · peak/off-peak pricing hours display (peak hours UTC 01:00–04:00, 06:00–10:00; weekends and Chinese public holidays are off-peak all day, with separate labels) · pre-switch popup & system-notification alerts for peak/off-peak changes (position / lead time / alert type configurable) · one-click price sync from the official docs · Codex-style token usage heat grid · multi-vendor model pricing (built-in 90+ model price catalog with auto-matching) · mainstream Coding Plan quota queries & display (Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow / CommandCode / SCNet / Volcano Ark / Qwen / Xiaomi MiMo) plan/API dual-track billing (subscription quota vs pay-as-you-go money separated, per-1% & full-window token/equivalent-cost estimates with daily/weekly/monthly curves) · · quota strip above the input box (budget / Go / coding-plan usage in one row, toggleable)
|
|
10
10
|
|
|
11
|
-
[](https://github.com/Han-1413141/dsh-cost-meter)
|
|
12
12
|
|
|
13
|
-
**v1.7.
|
|
13
|
+
**v1.7.40** adds Chinese metadata for the DSH plugin list while retaining the existing English package fallback. See the [release notes](docs/release-notes/v1.7.40.md).
|
|
14
14
|
|
|
15
15
|
[](https://www.npmjs.com/package/dsh-cost-meter)
|
|
16
16
|
[](LICENSE)
|
|
@@ -317,22 +317,22 @@ On Node.js 20, use `npm install -g pnpm@10` instead. See [pnpm installation and
|
|
|
317
317
|
dsh plugin --profile web add dsh-cost-meter
|
|
318
318
|
```
|
|
319
319
|
|
|
320
|
-
**PowerShell one-click script** (copy the whole line, paste, press Enter; pnpm is provisioned automatically, git is auto-detected — no clone needed; the install chain is **pinned to the release tag `v1.7.
|
|
320
|
+
**PowerShell one-click script** (copy the whole line, paste, press Enter; pnpm is provisioned automatically, git is auto-detected — no clone needed; the install chain is **pinned to the release tag `v1.7.40`** — review the script before running):
|
|
321
321
|
|
|
322
322
|
```powershell
|
|
323
|
-
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.
|
|
323
|
+
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.40/install.ps1 | iex
|
|
324
324
|
```
|
|
325
325
|
|
|
326
326
|
**Or a plain command line** (the machine must already have pnpm and git; also pinned to the tag):
|
|
327
327
|
|
|
328
328
|
```sh
|
|
329
|
-
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.
|
|
329
|
+
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.40
|
|
330
330
|
```
|
|
331
331
|
|
|
332
332
|
Without git, use the GitHub tag archive:
|
|
333
333
|
|
|
334
334
|
```sh
|
|
335
|
-
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.
|
|
335
|
+
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.40.tar.gz
|
|
336
336
|
```
|
|
337
337
|
|
|
338
338
|
After installing, **restart** `dsh web` (plugin rows, the Typert manifest and the client bundle are all scanned at startup):
|
package/README.zh-CN.md
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
本会话费用 · 当日费用 · OpenCode Go 订阅额度显示 · 预算与已用百分比 · 官方账户余额 · 自定义 Provider 余额查询(可配任意 HTTP 端点) · 余额三段进度条 · 历史记录 · 峰谷计价时段显示(UTC 01:00–04:00、06:00–10:00 为峰时段;周末与中国法定假日全天按谷价,分别标注) · 峰/谷切换前弹窗与系统通知提醒(位置/提前量/提醒类型可配) · 官方价格一键同步 · 类 Codex Token 用量热图 · 多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配) · 主流 Coding Plan 额度查询与显示(Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow / CommandCode / SCNet / 火山方舟 / 千问 / 小米 MiMo 十一家,含 Volcano Ark AK/SK 签名与 MiMo 控制台 Cookie 查询) · Plan/API 双轨计费(订阅额度与按量金额分离统计,每 1% 额度与满窗的 token/等值金额估算及日/周/月曲线) · 输入框上方额度横条(预算/Go/Coding Plan 用量一条横排显示,可开关)
|
|
10
10
|
|
|
11
|
-
[](https://github.com/Han-1413141/dsh-cost-meter)
|
|
12
12
|
|
|
13
|
-
**v1.7.
|
|
13
|
+
**v1.7.40**:为 DSH 插件列表增加中文名称与简介,英文界面沿用现有包信息。详见[更新说明](docs/release-notes/v1.7.40.md)。
|
|
14
14
|
|
|
15
15
|
[](https://www.npmjs.com/package/dsh-cost-meter)
|
|
16
16
|
[](LICENSE)
|
|
@@ -317,22 +317,22 @@ Node.js 20 请改用 `npm install -g pnpm@10`。版本要求见 [pnpm 官方安
|
|
|
317
317
|
dsh plugin --profile web add dsh-cost-meter
|
|
318
318
|
```
|
|
319
319
|
|
|
320
|
-
**PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.7.
|
|
320
|
+
**PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.7.40`**,建议先下载审阅再运行):
|
|
321
321
|
|
|
322
322
|
```powershell
|
|
323
|
-
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.
|
|
323
|
+
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.40/install.ps1 | iex
|
|
324
324
|
```
|
|
325
325
|
|
|
326
326
|
**或直接命令行**(机器上需已有 pnpm 与 git;同样固定到 tag):
|
|
327
327
|
|
|
328
328
|
```sh
|
|
329
|
-
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.
|
|
329
|
+
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.40
|
|
330
330
|
```
|
|
331
331
|
|
|
332
332
|
没有 git 时可用 GitHub tag 打包直链:
|
|
333
333
|
|
|
334
334
|
```sh
|
|
335
|
-
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.
|
|
335
|
+
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.40.tar.gz
|
|
336
336
|
```
|
|
337
337
|
|
|
338
338
|
安装后**重启** `dsh web`(插件行、Typert 清单与客户端 bundle 均在启动时扫描):
|
package/lib/aliyun-balance.js
CHANGED
|
@@ -94,18 +94,22 @@ async function readPayload(response, locale) {
|
|
|
94
94
|
} catch { throw failure(locale, 'payload') }
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
export async function queryAliyunBalance(ctx, config, { fetchImpl = fetch } = {}) {
|
|
97
|
+
export async function queryAliyunBalance(ctx, config, { fetchImpl = fetch, signal } = {}) {
|
|
98
|
+
signal?.throwIfAborted()
|
|
98
99
|
const locale = config?.locale
|
|
99
100
|
const [ak, sk, token] = await Promise.all(ALIYUN_BALANCE_CREDENTIAL_VARS.map(name => resolveCredential(ctx, name)))
|
|
100
101
|
if (!ak || !sk) throw Object.assign(failure(locale, 'missing'), { soft: true })
|
|
101
102
|
let response
|
|
102
103
|
try {
|
|
103
104
|
// 每次网络重试重新生成时间戳与 nonce;URL、Action、body 均固定为只读账户查询。
|
|
104
|
-
response = await fetchWithRetry(ALIYUN_BALANCE_URL, {}, {
|
|
105
|
+
response = await fetchWithRetry(ALIYUN_BALANCE_URL, { signal }, {
|
|
105
106
|
attempts: 2, timeoutMs: 15000,
|
|
106
107
|
fetchImpl: (url, init) => fetchImpl(url, { ...signAliyunBalanceRequest(ak, sk, token), signal: init.signal }),
|
|
107
108
|
})
|
|
108
|
-
} catch {
|
|
109
|
+
} catch {
|
|
110
|
+
signal?.throwIfAborted()
|
|
111
|
+
throw failure(locale, 'network')
|
|
112
|
+
}
|
|
109
113
|
if (!response.ok) {
|
|
110
114
|
await response.body?.cancel().catch(() => {})
|
|
111
115
|
if (response.status >= 300 && response.status < 400) throw failure(locale, 'redirect')
|
package/lib/custom-balance.js
CHANGED
|
@@ -148,14 +148,15 @@ const warnedHosts = new Set()
|
|
|
148
148
|
* @param {import('cordis').Context} ctx
|
|
149
149
|
* @param {Record<string, unknown>} config
|
|
150
150
|
*/
|
|
151
|
-
export async function queryCustomBalance(ctx, config) {
|
|
151
|
+
export async function queryCustomBalance(ctx, config, { signal } = {}) {
|
|
152
|
+
signal?.throwIfAborted()
|
|
152
153
|
const custom = config?.customBalance
|
|
153
154
|
if (custom?.enabled !== true) {
|
|
154
155
|
const error = new Error('custom balance disabled')
|
|
155
156
|
error.soft = true
|
|
156
157
|
throw error
|
|
157
158
|
}
|
|
158
|
-
if (custom.adapter === 'aliyun') return queryAliyunBalance(ctx, config)
|
|
159
|
+
if (custom.adapter === 'aliyun') return queryAliyunBalance(ctx, config, { signal })
|
|
159
160
|
const request = custom.request
|
|
160
161
|
if (request === null || typeof request !== 'object' || typeof request.url !== 'string' || request.url.length === 0) {
|
|
161
162
|
throw new Error('customBalance.request.url is required')
|
|
@@ -203,7 +204,7 @@ export async function queryCustomBalance(ctx, config) {
|
|
|
203
204
|
}
|
|
204
205
|
const method = typeof request.method === 'string' ? request.method.toUpperCase() : 'GET'
|
|
205
206
|
const headers = await resolveHeaders(rawHeaders, ctx)
|
|
206
|
-
const init = { method, headers, redirect: 'manual' }
|
|
207
|
+
const init = { method, headers, redirect: 'manual', signal }
|
|
207
208
|
if (method !== 'GET' && method !== 'HEAD' && request.body !== undefined) {
|
|
208
209
|
init.body = typeof request.body === 'string' ? request.body : JSON.stringify(request.body)
|
|
209
210
|
if (!Object.keys(headers).some(name => name.toLowerCase() === 'content-type')) {
|
|
@@ -215,6 +216,7 @@ export async function queryCustomBalance(ctx, config) {
|
|
|
215
216
|
// 目标主机,一次跨源重定向就足以把密钥发去别处;遇到重定向直接报错。
|
|
216
217
|
const response = await fetchWithRetry(request.url, init, { timeoutMs: 15000 })
|
|
217
218
|
if (response.status >= 300 && response.status < 400) {
|
|
219
|
+
try { await response.body?.cancel?.() } catch {}
|
|
218
220
|
throw new Error(`custom balance endpoint redirected (HTTP ${String(response.status)}); redirects are refused to avoid forwarding credentials to another host`)
|
|
219
221
|
}
|
|
220
222
|
if (!response.ok) {
|
package/lib/external-usage.js
CHANGED
|
@@ -11,7 +11,7 @@ const object = v => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
|
11
11
|
const count = v => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 && v <= 1e12
|
|
12
12
|
const money = v => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1e9
|
|
13
13
|
const dateKey = v => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) && !Number.isNaN(Date.parse(v + 'T00:00:00Z')) && new Date(v + 'T00:00:00Z').toISOString().slice(0, 10) === v
|
|
14
|
-
const timestamp = v => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(v) ? Date.parse(v) : NaN
|
|
14
|
+
const timestamp = v => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(v) && dateKey(v.slice(0, 10)) ? Date.parse(v) : NaN
|
|
15
15
|
|
|
16
16
|
function add(into, day) {
|
|
17
17
|
for (const key of fields) into[key] += day[key]
|
|
@@ -33,6 +33,7 @@ function summary(days, dayKey) {
|
|
|
33
33
|
/** A snapshot replaces its predecessor; it never mutates the DSH ledger or balance reconciliation. */
|
|
34
34
|
export function parseExternalUsageSnapshot(value, config, nowMs = Date.now()) {
|
|
35
35
|
if (!object(value)) return null
|
|
36
|
+
if (Object.hasOwn(value, 'sources') && !Array.isArray(value.sources)) return null
|
|
36
37
|
const fetchedAt = timestamp(value.fetchedAt)
|
|
37
38
|
if (!Number.isFinite(fetchedAt) || fetchedAt > nowMs + 300000 || nowMs - fetchedAt > MAX_AGE) return null
|
|
38
39
|
const entries = Array.isArray(value.sources) ? value.sources : [value]
|
package/lib/index.js
CHANGED
|
@@ -777,7 +777,7 @@ function normalizeGoWindow(raw) {
|
|
|
777
777
|
* @param config - 插件配置(goQuota.apiKey / 消息语言)。
|
|
778
778
|
* @param locale - 消息语言(zh/en)。
|
|
779
779
|
*/
|
|
780
|
-
async function queryGoQuota(ctx, config, locale) {
|
|
780
|
+
async function queryGoQuota(ctx, config, locale, signal) {
|
|
781
781
|
const key = await resolveGoKey(ctx, config)
|
|
782
782
|
if (key === null) {
|
|
783
783
|
const error = new Error(tmsg(locale, 'goQuotaKeyMissing'))
|
|
@@ -787,6 +787,7 @@ async function queryGoQuota(ctx, config, locale) {
|
|
|
787
787
|
// Cloudflare 前置会间歇性重置连接(ECONNRESET → fetch failed):瞬时网络错误
|
|
788
788
|
// 自动重试,401/403 等业务状态仍走下方原语义(issue #28)。
|
|
789
789
|
const response = await fetchWithRetry(GO_QUOTA_URL, {
|
|
790
|
+
signal,
|
|
790
791
|
headers: {
|
|
791
792
|
authorization: `Bearer ${key}`,
|
|
792
793
|
// 浏览器 UA:避免被 opencode.ai 前置 Cloudflare 以 error 1010 拦截。
|
|
@@ -944,7 +945,7 @@ function balanceEndpoint(baseURL) {
|
|
|
944
945
|
* @param locale - 消息语言(zh/en)。
|
|
945
946
|
* @returns { currency, totalBalance, grantedBalance, toppedUpBalance }。
|
|
946
947
|
*/
|
|
947
|
-
async function queryBalance(ctx, locale) {
|
|
948
|
+
async function queryBalance(ctx, locale, signal) {
|
|
948
949
|
const settings = ctx.get('settings')
|
|
949
950
|
const section = typeof settings?.get === 'function' ? settings.get('llm-deepseek') : undefined
|
|
950
951
|
const baseURL = section?.baseURL
|
|
@@ -976,6 +977,7 @@ async function queryBalance(ctx, locale) {
|
|
|
976
977
|
}
|
|
977
978
|
// 瞬时网络错误自动重试(issue #28 同一封装);非 2xx 状态仍按业务错误处理。
|
|
978
979
|
const response = await fetchWithRetry(endpoint, {
|
|
980
|
+
signal,
|
|
979
981
|
headers: { authorization: `Bearer ${apiKey}` },
|
|
980
982
|
}, { timeoutMs: 15000 })
|
|
981
983
|
if (!response.ok) throw new Error(tmsg(locale, 'balanceHttp', { code: String(response.status) }))
|
|
@@ -1317,17 +1319,32 @@ async function describeGoKey(ctx, config) {
|
|
|
1317
1319
|
}
|
|
1318
1320
|
|
|
1319
1321
|
function createService(ctx, ledger, openRouterPrices) {
|
|
1322
|
+
let active = true
|
|
1320
1323
|
// 余额进程内缓存:display=off 时不清缓存但不下发;按 refreshMinutes 过期。
|
|
1321
1324
|
let balanceCache = { fetchedAt: 0, value: emptyBalance() }
|
|
1322
1325
|
// OpenCode Go 订阅额度进程内缓存(同上策略)。
|
|
1323
1326
|
let goQuotaCache = { fetchedAt: 0, value: emptyGoQuota() }
|
|
1324
1327
|
// Coding plan 额度进程内缓存(每家一个条目,同上策略)。
|
|
1325
1328
|
let codingPlanCaches = {}
|
|
1329
|
+
const resetQueryCache = target => {
|
|
1330
|
+
if (target === 'balance') {
|
|
1331
|
+
balanceCache.controller?.abort()
|
|
1332
|
+
balanceCache = { fetchedAt: 0, value: emptyBalance() }
|
|
1333
|
+
} else if (target === 'goQuota') {
|
|
1334
|
+
goQuotaCache.controller?.abort()
|
|
1335
|
+
goQuotaCache = { fetchedAt: 0, value: emptyGoQuota() }
|
|
1336
|
+
} else if (target.startsWith('codingPlans.')) {
|
|
1337
|
+
const id = target.split('.')[1]
|
|
1338
|
+
codingPlanCaches[id]?.controller?.abort()
|
|
1339
|
+
delete codingPlanCaches[id]
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1326
1342
|
ctx.effect(() => () => {
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1343
|
+
active = false
|
|
1344
|
+
gatewayQuotaCaches = {}
|
|
1345
|
+
resetQueryCache('balance')
|
|
1346
|
+
resetQueryCache('goQuota')
|
|
1347
|
+
for (const id of Object.keys(codingPlanCaches)) resetQueryCache('codingPlans.' + id)
|
|
1331
1348
|
}, 'cost-meter: quota query cancellation')
|
|
1332
1349
|
// 余额差对账提示(drift 时 ok=false 携带文案,其余静默)。
|
|
1333
1350
|
let reconcileNotice = { ok: true, message: '' }
|
|
@@ -1404,8 +1421,10 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1404
1421
|
|
|
1405
1422
|
/** 按需刷新余额(过期或 force);失败落 error 状态,不影响其余状态字段。 */
|
|
1406
1423
|
const ensureBalance = async (force = false) => {
|
|
1424
|
+
if (!active) return
|
|
1407
1425
|
const config = balanceConfig()
|
|
1408
1426
|
if (config.display === 'off') {
|
|
1427
|
+
balanceCache.controller?.abort()
|
|
1409
1428
|
balanceCache = { fetchedAt: Date.now(), value: emptyBalance() }
|
|
1410
1429
|
return
|
|
1411
1430
|
}
|
|
@@ -1417,9 +1436,12 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1417
1436
|
// force 链式等待:等上一个任务结束后由本调用继续走新建流程,
|
|
1418
1437
|
// 保证手动强制刷新不被在途任务吞掉(否则返回的是即将过期的旧数据)。
|
|
1419
1438
|
await prev.catch(() => {})
|
|
1439
|
+
if (!active || balanceConfig().display === 'off') return
|
|
1420
1440
|
if (balanceCache.inFlight === prev) break // 上一个任务结束后由本调用继续走新建流程
|
|
1421
1441
|
}
|
|
1422
|
-
const
|
|
1442
|
+
const controller = new AbortController()
|
|
1443
|
+
const task = queryBalance(ctx, localeOf(ledger.config), controller.signal).then(result => {
|
|
1444
|
+
if (!active || balanceCache.inFlight !== task) return
|
|
1423
1445
|
balanceCache = { fetchedAt: Date.now(), value: { status: 'ok', message: '', fetchedAt: Date.now(), ...result } }
|
|
1424
1446
|
// 余额差交叉校验(issue #18):官方余额当日变动 vs 本地账本今日官方渠道费用,偏差超阈提示。
|
|
1425
1447
|
// issue #36:Coding Plan / 自定义 Provider 的费用不动官方余额,对账只统计 deepseek 渠道,否则订阅用户会恒报 drift。
|
|
@@ -1456,6 +1478,7 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1456
1478
|
: { ok: true, message: '' }
|
|
1457
1479
|
}
|
|
1458
1480
|
}, error => {
|
|
1481
|
+
if (!active || balanceCache.inFlight !== task) return
|
|
1459
1482
|
// 软失败(未配置 Key / 非官方端点等守卫错误,不会自愈)标记 fetchedAt 避免无意义重试;
|
|
1460
1483
|
// 硬失败(网络超时等临时性问题)写入 error 状态但保留外层 fetchedAt——UI 仍显示失败原因,
|
|
1461
1484
|
// 且下次轮询自动重试,不再被缓存有效期钉死(PR #40 补全)。
|
|
@@ -1470,13 +1493,16 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1470
1493
|
if (balanceCache.inFlight === task) delete balanceCache.inFlight
|
|
1471
1494
|
})
|
|
1472
1495
|
balanceCache.inFlight = task
|
|
1496
|
+
balanceCache.controller = controller
|
|
1473
1497
|
await task
|
|
1474
1498
|
}
|
|
1475
1499
|
|
|
1476
1500
|
/** 按需刷新 OpenCode Go 额度(过期或 force);未启用/显示关闭/失败均落空或 error 状态。 */
|
|
1477
1501
|
const ensureGoQuota = async (force = false) => {
|
|
1502
|
+
if (!active) return
|
|
1478
1503
|
const config = goQuotaConfig()
|
|
1479
1504
|
if (config.enabled === false || config.display === 'off') {
|
|
1505
|
+
goQuotaCache.controller?.abort()
|
|
1480
1506
|
goQuotaCache = { fetchedAt: Date.now(), value: emptyGoQuota() }
|
|
1481
1507
|
return
|
|
1482
1508
|
}
|
|
@@ -1488,12 +1514,16 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1488
1514
|
// force 链式等待:等上一个任务结束后由本调用继续走新建流程,
|
|
1489
1515
|
// 保证手动强制刷新不被在途任务吞掉(否则返回的是即将过期的旧数据)。
|
|
1490
1516
|
await prev.catch(() => {})
|
|
1517
|
+
if (!active || goQuotaConfig().enabled === false || goQuotaConfig().display === 'off') return
|
|
1491
1518
|
if (goQuotaCache.inFlight === prev) break // 上一个任务结束后由本调用继续走新建流程
|
|
1492
1519
|
}
|
|
1493
|
-
const
|
|
1520
|
+
const controller = new AbortController()
|
|
1521
|
+
const task = queryGoQuota(ctx, ledger.config, localeOf(ledger.config), controller.signal).then(result => {
|
|
1522
|
+
if (!active || goQuotaCache.inFlight !== task) return
|
|
1494
1523
|
goQuotaCache = { fetchedAt: Date.now(), value: { status: 'ok', message: '', fetchedAt: Date.now(), ...result } }
|
|
1495
1524
|
recordQuotaSamples('go', { rolling: result.rolling, weekly: result.weekly, monthly: result.monthly })
|
|
1496
1525
|
}, error => {
|
|
1526
|
+
if (!active || goQuotaCache.inFlight !== task) return
|
|
1497
1527
|
// 失败不更新外层 fetchedAt:让下次轮询重试,而非在缓存有效期内一直跳过(PR #40)。
|
|
1498
1528
|
// 软失败(未登录/无订阅,不会自愈)完整缓存避免无意义重试;硬失败(网络超时等)写入
|
|
1499
1529
|
// error 状态但保留旧 fetchedAt——UI 仍显示失败原因,轮询到点自动重试。
|
|
@@ -1508,6 +1538,7 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1508
1538
|
if (goQuotaCache.inFlight === task) delete goQuotaCache.inFlight
|
|
1509
1539
|
})
|
|
1510
1540
|
goQuotaCache.inFlight = task
|
|
1541
|
+
goQuotaCache.controller = controller
|
|
1511
1542
|
await task
|
|
1512
1543
|
}
|
|
1513
1544
|
|
|
@@ -1520,6 +1551,18 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1520
1551
|
const customBalanceConfigs = () => Array.isArray(ledger.config?.customBalances)
|
|
1521
1552
|
? ledger.config.customBalances : []
|
|
1522
1553
|
const customBalanceCaches = {}
|
|
1554
|
+
const customBalanceFingerprint = index => {
|
|
1555
|
+
// Currency conversion is presentation-only; it must preserve the API snapshot.
|
|
1556
|
+
const { convertToDisplayCurrency, labelEn, ...query } = customBalanceConfigs()[index] ?? {}
|
|
1557
|
+
return JSON.stringify([query, localeOf(ledger.config)])
|
|
1558
|
+
}
|
|
1559
|
+
const discardCustomBalanceCache = index => {
|
|
1560
|
+
customBalanceCaches[index]?.controller?.abort()
|
|
1561
|
+
delete customBalanceCaches[index]
|
|
1562
|
+
}
|
|
1563
|
+
ctx.effect(() => () => {
|
|
1564
|
+
for (const index of Object.keys(customBalanceCaches)) discardCustomBalanceCache(index)
|
|
1565
|
+
}, 'cost-meter: custom balance cancellation')
|
|
1523
1566
|
/**
|
|
1524
1567
|
* 作废引用了指定变量的全部自定义余额快照缓存(v1.7.6,issue #86):
|
|
1525
1568
|
* setCredential / clearCredential 写过 customVar 后调用,下一次状态组装
|
|
@@ -1530,17 +1573,24 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1530
1573
|
for (const key of Object.keys(customBalanceCaches)) {
|
|
1531
1574
|
const index = Number(key)
|
|
1532
1575
|
const uses = customBalanceCredentialVars(entries[index]).includes(varName)
|
|
1533
|
-
if (uses)
|
|
1576
|
+
if (uses) discardCustomBalanceCache(index)
|
|
1534
1577
|
}
|
|
1535
1578
|
}
|
|
1536
1579
|
const customBalanceCacheAt = index => {
|
|
1580
|
+
const fingerprint = customBalanceFingerprint(index)
|
|
1581
|
+
if (customBalanceCaches[index]?.fingerprint !== fingerprint) discardCustomBalanceCache(index)
|
|
1537
1582
|
if (customBalanceCaches[index] === undefined) {
|
|
1538
|
-
customBalanceCaches[index] = { fetchedAt: 0, value: emptyCustomBalance() }
|
|
1583
|
+
customBalanceCaches[index] = { fingerprint, fetchedAt: 0, value: emptyCustomBalance() }
|
|
1539
1584
|
}
|
|
1540
1585
|
return customBalanceCaches[index]
|
|
1541
1586
|
}
|
|
1542
1587
|
const ensureCustomBalance = async (force = false, index = null) => {
|
|
1588
|
+
if (!active) return
|
|
1543
1589
|
const entries = customBalanceConfigs()
|
|
1590
|
+
for (const key of Object.keys(customBalanceCaches)) {
|
|
1591
|
+
if (!entries[key]) discardCustomBalanceCache(key)
|
|
1592
|
+
else customBalanceCacheAt(key)
|
|
1593
|
+
}
|
|
1544
1594
|
const targets = []
|
|
1545
1595
|
if (index === null) {
|
|
1546
1596
|
// ambient:全部启用且非 off 的条目;一条都没有时清理出空缓存(与旧单条 off 行为一致)。
|
|
@@ -1549,7 +1599,7 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1549
1599
|
})
|
|
1550
1600
|
if (targets.length === 0) {
|
|
1551
1601
|
for (const key of Object.keys(customBalanceCaches)) {
|
|
1552
|
-
|
|
1602
|
+
discardCustomBalanceCache(key)
|
|
1553
1603
|
}
|
|
1554
1604
|
return
|
|
1555
1605
|
}
|
|
@@ -1562,6 +1612,7 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1562
1612
|
const ensureCustomBalanceEntry = async (force, index) => {
|
|
1563
1613
|
const config = customBalanceConfigs()[index]
|
|
1564
1614
|
const cache = customBalanceCacheAt(index)
|
|
1615
|
+
const current = () => active && customBalanceCaches[index] === cache && cache.fingerprint === customBalanceFingerprint(index)
|
|
1565
1616
|
const interval = Math.max(1, Number(config?.refreshMinutes) || 15) * 60_000
|
|
1566
1617
|
if (!force && Date.now() - cache.fetchedAt < interval) return
|
|
1567
1618
|
while (cache.inFlight !== undefined) {
|
|
@@ -1570,50 +1621,50 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1570
1621
|
// force 链式等待:等上一个任务结束后由本调用继续走新建流程,
|
|
1571
1622
|
// 保证手动强制刷新不被在途任务吞掉(否则返回的是即将过期的旧数据)。
|
|
1572
1623
|
await prev.catch(() => {})
|
|
1624
|
+
if (!current()) return
|
|
1573
1625
|
if (cache.inFlight === prev) break // 上一个任务结束后由本调用继续走新建流程
|
|
1574
1626
|
}
|
|
1575
1627
|
// queryCustomBalance 的单条入参重载:customBalances[i] 视图(兼容旧键,设置页
|
|
1576
1628
|
// 面向 entries 编辑)。
|
|
1577
1629
|
const configForEntry = { ...ledger.config, customBalance: config, customBalances: undefined }
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
}
|
|
1630
|
+
cache.controller = new AbortController()
|
|
1631
|
+
const task = queryCustomBalance(ctx, configForEntry, { signal: cache.controller.signal }).then(result => {
|
|
1632
|
+
if (!current()) return
|
|
1633
|
+
cache.fetchedAt = Date.now()
|
|
1634
|
+
cache.value = { status: 'ok', message: '', fetchedAt: cache.fetchedAt, index, ...result }
|
|
1583
1635
|
}, error => {
|
|
1636
|
+
if (!current()) return
|
|
1584
1637
|
// 失败不更新外层 fetchedAt:让下次轮询重试(PR #40,与 ensureGoQuota 同策略)。
|
|
1585
1638
|
// 软失败完整缓存;硬失败写 error 状态但保留旧 fetchedAt(UI 可见 + 自动重试)。
|
|
1586
1639
|
const now = Date.now()
|
|
1587
1640
|
const message = error instanceof Error ? error.message : String(error)
|
|
1588
1641
|
if (error && error.soft === true) {
|
|
1589
|
-
|
|
1642
|
+
cache.fetchedAt = now
|
|
1643
|
+
cache.value = {
|
|
1644
|
+
...emptyCustomBalance(),
|
|
1645
|
+
label: typeof config?.label === 'string' ? config.label : '',
|
|
1646
|
+
status: 'off',
|
|
1647
|
+
message,
|
|
1590
1648
|
fetchedAt: now,
|
|
1591
|
-
|
|
1592
|
-
...emptyCustomBalance(),
|
|
1593
|
-
label: typeof config?.label === 'string' ? config.label : '',
|
|
1594
|
-
status: 'off',
|
|
1595
|
-
message,
|
|
1596
|
-
fetchedAt: now,
|
|
1597
|
-
index,
|
|
1598
|
-
},
|
|
1649
|
+
index,
|
|
1599
1650
|
}
|
|
1600
1651
|
} else {
|
|
1601
|
-
|
|
1602
|
-
...
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
fetchedAt: now,
|
|
1609
|
-
index,
|
|
1610
|
-
},
|
|
1652
|
+
cache.value = {
|
|
1653
|
+
...emptyCustomBalance(),
|
|
1654
|
+
label: typeof config?.label === 'string' ? config.label : '',
|
|
1655
|
+
status: 'error',
|
|
1656
|
+
message,
|
|
1657
|
+
fetchedAt: now,
|
|
1658
|
+
index,
|
|
1611
1659
|
}
|
|
1612
1660
|
}
|
|
1613
1661
|
}).finally(() => {
|
|
1614
|
-
if (
|
|
1662
|
+
if (cache.inFlight === task) {
|
|
1663
|
+
delete cache.inFlight
|
|
1664
|
+
delete cache.controller
|
|
1665
|
+
}
|
|
1615
1666
|
})
|
|
1616
|
-
|
|
1667
|
+
cache.inFlight = task
|
|
1617
1668
|
await task
|
|
1618
1669
|
}
|
|
1619
1670
|
/** 多条快照(仅含有缓存值的条目;旧单条镜像取第一条可见条目)。 */
|
|
@@ -1621,7 +1672,7 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1621
1672
|
const entries = customBalanceConfigs()
|
|
1622
1673
|
const out = []
|
|
1623
1674
|
entries.forEach((entry, index) => {
|
|
1624
|
-
const cached =
|
|
1675
|
+
const cached = active ? customBalanceCacheAt(index) : undefined
|
|
1625
1676
|
if (cached === undefined) return
|
|
1626
1677
|
out.push({ ...emptyCustomBalance(), ...cached.value, index })
|
|
1627
1678
|
})
|
|
@@ -1738,10 +1789,12 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1738
1789
|
|
|
1739
1790
|
/** 按需刷新单家 coding plan 额度(过期或 force);未启用/显示关闭/失败均落空或 error 状态。 */
|
|
1740
1791
|
const ensureCodingPlan = async (id, force = false) => {
|
|
1792
|
+
if (!active) return
|
|
1741
1793
|
if (id === 'qwen') return ensureQwenPlan(force)
|
|
1742
1794
|
if (id === 'minimax') return ensureMiniMaxPlan(force)
|
|
1743
1795
|
const config = codingPlanConfigOf(id)
|
|
1744
1796
|
if (config.enabled !== true || config.display === 'off') {
|
|
1797
|
+
codingPlanCaches[id]?.controller?.abort()
|
|
1745
1798
|
codingPlanCaches[id] = { fetchedAt: Date.now(), value: emptyCodingPlan() }
|
|
1746
1799
|
return
|
|
1747
1800
|
}
|
|
@@ -1780,22 +1833,27 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1780
1833
|
// force 链式等待:等上一个任务结束后由本调用继续走新建流程,
|
|
1781
1834
|
// 保证手动强制刷新不被在途任务吞掉(否则返回的是即将过期的旧数据)。
|
|
1782
1835
|
await prev.catch(() => {})
|
|
1836
|
+
if (!active || codingPlanConfigOf(id).enabled !== true || codingPlanConfigOf(id).display === 'off') return
|
|
1783
1837
|
// 任务完成时代码会整体替换 codingPlanCaches[id] 条目:必须重读活引用后再
|
|
1784
1838
|
// 回到 while 条件判定,否则旧对象上的 inFlight 恒等于 prev,并发 force 刷新
|
|
1785
1839
|
// 会各自重复发起任务(双重上游请求)。
|
|
1786
1840
|
cache = codingPlanCaches[id]
|
|
1787
1841
|
}
|
|
1788
1842
|
const locale = localeOf(ledger.config)
|
|
1843
|
+
const controller = new AbortController()
|
|
1789
1844
|
const task = (async () => {
|
|
1790
1845
|
const key = await resolveCodingPlanKey(ctx, id, ledger.config)
|
|
1791
1846
|
return queryCodingPlan(id, key, locale, tmsg, {
|
|
1847
|
+
signal: controller.signal,
|
|
1792
1848
|
onAttempt: attempt => ctx.logger?.debug?.('[cost-meter] quota candidate', attempt),
|
|
1793
1849
|
})
|
|
1794
1850
|
})().then(result => {
|
|
1851
|
+
if (!active || codingPlanCaches[id]?.inFlight !== task) return
|
|
1795
1852
|
codingPlanCaches[id] = { fetchedAt: Date.now(), value: { status: 'ok', message: '', fetchedAt: Date.now(), windows: result.windows } }
|
|
1796
1853
|
// SCNet 百分比来自本地自估(自我引用),不参与采样估算。
|
|
1797
1854
|
if (id !== 'scnet') recordQuotaSamples(id, result.windows)
|
|
1798
1855
|
}, error => {
|
|
1856
|
+
if (!active || codingPlanCaches[id]?.inFlight !== task) return
|
|
1799
1857
|
// 失败不更新外层 fetchedAt:让下次轮询重试(PR #40,与 ensureGoQuota 同策略)。
|
|
1800
1858
|
// 软失败(未配置凭据/无订阅)完整缓存;硬失败写 error 状态但保留旧 fetchedAt。
|
|
1801
1859
|
const now = Date.now()
|
|
@@ -1811,7 +1869,7 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1811
1869
|
}).finally(() => {
|
|
1812
1870
|
if (codingPlanCaches[id]?.inFlight === task) delete codingPlanCaches[id].inFlight
|
|
1813
1871
|
})
|
|
1814
|
-
codingPlanCaches[id] = { ...(codingPlanCaches[id] ?? { fetchedAt: 0, value: emptyCodingPlan() }), inFlight: task }
|
|
1872
|
+
codingPlanCaches[id] = { ...(codingPlanCaches[id] ?? { fetchedAt: 0, value: emptyCodingPlan() }), inFlight: task, controller }
|
|
1815
1873
|
await task
|
|
1816
1874
|
}
|
|
1817
1875
|
|
|
@@ -1826,11 +1884,15 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1826
1884
|
const configured = await resolveGatewayKeyConfigured(ctx, source)
|
|
1827
1885
|
const fingerprint = gatewaySourceFingerprint(source, configured)
|
|
1828
1886
|
const configFingerprint = gatewaySourceConfigFingerprintOf(source)
|
|
1887
|
+
const currentSource = () => active && gatewaySourceConfigs().some(entry => entry.id === id && entry.enabled !== false
|
|
1888
|
+
&& gatewaySourceConfigFingerprintOf(entry) === configFingerprint)
|
|
1889
|
+
if (!currentSource()) return emptyGatewayForSource(source)
|
|
1829
1890
|
let cache = gatewayQuotaCaches[id]
|
|
1830
1891
|
if (cache === undefined || cache.configFingerprint !== configFingerprint || cache.keyConfigured !== configured) {
|
|
1831
1892
|
// Preserve only a valid LKG when config/key identity is unchanged. A changed
|
|
1832
1893
|
// source starts empty; an old in-flight task is intentionally detached.
|
|
1833
|
-
cache = { fetchedAt: 0, value: emptyGatewayForSource(source), lastGood: null, sourceFingerprint: fingerprint
|
|
1894
|
+
cache = { fetchedAt: 0, value: emptyGatewayForSource(source), lastGood: null, sourceFingerprint: fingerprint,
|
|
1895
|
+
configFingerprint, keyConfigured: configured }
|
|
1834
1896
|
gatewayQuotaCaches[id] = cache
|
|
1835
1897
|
}
|
|
1836
1898
|
const interval = Math.max(1, Number(source.refreshMinutes) || 15) * 60_000
|
|
@@ -1843,8 +1905,9 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1843
1905
|
}
|
|
1844
1906
|
// Force intentionally waits for the current request, then starts a new one.
|
|
1845
1907
|
await current.catch(() => {})
|
|
1908
|
+
if (!currentSource()) return emptyGatewayForSource(source)
|
|
1846
1909
|
cache = gatewayQuotaCaches[id]
|
|
1847
|
-
if (cache
|
|
1910
|
+
if (cache === undefined || cache.inFlight !== undefined) return ensureGatewayQuota(source, true)
|
|
1848
1911
|
}
|
|
1849
1912
|
const task = queryGatewayQuota(ctx, source).then(value => {
|
|
1850
1913
|
const active = gatewayQuotaCaches[id]
|
|
@@ -1856,14 +1919,14 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1856
1919
|
message: typeof value?.message === 'string' ? value.message : '', attemptedAt: value?.attemptedAt ?? Date.now(),
|
|
1857
1920
|
keyConfigured: value?.keyConfigured === true, keySource: typeof value?.keySource === 'string' ? value.keySource : 'none',
|
|
1858
1921
|
}
|
|
1859
|
-
gatewayQuotaCaches[id] = { fetchedAt: ok ? Date.now() : (active.fetchedAt ?? 0), value: next, lastGood
|
|
1922
|
+
gatewayQuotaCaches[id] = { ...active, fetchedAt: ok ? Date.now() : (active.fetchedAt ?? 0), value: next, lastGood }
|
|
1860
1923
|
return next
|
|
1861
1924
|
}).catch(error => {
|
|
1862
1925
|
const active = gatewayQuotaCaches[id]
|
|
1863
1926
|
if (active?.inFlight !== task || active.sourceFingerprint !== fingerprint) return active?.value ?? emptyGatewayForSource(source)
|
|
1864
1927
|
const lastGood = active.lastGood
|
|
1865
1928
|
const next = { ...(lastGood ?? emptyGatewayForSource(source)), status: lastGood ? 'stale' : 'error', message: 'gateway query failed', attemptedAt: Date.now() }
|
|
1866
|
-
gatewayQuotaCaches[id] = { fetchedAt: active.fetchedAt ?? 0, value: next, lastGood
|
|
1929
|
+
gatewayQuotaCaches[id] = { ...active, fetchedAt: active.fetchedAt ?? 0, value: next, lastGood }
|
|
1867
1930
|
return next
|
|
1868
1931
|
}).finally(() => {
|
|
1869
1932
|
if (gatewayQuotaCaches[id]?.inFlight === task) {
|
|
@@ -1901,7 +1964,9 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1901
1964
|
}
|
|
1902
1965
|
kick(balanceCache.fetchedAt > 0, () => ensureBalance(forceBalance))
|
|
1903
1966
|
kick(goQuotaCache.fetchedAt > 0, () => ensureGoQuota(false))
|
|
1904
|
-
|
|
1967
|
+
const customWarm = customBalanceConfigs().every((entry, index) => entry?.enabled !== true || entry.display === 'off'
|
|
1968
|
+
|| customBalanceCacheAt(index).fetchedAt > 0)
|
|
1969
|
+
kick(customWarm, () => ensureCustomBalance(false))
|
|
1905
1970
|
kick(Object.values(codingPlanCaches).some(cache => (cache?.fetchedAt ?? 0) > 0), () => ensureCodingPlans(false))
|
|
1906
1971
|
pending.push(ensureCodingPlan('scnet', false))
|
|
1907
1972
|
const enabledGatewaySources = gatewaySourceConfigs().filter(source => source?.enabled !== false)
|
|
@@ -1918,6 +1983,7 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1918
1983
|
},
|
|
1919
1984
|
|
|
1920
1985
|
async updateConfig(patch) {
|
|
1986
|
+
const before = ledger.config
|
|
1921
1987
|
const gatewayBefore = ledger.config?.gatewayQuotas
|
|
1922
1988
|
const gatewayBeforeSources = gatewayBefore?.sources
|
|
1923
1989
|
const currencyBefore = ledger.config.prices?.currency === 'CNY' ? 'CNY' : 'USD'
|
|
@@ -1927,6 +1993,12 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1927
1993
|
throw new Error(tmsg(locale, 'configRejected', { errors: errors.join(locale === 'zh' ? ';' : '; ') }))
|
|
1928
1994
|
}
|
|
1929
1995
|
ledger.config = config
|
|
1996
|
+
// Cache values belong to their query settings and credential generation.
|
|
1997
|
+
// Reset before build() so disabled or changed queries cannot publish late results.
|
|
1998
|
+
for (const target of ['balance', 'goQuota', ...CODING_PLAN_PROVIDER_IDS.map(id => 'codingPlans.' + id)]) {
|
|
1999
|
+
const value = cfg => target.startsWith('codingPlans.') ? cfg.codingPlans?.[target.split('.')[1]] : cfg[target]
|
|
2000
|
+
if (before.locale !== config.locale || JSON.stringify(value(before)) !== JSON.stringify(value(config))) resetQueryCache(target)
|
|
2001
|
+
}
|
|
1930
2002
|
if (patch?.gatewayQuotas !== undefined || gatewayBefore !== config.gatewayQuotas) {
|
|
1931
2003
|
syncGatewayQuotaCaches(gatewayBeforeSources, config.gatewayQuotas?.sources)
|
|
1932
2004
|
}
|
|
@@ -2104,6 +2176,7 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
2104
2176
|
invalidateCustomBalanceCachesForVar(customVar)
|
|
2105
2177
|
for (const source of gatewaySourceConfigs()) if (managementKeyVarOf(source) === customVar) delete gatewayQuotaCaches[source.id]
|
|
2106
2178
|
}
|
|
2179
|
+
else resetQueryCache(key)
|
|
2107
2180
|
return {
|
|
2108
2181
|
ok: true,
|
|
2109
2182
|
message: tmsg(locale, 'credentialSaved'),
|
|
@@ -2144,6 +2217,7 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
2144
2217
|
invalidateCustomBalanceCachesForVar(customVar)
|
|
2145
2218
|
for (const source of gatewaySourceConfigs()) if (managementKeyVarOf(source) === customVar) delete gatewayQuotaCaches[source.id]
|
|
2146
2219
|
}
|
|
2220
|
+
else resetQueryCache(key)
|
|
2147
2221
|
return {
|
|
2148
2222
|
ok: true,
|
|
2149
2223
|
message: tmsg(locale, 'credentialCleared'),
|
package/lib/store.js
CHANGED
|
@@ -1972,6 +1972,9 @@ export class Ledger {
|
|
|
1972
1972
|
let historyReset = null
|
|
1973
1973
|
try {
|
|
1974
1974
|
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
1975
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1976
|
+
throw new Error('Unsupported ledger format')
|
|
1977
|
+
}
|
|
1975
1978
|
if (parsed !== null && typeof parsed === 'object') {
|
|
1976
1979
|
if (parsed.version !== LEDGER_VERSION) {
|
|
1977
1980
|
// 先把旧文件改名留存,再按空账本启动:防止下一次 flush 直接覆盖销毁历史。
|
package/lib/usage-dedup.js
CHANGED
|
@@ -59,12 +59,16 @@ export function createUsageDeduper({ windowMs = USAGE_DEDUP_WINDOW_MS, now = Dat
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
const pruneIfNeeded =
|
|
62
|
+
// 不再访问的会话也要清理过期指纹。登记本次调用后再裁剪,避免把尚未
|
|
63
|
+
// 写入指纹的新会话当作空条目删除,使超过阈值后的转发对重复入账。
|
|
64
|
+
let nextPruneAt = 0
|
|
65
|
+
const pruneIfNeeded = t => {
|
|
66
66
|
if (sessions.size <= 1024) return
|
|
67
|
+
if (t < nextPruneAt) return
|
|
68
|
+
nextPruneAt = t + windowMs
|
|
67
69
|
for (const [key, entry] of sessions) {
|
|
70
|
+
sweep(entry.plain, t)
|
|
71
|
+
sweep(entry.remapped, t)
|
|
68
72
|
if (entry.plain.size === 0 && entry.remapped.size === 0) sessions.delete(key)
|
|
69
73
|
}
|
|
70
74
|
}
|
|
@@ -94,13 +98,13 @@ export function createUsageDeduper({ windowMs = USAGE_DEDUP_WINDOW_MS, now = Dat
|
|
|
94
98
|
}
|
|
95
99
|
sweep(entry.plain, t)
|
|
96
100
|
sweep(entry.remapped, t)
|
|
97
|
-
pruneIfNeeded()
|
|
98
101
|
const book = wrapped ? entry.remapped : entry.plain
|
|
99
102
|
// 包装样本与普通样本、改挂样本都互斥;普通样本只与改挂样本互斥。
|
|
100
103
|
if (wrapped ? (entry.plain.has(fp) || entry.remapped.has(fp)) : entry.remapped.has(fp)) {
|
|
101
104
|
return null
|
|
102
105
|
}
|
|
103
106
|
book.set(fp, t)
|
|
107
|
+
pruneIfNeeded(t)
|
|
104
108
|
if (book.size > maxEntries) {
|
|
105
109
|
// 容量护栏:淘汰最旧条目(窗口语义只关心最近的转发对)。
|
|
106
110
|
let oldestFp = null
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-cost-meter",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.40",
|
|
4
4
|
"description": "DeepSeek Harness 会话费用统计插件:本会话成本、当日费用、历史记录与官方价格同步,支持多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配)、主流 Coding Plan 订阅额度查询与显示(11 家,含小米 MiMo Token Plan 控制台 Cookie 查询、SCNet Token Plan 本地 Credits 计量与火山方舟 Volcano Ark Coding Plan AK/SK 签名查询)、自定义 Provider 余额查询(可配任意 HTTP 端点)与余额进度条、峰谷计价时段显示与切换前弹窗/系统通知提醒(位置/提前量/类型可配),界面中英双语。Session cost tracking plugin for DeepSeek Harness: per-conversation cost, daily totals, history and official price sync, with multi-vendor model pricing (built-in 90+ model catalog with auto-matching), Coding Plan quota queries & display for 11 vendors (incl. Xiaomi MiMo Token Plan via console cookie, local credits metering for the SCNet Token Plan and Volcano Ark Coding Plan via AK/SK signature), plan/API dual-track billing with per-1% & full-window Token Plan usage estimates, custom provider balance lookup (configurable HTTP endpoint) and balance progress bar, plus peak/off-peak pricing display with pre-switch popup & system-notification alerts (position / lead time / type configurable), in a bilingual (Chinese/English) UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
|
-
"bin": {
|
|
7
|
+
"bin": {
|
|
8
|
+
"dsh-cost-meter-repair-sessions": "./lib/repair-sessions-cli.js"
|
|
9
|
+
},
|
|
8
10
|
"exports": {
|
|
9
11
|
".": {
|
|
10
12
|
"default": "./lib/index.js"
|
|
@@ -15,11 +17,16 @@
|
|
|
15
17
|
"./typert": {
|
|
16
18
|
"default": "./lib/typert.host.js"
|
|
17
19
|
},
|
|
18
|
-
"./package.json": "./package.json"
|
|
20
|
+
"./package.json": "./package.json",
|
|
21
|
+
"./locale/*.json": "./locale/*.json"
|
|
19
22
|
},
|
|
20
23
|
"dsh": {
|
|
21
|
-
"bundle": {
|
|
22
|
-
|
|
24
|
+
"bundle": {
|
|
25
|
+
"patch": "./cordis.patch.yml"
|
|
26
|
+
},
|
|
27
|
+
"client": {
|
|
28
|
+
"platform": "web"
|
|
29
|
+
},
|
|
23
30
|
"compatibility": {
|
|
24
31
|
"dsh": ">=0.1.0-rc.5",
|
|
25
32
|
"dshReleases": {
|
|
@@ -82,6 +89,7 @@
|
|
|
82
89
|
}
|
|
83
90
|
},
|
|
84
91
|
"files": [
|
|
92
|
+
"locale",
|
|
85
93
|
"lib",
|
|
86
94
|
"cordis.patch.yml",
|
|
87
95
|
"docs/provider-pricing.json",
|
|
@@ -119,7 +127,11 @@
|
|
|
119
127
|
"engines": {
|
|
120
128
|
"node": ">=20"
|
|
121
129
|
},
|
|
122
|
-
"os": [
|
|
130
|
+
"os": [
|
|
131
|
+
"darwin",
|
|
132
|
+
"linux",
|
|
133
|
+
"win32"
|
|
134
|
+
],
|
|
123
135
|
"scripts": {
|
|
124
136
|
"build": "node scripts/build.mjs",
|
|
125
137
|
"test": "node test/verify.mjs"
|