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/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
- const ensureCustomBalance = async (force = false) => {
1292
- const config = customBalanceConfig()
1293
- if (config.enabled !== true || config.display === 'off') {
1294
- customBalanceCache = { fetchedAt: Date.now(), value: emptyCustomBalance() }
1295
- return
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
- const interval = Math.max(1, Number(config.refreshMinutes) || 15) * 60_000
1298
- if (!force && Date.now() - customBalanceCache.fetchedAt < interval) return
1299
- while (customBalanceCache.inFlight !== undefined) {
1300
- const prev = customBalanceCache.inFlight
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 (customBalanceCache.inFlight === prev) break // 上一个任务结束后由本调用继续走新建流程
1337
+ if (cache.inFlight === prev) break // 上一个任务结束后由本调用继续走新建流程
1306
1338
  }
1307
- const task = queryCustomBalance(ctx, ledger.config).then(result => {
1308
- customBalanceCache = {
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
- customBalanceCache = {
1353
+ customBalanceCaches[index] = {
1319
1354
  fetchedAt: now,
1320
1355
  value: {
1321
1356
  ...emptyCustomBalance(),
1322
- label: typeof config.label === 'string' ? config.label : '',
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
- customBalanceCache = {
1330
- ...customBalanceCache,
1365
+ customBalanceCaches[index] = {
1366
+ ...cache,
1331
1367
  value: {
1332
1368
  ...emptyCustomBalance(),
1333
- label: typeof config.label === 'string' ? config.label : '',
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 (customBalanceCache.inFlight === task) delete customBalanceCache.inFlight
1378
+ if (customBalanceCaches[index]?.inFlight === task) delete customBalanceCaches[index].inFlight
1342
1379
  })
1343
- customBalanceCache.inFlight = task
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(customBalanceCache.fetchedAt > 0, () => ensureCustomBalance(false))
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(), customBalanceCache.value, reconcileNotice, ctx)
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(), customBalanceCache.value, reconcileNotice, ctx),
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
- if (customBalanceConfig().enabled !== true) {
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 (customBalanceConfig().display === 'off') {
1595
+ if (entry.display === 'off') {
1538
1596
  return { ok: false, message: tmsg(locale, 'customBalanceDisplayOff') }
1539
1597
  }
1540
- await ensureCustomBalance(true)
1541
- const value = customBalanceCache.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(), value, reconcileNotice, ctx),
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(), customBalanceCache.value, reconcileNotice, ctx),
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(), customBalanceCache.value, reconcileNotice, ctx),
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(), customBalanceCache.value, reconcileNotice, ctx),
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(), customBalanceCache.value, reconcileNotice, ctx),
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
- const customBalance = candidate.customBalance
1005
- if (customBalance !== undefined) {
1006
- if (customBalance === null || typeof customBalance !== 'object' || Array.isArray(customBalance)) {
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 (typeof customBalance.enabled !== 'boolean') errors.push(vmsg(locale, 'customBalanceEnabled'))
1010
- if (!['sidebar', 'settings', 'both', 'off'].includes(customBalance.display)) errors.push(vmsg(locale, 'customBalanceDisplay'))
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
- customBalance.enabled = customBalance.enabled === true
1243
- customBalance.display = oneOf(customBalance.display, ['sidebar', 'settings', 'both', 'off'], 'both')
1244
- customBalance.refreshMinutes = Math.min(1440, Math.max(1, Math.floor(Number(customBalance.refreshMinutes) || 15)))
1245
- customBalance.label = typeof customBalance.label === 'string' ? customBalance.label : base.customBalance.label
1246
- customBalance.labelEn = typeof customBalance.labelEn === 'string' ? customBalance.labelEn : base.customBalance.labelEn
1247
- customBalance.unit = oneOf(customBalance.unit, ['USD', 'CNY', 'EUR'], base.customBalance.unit)
1248
- if (customBalance.request === null || typeof customBalance.request !== 'object' || Array.isArray(customBalance.request)) {
1249
- customBalance.request = { ...base.customBalance.request }
1250
- } else {
1251
- // 加载边界清洗:url/method 回落字符串,headers 只保留字符串→字符串(手改账本防击穿 strict codec)。
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
- customBalance.request = {
1257
- ...base.customBalance.request,
1258
- ...customBalance.request,
1259
- url: typeof customBalance.request.url === 'string' ? customBalance.request.url : '',
1260
- method: typeof customBalance.request.method === 'string' ? customBalance.request.method : 'GET',
1261
- headers: {
1262
- ...(base.customBalance.request?.headers ?? {}),
1263
- ...cleanedHeaders,
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
- if (customBalance.extract === null || typeof customBalance.extract !== 'object' || Array.isArray(customBalance.extract)) {
1268
- customBalance.extract = { ...base.customBalance.extract }
1269
- } else {
1270
- customBalance.extract = { ...base.customBalance.extract, ...customBalance.extract }
1271
- }
1272
- out.customBalance = customBalance
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。
@@ -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
- parameters: [],
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 余额。Query the configured custom provider balance immediately.',
576
- jsDoc: '/**\n * 立即查询自定义 Provider 余额(可配置 HTTP 请求 + extract 规则)。\n * @returns 查询结果与最新快照。\n * Query the configured custom provider balance immediately (configurable HTTP request + extract rules).\n * @returns The query result and the latest snapshot.\n */',
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.6.13",
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",