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/README.en.md +8 -5
- package/README.md +8 -5
- package/docs/provider-pricing.json +18 -1
- package/lib/backfill.js +12 -5
- package/lib/client.js +5 -6
- package/lib/index.js +31 -5
- package/lib/native-search-billing.js +278 -0
- package/lib/pricing.js +26 -7
- package/lib/session-tree.js +117 -0
- package/lib/store.js +15 -2
- package/lib/typert.host.js +41 -1
- package/package.json +1 -1
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
|
-
|
|
381
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
* 生效前(或禁用峰谷)→
|
|
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.
|
|
1033
|
-
? {
|
|
1034
|
-
|
|
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
|
-
|
|
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
|
-
+
|
|
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
|
+
}
|