dsh-cost-meter 1.7.10 → 1.7.13

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.
@@ -8,9 +8,16 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials'
8
8
  // ESM 环上再加一条边,DSH Desktop 的加载顺序下爆发 TDZ。net.js 是零本地
9
9
  // 依赖底层,从它导入即断环。
10
10
  import { fetchWithRetry, looksLikeSecretHeaderValue } from './net.js'
11
+ import { ALIYUN_BALANCE_CREDENTIAL_VARS, queryAliyunBalance } from './aliyun-balance.js'
11
12
 
12
13
  export const CUSTOM_BALANCE_ADAPTER_ID = 'custom'
13
14
 
15
+ export function customBalanceCredentialVars(entry) {
16
+ if (entry?.adapter === 'aliyun') return [...ALIYUN_BALANCE_CREDENTIAL_VARS]
17
+ return [...new Set(Object.values(entry?.request?.headers ?? {}).flatMap(value =>
18
+ typeof value === 'string' ? [...value.matchAll(/\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g)].map(match => match[1]) : []))]
19
+ }
20
+
14
21
  /**
15
22
  * 按点路径取响应 JSON 的字段(v1.6.8 加自有属性守卫)。
16
23
  * 只在**自有属性**上取值:`current[segment]` 会沿原型链上溯,`__proto__`/`constructor`
@@ -143,6 +150,7 @@ export async function queryCustomBalance(ctx, config) {
143
150
  error.soft = true
144
151
  throw error
145
152
  }
153
+ if (custom.adapter === 'aliyun') return queryAliyunBalance(ctx, config)
146
154
  const request = custom.request
147
155
  if (request === null || typeof request !== 'object' || typeof request.url !== 'string' || request.url.length === 0) {
148
156
  throw new Error('customBalance.request.url is required')
@@ -403,8 +403,8 @@ function codexWindowMeta(seconds) {
403
403
  ? seconds
404
404
  : typeof seconds === 'string' && seconds.trim() !== '' ? Number(seconds.trim()) : NaN
405
405
  if (!Number.isFinite(value) || value <= 0) return null
406
- if (value === FIVE_HOUR_SECONDS) return { id: 'five-hour', label: '5 小时', periodHours: 5 }
407
- if (value === WEEK_SECONDS) return { id: 'weekly', label: '每周', periodHours: 168 }
406
+ if (value === FIVE_HOUR_SECONDS) return { id: 'five-hour', label: '5h', periodHours: 5 }
407
+ if (value === WEEK_SECONDS) return { id: 'weekly', label: '周', periodHours: 168 }
408
408
  if (value >= MIN_MONTH_SECONDS && value <= MAX_MONTH_SECONDS) {
409
409
  return { id: 'monthly', label: '月度', periodHours: Math.round(value / 3600) }
410
410
  }
@@ -453,15 +453,16 @@ export function parseCodexUsage(payload, ctx = {}) {
453
453
  const resetAt = absoluteResetAt(pickField(window, 'reset_at', 'resetAt'))
454
454
  || relativeResetAt(pickField(window, 'reset_after_seconds', 'resetAfterSeconds'), now())
455
455
  seen.add(id)
456
- windows.push(makeWindow(id, `${groupLabel} · ${meta.label}`, percent, resetAt, meta.periodHours, scope))
456
+ // 组标签为空时直接用窗口标签(主限额组不叠加前缀,窄栏标签列放不下会竖排换行)。
457
+ windows.push(makeWindow(id, groupLabel ? `${groupLabel} · ${meta.label}` : meta.label, percent, resetAt, meta.periodHours, scope))
457
458
  }
458
459
 
459
460
  const source = payload === null || typeof payload !== 'object' ? {} : payload
460
461
 
461
462
  const mainGroup = pickField(source, 'rate_limit', 'rateLimit')
462
463
  if (mainGroup !== null && typeof mainGroup === 'object' && !Array.isArray(mainGroup)) {
463
- pushWindow('primary', 'account', pickField(mainGroup, 'primary_window', 'primaryWindow'), 0, '主力')
464
- pushWindow('primary', 'account', pickField(mainGroup, 'secondary_window', 'secondaryWindow'), 1, '主力')
464
+ pushWindow('primary', 'account', pickField(mainGroup, 'primary_window', 'primaryWindow'), 0, '')
465
+ pushWindow('primary', 'account', pickField(mainGroup, 'secondary_window', 'secondaryWindow'), 1, '')
465
466
  }
466
467
 
467
468
  const codeReviewGroup = pickField(source, 'code_review_rate_limit', 'codeReviewRateLimit')
@@ -208,9 +208,33 @@ function retryAfterMs(response) {
208
208
  return Number.isFinite(date) ? Math.min(5000, Math.max(0, date - Date.now())) : 0
209
209
  }
210
210
 
211
+ // CPA 是一个本地管理端点加上上游 Provider 的双跳链路。管理端或上游
212
+ // 偶发过载时,只有这些暂态 HTTP 状态才适合重试;认证/策略/解析失败必须立刻报错。
213
+ const RETRYABLE_GATEWAY_HTTP_STATUSES = new Set([408, 429, 500, 502, 503, 504])
214
+ const GATEWAY_RESPONSE_ATTEMPTS = 2
215
+ const GATEWAY_RETRY_DELAY_MS = 300
216
+
217
+ function isRetryableGatewayStatus(status) {
218
+ return RETRYABLE_GATEWAY_HTTP_STATUSES.has(Number(status))
219
+ }
220
+
221
+ function gatewayRetryDelay(response) {
222
+ return retryAfterMs(response) || GATEWAY_RETRY_DELAY_MS
223
+ }
224
+
225
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
226
+
227
+ async function discardResponse(response) {
228
+ // 重试前释放未消费的错误响应,避免多账号轮询占住连接池。
229
+ try { await response?.body?.cancel() } catch { /* 清理失败不掩盖原始 HTTP 错误。 */ }
230
+ }
231
+
211
232
  async function readJsonResponse(response, phase, { maxBytes = MAX_BODY_BYTES } = {}) {
212
233
  if (!response || typeof response.ok !== 'boolean') throw errorOf('CPA_RESPONSE_INVALID', `${phase} response is invalid`)
213
- if (!response.ok) throw responseStatusError(response, phase)
234
+ if (!response.ok) {
235
+ await discardResponse(response)
236
+ throw responseStatusError(response, phase)
237
+ }
214
238
  let body
215
239
  try {
216
240
  const length = safeNumber(headerValue(response.headers, 'content-length'))
@@ -250,7 +274,7 @@ async function fetchManagement(source, path, managementKey, options = {}) {
250
274
  'Content-Type': 'application/json',
251
275
  'X-Management-Key': managementKey,
252
276
  }
253
- for (let rateAttempt = 0; rateAttempt < 2; rateAttempt++) {
277
+ for (let attempt = 0; attempt < GATEWAY_RESPONSE_ATTEMPTS; attempt++) {
254
278
  let response
255
279
  try {
256
280
  response = await fetchWithRetry(url, { method: 'GET', headers, redirect: 'manual' }, {
@@ -261,14 +285,15 @@ async function fetchManagement(source, path, managementKey, options = {}) {
261
285
  throw errorOf('CPA_OUTER_HTTP_ERROR', 'management request failed', { status: 0 })
262
286
  }
263
287
  if (response.status >= 300 && response.status < 400) throw errorOf('CPA_REDIRECT_REFUSED', 'CPA redirect refused', { status: response.status })
264
- if (response.status === 429 && rateAttempt === 0) {
265
- const delay = retryAfterMs(response)
266
- if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay))
288
+ // 管理端临时过载/限流时按 Retry-After 或短退避再试一次。
289
+ if (isRetryableGatewayStatus(response.status) && attempt + 1 < GATEWAY_RESPONSE_ATTEMPTS) {
290
+ await discardResponse(response)
291
+ await sleep(gatewayRetryDelay(response))
267
292
  continue
268
293
  }
269
294
  return { payload: await readJsonResponse(response, path), serverVersion: headerValue(response.headers, 'x-cpa-version') }
270
295
  }
271
- throw errorOf('CPA_OUTER_HTTP_ERROR', 'management request rate limited', { status: 429 })
296
+ throw errorOf('CPA_OUTER_HTTP_ERROR', 'management request failed', { status: 0 })
272
297
  }
273
298
 
274
299
  /** Resolve only the management key value, never returning it to callers' state. */
@@ -408,21 +433,41 @@ async function apiCall(source, managementKey, account, request, options = {}) {
408
433
  if (projectId && request.url.includes('cloudcode-pa')) {
409
434
  body.data = request.data || JSON.stringify({ project: projectId })
410
435
  }
411
- let response
412
- try {
413
- response = await fetchWithRetry(endpoint(source, GATEWAY_MANAGEMENT_PATHS.apiCall), {
414
- method: 'POST',
415
- headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-Management-Key': managementKey },
416
- body: JSON.stringify(body),
417
- redirect: 'manual',
418
- }, { attempts: 2, timeoutMs: 30_000, fetchImpl })
419
- } catch (error) {
420
- if (error?.code) throw error
421
- throw errorOf('CPA_OUTER_HTTP_ERROR', 'CPA api-call failed')
436
+ for (let attempt = 0; attempt < GATEWAY_RESPONSE_ATTEMPTS; attempt++) {
437
+ let response
438
+ try {
439
+ response = await fetchWithRetry(endpoint(source, GATEWAY_MANAGEMENT_PATHS.apiCall), {
440
+ method: 'POST',
441
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-Management-Key': managementKey },
442
+ body: JSON.stringify(body),
443
+ redirect: 'manual',
444
+ }, { attempts: 2, timeoutMs: 30_000, fetchImpl })
445
+ } catch (error) {
446
+ if (error?.code) throw error
447
+ throw errorOf('CPA_OUTER_HTTP_ERROR', 'CPA api-call failed')
448
+ }
449
+ if (response.status >= 300 && response.status < 400) throw errorOf('CPA_REDIRECT_REFUSED', 'CPA redirect refused', { status: response.status })
450
+ // api-call 是副作用闸门已校验的只读额度请求;仅对暂态管理端状态重放一次。
451
+ if (isRetryableGatewayStatus(response.status) && attempt + 1 < GATEWAY_RESPONSE_ATTEMPTS) {
452
+ await discardResponse(response)
453
+ await sleep(gatewayRetryDelay(response))
454
+ continue
455
+ }
456
+ try {
457
+ const envelope = await readJsonResponse(response, 'api-call')
458
+ return normalizeInnerEnvelope(envelope, 'api-call')
459
+ } catch (error) {
460
+ // CPA 把上游 HTTP 状态装进 JSON envelope。429/5xx 同样属于暂态,
461
+ // 但认证、策略与格式错误绝不重试。
462
+ if (error?.code === 'CPA_INNER_HTTP_ERROR' && isRetryableGatewayStatus(error.status)
463
+ && attempt + 1 < GATEWAY_RESPONSE_ATTEMPTS) {
464
+ await sleep(GATEWAY_RETRY_DELAY_MS)
465
+ continue
466
+ }
467
+ throw error
468
+ }
422
469
  }
423
- if (response.status >= 300 && response.status < 400) throw errorOf('CPA_REDIRECT_REFUSED', 'CPA redirect refused', { status: response.status })
424
- const envelope = await readJsonResponse(response, 'api-call')
425
- return normalizeInnerEnvelope(envelope, 'api-call')
470
+ throw errorOf('CPA_OUTER_HTTP_ERROR', 'CPA api-call retry exhausted', { status: 0 })
426
471
  }
427
472
 
428
473
  async function queryApiAccount(source, managementKey, account, options = {}) {
package/lib/index.js CHANGED
@@ -29,6 +29,7 @@ import { recordSamples, buildPlanStats, canonicalWindowKey, periodStartOf, aggre
29
29
  import { fetchWithRetry } from './net.js'
30
30
  import { queryGatewayQuota, emptyGatewayQuota, managementKeyVarOf, gatewaySourceFingerprint } from './gateway-quotas.js'
31
31
  import { stateSchema } from './typert.host.js'
32
+ import { customBalanceCredentialVars } from './custom-balance.js'
32
33
 
33
34
  export const name = 'cost-meter'
34
35
 
@@ -982,13 +983,8 @@ async function buildState(ledger, balance = emptyBalance(), goQuota = emptyGoQuo
982
983
  {
983
984
  const credentials = ctx?.get?.('credentials')
984
985
  const names = new Set()
985
- for (const headers of (Array.isArray(ledger.config?.customBalances) ? ledger.config.customBalances : [])
986
- .map(entry => entry?.request?.headers)
987
- .filter(h => h !== null && typeof h === 'object' && !Array.isArray(h))) {
988
- for (const value of Object.values(headers)) {
989
- if (typeof value !== 'string') continue
990
- for (const match of value.matchAll(/\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g)) names.add(match[1])
991
- }
986
+ for (const entry of (Array.isArray(ledger.config?.customBalances) ? ledger.config.customBalances : [])) {
987
+ for (const name of customBalanceCredentialVars(entry)) names.add(name)
992
988
  }
993
989
  for (const source of (Array.isArray(ledger.config?.gatewayQuotas?.sources) ? ledger.config.gatewayQuotas.sources : [])) {
994
990
  names.add(managementKeyVarOf(source))
@@ -1439,9 +1435,7 @@ function createService(ctx, ledger) {
1439
1435
  const entries = customBalanceConfigs()
1440
1436
  for (const key of Object.keys(customBalanceCaches)) {
1441
1437
  const index = Number(key)
1442
- const headers = entries[index]?.request?.headers
1443
- if (headers === null || typeof headers !== 'object' || Array.isArray(headers)) continue
1444
- const uses = Object.values(headers).some(value => typeof value === 'string' && value.includes(`{{${varName}}}`))
1438
+ const uses = customBalanceCredentialVars(entries[index]).includes(varName)
1445
1439
  if (uses) customBalanceCaches[index] = { fetchedAt: 0, value: emptyCustomBalance() }
1446
1440
  }
1447
1441
  }
package/lib/store.js CHANGED
@@ -744,6 +744,7 @@ const VALIDATION_MESSAGES = {
744
744
  balanceBudgetCap: 'balance.budgetCap 必须是非负数或 null',
745
745
  goQuota: 'goQuota 非法',
746
746
  customBalance: 'customBalance 非法',
747
+ customBalanceAdapter: 'customBalance.adapter 必须是 custom / aliyun',
747
748
  customBalanceEnabled: 'customBalance.enabled 必须是布尔值',
748
749
  customBalanceDisplay: 'customBalance.display 必须是 sidebar / settings / both / off',
749
750
  customBalanceRefresh: 'customBalance.refreshMinutes 必须是 1-1440 的整数',
@@ -839,6 +840,7 @@ const VALIDATION_MESSAGES = {
839
840
  balanceBudgetCap: 'balance.budgetCap must be a non-negative number or null',
840
841
  goQuota: 'Invalid goQuota',
841
842
  customBalance: 'Invalid customBalance',
843
+ customBalanceAdapter: 'customBalance.adapter must be custom / aliyun',
842
844
  customBalanceEnabled: 'customBalance.enabled must be a boolean',
843
845
  customBalanceDisplay: 'customBalance.display must be sidebar / settings / both / off',
844
846
  customBalanceRefresh: 'customBalance.refreshMinutes must be an integer from 1 to 1440',
@@ -1158,6 +1160,7 @@ export function applyConfigPatch(current, rawPatch) {
1158
1160
  return
1159
1161
  }
1160
1162
  if (typeof entry.enabled !== 'boolean') errors.push(vmsg(locale, fieldPrefix + 'Enabled'))
1163
+ if (entry.adapter !== undefined && !['custom', 'aliyun'].includes(entry.adapter)) errors.push(vmsg(locale, fieldPrefix + 'Adapter'))
1161
1164
  if (!['sidebar', 'settings', 'both', 'off'].includes(entry.display)) errors.push(vmsg(locale, fieldPrefix + 'Display'))
1162
1165
  const refreshMinutes = Number(entry.refreshMinutes)
1163
1166
  if (!Number.isInteger(refreshMinutes) || refreshMinutes < 1 || refreshMinutes > 1440) errors.push(vmsg(locale, fieldPrefix + 'Refresh'))
@@ -1452,6 +1455,7 @@ export function sanitizeConfig(raw) {
1452
1455
  const sanitizeCustomEntry = (raw, baseEntry) => {
1453
1456
  const entry = raw ?? {}
1454
1457
  const clean = {
1458
+ ...(entry.adapter === 'aliyun' ? { adapter: 'aliyun' } : {}),
1455
1459
  enabled: entry.enabled === true,
1456
1460
  display: oneOf(entry.display, ['sidebar', 'settings', 'both', 'off'], 'both'),
1457
1461
  refreshMinutes: Math.min(1440, Math.max(1, Math.floor(Number(entry.refreshMinutes) || 15))),
@@ -184,6 +184,7 @@ const configSchema = z.object({
184
184
  keySource: z.string().optional(),
185
185
  }),
186
186
  customBalance: z.object({
187
+ adapter: z.enum(['custom', 'aliyun']).optional(),
187
188
  enabled: z.boolean(),
188
189
  label: z.string(),
189
190
  labelEn: z.string().optional(),
@@ -202,6 +203,7 @@ const configSchema = z.object({
202
203
  // 多配置形态(v1.7.0,issue #79):自定义 Provider 余额多条并行,数组为运行期
203
204
  // 真源;customBalance 单配置键仅为旧客户端兼容保留(= entries[0] 的镜像)。
204
205
  customBalances: z.array(z.object({
206
+ adapter: z.enum(['custom', 'aliyun']).optional(),
205
207
  enabled: z.boolean(),
206
208
  label: z.string(),
207
209
  labelEn: z.string().optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cost-meter",
3
- "version": "1.7.10",
3
+ "version": "1.7.13",
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",
@@ -17,11 +17,15 @@
17
17
  "./package.json": "./package.json"
18
18
  },
19
19
  "dsh": {
20
- "bundle": {
21
- "patch": "./cordis.patch.yml"
22
- },
23
- "client": {
24
- "platform": "web"
20
+ "bundle": {"patch": "./cordis.patch.yml"},
21
+ "client": {"platform": "web"},
22
+ "compatibility": {
23
+ "dsh": ">=0.1.0-rc.5",
24
+ "dshReleases": {
25
+ "0.1.2-alpha.3": "compatible",
26
+ "0.1.2-alpha.4": "compatible",
27
+ "0.1.2-alpha.5": "compatible"
28
+ }
25
29
  }
26
30
  },
27
31
  "dshhub": {
@@ -65,7 +69,8 @@
65
69
  "https://api.commandcode.ai",
66
70
  "https://ax.ac.sugon.com",
67
71
  "https://platform.qianwenai.com",
68
- "https://platform-home.qianwenai.com"
72
+ "https://platform-home.qianwenai.com",
73
+ "https://business.aliyuncs.com"
69
74
  ]
70
75
  }
71
76
  },
@@ -104,6 +109,7 @@
104
109
  "engines": {
105
110
  "node": ">=20"
106
111
  },
112
+ "os": ["darwin", "linux", "win32"],
107
113
  "scripts": {
108
114
  "build": "node scripts/build.mjs",
109
115
  "test": "node test/verify.mjs"