dsh-cost-meter 1.5.9 → 1.5.12
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 +5 -5
- package/README.md +5 -5
- package/lib/backfill.js +22 -11
- package/lib/client.js +67 -20
- package/lib/index.js +41 -12
- package/lib/pricing.js +6 -4
- package/lib/store.js +34 -4
- package/lib/typert.host.js +11 -3
- package/package.json +1 -1
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) · 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)
|
|
8
8
|
|
|
9
|
-
[](https://github.com/Han-1413141/dsh-cost-meter)
|
|
10
10
|
[](https://www.npmjs.com/package/dsh-cost-meter)
|
|
11
11
|
[](LICENSE)
|
|
12
12
|
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
@@ -191,22 +191,22 @@ Real captures from an actual DSH sidebar of the period strip and collapsed verti
|
|
|
191
191
|
dsh plugin --profile web add dsh-cost-meter
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
-
**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.5.
|
|
194
|
+
**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.5.12`** — review the script before running):
|
|
195
195
|
|
|
196
196
|
```powershell
|
|
197
|
-
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.5.
|
|
197
|
+
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.5.12/install.ps1 | iex
|
|
198
198
|
```
|
|
199
199
|
|
|
200
200
|
**Or a plain command line** (the machine must already have pnpm and git; also pinned to the tag):
|
|
201
201
|
|
|
202
202
|
```sh
|
|
203
|
-
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.5.
|
|
203
|
+
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.5.12
|
|
204
204
|
```
|
|
205
205
|
|
|
206
206
|
Without git, use the GitHub tag archive:
|
|
207
207
|
|
|
208
208
|
```sh
|
|
209
|
-
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.5.
|
|
209
|
+
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.5.12.tar.gz
|
|
210
210
|
```
|
|
211
211
|
|
|
212
212
|
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 为峰时段) · 官方价格一键同步 · 类 Codex Token 用量热图 · 多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配) · 主流 Coding Plan 额度查询与显示(Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow 六家)
|
|
8
8
|
|
|
9
|
-
[](https://github.com/Han-1413141/dsh-cost-meter)
|
|
10
10
|
[](https://www.npmjs.com/package/dsh-cost-meter)
|
|
11
11
|
[](LICENSE)
|
|
12
12
|
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
@@ -193,22 +193,22 @@
|
|
|
193
193
|
dsh plugin --profile web add dsh-cost-meter
|
|
194
194
|
```
|
|
195
195
|
|
|
196
|
-
**PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.5.
|
|
196
|
+
**PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.5.12`**,建议先下载审阅再运行):
|
|
197
197
|
|
|
198
198
|
```powershell
|
|
199
|
-
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.5.
|
|
199
|
+
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.5.12/install.ps1 | iex
|
|
200
200
|
```
|
|
201
201
|
|
|
202
202
|
**或直接命令行**(机器上需已有 pnpm 与 git;同样固定到 tag):
|
|
203
203
|
|
|
204
204
|
```sh
|
|
205
|
-
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.5.
|
|
205
|
+
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.5.12
|
|
206
206
|
```
|
|
207
207
|
|
|
208
208
|
没有 git 时可用 GitHub tag 打包直链:
|
|
209
209
|
|
|
210
210
|
```sh
|
|
211
|
-
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.5.
|
|
211
|
+
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.5.12.tar.gz
|
|
212
212
|
```
|
|
213
213
|
|
|
214
214
|
安装后**重启** `dsh web`(插件行、Typert 清单与客户端 bundle 均在启动时扫描):
|
package/lib/backfill.js
CHANGED
|
@@ -141,7 +141,7 @@ export function listSessionLogs(root) {
|
|
|
141
141
|
* @param records - readSessionRecords 的输出。
|
|
142
142
|
* @param config - 账本配置(prices / peak* / priceMatch / priceOverrides)。
|
|
143
143
|
* @param wantDates - 只统计这些日期键(YYYY-MM-DD)内的调用;null = 全部。
|
|
144
|
-
* @returns { sessionId, title, days: { date: { providerKey: 桶 } } }。
|
|
144
|
+
* @returns { sessionId, title, createdAt, days: { date: { providerKey: 桶 } } }。
|
|
145
145
|
*/
|
|
146
146
|
export function replaySessionRecords(records, config, wantDates = null) {
|
|
147
147
|
const zeroBuckets = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, calls: 0, cost: 0 })
|
|
@@ -151,6 +151,7 @@ export function replaySessionRecords(records, config, wantDates = null) {
|
|
|
151
151
|
}
|
|
152
152
|
let sessionId = ''
|
|
153
153
|
let title = ''
|
|
154
|
+
let createdAt = 0
|
|
154
155
|
let provider = 'deepseek'
|
|
155
156
|
let model = 'default'
|
|
156
157
|
let last = null
|
|
@@ -172,6 +173,8 @@ export function replaySessionRecords(records, config, wantDates = null) {
|
|
|
172
173
|
if (event === null || typeof event !== 'object') continue
|
|
173
174
|
if (event.type === 'session' && typeof event.id === 'string') {
|
|
174
175
|
sessionId = event.id
|
|
176
|
+
const created = Number(event.createdAt)
|
|
177
|
+
if (Number.isFinite(created) && created > 0) createdAt = created
|
|
175
178
|
continue
|
|
176
179
|
}
|
|
177
180
|
// 会话标题(宿主生成的 session/title 事件;同名多次时取最后一次)。
|
|
@@ -238,7 +241,7 @@ export function replaySessionRecords(records, config, wantDates = null) {
|
|
|
238
241
|
shift(sample, 1)
|
|
239
242
|
last = sample
|
|
240
243
|
}
|
|
241
|
-
return { sessionId, title, days }
|
|
244
|
+
return { sessionId, title, createdAt, days }
|
|
242
245
|
}
|
|
243
246
|
|
|
244
247
|
/**
|
|
@@ -267,11 +270,11 @@ export async function backfillLegacyLedger(ledger, sessionsRoot) {
|
|
|
267
270
|
}
|
|
268
271
|
}
|
|
269
272
|
}
|
|
270
|
-
//
|
|
273
|
+
// 会话标题/时间戳补齐:任一字段缺失则需扫描(日志里的 session/title / createdAt 是权威来源)。
|
|
271
274
|
let needTitles = false
|
|
272
275
|
for (const day of Object.values(ledger.days ?? {})) {
|
|
273
276
|
for (const session of day?.sessions ?? []) {
|
|
274
|
-
if (typeof session?.title !== 'string' || session.title.length === 0) {
|
|
277
|
+
if (typeof session?.title !== 'string' || session.title.length === 0 || !Number.isFinite(Number(session?.at))) {
|
|
275
278
|
needTitles = true
|
|
276
279
|
break
|
|
277
280
|
}
|
|
@@ -281,6 +284,7 @@ export async function backfillLegacyLedger(ledger, sessionsRoot) {
|
|
|
281
284
|
if (needDates.size === 0 && !needTitles) return result
|
|
282
285
|
const bySession = new Map()
|
|
283
286
|
const titles = new Map()
|
|
287
|
+
const createdAts = new Map()
|
|
284
288
|
let scannedCount = 0
|
|
285
289
|
for (const path of listSessionLogs(sessionsRoot)) {
|
|
286
290
|
// 会话日志多时逐份解压会长时间占住事件循环:每 8 份让出一次,不卡宿主 UI。
|
|
@@ -294,6 +298,7 @@ export async function backfillLegacyLedger(ledger, sessionsRoot) {
|
|
|
294
298
|
}
|
|
295
299
|
if (replayed.sessionId.length === 0) continue
|
|
296
300
|
if (replayed.title.length > 0 && !titles.has(replayed.sessionId)) titles.set(replayed.sessionId, replayed.title)
|
|
301
|
+
if (replayed.createdAt > 0 && !createdAts.has(replayed.sessionId)) createdAts.set(replayed.sessionId, replayed.createdAt)
|
|
297
302
|
if (needDates.size === 0) continue
|
|
298
303
|
const existing = bySession.get(replayed.sessionId)
|
|
299
304
|
if (existing === undefined) bySession.set(replayed.sessionId, replayed.days)
|
|
@@ -372,16 +377,22 @@ export async function backfillLegacyLedger(ledger, sessionsRoot) {
|
|
|
372
377
|
}
|
|
373
378
|
}
|
|
374
379
|
}
|
|
375
|
-
//
|
|
376
|
-
if (titles.size > 0) {
|
|
380
|
+
// 会话标题/时间戳补齐:只填缺失字段,不覆盖已有(幂等;实时新建的会话下次启动补齐)。
|
|
381
|
+
if (titles.size > 0 || createdAts.size > 0) {
|
|
377
382
|
for (const day of Object.values(ledger.days ?? {})) {
|
|
378
383
|
for (const session of day?.sessions ?? []) {
|
|
379
384
|
if (session === null || typeof session !== 'object') continue
|
|
380
|
-
if (typeof session.title
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
+
if ((typeof session.title !== 'string' || session.title.length === 0) && titles.size > 0) {
|
|
386
|
+
const found = titles.get(session.id)
|
|
387
|
+
if (found !== undefined) {
|
|
388
|
+
session.title = found
|
|
389
|
+
result.titles += 1
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (!Number.isFinite(Number(session.at)) && createdAts.size > 0) {
|
|
393
|
+
const foundAt = createdAts.get(session.id)
|
|
394
|
+
if (foundAt !== undefined) session.at = foundAt
|
|
395
|
+
}
|
|
385
396
|
}
|
|
386
397
|
}
|
|
387
398
|
}
|
package/lib/client.js
CHANGED
|
@@ -491,12 +491,18 @@ window.__ModuleLoader__.load({
|
|
|
491
491
|
historyNoSessions: '该日无会话明细(早期数据或会话日志已清理)',
|
|
492
492
|
historySessionsError: '会话明细加载失败',
|
|
493
493
|
sessionRankTitle: '按会话统计(全部历史)',
|
|
494
|
-
sessionRankHint: '
|
|
494
|
+
sessionRankHint: '不按日期分组,全部历史会话;排序与条数可切换',
|
|
495
495
|
sessionRankEmpty: '暂无会话数据',
|
|
496
496
|
sessionRankLoading: '会话排行加载中…',
|
|
497
497
|
sessionRankError: '会话排行加载失败',
|
|
498
498
|
sessionRankLimit: '显示条数',
|
|
499
499
|
showSessionIdLabel: '会话列表附显会话 ID',
|
|
500
|
+
sessionRankSort: '排序',
|
|
501
|
+
sessionSortCostDesc: '费用 高→低',
|
|
502
|
+
sessionSortCostAsc: '费用 低→高',
|
|
503
|
+
sessionSortTimeDesc: '时间 新→旧',
|
|
504
|
+
sessionSortTimeAsc: '时间 旧→新',
|
|
505
|
+
sessionSortRecent: '实时顺序',
|
|
500
506
|
history: '历史记录',
|
|
501
507
|
usageTitle: 'Token 用量统计',
|
|
502
508
|
usageTotal: '累计 {tokens} tokens · 输入 {input} · 缓存 {cache} · 输出 {output} · {calls} 次调用',
|
|
@@ -778,12 +784,18 @@ window.__ModuleLoader__.load({
|
|
|
778
784
|
historyNoSessions: 'No session details for this day (early data or session logs cleaned)',
|
|
779
785
|
historySessionsError: 'Failed to load session details',
|
|
780
786
|
sessionRankTitle: 'By session (all history)',
|
|
781
|
-
sessionRankHint: 'All historical sessions
|
|
787
|
+
sessionRankHint: 'All historical sessions, not grouped by date; sort and row count are switchable',
|
|
782
788
|
sessionRankEmpty: 'No session data yet',
|
|
783
789
|
sessionRankLoading: 'Loading session ranking…',
|
|
784
790
|
sessionRankError: 'Failed to load session ranking',
|
|
785
791
|
sessionRankLimit: 'Rows',
|
|
786
792
|
showSessionIdLabel: 'Show session IDs in session lists',
|
|
793
|
+
sessionRankSort: 'Sort',
|
|
794
|
+
sessionSortCostDesc: 'Cost high→low',
|
|
795
|
+
sessionSortCostAsc: 'Cost low→high',
|
|
796
|
+
sessionSortTimeDesc: 'Time new→old',
|
|
797
|
+
sessionSortTimeAsc: 'Time old→new',
|
|
798
|
+
sessionSortRecent: 'Recent order',
|
|
787
799
|
history: 'History',
|
|
788
800
|
usageTitle: 'Token usage stats',
|
|
789
801
|
usageTotal: 'All-time {tokens} tokens · input {input} · cache {cache} · output {output} · {calls} calls',
|
|
@@ -1182,6 +1194,10 @@ window.__ModuleLoader__.load({
|
|
|
1182
1194
|
if (!Number.isFinite(Number(v))) fail('limit', 'number')
|
|
1183
1195
|
return Number(v)
|
|
1184
1196
|
})
|
|
1197
|
+
const sortCodec = codecOf(v => {
|
|
1198
|
+
if (typeof v !== 'string') fail('sort', 'string')
|
|
1199
|
+
return v
|
|
1200
|
+
})
|
|
1185
1201
|
const topSessionsCodec = codecOf(v => {
|
|
1186
1202
|
if (v === null || typeof v !== 'object' || Array.isArray(v)) fail('topSessions', 'object')
|
|
1187
1203
|
return v
|
|
@@ -1243,7 +1259,11 @@ window.__ModuleLoader__.load({
|
|
|
1243
1259
|
{
|
|
1244
1260
|
id: 'dsh-cost-meter#costMeter/getTopSessions', service: 'costMeter', namespace: 'costMeter', method: 'getTopSessions',
|
|
1245
1261
|
invocation: { kind: 'direct' },
|
|
1246
|
-
parameters: [
|
|
1262
|
+
parameters: [
|
|
1263
|
+
{ name: 'limit', wire: 'limit', source: 'json', codec: { mode: 'strict', typeSymbol: 'dsh-cost-meter#SessionLimit', schema: limitCodec } },
|
|
1264
|
+
{ name: 'sort', wire: 'sort', source: 'json', codec: { mode: 'strict', typeSymbol: 'dsh-cost-meter#SessionSort', schema: sortCodec }, acceptsUndefined: true },
|
|
1265
|
+
{ name: 'dir', wire: 'dir', source: 'json', codec: { mode: 'strict', typeSymbol: 'dsh-cost-meter#SessionSortDir', schema: sortCodec }, acceptsUndefined: true },
|
|
1266
|
+
],
|
|
1247
1267
|
result: { mode: 'strict', typeSymbol: 'dsh-cost-meter#TopSessions', schema: topSessionsCodec },
|
|
1248
1268
|
},
|
|
1249
1269
|
],
|
|
@@ -2335,6 +2355,19 @@ window.__ModuleLoader__.load({
|
|
|
2335
2355
|
el('p', { className: 'cm-card-sub' }, props.sub))
|
|
2336
2356
|
}
|
|
2337
2357
|
|
|
2358
|
+
// 历史记录折叠面板(issue #22):三角展开/收起,内部为按天表格(日期行再展开会话明细)。
|
|
2359
|
+
function HistoryPanel(props) {
|
|
2360
|
+
const { state, api } = props
|
|
2361
|
+
const t = makeT(resolveLocale(state.config?.locale))
|
|
2362
|
+
const [open, setOpen] = useState(false)
|
|
2363
|
+
return el('div', { className: 'cm-budget' },
|
|
2364
|
+
el('div', { className: 'cm-budget-head' },
|
|
2365
|
+
el('button', { type: 'button', className: 'cm-collapse-h', 'aria-expanded': String(open), onClick: () => setOpen(!open) },
|
|
2366
|
+
el('span', { className: 'cm-caret' + (open ? ' open' : '') }),
|
|
2367
|
+
el('h3', { className: 'cm-h' }, t('history')))),
|
|
2368
|
+
open ? el('div', { className: 'cm-collapse-body' }, el(HistoryTable, { state, api })) : null)
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2338
2371
|
function HistoryTable(props) {
|
|
2339
2372
|
const { state, api } = props
|
|
2340
2373
|
const t = makeT(resolveLocale(state.config?.locale))
|
|
@@ -2394,19 +2427,22 @@ window.__ModuleLoader__.load({
|
|
|
2394
2427
|
}))))
|
|
2395
2428
|
}
|
|
2396
2429
|
|
|
2397
|
-
// 按会话统计(issue #22 不分日期视角)
|
|
2430
|
+
// 按会话统计(issue #22 不分日期视角):全部历史会话排行,默认收起、展开时按需拉取;排序可切换。
|
|
2398
2431
|
function SessionRankPanel(props) {
|
|
2399
2432
|
const { state, api } = props
|
|
2400
2433
|
const t = makeT(resolveLocale(state.config?.locale))
|
|
2401
2434
|
const [open, setOpen] = useState(false)
|
|
2402
2435
|
const [limit, setLimit] = useState(100)
|
|
2436
|
+
// 排序模式:cost-desc / cost-asc / time-desc / time-asc / recent(实时顺序)。
|
|
2437
|
+
const [sortMode, setSortMode] = useState('cost-desc')
|
|
2403
2438
|
const [rows, setRows] = useState(null)
|
|
2404
2439
|
const [busy, setBusy] = useState(false)
|
|
2405
2440
|
const [err, setErr] = useState(false)
|
|
2406
|
-
const load = n => {
|
|
2441
|
+
const load = (n, mode) => {
|
|
2442
|
+
const [sort, dir] = mode === 'recent' ? ['recent', 'desc'] : mode.split('-')
|
|
2407
2443
|
setBusy(true)
|
|
2408
2444
|
setErr(false)
|
|
2409
|
-
api.getTopSessions(n)
|
|
2445
|
+
api.getTopSessions(n, sort, dir)
|
|
2410
2446
|
.then(res => setRows(Array.isArray(res?.sessions) ? res.sessions : []))
|
|
2411
2447
|
.catch(() => setErr(true))
|
|
2412
2448
|
.finally(() => setBusy(false))
|
|
@@ -2414,13 +2450,17 @@ window.__ModuleLoader__.load({
|
|
|
2414
2450
|
const toggle = () => {
|
|
2415
2451
|
setOpen(o => {
|
|
2416
2452
|
const next = !o
|
|
2417
|
-
if (next && rows === null && !busy) load(limit)
|
|
2453
|
+
if (next && rows === null && !busy) load(limit, sortMode)
|
|
2418
2454
|
return next
|
|
2419
2455
|
})
|
|
2420
2456
|
}
|
|
2421
2457
|
const changeLimit = n => {
|
|
2422
2458
|
setLimit(n)
|
|
2423
|
-
if (open || rows !== null) load(n)
|
|
2459
|
+
if (open || rows !== null) load(n, sortMode)
|
|
2460
|
+
}
|
|
2461
|
+
const changeSort = mode => {
|
|
2462
|
+
setSortMode(mode)
|
|
2463
|
+
if (open || rows !== null) load(limit, mode)
|
|
2424
2464
|
}
|
|
2425
2465
|
return el('div', { className: 'cm-budget' },
|
|
2426
2466
|
el('div', { className: 'cm-budget-head' },
|
|
@@ -2429,12 +2469,21 @@ window.__ModuleLoader__.load({
|
|
|
2429
2469
|
el('h3', { className: 'cm-h' }, t('sessionRankTitle')))),
|
|
2430
2470
|
open ? el('div', { className: 'cm-collapse-body' },
|
|
2431
2471
|
el('p', { className: 'cm-hint' }, t('sessionRankHint')),
|
|
2432
|
-
el('div', {
|
|
2433
|
-
el('
|
|
2434
|
-
|
|
2435
|
-
el('
|
|
2436
|
-
|
|
2437
|
-
|
|
2472
|
+
el('div', { style: { display: 'flex', gap: '12px', flexWrap: 'wrap', margin: '6px 0' } },
|
|
2473
|
+
el('div', { className: 'cm-field' },
|
|
2474
|
+
el('label', null, t('sessionRankSort')),
|
|
2475
|
+
el('select', { className: 'cm-input', value: sortMode, onChange: event => changeSort(event.target.value) },
|
|
2476
|
+
el('option', { value: 'cost-desc' }, t('sessionSortCostDesc')),
|
|
2477
|
+
el('option', { value: 'cost-asc' }, t('sessionSortCostAsc')),
|
|
2478
|
+
el('option', { value: 'time-desc' }, t('sessionSortTimeDesc')),
|
|
2479
|
+
el('option', { value: 'time-asc' }, t('sessionSortTimeAsc')),
|
|
2480
|
+
el('option', { value: 'recent' }, t('sessionSortRecent')))),
|
|
2481
|
+
el('div', { className: 'cm-field' },
|
|
2482
|
+
el('label', null, t('sessionRankLimit')),
|
|
2483
|
+
el('select', { className: 'cm-input', value: String(limit), onChange: event => changeLimit(Number(event.target.value)) },
|
|
2484
|
+
el('option', { value: '50' }, '50'),
|
|
2485
|
+
el('option', { value: '100' }, '100'),
|
|
2486
|
+
el('option', { value: '200' }, '200')))),
|
|
2438
2487
|
busy ? el('p', { className: 'cm-empty' }, t('sessionRankLoading'))
|
|
2439
2488
|
: err ? el('p', { className: 'cm-empty' }, t('sessionRankError'))
|
|
2440
2489
|
: rows === null || rows.length === 0 ? el('p', { className: 'cm-empty' }, t('sessionRankEmpty'))
|
|
@@ -3753,10 +3802,8 @@ window.__ModuleLoader__.load({
|
|
|
3753
3802
|
el('div', null,
|
|
3754
3803
|
el('h3', { className: 'cm-h' }, t('todaySessions')),
|
|
3755
3804
|
el(TodaySessions, { state, t })),
|
|
3756
|
-
// 历史
|
|
3757
|
-
el(
|
|
3758
|
-
el('h3', { className: 'cm-h' }, t('history')),
|
|
3759
|
-
el(HistoryTable, { state, api })),
|
|
3805
|
+
// 历史(三角折叠面板;日期行可再展开会话明细)
|
|
3806
|
+
el(HistoryPanel, { state, api }),
|
|
3760
3807
|
// 按会话统计(全部历史,不分日期;issue #22)
|
|
3761
3808
|
el(SessionRankPanel, { state, api }),
|
|
3762
3809
|
// 显示设置
|
|
@@ -4183,8 +4230,8 @@ window.__ModuleLoader__.load({
|
|
|
4183
4230
|
},
|
|
4184
4231
|
// 按需拉取某天会话明细(issue #22),返回当日完整记录。
|
|
4185
4232
|
getDaySessions: async date => call('getDaySessions', [date]),
|
|
4186
|
-
//
|
|
4187
|
-
getTopSessions: async limit => call('getTopSessions', [limit]),
|
|
4233
|
+
// 跨全部日期的会话排行(issue #22 不分日期视角):支持费用/时间升降序与实时顺序。
|
|
4234
|
+
getTopSessions: async (limit, sort, dir) => call('getTopSessions', [limit, sort, dir]),
|
|
4188
4235
|
refreshBalance: async () => {
|
|
4189
4236
|
const result = await costMeter.refreshBalance()
|
|
4190
4237
|
if (result === null || typeof result !== 'object' || result.ok !== true) {
|
package/lib/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import fs from 'node:fs'
|
|
|
18
18
|
import { join } from 'node:path'
|
|
19
19
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
20
20
|
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
|
21
|
-
import { Ledger, applyConfigPatch, localDayKey, reconcileBalanceDelta, zeroDay } from './store.js'
|
|
21
|
+
import { Ledger, applyConfigPatch, localDayKey, pickBalanceInfo, reconcileBalanceDelta, zeroDay } from './store.js'
|
|
22
22
|
import { backfillLegacyLedger } from './backfill.js'
|
|
23
23
|
import { OFFICIAL_PRICING_URL, normalizePrice, parsePricingHtml, costOf, priceEntryFor, providerPriceEntryFor, buildPriceCatalog } from './pricing.js'
|
|
24
24
|
import { CODING_PLAN_PROVIDERS, CODING_PLAN_PROVIDER_IDS, queryCodingPlan, emptyCustomBalance, queryCustomBalance } from './coding-plans.js'
|
|
@@ -467,7 +467,8 @@ async function queryBalance(ctx, locale) {
|
|
|
467
467
|
})
|
|
468
468
|
if (!response.ok) throw new Error(tmsg(locale, 'balanceHttp', { code: String(response.status) }))
|
|
469
469
|
const data = await response.json()
|
|
470
|
-
|
|
470
|
+
// 多币种账号返回 CNY/USD 两条且顺序不稳定(#24/#25):按余额与币种挑选,不固定取首条。
|
|
471
|
+
const info = pickBalanceInfo(data?.balance_infos)
|
|
471
472
|
if (info === undefined) throw new Error(tmsg(locale, 'balanceNoInfos'))
|
|
472
473
|
const num = value => {
|
|
473
474
|
const parsed = Number(value)
|
|
@@ -525,10 +526,13 @@ function buildState(ledger, balance = emptyBalance(), goQuota = emptyGoQuota(),
|
|
|
525
526
|
const check = stateSchema.safeParse(state)
|
|
526
527
|
if (check.success) return state
|
|
527
528
|
console.warn('[dsh-cost-meter] state 与 codec 漂移,尝试降级恢复可用性:', JSON.stringify(check.error.issues?.slice(0, 3) ?? check.error))
|
|
529
|
+
// 注意剔除键必须用解构省略而非赋 undefined:priceCatalog 是 schema 声明的
|
|
530
|
+
// optional 键,显式 undefined 键会被网关 JSON 安全校验拒绝(值合法但属性不安全)。
|
|
531
|
+
const { priceCatalog: _dropped, ...stateNoCatalog } = state
|
|
528
532
|
const attempts = [
|
|
529
|
-
|
|
530
|
-
{ ...
|
|
531
|
-
{ ...
|
|
533
|
+
stateNoCatalog,
|
|
534
|
+
{ ...stateNoCatalog, codingPlans: {} },
|
|
535
|
+
{ ...stateNoCatalog, codingPlans: {}, balance: emptyBalance(), goQuota: emptyGoQuota(), customBalance: emptyCustomBalance() },
|
|
532
536
|
]
|
|
533
537
|
for (const fallback of attempts) {
|
|
534
538
|
if (stateSchema.safeParse(fallback).success) return fallback
|
|
@@ -909,15 +913,24 @@ function createService(ctx, ledger) {
|
|
|
909
913
|
return day === undefined ? zeroDay(date) : ledger.copyDay(day)
|
|
910
914
|
},
|
|
911
915
|
|
|
912
|
-
//
|
|
913
|
-
|
|
916
|
+
// 跨全部日期返回前 N 个会话(issue #22 按会话视角,不分日期)。
|
|
917
|
+
// sort:cost(费用) | time(会话创建时间) | recent(实时顺序,即账本/侧边栏顺序);dir:asc | desc。
|
|
918
|
+
async getTopSessions(limit, sort = 'cost', dir = 'desc') {
|
|
914
919
|
const n = Math.max(1, Math.min(500, Math.floor(Number(limit)) || 100))
|
|
920
|
+
const sortKey = sort === 'time' || sort === 'recent' ? sort : 'cost'
|
|
921
|
+
const asc = dir === 'asc'
|
|
915
922
|
const all = []
|
|
916
|
-
|
|
923
|
+
const dateKeys = Object.keys(ledger.days)
|
|
924
|
+
// recent 的降序 = 侧边栏直觉的「新会话在前」:日期倒序 + 每日会话倒序。
|
|
925
|
+
if (sortKey === 'recent' && !asc) dateKeys.reverse()
|
|
926
|
+
for (const date of dateKeys) {
|
|
927
|
+
const day = ledger.days[date]
|
|
917
928
|
if (!Array.isArray(day.sessions)) continue
|
|
918
|
-
|
|
929
|
+
const rows = day.sessions.slice()
|
|
930
|
+
if (sortKey === 'recent' && !asc) rows.reverse()
|
|
931
|
+
for (const s of rows) {
|
|
919
932
|
if (s === null || typeof s !== 'object') continue
|
|
920
|
-
|
|
933
|
+
const row = {
|
|
921
934
|
date,
|
|
922
935
|
id: String(s.id ?? ''),
|
|
923
936
|
input: s.input ?? 0,
|
|
@@ -928,10 +941,26 @@ function createService(ctx, ledger) {
|
|
|
928
941
|
calls: s.calls ?? 0,
|
|
929
942
|
cost: s.cost ?? 0,
|
|
930
943
|
byProviderModel: s.byProviderModel ?? {},
|
|
931
|
-
}
|
|
944
|
+
}
|
|
945
|
+
// title/at 缺席时不得写入 undefined 键:网关对返回值做 JSON 安全校验,
|
|
946
|
+
// 显式 undefined 属性会被「undefined is not JSON-safe」拒绝,整个 RPC
|
|
947
|
+
// result-invalid,会话排行面板加载失败(未命名/无时间戳会话即触发)。
|
|
948
|
+
if (typeof s.title === 'string' && s.title.length > 0) row.title = s.title
|
|
949
|
+
const at = Number(s.at)
|
|
950
|
+
if (Number.isFinite(at) && at > 0) row.at = at
|
|
951
|
+
all.push(row)
|
|
932
952
|
}
|
|
933
953
|
}
|
|
934
|
-
all.sort((a, b) => b.cost - a.cost)
|
|
954
|
+
if (sortKey === 'cost') all.sort((a, b) => asc ? a.cost - b.cost : b.cost - a.cost)
|
|
955
|
+
else if (sortKey === 'time') {
|
|
956
|
+
// 无时间戳的条目排末尾。
|
|
957
|
+
all.sort((a, b) => {
|
|
958
|
+
const ta = Number.isFinite(a.at) ? a.at : asc ? Number.MAX_SAFE_INTEGER : 0
|
|
959
|
+
const tb = Number.isFinite(b.at) ? b.at : asc ? Number.MAX_SAFE_INTEGER : 0
|
|
960
|
+
return asc ? ta - tb : tb - ta
|
|
961
|
+
})
|
|
962
|
+
}
|
|
963
|
+
// recent 已按构造顺序排好,不再重排。
|
|
935
964
|
return { sessions: all.slice(0, n) }
|
|
936
965
|
},
|
|
937
966
|
}
|
package/lib/pricing.js
CHANGED
|
@@ -248,11 +248,13 @@ export const PROVIDER_MODEL_FAMILIES = {
|
|
|
248
248
|
* 内置只读目录(含 DeepSeek 当前模型);「挂载」= 把条目复制进可编辑价格表。
|
|
249
249
|
*/
|
|
250
250
|
export function buildPriceCatalog() {
|
|
251
|
-
const catalog =
|
|
251
|
+
const catalog = Object.create(null)
|
|
252
|
+
const isUnsafeKey = (key) => key === '__proto__' || key === 'constructor' || key === 'prototype'
|
|
252
253
|
const put = (provider, id, entry) => {
|
|
253
254
|
const family = PROVIDER_MODEL_FAMILIES[provider]?.[id] ?? id
|
|
254
|
-
if (
|
|
255
|
-
if (catalog[provider]
|
|
255
|
+
if (isUnsafeKey(provider) || isUnsafeKey(family) || isUnsafeKey(id)) return
|
|
256
|
+
if (catalog[provider] === undefined) catalog[provider] = Object.create(null)
|
|
257
|
+
if (catalog[provider][family] === undefined) catalog[provider][family] = Object.create(null)
|
|
256
258
|
catalog[provider][family][id] = entry
|
|
257
259
|
}
|
|
258
260
|
for (const [id, entry] of Object.entries(DEFAULT_PRICE_TABLE.models)) put('deepseek', id, entry)
|
|
@@ -654,12 +656,12 @@ export function formatMoney(usdCost, display) {
|
|
|
654
656
|
function decodeEntities(text) {
|
|
655
657
|
return text
|
|
656
658
|
.replace(/ /g, ' ')
|
|
657
|
-
.replace(/&/g, '&')
|
|
658
659
|
.replace(/</g, '<')
|
|
659
660
|
.replace(/>/g, '>')
|
|
660
661
|
.replace(/"/g, '"')
|
|
661
662
|
.replace(/'/g, "'")
|
|
662
663
|
.replace(/—/g, '—')
|
|
664
|
+
.replace(/&/g, '&')
|
|
663
665
|
}
|
|
664
666
|
|
|
665
667
|
function stripTags(html) {
|
package/lib/store.js
CHANGED
|
@@ -172,6 +172,8 @@ export function sanitizeDays(days) {
|
|
|
172
172
|
session.id = typeof session.id === 'string' ? session.id : ''
|
|
173
173
|
// 会话标题(由历史回填从会话日志补齐):非字符串一律剔除,防击穿 strict sessionSchema。
|
|
174
174
|
if (typeof session.title !== 'string' || session.title.length === 0) delete session.title
|
|
175
|
+
// 会话时间戳(实时入账/回填补齐):非正数剔除。
|
|
176
|
+
if (!Number.isFinite(Number(session.at)) || Number(session.at) <= 0) delete session.at
|
|
175
177
|
session.byProviderModel = session.byProviderModel !== null && typeof session.byProviderModel === 'object' && !Array.isArray(session.byProviderModel)
|
|
176
178
|
? session.byProviderModel
|
|
177
179
|
: {}
|
|
@@ -721,7 +723,9 @@ export class Ledger {
|
|
|
721
723
|
const ref = parsed.balanceRef
|
|
722
724
|
if (ref !== null && typeof ref === 'object' && typeof ref.date === 'string'
|
|
723
725
|
&& Number.isFinite(ref.total) && Number.isFinite(ref.granted) && Number.isFinite(ref.topped)) {
|
|
724
|
-
|
|
726
|
+
// 旧账本无 currency 字段:保留 undefined,与新版首次拉取币种不等,
|
|
727
|
+
// reconcile 时自然重置基准一次(旧参考点可能录自 USD 0.00 误读,不可信)。
|
|
728
|
+
balanceRef = { date: ref.date, total: ref.total, granted: ref.granted, topped: ref.topped, currency: typeof ref.currency === 'string' ? ref.currency : undefined, at: Number(ref.at) || 0 }
|
|
725
729
|
}
|
|
726
730
|
}
|
|
727
731
|
}
|
|
@@ -797,6 +801,7 @@ export class Ledger {
|
|
|
797
801
|
let session = sessions.find(s => s.id === sessionId)
|
|
798
802
|
if (session === undefined) {
|
|
799
803
|
session = zeroSession(sessionId)
|
|
804
|
+
session.at = atMs // 会话首次入账时刻(按会话排行「按时间排序」用)。
|
|
800
805
|
sessions.push(session)
|
|
801
806
|
if (sessions.length > MAX_SESSIONS_PER_DAY) sessions = sessions.slice(-MAX_SESSIONS_PER_DAY)
|
|
802
807
|
day.sessions = sessions
|
|
@@ -959,9 +964,10 @@ export class Ledger {
|
|
|
959
964
|
/**
|
|
960
965
|
* 余额差交叉校验(issue #18 讨论):用官方余额当日变动反推消费,与本地账本今日合计比对。
|
|
961
966
|
* 仅当余额确实减少时才对账——订阅/Coding Plan 消费不动官方余额,若强行用余额差
|
|
962
|
-
*
|
|
963
|
-
*
|
|
964
|
-
* @param
|
|
967
|
+
* 替代今日费用会把订阅用户全天消费归零;充值/额度结构变动时重置参考点防误判;
|
|
968
|
+
* 参考点与本次拉取的币种不一致时(多币种条目挑选切换/旧账本无币种标记)同样重置。
|
|
969
|
+
* @param prevRef - 上一参考点({ date, total, granted, topped, currency, at })或 null。
|
|
970
|
+
* @param balance - 本次拉取结果({ currency, totalBalance, grantedBalance, toppedUpBalance })。
|
|
965
971
|
* @param todayCost - 本地账本今日合计费用。
|
|
966
972
|
* @param dayKey - 本地日期键(YYYY-MM-DD)。
|
|
967
973
|
* @param nowMs - 当前时刻。
|
|
@@ -976,12 +982,18 @@ export function reconcileBalanceDelta(prevRef, balance, todayCost, dayKey, nowMs
|
|
|
976
982
|
total: balance.totalBalance,
|
|
977
983
|
granted: Number.isFinite(balance.grantedBalance) ? balance.grantedBalance : 0,
|
|
978
984
|
topped: Number.isFinite(balance.toppedUpBalance) ? balance.toppedUpBalance : 0,
|
|
985
|
+
currency: typeof balance.currency === 'string' ? balance.currency : '',
|
|
979
986
|
at: nowMs,
|
|
980
987
|
}
|
|
981
988
|
// 新的一天(或首次/参考点形状异常):打基准,不对账。
|
|
982
989
|
if (prevRef === null || typeof prevRef !== 'object' || prevRef.date !== dayKey) {
|
|
983
990
|
return { ref: snap, event: { kind: 'baseline' } }
|
|
984
991
|
}
|
|
992
|
+
// 币种变化(#24/#25):选中的条目币种切换(或旧参考点无币种标记)时金额不可比,
|
|
993
|
+
// 重置基准不对账——否则 USD 0.00 误读会让余额差虚高,触发 drift 误报。
|
|
994
|
+
if (prevRef.currency !== snap.currency) {
|
|
995
|
+
return { ref: snap, event: { kind: 'structure-reset' } }
|
|
996
|
+
}
|
|
985
997
|
// 充值/额度授予变动:充值与授信只会让分项余额增加(消费只会减少),分项变大则旧参考点失效,重置不告警。
|
|
986
998
|
if (snap.granted > prevRef.granted + 0.009 || snap.topped > prevRef.topped + 0.009) {
|
|
987
999
|
return { ref: snap, event: { kind: 'structure-reset' } }
|
|
@@ -995,3 +1007,21 @@ export function reconcileBalanceDelta(prevRef, balance, todayCost, dayKey, nowMs
|
|
|
995
1007
|
// ok/drift 都保留当日首次基准,后续拉取继续与早间基线比对。
|
|
996
1008
|
return { ref: prevRef, event: { kind: 'ok', spent, todayCost } }
|
|
997
1009
|
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* 从官方余额接口的 balance_infos 中挑选要展示的条目(issues #24/#25)。
|
|
1013
|
+
*
|
|
1014
|
+
* 多币种账号同时返回 CNY/USD 两条,且实测两条的排列顺序每次请求不稳定——
|
|
1015
|
+
* 固定取首条会在 USD 排前时读到 0.00,余额在正确值与 0 之间跳变。挑选规则:
|
|
1016
|
+
* - 优先取「有余额」的条目;同有余额时优先 CNY(开放平台主币种,确定性优先);
|
|
1017
|
+
* - 全部为零时优先 CNY(未充值/新账号,保证不随返回顺序跳变);
|
|
1018
|
+
* - 无 CNY 条目时兜底首条(单币种账号行为与旧版一致)。
|
|
1019
|
+
* @param infos - balance_infos 数组(元素含 currency / total_balance 等官方字段)。
|
|
1020
|
+
* @returns 选中的条目,或 undefined(无有效条目,调用方按缺 balance_infos 报错)。
|
|
1021
|
+
*/
|
|
1022
|
+
export function pickBalanceInfo(infos) {
|
|
1023
|
+
const list = Array.isArray(infos) ? infos.filter(entry => entry !== null && typeof entry === 'object') : []
|
|
1024
|
+
const positive = list.filter(entry => Number(entry.total_balance) > 0)
|
|
1025
|
+
const cnyFirst = entries => entries.find(entry => String(entry.currency).toUpperCase() === 'CNY')
|
|
1026
|
+
return cnyFirst(positive) ?? positive[0] ?? cnyFirst(list) ?? list[0]
|
|
1027
|
+
}
|
package/lib/typert.host.js
CHANGED
|
@@ -11,6 +11,7 @@ const num = z.number()
|
|
|
11
11
|
const sessionSchema = z.object({
|
|
12
12
|
id: z.string(),
|
|
13
13
|
title: z.string().optional(),
|
|
14
|
+
at: num.optional(),
|
|
14
15
|
input: num,
|
|
15
16
|
output: num,
|
|
16
17
|
cacheRead: num,
|
|
@@ -254,6 +255,8 @@ const _day$codec = { mode: 'strict', typeSymbol: 'dsh-cost-meter#DayRecord', sch
|
|
|
254
255
|
const _date$codec = { mode: 'strict', typeSymbol: 'dsh-cost-meter#DayKey', schema: z.string() }
|
|
255
256
|
const _topSessions$codec = { mode: 'strict', typeSymbol: 'dsh-cost-meter#TopSessions', schema: topSessionsSchema }
|
|
256
257
|
const _limit$codec = { mode: 'strict', typeSymbol: 'dsh-cost-meter#SessionLimit', schema: num }
|
|
258
|
+
const _sort$codec = { mode: 'strict', typeSymbol: 'dsh-cost-meter#SessionSort', schema: z.string() }
|
|
259
|
+
const _dir$codec = { mode: 'strict', typeSymbol: 'dsh-cost-meter#SessionSortDir', schema: z.string() }
|
|
257
260
|
|
|
258
261
|
export const TYPERT = {
|
|
259
262
|
package: 'dsh-cost-meter',
|
|
@@ -355,6 +358,11 @@ export const TYPERT = {
|
|
|
355
358
|
invocation: { kind: 'direct' },
|
|
356
359
|
parameters: [
|
|
357
360
|
{ name: 'limit', wire: 'limit', source: 'json', codec: _limit$codec },
|
|
361
|
+
// sort/dir 与服务端函数默认值(sort='cost', dir='desc')对应,声明可缺省:
|
|
362
|
+
// 网关对 args 字段做精确匹配,不声明 acceptsUndefined 时旧客户端单参数调用
|
|
363
|
+
// 会因 missing "sort"/"dir" 被拒,会话排行面板直接加载失败。
|
|
364
|
+
{ name: 'sort', wire: 'sort', source: 'json', codec: _sort$codec, acceptsUndefined: true },
|
|
365
|
+
{ name: 'dir', wire: 'dir', source: 'json', codec: _dir$codec, acceptsUndefined: true },
|
|
358
366
|
],
|
|
359
367
|
result: _topSessions$codec,
|
|
360
368
|
},
|
|
@@ -435,9 +443,9 @@ export const TYPERT = {
|
|
|
435
443
|
{
|
|
436
444
|
kind: 'method',
|
|
437
445
|
name: 'getTopSessions',
|
|
438
|
-
signature: 'getTopSessions(limit: number): TopSessions',
|
|
439
|
-
summary: '
|
|
440
|
-
jsDoc: '/**\n *
|
|
446
|
+
signature: 'getTopSessions(limit: number, sort?: string, dir?: string): TopSessions',
|
|
447
|
+
summary: '跨全部日期按指定排序返回前 N 个会话(不分日期视角)。Return the top N sessions across all days with the given sort.',
|
|
448
|
+
jsDoc: '/**\n * 跨全部日期返回前 N 个会话(每条带所属日期/标题/创建时刻)。\n * @param limit - 返回条数上限(服务端限制 1-500)。\n * @param sort - cost(费用) | time(创建时间) | recent(实时顺序)。\n * @param dir - asc | desc(默认 desc)。\n * @returns 会话列表(含 date/title/at 字段)。\n * Return the top N sessions across all days (each tagged with date/title/createdAt).\n * @param limit - Max rows to return (server clamps to 1-500).\n * @param sort - cost | time | recent (ledger/sidebar order).\n * @param dir - asc | desc (defaults to desc).\n * @returns Session list with date/title/at fields.\n */',
|
|
441
449
|
},
|
|
442
450
|
],
|
|
443
451
|
types: [],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-cost-meter",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.12",
|
|
4
4
|
"description": "DeepSeek Harness 会话费用统计插件:本会话成本、当日费用、历史记录与官方价格同步,支持多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配)、主流 Coding Plan 订阅额度查询与显示(6 家)、自定义 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 6 vendors, custom provider balance lookup (configurable HTTP endpoint) and balance progress bar, in a bilingual (Chinese/English) UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|