dsh-cost-meter 1.7.19 → 1.7.21

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
@@ -22,6 +22,7 @@ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
22
22
  import { Ledger, applyConfigPatch, localDayKey, pickBalanceInfo, reconcileBalanceDelta, zeroDay, splitLedgerApiCost, repairLedgerPricing, dedupeWrapperProviderDays, unpriceLocalOriginModels, stripSecrets, stripSecretPatch, secretRefOf, readSecret, writeSecret, SECRET_TARGETS, looksLikeSecretHeaderValue } from './store.js'
23
23
  import { backfillLegacyLedger, importLegacyHistory, repairForkSeed, repairProviderDupes, recomputeLedgerPricingBasis } from './backfill.js'
24
24
  import { createLlmStreamBilling } from './billing-stream.js'
25
+ import { installNativeSearchBilling, nativeSearchReconcile, isNativeSearchUsageEvent } from './native-search-billing.js'
25
26
  import { OFFICIAL_PRICING_URL, OFFICIAL_PRICING_URL_ZH, LEGACY_BASE_BOUNDARY, FLASH_PRICE_EFFECTIVE_AT, PRO_FLASH_ROUTING_EFFECTIVE_AT, normalizePrice, parsePricingHtml, repairDefaultPeakPrice, upgradeDeepSeekPriceTable, costOf, providerPriceEntryFor, buildPriceCatalog, usdFromCost, isWrapperProviderId, wrapperUpstreamProvider } from './pricing.js'
26
27
  import { createUsageDeduper, USAGE_DEDUP_WINDOW_MS, usageFingerprint } from './usage-dedup.js'
27
28
  import { CODING_PLAN_PROVIDERS, CODING_PLAN_PROVIDER_IDS, queryCodingPlan, scnetTokenPlanWindows, qwenTokenPlanWindows, emptyCustomBalance, queryCustomBalance, normalizeVolcengineKey } from './coding-plans.js'
@@ -31,6 +32,7 @@ import { queryGatewayQuota, emptyGatewayQuota, managementKeyVarOf, gatewaySource
31
32
  import { stateSchema } from './typert.host.js'
32
33
  import { customBalanceCredentialVars } from './custom-balance.js'
33
34
  import { readScnetSnapshot } from './scnet-snapshot.js'
35
+ import { getSessionCost as readSessionCost } from './session-tree.js'
34
36
 
35
37
  export const name = 'cost-meter'
36
38
 
@@ -281,6 +283,7 @@ const usageProjectionStateSchema = z.object({
281
283
  at: z.number(),
282
284
  w: z.number().optional(),
283
285
  })).max(32).optional(),
286
+ nativeSearchIds: z.array(z.string()).max(128).optional(),
284
287
  })
285
288
 
286
289
  /** state → wire payload 读侧投影(新版 wire.view 与旧版 view 共用同一实现)。 */
