dsh-cost-meter 1.6.13 → 1.7.1
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.en.md +5 -5
- package/README.md +5 -5
- package/lib/client.js +2 -2
- package/lib/index.js +97 -39
- package/lib/store.js +96 -52
- package/lib/typert.host.js +40 -4
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -941,11 +941,12 @@ function secretMigrationNotice(ledger) {
|
|
|
941
941
|
* @param balance - 官方余额快照。
|
|
942
942
|
* @param goQuota - OpenCode Go 额度快照。
|
|
943
943
|
* @param codingPlans - 各家 coding plan 合并快照(mergedCodingPlans 产出)。
|
|
944
|
-
* @param customBalance - 自定义 Provider
|
|
944
|
+
* @param customBalance - 自定义 Provider 余额快照(旧单条镜像;多配置形态为第一条可见)。
|
|
945
945
|
* @param reconcile - 余额差对账提示。
|
|
946
946
|
* @param ctx - 宿主上下文(可选;用于查询密钥配置状态,缺省则只下发未配置)。
|
|
947
|
+
* @param customBalances - 多配置形态的全部条目快照(v1.7.0,issue #79;缺省 = [])。
|
|
947
948
|
*/
|
|
948
|
-
async function buildState(ledger, balance = emptyBalance(), goQuota = emptyGoQuota(), codingPlans = {}, customBalance = emptyCustomBalance(), reconcile = { ok: true, message: '' }, ctx = null) {
|
|
949
|
+
async function buildState(ledger, balance = emptyBalance(), goQuota = emptyGoQuota(), codingPlans = {}, customBalance = emptyCustomBalance(), reconcile = { ok: true, message: '' }, ctx = null, customBalances = []) {
|
|
949
950
|
const now = Date.now()
|
|
950
951
|
const dayKey = localDayKey(now)
|
|
951
952
|
const monthKey = dayKey.slice(0, 7)
|
|
@@ -1011,6 +1012,8 @@ async function buildState(ledger, balance = emptyBalance(), goQuota = emptyGoQuo
|
|
|
1011
1012
|
balance,
|
|
1012
1013
|
goQuota,
|
|
1013
1014
|
customBalance,
|
|
1015
|
+
// 多配置形态(v1.7.0,issue #79):全部自定义 Provider 余额条目快照。
|
|
1016
|
+
customBalances,
|
|
1014
1017
|
// 余额差交叉校验提示(issue #18):本地今日合计与官方余额当日变动偏差超阈时 ok=false。
|
|
1015
1018
|
reconcile,
|
|
1016
1019
|
codingPlans,
|
|
@@ -1044,7 +1047,7 @@ async function buildState(ledger, balance = emptyBalance(), goQuota = emptyGoQuo
|
|
|
1044
1047
|
const attempts = [
|
|
1045
1048
|
stateNoCatalog,
|
|
1046
1049
|
{ ...stateNoCatalog, codingPlans: {} },
|
|
1047
|
-
{ ...stateNoCatalog, codingPlans: {}, balance: emptyBalance(), goQuota: emptyGoQuota(), customBalance: emptyCustomBalance() },
|
|
1050
|
+
{ ...stateNoCatalog, codingPlans: {}, balance: emptyBalance(), goQuota: emptyGoQuota(), customBalance: emptyCustomBalance(), customBalances: [] },
|
|
1048
1051
|
]
|
|
1049
1052
|
for (const fallback of attempts) {
|
|
1050
1053
|
if (stateSchema.safeParse(fallback).success) return fallback
|
|
@@ -1153,7 +1156,6 @@ function createService(ctx, ledger) {
|
|
|
1153
1156
|
let balanceCache = { fetchedAt: 0, value: emptyBalance() }
|
|
1154
1157
|
// OpenCode Go 订阅额度进程内缓存(同上策略)。
|
|
1155
1158
|
let goQuotaCache = { fetchedAt: 0, value: emptyGoQuota() }
|
|
1156
|
-
let customBalanceCache = { fetchedAt: 0, value: emptyCustomBalance() }
|
|
1157
1159
|
// Coding plan 额度进程内缓存(每家一个条目,同上策略)。
|
|
1158
1160
|
let codingPlanCaches = {}
|
|
1159
1161
|
// 余额差对账提示(drift 时 ok=false 携带文案,其余静默)。
|
|
@@ -1161,7 +1163,6 @@ function createService(ctx, ledger) {
|
|
|
1161
1163
|
|
|
1162
1164
|
const balanceConfig = () => ledger.config?.balance ?? { display: 'both', refreshMinutes: 5 }
|
|
1163
1165
|
const goQuotaConfig = () => ledger.config?.goQuota ?? { enabled: true, display: 'both', refreshMinutes: 15, apiKey: '' }
|
|
1164
|
-
const customBalanceConfig = () => ledger.config?.customBalance ?? { enabled: false, display: 'off', refreshMinutes: 15, label: '', request: { url: '' }, extract: {} }
|
|
1165
1166
|
const codingPlanConfigOf = id => ({
|
|
1166
1167
|
enabled: false,
|
|
1167
1168
|
display: 'settings',
|
|
@@ -1287,27 +1288,61 @@ function createService(ctx, ledger) {
|
|
|
1287
1288
|
await task
|
|
1288
1289
|
}
|
|
1289
1290
|
|
|
1290
|
-
/** 按需刷新自定义 Provider 余额(过期或 force)。
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1291
|
+
/** 按需刷新自定义 Provider 余额(过期或 force)。
|
|
1292
|
+
* v1.7.0(issue #79)起为多配置形态:每条 config.customBalances[i] 独立缓存
|
|
1293
|
+
* (customBalanceCaches[i] = { fetchedAt, value, inFlight });旧单配置形态由
|
|
1294
|
+
* sanitizeConfig 迁移为 entries[0],运行期统一按数组处理。index 指定时只刷
|
|
1295
|
+
* 该条;缺省(ambient 快照)刷全部可见条目。
|
|
1296
|
+
*/
|
|
1297
|
+
const customBalanceConfigs = () => Array.isArray(ledger.config?.customBalances)
|
|
1298
|
+
? ledger.config.customBalances : []
|
|
1299
|
+
const customBalanceCaches = {}
|
|
1300
|
+
const customBalanceCacheAt = index => {
|
|
1301
|
+
if (customBalanceCaches[index] === undefined) {
|
|
1302
|
+
customBalanceCaches[index] = { fetchedAt: 0, value: emptyCustomBalance() }
|
|
1296
1303
|
}
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1304
|
+
return customBalanceCaches[index]
|
|
1305
|
+
}
|
|
1306
|
+
const ensureCustomBalance = async (force = false, index = null) => {
|
|
1307
|
+
const entries = customBalanceConfigs()
|
|
1308
|
+
const targets = []
|
|
1309
|
+
if (index === null) {
|
|
1310
|
+
// ambient:全部启用且非 off 的条目;一条都没有时清理出空缓存(与旧单条 off 行为一致)。
|
|
1311
|
+
entries.forEach((entry, i) => {
|
|
1312
|
+
if (entry?.enabled === true && entry.display !== 'off') targets.push(i)
|
|
1313
|
+
})
|
|
1314
|
+
if (targets.length === 0) {
|
|
1315
|
+
for (const key of Object.keys(customBalanceCaches)) {
|
|
1316
|
+
customBalanceCaches[key] = { fetchedAt: Date.now(), value: emptyCustomBalance() }
|
|
1317
|
+
}
|
|
1318
|
+
return
|
|
1319
|
+
}
|
|
1320
|
+
} else {
|
|
1321
|
+
if (entries[index]?.enabled !== true || entries[index].display === 'off') return
|
|
1322
|
+
targets.push(index)
|
|
1323
|
+
}
|
|
1324
|
+
await Promise.all(targets.map(i => ensureCustomBalanceEntry(force, i)))
|
|
1325
|
+
}
|
|
1326
|
+
const ensureCustomBalanceEntry = async (force, index) => {
|
|
1327
|
+
const config = customBalanceConfigs()[index]
|
|
1328
|
+
const cache = customBalanceCacheAt(index)
|
|
1329
|
+
const interval = Math.max(1, Number(config?.refreshMinutes) || 15) * 60_000
|
|
1330
|
+
if (!force && Date.now() - cache.fetchedAt < interval) return
|
|
1331
|
+
while (cache.inFlight !== undefined) {
|
|
1332
|
+
const prev = cache.inFlight
|
|
1301
1333
|
if (!force) { await prev; return }
|
|
1302
1334
|
// force 链式等待:等上一个任务结束后由本调用继续走新建流程,
|
|
1303
1335
|
// 保证手动强制刷新不被在途任务吞掉(否则返回的是即将过期的旧数据)。
|
|
1304
1336
|
await prev.catch(() => {})
|
|
1305
|
-
if (
|
|
1337
|
+
if (cache.inFlight === prev) break // 上一个任务结束后由本调用继续走新建流程
|
|
1306
1338
|
}
|
|
1307
|
-
|
|
1308
|
-
|
|
1339
|
+
// queryCustomBalance 的单条入参重载:customBalances[i] 视图(兼容旧键,设置页
|
|
1340
|
+
// 面向 entries 编辑)。
|
|
1341
|
+
const configForEntry = { ...ledger.config, customBalance: config, customBalances: undefined }
|
|
1342
|
+
const task = queryCustomBalance(ctx, configForEntry).then(result => {
|
|
1343
|
+
customBalanceCaches[index] = {
|
|
1309
1344
|
fetchedAt: Date.now(),
|
|
1310
|
-
value: { status: 'ok', message: '', fetchedAt: Date.now(), ...result },
|
|
1345
|
+
value: { status: 'ok', message: '', fetchedAt: Date.now(), index, ...result },
|
|
1311
1346
|
}
|
|
1312
1347
|
}, error => {
|
|
1313
1348
|
// 失败不更新外层 fetchedAt:让下次轮询重试(PR #40,与 ensureGoQuota 同策略)。
|
|
@@ -1315,34 +1350,53 @@ function createService(ctx, ledger) {
|
|
|
1315
1350
|
const now = Date.now()
|
|
1316
1351
|
const message = error instanceof Error ? error.message : String(error)
|
|
1317
1352
|
if (error && error.soft === true) {
|
|
1318
|
-
|
|
1353
|
+
customBalanceCaches[index] = {
|
|
1319
1354
|
fetchedAt: now,
|
|
1320
1355
|
value: {
|
|
1321
1356
|
...emptyCustomBalance(),
|
|
1322
|
-
label: typeof config
|
|
1357
|
+
label: typeof config?.label === 'string' ? config.label : '',
|
|
1323
1358
|
status: 'off',
|
|
1324
1359
|
message,
|
|
1325
1360
|
fetchedAt: now,
|
|
1361
|
+
index,
|
|
1326
1362
|
},
|
|
1327
1363
|
}
|
|
1328
1364
|
} else {
|
|
1329
|
-
|
|
1330
|
-
...
|
|
1365
|
+
customBalanceCaches[index] = {
|
|
1366
|
+
...cache,
|
|
1331
1367
|
value: {
|
|
1332
1368
|
...emptyCustomBalance(),
|
|
1333
|
-
label: typeof config
|
|
1369
|
+
label: typeof config?.label === 'string' ? config.label : '',
|
|
1334
1370
|
status: 'error',
|
|
1335
1371
|
message,
|
|
1336
1372
|
fetchedAt: now,
|
|
1373
|
+
index,
|
|
1337
1374
|
},
|
|
1338
1375
|
}
|
|
1339
1376
|
}
|
|
1340
1377
|
}).finally(() => {
|
|
1341
|
-
if (
|
|
1378
|
+
if (customBalanceCaches[index]?.inFlight === task) delete customBalanceCaches[index].inFlight
|
|
1342
1379
|
})
|
|
1343
|
-
|
|
1380
|
+
customBalanceCaches[index] = { ...cache, inFlight: task }
|
|
1344
1381
|
await task
|
|
1345
1382
|
}
|
|
1383
|
+
/** 多条快照(仅含有缓存值的条目;旧单条镜像取第一条可见条目)。 */
|
|
1384
|
+
const customBalanceSnapshots = () => {
|
|
1385
|
+
const entries = customBalanceConfigs()
|
|
1386
|
+
const out = []
|
|
1387
|
+
entries.forEach((entry, index) => {
|
|
1388
|
+
const cached = customBalanceCaches[index]
|
|
1389
|
+
if (cached === undefined) return
|
|
1390
|
+
out.push({ ...emptyCustomBalance(), ...cached.value, index })
|
|
1391
|
+
})
|
|
1392
|
+
return out
|
|
1393
|
+
}
|
|
1394
|
+
const customBalanceMirror = () => {
|
|
1395
|
+
const snapshots = customBalanceSnapshots()
|
|
1396
|
+
if (snapshots.length === 0) return emptyCustomBalance()
|
|
1397
|
+
// 旧客户端镜像:优先取「侧边栏可见且 ok」的第一条,否则第一条。
|
|
1398
|
+
return snapshots.find(s => s.status === 'ok') ?? snapshots[0]
|
|
1399
|
+
}
|
|
1346
1400
|
|
|
1347
1401
|
/**
|
|
1348
1402
|
* 合并配置与运行时额度状态,得到对客户端的 codingPlans 快照。
|
|
@@ -1468,10 +1522,10 @@ function createService(ctx, ledger) {
|
|
|
1468
1522
|
}
|
|
1469
1523
|
kick(balanceCache.fetchedAt > 0, () => ensureBalance(forceBalance))
|
|
1470
1524
|
kick(goQuotaCache.fetchedAt > 0, () => ensureGoQuota(false))
|
|
1471
|
-
kick(
|
|
1525
|
+
kick(Object.values(customBalanceCaches).some(cache => (cache?.fetchedAt ?? 0) > 0), () => ensureCustomBalance(false))
|
|
1472
1526
|
kick(Object.values(codingPlanCaches).some(cache => (cache?.fetchedAt ?? 0) > 0), () => ensureCodingPlans(false))
|
|
1473
1527
|
await Promise.all(pending)
|
|
1474
|
-
return await buildState(ledger, balanceCache.value, goQuotaCache.value, await mergedCodingPlans(),
|
|
1528
|
+
return await buildState(ledger, balanceCache.value, goQuotaCache.value, await mergedCodingPlans(), customBalanceMirror(), reconcileNotice, ctx, customBalanceSnapshots())
|
|
1475
1529
|
}
|
|
1476
1530
|
|
|
1477
1531
|
const service = {
|
|
@@ -1525,26 +1579,30 @@ function createService(ctx, ledger) {
|
|
|
1525
1579
|
return {
|
|
1526
1580
|
ok: value.status === 'ok',
|
|
1527
1581
|
message: value.status === 'ok' ? tmsg(locale, 'balanceRefreshed') : tmsg(locale, 'balanceQueryFailed', { message: value.message }),
|
|
1528
|
-
state: await buildState(ledger, value, goQuotaCache.value, await mergedCodingPlans(),
|
|
1582
|
+
state: await buildState(ledger, value, goQuotaCache.value, await mergedCodingPlans(), customBalanceMirror(), reconcileNotice, ctx, customBalanceSnapshots()),
|
|
1529
1583
|
}
|
|
1530
1584
|
},
|
|
1531
1585
|
|
|
1532
|
-
async refreshCustomBalance() {
|
|
1586
|
+
async refreshCustomBalance(index = null) {
|
|
1533
1587
|
const locale = localeOf(ledger.config)
|
|
1534
|
-
|
|
1588
|
+
const entries = customBalanceConfigs()
|
|
1589
|
+
// index 指定:单条强刷(多配置形态设置页/侧边栏逐条刷新按钮);缺省:全量。
|
|
1590
|
+
const idx = Number.isInteger(index) && index >= 0 && index < 8 ? index : null
|
|
1591
|
+
const entry = idx !== null ? entries[idx] : entries.find(e => e?.enabled === true && e.display !== 'off')
|
|
1592
|
+
if (entry === undefined || entry === null || entry.enabled !== true) {
|
|
1535
1593
|
return { ok: false, message: tmsg(locale, 'customBalanceDisabled') }
|
|
1536
1594
|
}
|
|
1537
|
-
if (
|
|
1595
|
+
if (entry.display === 'off') {
|
|
1538
1596
|
return { ok: false, message: tmsg(locale, 'customBalanceDisplayOff') }
|
|
1539
1597
|
}
|
|
1540
|
-
await ensureCustomBalance(true)
|
|
1541
|
-
const value =
|
|
1598
|
+
await ensureCustomBalance(true, idx)
|
|
1599
|
+
const value = idx !== null ? (customBalanceCaches[idx]?.value ?? emptyCustomBalance()) : customBalanceMirror()
|
|
1542
1600
|
return {
|
|
1543
1601
|
ok: value.status === 'ok',
|
|
1544
1602
|
message: value.status === 'ok' ? tmsg(locale, 'customBalanceRefreshed')
|
|
1545
1603
|
: value.status === 'off' && value.message ? value.message
|
|
1546
1604
|
: tmsg(locale, 'customBalanceQueryFailed', { message: value.message }),
|
|
1547
|
-
state: await buildState(ledger, balanceCache.value, goQuotaCache.value, await mergedCodingPlans(),
|
|
1605
|
+
state: await buildState(ledger, balanceCache.value, goQuotaCache.value, await mergedCodingPlans(), customBalanceMirror(), reconcileNotice, ctx, customBalanceSnapshots()),
|
|
1548
1606
|
}
|
|
1549
1607
|
},
|
|
1550
1608
|
|
|
@@ -1563,7 +1621,7 @@ function createService(ctx, ledger) {
|
|
|
1563
1621
|
message: value.status === 'ok' ? tmsg(locale, 'goQuotaRefreshed')
|
|
1564
1622
|
: value.status === 'off' && value.message ? value.message
|
|
1565
1623
|
: tmsg(locale, 'goQuotaQueryFailed', { message: value.message }),
|
|
1566
|
-
state: await buildState(ledger, balanceCache.value, value, await mergedCodingPlans(),
|
|
1624
|
+
state: await buildState(ledger, balanceCache.value, value, await mergedCodingPlans(), customBalanceMirror(), reconcileNotice, ctx, customBalanceSnapshots()),
|
|
1567
1625
|
}
|
|
1568
1626
|
},
|
|
1569
1627
|
|
|
@@ -1588,7 +1646,7 @@ function createService(ctx, ledger) {
|
|
|
1588
1646
|
message: value.status === 'ok' ? tmsg(locale, 'codingPlanRefreshed', { provider: label })
|
|
1589
1647
|
: value.status === 'off' && value.message ? value.message
|
|
1590
1648
|
: tmsg(locale, 'codingPlanQueryFailed', { provider: label, message: value.message }),
|
|
1591
|
-
state: await buildState(ledger, balanceCache.value, goQuotaCache.value, await mergedCodingPlans(),
|
|
1649
|
+
state: await buildState(ledger, balanceCache.value, goQuotaCache.value, await mergedCodingPlans(), customBalanceMirror(), reconcileNotice, ctx, customBalanceSnapshots()),
|
|
1592
1650
|
}
|
|
1593
1651
|
},
|
|
1594
1652
|
|
|
@@ -1626,7 +1684,7 @@ function createService(ctx, ledger) {
|
|
|
1626
1684
|
return {
|
|
1627
1685
|
ok: true,
|
|
1628
1686
|
message: tmsg(locale, 'credentialSaved'),
|
|
1629
|
-
state: await buildState(ledger, balanceCache.value, goQuotaCache.value, await mergedCodingPlans(),
|
|
1687
|
+
state: await buildState(ledger, balanceCache.value, goQuotaCache.value, await mergedCodingPlans(), customBalanceMirror(), reconcileNotice, ctx, customBalanceSnapshots()),
|
|
1630
1688
|
}
|
|
1631
1689
|
},
|
|
1632
1690
|
|
|
@@ -1658,7 +1716,7 @@ function createService(ctx, ledger) {
|
|
|
1658
1716
|
return {
|
|
1659
1717
|
ok: true,
|
|
1660
1718
|
message: tmsg(locale, 'credentialCleared'),
|
|
1661
|
-
state: await buildState(ledger, balanceCache.value, goQuotaCache.value, await mergedCodingPlans(),
|
|
1719
|
+
state: await buildState(ledger, balanceCache.value, goQuotaCache.value, await mergedCodingPlans(), customBalanceMirror(), reconcileNotice, ctx, customBalanceSnapshots()),
|
|
1662
1720
|
}
|
|
1663
1721
|
},
|
|
1664
1722
|
|
package/lib/store.js
CHANGED
|
@@ -130,6 +130,10 @@ export function defaultConfig() {
|
|
|
130
130
|
unit: 'USD',
|
|
131
131
|
},
|
|
132
132
|
},
|
|
133
|
+
// 多配置形态(v1.7.0,issue #79):自定义 Provider 余额支持多条并行,每条
|
|
134
|
+
// 独立 enabled/display/刷新间隔/请求与解析规则。旧 customBalance 单配置
|
|
135
|
+
// 在 sanitizeConfig 中自动迁移为 entries[0](有值时),运行期以本数组为准。
|
|
136
|
+
customBalances: [],
|
|
133
137
|
corner: {
|
|
134
138
|
enabled: false, // 右下角(composer dock)显示 Go 额度 / 预算 chips
|
|
135
139
|
goRolling: true, // 滚动 5 小时额度
|
|
@@ -664,6 +668,8 @@ const VALIDATION_MESSAGES = {
|
|
|
664
668
|
customBalanceRequest: 'customBalance.request.url 必须是非空字符串',
|
|
665
669
|
customBalanceHeaders: 'customBalance.request.headers 必须是字符串→字符串映射',
|
|
666
670
|
customBalanceExtract: 'customBalance.extract 必须是对象',
|
|
671
|
+
customBalances: 'customBalances 必须是数组',
|
|
672
|
+
customBalancesTooMany: '自定义 Provider 余额配置最多 8 条',
|
|
667
673
|
goQuotaEnabled: 'goQuota.enabled 必须是布尔值',
|
|
668
674
|
goQuotaDisplay: 'goQuota.display 必须是 sidebar / settings / both / off',
|
|
669
675
|
goQuotaRefresh: 'goQuota.refreshMinutes 必须是 1-1440 的整数',
|
|
@@ -743,6 +749,8 @@ const VALIDATION_MESSAGES = {
|
|
|
743
749
|
customBalanceRequest: 'customBalance.request.url must be a non-empty string',
|
|
744
750
|
customBalanceHeaders: 'customBalance.request.headers must be a string→string map',
|
|
745
751
|
customBalanceExtract: 'customBalance.extract must be an object',
|
|
752
|
+
customBalances: 'customBalances must be an array',
|
|
753
|
+
customBalancesTooMany: 'At most 8 custom provider balance entries are allowed',
|
|
746
754
|
goQuotaEnabled: 'goQuota.enabled must be a boolean',
|
|
747
755
|
goQuotaDisplay: 'goQuota.display must be sidebar / settings / both / off',
|
|
748
756
|
goQuotaRefresh: 'goQuota.refreshMinutes must be an integer from 1 to 1440',
|
|
@@ -1000,31 +1008,43 @@ export function applyConfigPatch(current, rawPatch) {
|
|
|
1000
1008
|
else balance.budgetCap = cap > 0 ? cap : null
|
|
1001
1009
|
}
|
|
1002
1010
|
}
|
|
1003
|
-
// 自定义 Provider
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
if (
|
|
1011
|
+
// 自定义 Provider 余额校验(v1.7.0 起支持多配置,issue #79)。
|
|
1012
|
+
// 单条校验逻辑对 customBalance(旧单配置)与 customBalances[n](新数组)复用。
|
|
1013
|
+
const validateCustomBalanceEntry = (entry, fieldPrefix) => {
|
|
1014
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
1007
1015
|
errors.push(vmsg(locale, 'customBalance'))
|
|
1016
|
+
return
|
|
1017
|
+
}
|
|
1018
|
+
if (typeof entry.enabled !== 'boolean') errors.push(vmsg(locale, fieldPrefix + 'Enabled'))
|
|
1019
|
+
if (!['sidebar', 'settings', 'both', 'off'].includes(entry.display)) errors.push(vmsg(locale, fieldPrefix + 'Display'))
|
|
1020
|
+
const refreshMinutes = Number(entry.refreshMinutes)
|
|
1021
|
+
if (!Number.isInteger(refreshMinutes) || refreshMinutes < 1 || refreshMinutes > 1440) errors.push(vmsg(locale, fieldPrefix + 'Refresh'))
|
|
1022
|
+
else entry.refreshMinutes = refreshMinutes
|
|
1023
|
+
if (typeof entry.label !== 'string') errors.push(vmsg(locale, fieldPrefix + 'Label'))
|
|
1024
|
+
if (entry.labelEn !== undefined && typeof entry.labelEn !== 'string') errors.push(vmsg(locale, fieldPrefix + 'LabelEn'))
|
|
1025
|
+
if (entry.unit !== undefined && !['USD', 'CNY', 'EUR'].includes(entry.unit)) errors.push(vmsg(locale, fieldPrefix + 'Unit'))
|
|
1026
|
+
const request = entry.request
|
|
1027
|
+
// url 仅在启用时必填:默认禁用状态下不能阻断其它配置项的保存。
|
|
1028
|
+
if (request === null || typeof request !== 'object' || Array.isArray(request) || typeof request.url !== 'string' || (entry.enabled === true && request.url.length === 0)) {
|
|
1029
|
+
errors.push(vmsg(locale, fieldPrefix + 'Request'))
|
|
1030
|
+
} else if (request.headers !== undefined && (request.headers === null || typeof request.headers !== 'object' || Array.isArray(request.headers)
|
|
1031
|
+
|| Object.values(request.headers).some(value => typeof value !== 'string'))) {
|
|
1032
|
+
// 值非字符串会击穿 typert strict configSchema(z.record(string, string)),导致整个 getState 被拒。
|
|
1033
|
+
errors.push(vmsg(locale, fieldPrefix + 'Headers'))
|
|
1034
|
+
}
|
|
1035
|
+
const extract = entry.extract
|
|
1036
|
+
if (extract === null || typeof extract !== 'object' || Array.isArray(extract)) errors.push(vmsg(locale, fieldPrefix + 'Extract'))
|
|
1037
|
+
}
|
|
1038
|
+
const customBalance = candidate.customBalance
|
|
1039
|
+
if (customBalance !== undefined) validateCustomBalanceEntry(customBalance, 'customBalance')
|
|
1040
|
+
// 数组形态:上限 8 条(设置页逐条编辑,超出的截断并报错)。
|
|
1041
|
+
const customBalances = candidate.customBalances
|
|
1042
|
+
if (customBalances !== undefined) {
|
|
1043
|
+
if (!Array.isArray(customBalances)) {
|
|
1044
|
+
errors.push(vmsg(locale, 'customBalances'))
|
|
1008
1045
|
} else {
|
|
1009
|
-
if (
|
|
1010
|
-
|
|
1011
|
-
const refreshMinutes = Number(customBalance.refreshMinutes)
|
|
1012
|
-
if (!Number.isInteger(refreshMinutes) || refreshMinutes < 1 || refreshMinutes > 1440) errors.push(vmsg(locale, 'customBalanceRefresh'))
|
|
1013
|
-
else customBalance.refreshMinutes = refreshMinutes
|
|
1014
|
-
if (typeof customBalance.label !== 'string') errors.push(vmsg(locale, 'customBalanceLabel'))
|
|
1015
|
-
if (customBalance.labelEn !== undefined && typeof customBalance.labelEn !== 'string') errors.push(vmsg(locale, 'customBalanceLabelEn'))
|
|
1016
|
-
if (customBalance.unit !== undefined && !['USD', 'CNY', 'EUR'].includes(customBalance.unit)) errors.push(vmsg(locale, 'customBalanceUnit'))
|
|
1017
|
-
const request = customBalance.request
|
|
1018
|
-
// url 仅在启用时必填:默认禁用状态下不能阻断其它配置项的保存。
|
|
1019
|
-
if (request === null || typeof request !== 'object' || Array.isArray(request) || typeof request.url !== 'string' || (customBalance.enabled === true && request.url.length === 0)) {
|
|
1020
|
-
errors.push(vmsg(locale, 'customBalanceRequest'))
|
|
1021
|
-
} else if (request.headers !== undefined && (request.headers === null || typeof request.headers !== 'object' || Array.isArray(request.headers)
|
|
1022
|
-
|| Object.values(request.headers).some(value => typeof value !== 'string'))) {
|
|
1023
|
-
// 值非字符串会击穿 typert strict configSchema(z.record(string, string)),导致整个 getState 被拒。
|
|
1024
|
-
errors.push(vmsg(locale, 'customBalanceHeaders'))
|
|
1025
|
-
}
|
|
1026
|
-
const extract = customBalance.extract
|
|
1027
|
-
if (extract === null || typeof extract !== 'object' || Array.isArray(extract)) errors.push(vmsg(locale, 'customBalanceExtract'))
|
|
1046
|
+
if (customBalances.length > 8) errors.push(vmsg(locale, 'customBalancesTooMany'))
|
|
1047
|
+
customBalances.slice(0, 8).forEach(entry => validateCustomBalanceEntry(entry, 'customBalance'))
|
|
1028
1048
|
}
|
|
1029
1049
|
}
|
|
1030
1050
|
// OpenCode Go 订阅额度显示校验。
|
|
@@ -1239,37 +1259,61 @@ export function sanitizeConfig(raw) {
|
|
|
1239
1259
|
goQuota.main = oneOf(goQuota.main, ['rolling', 'weekly', 'monthly'], 'rolling')
|
|
1240
1260
|
goQuota.detail = goQuota.detail !== false
|
|
1241
1261
|
const customBalance = out.customBalance ?? base.customBalance
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
const cleanedHeaders = {}
|
|
1253
|
-
for (const [key, value] of Object.entries(customBalance.request.headers ?? {})) {
|
|
1254
|
-
if (typeof key === 'string' && typeof value === 'string') cleanedHeaders[key] = value
|
|
1262
|
+
// 单条收敛(旧 customBalance 形态,加载/补丁均经此处;字段口径与数组条目一致)。
|
|
1263
|
+
const sanitizeCustomEntry = (raw, baseEntry) => {
|
|
1264
|
+
const entry = raw ?? {}
|
|
1265
|
+
const clean = {
|
|
1266
|
+
enabled: entry.enabled === true,
|
|
1267
|
+
display: oneOf(entry.display, ['sidebar', 'settings', 'both', 'off'], 'both'),
|
|
1268
|
+
refreshMinutes: Math.min(1440, Math.max(1, Math.floor(Number(entry.refreshMinutes) || 15))),
|
|
1269
|
+
label: typeof entry.label === 'string' ? entry.label : baseEntry.label,
|
|
1270
|
+
labelEn: typeof entry.labelEn === 'string' ? entry.labelEn : baseEntry.labelEn,
|
|
1271
|
+
unit: oneOf(entry.unit, ['USD', 'CNY', 'EUR'], baseEntry.unit),
|
|
1255
1272
|
}
|
|
1256
|
-
|
|
1257
|
-
...
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
headers
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1273
|
+
if (entry.request === null || typeof entry.request !== 'object' || Array.isArray(entry.request)) {
|
|
1274
|
+
clean.request = { ...baseEntry.request }
|
|
1275
|
+
} else {
|
|
1276
|
+
// 加载边界清洗:url/method 回落字符串,headers 只保留字符串→字符串(手改账本防击穿 strict codec)。
|
|
1277
|
+
const cleanedHeaders = {}
|
|
1278
|
+
for (const [key, value] of Object.entries(entry.request.headers ?? {})) {
|
|
1279
|
+
if (typeof key === 'string' && typeof value === 'string') cleanedHeaders[key] = value
|
|
1280
|
+
}
|
|
1281
|
+
clean.request = {
|
|
1282
|
+
...baseEntry.request,
|
|
1283
|
+
...entry.request,
|
|
1284
|
+
url: typeof entry.request.url === 'string' ? entry.request.url : '',
|
|
1285
|
+
method: typeof entry.request.method === 'string' ? entry.request.method : 'GET',
|
|
1286
|
+
headers: {
|
|
1287
|
+
...(baseEntry.request?.headers ?? {}),
|
|
1288
|
+
...cleanedHeaders,
|
|
1289
|
+
},
|
|
1290
|
+
}
|
|
1265
1291
|
}
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1292
|
+
if (entry.extract === null || typeof entry.extract !== 'object' || Array.isArray(entry.extract)) {
|
|
1293
|
+
clean.extract = { ...baseEntry.extract }
|
|
1294
|
+
} else {
|
|
1295
|
+
clean.extract = { ...baseEntry.extract, ...entry.extract }
|
|
1296
|
+
}
|
|
1297
|
+
// 凭据外带白名单(v1.6.8)随行:字符串数组,仅保留非空串。
|
|
1298
|
+
if (Array.isArray(entry.allowedHosts)) {
|
|
1299
|
+
const hosts = entry.allowedHosts.filter(h => typeof h === 'string' && h.length > 0)
|
|
1300
|
+
if (hosts.length > 0) clean.allowedHosts = hosts
|
|
1301
|
+
}
|
|
1302
|
+
return clean
|
|
1303
|
+
}
|
|
1304
|
+
const sanitizedSingle = sanitizeCustomEntry(customBalance, base.customBalance)
|
|
1305
|
+
out.customBalance = sanitizedSingle
|
|
1306
|
+
// 多配置形态(v1.7.0,issue #79):数组为运行期真源。旧单配置有实际内容
|
|
1307
|
+
// (启用或配置过 URL)时自动迁移为 entries[0],保证升级无缝;数组缺席/非法
|
|
1308
|
+
// 时回落「单条迁移结果」;上限 8 条。
|
|
1309
|
+
let rawEntries = out.customBalances
|
|
1310
|
+
if (!Array.isArray(rawEntries)) rawEntries = []
|
|
1311
|
+
const baseEntry = base.customBalances?.[0] ?? base.customBalance
|
|
1312
|
+
const entries = rawEntries.slice(0, 8).map(raw => sanitizeCustomEntry(raw, baseEntry))
|
|
1313
|
+
const legacyHasContent = sanitizedSingle.enabled === true
|
|
1314
|
+
|| (typeof sanitizedSingle.request?.url === 'string' && sanitizedSingle.request.url.length > 0)
|
|
1315
|
+
if (entries.length === 0 && legacyHasContent) entries.push(sanitizedSingle)
|
|
1316
|
+
out.customBalances = entries
|
|
1273
1317
|
const corner = out.corner
|
|
1274
1318
|
for (const key of ['enabled', 'goRolling', 'goWeekly', 'goMonthly', 'budget']) corner[key] = corner[key] === true || (corner[key] !== false && key !== 'enabled')
|
|
1275
1319
|
// 输入框上方额度横条:enabled/promptSeen 缺省 false,budget/go/plans 缺省 true。
|
package/lib/typert.host.js
CHANGED
|
@@ -197,7 +197,26 @@ const configSchema = z.object({
|
|
|
197
197
|
body: z.unknown().optional(),
|
|
198
198
|
}),
|
|
199
199
|
extract: z.record(z.string(), z.unknown()),
|
|
200
|
+
allowedHosts: z.array(z.string()).optional(),
|
|
200
201
|
}).optional(),
|
|
202
|
+
// 多配置形态(v1.7.0,issue #79):自定义 Provider 余额多条并行,数组为运行期
|
|
203
|
+
// 真源;customBalance 单配置键仅为旧客户端兼容保留(= entries[0] 的镜像)。
|
|
204
|
+
customBalances: z.array(z.object({
|
|
205
|
+
enabled: z.boolean(),
|
|
206
|
+
label: z.string(),
|
|
207
|
+
labelEn: z.string().optional(),
|
|
208
|
+
display: z.enum(['sidebar', 'settings', 'both', 'off']),
|
|
209
|
+
unit: z.enum(['USD', 'CNY', 'EUR']).optional(),
|
|
210
|
+
refreshMinutes: num,
|
|
211
|
+
request: z.object({
|
|
212
|
+
url: z.string(),
|
|
213
|
+
method: z.string().optional(),
|
|
214
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
215
|
+
body: z.unknown().optional(),
|
|
216
|
+
}),
|
|
217
|
+
extract: z.record(z.string(), z.unknown()),
|
|
218
|
+
allowedHosts: z.array(z.string()).optional(),
|
|
219
|
+
})).optional(),
|
|
201
220
|
corner: z.object({
|
|
202
221
|
enabled: z.boolean(),
|
|
203
222
|
goRolling: z.boolean(),
|
|
@@ -265,6 +284,9 @@ const customBalanceSchema = z.object({
|
|
|
265
284
|
remaining: num,
|
|
266
285
|
maxBudget: z.union([num, z.null()]),
|
|
267
286
|
spend: z.union([num, z.null()]),
|
|
287
|
+
// 条目索引(v1.7.0,issue #79):多配置形态下每条快照自带其在
|
|
288
|
+
// config.customBalances 中的下标,客户端据此定位刷新按钮对应的条目。
|
|
289
|
+
index: num.optional(),
|
|
268
290
|
})
|
|
269
291
|
|
|
270
292
|
// Coding plan 额度状态条目(运行时合并配置与查询结果;windows 为各用量窗口)。
|
|
@@ -336,6 +358,9 @@ export const stateSchema = z.object({
|
|
|
336
358
|
goQuota: goQuotaSchema,
|
|
337
359
|
// optional:兼容旧快照/降级路径(与 codingPlans/priceCatalog 同策略,避免 strict codec 击穿)。
|
|
338
360
|
customBalance: customBalanceSchema.optional(),
|
|
361
|
+
// 多配置形态(v1.7.0,issue #79):每条独立快照;customBalance 单条键为
|
|
362
|
+
// 旧客户端兼容镜像(= 第一条 sidebar 可见条目或 entries[0])。
|
|
363
|
+
customBalances: z.array(customBalanceSchema).optional(),
|
|
339
364
|
// 余额差交叉校验提示(issue #18):旧快照无此字段,optional 防击穿。
|
|
340
365
|
reconcile: z.object({ ok: z.boolean(), message: z.string() }).optional(),
|
|
341
366
|
codingPlans: z.record(z.string(), codingPlanSchema),
|
|
@@ -437,7 +462,18 @@ export const TYPERT = {
|
|
|
437
462
|
namespace: 'costMeter',
|
|
438
463
|
method: 'refreshCustomBalance',
|
|
439
464
|
invocation: { kind: 'direct' },
|
|
440
|
-
|
|
465
|
+
// index(v1.7.0,issue #79):多配置形态下指定刷新的条目下标;缺省 = 全部刷新。
|
|
466
|
+
// acceptsUndefined(v1.7.1):网关按「期望 wire 字段是否在 args 中」判缺失——
|
|
467
|
+
// 无此标志的 json 参数缺省会以 arguments-invalid 拒绝调用;标志允许旧客户端
|
|
468
|
+
// 的无参调用(等价全量刷新)继续走通。codec 必须是 strict 对象(裸 zod schema
|
|
469
|
+
// 会被 typert-loader 以「parameter codec must use a strict codec」拒绝注册,
|
|
470
|
+
// v1.7.0 因此无法启动宿主——本版回归)。
|
|
471
|
+
parameters: [
|
|
472
|
+
{
|
|
473
|
+
name: 'index', wire: 'index', source: 'json', acceptsUndefined: true,
|
|
474
|
+
codec: { mode: 'strict', typeSymbol: 'dsh-cost-meter#CustomBalanceIndex', schema: z.number().int().min(0).max(7).optional() },
|
|
475
|
+
},
|
|
476
|
+
],
|
|
441
477
|
result: _fetch$codec,
|
|
442
478
|
},
|
|
443
479
|
{
|
|
@@ -571,9 +607,9 @@ export const TYPERT = {
|
|
|
571
607
|
{
|
|
572
608
|
kind: 'method',
|
|
573
609
|
name: 'refreshCustomBalance',
|
|
574
|
-
signature: 'refreshCustomBalance(): Promise<FetchPricesResult>',
|
|
575
|
-
summary: '立即查询自定义 Provider
|
|
576
|
-
jsDoc: '/**\n * 立即查询自定义 Provider 余额(可配置 HTTP 请求 + extract 规则)。\n * @returns 查询结果与最新快照。\n * Query
|
|
610
|
+
signature: 'refreshCustomBalance(index?: number): Promise<FetchPricesResult>',
|
|
611
|
+
summary: '立即查询自定义 Provider 余额(可指定条目)。Query custom provider balances immediately (optionally one entry).',
|
|
612
|
+
jsDoc: '/**\n * 立即查询自定义 Provider 余额(可配置 HTTP 请求 + extract 规则)。\n * @param index - 可选:多配置形态下要刷新的条目下标(0-7);缺省刷新全部条目。\n * @returns 查询结果与最新快照。\n * Query custom provider balances immediately (configurable HTTP request + extract rules).\n * @param index - Optional: entry index (0-7) to refresh in the multi-entry form; omit to refresh all.\n * @returns The query result and the latest snapshot.\n */',
|
|
577
613
|
},
|
|
578
614
|
{
|
|
579
615
|
kind: 'method',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-cost-meter",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1",
|
|
4
4
|
"description": "DeepSeek Harness 会话费用统计插件:本会话成本、当日费用、历史记录与官方价格同步,支持多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配)、主流 Coding Plan 订阅额度查询与显示(9 家,含 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 9 vendors (incl. 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",
|