dsh-cost-meter 1.5.5

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 ADDED
@@ -0,0 +1,975 @@
1
+ /**
2
+ * dsh-cost-meter 宿主插件。
3
+ *
4
+ * 单一 Loader 行(见 cordis.patch.yml)挂载本模块,职责:
5
+ * 1. 打开/维护账本($DSH_HOME/storages/cost-meter/ledger.json);
6
+ * 2. 包裹 `llm/stream` 瀑布,捕获每次模型调用的 usage 块并按官方价格计费;
7
+ * 3. 注册 `costUsage` 会话投影(纯 token 桶 + 按模型拆分,客户端按价表计价);
8
+ * 4. 提供 `costMeter` 服务(手写 typertRemote 绑定,配合 ./typert 清单走
9
+ * Typert 网关),客户端经 `remote.costMeter.*` 读写状态与配置。
10
+ *
11
+ * 不导入 cordis/dsh-* 运行时包中的 Service/Context 类:仅用 ctx API 与 Node
12
+ * 内建能力,因此与宿主进程共享同一套运行时实例;dsh-credentials 只用于
13
+ * 余额查询的凭证引用构造(credentialRef 为纯函数,无跨实例状态)。
14
+ */
15
+
16
+ import { z } from 'zod'
17
+ import fs from 'node:fs'
18
+ import { join } from 'node:path'
19
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
20
+ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
21
+ import { Ledger, applyConfigPatch, localDayKey, reconcileBalanceDelta } from './store.js'
22
+ import { backfillLegacyLedger } from './backfill.js'
23
+ import { OFFICIAL_PRICING_URL, normalizePrice, parsePricingHtml, costOf, priceEntryFor, providerPriceEntryFor, buildPriceCatalog } from './pricing.js'
24
+ import { CODING_PLAN_PROVIDERS, CODING_PLAN_PROVIDER_IDS, queryCodingPlan, emptyCustomBalance, queryCustomBalance } from './coding-plans.js'
25
+ import { stateSchema } from './typert.host.js'
26
+
27
+ export const name = 'cost-meter'
28
+
29
+ // ── 多语言(中/英) ─────────────────────────────────────────────────────────
30
+
31
+ /** 服务端用户可见文案(zh/en)。 */
32
+ const SERVER_MESSAGES = {
33
+ zh: {
34
+ apiKeyMissing: '未配置 DeepSeek API Key(请在 设置→模型 中配置,或导出 {env} 环境变量)',
35
+ balanceHttp: '余额接口 HTTP {code}',
36
+ balanceNoInfos: '余额接口响应缺少 balance_infos',
37
+ balanceEndpointNotOfficial: '余额查询仅支持官方端点(api.deepseek.com):当前配置的 baseURL {url} 不是官方域名,为保护 API Key 已拒绝发起请求',
38
+ pageTooShort: '页面内容过短,可能被网关拦截',
39
+ noModelsParsed: '官方页面中未解析出任何模型价格,页面结构可能已变化,请稍后重试或手动编辑价格',
40
+ configRejected: '配置更新被拒绝:{errors}',
41
+ balanceDisplayOff: '余额显示已关闭,请先在 显示设置 中开启',
42
+ balanceRefreshed: '余额已刷新',
43
+ balanceQueryFailed: '余额查询失败:{message}',
44
+ reconcileWarn: '对账提示:本地账本今日合计 {cost} 与官方余额当日变动 {delta} 偏差较大,请核对价格表或近期账单',
45
+ goQuotaKeyMissing: '未找到 OpenCode Go API Key。有 Go 订阅的话:运行 opencode login、导出 OPENCODE_GO_API_KEY 环境变量,或在显示设置中填写 Key;没有订阅可关闭上方「启用」开关。',
46
+ goQuotaHttp: 'OpenCode Go 额度接口 HTTP {code}',
47
+ goQuotaNoSub: '没有检测到生效的 OpenCode Go 订阅(接口返回 {code}),或 API Key 无效。没有订阅可关闭上方「启用」开关。',
48
+ goQuotaNoUsage: 'OpenCode Go 额度响应缺少 usage 字段',
49
+ goQuotaDisabled: 'OpenCode Go 额度未启用,请先在 费用设置 中开启',
50
+ goQuotaDisplayOff: 'OpenCode Go 额度显示已关闭,请先在 显示设置 中开启',
51
+ goQuotaRefreshed: 'OpenCode Go 额度已刷新',
52
+ goQuotaQueryFailed: 'OpenCode Go 额度查询失败:{message}',
53
+ customBalanceDisabled: '自定义 Provider 余额未启用',
54
+ customBalanceDisplayOff: '自定义 Provider 余额显示已关闭',
55
+ customBalanceRefreshed: '自定义 Provider 余额已刷新',
56
+ customBalanceQueryFailed: '自定义 Provider 余额查询失败:{message}',
57
+ pricesSynced: '已从官方文档同步 {ids} 的价格',
58
+ priceSyncFailed: '官方价格同步失败:{error}',
59
+ codingPlanKeyMissing: '未找到 {provider} 的凭据。请在下方填写 API Key,或配置对应环境变量/CLI 登录态;没有订阅可关闭该家的「启用」开关。',
60
+ codingPlanUnauthorized: '{provider} 凭据无效或没有生效的订阅(接口返回 {code})。没有订阅可关闭该家的「启用」开关。',
61
+ codingPlanHttp: '{provider} 额度接口 HTTP {code}({url})',
62
+ codingPlanNoUsage: '{provider} 额度响应中未解析出用量窗口,接口结构可能已变化',
63
+ codingPlanUnknown: '未知的 coding plan 提供商:{provider}',
64
+ codingPlanDisplayOff: '{provider} 额度显示已关闭,请先在面板中开启',
65
+ codingPlanDisabled: '{provider} 额度未启用,请先在面板中开启',
66
+ codingPlanRefreshed: '{provider} 额度已刷新',
67
+ codingPlanQueryFailed: '{provider} 额度查询失败:{message}',
68
+ },
69
+ en: {
70
+ apiKeyMissing: 'DeepSeek API key not configured (configure it in Settings → Models, or export the {env} environment variable)',
71
+ balanceHttp: 'Balance API returned HTTP {code}',
72
+ balanceNoInfos: 'Balance API response is missing balance_infos',
73
+ balanceEndpointNotOfficial: 'Balance lookup only supports the official endpoint (api.deepseek.com): the configured baseURL {url} is not an official host, so the API key will not be sent there',
74
+ pageTooShort: 'Page content too short; the request may have been blocked by the gateway',
75
+ noModelsParsed: 'No model prices could be parsed from the official page; the page structure may have changed — try again later or edit the price table manually.',
76
+ configRejected: 'Config update rejected: {errors}',
77
+ balanceDisplayOff: 'Balance display is off; enable it in Display settings first',
78
+ balanceRefreshed: 'Balance refreshed',
79
+ balanceQueryFailed: 'Balance query failed: {message}',
80
+ reconcileWarn: 'Reconciliation notice: today\'s local ledger cost ({cost}) deviates significantly from the official balance change ({delta}); please check the price table or recent bills',
81
+ goQuotaKeyMissing: 'OpenCode Go API key not found. If you have a Go subscription: run opencode login, export OPENCODE_GO_API_KEY, or set the key in Display settings; otherwise turn off the Enable switch above.',
82
+ goQuotaHttp: 'OpenCode Go quota API returned HTTP {code}',
83
+ goQuotaNoSub: 'No active OpenCode Go subscription detected (API returned {code}), or the API key is invalid. Turn off the Enable switch above if you have no subscription.',
84
+ goQuotaNoUsage: 'OpenCode Go quota response is missing the usage field',
85
+ goQuotaDisabled: 'OpenCode Go quota is disabled; enable it in the Cost settings first',
86
+ goQuotaDisplayOff: 'OpenCode Go quota display is off; enable it in Display settings first',
87
+ goQuotaRefreshed: 'OpenCode Go quota refreshed',
88
+ goQuotaQueryFailed: 'OpenCode Go quota query failed: {message}',
89
+ customBalanceDisabled: 'Custom provider balance is disabled',
90
+ customBalanceDisplayOff: 'Custom provider balance display is off',
91
+ customBalanceRefreshed: 'Custom provider balance refreshed',
92
+ customBalanceQueryFailed: 'Custom provider balance query failed: {message}',
93
+ pricesSynced: 'Synced prices for {ids} from the official docs',
94
+ priceSyncFailed: 'Official price sync failed: {error}',
95
+ codingPlanKeyMissing: 'No credentials found for {provider}. Enter the API key below, or configure the matching environment variable / CLI login; turn off the Enable switch if you have no subscription.',
96
+ codingPlanUnauthorized: '{provider} credentials are invalid or no active subscription was detected (API returned {code}). Turn off the Enable switch if you have no subscription.',
97
+ codingPlanHttp: '{provider} quota API returned HTTP {code} ({url})',
98
+ codingPlanNoUsage: 'No usage windows could be parsed from the {provider} quota response; the API shape may have changed',
99
+ codingPlanUnknown: 'Unknown coding plan provider: {provider}',
100
+ codingPlanDisplayOff: '{provider} quota display is off; enable it in the panel first',
101
+ codingPlanDisabled: '{provider} quota is disabled; enable it in the panel first',
102
+ codingPlanRefreshed: '{provider} quota refreshed',
103
+ codingPlanQueryFailed: '{provider} quota query failed: {message}',
104
+ },
105
+ }
106
+
107
+ /** 取服务端文案(zh/en),支持 {var} 插值。 */
108
+ function tmsg(locale, code, vars) {
109
+ const dict = locale === 'en' ? SERVER_MESSAGES.en : SERVER_MESSAGES.zh
110
+ let text = dict[code] ?? code
111
+ if (vars) for (const key of Object.keys(vars)) text = text.split(`{${key}}`).join(String(vars[key]))
112
+ return text
113
+ }
114
+
115
+ /** 从配置解析消息语言:'en' → en;auto/zh → zh(服务端无法探测浏览器)。 */
116
+ function localeOf(config) {
117
+ return config?.locale === 'en' ? 'en' : 'zh'
118
+ }
119
+
120
+ // ── costUsage 会话投影 ─────────────────────────────────────────────────────
121
+
122
+ const usageProjectionSchema = z.object({
123
+ input: z.number(),
124
+ output: z.number(),
125
+ cacheRead: z.number(),
126
+ cacheWrite: z.number(),
127
+ reasoning: z.number(),
128
+ cost: z.number(),
129
+ byModel: z.record(z.string(), z.object({
130
+ input: z.number(),
131
+ output: z.number(),
132
+ cacheRead: z.number(),
133
+ cacheWrite: z.number(),
134
+ reasoning: z.number().optional(),
135
+ cost: z.number(),
136
+ })),
137
+ byProviderModel: z.record(z.string(), z.object({
138
+ input: z.number(),
139
+ output: z.number(),
140
+ cacheRead: z.number(),
141
+ cacheWrite: z.number(),
142
+ reasoning: z.number(),
143
+ cost: z.number(),
144
+ })).optional(),
145
+ })
146
+
147
+ /**
148
+ * costUsage 会话投影工厂:闭包账本,按事件时刻(event.time)用当时的价格档位
149
+ * 逐次计费(峰谷时代前按 legacyBase,之后按峰谷两档),保证会话徽章历史正确。
150
+ */
151
+ function makeCostUsageProjection(ledger) {
152
+ const zeroBuckets = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, cost: 0 })
153
+ const peakConfig = () => ({
154
+ enabled: ledger.config?.peakEnabled === true,
155
+ effectiveAtMs: Date.parse(ledger.config?.peakEffectiveAt ?? ''),
156
+ windows: ledger.config?.peakWindows,
157
+ })
158
+ return {
159
+ key: 'costUsage',
160
+ schema: usageProjectionSchema,
161
+ stateVersion: 3,
162
+ init: () => ({ provider: 'deepseek', model: 'default', totals: zeroBuckets(), byModel: {}, byProviderModel: {}, last: null }),
163
+ apply(state, event) {
164
+ if (event.type === 'request/header') {
165
+ const model = event.data?.header?.config?.model
166
+ const provider = event.data?.header?.config?.provider
167
+ const nextModel = typeof model === 'string' && model.length > 0 ? model : 'default'
168
+ const nextProvider = typeof provider === 'string' && provider.length > 0 ? provider : 'deepseek'
169
+ return nextModel === state.model && nextProvider === state.provider ? state : { ...state, model: nextModel, provider: nextProvider }
170
+ }
171
+ let usage = null
172
+ let turn = 0
173
+ let step = 0
174
+ if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'usage' && event.data.chunk.usage !== undefined) {
175
+ usage = event.data.chunk.usage
176
+ turn = event.data.turn
177
+ step = event.data.step
178
+ } else if (event.type === 'assistant/message' && event.data?.usage !== undefined) {
179
+ usage = event.data.usage
180
+ turn = event.data.turn
181
+ step = event.data.step
182
+ } else {
183
+ return state
184
+ }
185
+ const buckets = {
186
+ input: usage.inputTokens ?? 0,
187
+ output: usage.outputTokens ?? 0,
188
+ cacheRead: usage.cacheReadTokens ?? 0,
189
+ cacheWrite: usage.cacheWriteTokens ?? 0,
190
+ reasoning: usage.reasoningTokens ?? 0,
191
+ }
192
+ const key = `${turn}:${step}`
193
+ const prev = state.last !== null && state.last.key === key ? state.last : null
194
+ if (prev !== null && prev.provider === state.provider && prev.model === state.model
195
+ && prev.buckets.input === buckets.input && prev.buckets.output === buckets.output
196
+ && prev.buckets.cacheRead === buckets.cacheRead && prev.buckets.cacheWrite === buckets.cacheWrite
197
+ && prev.buckets.reasoning === buckets.reasoning) {
198
+ return state
199
+ }
200
+ // 按事件时刻计费(历史正确):峰谷时代前用 legacyBase,之后按峰谷两档。
201
+ const atMs = Number.isFinite(Number(event.time)) && Number(event.time) > 0 ? Number(event.time) : Date.now()
202
+ const resolved = providerPriceEntryFor(state.provider, state.model, ledger.config?.prices, {
203
+ mode: ledger.config?.priceMatch === 'exact' ? 'exact' : 'auto',
204
+ overrides: ledger.config?.priceOverrides,
205
+ })
206
+ const peak = peakConfig()
207
+ peak.enabled = resolved.billingMode === 'deepseek-peak' && peak.enabled
208
+ const billed = resolved.priced ? costOf(buckets, resolved.entry, atMs, peak) : 0
209
+ // 同一 (turn, step) 的最终样本替换流式样本,先减后加,避免重复计数。
210
+ const totals = { ...state.totals, reasoning: state.totals.reasoning ?? 0 }
211
+ const byModel = { ...state.byModel }
212
+ const byProviderModel = { ...(state.byProviderModel ?? {}) }
213
+ const shift = (provider, model, bucket, cost, sign) => {
214
+ totals.input += sign * bucket.input
215
+ totals.output += sign * bucket.output
216
+ totals.cacheRead += sign * bucket.cacheRead
217
+ totals.cacheWrite += sign * bucket.cacheWrite
218
+ totals.reasoning += sign * bucket.reasoning
219
+ totals.cost += sign * cost
220
+ const current = byModel[model] ?? zeroBuckets()
221
+ byModel[model] = {
222
+ input: current.input + sign * bucket.input,
223
+ output: current.output + sign * bucket.output,
224
+ cacheRead: current.cacheRead + sign * bucket.cacheRead,
225
+ cacheWrite: current.cacheWrite + sign * bucket.cacheWrite,
226
+ reasoning: (current.reasoning ?? 0) + sign * bucket.reasoning,
227
+ cost: current.cost + sign * cost,
228
+ }
229
+ const providerKey = `${provider}:${model}`
230
+ const providerCurrent = byProviderModel[providerKey] ?? zeroBuckets()
231
+ byProviderModel[providerKey] = {
232
+ input: providerCurrent.input + sign * bucket.input,
233
+ output: providerCurrent.output + sign * bucket.output,
234
+ cacheRead: providerCurrent.cacheRead + sign * bucket.cacheRead,
235
+ cacheWrite: providerCurrent.cacheWrite + sign * bucket.cacheWrite,
236
+ reasoning: providerCurrent.reasoning + sign * bucket.reasoning,
237
+ cost: providerCurrent.cost + sign * cost,
238
+ }
239
+ }
240
+ if (prev !== null) shift(prev.provider, prev.model, prev.buckets, prev.cost, -1)
241
+ shift(state.provider, state.model, buckets, billed, 1)
242
+ return { provider: state.provider, model: state.model, totals, byModel, byProviderModel, last: { key, provider: state.provider, model: state.model, buckets, cost: billed } }
243
+ },
244
+ view(state) {
245
+ return {
246
+ input: state.totals.input,
247
+ output: state.totals.output,
248
+ cacheRead: state.totals.cacheRead,
249
+ cacheWrite: state.totals.cacheWrite,
250
+ reasoning: state.totals.reasoning,
251
+ cost: state.totals.cost,
252
+ byModel: state.byModel,
253
+ byProviderModel: state.byProviderModel,
254
+ }
255
+ },
256
+ }
257
+ }
258
+
259
+ // ── 服务 ───────────────────────────────────────────────────────────────────
260
+
261
+ /** 余额占位(未开启显示或查询失败时的空值)。 */
262
+ function emptyBalance() {
263
+ return { status: 'off', message: '', fetchedAt: 0, currency: '', totalBalance: 0, grantedBalance: 0, toppedUpBalance: 0 }
264
+ }
265
+
266
+ /** OpenCode Go 订阅额度端点(官方固定域名)。 */
267
+ const GO_QUOTA_URL = 'https://opencode.ai/zen/go/v1/usage'
268
+
269
+ /** OpenCode Go 额度占位(未开启显示或查询失败时的空值)。 */
270
+ function emptyGoQuota() {
271
+ return { status: 'off', message: '', fetchedAt: 0, rolling: null, weekly: null, monthly: null }
272
+ }
273
+
274
+ /** 从 opencode auth.json 自动发现 opencode-go 的 API Key(与 opencode CLI 共用登录态)。 */
275
+ function findGoKeyInAuthJson() {
276
+ const home = process.env.USERPROFILE || process.env.HOME || ''
277
+ const candidates = [
278
+ home ? `${home}/.local/share/opencode/auth.json` : '',
279
+ process.env.XDG_CONFIG_HOME ? `${process.env.XDG_CONFIG_HOME}/opencode/auth.json` : '',
280
+ home ? `${home}/.config/opencode/auth.json` : '',
281
+ ].filter(Boolean)
282
+ for (const path of candidates) {
283
+ try {
284
+ const data = JSON.parse(fs.readFileSync(path, 'utf8'))
285
+ const key = data?.['opencode-go']?.key
286
+ if (typeof key === 'string' && key.length > 0) return key
287
+ } catch {
288
+ // 文件不存在或不可读:继续尝试下一个位置。
289
+ }
290
+ }
291
+ return null
292
+ }
293
+
294
+ /**
295
+ * 解析 OpenCode Go API Key(与余额路径 queryBalance 同一套优先级):
296
+ * 显式配置 → DSH 凭据库(OPENCODE_GO_API_KEY)→ 环境变量 OPENCODE_GO_API_KEY
297
+ * → 兼容旧名环境变量 OPENCODE_API_KEY → opencode auth.json 兜底。
298
+ * @param ctx - 宿主插件上下文(用于读取凭证服务)。
299
+ * @param config - 插件配置(goQuota.apiKey)。
300
+ */
301
+ async function resolveGoKey(ctx, config) {
302
+ const explicit = String(config?.goQuota?.apiKey ?? '').trim()
303
+ if (explicit.length > 0) return explicit
304
+ const credentials = ctx.get('credentials')
305
+ if (credentials !== undefined) {
306
+ try {
307
+ const hit = await credentials.resolve(credentialRef('OPENCODE_GO_API_KEY'))
308
+ if (typeof hit?.value === 'string' && hit.value.length > 0) return hit.value
309
+ } catch {
310
+ // 凭证解析失败时回退到环境变量。
311
+ }
312
+ }
313
+ for (const name of ['OPENCODE_GO_API_KEY', 'OPENCODE_API_KEY']) {
314
+ const value = String(process.env[name] ?? '').trim()
315
+ if (value.length > 0) return value
316
+ }
317
+ return findGoKeyInAuthJson()
318
+ }
319
+
320
+ /** 归一化单个额度窗口(percent + resetsAt)。 */
321
+ function normalizeGoWindow(raw) {
322
+ if (raw === null || typeof raw !== 'object') return null
323
+ const percent = Number(raw.percent)
324
+ if (!Number.isFinite(percent)) return null
325
+ return { percent, resetsAt: typeof raw.resetsAt === 'string' ? raw.resetsAt : '' }
326
+ }
327
+
328
+ /**
329
+ * 查询 OpenCode Go 订阅额度(GET {GO_QUOTA_URL})。
330
+ * 返回 rolling(滚动 5 小时)/ weekly(本周)/ monthly(本月) 三档用量百分比与重置时间。
331
+ * 凭证只发往官方域名 opencode.ai;Key 解析顺序见 resolveGoKey。
332
+ * 请求需携带浏览器 User-Agent,否则会被 opencode.ai 前置 Cloudflare 拦截(error 1010)。
333
+ * @param ctx - 宿主插件上下文(用于解析 DSH 凭据库中的 Key)。
334
+ * @param config - 插件配置(goQuota.apiKey / 消息语言)。
335
+ * @param locale - 消息语言(zh/en)。
336
+ */
337
+ async function queryGoQuota(ctx, config, locale) {
338
+ const key = await resolveGoKey(ctx, config)
339
+ if (key === null) {
340
+ const error = new Error(tmsg(locale, 'goQuotaKeyMissing'))
341
+ error.soft = true // 未登录/未配置 Key 属预期场景,面板以中性提示展示
342
+ throw error
343
+ }
344
+ const response = await fetch(GO_QUOTA_URL, {
345
+ headers: {
346
+ authorization: `Bearer ${key}`,
347
+ // 浏览器 UA:避免被 opencode.ai 前置 Cloudflare 以 error 1010 拦截。
348
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
349
+ },
350
+ signal: AbortSignal.timeout(15000),
351
+ })
352
+ if (!response.ok) {
353
+ if (response.status === 401 || response.status === 403) {
354
+ const error = new Error(tmsg(locale, 'goQuotaNoSub', { code: String(response.status) }))
355
+ error.soft = true // 无订阅/Key 无效属预期场景,面板以中性提示展示
356
+ throw error
357
+ }
358
+ throw new Error(tmsg(locale, 'goQuotaHttp', { code: String(response.status) }))
359
+ }
360
+ const data = await response.json()
361
+ const usage = data?.usage
362
+ if (usage === null || typeof usage !== 'object') throw new Error(tmsg(locale, 'goQuotaNoUsage'))
363
+ return {
364
+ rolling: normalizeGoWindow(usage.rolling),
365
+ weekly: normalizeGoWindow(usage.weekly),
366
+ monthly: normalizeGoWindow(usage.monthly),
367
+ }
368
+ }
369
+
370
+ /** Coding plan 额度占位(未启用/未查询/失败时的空值)。 */
371
+ function emptyCodingPlan() {
372
+ return { status: 'off', message: '', fetchedAt: 0, windows: {} }
373
+ }
374
+
375
+ /** 从 Claude Code 登录态文件自动发现 Anthropic OAuth access token。 */
376
+ function findAnthropicOAuthToken() {
377
+ const home = process.env.USERPROFILE || process.env.HOME || ''
378
+ if (home.length === 0) return null
379
+ try {
380
+ const data = JSON.parse(fs.readFileSync(`${home}/.claude/.credentials.json`, 'utf8'))
381
+ const token = data?.claudeAiOauth?.accessToken
382
+ if (typeof token === 'string' && token.length > 0) return token
383
+ } catch {
384
+ // 文件不存在或不可读:视为未登录 Claude Code。
385
+ }
386
+ return null
387
+ }
388
+
389
+ /**
390
+ * 解析单家 coding plan 凭据(与余额/Go 额度同一套优先级):
391
+ * 显式配置(codingPlans[id].apiKey)→ DSH 凭据库(各家环境变量名)→ 环境变量
392
+ * → CLI 登录态兜底(目前仅 Anthropic 的 ~/.claude/.credentials.json)。
393
+ * @param ctx - 宿主插件上下文。
394
+ * @param provider - anthropic | zai | minimax。
395
+ * @param config - 插件配置。
396
+ */
397
+ async function resolveCodingPlanKey(ctx, provider, config) {
398
+ const explicit = String(config?.codingPlans?.[provider]?.apiKey ?? '').trim()
399
+ if (explicit.length > 0) return explicit
400
+ const envs = CODING_PLAN_PROVIDERS[provider]?.credentialEnvs ?? []
401
+ const credentials = ctx.get('credentials')
402
+ if (credentials !== undefined) {
403
+ for (const name of envs) {
404
+ try {
405
+ const hit = await credentials.resolve(credentialRef(name))
406
+ if (typeof hit?.value === 'string' && hit.value.length > 0) return hit.value
407
+ } catch {
408
+ // 凭证解析失败时继续尝试下一个候选名。
409
+ }
410
+ }
411
+ }
412
+ for (const name of envs) {
413
+ const value = String(process.env[name] ?? '').trim()
414
+ if (value.length > 0) return value
415
+ }
416
+ if (provider === 'anthropic') return findAnthropicOAuthToken()
417
+ return null
418
+ }
419
+
420
+ /** 官方余额端点:仅允许官方域名(api.deepseek.com),防止 API Key 被发往非官方端点;非法端点返回 null。 */
421
+ function balanceEndpoint(baseURL) {
422
+ let base = String(baseURL ?? '').trim().replace(/\/+$/, '')
423
+ if (base.length === 0) base = String(process.env.DEEPSEEK_BASE_URL ?? '').trim().replace(/\/+$/, '')
424
+ if (base.length === 0) base = 'https://api.deepseek.com'
425
+ if (/\/v\d+$/i.test(base)) base = base.replace(/\/v\d+$/i, '')
426
+ let host = ''
427
+ try { host = new URL(base).host.toLowerCase() } catch { return null }
428
+ if (host !== 'api.deepseek.com') return null
429
+ return `${base}/user/balance`
430
+ }
431
+
432
+ /**
433
+ * 调用官方开放平台余额接口(GET {base}/user/balance)。
434
+ * 凭证与端点均取自 llm-deepseek 的设置段与凭证服务,与模型请求同一把 Key。
435
+ * @param ctx - 宿主插件上下文。
436
+ * @param locale - 消息语言(zh/en)。
437
+ * @returns { currency, totalBalance, grantedBalance, toppedUpBalance }。
438
+ */
439
+ async function queryBalance(ctx, locale) {
440
+ const settings = ctx.get('settings')
441
+ const section = typeof settings?.get === 'function' ? settings.get('llm-deepseek') : undefined
442
+ const baseURL = section?.baseURL
443
+ const apiKeyEnv = typeof section?.apiKeyEnv === 'string' && section.apiKeyEnv.length > 0
444
+ ? section.apiKeyEnv
445
+ : 'DEEPSEEK_API_KEY'
446
+ let apiKey = null
447
+ const credentials = ctx.get('credentials')
448
+ if (credentials !== undefined) {
449
+ try {
450
+ const hit = await credentials.resolve(credentialRef(apiKeyEnv))
451
+ if (hit?.value !== undefined && hit.value.length > 0) apiKey = hit.value
452
+ } catch {
453
+ // 凭证解析失败时回退到环境变量。
454
+ }
455
+ }
456
+ if (apiKey === null && typeof process.env[apiKeyEnv] === 'string') apiKey = process.env[apiKeyEnv]
457
+ if (apiKey === null || apiKey.length === 0) {
458
+ throw new Error(tmsg(locale, 'apiKeyMissing', { env: apiKeyEnv }))
459
+ }
460
+ const endpoint = balanceEndpoint(baseURL)
461
+ if (endpoint === null) {
462
+ throw new Error(tmsg(locale, 'balanceEndpointNotOfficial', { url: String(baseURL ?? '') }))
463
+ }
464
+ const response = await fetch(endpoint, {
465
+ headers: { authorization: `Bearer ${apiKey}` },
466
+ signal: AbortSignal.timeout(15000),
467
+ })
468
+ if (!response.ok) throw new Error(tmsg(locale, 'balanceHttp', { code: String(response.status) }))
469
+ const data = await response.json()
470
+ const info = Array.isArray(data?.balance_infos) ? data.balance_infos[0] : undefined
471
+ if (info === undefined) throw new Error(tmsg(locale, 'balanceNoInfos'))
472
+ const num = value => {
473
+ const parsed = Number(value)
474
+ return Number.isFinite(parsed) ? parsed : 0
475
+ }
476
+ return {
477
+ currency: typeof info.currency === 'string' ? info.currency : '',
478
+ totalBalance: num(info.total_balance),
479
+ grantedBalance: num(info.granted_balance),
480
+ toppedUpBalance: num(info.topped_up_balance),
481
+ }
482
+ }
483
+
484
+ /** 扩展价格表目录(内置只读;provider → family → model → 价格)。 */
485
+ const PRICE_CATALOG = buildPriceCatalog()
486
+
487
+ /** 组装对客户端的完整账本快照。 */
488
+ function buildState(ledger, balance = emptyBalance(), goQuota = emptyGoQuota(), codingPlans = {}, customBalance = emptyCustomBalance(), reconcile = { ok: true, message: '' }) {
489
+ const now = Date.now()
490
+ const dayKey = localDayKey(now)
491
+ const monthKey = dayKey.slice(0, 7)
492
+ // 预算已用金额(美元):按配置周期聚合;custom 区间左闭右闭,结束为空 = 今日。
493
+ const budget = ledger.config?.budget ?? {}
494
+ let budgetUsed
495
+ if (budget.period === 'day') budgetUsed = ledger.today().cost
496
+ else if (budget.period === 'all') budgetUsed = ledger.sumDays(undefined).cost
497
+ else if (budget.period === 'custom') {
498
+ const start = typeof budget.customStart === 'string' ? budget.customStart : null
499
+ const end = typeof budget.customEnd === 'string' && budget.customEnd.length > 0 ? budget.customEnd : dayKey
500
+ budgetUsed = start === null ? 0 : ledger.sumRange(start, end).cost
501
+ } else budgetUsed = ledger.sumDays(monthKey).cost
502
+ const state = {
503
+ today: ledger.today(),
504
+ month: ledger.sumDays(monthKey),
505
+ total: ledger.sumDays(undefined),
506
+ budgetUsed,
507
+ balance,
508
+ goQuota,
509
+ customBalance,
510
+ // 余额差交叉校验提示(issue #18):本地今日合计与官方余额当日变动偏差超阈时 ok=false。
511
+ reconcile,
512
+ codingPlans,
513
+ history: ledger.history(90),
514
+ config: ledger.config,
515
+ priceCatalog: PRICE_CATALOG,
516
+ meta: {
517
+ now,
518
+ timezoneOffsetMinutes: -new Date(now).getTimezoneOffset(),
519
+ dayKey,
520
+ monthKey,
521
+ },
522
+ }
523
+ // 可用性兑底:若快照与 strict codec 漂移(新增字段 schema 未同步等),
524
+ // 逐级降级(剔目录 → 空额度状态)重试,而不是让整个 getState 被拒导致「账本不可用」。
525
+ const check = stateSchema.safeParse(state)
526
+ if (check.success) return state
527
+ console.warn('[dsh-cost-meter] state 与 codec 漂移,尝试降级恢复可用性:', JSON.stringify(check.error.issues?.slice(0, 3) ?? check.error))
528
+ const attempts = [
529
+ { ...state, priceCatalog: undefined },
530
+ { ...state, priceCatalog: undefined, codingPlans: {} },
531
+ { ...state, priceCatalog: undefined, codingPlans: {}, balance: emptyBalance(), goQuota: emptyGoQuota(), customBalance: emptyCustomBalance() },
532
+ ]
533
+ for (const fallback of attempts) {
534
+ if (stateSchema.safeParse(fallback).success) return fallback
535
+ }
536
+ return state
537
+ }
538
+
539
+ /** 带超时抓取官方定价页。 */
540
+ async function fetchPricingHtml(locale) {
541
+ const response = await fetch(OFFICIAL_PRICING_URL, {
542
+ signal: AbortSignal.timeout(20000),
543
+ headers: { 'user-agent': 'dsh-cost-meter/0.4 (DeepSeek Harness plugin)' },
544
+ })
545
+ if (!response.ok) throw new Error(`HTTP ${String(response.status)}`)
546
+ const text = await response.text()
547
+ if (text.length < 500) throw new Error(tmsg(locale, 'pageTooShort'))
548
+ return text
549
+ }
550
+
551
+ /**
552
+ * 创建 costMeter 服务对象。手写 `typertRemote` 绑定(service/serviceKey/namespace)
553
+ * 满足 Typert 网关的 validateBinding 校验;方法按清单参数顺序位置调用。
554
+ * @param ctx - 宿主插件上下文。
555
+ * @param ledger - 账本。
556
+ * @returns 服务对象。
557
+ */
558
+ function createService(ctx, ledger) {
559
+ // 余额进程内缓存:display=off 时不清缓存但不下发;按 refreshMinutes 过期。
560
+ let balanceCache = { fetchedAt: 0, value: emptyBalance() }
561
+ // OpenCode Go 订阅额度进程内缓存(同上策略)。
562
+ let goQuotaCache = { fetchedAt: 0, value: emptyGoQuota() }
563
+ let customBalanceCache = { fetchedAt: 0, value: emptyCustomBalance() }
564
+ // Coding plan 额度进程内缓存(每家一个条目,同上策略)。
565
+ let codingPlanCaches = {}
566
+ // 余额差对账提示(drift 时 ok=false 携带文案,其余静默)。
567
+ let reconcileNotice = { ok: true, message: '' }
568
+
569
+ const balanceConfig = () => ledger.config?.balance ?? { display: 'both', refreshMinutes: 5 }
570
+ const goQuotaConfig = () => ledger.config?.goQuota ?? { enabled: true, display: 'both', refreshMinutes: 15, apiKey: '' }
571
+ const customBalanceConfig = () => ledger.config?.customBalance ?? { enabled: false, display: 'off', refreshMinutes: 15, label: '', request: { url: '' }, extract: {} }
572
+ const codingPlanConfigOf = id => ({
573
+ enabled: false,
574
+ display: 'settings',
575
+ refreshMinutes: 15,
576
+ apiKey: '',
577
+ ...(ledger.config?.codingPlans?.[id] ?? {}),
578
+ })
579
+
580
+ /** 按需刷新余额(过期或 force);失败落 error 状态,不影响其余状态字段。 */
581
+ const ensureBalance = async (force = false) => {
582
+ const config = balanceConfig()
583
+ if (config.display === 'off') {
584
+ balanceCache = { fetchedAt: Date.now(), value: emptyBalance() }
585
+ return
586
+ }
587
+ const interval = Math.max(1, Number(config.refreshMinutes) || 5) * 60_000
588
+ if (!force && Date.now() - balanceCache.fetchedAt < interval) return
589
+ if (balanceCache.inFlight !== undefined) {
590
+ await balanceCache.inFlight
591
+ return
592
+ }
593
+ const task = queryBalance(ctx, localeOf(ledger.config)).then(result => {
594
+ balanceCache = { fetchedAt: Date.now(), value: { status: 'ok', message: '', fetchedAt: Date.now(), ...result } }
595
+ // 余额差交叉校验(issue #18):官方余额当日变动 vs 本地账本今日合计,偏差超阈提示。
596
+ if ((ledger.config?.balance?.reconcile ?? true) === true && balanceCache.value.status === 'ok') {
597
+ const nowMs = Date.now()
598
+ const usd = v => '$' + Number(v).toFixed(4)
599
+ const { ref, event } = reconcileBalanceDelta(ledger.balanceRef, balanceCache.value, ledger.today().cost, localDayKey(nowMs), nowMs)
600
+ if (ref !== ledger.balanceRef) {
601
+ ledger.balanceRef = ref
602
+ ledger.scheduleWrite()
603
+ }
604
+ reconcileNotice = event !== null && event.kind === 'drift'
605
+ ? { ok: false, message: tmsg(localeOf(ledger.config), 'reconcileWarn', { cost: usd(event.todayCost), delta: usd(event.spent) }) }
606
+ : { ok: true, message: '' }
607
+ }
608
+ }, error => {
609
+ balanceCache = {
610
+ fetchedAt: Date.now(),
611
+ value: {
612
+ ...emptyBalance(),
613
+ status: 'error',
614
+ message: error instanceof Error ? error.message : String(error),
615
+ fetchedAt: Date.now(),
616
+ },
617
+ }
618
+ }).finally(() => {
619
+ if (balanceCache.inFlight === task) delete balanceCache.inFlight
620
+ })
621
+ balanceCache.inFlight = task
622
+ await task
623
+ }
624
+
625
+ /** 按需刷新 OpenCode Go 额度(过期或 force);未启用/显示关闭/失败均落空或 error 状态。 */
626
+ const ensureGoQuota = async (force = false) => {
627
+ const config = goQuotaConfig()
628
+ if (config.enabled === false || config.display === 'off') {
629
+ goQuotaCache = { fetchedAt: Date.now(), value: emptyGoQuota() }
630
+ return
631
+ }
632
+ const interval = Math.max(1, Number(config.refreshMinutes) || 15) * 60_000
633
+ if (!force && Date.now() - goQuotaCache.fetchedAt < interval) return
634
+ if (goQuotaCache.inFlight !== undefined) {
635
+ await goQuotaCache.inFlight
636
+ return
637
+ }
638
+ const task = queryGoQuota(ctx, ledger.config, localeOf(ledger.config)).then(result => {
639
+ goQuotaCache = { fetchedAt: Date.now(), value: { status: 'ok', message: '', fetchedAt: Date.now(), ...result } }
640
+ }, error => {
641
+ goQuotaCache = {
642
+ fetchedAt: Date.now(),
643
+ value: {
644
+ ...emptyGoQuota(),
645
+ // 未登录/无订阅等预期场景降级为 off(中性提示);其余为 error(红色提示)。
646
+ status: (error && error.soft === true) ? 'off' : 'error',
647
+ message: error instanceof Error ? error.message : String(error),
648
+ fetchedAt: Date.now(),
649
+ },
650
+ }
651
+ }).finally(() => {
652
+ if (goQuotaCache.inFlight === task) delete goQuotaCache.inFlight
653
+ })
654
+ goQuotaCache.inFlight = task
655
+ await task
656
+ }
657
+
658
+ /** 按需刷新自定义 Provider 余额(过期或 force)。 */
659
+ const ensureCustomBalance = async (force = false) => {
660
+ const config = customBalanceConfig()
661
+ if (config.enabled !== true || config.display === 'off') {
662
+ customBalanceCache = { fetchedAt: Date.now(), value: emptyCustomBalance() }
663
+ return
664
+ }
665
+ const interval = Math.max(1, Number(config.refreshMinutes) || 15) * 60_000
666
+ if (!force && Date.now() - customBalanceCache.fetchedAt < interval) return
667
+ if (customBalanceCache.inFlight !== undefined) {
668
+ await customBalanceCache.inFlight
669
+ return
670
+ }
671
+ const task = queryCustomBalance(ctx, ledger.config).then(result => {
672
+ customBalanceCache = {
673
+ fetchedAt: Date.now(),
674
+ value: { status: 'ok', message: '', fetchedAt: Date.now(), ...result },
675
+ }
676
+ }, error => {
677
+ customBalanceCache = {
678
+ fetchedAt: Date.now(),
679
+ value: {
680
+ ...emptyCustomBalance(),
681
+ label: typeof config.label === 'string' ? config.label : '',
682
+ status: (error && error.soft === true) ? 'off' : 'error',
683
+ message: error instanceof Error ? error.message : String(error),
684
+ fetchedAt: Date.now(),
685
+ },
686
+ }
687
+ }).finally(() => {
688
+ if (customBalanceCache.inFlight === task) delete customBalanceCache.inFlight
689
+ })
690
+ customBalanceCache.inFlight = task
691
+ await task
692
+ }
693
+
694
+ /** 合并配置与运行时额度状态,得到对客户端的 codingPlans 快照。 */
695
+ const mergedCodingPlans = () => {
696
+ const out = {}
697
+ for (const id of CODING_PLAN_PROVIDER_IDS) {
698
+ const cfg = codingPlanConfigOf(id)
699
+ const cached = codingPlanCaches[id]?.value ?? emptyCodingPlan()
700
+ out[id] = {
701
+ enabled: cfg.enabled === true,
702
+ display: typeof cfg.display === 'string' ? cfg.display : 'settings',
703
+ refreshMinutes: Number.isFinite(Number(cfg.refreshMinutes)) && Number(cfg.refreshMinutes) > 0 ? Number(cfg.refreshMinutes) : 15,
704
+ apiKey: typeof cfg.apiKey === 'string' ? cfg.apiKey : '',
705
+ ...cached,
706
+ windows: cached.windows !== null && typeof cached.windows === 'object' ? cached.windows : {},
707
+ }
708
+ }
709
+ return out
710
+ }
711
+
712
+ /** 按需刷新单家 coding plan 额度(过期或 force);未启用/显示关闭/失败均落空或 error 状态。 */
713
+ const ensureCodingPlan = async (id, force = false) => {
714
+ const config = codingPlanConfigOf(id)
715
+ if (config.enabled !== true || config.display === 'off') {
716
+ codingPlanCaches[id] = { fetchedAt: Date.now(), value: emptyCodingPlan() }
717
+ return
718
+ }
719
+ const interval = Math.max(1, Number(config.refreshMinutes) || 15) * 60_000
720
+ const cache = codingPlanCaches[id]
721
+ if (!force && cache !== undefined && Date.now() - cache.fetchedAt < interval) return
722
+ if (cache !== undefined && cache.inFlight !== undefined) {
723
+ await cache.inFlight
724
+ return
725
+ }
726
+ const locale = localeOf(ledger.config)
727
+ const task = (async () => {
728
+ const key = await resolveCodingPlanKey(ctx, id, ledger.config)
729
+ return queryCodingPlan(id, key, locale, tmsg)
730
+ })().then(result => {
731
+ codingPlanCaches[id] = { fetchedAt: Date.now(), value: { status: 'ok', message: '', fetchedAt: Date.now(), windows: result.windows } }
732
+ }, error => {
733
+ codingPlanCaches[id] = {
734
+ fetchedAt: Date.now(),
735
+ value: {
736
+ ...emptyCodingPlan(),
737
+ // 未配置凭据/无订阅等预期场景降级为 off(中性提示);其余为 error(红色提示)。
738
+ status: (error && error.soft === true) ? 'off' : 'error',
739
+ message: error instanceof Error ? error.message : String(error),
740
+ fetchedAt: Date.now(),
741
+ },
742
+ }
743
+ }).finally(() => {
744
+ if (codingPlanCaches[id]?.inFlight === task) delete codingPlanCaches[id].inFlight
745
+ })
746
+ codingPlanCaches[id] = { ...(codingPlanCaches[id] ?? { fetchedAt: 0, value: emptyCodingPlan() }), inFlight: task }
747
+ await task
748
+ }
749
+
750
+ /** 按需刷新全部已启用 coding plan 额度(并行)。 */
751
+ const ensureCodingPlans = async (force = false) => {
752
+ await Promise.all(CODING_PLAN_PROVIDER_IDS.map(id => ensureCodingPlan(id, force)))
753
+ }
754
+
755
+ const build = async (forceBalance = false) => {
756
+ await Promise.all([ensureBalance(forceBalance), ensureGoQuota(false), ensureCustomBalance(false), ensureCodingPlans(false)])
757
+ return buildState(ledger, balanceCache.value, goQuotaCache.value, mergedCodingPlans(), customBalanceCache.value, reconcileNotice)
758
+ }
759
+
760
+ const service = {
761
+ async getState() {
762
+ return build(false)
763
+ },
764
+
765
+ async updateConfig(patch) {
766
+ const { config, errors } = applyConfigPatch(ledger.config, patch)
767
+ if (errors.length > 0) {
768
+ const locale = patch !== null && typeof patch === 'object' && patch.locale === 'en' ? 'en' : localeOf(ledger.config)
769
+ throw new Error(tmsg(locale, 'configRejected', { errors: errors.join(locale === 'zh' ? ';' : '; ') }))
770
+ }
771
+ ledger.config = config
772
+ if (config.balance?.reconcile !== true) reconcileNotice = { ok: true, message: '' }
773
+ ledger.scheduleWrite()
774
+ return build(false)
775
+ },
776
+
777
+ async refreshBalance() {
778
+ const locale = localeOf(ledger.config)
779
+ if (balanceConfig().display === 'off') {
780
+ return { ok: false, message: tmsg(locale, 'balanceDisplayOff') }
781
+ }
782
+ await ensureBalance(true)
783
+ const value = balanceCache.value
784
+ return {
785
+ ok: value.status === 'ok',
786
+ message: value.status === 'ok' ? tmsg(locale, 'balanceRefreshed') : tmsg(locale, 'balanceQueryFailed', { message: value.message }),
787
+ state: buildState(ledger, value, goQuotaCache.value, mergedCodingPlans(), customBalanceCache.value, reconcileNotice),
788
+ }
789
+ },
790
+
791
+ async refreshCustomBalance() {
792
+ const locale = localeOf(ledger.config)
793
+ if (customBalanceConfig().enabled !== true) {
794
+ return { ok: false, message: tmsg(locale, 'customBalanceDisabled') }
795
+ }
796
+ if (customBalanceConfig().display === 'off') {
797
+ return { ok: false, message: tmsg(locale, 'customBalanceDisplayOff') }
798
+ }
799
+ await ensureCustomBalance(true)
800
+ const value = customBalanceCache.value
801
+ return {
802
+ ok: value.status === 'ok',
803
+ message: value.status === 'ok' ? tmsg(locale, 'customBalanceRefreshed')
804
+ : value.status === 'off' && value.message ? value.message
805
+ : tmsg(locale, 'customBalanceQueryFailed', { message: value.message }),
806
+ state: buildState(ledger, balanceCache.value, goQuotaCache.value, mergedCodingPlans(), value, reconcileNotice),
807
+ }
808
+ },
809
+
810
+ async refreshGoQuota() {
811
+ const locale = localeOf(ledger.config)
812
+ if (goQuotaConfig().enabled === false) {
813
+ return { ok: false, message: tmsg(locale, 'goQuotaDisabled') }
814
+ }
815
+ if (goQuotaConfig().display === 'off') {
816
+ return { ok: false, message: tmsg(locale, 'goQuotaDisplayOff') }
817
+ }
818
+ await ensureGoQuota(true)
819
+ const value = goQuotaCache.value
820
+ return {
821
+ ok: value.status === 'ok',
822
+ message: value.status === 'ok' ? tmsg(locale, 'goQuotaRefreshed')
823
+ : value.status === 'off' && value.message ? value.message
824
+ : tmsg(locale, 'goQuotaQueryFailed', { message: value.message }),
825
+ state: buildState(ledger, balanceCache.value, value, mergedCodingPlans(), customBalanceCache.value, reconcileNotice),
826
+ }
827
+ },
828
+
829
+ async refreshCodingPlan(provider) {
830
+ const locale = localeOf(ledger.config)
831
+ const id = typeof provider === 'string' ? provider : ''
832
+ if (!CODING_PLAN_PROVIDER_IDS.includes(id)) {
833
+ return { ok: false, message: tmsg(locale, 'codingPlanUnknown', { provider: id }) }
834
+ }
835
+ const label = CODING_PLAN_PROVIDERS[id].label
836
+ const config = codingPlanConfigOf(id)
837
+ if (config.enabled !== true) {
838
+ return { ok: false, message: tmsg(locale, 'codingPlanDisabled', { provider: label }) }
839
+ }
840
+ if (config.display === 'off') {
841
+ return { ok: false, message: tmsg(locale, 'codingPlanDisplayOff', { provider: label }) }
842
+ }
843
+ await ensureCodingPlan(id, true)
844
+ const value = codingPlanCaches[id]?.value ?? emptyCodingPlan()
845
+ return {
846
+ ok: value.status === 'ok',
847
+ message: value.status === 'ok' ? tmsg(locale, 'codingPlanRefreshed', { provider: label })
848
+ : value.status === 'off' && value.message ? value.message
849
+ : tmsg(locale, 'codingPlanQueryFailed', { provider: label, message: value.message }),
850
+ state: buildState(ledger, balanceCache.value, goQuotaCache.value, mergedCodingPlans(), customBalanceCache.value, reconcileNotice),
851
+ }
852
+ },
853
+
854
+ async fetchPrices() {
855
+ const locale = localeOf(ledger.config)
856
+ try {
857
+ const html = await fetchPricingHtml(locale)
858
+ const parsed = parsePricingHtml(html)
859
+ const models = { ...ledger.config.prices.models }
860
+ for (const [id, raw] of Object.entries(parsed.models)) {
861
+ const entry = normalizePrice(raw)
862
+ if (entry === null) continue
863
+ models[id] = { ...(models[id] ?? {}), ...entry }
864
+ }
865
+ const patch = {
866
+ prices: { ...ledger.config.prices, models },
867
+ priceSource: 'official',
868
+ fetchedAt: new Date().toISOString(),
869
+ }
870
+ if (typeof parsed.effectiveAt === 'string') patch.peakEffectiveAt = parsed.effectiveAt
871
+ else patch.peakEffectiveAt = new Date().toISOString() // 页面已无生效时间:两档方案即时生效
872
+ if (Array.isArray(parsed.peakWindows) && parsed.peakWindows.length > 0) {
873
+ patch.peakWindows = parsed.peakWindows
874
+ }
875
+ const { config, errors } = applyConfigPatch(ledger.config, patch)
876
+ if (errors.length > 0) throw new Error(errors.join(';'))
877
+ ledger.config = config
878
+ ledger.scheduleWrite()
879
+ const ids = Object.keys(parsed.models)
880
+ return {
881
+ ok: true,
882
+ message: tmsg(locale, 'pricesSynced', { ids: ids.join(locale === 'zh' ? '、' : ', ') }),
883
+ state: await build(false),
884
+ }
885
+ } catch (error) {
886
+ const detail = error?.code === 'ERR_NO_MODELS'
887
+ ? tmsg(locale, 'noModelsParsed')
888
+ : (error instanceof Error ? error.message : String(error))
889
+ return {
890
+ ok: false,
891
+ message: tmsg(locale, 'priceSyncFailed', { error: detail }),
892
+ }
893
+ }
894
+ },
895
+
896
+ async resetHistory() {
897
+ ledger.days = {}
898
+ ledger.scheduleWrite()
899
+ return build(false)
900
+ },
901
+ }
902
+ Object.defineProperty(service, 'typertRemote', {
903
+ configurable: false,
904
+ enumerable: false,
905
+ writable: false,
906
+ value: { service, serviceKey: 'costMeter', namespace: 'costMeter' },
907
+ })
908
+ return service
909
+ }
910
+
911
+ // ── 插件主体 ───────────────────────────────────────────────────────────────
912
+
913
+ /**
914
+ * 挂载账本、llm/stream 计费包裹、会话投影与 costMeter 服务。
915
+ * @param ctx - 宿主插件上下文。
916
+ */
917
+ export function apply(ctx) {
918
+ const ledger = Ledger.open()
919
+ console.log(`[dsh-cost-meter] 已加载,账本:${ledger.path}`)
920
+
921
+ // 卸载/退出前最终落盘。
922
+ ctx.effect(() => () => ledger.close(), 'cost-meter: ledger close')
923
+
924
+ // 历史账本按模型回填:按模型统计上线前的日期只有合计,启动后延迟回放
925
+ // 宿主会话日志填补空 byProviderModel(幂等,只填空条目,不重复计数)。
926
+ const backfillTimer = setTimeout(() => {
927
+ backfillLegacyLedger(ledger, join(resolveDshHome(), 'sessions')).then(filled => {
928
+ if (filled.days > 0 || filled.sessions > 0) {
929
+ console.log(`[dsh-cost-meter] 历史按模型统计回填完成:${filled.days} 天 / ${filled.sessions} 个会话(扫描 ${filled.scanned} 份会话日志${filled.recosted > 0 ? `,重算 ${filled.recosted} 天金额` : ''})`)
930
+ }
931
+ }, error => {
932
+ console.warn(`[dsh-cost-meter] 历史按模型统计回填失败: ${String(error?.message ?? error)}`)
933
+ })
934
+ }, 3000)
935
+ backfillTimer.unref?.()
936
+
937
+ // 包裹 llm/stream:捕获 usage 块(位于 finish 之前),按官方价格计入账本。
938
+ // 本插件是链尾监听者,next() 即适配器流;仅透传数据块,不改变流协议。
939
+ ctx.on('llm/stream', (options, next) => {
940
+ const downstream = next()
941
+ return (async function* costMeterStream() {
942
+ let usage = null
943
+ try {
944
+ for await (const chunk of downstream) {
945
+ if (chunk !== null && chunk !== undefined && chunk.type === 'usage' && chunk.usage !== undefined) {
946
+ usage = chunk.usage
947
+ }
948
+ yield chunk
949
+ }
950
+ } finally {
951
+ if (usage !== null) {
952
+ try {
953
+ ledger.account({
954
+ input: usage.inputTokens ?? 0,
955
+ output: usage.outputTokens ?? 0,
956
+ cacheRead: usage.cacheReadTokens ?? 0,
957
+ cacheWrite: usage.cacheWriteTokens ?? 0,
958
+ reasoning: usage.reasoningTokens ?? 0,
959
+ }, options?.model, options?.sessionId, Date.now(), options?.provider)
960
+ } catch (error) {
961
+ ctx.logger?.warn?.(`[dsh-cost-meter] 计费失败: ${String(error)}`)
962
+ }
963
+ }
964
+ }
965
+ })()
966
+ })
967
+
968
+ // costUsage 投影:向会话历史页/推送帧提供 token 桶(客户端计价)。
969
+ ctx.inject(['sessionProjections'], (projectionCtx) => {
970
+ projectionCtx.sessionProjections.register(makeCostUsageProjection(ledger))
971
+ })
972
+
973
+ // RPC 服务:客户端经 remote.costMeter.* 调用(./typert 清单由 typert-loader 注册)。
974
+ ctx.provide('costMeter', createService(ctx, ledger))
975
+ }