@@ -377,8 +380,16 @@ function makeCostUsageProjection(ledger) {
377
380
  // v7→v8(issue #77):折叠计入 compaction/summary(压缩摘要调用,此前漏计);
378
381
  // 升版本触发宿主对旧 checkpoint 全量重放,历史摘要调用量随之补齐。
379
382
  // v8→v9(issue #109):默认回退补齐峰谷档位,旧金额 checkpoint 需按事件重放。
380
- stateVersion: 9,
381
- init: () => ({ provider: 'deepseek', model: 'default', totals: zeroBuckets(), byModel: {}, byProviderModel: {}, last: null, createdAt: 0, seedEndSeq: -1, shadow: emptyShadow(), seedLength: -1, seedDeducted: false, recent: [] }),
383
+ // v10 (#130):新版宿主 init(header, inheritedEventCount) 区分恢复和继承。
384
+ // 普通重启也追加 end-seed,不能把恢复前已付费的调用当作 fork 种子扣掉。
385
+ stateVersion: 10,
386
+ init: (header, inheritedEventCount) => {
387
+ const known = Number.isInteger(inheritedEventCount) && inheritedEventCount >= 0
388
+ const boundary = known && inheritedEventCount > 0 ? inheritedEventCount : -1
389
+ return { provider: 'deepseek', model: 'default', totals: zeroBuckets(), byModel: {}, byProviderModel: {}, last: null,
390
+ createdAt: Number(header?.createdAt) || 0, seedEndSeq: boundary, shadow: emptyShadow(),
391
+ seedLength: boundary, seedDeducted: known, recent: [] }
392
+ },
382
393
  apply(state, event) {
383
394
  // 兼容旧 checkpoint 的缺字段(版本升级前持久化的 v5 状态):缺省回落。
384
395
  if (state.seedDeducted === undefined) state.seedDeducted = false
@@ -478,6 +489,8 @@ function makeCostUsageProjection(ledger) {
478
489
  let eventProvider = null
479
490
  let eventModel = null
480
491
  let keyOverride = null
492
+ const nativeSearch = isNativeSearchUsageEvent(event)
493
+ let nativeSearchIds = state.nativeSearchIds ?? []
481
494
  // 判空用 != null:usage === null 时 !== undefined 会放行,随后读
482
495
  // usage.inputTokens 直接抛 TypeError 打断投影;billing-stream 侧同处
483
496
  // 还会让 null 覆盖先前捕获的有效 usage 快照导致整次调用漏计。
@@ -489,6 +502,13 @@ function makeCostUsageProjection(ledger) {
489
502
  usage = event.data.usage
490
503
  turn = event.data.turn ?? 0
491
504
  step = event.data.step ?? 0
505
+ } else if (nativeSearch && event.data?.usage != null && typeof event.data.requestId === 'string') {
506
+ if (nativeSearchIds.includes(event.data.requestId)) return state
507
+ nativeSearchIds = [...nativeSearchIds.slice(-127), event.data.requestId]
508
+ usage = event.data.usage
509
+ eventProvider = event.data.provider
510
+ eventModel = event.data.model
511
+ keyOverride = `search:${event.data.requestId}`
492
512
  } else if (event.type === 'compaction/summary' && event.data?.usage != null) {
493
513
  usage = event.data.usage
494
514
  const source = event.data.message?.source ?? {}
@@ -528,7 +548,8 @@ function makeCostUsageProjection(ledger) {
528
548
  return state
529
549
  }
530
550
  // 按事件时刻计费(历史正确):峰谷时代前用 legacyBase,之后按峰谷两档。
531
- const atMs = Number.isFinite(Number(event.time)) && Number(event.time) > 0 ? Number(event.time) : Date.now()
551
+ const billingTime = nativeSearch ? event.data.startedAtMs : event.time
552
+ const atMs = Number.isFinite(Number(billingTime)) && Number(billingTime) > 0 ? Number(billingTime) : Date.now()
532
553
  // 指纹窗口去重(与 lib/usage-dedup.js 同语义的序列化形态):先按窗口清扫
533
554
  // 滚动列表,重复转发跳过(清扫结果仍落回 state,保持窗口推进)。有界:
534
555
  // 超过 24 条先截断再判定,checkpoint 体积恒定。compaction/summary 不参与:
@@ -603,7 +624,7 @@ function makeCostUsageProjection(ledger) {
603
624
  }
604
625
  // createdAt/seedLength/seedDeducted 必须随状态携带:usage 样本更新不能丢掉 fork 过滤基准。
605
626
  // recent 随行:包装层转发对去重的滚动窗口状态(有界 ≤25 条)。
606
- return { provider: state.provider, model: state.model, totals, byModel, byProviderModel, createdAt: state.createdAt, seedEndSeq: state.seedEndSeq, seedLength: state.seedLength, seedDeducted: state.seedDeducted, shadow: shadowAgg, recent, last: { key, provider: effectiveProvider, model, buckets, cost: billed } }
627
+ return { provider: state.provider, model: state.model, totals, byModel, byProviderModel, createdAt: state.createdAt, seedEndSeq: state.seedEndSeq, seedLength: state.seedLength, seedDeducted: state.seedDeducted, shadow: shadowAgg, recent, nativeSearchIds, last: nativeSearch ? state.last : { key, provider: effectiveProvider, model, buckets, cost: billed } }
607
628
  },
608
629
  view: projectionView,
609
630
  // DSH 0.1.1-rc.1 起会话投影需声明 wire 才会向客户端推送(PR #39 by
@@ -1095,7 +1116,7 @@ async function buildState(ledger, balance = emptyBalance(), goQuota = emptyGoQuo
1095
1116
  // 自定义余额 {{VAR}} 占位符凭据状态(v1.7.6,issue #86):变量名 → { configured, source }。
1096
1117
  customVarStatus,
1097
1118
  // 余额差交叉校验提示(issue #18):本地今日合计与官方余额当日变动偏差超阈时 ok=false。
1098
- reconcile,
1119
+ reconcile: nativeSearchReconcile(ledger, localeOf(ledger.config), reconcile),
1099
1120
  codingPlans,
1100
1121
  planStats,
1101
1122
  history: ledger.history(90),
@@ -2128,6 +2149,9 @@ function createService(ctx, ledger) {
2128
2149
 
2129
2150
  // 跨全部日期返回前 N 个会话(issue #22 按会话视角,不分日期)。
2130
2151
  // sort:cost(费用) | time(会话创建时间) | recent(实时顺序,即账本/侧边栏顺序);dir:asc | desc。
2152
+ async getSessionCost(sessionId) {
2153
+ return readSessionCost(ledger, ctx, sessionId)
2154
+ },
2131
2155
  async getTopSessions(limit, sort = 'cost', dir = 'desc') {
2132
2156
  const n = Math.max(1, Math.min(500, Math.floor(Number(limit)) || 100))
2133
2157
  const sortKey = sort === 'time' || sort === 'recent' ? sort : 'cost'
@@ -2800,6 +2824,8 @@ export function apply(ctx) {
2800
2824
  }), { global: true })
2801
2825
 
2802
2826
  // costUsage 投影:向会话历史页/推送帧提供 token 桶(客户端计价)。
2827
+ installNativeSearchBilling(ctx, ledger)
2828
+
2803
2829
  ctx.inject(['sessionProjections'], (projectionCtx) => {
2804
2830
  projectionCtx.sessionProjections.register(makeCostUsageProjection(ledger))
2805
2831
  })
@@ -0,0 +1,278 @@
1
+ /**
2
+ * DeepSeek 原生搜索不经过 llm/stream,且宿主会丢弃 Messages 响应的 usage。
3
+ * 只在 web.search 的异步作用域内订阅官方 Messages 响应诊断;不替换 fetch、
4
+ * 不读取请求 headers/body,也不改变搜索返回值、取消或错误语义。
5
+ */
6
+ import { AsyncLocalStorage } from 'node:async_hooks'
7
+ import { channel } from 'node:diagnostics_channel'
8
+ import { randomUUID } from 'node:crypto'
9
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, statSync } from 'node:fs'
10
+ import { dirname } from 'node:path'
11
+ import { gunzipSync, inflateSync, brotliDecompressSync } from 'node:zlib'
12
+
13
+ export const NATIVE_SEARCH_USAGE_EVENT = 'cost-meter/native-search-usage'
14
+ export function isNativeSearchUsageEvent(event) {
15
+ const data = event?.data
16
+ return event?.type === NATIVE_SEARCH_USAGE_EVENT && data?.provider === 'deepseek-official'
17
+ && typeof data.model === 'string' && /^deepseek-[a-z\d._:-]{1,120}$/i.test(data.model)
18
+ && typeof data.requestId === 'string' && /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/i.test(data.requestId)
19
+ && Number.isFinite(data.startedAtMs) && data.startedAtMs > 0
20
+ && ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens'].every(key => Number.isSafeInteger(data.usage?.[key]) && data.usage[key] >= 0)
21
+ }
22
+ const MAX_BODY_BYTES = 4 * 1024 * 1024
23
+ const monitors = new WeakMap()
24
+
25
+ const dayKey = (at) => {
26
+ const date = new Date(at)
27
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
28
+ }
29
+ const count = (value) => Number.isSafeInteger(value) && value >= 0
30
+
31
+ /** Anthropic 的 input_tokens 不含两类 cache token;五桶不能重复加总。 */
32
+ export function nativeSearchUsage(response) {
33
+ const source = response?.usage
34
+ if (!source || !count(source.input_tokens) || !count(source.output_tokens)) return null
35
+ for (const key of ['cache_read_input_tokens', 'cache_creation_input_tokens']) {
36
+ if (source[key] !== undefined && !count(source[key])) return null
37
+ }
38
+ if (typeof response.model !== 'string' || !/^deepseek-[a-z\d._:-]{1,120}$/i.test(response.model)) return null
39
+ return {
40
+ model: response.model,
41
+ usage: {
42
+ inputTokens: source.input_tokens,
43
+ outputTokens: source.output_tokens,
44
+ cacheReadTokens: source.cache_read_input_tokens ?? 0,
45
+ cacheWriteTokens: source.cache_creation_input_tokens ?? 0,
46
+ reasoningTokens: 0,
47
+ },
48
+ }
49
+ }
50
+
51
+ function officialRequest(request) {
52
+ return request?.method === 'POST' && String(request.origin) === 'https://api.deepseek.com'
53
+ && request.path === '/anthropic/v1/messages'
54
+ }
55
+
56
+ function responseEncoding(headers) {
57
+ if (Array.isArray(headers)) {
58
+ for (let i = 0; i + 1 < headers.length; i += 2) {
59
+ if (String(headers[i]).toLowerCase() === 'content-encoding') return String(headers[i + 1]).trim().toLowerCase()
60
+ }
61
+ return ''
62
+ }
63
+ return String(headers?.['content-encoding'] ?? '').trim().toLowerCase()
64
+ }
65
+
66
+ function parseBody(request) {
67
+ let bytes = Buffer.concat(request.chunks, request.bytes)
68
+ const options = { maxOutputLength: MAX_BODY_BYTES }
69
+ if (request.encoding === 'gzip') bytes = gunzipSync(bytes, options)
70
+ else if (request.encoding === 'deflate') bytes = inflateSync(bytes, options)
71
+ else if (request.encoding === 'br') bytes = brotliDecompressSync(bytes, options)
72
+ else if (request.encoding !== '' && request.encoding !== 'identity') return null
73
+ return nativeSearchUsage(JSON.parse(bytes.toString('utf8')))
74
+ }
75
+
76
+ /**
77
+ * 可注入诊断通道/目标判定仅用于不付费的本地回归;生产固定使用官方端点。
78
+ * 每个 HTTP request 独立计数,相同 token 的并发/重复搜索不会相互去重。
79
+ */
80
+ export function createNativeSearchBilling({ account, record = () => {}, uncovered = () => {}, now = Date.now, targetRequest = officialRequest, diagnostics = channel }) {
81
+ const scope = new AsyncLocalStorage()
82
+ const requests = new WeakMap()
83
+ const pending = new Set()
84
+ const subscriptions = []
85
+ let active = true
86
+ const subscribe = (name, listener) => {
87
+ const source = diagnostics(`undici:request:${name}`)
88
+ // diagnostics_channel 的监听器不得抛错,否则 Node 会作为 uncaughtException 处理。
89
+ const safe = (event) => { try { if (active) listener(event) } catch {} }
90
+ source.subscribe(safe)
91
+ subscriptions.push(() => source.unsubscribe(safe))
92
+ }
93
+ const miss = (item, reason) => {
94
+ if (item.coverageReported) return
95
+ item.coverageReported = true
96
+ try { uncovered({ startedAtMs: item.startedAtMs, reason }) } catch {}
97
+ }
98
+ const capture = (item, chunk, source) => {
99
+ if (!active || item.done || item.status < 200 || item.status >= 300 || item.tooLarge) return
100
+ // 兼容层与诊断通道可能同时出现,只选先到达的一条,不重复累计响应字节。
101
+ if (item.captureSource && item.captureSource !== source) return
102
+ item.captureSource = source
103
+ item.bytes += chunk.byteLength
104
+ if (item.bytes > MAX_BODY_BYTES) { item.tooLarge = true; item.chunks = []; return }
105
+ item.chunks.push(Buffer.from(chunk))
106
+ }
107
+ const release = (request, item) => {
108
+ item.chunks = []
109
+ try { item.restore?.() } catch {}
110
+ item.restore = undefined
111
+ item.release = undefined
112
+ pending.delete(item)
113
+ requests.delete(request)
114
+ }
115
+ subscribe('create', ({ request }) => {
116
+ const current = scope.getStore()
117
+ if (!current || !targetRequest(request)) return
118
+ const item = { session: current.session, startedAtMs: now(), requestId: randomUUID(), bytes: 0, chunks: [], encoding: '', status: 0, done: false }
119
+ requests.set(request, item)
120
+ current.requests.push(item)
121
+ pending.add(item)
122
+ item.release = () => release(request, item)
123
+ // Node 20/22 的 undici 6 尚无响应 body 诊断通道。只观察这一请求的
124
+ // onData,不更换 dispatcher/fetch;保留 this/返回值,并在结束/卸载时恢复。
125
+ // 这是有能力检测的旧运行时兼容层;宿主更换网络实现后退回缺口提示。
126
+ const own = Object.getOwnPropertyDescriptor(request, 'onData')
127
+ const original = request.onData
128
+ if (typeof original === 'function' && own?.configurable !== false && Object.isExtensible(request)) {
129
+ const wrapped = function (...args) {
130
+ try { capture(item, args[0], 'onData') } catch {}
131
+ return Reflect.apply(original, this, args)
132
+ }
133
+ Object.defineProperty(request, 'onData', { configurable: true, writable: true, value: wrapped })
134
+ item.restore = () => {
135
+ if (Object.getOwnPropertyDescriptor(request, 'onData')?.value !== wrapped) return
136
+ if (own) Object.defineProperty(request, 'onData', own)
137
+ else delete request.onData
138
+ }
139
+ }
140
+ })
141
+ subscribe('headers', ({ request, response }) => {
142
+ const item = requests.get(request)
143
+ if (!item) return
144
+ item.status = response.statusCode
145
+ item.encoding = responseEncoding(response.headers)
146
+ })
147
+ subscribe('bodyChunkReceived', ({ request, chunk }) => {
148
+ const item = requests.get(request)
149
+ if (item) capture(item, chunk, 'diagnostic')
150
+ })
151
+ subscribe('trailers', ({ request }) => {
152
+ const item = requests.get(request)
153
+ if (!item || item.done) return
154
+ item.done = true
155
+ try {
156
+ if (item.status < 200 || item.status >= 300) return
157
+ const parsed = item.tooLarge || item.bytes === 0 ? null : parseBody(item)
158
+ if (!parsed) { miss(item, 'usage-unavailable'); return }
159
+ const event = { ...parsed, provider: 'deepseek-official', startedAtMs: item.startedAtMs, requestId: item.requestId }
160
+ try { account(event, item.session) } catch { miss(item, 'account-failed'); return }
161
+ if (item.session) {
162
+ try { record(item.session, event) } catch { miss(item, 'history-unavailable') }
163
+ } else miss(item, 'history-unavailable')
164
+ } catch {
165
+ miss(item, 'usage-unavailable')
166
+ } finally {
167
+ release(request, item)
168
+ }
169
+ })
170
+ subscribe('error', ({ request }) => {
171
+ const item = requests.get(request)
172
+ if (!item || item.done) return
173
+ item.done = true
174
+ // 请求已派发后的中断可能发生费用,但没有完整 usage 时不能猜测金额。
175
+ miss(item, 'response-incomplete')
176
+ release(request, item)
177
+ })
178
+ return {
179
+ async run(operation, session) {
180
+ if (!active || scope.getStore()) return operation()
181
+ const current = { session, requests: [] }
182
+ try { return await scope.run(current, operation) }
183
+ finally {
184
+ for (const item of current.requests) {
185
+ if (!item.done) {
186
+ item.done = true
187
+ if (active) miss(item, 'response-incomplete')
188
+ item.release()
189
+ }
190
+ }
191
+ }
192
+ },
193
+ dispose() {
194
+ active = false
195
+ for (const unsubscribe of subscriptions) unsubscribe()
196
+ for (const item of pending) { item.done = true; item.release() }
197
+ pending.clear()
198
+ scope.disable()
199
+ },
200
+ }
201
+ }
202
+
203
+ /** 仅保存计数和日期,重启仍能解释未覆盖调用;不写查询、响应正文或凭据。 */
204
+ export function createSearchCoverage(path, now = Date.now) {
205
+ let days = {}
206
+ try {
207
+ const raw = statSync(path).size < 64 * 1024 ? readFileSync(path, 'utf8') : ''
208
+ if (raw.length < 64 * 1024) {
209
+ const parsed = JSON.parse(raw)
210
+ for (const [day, total] of Object.entries(parsed.days ?? {})) {
211
+ if (/^\d{4}-\d{2}-\d{2}$/.test(day) && count(total)) days[day] = total
212
+ }
213
+ }
214
+ } catch {}
215
+ let timer
216
+ const flush = () => {
217
+ clearTimeout(timer)
218
+ timer = undefined
219
+ try {
220
+ mkdirSync(dirname(path), { recursive: true })
221
+ writeFileSync(`${path}.tmp`, JSON.stringify({ days }))
222
+ renameSync(`${path}.tmp`, path)
223
+ } catch { console.warn('[dsh-cost-meter] 原生搜索覆盖提示未能持久化') }
224
+ }
225
+ return {
226
+ add({ startedAtMs }) {
227
+ const day = dayKey(startedAtMs)
228
+ days[day] = Math.min(Number.MAX_SAFE_INTEGER, (days[day] ?? 0) + 1)
229
+ days = Object.fromEntries(Object.entries(days).sort(([a], [b]) => a.localeCompare(b)).slice(-90))
230
+ if (!timer) { timer = setTimeout(flush, 1000); timer.unref?.() }
231
+ },
232
+ today() { return days[dayKey(now())] ?? 0 },
233
+ close() { if (timer) flush() },
234
+ }
235
+ }
236
+
237
+ /** 保留已有对账偏差信息,将真实发生的搜索覆盖缺口追加到同一个提示。 */
238
+ export function nativeSearchReconcile(ledger, locale, reconcile) {
239
+ const missed = monitors.get(ledger)?.coverage.today() ?? 0
240
+ if (!missed) return reconcile
241
+ const message = locale === 'en'
242
+ ? `Native search coverage: ${missed} official search request(s) today lack complete usage or a durable usage event; their tokens or history may be missing. The current host/Node transport must expose response usage for exact accounting; no per-search estimate was added.`
243
+ : `原生搜索统计缺口:今日 ${missed} 次官方搜索请求未取得完整 usage 或未能持久化用量事件,费用或历史可能漏记。精确计费需要宿主/Node 网络实现提供响应 usage;未按搜索次数虚构金额。`
244
+ return { ok: false, message: [reconcile?.message, message].filter(Boolean).join('\n') }
245
+ }
246
+
247
+ /** 可选 web 服务:未安装搜索能力时不影响插件启动。 */
248
+ export function installNativeSearchBilling(ctx, ledger) {
249
+ if (monitors.has(ledger)) return
250
+ const coverage = createSearchCoverage(`${ledger.path}.native-search-coverage.json`)
251
+ const monitor = createNativeSearchBilling({
252
+ account(event, session) {
253
+ const usage = event.usage
254
+ ledger.account({ input: usage.inputTokens, output: usage.outputTokens, cacheRead: usage.cacheReadTokens, cacheWrite: usage.cacheWriteTokens, reasoning: usage.reasoningTokens }, event.model, session?.id, event.startedAtMs, event.provider)
255
+ },
256
+ record(session, event) { session.append(NATIVE_SEARCH_USAGE_EVENT, event) },
257
+ uncovered: (event) => coverage.add(event),
258
+ })
259
+ monitors.set(ledger, { coverage })
260
+ ctx.effect(() => () => { monitor.dispose(); coverage.close(); monitors.delete(ledger) }, 'cost-meter: native search billing')
261
+ ctx.inject(['web'], (webCtx) => {
262
+ const web = webCtx.web
263
+ const own = Object.getOwnPropertyDescriptor(web, 'search')
264
+ const original = own?.value ?? Object.getOwnPropertyDescriptor(Object.getPrototypeOf(web), 'search')?.value
265
+ if (typeof original !== 'function' || own?.configurable === false) return
266
+ const wrapped = function (...args) {
267
+ let session
268
+ try { session = this.ctx?.get?.('agents')?.currentInitiator?.()?.session ?? webCtx.get?.('agents')?.currentInitiator?.()?.session } catch {}
269
+ return monitor.run(() => Reflect.apply(original, this, args), session)
270
+ }
271
+ Object.defineProperty(web, 'search', { configurable: true, writable: true, value: wrapped })
272
+ webCtx.effect(() => () => {
273
+ if (Object.getOwnPropertyDescriptor(web, 'search')?.value !== wrapped) return
274
+ if (own) Object.defineProperty(web, 'search', own)
275
+ else delete web.search
276
+ }, 'cost-meter: web search observation')
277
+ })
278
+ }
package/lib/pricing.js CHANGED
@@ -109,6 +109,7 @@ export const DEFAULT_PEAK_WINDOWS = [
109
109
  export const DEFAULT_PROVIDER_PRICE_TABLE = {
110
110
  openai: {
111
111
  models: {
112
+ 'gpt-6-astra': { input: 10, cachedInput: 1, cacheWrite: 12.5, output: 50, longContext: { aboveInputTokens: 272000, cacheMiss: 20, cacheHit: 2, cacheWrite: 25, output: 75 }, billingMode: 'flat', sourceUrl: 'https://developers.openai.com/api/docs/models/gpt-6-astra', checkedAt: '2026-09-11', notes: '标准 API 价,OpenCode Zen 同价;完整输入(含缓存读写)超过 272K 时整次请求按长上下文档计费;不含 Batch/Flex/Fast 服务档折扣或倍率' },
112
113
  'gpt-5.6-sol': { input: 2, cachedInput: 0.2, output: 10, billingMode: 'flat', sourceUrl: 'https://opencode.ai/docs/zen', checkedAt: '2026-08-25', notes: '≤272K 档;超过 272K 按 $4/$15 计(缓存读 $0.40、写入 $5);缓存写入 $2.50;目录标注 2026-09-18 前为五折促销价(issue #58)' },
113
114
  'gpt-5.6-terra': { input: 2, cachedInput: 0.2, output: 12, billingMode: 'flat', sourceUrl: 'https://opencode.ai/docs/zen', checkedAt: '2026-08-17', notes: '≤272K 档;超过 272K 按 $4/$18 计;缓存写入 $2.50' },
114
115
  'gpt-5.6-luna': { input: 0.2, cachedInput: 0.02, output: 1.2, billingMode: 'flat', sourceUrl: 'https://opencode.ai/docs/zen', checkedAt: '2026-08-17', notes: '≤272K 档;超过 272K 按 $0.40/$1.80 计;缓存写入 $0.25' },
@@ -285,6 +286,7 @@ export const DEFAULT_PROVIDER_PRICE_TABLE = {
285
286
  export const PROVIDER_MODEL_FAMILIES = {
286
287
  deepseek: { 'deepseek-v4-flash': 'DeepSeek v4', 'deepseek-v4-pro': 'DeepSeek v4', 'deepseek-v4-flash-vision-exp': 'DeepSeek v4', 'deepseek-v4.1-flash': 'DeepSeek v4.1' },
287
288
  openai: {
289
+ 'gpt-6-astra': 'GPT-6 Astra',
288
290
  'gpt-5.6-sol': 'GPT-5.6', 'gpt-5.6-terra': 'GPT-5.6', 'gpt-5.6-luna': 'GPT-5.6',
289
291
  'gpt-5.5': 'GPT-5.5', 'gpt-5.5-pro': 'GPT-5.5',
290
292
  'gpt-5.4': 'GPT-5.4', 'gpt-5.4-pro': 'GPT-5.4', 'gpt-5.4-mini': 'GPT-5.4', 'gpt-5.4-nano': 'GPT-5.4',
@@ -542,7 +544,8 @@ function completeTier(raw) {
542
544
  const cacheHit = n('cacheHit') ?? n('cachedInput') ?? n('cacheRead') ?? cacheMiss
543
545
  const output = n('output') ?? 0
544
546
  const reasoning = n('reasoning')
545
- return reasoning === undefined ? { cacheHit, cacheMiss, output } : { cacheHit, cacheMiss, output, reasoning }
547
+ const cacheWrite = n('cacheWrite')
548
+ return { cacheHit, cacheMiss, output, ...(reasoning === undefined ? {} : { reasoning }), ...(cacheWrite === undefined ? {} : { cacheWrite }) }
546
549
  }
547
550
 
548
551
  /**
@@ -559,6 +562,16 @@ export function normalizePrice(value) {
559
562
  }
560
563
  if (!('cacheHit' in value) && !('cacheMiss' in value) && !('output' in value) && !('input' in value)) return null
561
564
  const entry = completeTier(value)
565
+ const validRate = n => typeof n === 'number' && Number.isFinite(n) && n >= 0
566
+ if (value.cacheWrite !== undefined && !validRate(value.cacheWrite)) return null
567
+ if (value.longContext !== undefined) {
568
+ const long = value.longContext
569
+ if (!long || typeof long !== 'object' || Array.isArray(long)
570
+ || !validRate(long.aboveInputTokens) || long.aboveInputTokens === 0
571
+ || !['cacheMiss', 'cacheHit', 'output'].every(key => validRate(long[key]))
572
+ || (long.cacheWrite !== undefined && !validRate(long.cacheWrite))) return null
573
+ entry.longContext = { aboveInputTokens: long.aboveInputTokens, ...completeTier(long) }
574
+ }
562
575
  if (value.legacy === true) entry.legacy = true
563
576
  if (value.billingMode === 'flat' || value.billingMode === 'deepseek-peak' || value.billingMode === 'batch') entry.billingMode = value.billingMode
564
577
  for (const key of ['sourceUrl', 'checkedAt', 'notes']) if (typeof value[key] === 'string') entry[key] = value[key]
@@ -594,6 +607,7 @@ export function normalizePrice(value) {
594
607
  /** 全部价格为 0 的记录视为空记录。 */
595
608
  export function isZeroPrice(entry) {
596
609
  return entry !== null && entry.cacheHit === 0 && entry.cacheMiss === 0 && entry.output === 0
610
+ && !(entry.cacheWrite > 0) && (!entry.longContext || isZeroPrice(entry.longContext))
597
611
  }
598
612
 
599
613
  /**
@@ -1021,7 +1035,7 @@ export function peakPhaseAt(atMs, windows) {
1021
1035
 
1022
1036
  /**
1023
1037
  * 为一次用量挑选价格档位:生效后峰时段 → peak;生效后谷时段 → offPeak;
1024
- * 生效前(或禁用峰谷)→ 基础价格。cache write 与 cache hit 同价。
1038
+ * 生效前(或禁用峰谷)→ 基础价格。保留显式缓存写价和单次输入长度档位。
1025
1039
  * @param entry - 模型价格记录。
1026
1040
  * @param atMs - 计费时刻。
1027
1041
  * @param peak - { enabled, effectiveAtMs, windows } 峰谷配置。
@@ -1029,9 +1043,10 @@ export function peakPhaseAt(atMs, windows) {
1029
1043
  */
1030
1044
  export function tierFor(entry, atMs, peak) {
1031
1045
  const base = priceAt(entry, atMs) ?? { cacheHit: 0, cacheMiss: 0, output: 0 }
1032
- const asTier = price => price.reasoning === undefined
1033
- ? { cacheHit: price.cacheHit, cacheMiss: price.cacheMiss, output: price.output }
1034
- : { cacheHit: price.cacheHit, cacheMiss: price.cacheMiss, output: price.output, reasoning: price.reasoning }
1046
+ const asTier = price => ({ cacheHit: price.cacheHit, cacheMiss: price.cacheMiss, output: price.output,
1047
+ ...(price.reasoning === undefined ? {} : { reasoning: price.reasoning }),
1048
+ ...(price.cacheWrite === undefined ? {} : { cacheWrite: price.cacheWrite }),
1049
+ ...(price.longContext === undefined ? {} : { longContext: price.longContext }) })
1035
1050
  // 峰谷时代之前(2026-08-16 16:00 UTC 前):按当时的基础价计费(历史正确性)。
1036
1051
  if (Number.isFinite(atMs) && atMs < Date.parse(LEGACY_BASE_BOUNDARY)) {
1037
1052
  const lb = base.legacyBase
@@ -1062,16 +1077,20 @@ export function tierFor(entry, atMs, peak) {
1062
1077
  * @returns 美元成本(非负)。
1063
1078
  */
1064
1079
  export function costOf(tokens, entry, atMs, peak) {
1065
- const tier = tierFor(entry, atMs, peak)
1080
+ let tier = tierFor(entry, atMs, peak)
1066
1081
  const input = Math.max(0, Number(tokens?.input) || 0)
1067
1082
  const output = Math.max(0, Number(tokens?.output) || 0)
1068
1083
  const cacheRead = Math.max(0, Number(tokens?.cacheRead) || 0)
1069
1084
  const cacheWrite = Math.max(0, Number(tokens?.cacheWrite) || 0)
1070
1085
  const reasoning = Math.max(0, Number(tokens?.reasoning) || 0)
1086
+ // 宿主的 input/cacheRead/cacheWrite 是互斥桶;输入长度包含缓存,输出不参与阈值。
1087
+ // 此处必须传单次调用,日/会话聚合无法还原每次请求的上下文档位。
1088
+ if (tier.longContext && input + cacheRead + cacheWrite > tier.longContext.aboveInputTokens) tier = tier.longContext
1071
1089
  const reasoningPrice = typeof tier.reasoning === 'number' ? tier.reasoning : 0
1072
1090
  const cost = (input * tier.cacheMiss
1073
1091
  + output * tier.output
1074
- + (cacheRead + cacheWrite) * tier.cacheHit
1092
+ + cacheRead * tier.cacheHit
1093
+ + cacheWrite * (tier.cacheWrite ?? tier.cacheHit)
1075
1094
  + reasoning * reasoningPrice) / 1_000_000
1076
1095
  // 终值防护:档位字段缺失/非法导致的 NaN/Infinity 与负值一律按 0 入账。
1077
1096
  return Number.isFinite(cost) && cost > 0 ? cost : 0
@@ -0,0 +1,117 @@
1
+ /**
2
+ * 会话费用展示聚合。父子会话仍分别入账;这里只读取账本,不把子代理费用再次写入父账。
3
+ * 宿主的 parentSession 也用于普通 fork,只有 origin=subagent 的连续链才属于子代理。
4
+ */
5
+
6
+ const FIELDS = ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning', 'calls', 'cost', 'apiCost']
7
+ const amount = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
8
+ const validId = value => typeof value === 'string' && value.length > 0 && value.length <= 512
9
+ const emptyBuckets = () => Object.fromEntries(FIELDS.map(key => [key, 0]))
10
+
11
+ function empty(id) {
12
+ return { id, ...emptyBuckets(), byProviderModel: {} }
13
+ }
14
+
15
+ function add(target, source) {
16
+ for (const key of FIELDS) target[key] += amount(key === 'apiCost' ? (source.apiCost ?? source.cost) : source[key])
17
+ }
18
+
19
+ function addSession(target, source) {
20
+ add(target, source)
21
+ for (const [key, bucket] of Object.entries(source.byProviderModel ?? {})) {
22
+ if (bucket === null || typeof bucket !== 'object' || Array.isArray(bucket)) continue
23
+ // defineProperty 避免来源中的 __proto__ 等键改写普通对象原型。
24
+ if (!Object.hasOwn(target.byProviderModel, key)) {
25
+ Object.defineProperty(target.byProviderModel, key, { value: emptyBuckets(), enumerable: true })
26
+ }
27
+ add(target.byProviderModel[key], bucket)
28
+ }
29
+ }
30
+
31
+ function cleanHeader(value) {
32
+ if (value === null || typeof value !== 'object' || !validId(value.id)) return null
33
+ return { id: value.id, ...(value.origin === 'subagent' && validId(value.parentSession)
34
+ ? { origin: 'subagent', parentSession: value.parentSession } : {}) }
35
+ }
36
+
37
+ /** 只取宿主公开目录的身份字段;不读取会话正文、工具结果或请求凭据。 */
38
+ export async function readSessionHeaders(ctx) {
39
+ const headers = new Map()
40
+ const accept = records => {
41
+ if (!Array.isArray(records)) return
42
+ for (const record of records) {
43
+ const header = cleanHeader(record?.header ?? record)
44
+ if (header !== null) headers.set(header.id, header)
45
+ }
46
+ }
47
+ const get = name => { try { return ctx?.get?.(name) } catch { return undefined } }
48
+ let listed = false
49
+ const query = get('sessionQuery')
50
+ if (typeof query?.listSessions === 'function') {
51
+ try {
52
+ const records = await query.listSessions()
53
+ if (Array.isArray(records)) { accept(records); listed = true }
54
+ } catch { /* 单次目录故障不影响主会话费用;尝试旧版持久化接口。 */ }
55
+ }
56
+ if (!listed) {
57
+ const persistence = get('sessionPersistence')
58
+ if (typeof persistence?.list === 'function') {
59
+ try { accept(await persistence.list()) } catch { /* 无持久化目录时仍可使用 live 会话。 */ }
60
+ }
61
+ }
62
+ const sessions = get('sessions') ?? ctx?.sessions
63
+ if (typeof sessions?.list === 'function') {
64
+ try { accept(sessions.list()) } catch { /* 兼容没有会话目录服务的旧宿主。 */ }
65
+ }
66
+ return [...headers.values()]
67
+ }
68
+
69
+ /** 返回确定属于目标会话的连续子代理后代;普通 fork、断链、自引用与环均不归并。 */
70
+ export function subagentIds(sessionId, headers) {
71
+ const byId = new Map()
72
+ for (const raw of Array.isArray(headers) ? headers : []) {
73
+ const header = cleanHeader(raw?.header ?? raw)
74
+ if (header !== null) byId.set(header.id, header)
75
+ }
76
+ const descendants = new Set()
77
+ for (const candidate of byId.values()) {
78
+ if (candidate.id === sessionId || candidate.origin !== 'subagent') continue
79
+ const seen = new Set([candidate.id])
80
+ let current = candidate, belongs = false, cyclic = false
81
+ while (current?.origin === 'subagent' && validId(current.parentSession)) {
82
+ const parent = current.parentSession
83
+ if (seen.has(parent)) { cyclic = true; break }
84
+ seen.add(parent)
85
+ if (parent === sessionId) belongs = true
86
+ current = byId.get(parent)
87
+ }
88
+ if (belongs && !cyclic) descendants.add(candidate.id)
89
+ }
90
+ return descendants
91
+ }
92
+
93
+ /** own 与 subagents 分开返回,让客户端先选定主会话的完整费用口径后只加一次后代。 */
94
+ export function aggregateSessionCost(days, sessionId, headers = [], includeSubagents = false) {
95
+ if (!validId(sessionId)) throw new Error('invalid session id')
96
+ const descendants = includeSubagents ? subagentIds(sessionId, headers) : new Set()
97
+ const own = empty(sessionId), subagents = empty(sessionId)
98
+ let found = false
99
+ const counted = new Set()
100
+ for (const day of Object.values(days ?? {})) {
101
+ if (!Array.isArray(day?.sessions)) continue
102
+ for (const row of day.sessions) {
103
+ if (row === null || typeof row !== 'object') continue
104
+ if (row.id === sessionId) { addSession(own, row); found = true }
105
+ else if (descendants.has(row.id)) { addSession(subagents, row); counted.add(row.id) }
106
+ }
107
+ }
108
+ return { own, subagents, subagentCount: counted.size, found }
109
+ }
110
+
111
+ /** 按需读取一个会话,避免 getState 广播全部历史会话明细。 */
112
+ export async function getSessionCost(ledger, ctx, sessionId) {
113
+ if (!validId(sessionId)) throw new Error('invalid session id')
114
+ const include = ledger.config?.includeSubagentCost === true
115
+ const headers = include ? await readSessionHeaders(ctx) : []
116
+ return aggregateSessionCost(ledger.days, sessionId, headers, include)
117
+ }