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 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
- [![version](https://img.shields.io/badge/version-1.5.9-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
9
+ [![version](https://img.shields.io/badge/version-1.5.12-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)
@@ -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.9`** — review the script before running):
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.9/install.ps1 | iex
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.9
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.9.tar.gz
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
- [![version](https://img.shields.io/badge/version-1.5.9-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
9
+ [![version](https://img.shields.io/badge/version-1.5.12-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)
@@ -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.9`**,建议先下载审阅再运行):
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.9/install.ps1 | iex
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.9
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.9.tar.gz
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
- // 会话标题补齐:任一会话条目缺标题则需扫描(日志里的 session/title 事件是标题权威来源)。
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 === 'string' && session.title.length > 0) continue
381
- const found = titles.get(session.id)
382
- if (found === undefined) continue
383
- session.title = found
384
- result.titles += 1
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 sorted by cost, not grouped by date',
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: [{ name: 'limit', wire: 'limit', source: 'json', codec: { mode: 'strict', typeSymbol: 'dsh-cost-meter#SessionLimit', schema: limitCodec } }],
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', { className: 'cm-field', style: { margin: '6px 0' } },
2433
- el('label', null, t('sessionRankLimit')),
2434
- el('select', { className: 'cm-input', value: String(limit), onChange: event => changeLimit(Number(event.target.value)) },
2435
- el('option', { value: '50' }, '50'),
2436
- el('option', { value: '100' }, '100'),
2437
- el('option', { value: '200' }, '200'))),
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('div', null,
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
- // 跨全部日期按费用降序的会话排行(issue #22 不分日期视角)
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
- const info = Array.isArray(data?.balance_infos) ? data.balance_infos[0] : undefined
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
- { ...state, priceCatalog: undefined },
530
- { ...state, priceCatalog: undefined, codingPlans: {} },
531
- { ...state, priceCatalog: undefined, codingPlans: {}, balance: emptyBalance(), goQuota: emptyGoQuota(), customBalance: emptyCustomBalance() },
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
- // 跨全部日期按费用降序返回前 N 个会话(issue #22 按会话视角,不分日期)。
913
- async getTopSessions(limit) {
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
- for (const [date, day] of Object.entries(ledger.days)) {
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
- for (const s of day.sessions) {
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
- all.push({
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 (catalog[provider] === undefined) catalog[provider] = {}
255
- if (catalog[provider][family] === undefined) catalog[provider][family] = {}
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(/&lt;/g, '<')
659
660
  .replace(/&gt;/g, '>')
660
661
  .replace(/&quot;/g, '"')
661
662
  .replace(/&#39;/g, "'")
662
663
  .replace(/&mdash;/g, '—')
664
+ .replace(/&amp;/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
- balanceRef = { date: ref.date, total: ref.total, granted: ref.granted, topped: ref.topped, at: Number(ref.at) || 0 }
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
- * @param prevRef - 上一参考点({ date, total, granted, topped, at })或 null。
964
- * @param balance - 本次拉取结果({ totalBalance, grantedBalance, toppedUpBalance })。
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
+ }
@@ -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: '跨全部日期按费用降序返回前 N 个会话(不分日期视角)。Return the top N sessions by cost across all days.',
440
- jsDoc: '/**\n * 跨全部日期按费用降序返回前 N 个会话(每条带所属日期;不含今日实时条目之外的去重)。\n * @param limit - 返回条数上限(服务端限制 1-500)。\n * @returns 会话列表(含 date 字段)。\n * Return the top N sessions by cost across all days (each tagged with its date).\n * @param limit - Max rows to return (server clamps to 1-500).\n * @returns Session list with a date field.\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.9",
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",