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.
- package/README.en.md +9 -7
- package/README.md +9 -7
- package/lib/aliyun-balance.js +119 -0
- package/lib/client.js +3 -3
- package/lib/custom-balance.js +8 -0
- package/lib/gateway-quota-adapters.js +6 -5
- package/lib/gateway-quotas.js +65 -20
- package/lib/index.js +4 -10
- package/lib/store.js +4 -0
- package/lib/typert.host.js +2 -0
- package/package.json +13 -7
package/lib/custom-balance.js
CHANGED
|
@@ -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: '
|
|
407
|
-
if (value === WEEK_SECONDS) return { id: 'weekly', label: '
|
|
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
|
-
|
|
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')
|
package/lib/gateway-quotas.js
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
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
|
|
986
|
-
|
|
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
|
|
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))),
|
package/lib/typert.host.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"
|
|
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"
|