dsh-cost-meter 1.7.9 → 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 CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  Per-conversation cost · daily totals · OpenCode Go subscription quota display · budget with usage percentage · official account balance · custom provider balance · balance progress bar · history · peak/off-peak pricing hours display (peak hours UTC 01:00–04:00, 06:00–10:00; from Aug 23, 2026 weekends are billed at off-peak prices all day, shown as “Weekend — all off-peak”) · pre-switch popup & system-notification alerts for peak/off-peak changes (position / lead time / alert type configurable) · one-click price sync from the official docs · Codex-style token usage heat grid · multi-vendor model pricing (built-in 90+ model price catalog with auto-matching) · mainstream Coding Plan quota queries & display (Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow / CommandCode / SCNet) plan/API dual-track billing (subscription quota vs pay-as-you-go money separated, per-1% & full-window token/equivalent-cost estimates with daily/weekly/monthly curves) · · quota strip above the input box (budget / Go / coding-plan usage in one row, toggleable)
8
8
 
9
- [![version](https://img.shields.io/badge/version-1.7.9-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
9
+ [![version](https://img.shields.io/badge/version-1.7.13-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
10
10
  [![npm](https://img.shields.io/npm/v/dsh-cost-meter?label=npm)](https://www.npmjs.com/package/dsh-cost-meter)
11
11
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
12
12
  [![dsh](https://img.shields.io/badge/DeepSeek%20Harness-dsh--plugin-4176E6)](https://github.com/deepseek-ai/deepseek-harness)
@@ -57,6 +57,8 @@ English | [中文](README.md)
57
57
 
58
58
  ## Custom provider balance example (NewApi template)
59
59
 
60
+ For Qianwen / Alibaba Cloud fund accounts, use **Add Qianwen / Alibaba Cloud balance** to query available funds with a RAM AccessKey signature. The card uses the currency returned by the API. See the [setup, permissions and balance definition](docs/qianwen-balance.md#english).
61
+
60
62
  The `extract` rules accept four forms: a numeric constant, a dot path string, `add`/`subtract` over multiple paths, and `divide` scaling by a `by` divisor. **`divide` fits NewApi and other endpoints that meter balance in integer quota** (1 USD = 500000 quota — the same conversion cc-switch uses).
61
63
 
62
64
  For NewApi `GET /api/usage/token` (response `{ "code": 200, "data": { "total_granted": ..., "total_used": ..., "total_available": ..., "unlimited_quota": false } }`):
@@ -87,11 +89,11 @@ For NewApi `GET /api/usage/token` (response `{ "code": 200, "data": { "total_gra
87
89
  - Unlimited-quota tokens (`unlimited_quota: true`) have no `total_available`, so `remaining` cannot be extracted and the query reports “remaining is missing or not numeric” — use a limited-quota token or a middle-layer endpoint that converts the units;
88
90
  - Entry point: Settings → Cost (Quota tab) → “Custom provider balance” → expand config; or write `config.customBalance` in `storages/cost-meter/ledger.json`.
89
91
 
90
- ### Credentials & security (v1.7.9)
92
+ ### Credentials & security
91
93
 
92
94
  - **Variable naming**: `{{VAR_NAME}}` follows the `<ROUTE>_API_KEY` convention — `<ROUTE>` is the Provider ID from the DSH Models page (Settings → Models), uppercased with non-alphanumeric characters replaced by underscores, e.g. `openai`→`{{OPENAI_API_KEY}}`, `anthropic`→`{{ANTHROPIC_API_KEY}}`, `abc23-d`→`{{ABC23_D_API_KEY}}`. Sharing a name with the Models page means the balance query and model calls **share the same key** (both resolve from the DSH credential store). This note is also shown above the “Headers (JSON)” input in Settings.
93
95
  - **Credential input fields**: after expanding an entry, the “Credential input” section renders one write-only field per `{{VAR}}` placeholder found in the headers — the key goes straight into the DSH credential store (never written to disk, never echoed back, never stored in `ledger.json`); no need to hand-edit environment variables or credential files.
94
- - **Automatic plaintext migration**: older versions let a literal `Bearer sk-…` in the headers leak into `ledger.json` in plaintext. Since v1.7.9 the plugin imports such keys into the DSH credential store at startup and replaces the header value with a `{{CUSTOM_BALANCE_KEY_…}}` placeholder (derived from the entry's host + header name, stable across restarts) — nothing breaks. From now on `ledger.json` and the config shipped to the browser **never contain plaintext keys**: suspected secret headers (Authorization / X-Api-Key / Bearer / sk- prefixes / long opaque strings) are blanked, while placeholders and ordinary headers pass through.
96
+ - **Automatic plaintext migration**: older versions let a literal `Bearer sk-…` in the headers leak into `ledger.json` in plaintext. The plugin imports such keys into the DSH credential store at startup and replaces the header value with a `{{CUSTOM_BALANCE_KEY_…}}` placeholder (derived from the entry's host + header name, stable across restarts) — nothing breaks. From now on `ledger.json` and the config shipped to the browser **never contain plaintext keys**: suspected secret headers (Authorization / X-Api-Key / Bearer / sk- prefixes / long opaque strings) are blanked, while placeholders and ordinary headers pass through.
95
97
  - **Credential allowlist `allowedHosts`**: when headers carry credentials (placeholders or plaintext), the outbound host must be on this list or the request is refused — protection against leaked keys when importing someone else's config. Without a list, requests proceed with a one-time logged warning. The entry panel provides an “Allowed hosts” input (comma-separated).
96
98
 
97
99
  ## CLIProxyAPI Gateway Quotas and WorkBuddy Credits (Issue #87)
@@ -252,22 +254,22 @@ Real captures from an actual DSH sidebar of the period strip and collapsed verti
252
254
  dsh plugin --profile web add dsh-cost-meter
253
255
  ```
254
256
 
255
- **PowerShell one-click script** (copy the whole line, paste, press Enter; pnpm is provisioned automatically, git is auto-detected — no clone needed; the install chain is **pinned to the release tag `v1.7.9`** — review the script before running):
257
+ **PowerShell one-click script** (copy the whole line, paste, press Enter; pnpm is provisioned automatically, git is auto-detected — no clone needed; the install chain is **pinned to the release tag `v1.7.13`** — review the script before running):
256
258
 
257
259
  ```powershell
258
- irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.9/install.ps1 | iex
260
+ irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.13/install.ps1 | iex
259
261
  ```
260
262
 
261
263
  **Or a plain command line** (the machine must already have pnpm and git; also pinned to the tag):
262
264
 
263
265
  ```sh
264
- dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.9
266
+ dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.13
265
267
  ```
266
268
 
267
269
  Without git, use the GitHub tag archive:
268
270
 
269
271
  ```sh
270
- dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.9.tar.gz
272
+ dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.13.tar.gz
271
273
  ```
272
274
 
273
275
  After installing, **restart** `dsh web` (plugin rows, the Typert manifest and the client bundle are all scanned at startup):
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  本会话费用 · 当日费用 · OpenCode Go 订阅额度显示 · 预算与已用百分比 · 官方账户余额 · 自定义 Provider 余额查询(可配任意 HTTP 端点) · 余额三段进度条 · 历史记录 · 峰谷计价时段显示(UTC 01:00–04:00、06:00–10:00 为峰时段;2026-08-23 起周末全天按谷价,显示「周末时段——全谷价」) · 峰/谷切换前弹窗与系统通知提醒(位置/提前量/提醒类型可配) · 官方价格一键同步 · 类 Codex Token 用量热图 · 多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配) · 主流 Coding Plan 额度查询与显示(Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow / CommandCode / SCNet / 火山方舟 九家,含 Volcano Ark AK/SK 签名) · Plan/API 双轨计费(订阅额度与按量金额分离统计,每 1% 额度与满窗的 token/等值金额估算及日/周/月曲线) · 输入框上方额度横条(预算/Go/Coding Plan 用量一条横排显示,可开关)
8
8
 
9
- [![version](https://img.shields.io/badge/version-1.7.9-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
9
+ [![version](https://img.shields.io/badge/version-1.7.13-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
10
10
  [![npm](https://img.shields.io/npm/v/dsh-cost-meter?label=npm)](https://www.npmjs.com/package/dsh-cost-meter)
11
11
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
12
12
  [![dsh](https://img.shields.io/badge/DeepSeek%20Harness-dsh--plugin-4176E6)](https://github.com/deepseek-ai/deepseek-harness)
@@ -58,6 +58,8 @@
58
58
 
59
59
  ## 自定义 Provider 余额配置示例(NewApi 模板)
60
60
 
61
+ 千问 / 阿里云资金账户余额可直接点击「添加千问 / 阿里云余额」,使用 RAM AccessKey 签名查询,显示接口返回的可用金与币种;配置步骤、所需权限及口径见[千问余额说明](docs/qianwen-balance.md)。
62
+
61
63
  自定义 Provider 余额的 `extract` 规则支持四种形式:数字常量、点路径字符串、`add`/`subtract` 多路径加减、`divide` 按 `by` 除数缩放。**`divide` 适用于 NewApi 等以 quota 整数计量的端点**(1 USD = 500000 quota,与 cc-switch 同款换算)。
62
64
 
63
65
  以 NewApi 的 `GET /api/usage/token` 为例(响应 `{ "code": 200, "data": { "total_granted": ..., "total_used": ..., "total_available": ..., "unlimited_quota": false } }`):
@@ -88,11 +90,11 @@
88
90
  - 无限额度 token(`unlimited_quota: true`)没有 `total_available`,无法提取 `remaining`,查询会报「remaining is missing or not numeric」——请改用有限额度 token,或在中间层端点换算;
89
91
  - 配置入口:设置 → 费用(额度标签)→「自定义 Provider 余额」展开配置;或直接改 `storages/cost-meter/ledger.json` 的 `config.customBalance`。
90
92
 
91
- ### 凭据与安全(v1.7.9)
93
+ ### 凭据与安全
92
94
 
93
95
  - **变量名命名规则**:`{{VAR_NAME}}` 的参考格式为 `<ROUTE>_API_KEY`——`<ROUTE>` 对应 DSH 模型配置页(设置 → 模型)里的 Provider ID,把 ID 大写、非字母数字字符替换为下划线,例如 `openai`→`{{OPENAI_API_KEY}}`、`anthropic`→`{{ANTHROPIC_API_KEY}}`、`abc23-d`→`{{ABC23_D_API_KEY}}`。与模型页共用同一变量名,自定义余额查询与模型调用即**共用同一把密钥**(都从 DSH 凭据库解析)。该说明也展示在设置页「请求头 (JSON)」输入框上方。
94
96
  - **凭据输入框**:展开条目配置后,「凭据输入」区会为请求头里出现的每个 `{{VAR}}` 占位符显示一行 write-only 输入框,密钥直接存入 DSH 凭据库(不落盘、不回显、不经 `ledger.json`),无需再手改环境变量或凭据文件。
95
- - **明文密钥自动迁移**:旧版本把 `Bearer sk-…` 明文写在请求头里时会明文落盘;v1.7.9 起插件在启动时自动把这类明文导入 DSH 凭据库,并把头值替换为 `{{CUSTOM_BALANCE_KEY_…}}` 占位符(名称由条目 host + 头名派生,跨重启稳定),功能不受影响。此后 `ledger.json` 与下发给浏览器的配置**永不包含明文密钥**——疑似密钥头(Authorization / X-Api-Key / Bearer / sk- 前缀 / 长不透明串)一律置空,占位符与普通头照常保留。
97
+ - **明文密钥自动迁移**:旧版本把 `Bearer sk-…` 明文写在请求头里时会明文落盘;插件在启动时自动把这类明文导入 DSH 凭据库,并把头值替换为 `{{CUSTOM_BALANCE_KEY_…}}` 占位符(名称由条目 host + 头名派生,跨重启稳定),功能不受影响。此后 `ledger.json` 与下发给浏览器的配置**永不包含明文密钥**——疑似密钥头(Authorization / X-Api-Key / Bearer / sk- 前缀 / 长不透明串)一律置空,占位符与普通头照常保留。
96
98
  - **凭据白名单 `allowedHosts`**:请求头携带密钥(占位符或明文)时,出站主机必须命中该白名单,否则直接拒绝——用于防止「导入他人配置」导致密钥外带。未配置白名单时放行并在日志警告一次。设置页条目面板内有「凭据白名单主机」输入框(逗号分隔)。
97
99
 
98
100
  ## CLIProxyAPI 网关额度与 WorkBuddy 积分 (Issue #87)
@@ -254,22 +256,22 @@
254
256
  dsh plugin --profile web add dsh-cost-meter
255
257
  ```
256
258
 
257
- **PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.7.9`**,建议先下载审阅再运行):
259
+ **PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.7.13`**,建议先下载审阅再运行):
258
260
 
259
261
  ```powershell
260
- irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.9/install.ps1 | iex
262
+ irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.13/install.ps1 | iex
261
263
  ```
262
264
 
263
265
  **或直接命令行**(机器上需已有 pnpm 与 git;同样固定到 tag):
264
266
 
265
267
  ```sh
266
- dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.9
268
+ dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.13
267
269
  ```
268
270
 
269
271
  没有 git 时可用 GitHub tag 打包直链:
270
272
 
271
273
  ```sh
272
- dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.9.tar.gz
274
+ dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.13.tar.gz
273
275
  ```
274
276
 
275
277
  安装后**重启** `dsh web`(插件行、Typert 清单与客户端 bundle 均在启动时扫描):
@@ -0,0 +1,119 @@
1
+ /** 千问 / 阿里云资金账户可用金(issue #98)。凭据仅用于服务端 ACS3 签名。 */
2
+ import { createHash, createHmac, randomUUID } from 'node:crypto'
3
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
4
+ import { fetchWithRetry } from './net.js'
5
+
6
+ // API shape/endpoint: aliyun/alibabacloud-typescript-sdk, bssopenapi-20230930.
7
+ // Signature: https://help.aliyun.com/zh/sdk/product-overview/v3-request-structure-and-signature
8
+ export const ALIYUN_BALANCE_URL = 'https://business.aliyuncs.com/'
9
+ export const ALIYUN_BALANCE_CREDENTIAL_VARS = [
10
+ 'ALIBABA_CLOUD_ACCESS_KEY_ID',
11
+ 'ALIBABA_CLOUD_ACCESS_KEY_SECRET',
12
+ 'ALIBABA_CLOUD_SECURITY_TOKEN',
13
+ ]
14
+ const MAX_BYTES = 262144
15
+ const hash = value => createHash('sha256').update(value).digest('hex')
16
+
17
+ export function signAliyunBalanceRequest(accessKeyId, accessKeySecret, securityToken = '', { now = new Date(), nonce = randomUUID() } = {}) {
18
+ const headers = {
19
+ 'content-type': 'application/x-www-form-urlencoded',
20
+ host: 'business.aliyuncs.com',
21
+ 'x-acs-action': 'GetFundAccountAvailableAmount',
22
+ 'x-acs-content-sha256': hash(''),
23
+ 'x-acs-date': now.toISOString().replace(/\.\d{3}Z$/, 'Z'),
24
+ 'x-acs-signature-nonce': nonce,
25
+ 'x-acs-version': '2023-09-30',
26
+ }
27
+ if (securityToken) headers['x-acs-security-token'] = securityToken
28
+ const keys = Object.keys(headers).sort()
29
+ const signedHeaders = keys.join(';')
30
+ const canonicalHeaders = keys.map(key => `${key}:${headers[key].trim()}\n`).join('')
31
+ const canonical = ['POST', '/', '', canonicalHeaders, signedHeaders, hash('')].join('\n')
32
+ const signature = createHmac('sha256', accessKeySecret).update(`ACS3-HMAC-SHA256\n${hash(canonical)}`).digest('hex')
33
+ headers.Authorization = `ACS3-HMAC-SHA256 Credential=${accessKeyId},SignedHeaders=${signedHeaders},Signature=${signature}`
34
+ headers.Accept = 'application/json'
35
+ return { method: 'POST', headers, body: '', redirect: 'manual' }
36
+ }
37
+
38
+ const messages = {
39
+ zh: {
40
+ missing: '请配置阿里云 RAM AccessKey ID 和 AccessKey Secret(不是千问模型 API Key)',
41
+ network: '阿里云余额网络请求失败,请稍后重试',
42
+ redirect: '阿里云余额接口发生重定向,已拒绝转发凭据',
43
+ permission: '阿里云余额鉴权失败,请检查 AccessKey、STS 有效期及 RAM 权限 bss:DescribeBillingAccount',
44
+ http: '阿里云余额接口返回 HTTP {status}',
45
+ payload: '阿里云余额响应无效或过大,请稍后重试',
46
+ amount: '阿里云余额响应缺少有效的 AvailableAmount,未将其视为零余额',
47
+ currency: '阿里云余额响应缺少受支持的币种(CNY / USD / EUR)',
48
+ },
49
+ en: {
50
+ missing: 'Configure Alibaba Cloud RAM AccessKey ID and AccessKey Secret (not a Qianwen model API key)',
51
+ network: 'Alibaba Cloud balance network request failed; please retry later',
52
+ redirect: 'Alibaba Cloud balance redirect refused to protect credentials',
53
+ permission: 'Alibaba Cloud balance authentication failed; check AccessKey, STS expiry and RAM permission bss:DescribeBillingAccount',
54
+ http: 'Alibaba Cloud balance returned HTTP {status}',
55
+ payload: 'Alibaba Cloud balance response is invalid or too large; please retry later',
56
+ amount: 'Alibaba Cloud balance response has no valid AvailableAmount; it was not treated as zero',
57
+ currency: 'Alibaba Cloud balance response has no supported currency (CNY / USD / EUR)',
58
+ },
59
+ }
60
+ const failure = (locale, key, status = '') => new Error(messages[locale === 'en' ? 'en' : 'zh'][key].replace('{status}', String(status)))
61
+
62
+ export function parseAliyunBalance(data, locale = 'zh') {
63
+ const raw = data?.AvailableAmount
64
+ const text = typeof raw === 'string' ? raw.trim() : ''
65
+ const amount = typeof raw === 'number' ? raw : /^[+-]?\d+(\.\d+)?$/.test(text) ? Number(text) : NaN
66
+ if (!Number.isFinite(amount)) throw failure(locale, 'amount')
67
+ if (!['CNY', 'USD', 'EUR'].includes(data?.Currency)) throw failure(locale, 'currency')
68
+ // AvailableAmount 是可用金,包含信控/未结清款等因素;不能把它当成现金余额或消费额。
69
+ return { remaining: amount, unit: data.Currency, maxBudget: null, spend: null }
70
+ }
71
+
72
+ async function resolveCredential(ctx, name) {
73
+ try {
74
+ const hit = await ctx?.get?.('credentials')?.resolve(credentialRef(name))
75
+ if (typeof hit?.value === 'string' && hit.value.trim()) return hit.value.trim()
76
+ } catch { /* 与现有余额 adapter 一致,回落环境变量。 */ }
77
+ return String(process.env[name] ?? '').trim()
78
+ }
79
+
80
+ async function readPayload(response, locale) {
81
+ if (Number(response.headers.get('content-length')) > MAX_BYTES) {
82
+ await response.body?.cancel().catch(() => {})
83
+ throw failure(locale, 'payload')
84
+ }
85
+ try {
86
+ const parts = []
87
+ let bytes = 0
88
+ for await (const part of response.body) {
89
+ bytes += part.length
90
+ if (bytes > MAX_BYTES) throw failure(locale, 'payload')
91
+ parts.push(part)
92
+ }
93
+ return JSON.parse(Buffer.concat(parts).toString('utf8'))
94
+ } catch { throw failure(locale, 'payload') }
95
+ }
96
+
97
+ export async function queryAliyunBalance(ctx, config, { fetchImpl = fetch } = {}) {
98
+ const locale = config?.locale
99
+ const [ak, sk, token] = await Promise.all(ALIYUN_BALANCE_CREDENTIAL_VARS.map(name => resolveCredential(ctx, name)))
100
+ if (!ak || !sk) throw Object.assign(failure(locale, 'missing'), { soft: true })
101
+ let response
102
+ try {
103
+ // 每次网络重试重新生成时间戳与 nonce;URL、Action、body 均固定为只读账户查询。
104
+ response = await fetchWithRetry(ALIYUN_BALANCE_URL, {}, {
105
+ attempts: 2, timeoutMs: 15000,
106
+ fetchImpl: (url, init) => fetchImpl(url, { ...signAliyunBalanceRequest(ak, sk, token), signal: init.signal }),
107
+ })
108
+ } catch { throw failure(locale, 'network') }
109
+ if (!response.ok) {
110
+ await response.body?.cancel().catch(() => {})
111
+ if (response.status >= 300 && response.status < 400) throw failure(locale, 'redirect')
112
+ if (response.status === 401 || response.status === 403) throw failure(locale, 'permission')
113
+ throw failure(locale, 'http', response.status)
114
+ }
115
+ const data = await readPayload(response, locale)
116
+ // 不把上游 Message/RequestId/账户身份下发浏览器;即使错误响应夹带余额也不能记作成功。
117
+ if ((data?.Code && data.Code !== 'Success') || data?.Success === false) throw failure(locale, 'permission')
118
+ return { label: config?.customBalance?.label || '千问 / 阿里云', ...parseAliyunBalance(data, locale) }
119
+ }