dsh-cost-meter 1.7.37 → 1.7.39
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 +7 -6
- package/README.zh-CN.md +7 -6
- package/docs/external-usage.md +61 -0
- package/lib/aliyun-balance.js +7 -3
- package/lib/client.js +5 -5
- package/lib/custom-balance.js +5 -3
- package/lib/external-usage.js +111 -0
- package/lib/index.js +122 -44
- package/lib/store.js +3 -0
- package/lib/typert.host.js +9 -0
- package/lib/usage-dedup.js +9 -5
- package/package.json +3 -2
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) {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { constants } from 'node:fs'
|
|
2
|
+
import { open } from 'node:fs/promises'
|
|
3
|
+
import { localDayKey, zeroDay } from './store.js'
|
|
4
|
+
import { costOf, providerPriceEntryFor, usdFromCost } from './pricing.js'
|
|
5
|
+
|
|
6
|
+
export const EXTERNAL_USAGE_MAX_BYTES = 512 * 1024
|
|
7
|
+
const MAX_AGE = 30 * 86400000
|
|
8
|
+
const FRESH_AGE = 86400000
|
|
9
|
+
const fields = ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning', 'calls', 'cost', 'apiCost']
|
|
10
|
+
const object = v => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
11
|
+
const count = v => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 && v <= 1e12
|
|
12
|
+
const money = v => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1e9
|
|
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) && dateKey(v.slice(0, 10)) ? Date.parse(v) : NaN
|
|
15
|
+
|
|
16
|
+
function add(into, day) {
|
|
17
|
+
for (const key of fields) into[key] += day[key]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function summary(days, dayKey) {
|
|
21
|
+
const today = zeroDay(dayKey)
|
|
22
|
+
const month = zeroDay(dayKey.slice(0, 7))
|
|
23
|
+
const total = zeroDay('total')
|
|
24
|
+
const history = Object.values(days).sort((a, b) => b.date.localeCompare(a.date)).slice(0, 90)
|
|
25
|
+
for (const day of Object.values(days)) {
|
|
26
|
+
add(total, day)
|
|
27
|
+
if (day.date.startsWith(dayKey.slice(0, 7))) add(month, day)
|
|
28
|
+
if (day.date === dayKey) add(today, day)
|
|
29
|
+
}
|
|
30
|
+
return { today, month, total, history }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A snapshot replaces its predecessor; it never mutates the DSH ledger or balance reconciliation. */
|
|
34
|
+
export function parseExternalUsageSnapshot(value, config, nowMs = Date.now()) {
|
|
35
|
+
if (!object(value)) return null
|
|
36
|
+
if (Object.hasOwn(value, 'sources') && !Array.isArray(value.sources)) return null
|
|
37
|
+
const fetchedAt = timestamp(value.fetchedAt)
|
|
38
|
+
if (!Number.isFinite(fetchedAt) || fetchedAt > nowMs + 300000 || nowMs - fetchedAt > MAX_AGE) return null
|
|
39
|
+
const entries = Array.isArray(value.sources) ? value.sources : [value]
|
|
40
|
+
if (entries.length < 1 || entries.length > 8) return null
|
|
41
|
+
const seen = new Set()
|
|
42
|
+
const sources = []
|
|
43
|
+
const combinedDays = {}
|
|
44
|
+
let recordCount = 0
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
if (!object(entry) || typeof entry.source !== 'string' || entry.source.length < 1 || entry.source.length > 64 || seen.has(entry.source)) return null
|
|
47
|
+
seen.add(entry.source)
|
|
48
|
+
if (object(entry.days) === Array.isArray(entry.records)) return null // exactly one input mode
|
|
49
|
+
const days = {}
|
|
50
|
+
if (object(entry.days)) {
|
|
51
|
+
if (Object.keys(entry.days).length > 3660) return null
|
|
52
|
+
for (const [date, raw] of Object.entries(entry.days)) {
|
|
53
|
+
if (!dateKey(date) || date > localDayKey(nowMs) || !object(raw) || !count(raw.input) || !count(raw.output) || !count(raw.cached) || !count(raw.calls) || !money(raw.costUsd)) return null
|
|
54
|
+
if (raw.cacheWrite !== undefined && !count(raw.cacheWrite)) return null
|
|
55
|
+
if (raw.reasoning !== undefined && !count(raw.reasoning)) return null
|
|
56
|
+
days[date] = { ...zeroDay(date), input: raw.input, output: raw.output, cacheRead: raw.cached, cacheWrite: raw.cacheWrite ?? 0, reasoning: raw.reasoning ?? 0, calls: raw.calls, cost: raw.costUsd, apiCost: raw.costUsd }
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
recordCount += entry.records.length
|
|
60
|
+
if (recordCount > 5000) return null
|
|
61
|
+
const ids = new Set()
|
|
62
|
+
for (const raw of entry.records) {
|
|
63
|
+
if (!object(raw) || typeof raw.id !== 'string' || raw.id.length < 1 || raw.id.length > 128 || ids.has(raw.id)) return null
|
|
64
|
+
ids.add(raw.id)
|
|
65
|
+
const at = timestamp(raw.at)
|
|
66
|
+
if (!Number.isFinite(at) || at > nowMs + 300000 || !count(raw.input) || !count(raw.output) || !count(raw.cached)) return null
|
|
67
|
+
if (raw.cacheWrite !== undefined && !count(raw.cacheWrite)) return null
|
|
68
|
+
if (raw.reasoning !== undefined && !count(raw.reasoning)) return null
|
|
69
|
+
if (typeof raw.provider !== 'string' || !raw.provider || typeof raw.model !== 'string' || !raw.model) return null
|
|
70
|
+
const resolved = providerPriceEntryFor(raw.provider, raw.model, config?.prices, {
|
|
71
|
+
mode: config?.priceMatch === 'exact' ? 'exact' : 'auto', overrides: config?.priceOverrides,
|
|
72
|
+
})
|
|
73
|
+
if (!resolved.priced) return null
|
|
74
|
+
const peak = { enabled: resolved.billingMode === 'deepseek-peak' && config?.peakEnabled === true,
|
|
75
|
+
effectiveAtMs: Date.parse(config?.peakEffectiveAt ?? ''), windows: config?.peakWindows, holidays: config?.peakHolidays }
|
|
76
|
+
const tokens = { input: raw.input, output: raw.output, cacheRead: raw.cached, cacheWrite: raw.cacheWrite ?? 0, reasoning: raw.reasoning ?? 0 }
|
|
77
|
+
const priced = costOf(tokens, resolved.entry, at, peak)
|
|
78
|
+
const usd = usdFromCost(priced, resolved.billingMode === 'deepseek-peak' && config?.prices?.currency === 'CNY' ? 'CNY' : 'USD', config?.exchangeRate)
|
|
79
|
+
const date = localDayKey(at)
|
|
80
|
+
const day = days[date] ?? (days[date] = zeroDay(date))
|
|
81
|
+
for (const key of ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning']) day[key] += tokens[key]
|
|
82
|
+
day.calls++
|
|
83
|
+
day.cost += usd
|
|
84
|
+
day.apiCost += usd
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const [date, day] of Object.entries(days)) add(combinedDays[date] ?? (combinedDays[date] = zeroDay(date)), day)
|
|
88
|
+
sources.push({ source: entry.source, ...summary(days, localDayKey(nowMs)) })
|
|
89
|
+
}
|
|
90
|
+
return { fetchedAt, stale: nowMs - fetchedAt > FRESH_AGE, sources,
|
|
91
|
+
combined: summary(combinedDays, localDayKey(nowMs)) }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Bounded, fail-closed read of a regular file. Producers should write a temp file and rename it. */
|
|
95
|
+
export async function readExternalUsageSnapshot(path, config, nowMs = Date.now()) {
|
|
96
|
+
let file
|
|
97
|
+
try {
|
|
98
|
+
file = await open(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0))
|
|
99
|
+
const stat = await file.stat()
|
|
100
|
+
if (!stat.isFile() || stat.size > EXTERNAL_USAGE_MAX_BYTES) return null
|
|
101
|
+
const buffer = Buffer.alloc(EXTERNAL_USAGE_MAX_BYTES + 1)
|
|
102
|
+
let length = 0
|
|
103
|
+
while (length < buffer.length) {
|
|
104
|
+
const { bytesRead } = await file.read(buffer, length, buffer.length - length, null)
|
|
105
|
+
if (!bytesRead) break
|
|
106
|
+
length += bytesRead
|
|
107
|
+
}
|
|
108
|
+
if (length > EXTERNAL_USAGE_MAX_BYTES) return null
|
|
109
|
+
return parseExternalUsageSnapshot(JSON.parse(buffer.subarray(0, length).toString('utf8')), config, nowMs)
|
|
110
|
+
} catch { return null } finally { await file?.close().catch(() => {}) }
|
|
111
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -33,6 +33,7 @@ import { queryGatewayQuota, emptyGatewayQuota, managementKeyVarOf, gatewaySource
|
|
|
33
33
|
import { stateSchema, TYPERT } from './typert.host.js'
|
|
34
34
|
import { customBalanceCredentialVars } from './custom-balance.js'
|
|
35
35
|
import { readScnetSnapshot } from './scnet-snapshot.js'
|
|
36
|
+
import { readExternalUsageSnapshot } from './external-usage.js'
|
|
36
37
|
import { queryQwenCli } from './qwen-cli.js'
|
|
37
38
|
import { queryBailianCli } from './bailian-cli.js'
|
|
38
39
|
import { getSessionCost as readSessionCost } from './session-tree.js'
|
|
@@ -776,7 +777,7 @@ function normalizeGoWindow(raw) {
|
|
|
776
777
|
* @param config - 插件配置(goQuota.apiKey / 消息语言)。
|
|
777
778
|
* @param locale - 消息语言(zh/en)。
|
|
778
779
|
*/
|
|
779
|
-
async function queryGoQuota(ctx, config, locale) {
|
|
780
|
+
async function queryGoQuota(ctx, config, locale, signal) {
|
|
780
781
|
const key = await resolveGoKey(ctx, config)
|
|
781
782
|
if (key === null) {
|
|
782
783
|
const error = new Error(tmsg(locale, 'goQuotaKeyMissing'))
|
|
@@ -786,6 +787,7 @@ async function queryGoQuota(ctx, config, locale) {
|
|
|
786
787
|
// Cloudflare 前置会间歇性重置连接(ECONNRESET → fetch failed):瞬时网络错误
|
|
787
788
|
// 自动重试,401/403 等业务状态仍走下方原语义(issue #28)。
|
|
788
789
|
const response = await fetchWithRetry(GO_QUOTA_URL, {
|
|
790
|
+
signal,
|
|
789
791
|
headers: {
|
|
790
792
|
authorization: `Bearer ${key}`,
|
|
791
793
|
// 浏览器 UA:避免被 opencode.ai 前置 Cloudflare 以 error 1010 拦截。
|
|
@@ -943,7 +945,7 @@ function balanceEndpoint(baseURL) {
|
|
|
943
945
|
* @param locale - 消息语言(zh/en)。
|
|
944
946
|
* @returns { currency, totalBalance, grantedBalance, toppedUpBalance }。
|
|
945
947
|
*/
|
|
946
|
-
async function queryBalance(ctx, locale) {
|
|
948
|
+
async function queryBalance(ctx, locale, signal) {
|
|
947
949
|
const settings = ctx.get('settings')
|
|
948
950
|
const section = typeof settings?.get === 'function' ? settings.get('llm-deepseek') : undefined
|
|
949
951
|
const baseURL = section?.baseURL
|
|
@@ -975,6 +977,7 @@ async function queryBalance(ctx, locale) {
|
|
|
975
977
|
}
|
|
976
978
|
// 瞬时网络错误自动重试(issue #28 同一封装);非 2xx 状态仍按业务错误处理。
|
|
977
979
|
const response = await fetchWithRetry(endpoint, {
|
|
980
|
+
signal,
|
|
978
981
|
headers: { authorization: `Bearer ${apiKey}` },
|
|
979
982
|
}, { timeoutMs: 15000 })
|
|
980
983
|
if (!response.ok) throw new Error(tmsg(locale, 'balanceHttp', { code: String(response.status) }))
|
|
@@ -1165,6 +1168,9 @@ async function buildState(ledger, balance = emptyBalance(), goQuota = emptyGoQuo
|
|
|
1165
1168
|
codingPlans,
|
|
1166
1169
|
planStats,
|
|
1167
1170
|
history: ledger.history(90),
|
|
1171
|
+
// 独立来源账本只读快照;today/month/history 继续仅指 DSH,官方余额对账不混入外部费用。
|
|
1172
|
+
...(await readExternalUsageSnapshot(join(dirname(ledger.path), 'external_usage.json'), ledger.config, now)
|
|
1173
|
+
.then(externalUsage => externalUsage ? { externalUsage } : {})),
|
|
1168
1174
|
// 密钥脱敏(v1.6.8):下发给前端的 config 不含任何明文密钥,只留空占位字符串。
|
|
1169
1175
|
config: clientConfig,
|
|
1170
1176
|
priceCatalog: ledger.config.prices?.currency === 'CNY' ? PRICE_CATALOG_CNY : PRICE_CATALOG,
|
|
@@ -1313,17 +1319,32 @@ async function describeGoKey(ctx, config) {
|
|
|
1313
1319
|
}
|
|
1314
1320
|
|
|
1315
1321
|
function createService(ctx, ledger, openRouterPrices) {
|
|
1322
|
+
let active = true
|
|
1316
1323
|
// 余额进程内缓存:display=off 时不清缓存但不下发;按 refreshMinutes 过期。
|
|
1317
1324
|
let balanceCache = { fetchedAt: 0, value: emptyBalance() }
|
|
1318
1325
|
// OpenCode Go 订阅额度进程内缓存(同上策略)。
|
|
1319
1326
|
let goQuotaCache = { fetchedAt: 0, value: emptyGoQuota() }
|
|
1320
1327
|
// Coding plan 额度进程内缓存(每家一个条目,同上策略)。
|
|
1321
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
|
+
}
|
|
1322
1342
|
ctx.effect(() => () => {
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1343
|
+
active = false
|
|
1344
|
+
gatewayQuotaCaches = {}
|
|
1345
|
+
resetQueryCache('balance')
|
|
1346
|
+
resetQueryCache('goQuota')
|
|
1347
|
+
for (const id of Object.keys(codingPlanCaches)) resetQueryCache('codingPlans.' + id)
|
|
1327
1348
|
}, 'cost-meter: quota query cancellation')
|
|
1328
1349
|
// 余额差对账提示(drift 时 ok=false 携带文案,其余静默)。
|
|
1329
1350
|
let reconcileNotice = { ok: true, message: '' }
|
|
@@ -1400,8 +1421,10 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1400
1421
|
|
|
1401
1422
|
/** 按需刷新余额(过期或 force);失败落 error 状态,不影响其余状态字段。 */
|
|
1402
1423
|
const ensureBalance = async (force = false) => {
|
|
1424
|
+
if (!active) return
|
|
1403
1425
|
const config = balanceConfig()
|
|
1404
1426
|
if (config.display === 'off') {
|
|
1427
|
+
balanceCache.controller?.abort()
|
|
1405
1428
|
balanceCache = { fetchedAt: Date.now(), value: emptyBalance() }
|
|
1406
1429
|
return
|
|
1407
1430
|
}
|
|
@@ -1413,9 +1436,12 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1413
1436
|
// force 链式等待:等上一个任务结束后由本调用继续走新建流程,
|
|
1414
1437
|
// 保证手动强制刷新不被在途任务吞掉(否则返回的是即将过期的旧数据)。
|
|
1415
1438
|
await prev.catch(() => {})
|
|
1439
|
+
if (!active || balanceConfig().display === 'off') return
|
|
1416
1440
|
if (balanceCache.inFlight === prev) break // 上一个任务结束后由本调用继续走新建流程
|
|
1417
1441
|
}
|
|
1418
|
-
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
|
|
1419
1445
|
balanceCache = { fetchedAt: Date.now(), value: { status: 'ok', message: '', fetchedAt: Date.now(), ...result } }
|
|
1420
1446
|
// 余额差交叉校验(issue #18):官方余额当日变动 vs 本地账本今日官方渠道费用,偏差超阈提示。
|
|
1421
1447
|
// issue #36:Coding Plan / 自定义 Provider 的费用不动官方余额,对账只统计 deepseek 渠道,否则订阅用户会恒报 drift。
|
|
@@ -1452,6 +1478,7 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1452
1478
|
: { ok: true, message: '' }
|
|
1453
1479
|
}
|
|
1454
1480
|
}, error => {
|
|
1481
|
+
if (!active || balanceCache.inFlight !== task) return
|
|
1455
1482
|
// 软失败(未配置 Key / 非官方端点等守卫错误,不会自愈)标记 fetchedAt 避免无意义重试;
|
|
1456
1483
|
// 硬失败(网络超时等临时性问题)写入 error 状态但保留外层 fetchedAt——UI 仍显示失败原因,
|
|
1457
1484
|
// 且下次轮询自动重试,不再被缓存有效期钉死(PR #40 补全)。
|
|
@@ -1466,13 +1493,16 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1466
1493
|
if (balanceCache.inFlight === task) delete balanceCache.inFlight
|
|
1467
1494
|
})
|
|
1468
1495
|
balanceCache.inFlight = task
|
|
1496
|
+
balanceCache.controller = controller
|
|
1469
1497
|
await task
|
|
1470
1498
|
}
|
|
1471
1499
|
|
|
1472
1500
|
/** 按需刷新 OpenCode Go 额度(过期或 force);未启用/显示关闭/失败均落空或 error 状态。 */
|
|
1473
1501
|
const ensureGoQuota = async (force = false) => {
|
|
1502
|
+
if (!active) return
|
|
1474
1503
|
const config = goQuotaConfig()
|
|
1475
1504
|
if (config.enabled === false || config.display === 'off') {
|
|
1505
|
+
goQuotaCache.controller?.abort()
|
|
1476
1506
|
goQuotaCache = { fetchedAt: Date.now(), value: emptyGoQuota() }
|
|
1477
1507
|
return
|
|
1478
1508
|
}
|
|
@@ -1484,12 +1514,16 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1484
1514
|
// force 链式等待:等上一个任务结束后由本调用继续走新建流程,
|
|
1485
1515
|
// 保证手动强制刷新不被在途任务吞掉(否则返回的是即将过期的旧数据)。
|
|
1486
1516
|
await prev.catch(() => {})
|
|
1517
|
+
if (!active || goQuotaConfig().enabled === false || goQuotaConfig().display === 'off') return
|
|
1487
1518
|
if (goQuotaCache.inFlight === prev) break // 上一个任务结束后由本调用继续走新建流程
|
|
1488
1519
|
}
|
|
1489
|
-
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
|
|
1490
1523
|
goQuotaCache = { fetchedAt: Date.now(), value: { status: 'ok', message: '', fetchedAt: Date.now(), ...result } }
|
|
1491
1524
|
recordQuotaSamples('go', { rolling: result.rolling, weekly: result.weekly, monthly: result.monthly })
|
|
1492
1525
|
}, error => {
|
|
1526
|
+
if (!active || goQuotaCache.inFlight !== task) return
|
|
1493
1527
|
// 失败不更新外层 fetchedAt:让下次轮询重试,而非在缓存有效期内一直跳过(PR #40)。
|
|
1494
1528
|
// 软失败(未登录/无订阅,不会自愈)完整缓存避免无意义重试;硬失败(网络超时等)写入
|
|
1495
1529
|
// error 状态但保留旧 fetchedAt——UI 仍显示失败原因,轮询到点自动重试。
|
|
@@ -1504,6 +1538,7 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1504
1538
|
if (goQuotaCache.inFlight === task) delete goQuotaCache.inFlight
|
|
1505
1539
|
})
|
|
1506
1540
|
goQuotaCache.inFlight = task
|
|
1541
|
+
goQuotaCache.controller = controller
|
|
1507
1542
|
await task
|
|
1508
1543
|
}
|
|
1509
1544
|
|
|
@@ -1516,6 +1551,18 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1516
1551
|
const customBalanceConfigs = () => Array.isArray(ledger.config?.customBalances)
|
|
1517
1552
|
? ledger.config.customBalances : []
|
|
1518
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')
|
|
1519
1566
|
/**
|
|
1520
1567
|
* 作废引用了指定变量的全部自定义余额快照缓存(v1.7.6,issue #86):
|
|
1521
1568
|
* setCredential / clearCredential 写过 customVar 后调用,下一次状态组装
|
|
@@ -1526,17 +1573,24 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1526
1573
|
for (const key of Object.keys(customBalanceCaches)) {
|
|
1527
1574
|
const index = Number(key)
|
|
1528
1575
|
const uses = customBalanceCredentialVars(entries[index]).includes(varName)
|
|
1529
|
-
if (uses)
|
|
1576
|
+
if (uses) discardCustomBalanceCache(index)
|
|
1530
1577
|
}
|
|
1531
1578
|
}
|
|
1532
1579
|
const customBalanceCacheAt = index => {
|
|
1580
|
+
const fingerprint = customBalanceFingerprint(index)
|
|
1581
|
+
if (customBalanceCaches[index]?.fingerprint !== fingerprint) discardCustomBalanceCache(index)
|
|
1533
1582
|
if (customBalanceCaches[index] === undefined) {
|
|
1534
|
-
customBalanceCaches[index] = { fetchedAt: 0, value: emptyCustomBalance() }
|
|
1583
|
+
customBalanceCaches[index] = { fingerprint, fetchedAt: 0, value: emptyCustomBalance() }
|
|
1535
1584
|
}
|
|
1536
1585
|
return customBalanceCaches[index]
|
|
1537
1586
|
}
|
|
1538
1587
|
const ensureCustomBalance = async (force = false, index = null) => {
|
|
1588
|
+
if (!active) return
|
|
1539
1589
|
const entries = customBalanceConfigs()
|
|
1590
|
+
for (const key of Object.keys(customBalanceCaches)) {
|
|
1591
|
+
if (!entries[key]) discardCustomBalanceCache(key)
|
|
1592
|
+
else customBalanceCacheAt(key)
|
|
1593
|
+
}
|
|
1540
1594
|
const targets = []
|
|
1541
1595
|
if (index === null) {
|
|
1542
1596
|
// ambient:全部启用且非 off 的条目;一条都没有时清理出空缓存(与旧单条 off 行为一致)。
|
|
@@ -1545,7 +1599,7 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1545
1599
|
})
|
|
1546
1600
|
if (targets.length === 0) {
|
|
1547
1601
|
for (const key of Object.keys(customBalanceCaches)) {
|
|
1548
|
-
|
|
1602
|
+
discardCustomBalanceCache(key)
|
|
1549
1603
|
}
|
|
1550
1604
|
return
|
|
1551
1605
|
}
|
|
@@ -1558,6 +1612,7 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1558
1612
|
const ensureCustomBalanceEntry = async (force, index) => {
|
|
1559
1613
|
const config = customBalanceConfigs()[index]
|
|
1560
1614
|
const cache = customBalanceCacheAt(index)
|
|
1615
|
+
const current = () => active && customBalanceCaches[index] === cache && cache.fingerprint === customBalanceFingerprint(index)
|
|
1561
1616
|
const interval = Math.max(1, Number(config?.refreshMinutes) || 15) * 60_000
|
|
1562
1617
|
if (!force && Date.now() - cache.fetchedAt < interval) return
|
|
1563
1618
|
while (cache.inFlight !== undefined) {
|
|
@@ -1566,50 +1621,50 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1566
1621
|
// force 链式等待:等上一个任务结束后由本调用继续走新建流程,
|
|
1567
1622
|
// 保证手动强制刷新不被在途任务吞掉(否则返回的是即将过期的旧数据)。
|
|
1568
1623
|
await prev.catch(() => {})
|
|
1624
|
+
if (!current()) return
|
|
1569
1625
|
if (cache.inFlight === prev) break // 上一个任务结束后由本调用继续走新建流程
|
|
1570
1626
|
}
|
|
1571
1627
|
// queryCustomBalance 的单条入参重载:customBalances[i] 视图(兼容旧键,设置页
|
|
1572
1628
|
// 面向 entries 编辑)。
|
|
1573
1629
|
const configForEntry = { ...ledger.config, customBalance: config, customBalances: undefined }
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
}
|
|
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 }
|
|
1579
1635
|
}, error => {
|
|
1636
|
+
if (!current()) return
|
|
1580
1637
|
// 失败不更新外层 fetchedAt:让下次轮询重试(PR #40,与 ensureGoQuota 同策略)。
|
|
1581
1638
|
// 软失败完整缓存;硬失败写 error 状态但保留旧 fetchedAt(UI 可见 + 自动重试)。
|
|
1582
1639
|
const now = Date.now()
|
|
1583
1640
|
const message = error instanceof Error ? error.message : String(error)
|
|
1584
1641
|
if (error && error.soft === true) {
|
|
1585
|
-
|
|
1642
|
+
cache.fetchedAt = now
|
|
1643
|
+
cache.value = {
|
|
1644
|
+
...emptyCustomBalance(),
|
|
1645
|
+
label: typeof config?.label === 'string' ? config.label : '',
|
|
1646
|
+
status: 'off',
|
|
1647
|
+
message,
|
|
1586
1648
|
fetchedAt: now,
|
|
1587
|
-
|
|
1588
|
-
...emptyCustomBalance(),
|
|
1589
|
-
label: typeof config?.label === 'string' ? config.label : '',
|
|
1590
|
-
status: 'off',
|
|
1591
|
-
message,
|
|
1592
|
-
fetchedAt: now,
|
|
1593
|
-
index,
|
|
1594
|
-
},
|
|
1649
|
+
index,
|
|
1595
1650
|
}
|
|
1596
1651
|
} else {
|
|
1597
|
-
|
|
1598
|
-
...
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
fetchedAt: now,
|
|
1605
|
-
index,
|
|
1606
|
-
},
|
|
1652
|
+
cache.value = {
|
|
1653
|
+
...emptyCustomBalance(),
|
|
1654
|
+
label: typeof config?.label === 'string' ? config.label : '',
|
|
1655
|
+
status: 'error',
|
|
1656
|
+
message,
|
|
1657
|
+
fetchedAt: now,
|
|
1658
|
+
index,
|
|
1607
1659
|
}
|
|
1608
1660
|
}
|
|
1609
1661
|
}).finally(() => {
|
|
1610
|
-
if (
|
|
1662
|
+
if (cache.inFlight === task) {
|
|
1663
|
+
delete cache.inFlight
|
|
1664
|
+
delete cache.controller
|
|
1665
|
+
}
|
|
1611
1666
|
})
|
|
1612
|
-
|
|
1667
|
+
cache.inFlight = task
|
|
1613
1668
|
await task
|
|
1614
1669
|
}
|
|
1615
1670
|
/** 多条快照(仅含有缓存值的条目;旧单条镜像取第一条可见条目)。 */
|
|
@@ -1617,7 +1672,7 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1617
1672
|
const entries = customBalanceConfigs()
|
|
1618
1673
|
const out = []
|
|
1619
1674
|
entries.forEach((entry, index) => {
|
|
1620
|
-
const cached =
|
|
1675
|
+
const cached = active ? customBalanceCacheAt(index) : undefined
|
|
1621
1676
|
if (cached === undefined) return
|
|
1622
1677
|
out.push({ ...emptyCustomBalance(), ...cached.value, index })
|
|
1623
1678
|
})
|
|
@@ -1734,10 +1789,12 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1734
1789
|
|
|
1735
1790
|
/** 按需刷新单家 coding plan 额度(过期或 force);未启用/显示关闭/失败均落空或 error 状态。 */
|
|
1736
1791
|
const ensureCodingPlan = async (id, force = false) => {
|
|
1792
|
+
if (!active) return
|
|
1737
1793
|
if (id === 'qwen') return ensureQwenPlan(force)
|
|
1738
1794
|
if (id === 'minimax') return ensureMiniMaxPlan(force)
|
|
1739
1795
|
const config = codingPlanConfigOf(id)
|
|
1740
1796
|
if (config.enabled !== true || config.display === 'off') {
|
|
1797
|
+
codingPlanCaches[id]?.controller?.abort()
|
|
1741
1798
|
codingPlanCaches[id] = { fetchedAt: Date.now(), value: emptyCodingPlan() }
|
|
1742
1799
|
return
|
|
1743
1800
|
}
|
|
@@ -1776,22 +1833,27 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1776
1833
|
// force 链式等待:等上一个任务结束后由本调用继续走新建流程,
|
|
1777
1834
|
// 保证手动强制刷新不被在途任务吞掉(否则返回的是即将过期的旧数据)。
|
|
1778
1835
|
await prev.catch(() => {})
|
|
1836
|
+
if (!active || codingPlanConfigOf(id).enabled !== true || codingPlanConfigOf(id).display === 'off') return
|
|
1779
1837
|
// 任务完成时代码会整体替换 codingPlanCaches[id] 条目:必须重读活引用后再
|
|
1780
1838
|
// 回到 while 条件判定,否则旧对象上的 inFlight 恒等于 prev,并发 force 刷新
|
|
1781
1839
|
// 会各自重复发起任务(双重上游请求)。
|
|
1782
1840
|
cache = codingPlanCaches[id]
|
|
1783
1841
|
}
|
|
1784
1842
|
const locale = localeOf(ledger.config)
|
|
1843
|
+
const controller = new AbortController()
|
|
1785
1844
|
const task = (async () => {
|
|
1786
1845
|
const key = await resolveCodingPlanKey(ctx, id, ledger.config)
|
|
1787
1846
|
return queryCodingPlan(id, key, locale, tmsg, {
|
|
1847
|
+
signal: controller.signal,
|
|
1788
1848
|
onAttempt: attempt => ctx.logger?.debug?.('[cost-meter] quota candidate', attempt),
|
|
1789
1849
|
})
|
|
1790
1850
|
})().then(result => {
|
|
1851
|
+
if (!active || codingPlanCaches[id]?.inFlight !== task) return
|
|
1791
1852
|
codingPlanCaches[id] = { fetchedAt: Date.now(), value: { status: 'ok', message: '', fetchedAt: Date.now(), windows: result.windows } }
|
|
1792
1853
|
// SCNet 百分比来自本地自估(自我引用),不参与采样估算。
|
|
1793
1854
|
if (id !== 'scnet') recordQuotaSamples(id, result.windows)
|
|
1794
1855
|
}, error => {
|
|
1856
|
+
if (!active || codingPlanCaches[id]?.inFlight !== task) return
|
|
1795
1857
|
// 失败不更新外层 fetchedAt:让下次轮询重试(PR #40,与 ensureGoQuota 同策略)。
|
|
1796
1858
|
// 软失败(未配置凭据/无订阅)完整缓存;硬失败写 error 状态但保留旧 fetchedAt。
|
|
1797
1859
|
const now = Date.now()
|
|
@@ -1807,7 +1869,7 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1807
1869
|
}).finally(() => {
|
|
1808
1870
|
if (codingPlanCaches[id]?.inFlight === task) delete codingPlanCaches[id].inFlight
|
|
1809
1871
|
})
|
|
1810
|
-
codingPlanCaches[id] = { ...(codingPlanCaches[id] ?? { fetchedAt: 0, value: emptyCodingPlan() }), inFlight: task }
|
|
1872
|
+
codingPlanCaches[id] = { ...(codingPlanCaches[id] ?? { fetchedAt: 0, value: emptyCodingPlan() }), inFlight: task, controller }
|
|
1811
1873
|
await task
|
|
1812
1874
|
}
|
|
1813
1875
|
|
|
@@ -1822,11 +1884,15 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1822
1884
|
const configured = await resolveGatewayKeyConfigured(ctx, source)
|
|
1823
1885
|
const fingerprint = gatewaySourceFingerprint(source, configured)
|
|
1824
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)
|
|
1825
1890
|
let cache = gatewayQuotaCaches[id]
|
|
1826
1891
|
if (cache === undefined || cache.configFingerprint !== configFingerprint || cache.keyConfigured !== configured) {
|
|
1827
1892
|
// Preserve only a valid LKG when config/key identity is unchanged. A changed
|
|
1828
1893
|
// source starts empty; an old in-flight task is intentionally detached.
|
|
1829
|
-
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 }
|
|
1830
1896
|
gatewayQuotaCaches[id] = cache
|
|
1831
1897
|
}
|
|
1832
1898
|
const interval = Math.max(1, Number(source.refreshMinutes) || 15) * 60_000
|
|
@@ -1839,8 +1905,9 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1839
1905
|
}
|
|
1840
1906
|
// Force intentionally waits for the current request, then starts a new one.
|
|
1841
1907
|
await current.catch(() => {})
|
|
1908
|
+
if (!currentSource()) return emptyGatewayForSource(source)
|
|
1842
1909
|
cache = gatewayQuotaCaches[id]
|
|
1843
|
-
if (cache
|
|
1910
|
+
if (cache === undefined || cache.inFlight !== undefined) return ensureGatewayQuota(source, true)
|
|
1844
1911
|
}
|
|
1845
1912
|
const task = queryGatewayQuota(ctx, source).then(value => {
|
|
1846
1913
|
const active = gatewayQuotaCaches[id]
|
|
@@ -1852,14 +1919,14 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1852
1919
|
message: typeof value?.message === 'string' ? value.message : '', attemptedAt: value?.attemptedAt ?? Date.now(),
|
|
1853
1920
|
keyConfigured: value?.keyConfigured === true, keySource: typeof value?.keySource === 'string' ? value.keySource : 'none',
|
|
1854
1921
|
}
|
|
1855
|
-
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 }
|
|
1856
1923
|
return next
|
|
1857
1924
|
}).catch(error => {
|
|
1858
1925
|
const active = gatewayQuotaCaches[id]
|
|
1859
1926
|
if (active?.inFlight !== task || active.sourceFingerprint !== fingerprint) return active?.value ?? emptyGatewayForSource(source)
|
|
1860
1927
|
const lastGood = active.lastGood
|
|
1861
1928
|
const next = { ...(lastGood ?? emptyGatewayForSource(source)), status: lastGood ? 'stale' : 'error', message: 'gateway query failed', attemptedAt: Date.now() }
|
|
1862
|
-
gatewayQuotaCaches[id] = { fetchedAt: active.fetchedAt ?? 0, value: next, lastGood
|
|
1929
|
+
gatewayQuotaCaches[id] = { ...active, fetchedAt: active.fetchedAt ?? 0, value: next, lastGood }
|
|
1863
1930
|
return next
|
|
1864
1931
|
}).finally(() => {
|
|
1865
1932
|
if (gatewayQuotaCaches[id]?.inFlight === task) {
|
|
@@ -1897,7 +1964,9 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1897
1964
|
}
|
|
1898
1965
|
kick(balanceCache.fetchedAt > 0, () => ensureBalance(forceBalance))
|
|
1899
1966
|
kick(goQuotaCache.fetchedAt > 0, () => ensureGoQuota(false))
|
|
1900
|
-
|
|
1967
|
+
const customWarm = customBalanceConfigs().every((entry, index) => entry?.enabled !== true || entry.display === 'off'
|
|
1968
|
+
|| customBalanceCacheAt(index).fetchedAt > 0)
|
|
1969
|
+
kick(customWarm, () => ensureCustomBalance(false))
|
|
1901
1970
|
kick(Object.values(codingPlanCaches).some(cache => (cache?.fetchedAt ?? 0) > 0), () => ensureCodingPlans(false))
|
|
1902
1971
|
pending.push(ensureCodingPlan('scnet', false))
|
|
1903
1972
|
const enabledGatewaySources = gatewaySourceConfigs().filter(source => source?.enabled !== false)
|
|
@@ -1914,6 +1983,7 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1914
1983
|
},
|
|
1915
1984
|
|
|
1916
1985
|
async updateConfig(patch) {
|
|
1986
|
+
const before = ledger.config
|
|
1917
1987
|
const gatewayBefore = ledger.config?.gatewayQuotas
|
|
1918
1988
|
const gatewayBeforeSources = gatewayBefore?.sources
|
|
1919
1989
|
const currencyBefore = ledger.config.prices?.currency === 'CNY' ? 'CNY' : 'USD'
|
|
@@ -1923,6 +1993,12 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
1923
1993
|
throw new Error(tmsg(locale, 'configRejected', { errors: errors.join(locale === 'zh' ? ';' : '; ') }))
|
|
1924
1994
|
}
|
|
1925
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
|
+
}
|
|
1926
2002
|
if (patch?.gatewayQuotas !== undefined || gatewayBefore !== config.gatewayQuotas) {
|
|
1927
2003
|
syncGatewayQuotaCaches(gatewayBeforeSources, config.gatewayQuotas?.sources)
|
|
1928
2004
|
}
|
|
@@ -2100,6 +2176,7 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
2100
2176
|
invalidateCustomBalanceCachesForVar(customVar)
|
|
2101
2177
|
for (const source of gatewaySourceConfigs()) if (managementKeyVarOf(source) === customVar) delete gatewayQuotaCaches[source.id]
|
|
2102
2178
|
}
|
|
2179
|
+
else resetQueryCache(key)
|
|
2103
2180
|
return {
|
|
2104
2181
|
ok: true,
|
|
2105
2182
|
message: tmsg(locale, 'credentialSaved'),
|
|
@@ -2140,6 +2217,7 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
2140
2217
|
invalidateCustomBalanceCachesForVar(customVar)
|
|
2141
2218
|
for (const source of gatewaySourceConfigs()) if (managementKeyVarOf(source) === customVar) delete gatewayQuotaCaches[source.id]
|
|
2142
2219
|
}
|
|
2220
|
+
else resetQueryCache(key)
|
|
2143
2221
|
return {
|
|
2144
2222
|
ok: true,
|
|
2145
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/typert.host.js
CHANGED
|
@@ -38,6 +38,14 @@ const daySchema = z.object({
|
|
|
38
38
|
sessions: z.array(sessionSchema),
|
|
39
39
|
})
|
|
40
40
|
|
|
41
|
+
const externalSummarySchema = z.object({ today: daySchema, month: daySchema, total: daySchema, history: z.array(daySchema) })
|
|
42
|
+
const externalUsageSchema = z.object({
|
|
43
|
+
fetchedAt: num,
|
|
44
|
+
stale: z.boolean(),
|
|
45
|
+
sources: z.array(externalSummarySchema.extend({ source: z.string() })),
|
|
46
|
+
combined: externalSummarySchema,
|
|
47
|
+
})
|
|
48
|
+
|
|
41
49
|
// 带所属日期的会话条目(issue #22 按会话排行:不分日期视角)。
|
|
42
50
|
const datedSessionSchema = sessionSchema.extend({ date: z.string() })
|
|
43
51
|
|
|
@@ -470,6 +478,7 @@ export const stateSchema = z.object({
|
|
|
470
478
|
// Token Plan 统计(issue #64);缺席 = 旧快照/降级路径。
|
|
471
479
|
planStats: planStatsSchema.optional(),
|
|
472
480
|
history: z.array(daySchema),
|
|
481
|
+
externalUsage: externalUsageSchema.optional(),
|
|
473
482
|
config: configSchema,
|
|
474
483
|
priceCatalog: z.record(z.string(), z.record(z.string(), z.record(z.string(), catalogEntrySchema))).optional(),
|
|
475
484
|
// 存量密钥迁移提示(v1.6.8):仅在有密钥未能自动导入凭据库时出现,值为环境变量名。
|