dsh-cost-meter 1.7.47 → 1.7.49

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/lib/index.js CHANGED
@@ -37,6 +37,7 @@ import { readExternalUsageSnapshot, externalTodayCostForReconcile } from './exte
37
37
  import { queryQwenCli } from './qwen-cli.js'
38
38
  import { queryBailianCli } from './bailian-cli.js'
39
39
  import { getSessionCost as readSessionCost } from './session-tree.js'
40
+ import { getTurnCost } from './turn-cost.js'
40
41
 
41
42
  export const name = 'cost-meter'
42
43
 
@@ -2391,6 +2392,9 @@ function createService(ctx, ledger, openRouterPrices) {
2391
2392
  async getSessionCost(sessionId) {
2392
2393
  return readSessionCost(ledger, ctx, sessionId)
2393
2394
  },
2395
+ async getTurnCost(sessionId, startSeq, endSeq) {
2396
+ return getTurnCost(ledger, ctx, sessionId, startSeq, endSeq)
2397
+ },
2394
2398
  async getTopSessions(limit, sort = 'cost', dir = 'desc') {
2395
2399
  const n = Math.max(1, Math.min(500, Math.floor(Number(limit)) || 100))
2396
2400
  const sortKey = sort === 'time' || sort === 'recent' ? sort : 'cost'
@@ -72,6 +72,7 @@ function parseBody(request) {
72
72
  */
73
73
  export function createNativeSearchBilling({ account, record = () => {}, uncovered = () => {}, now = Date.now, targetRequest = officialRequest, diagnostics = channel }) {
74
74
  const scope = new AsyncLocalStorage()
75
+ const searchSignal = new AsyncLocalStorage()
75
76
  const requests = new WeakMap()
76
77
  const pending = new Set()
77
78
  const subscriptions = []
@@ -98,6 +99,7 @@ export function createNativeSearchBilling({ account, record = () => {}, uncovere
98
99
  item.chunks.push(Buffer.from(chunk))
99
100
  }
100
101
  const release = (request, item) => {
102
+ clearTimeout(item.timer)
101
103
  item.chunks = []
102
104
  try { item.restore?.() } catch {}
103
105
  item.restore = undefined
@@ -106,13 +108,23 @@ export function createNativeSearchBilling({ account, record = () => {}, uncovere
106
108
  requests.delete(request)
107
109
  }
108
110
  subscribe('create', ({ request }) => {
109
- const current = scope.getStore()
110
- if (!current || !targetRequest(request)) return
111
+ if (!targetRequest(request)) return
112
+ const signal = searchSignal.getStore()
113
+ const scoped = scope.getStore()
114
+ const current = scoped ?? (signal?.pending && now() - signal.at < 60_000 ? signal : null)
115
+ if (!current) return
116
+ if (signal) signal.pending = false
111
117
  const item = { session: current.session, startedAtMs: now(), requestId: randomUUID(), bytes: 0, chunks: [], encoding: '', status: 0, done: false }
112
118
  requests.set(request, item)
113
119
  current.requests.push(item)
114
120
  pending.add(item)
115
121
  item.release = () => release(request, item)
122
+ if (!scoped) {
123
+ item.timer = setTimeout(() => {
124
+ item.done = true; miss(item, 'response-incomplete'); release(request, item)
125
+ }, 60_000)
126
+ item.timer.unref?.()
127
+ }
116
128
  // Node 20/22 的 undici 6 尚无响应 body 诊断通道。只观察这一请求的
117
129
  // onData,不更换 dispatcher/fetch;保留 this/返回值,并在结束/卸载时恢复。
118
130
  // 这是有能力检测的旧运行时兼容层;宿主更换网络实现后退回缺口提示。
@@ -169,6 +181,18 @@ export function createNativeSearchBilling({ account, record = () => {}, uncovere
169
181
  release(request, item)
170
182
  })
171
183
  return {
184
+ // The host publishes this exact event immediately before native search
185
+ // dispatch. It gives the request its own session boundary even when another
186
+ // plugin replaces web.search or the tool invokes a previously saved method.
187
+ signal(session, event) {
188
+ if (!active) return
189
+ if (event?.type === 'web/deepseek-search-llm-request') {
190
+ searchSignal.enterWith({ session, at: now(), pending: event.data?.endpoint === 'https://api.deepseek.com/anthropic/v1/messages', requests: [] })
191
+ } else if (['tool/result', 'turn/end', 'step/end'].includes(event?.type)) {
192
+ const current = searchSignal.getStore()
193
+ if (current?.session?.id === session?.id) current.pending = false
194
+ }
195
+ },
172
196
  async run(operation, session) {
173
197
  if (!active || scope.getStore()) return operation()
174
198
  const current = { session, requests: [] }
@@ -189,6 +213,7 @@ export function createNativeSearchBilling({ account, record = () => {}, uncovere
189
213
  for (const item of pending) { item.done = true; item.release() }
190
214
  pending.clear()
191
215
  scope.disable()
216
+ searchSignal.disable()
192
217
  },
193
218
  }
194
219
  }
@@ -252,6 +277,7 @@ export function installNativeSearchBilling(ctx, ledger) {
252
277
  uncovered: (event) => coverage.add(event),
253
278
  })
254
279
  monitors.set(ledger, { coverage })
280
+ ctx.on?.('session/event', (session, event) => monitor.signal(session, event), { global: true })
255
281
  const observations = new Map()
256
282
  const observe = (service, webCtx = ctx) => {
257
283
  if (!service) return
package/lib/store.js CHANGED
@@ -143,6 +143,7 @@ export function defaultConfig() {
143
143
  showTotalWithPlan: false, // 「含 Plan 总额」全局开关(v1.6.0):开启后全部金额展示按总等值(cost)计;默认按真金白银(apiCost)
144
144
  includeSubagentCost: false, // 本会话展示可合并连续子代理后代;不重复写入每日账本。
145
145
  codexQuotaEnabled: false, // Codex 额度按需开启,避免未使用 Codex 时显示卡片或查询。
146
+ codexQuotaSidebar: true, // 独立控制侧栏卡片,保留会话额度条。
146
147
  sidebarSimple: false,
147
148
  sidebarSimplePromptSeen: false,
148
149
  sidebarModels: { enabled: false, period: 'today', topN: 5, summary: 'total', position: 'last', defaultOpen: false, remember: true, tokens: false, shares: true, refreshSeconds: 60, dock: false },
@@ -740,6 +741,7 @@ const VALIDATION_MESSAGES = {
740
741
  showTotalWithPlan: 'showTotalWithPlan 必须是布尔值',
741
742
  includeSubagentCost: 'includeSubagentCost 必须是布尔值',
742
743
  codexQuotaEnabled: 'codexQuotaEnabled 必须是布尔值',
744
+ codexQuotaSidebar: 'codexQuotaSidebar 必须是布尔值',
743
745
  sidebarSimple: '简化显示设置必须是布尔值',
744
746
  sidebarModels: '模型花费卡片设置无效:Top-N 为 1–10,刷新间隔为 10–60 秒',
745
747
  sidebarStyle: 'sidebarStyle 必须是 standard / compact',
@@ -844,6 +846,7 @@ const VALIDATION_MESSAGES = {
844
846
  showTotalWithPlan: 'showTotalWithPlan must be a boolean',
845
847
  includeSubagentCost: 'includeSubagentCost must be a boolean',
846
848
  codexQuotaEnabled: 'codexQuotaEnabled must be a boolean',
849
+ codexQuotaSidebar: 'codexQuotaSidebar must be a boolean',
847
850
  sidebarSimple: 'Simple display settings must be boolean',
848
851
  sidebarModels: 'Invalid model cost card settings: Top-N must be 1–10 and refresh interval 10–60 seconds',
849
852
  sidebarStyle: 'sidebarStyle must be standard / compact',
@@ -1142,7 +1145,7 @@ export function applyConfigPatch(current, rawPatch) {
1142
1145
  if (candidate.sidebarTodayMetric !== undefined && !['cost', 'tokens'].includes(candidate.sidebarTodayMetric)) errors.push('sidebarTodayMetric must be cost or tokens')
1143
1146
  // 「含 Plan 总额」全局开关(v1.6.0):布尔,可缺省(默认关)。
1144
1147
  if (candidate.showTotalWithPlan !== undefined && typeof candidate.showTotalWithPlan !== 'boolean') errors.push(vmsg(locale, 'showTotalWithPlan'))
1145
- for (const field of ['includeSubagentCost', 'codexQuotaEnabled']) {
1148
+ for (const field of ['includeSubagentCost', 'codexQuotaEnabled', 'codexQuotaSidebar']) {
1146
1149
  if (candidate[field] !== undefined && typeof candidate[field] !== 'boolean') errors.push(vmsg(locale, field))
1147
1150
  }
1148
1151
  // 侧边栏进度条样式:standard(默认,纵向单列) | compact(额度卡两列网格)。
@@ -1480,6 +1483,7 @@ export function sanitizeConfig(raw) {
1480
1483
  out.showTotalWithPlan = out.showTotalWithPlan === true
1481
1484
  out.includeSubagentCost = out.includeSubagentCost === true
1482
1485
  out.codexQuotaEnabled = out.codexQuotaEnabled === true
1486
+ out.codexQuotaSidebar = out.codexQuotaSidebar !== false
1483
1487
  out.sidebarSimple = out.sidebarSimple === true
1484
1488
  out.sidebarSimplePromptSeen = out.sidebarSimplePromptSeen === true
1485
1489
  out.sidebarModels = Object.fromEntries(Object.entries(base.sidebarModels).map(([k, fallback]) => {
@@ -0,0 +1,71 @@
1
+ import { statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
4
+ import { listSessionLogs, iterateSessionRecords, replaySessionRecords } from './backfill.js'
5
+ import { nativeSearchRecordsFor } from './native-search-history.js'
6
+ import { tierFor, usdFromCost } from './pricing.js'
7
+
8
+ const cache = new Map()
9
+ const fields = { input: 'cacheMiss', output: 'output', cacheRead: 'cacheHit', cacheWrite: 'cacheWrite', reasoning: 'reasoning' }
10
+ const emptyTurn = () => ({ found: false, cost: 0, apiCost: 0, rows: [], calls: [] })
11
+ export function turnCostFromRecords(records, config, startSeq, endSeq, native = []) {
12
+ const start = records.find(row => row.seq === startSeq && row.type === 'turn/start')
13
+ const end = records.find(row => row.seq === endSeq && row.type === 'turn/end')
14
+ if (!start || !end || endSeq < startSeq || start.data?.turn !== end.data?.turn) return emptyTurn()
15
+ const headers = records.filter(row => row.type === 'request/header' && row.seq < startSeq).slice(-1)
16
+ const selected = [...headers, ...records.filter(row => row.seq >= startSeq && row.seq <= endSeq),
17
+ ...native.filter(row => row.data.startedAtMs >= start.time && row.data.startedAtMs <= end.time)]
18
+ const { samples } = replaySessionRecords(selected, config, null, true)
19
+ const parts = new Map()
20
+ const calls = []
21
+ for (const sample of samples) {
22
+ let tier = tierFor(sample.resolved.entry, sample.atMs, sample.peak)
23
+ const longContext = !!tier.longContext && sample.buckets.input + sample.buckets.cacheRead + sample.buckets.cacheWrite > tier.longContext.aboveInputTokens
24
+ if (longContext) tier = tier.longContext
25
+ const call = { kind: sample.kind, provider: sample.provider, model: sample.model, atMs: sample.atMs, cost: sample.cost, apiCost: sample.apiCost, plan: sample.plan, priced: sample.resolved.priced, longContext, rows: [] }
26
+ for (const [bucket, priceKey] of Object.entries(fields)) {
27
+ const tokens = sample.buckets[bucket]
28
+ if (!tokens) continue
29
+ const rate = sample.resolved.priced ? usdFromCost(tier[priceKey] ?? (bucket === 'cacheWrite' ? tier.cacheHit : 0), sample.resolved.currency, config.exchangeRate) : 0
30
+ const plan = sample.plan
31
+ call.rows.push({ provider: sample.provider, model: sample.model, bucket, tokens, rate, cost: tokens * rate / 1e6, priced: sample.resolved.priced, plan })
32
+ const key = JSON.stringify([sample.provider, sample.model, bucket, rate, plan, sample.resolved.priced])
33
+ const row = parts.get(key) ?? { provider: sample.provider, model: sample.model, bucket, tokens: 0, rate, cost: 0, priced: sample.resolved.priced, plan }
34
+ row.tokens += tokens; row.cost += tokens * rate / 1e6
35
+ parts.set(key, row)
36
+ }
37
+ calls.push(call)
38
+ }
39
+ calls.sort((a, b) => a.atMs - b.atMs)
40
+ return { found: samples.length > 0, cost: samples.reduce((n, row) => n + row.cost, 0), apiCost: samples.reduce((n, row) => n + row.apiCost, 0), rows: [...parts.values()], calls }
41
+ }
42
+
43
+ export async function getTurnCost(ledger, ctx, sessionId, startSeq, endSeq) {
44
+ if (typeof sessionId !== 'string' || !sessionId || sessionId.length > 512 || ![startSeq, endSeq].every(n => Number.isSafeInteger(n) && n >= 0) || endSeq < startSeq) throw new Error('invalid turn identity')
45
+ const live = ctx?.get?.('sessions')?.get?.(sessionId)?.snapshotEvents?.()
46
+ if (Array.isArray(live)) return turnCostFromRecords(live, ledger.config, startSeq, endSeq, await nativeSearchRecordsFor(ledger.path, sessionId))
47
+ const paths = listSessionLogs(join(resolveDshHome(), 'sessions'), new Set([sessionId]))
48
+ if (!paths.length) return emptyTurn()
49
+ const path = paths[0], info = statSync(path)
50
+ const key = `${path}:${info.size}:${info.mtimeMs}`
51
+ let pending = cache.get(key)
52
+ if (!pending) {
53
+ pending = (async () => {
54
+ const rows = []
55
+ for await (const row of iterateSessionRecords(path)) {
56
+ if (row.type === 'request/header') rows.push({ type: row.type, seq: row.seq, time: row.time, data: { header: { config: { model: row.data?.header?.config?.model, provider: row.data?.header?.config?.provider } } } })
57
+ else if (['turn/start', 'turn/end', 'compaction/summary', 'assistant/message'].includes(row.type) || row.type === 'assistant/chunk' && row.data?.chunk?.type === 'usage') {
58
+ rows.push({ type: row.type, seq: row.seq, time: row.time, data: { turn: row.data?.turn, step: row.data?.step, provider: row.data?.provider, model: row.data?.model,
59
+ usage: row.data?.usage, message: { source: row.data?.message?.source }, chunk: row.data?.chunk?.type === 'usage' ? row.data.chunk : undefined } })
60
+ }
61
+ if (rows.length > 200_000) throw new Error('Session usage history exceeds the turn detail limit')
62
+ }
63
+ return rows
64
+ })()
65
+ cache.set(key, pending)
66
+ if (cache.size > 16) cache.delete(cache.keys().next().value)
67
+ pending.catch(() => cache.delete(key))
68
+ }
69
+ const records = await pending
70
+ return turnCostFromRecords(records, ledger.config, startSeq, endSeq, await nativeSearchRecordsFor(ledger.path, sessionId))
71
+ }
@@ -59,6 +59,12 @@ export const sessionCostSchema = z.object({
59
59
  subagentCount: num,
60
60
  found: z.boolean(),
61
61
  })
62
+ const turnCostRowSchema = z.object({ provider: z.string(), model: z.string(), bucket: z.string(), tokens: num, rate: num, cost: num, priced: z.boolean(), plan: z.boolean() })
63
+ export const turnCostSchema = z.object({
64
+ found: z.boolean(), cost: num, apiCost: num,
65
+ rows: z.array(turnCostRowSchema),
66
+ calls: z.array(z.object({ kind: z.string(), provider: z.string(), model: z.string(), atMs: num, cost: num, apiCost: num, plan: z.boolean(), priced: z.boolean(), longContext: z.boolean(), rows: z.array(turnCostRowSchema) })),
67
+ })
62
68
 
63
69
  const longContextSchema = z.object({
64
70
  aboveInputTokens: z.number().positive(),
@@ -160,6 +166,7 @@ const configSchema = z.object({
160
166
  showTotalWithPlan: z.boolean().optional(),
161
167
  includeSubagentCost: z.boolean().optional(),
162
168
  codexQuotaEnabled: z.boolean().optional(),
169
+ codexQuotaSidebar: z.boolean().optional(),
163
170
  sidebarSimple: z.boolean().optional(),
164
171
  sidebarSimplePromptSeen: z.boolean().optional(),
165
172
  sidebarModels: z.object({ enabled: z.boolean(), period: z.enum(['today', 'history']), topN: z.number().int().min(1).max(10), summary: z.enum(['total', 'top']), position: z.enum(['first', 'afterBalance', 'last']), defaultOpen: z.boolean(), remember: z.boolean(), tokens: z.boolean(), shares: z.boolean(), refreshSeconds: z.number().int().min(10).max(60), dock: z.boolean() }).strict().optional(),
@@ -523,6 +530,8 @@ const _sort$codec = strictCodec('SessionSort', z.string())
523
530
  const _dir$codec = strictCodec('SessionSortDir', z.string())
524
531
  const _sessionId$codec = strictCodec('SessionId', z.string().min(1).max(512))
525
532
  const _sessionCost$codec = strictCodec('SessionCost', sessionCostSchema)
533
+ const _turnCost$codec = strictCodec('TurnCost', turnCostSchema)
534
+ const _turnSeq$codec = strictCodec('TurnSeq', z.number().int().nonnegative())
526
535
 
527
536
  export const TYPERT = {
528
537
  package: 'dsh-cost-meter',
@@ -673,6 +682,12 @@ export const TYPERT = {
673
682
  parameters: [{ name: 'sessionId', wire: 'sessionId', source: 'json', codec: _sessionId$codec }],
674
683
  result: _sessionCost$codec,
675
684
  },
685
+ {
686
+ id: 'dsh-cost-meter#costMeter/getTurnCost', service: 'costMeter', namespace: 'costMeter', method: 'getTurnCost',
687
+ invocation: { kind: 'direct' },
688
+ parameters: [{ name: 'sessionId', wire: 'sessionId', source: 'json', codec: _sessionId$codec }, { name: 'startSeq', wire: 'startSeq', source: 'json', codec: _turnSeq$codec }, { name: 'endSeq', wire: 'endSeq', source: 'json', codec: _turnSeq$codec }],
689
+ result: _turnCost$codec,
690
+ },
676
691
  {
677
692
  // 写入一枚密钥到 DSH 凭据库(v1.6.8):密钥不再经 updateConfig 传递,值只进凭据库。
678
693
  id: 'dsh-cost-meter#costMeter/setCredential',
@@ -786,6 +801,12 @@ export const TYPERT = {
786
801
  summary: '跨全部日期按指定排序返回前 N 个会话(不分日期视角)。Return the top N sessions across all days with the given sort.',
787
802
  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 */',
788
803
  },
804
+ {
805
+ kind: 'method',
806
+ name: 'getTurnCost',
807
+ signature: 'getTurnCost(sessionId: string, startSeq: number, endSeq: number): TurnCost',
808
+ summary: '读取已完成轮次的分项用量与估算费用。Read recorded usage and estimated costs for a completed turn.',
809
+ },
789
810
  {
790
811
  kind: 'method',
791
812
  name: 'getSessionCost',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cost-meter",
3
- "version": "1.7.47",
3
+ "version": "1.7.49",
4
4
  "description": "DeepSeek Harness 会话费用统计插件:本会话成本、当日费用、历史记录与官方价格同步,支持多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配)、主流 Coding Plan 订阅额度查询与显示(11 家,含小米 MiMo Token Plan 控制台 Cookie 查询、SCNet Token Plan 本地 Credits 计量与火山方舟 Volcano Ark Coding Plan AK/SK 签名查询)、自定义 Provider 余额查询(可配任意 HTTP 端点)与余额进度条、峰谷计价时段显示与切换前弹窗/系统通知提醒(位置/提前量/类型可配),界面中英双语。Session cost tracking plugin for DeepSeek Harness: per-conversation cost, daily totals, history and official price sync, with multi-vendor model pricing (built-in 90+ model catalog with auto-matching), Coding Plan quota queries & display for 11 vendors (incl. Xiaomi MiMo Token Plan via console cookie, local credits metering for the SCNet Token Plan and Volcano Ark Coding Plan via AK/SK signature), plan/API dual-track billing with per-1% & full-window Token Plan usage estimates, custom provider balance lookup (configurable HTTP endpoint) and balance progress bar, plus peak/off-peak pricing display with pre-switch popup & system-notification alerts (position / lead time / type configurable), in a bilingual (Chinese/English) UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -37,7 +37,8 @@
37
37
  "0.1.3-alpha.1": "unknown",
38
38
  "0.1.3-alpha.2": "compatible",
39
39
  "0.1.5-alpha.1": "compatible",
40
- "0.2.0-rc.1": "compatible"
40
+ "0.2.0-rc.1": "compatible",
41
+ "0.2.0-rc.2": "compatible"
41
42
  }
42
43
  }
43
44
  },