dsh-cost-meter 1.7.33 → 1.7.35
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.md +6 -6
- package/README.zh-CN.md +6 -6
- package/lib/bailian-cli.js +130 -0
- package/lib/cli-bridge.js +68 -0
- package/lib/client.js +5 -5
- package/lib/coding-plans.js +53 -4
- package/lib/index.js +15 -11
- package/lib/ledger-persistence.js +223 -0
- package/lib/net.js +21 -1
- package/lib/qwen-cli.js +9 -44
- package/lib/store.js +71 -99
- package/lib/typert.host.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
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 / Volcano Ark / Qwen / Xiaomi MiMo) 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)
|
|
10
10
|
|
|
11
|
-
[](https://github.com/Han-1413141/dsh-cost-meter)
|
|
12
12
|
|
|
13
|
-
**v1.7.
|
|
13
|
+
**v1.7.35** adds Bailian CLI quota support, full model names on hover, bounded decoding of bare gzip responses, and per-endpoint GLM quota diagnostics. See the [release notes](docs/release-notes/v1.7.35.md).
|
|
14
14
|
|
|
15
15
|
[](https://www.npmjs.com/package/dsh-cost-meter)
|
|
16
16
|
[](LICENSE)
|
|
@@ -316,22 +316,22 @@ On Node.js 20, use `npm install -g pnpm@10` instead. See [pnpm installation and
|
|
|
316
316
|
dsh plugin --profile web add dsh-cost-meter
|
|
317
317
|
```
|
|
318
318
|
|
|
319
|
-
**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.
|
|
319
|
+
**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.35`** — review the script before running):
|
|
320
320
|
|
|
321
321
|
```powershell
|
|
322
|
-
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.
|
|
322
|
+
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.35/install.ps1 | iex
|
|
323
323
|
```
|
|
324
324
|
|
|
325
325
|
**Or a plain command line** (the machine must already have pnpm and git; also pinned to the tag):
|
|
326
326
|
|
|
327
327
|
```sh
|
|
328
|
-
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.
|
|
328
|
+
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.35
|
|
329
329
|
```
|
|
330
330
|
|
|
331
331
|
Without git, use the GitHub tag archive:
|
|
332
332
|
|
|
333
333
|
```sh
|
|
334
|
-
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.
|
|
334
|
+
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.35.tar.gz
|
|
335
335
|
```
|
|
336
336
|
|
|
337
337
|
After installing, **restart** `dsh web` (plugin rows, the Typert manifest and the client bundle are all scanned at startup):
|
package/README.zh-CN.md
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
本会话费用 · 当日费用 · 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 / 火山方舟 / 千问 / 小米 MiMo 十一家,含 Volcano Ark AK/SK 签名与 MiMo 控制台 Cookie 查询) · Plan/API 双轨计费(订阅额度与按量金额分离统计,每 1% 额度与满窗的 token/等值金额估算及日/周/月曲线) · 输入框上方额度横条(预算/Go/Coding Plan 用量一条横排显示,可开关)
|
|
10
10
|
|
|
11
|
-
[](https://github.com/Han-1413141/dsh-cost-meter)
|
|
12
12
|
|
|
13
|
-
**v1.7.
|
|
13
|
+
**v1.7.35**:新增百炼 CLI 额度来源、模型全名悬停提示,修复裸 gzip 响应解析,并为 GLM 额度查询提供逐端点诊断。详见[更新说明](docs/release-notes/v1.7.35.md)。
|
|
14
14
|
|
|
15
15
|
[](https://www.npmjs.com/package/dsh-cost-meter)
|
|
16
16
|
[](LICENSE)
|
|
@@ -316,22 +316,22 @@ Node.js 20 请改用 `npm install -g pnpm@10`。版本要求见 [pnpm 官方安
|
|
|
316
316
|
dsh plugin --profile web add dsh-cost-meter
|
|
317
317
|
```
|
|
318
318
|
|
|
319
|
-
**PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.7.
|
|
319
|
+
**PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.7.35`**,建议先下载审阅再运行):
|
|
320
320
|
|
|
321
321
|
```powershell
|
|
322
|
-
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.
|
|
322
|
+
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.35/install.ps1 | iex
|
|
323
323
|
```
|
|
324
324
|
|
|
325
325
|
**或直接命令行**(机器上需已有 pnpm 与 git;同样固定到 tag):
|
|
326
326
|
|
|
327
327
|
```sh
|
|
328
|
-
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.
|
|
328
|
+
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.35
|
|
329
329
|
```
|
|
330
330
|
|
|
331
331
|
没有 git 时可用 GitHub tag 打包直链:
|
|
332
332
|
|
|
333
333
|
```sh
|
|
334
|
-
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.
|
|
334
|
+
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.35.tar.gz
|
|
335
335
|
```
|
|
336
336
|
|
|
337
337
|
安装后**重启** `dsh web`(插件行、Typert 清单与客户端 bundle 均在启动时扫描):
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official Bailian CLI bridge (GitHub modelstudioai/cli, npm bailian-cli, `bl` / `bailian`).
|
|
3
|
+
* Credentials remain owned by the CLI (bl auth login --console / --api-key).
|
|
4
|
+
* Reads Token Plan + Coding Plan quota windows via two fixed read-only subcommands.
|
|
5
|
+
*/
|
|
6
|
+
import { bridgeCode, resolveNpmCli, runCliJson } from './cli-bridge.js'
|
|
7
|
+
|
|
8
|
+
export const BAILIAN_TOKEN_PLAN_ARGS = Object.freeze(['usage', 'token-plan', '--output', 'json', '--quiet'])
|
|
9
|
+
export const BAILIAN_CODING_PLAN_ARGS = Object.freeze(['usage', 'coding-plan', '--output', 'json', '--quiet'])
|
|
10
|
+
|
|
11
|
+
const MESSAGES = {
|
|
12
|
+
zh: {
|
|
13
|
+
missing: '未找到百炼 CLI。请在 DSH 所在机器安装 bailian-cli(npm install -g bailian-cli),运行 bl auth login --console,并重启 DSH 以更新 PATH。',
|
|
14
|
+
failed: '百炼 CLI 查询失败,请运行 bl auth status --output json 检查登录后重试。',
|
|
15
|
+
timeout: '百炼 CLI 查询超时,请检查网络后刷新。',
|
|
16
|
+
invalid: '百炼 CLI 返回的额度格式无效,请更新 CLI 后重试。',
|
|
17
|
+
unavailable: '百炼 CLI 未返回有效计划额度(可能未订阅 Token Plan / Coding Plan),请检查 CLI 或切换其他额度来源。',
|
|
18
|
+
},
|
|
19
|
+
en: {
|
|
20
|
+
missing: 'Bailian CLI not found. Install bailian-cli on the DSH host (npm install -g bailian-cli), run bl auth login --console, then restart DSH to refresh PATH.',
|
|
21
|
+
failed: 'Bailian CLI query failed. Check bl auth status --output json and retry.',
|
|
22
|
+
timeout: 'Bailian CLI query timed out. Check the network and refresh.',
|
|
23
|
+
invalid: 'Bailian CLI returned invalid quota data. Update the CLI and retry.',
|
|
24
|
+
unavailable: 'Bailian CLI returned no valid plan quota (no Token Plan / Coding Plan subscription). Check the CLI or select another quota source.',
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
const message = (locale, code) => MESSAGES[locale === 'en' ? 'en' : 'zh'][code]
|
|
28
|
+
const fail = (locale, code) => Object.assign(new Error(message(locale, code)), { code, soft: code === 'missing' || code === 'unavailable' })
|
|
29
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
30
|
+
const finite = value => typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
31
|
+
|
|
32
|
+
const PERIOD_KEYS = [['per5Hour', 'fiveHour'], ['perWeek', 'weekly'], ['perBillMonth', 'monthly']]
|
|
33
|
+
const WINDOW_ORDER = ['fiveHour', 'weekly', 'monthly']
|
|
34
|
+
|
|
35
|
+
function windowOf(ratio, reset, locale) {
|
|
36
|
+
if (!finite(ratio)) throw fail(locale, 'invalid')
|
|
37
|
+
let resetsAt = ''
|
|
38
|
+
if (reset !== undefined && reset !== 0) {
|
|
39
|
+
if (!finite(reset) || !Number.isFinite(new Date(reset).getTime())) throw fail(locale, 'invalid')
|
|
40
|
+
resetsAt = new Date(reset).toISOString()
|
|
41
|
+
}
|
|
42
|
+
return { percent: Math.min(100, Math.round(ratio * 1000) / 10), resetsAt }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function tokenWindows(payload, locale) {
|
|
46
|
+
const windows = {}
|
|
47
|
+
for (const [prefix, name] of [['per5Hour', 'fiveHour'], ['per1Week', 'weekly']]) {
|
|
48
|
+
const ratio = payload[`${prefix}Percentage`]
|
|
49
|
+
if (ratio !== undefined) windows[name] = windowOf(ratio, payload[`${prefix}ResetTime`], locale)
|
|
50
|
+
}
|
|
51
|
+
return windows
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function codingWindows(payload, locale) {
|
|
55
|
+
const windows = {}
|
|
56
|
+
for (const [rawKey, name] of PERIOD_KEYS) {
|
|
57
|
+
const cell = payload[rawKey]
|
|
58
|
+
if (cell === undefined) continue
|
|
59
|
+
if (!record(cell)) throw fail(locale, 'invalid')
|
|
60
|
+
const total = cell.totalQuota
|
|
61
|
+
// Unbounded or missing limits produce no window (the console prints "unlimited").
|
|
62
|
+
if (total === undefined || total === 0) continue
|
|
63
|
+
if (!finite(total)) throw fail(locale, 'invalid')
|
|
64
|
+
const ratio = cell.percentage ?? (finite(cell.usedQuota) ? cell.usedQuota / total : undefined)
|
|
65
|
+
if (ratio === undefined) continue
|
|
66
|
+
windows[name] = windowOf(ratio, cell.resetTime, locale)
|
|
67
|
+
}
|
|
68
|
+
return windows
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Merge both payloads into the normalized windows contract.
|
|
73
|
+
* Token Plan wins same-name 5h/week windows; Coding Plan always contributes the
|
|
74
|
+
* billing-month window. `source` records which subscriptions answered.
|
|
75
|
+
*/
|
|
76
|
+
export function parseBailianUsage(payloads, locale = 'zh') {
|
|
77
|
+
const token = payloads?.token, coding = payloads?.coding
|
|
78
|
+
let invalid = false
|
|
79
|
+
const parse = (payload, reader) => {
|
|
80
|
+
if (payload === null || payload === undefined) return {}
|
|
81
|
+
try {
|
|
82
|
+
if (!record(payload)) throw fail(locale, 'invalid')
|
|
83
|
+
return reader(payload, locale)
|
|
84
|
+
} catch { invalid = true; return {} }
|
|
85
|
+
}
|
|
86
|
+
const tokenWins = parse(token, tokenWindows)
|
|
87
|
+
const codingWins = parse(coding, codingWindows)
|
|
88
|
+
const windows = {}
|
|
89
|
+
for (const name of WINDOW_ORDER) {
|
|
90
|
+
const win = tokenWins[name] ?? codingWins[name]
|
|
91
|
+
if (win !== undefined) windows[name] = win
|
|
92
|
+
}
|
|
93
|
+
if (Object.keys(windows).length === 0) throw fail(locale, invalid ? 'invalid' : 'unavailable')
|
|
94
|
+
const sources = []
|
|
95
|
+
if (Object.keys(tokenWins).length > 0) sources.push('Token Plan')
|
|
96
|
+
if (Object.keys(codingWins).length > 0) {
|
|
97
|
+
sources.push(typeof coding.instanceType === 'string' && coding.instanceType.length > 0
|
|
98
|
+
? `Coding Plan (${coding.instanceType})` : 'Coding Plan')
|
|
99
|
+
}
|
|
100
|
+
windows.source = { resetsAt: '', text: `${sources.join(' + ')} (CLI)` }
|
|
101
|
+
return { windows }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function queryBailianCli(locale = 'zh', options = {}) {
|
|
105
|
+
const command = await resolveNpmCli({ packageName: 'bailian-cli', binName: 'bl', env: options.env, platform: options.platform })
|
|
106
|
+
if (!command) throw fail(locale, 'missing')
|
|
107
|
+
const { env = process.env, signal, timeoutMs = 15000, maxBuffer = 1024 * 1024 } = options
|
|
108
|
+
const run = args => runCliJson({ ...command, args: [...command.args, ...args], env, signal, timeoutMs, maxBuffer })
|
|
109
|
+
const settled = await Promise.allSettled([run(BAILIAN_TOKEN_PLAN_ARGS), run(BAILIAN_CODING_PLAN_ARGS)])
|
|
110
|
+
signal?.throwIfAborted()
|
|
111
|
+
if (settled.every(x => x.status === 'rejected')) {
|
|
112
|
+
const errors = settled.map(x => x.reason)
|
|
113
|
+
// Surface timeout first: it is the most actionable failure across both commands.
|
|
114
|
+
throw fail(locale, bridgeCode(errors.find(e => e.kind === 'timeout') ?? errors[0]))
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
return parseBailianUsage({
|
|
118
|
+
token: settled[0].status === 'fulfilled' ? settled[0].value : null,
|
|
119
|
+
coding: settled[1].status === 'fulfilled' ? settled[1].value : null,
|
|
120
|
+
}, locale)
|
|
121
|
+
} catch (error) {
|
|
122
|
+
// With one command broken, a surviving source still answers; only when nothing
|
|
123
|
+
// valid remains does the broken command's real failure become the message.
|
|
124
|
+
if (error.code === 'unavailable') {
|
|
125
|
+
const broken = settled.map(x => x.reason).find((_, i) => settled[i].status === 'rejected')
|
|
126
|
+
if (broken) throw fail(locale, bridgeCode(broken))
|
|
127
|
+
}
|
|
128
|
+
throw error
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共享 CLI 子进程桥(qwen-cli / bailian-cli):PATH 解析 + 固定参数执行 + JSON 读取。
|
|
3
|
+
* 凭据始终由 CLI 自持;错误只携带分类标签(kind/exitCode),永不携带子进程输出或路径。
|
|
4
|
+
*/
|
|
5
|
+
import { execFile } from 'node:child_process'
|
|
6
|
+
import { access, readFile, stat } from 'node:fs/promises'
|
|
7
|
+
import { constants } from 'node:fs'
|
|
8
|
+
import { delimiter, isAbsolute, join, relative, resolve } from 'node:path'
|
|
9
|
+
|
|
10
|
+
/** Resolve an npm-installed CLI without a shell; Windows npm .cmd shims are never evaluated. */
|
|
11
|
+
export async function resolveNpmCli({ packageName, binName, env = process.env, platform = process.platform } = {}) {
|
|
12
|
+
const pathValue = Object.entries(env).find(([key]) => key.toLowerCase() === 'path')?.[1] ?? ''
|
|
13
|
+
for (const raw of pathValue.split(platform === 'win32' ? ';' : delimiter)) {
|
|
14
|
+
const dir = raw.replace(/^"(.*)"$/, '$1')
|
|
15
|
+
// Empty/relative PATH entries must not execute a workspace-supplied program.
|
|
16
|
+
if (!isAbsolute(dir)) continue
|
|
17
|
+
if (platform === 'win32') {
|
|
18
|
+
// npm global installs put the package next to the Windows shim.
|
|
19
|
+
const root = join(dir, 'node_modules', packageName)
|
|
20
|
+
try {
|
|
21
|
+
const meta = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
|
|
22
|
+
const bin = meta.bin?.[binName]
|
|
23
|
+
if (meta.name === packageName && typeof bin === 'string') {
|
|
24
|
+
const target = resolve(root, bin), rel = relative(root, target)
|
|
25
|
+
if (rel && !rel.startsWith('..') && !isAbsolute(rel) && (await stat(target)).isFile()) {
|
|
26
|
+
return { file: process.execPath, args: [target] }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
} catch { /* Try the next installed executable. */ }
|
|
30
|
+
}
|
|
31
|
+
const file = join(dir, binName + (platform === 'win32' ? '.exe' : ''))
|
|
32
|
+
try {
|
|
33
|
+
await access(file, platform === 'win32' ? constants.F_OK : constants.X_OK)
|
|
34
|
+
if ((await stat(file)).isFile()) return { file, args: [] }
|
|
35
|
+
} catch { /* Not installed in this PATH entry. */ }
|
|
36
|
+
}
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Run a fixed CLI command and parse stdout as JSON. Rejects only { kind, exitCode }:
|
|
42
|
+
* 'maxbuffer' | 'timeout' | 'exit' | 'spawn' | 'json' — never raw output or paths.
|
|
43
|
+
*/
|
|
44
|
+
export function runCliJson({ file, args, env = process.env, signal, timeoutMs = 15000, maxBuffer = 1024 * 1024 }) {
|
|
45
|
+
return new Promise((done, reject) => {
|
|
46
|
+
const child = execFile(file, args, {
|
|
47
|
+
encoding: 'utf8', windowsHide: true, shell: false, env, signal, timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer,
|
|
48
|
+
}, (error, out) => {
|
|
49
|
+
if (error) {
|
|
50
|
+
const kind = error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' ? 'maxbuffer'
|
|
51
|
+
: error.killed || error.code === 'ERR_CHILD_PROCESS_TIMEDOUT' ? 'timeout'
|
|
52
|
+
: typeof error.code === 'number' ? 'exit'
|
|
53
|
+
: 'spawn'
|
|
54
|
+
reject(Object.assign(new Error('cli'), { kind, exitCode: typeof error.code === 'number' ? error.code : undefined }))
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
try { done(JSON.parse(out)) } catch { reject(Object.assign(new Error('cli'), { kind: 'json' })) }
|
|
58
|
+
})
|
|
59
|
+
child.stdin?.end()
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Map a bridge error kind to a per-CLI message code (exitCode may refine 'exit'). */
|
|
64
|
+
export const bridgeCode = (error, { authExitCode } = {}) =>
|
|
65
|
+
error.kind === 'timeout' ? 'timeout'
|
|
66
|
+
: error.kind === 'maxbuffer' || error.kind === 'json' ? 'invalid'
|
|
67
|
+
: error.kind === 'exit' && authExitCode !== undefined && error.exitCode === authExitCode ? 'auth'
|
|
68
|
+
: 'failed'
|