dsh-plugin-weapp-gateway 0.8.13 → 0.8.23

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.
@@ -0,0 +1,794 @@
1
+ /**
2
+ * Cost Meter bridge — standalone, projection-based (no `dsh-cost-meter` plugin).
3
+ *
4
+ * Architecture mirrors the context dashboard: this module registers a DSH
5
+ * session projection (`costMeter`) that folds every durable `assistant/message`
6
+ * event's provider-reported `data.usage` into day / month / lifetime token and
7
+ * cost buckets. The host replays the durable log into that projection and
8
+ * persists it in the session projection cache, so answering a query costs one
9
+ * cheap in-process snapshot per session instead of re-reading megabytes of
10
+ * history over RPC (which blows the client's 8s budget on real profiles).
11
+ *
12
+ * The wire contract the mini program's `cost-view.js` expects is unchanged:
13
+ *
14
+ * { type: 'cost-meter', requestId, action, patch? } ->
15
+ * { kind: 'cost-meter', requestId, action, summary, config? }
16
+ *
17
+ * Actions: summary (30s cache), config, update (allowlisted patch), refresh.
18
+ *
19
+ * Prices are a built-in USD-per-million estimate table keyed by model id
20
+ * substring; `currency` / `exchangeRate` from the persisted config turn the USD
21
+ * fold into the display currency.
22
+ */
23
+
24
+ import fs from 'node:fs'
25
+ import path from 'node:path'
26
+ import os from 'node:os'
27
+
28
+ export const COST_METER_REQUEST_TYPES = new Set(['cost-meter'])
29
+
30
+ const MAX_REQUEST_ID = 128
31
+ const FINITE = v => typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0
32
+ const CACHE_TTL_MS = 30_000
33
+ const MAX_SESSIONS = 400
34
+ const MAX_SNAPSHOT_SESSIONS = 40
35
+ const MAX_FALLBACK_SESSIONS = 8
36
+ const KEEP_DAYS = 45
37
+ const KEEP_MONTHS = 14
38
+
39
+ // A plain object with the only method the projection registry calls. Keeping
40
+ // this bridge free of a zod dependency lets the module load both in the host
41
+ // plugin and in plain-Node unit tests (same trick as context-bridge).
42
+ const passthroughSchema = Object.freeze({
43
+ parse: value => value,
44
+ safeParse: value => ({ success: true, data: value }),
45
+ '~standard': Object.freeze({
46
+ version: 1,
47
+ vendor: 'weapp-gateway',
48
+ validate: value => ({ value }),
49
+ }),
50
+ })
51
+
52
+ // Built-in USD list prices per million tokens (display estimate only). Keys
53
+ // match by model id substring; '*' is the fallback for unknown/local models.
54
+ const DEFAULT_PRICES = {
55
+ 'deepseek-chat': { input: 0.27, cacheRead: 0.07, cacheWrite: 0.27, output: 1.10 },
56
+ 'deepseek-reasoner': { input: 0.55, cacheRead: 0.14, cacheWrite: 0.55, output: 2.19 },
57
+ 'deepseek-v3': { input: 0.27, cacheRead: 0.07, cacheWrite: 0.27, output: 1.10 },
58
+ 'deepseek-r1': { input: 0.55, cacheRead: 0.14, cacheWrite: 0.55, output: 2.19 },
59
+ 'deepseek': { input: 0.27, cacheRead: 0.07, cacheWrite: 0.27, output: 1.10 },
60
+ 'gpt-4o-mini': { input: 0.15, cacheRead: 0.075, cacheWrite: 0.15, output: 0.6 },
61
+ 'gpt-4o': { input: 2.5, cacheRead: 1.25, cacheWrite: 2.5, output: 10 },
62
+ 'gpt-5': { input: 1.25, cacheRead: 0.125, cacheWrite: 1.25, output: 10 },
63
+ 'gpt-6': { input: 1.25, cacheRead: 0.125, cacheWrite: 1.25, output: 10 },
64
+ 'claude-3-5-sonnet': { input: 3, cacheRead: 0.3, cacheWrite: 3.75, output: 15 },
65
+ 'claude-3-5-haiku': { input: 0.8, cacheRead: 0.08, cacheWrite: 1, output: 4 },
66
+ 'claude': { input: 3, cacheRead: 0.3, cacheWrite: 3.75, output: 15 },
67
+ 'glm-4-flash': { input: 0.1, cacheRead: 0.01, cacheWrite: 0.1, output: 0.1 },
68
+ 'glm': { input: 0.5, cacheRead: 0.05, cacheWrite: 0.5, output: 2 },
69
+ 'qwen-max': { input: 0.04, cacheRead: 0.004, cacheWrite: 0.04, output: 0.12 },
70
+ 'qwen-plus': { input: 0.008, cacheRead: 0.0008, cacheWrite: 0.008, output: 0.032 },
71
+ 'qwen-turbo': { input: 0.005, cacheRead: 0.0005, cacheWrite: 0.005, output: 0.02 },
72
+ 'qwen': { input: 0.04, cacheRead: 0.004, cacheWrite: 0.04, output: 0.12 },
73
+ 'step-': { input: 0.5, cacheRead: 0.05, cacheWrite: 0.5, output: 2 },
74
+ 'grok': { input: 2, cacheRead: 0.2, cacheWrite: 2, output: 10 },
75
+ 'llama': { input: 0.2, cacheRead: 0.02, cacheWrite: 0.2, output: 0.2 },
76
+ 'mistral': { input: 0.15, cacheRead: 0.015, cacheWrite: 0.15, output: 0.6 },
77
+ '*': { input: 0.2, cacheRead: 0.02, cacheWrite: 0.2, output: 0.2 },
78
+ }
79
+
80
+ const DEFAULT_CONFIG = Object.freeze({
81
+ currency: 'CNY',
82
+ exchangeRate: 7.2,
83
+ budget: { enabled: false, amount: 0, period: 'month' },
84
+ balance: { display: 'both' },
85
+ hideBudget: false,
86
+ showTotalWithPlan: false,
87
+ includeSubagentCost: true,
88
+ locale: 'zh',
89
+ })
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Numbers
93
+ // ---------------------------------------------------------------------------
94
+
95
+ function numeric(v, fallback = 0) { return FINITE(v) ?? fallback }
96
+
97
+ function tokenCountOf(value) {
98
+ if (typeof value === 'number') return Number.isFinite(value) ? Math.max(0, Math.round(value)) : null
99
+ if (typeof value === 'string' && value.trim() !== '') {
100
+ const parsed = Number(value)
101
+ return Number.isFinite(parsed) ? Math.max(0, Math.round(parsed)) : null
102
+ }
103
+ return null
104
+ }
105
+
106
+ function priceOf(modelId) {
107
+ const key = typeof modelId === 'string' ? modelId.toLowerCase() : ''
108
+ if (key !== '') {
109
+ for (const [name, price] of Object.entries(DEFAULT_PRICES)) {
110
+ if (name === '*') continue
111
+ if (key.includes(name)) return price
112
+ }
113
+ }
114
+ return DEFAULT_PRICES['*']
115
+ }
116
+
117
+ /**
118
+ * Bill one usage record. Provider usage may report `inputTokens` inclusive of
119
+ * cache hits (DeepSeek's `prompt_tokens` does), so prefer `uncachedInputTokens`
120
+ * and otherwise subtract the cache-read portion — otherwise cached tokens get
121
+ * billed twice.
122
+ */
123
+ function usageRow(usage, modelId) {
124
+ const u = usage && typeof usage === 'object' ? usage : {}
125
+ const cacheRead = tokenCountOf(u.cacheReadTokens) ?? tokenCountOf(u.cachedInputTokens) ?? 0
126
+ const cacheWrite = tokenCountOf(u.cacheWriteTokens) ?? 0
127
+ const output = tokenCountOf(u.outputTokens) ?? tokenCountOf(u.completionTokens) ?? 0
128
+ const reasoning = tokenCountOf(u.reasoningTokens) ?? 0
129
+ let input = tokenCountOf(u.uncachedInputTokens)
130
+ if (input === null) {
131
+ const raw = tokenCountOf(u.inputTokens) ?? tokenCountOf(u.promptTokens) ?? 0
132
+ input = Math.max(0, raw - cacheRead)
133
+ }
134
+ const price = priceOf(modelId)
135
+ const cost = (
136
+ input * numeric(price.input)
137
+ + cacheRead * numeric(price.cacheRead)
138
+ + cacheWrite * numeric(price.cacheWrite)
139
+ + (output + reasoning) * numeric(price.output)
140
+ ) / 1_000_000
141
+ return {
142
+ input,
143
+ cacheRead,
144
+ cacheWrite,
145
+ output,
146
+ reasoning,
147
+ tokens: input + cacheRead + cacheWrite + output + reasoning,
148
+ cost,
149
+ calls: 1,
150
+ }
151
+ }
152
+
153
+ function emptyBucket() {
154
+ return { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, reasoning: 0, tokens: 0, cost: 0, calls: 0 }
155
+ }
156
+
157
+ function addRow(target, row) {
158
+ if (!target || !row) return target
159
+ target.input += numeric(row.input)
160
+ target.cacheRead += numeric(row.cacheRead)
161
+ target.cacheWrite += numeric(row.cacheWrite)
162
+ target.output += numeric(row.output)
163
+ target.reasoning += numeric(row.reasoning)
164
+ target.tokens += numeric(row.tokens)
165
+ target.cost += numeric(row.cost)
166
+ target.calls += numeric(row.calls)
167
+ return target
168
+ }
169
+
170
+ function dayKeyOf(time) {
171
+ const d = new Date(time)
172
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
173
+ }
174
+
175
+ function monthKeyOf(time) {
176
+ const d = new Date(time)
177
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
178
+ }
179
+
180
+ function modelOf(event) {
181
+ const data = event && event.data !== null && typeof event.data === 'object' ? event.data : null
182
+ if (!data) return ''
183
+ if (typeof data.model === 'string' && data.model !== '') return data.model
184
+ const source = data.source && typeof data.source === 'object' ? data.source : null
185
+ if (source && typeof source.model === 'string' && source.model !== '') return source.model
186
+ return ''
187
+ }
188
+
189
+ function providerOf(event) {
190
+ const data = event && event.data !== null && typeof event.data === 'object' ? event.data : null
191
+ if (!data) return ''
192
+ if (typeof data.provider === 'string' && data.provider !== '') return data.provider
193
+ const message = data.message && typeof data.message === 'object' ? data.message : null
194
+ const messageSource = message && message.source && typeof message.source === 'object' ? message.source : null
195
+ if (messageSource && typeof messageSource.provider === 'string' && messageSource.provider !== '') return messageSource.provider
196
+ const source = data.source && typeof data.source === 'object' ? data.source : null
197
+ if (source && typeof source.provider === 'string' && source.provider !== '') return source.provider
198
+ return ''
199
+ }
200
+
201
+ const MAX_MODELS_PER_DAY = 64
202
+
203
+ /** Per-provider/model day buckets back the local Coding Plan estimates (SCNet/Qwen). */
204
+ function mergeProviderModel(target, source) {
205
+ if (!target || !source || typeof source !== 'object') return target
206
+ if (!target.byProviderModel || typeof target.byProviderModel !== 'object') target.byProviderModel = {}
207
+ for (const [key, bucket] of Object.entries(source.byProviderModel || {})) {
208
+ if (!bucket || typeof bucket !== 'object') continue
209
+ if (!target.byProviderModel[key]) {
210
+ if (Object.keys(target.byProviderModel).length >= MAX_MODELS_PER_DAY) continue
211
+ target.byProviderModel[key] = emptyBucket()
212
+ }
213
+ addRow(target.byProviderModel[key], bucket)
214
+ }
215
+ return target
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Projection state (plain JSON; safe to persist in the host projection cache)
220
+ // ---------------------------------------------------------------------------
221
+
222
+ function createCostState() {
223
+ return { v: 2, days: {}, months: {}, total: emptyBucket(), lastTime: 0 }
224
+ }
225
+
226
+ function pruneState(state) {
227
+ const newest = numeric(state.lastTime)
228
+ if (newest <= 0) return state
229
+ const dayCutoff = newest - KEEP_DAYS * 86_400_000
230
+ for (const key of Object.keys(state.days)) {
231
+ const t = Date.parse(key + 'T00:00:00')
232
+ if (!Number.isFinite(t) || t < dayCutoff) delete state.days[key]
233
+ }
234
+ const monthCutoff = newest - KEEP_MONTHS * 31 * 86_400_000
235
+ for (const key of Object.keys(state.months)) {
236
+ const t = Date.parse(key + '-01T00:00:00')
237
+ if (!Number.isFinite(t) || t < monthCutoff) delete state.months[key]
238
+ }
239
+ return state
240
+ }
241
+
242
+ /** Fold one durable event into the cost projection state (mutating). */
243
+ export function applyCostEvent(state, event) {
244
+ if (!state || typeof state !== 'object') return state
245
+ if (!event || typeof event !== 'object' || event.type !== 'assistant/message') return state
246
+ const data = event.data !== null && typeof event.data === 'object' ? event.data : null
247
+ const usage = data && data.usage !== null && typeof data.usage === 'object' ? data.usage : null
248
+ if (!usage) return state
249
+ const row = usageRow(usage, modelOf(event))
250
+ if (row.tokens === 0) return state
251
+ const time = typeof event.time === 'number' && Number.isFinite(event.time) && event.time > 0
252
+ ? event.time
253
+ : (typeof data.time === 'number' && Number.isFinite(data.time) && data.time > 0 ? data.time : 0)
254
+ const stamp = time > 0 ? time : numeric(state.lastTime)
255
+ const dk = dayKeyOf(stamp || Date.now())
256
+ const mk = monthKeyOf(stamp || Date.now())
257
+ if (state.days[dk] === undefined) {
258
+ state.days[dk] = emptyBucket()
259
+ if (stamp > 0) {
260
+ state.lastTime = Math.max(numeric(state.lastTime), stamp)
261
+ pruneState(state)
262
+ }
263
+ }
264
+ addRow(state.days[dk], row)
265
+ const pmKey = (providerOf(event) || 'unknown') + ':' + (modelOf(event) || 'unknown')
266
+ const dayBucket = state.days[dk]
267
+ if (!dayBucket.byProviderModel || typeof dayBucket.byProviderModel !== 'object') dayBucket.byProviderModel = {}
268
+ if (dayBucket.byProviderModel[pmKey] === undefined && Object.keys(dayBucket.byProviderModel).length >= MAX_MODELS_PER_DAY) {
269
+ // 模型数上限:超出的新模型只计入日汇总,不再单独记录。
270
+ } else {
271
+ if (dayBucket.byProviderModel[pmKey] === undefined) dayBucket.byProviderModel[pmKey] = emptyBucket()
272
+ addRow(dayBucket.byProviderModel[pmKey], row)
273
+ }
274
+ if (state.months[mk] === undefined) state.months[mk] = emptyBucket()
275
+ addRow(state.months[mk], row)
276
+ addRow(state.total, row)
277
+ if (stamp > 0) state.lastTime = Math.max(numeric(state.lastTime), stamp)
278
+ return state
279
+ }
280
+
281
+ /** Time-independent view: the bridge selects today/month at request time. */
282
+ function costViewOf(state) {
283
+ if (!state || typeof state !== 'object') return null
284
+ const days = {}
285
+ for (const [key, bucket] of Object.entries(state.days || {})) {
286
+ if (bucket && numeric(bucket.calls) > 0) days[key] = { ...emptyBucket(), ...bucket }
287
+ }
288
+ const months = {}
289
+ for (const [key, bucket] of Object.entries(state.months || {})) {
290
+ if (bucket && numeric(bucket.calls) > 0) months[key] = { ...emptyBucket(), ...bucket }
291
+ }
292
+ const total = state.total && numeric(state.total.calls) > 0 ? { ...emptyBucket(), ...state.total } : emptyBucket()
293
+ return { v: 2, days, months, total, lastTime: numeric(state.lastTime) }
294
+ }
295
+
296
+ /** The `costMeter` projection definition registered on `ctx.sessionProjections`. */
297
+ export const costMeterProjectionDefinition = {
298
+ key: 'costMeter',
299
+ stateVersion: 2,
300
+ stateSchema: passthroughSchema,
301
+ init: () => createCostState(),
302
+ apply: (state, event) => applyCostEvent(state, event),
303
+ wire: { viewSchema: passthroughSchema, view: state => costViewOf(state) },
304
+ }
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Host services (in-process, same pattern as context-bridge)
308
+ // ---------------------------------------------------------------------------
309
+
310
+ let hostSessions = null
311
+ let hostProjections = null
312
+
313
+ /** Register the cost projection so the host folds it into the projection cache. */
314
+ export function registerCostMeterProjection(ctx) {
315
+ if (ctx === null || typeof ctx !== 'object' || typeof ctx.inject !== 'function') return false
316
+ try {
317
+ ctx.inject(['sessionProjections'], (sub) => {
318
+ if (!sub || !sub.sessionProjections || typeof sub.sessionProjections.register !== 'function') return
319
+ sub.sessionProjections.register(costMeterProjectionDefinition)
320
+ })
321
+ return true
322
+ } catch (_) {
323
+ return false
324
+ }
325
+ }
326
+
327
+ /** Capture the host services the summary query reads. */
328
+ export function registerCostMeterServices(ctx) {
329
+ if (ctx === null || typeof ctx !== 'object' || typeof ctx.inject !== 'function') return false
330
+ try {
331
+ ctx.inject(['sessions', 'sessionProjections'], (sub) => {
332
+ if (!sub || !sub.sessions || !sub.sessionProjections) return
333
+ hostSessions = sub.sessions
334
+ hostProjections = sub.sessionProjections
335
+ sub.effect(() => () => {
336
+ hostSessions = null
337
+ hostProjections = null
338
+ })
339
+ })
340
+ return true
341
+ } catch (_) {
342
+ return false
343
+ }
344
+ }
345
+
346
+ function hostReady() {
347
+ return hostSessions !== null && typeof hostSessions.list === 'function'
348
+ }
349
+
350
+ function hostSessionList() {
351
+ try {
352
+ const result = hostSessions.list()
353
+ return Array.isArray(result) ? result : Array.isArray(result?.items) ? result.items : []
354
+ } catch (_) {
355
+ return []
356
+ }
357
+ }
358
+
359
+ function newAccumulator() {
360
+ return { days: {}, months: {}, total: emptyBucket(), sessions: 0, covered: 0 }
361
+ }
362
+
363
+ /** Merge one `costMeter` projection view into the accumulator. */
364
+ function mergeCostValue(acc, value) {
365
+ if (!value || typeof value !== 'object') return false
366
+ acc.covered += 1
367
+ for (const [key, bucket] of Object.entries(value.days || {})) {
368
+ if (!acc.days[key]) acc.days[key] = emptyBucket()
369
+ addRow(acc.days[key], bucket)
370
+ mergeProviderModel(acc.days[key], bucket)
371
+ }
372
+ for (const [key, bucket] of Object.entries(value.months || {})) {
373
+ if (!acc.months[key]) acc.months[key] = emptyBucket()
374
+ addRow(acc.months[key], bucket)
375
+ }
376
+ addRow(acc.total, value.total)
377
+ return true
378
+ }
379
+
380
+ function sessionIdOfRecord(session) {
381
+ if (typeof session === 'string') return session
382
+ if (!session || typeof session !== 'object') return ''
383
+ if (typeof session.sessionId === 'string' && session.sessionId !== '') return session.sessionId
384
+ if (typeof session.id === 'string' && session.id !== '') return session.id
385
+ if (session.header && typeof session.header.id === 'string' && session.header.id !== '') return session.header.id
386
+ return ''
387
+ }
388
+
389
+ /**
390
+ * Fold the `costMeter` projections that `session/list` already ships on every
391
+ * item. This is the fast path the wire test documents (`items[].projections`):
392
+ * one RPC for the whole profile, so it always fits the client's 8s budget.
393
+ */
394
+ async function sumListProjections(api, signal) {
395
+ const acc = newAccumulator()
396
+ if (!api || !api.sessions || typeof api.sessions.list !== 'function') return acc
397
+ let items = []
398
+ try {
399
+ const result = await api.sessions.list({}, signal)
400
+ items = Array.isArray(result) ? result : Array.isArray(result?.items) ? result.items : []
401
+ } catch (_) {
402
+ return acc
403
+ }
404
+ for (const item of items.slice(0, MAX_SESSIONS)) {
405
+ acc.sessions += 1
406
+ const projections = item && typeof item === 'object' ? item.projections : null
407
+ const values = projections && typeof projections === 'object' && projections.values && typeof projections.values === 'object'
408
+ ? projections.values
409
+ : projections && typeof projections === 'object' && !Array.isArray(projections)
410
+ ? projections
411
+ : null
412
+ mergeCostValue(acc, values ? values.costMeter : null)
413
+ }
414
+ return acc
415
+ }
416
+
417
+ /**
418
+ * In-process fallback for hosts whose session list does not carry projections.
419
+ * Bounded to MAX_SNAPSHOT_SESSIONS so a cold profile cannot blow the client
420
+ * timeout while projections fold for the first time.
421
+ */
422
+ function sumSnapshotProjections() {
423
+ const acc = newAccumulator()
424
+ if (hostProjections === null || typeof hostProjections.snapshot !== 'function') return acc
425
+ const list = hostSessionList()
426
+ for (const session of list.slice(0, Math.min(list.length, MAX_SNAPSHOT_SESSIONS))) {
427
+ acc.sessions += 1
428
+ let values = null
429
+ try {
430
+ const snapshot = hostProjections.snapshot(session)
431
+ values = snapshot !== null && typeof snapshot === 'object' && snapshot.values !== null && typeof snapshot.values === 'object'
432
+ ? snapshot.values
433
+ : null
434
+ } catch (_) {
435
+ values = null
436
+ }
437
+ mergeCostValue(acc, values ? values.costMeter : null)
438
+ }
439
+ return acc
440
+ }
441
+
442
+ /**
443
+ * Collect the cost totals for the whole profile.
444
+ *
445
+ * Order matters: the single-RPC session list first, then a bounded in-process
446
+ * snapshot pass for sessions the list did not describe, then the history fold
447
+ * for hosts that expose neither. Every path degrades to partial data rather
448
+ * than an error, except when nothing at all is readable.
449
+ */
450
+ async function collectTotals(api, signal) {
451
+ const diag = []
452
+ const list = await sumListProjections(api, signal)
453
+ diag.push({ path: 'list', sessions: list.sessions, covered: list.covered })
454
+
455
+ if (list.covered === 0 && hostReady()) {
456
+ const snap = sumSnapshotProjections()
457
+ diag.push({ path: 'snapshot', sessions: snap.sessions, covered: snap.covered })
458
+ if (snap.covered > 0) return { acc: snap, diag }
459
+ }
460
+
461
+ if (list.covered > 0) {
462
+ // Sessions whose projections the list omitted still count toward the
463
+ // profile total; fold a bounded slice of them in-process.
464
+ const snap = sumSnapshotProjections()
465
+ if (snap.covered > 0) {
466
+ for (const [key, bucket] of Object.entries(snap.days)) {
467
+ if (!list.days[key]) list.days[key] = emptyBucket()
468
+ addRow(list.days[key], bucket)
469
+ }
470
+ for (const [key, bucket] of Object.entries(snap.months)) {
471
+ if (!list.months[key]) list.months[key] = emptyBucket()
472
+ addRow(list.months[key], bucket)
473
+ }
474
+ // Only add the snapshot total when the list contributed nothing for the
475
+ // lifetime bucket, otherwise the same sessions would be counted twice.
476
+ if (list.total.calls === 0) addRow(list.total, snap.total)
477
+ diag.push({ path: 'snapshot+list', sessions: list.sessions, covered: list.covered + snap.covered })
478
+ }
479
+ return { acc: list, diag }
480
+ }
481
+
482
+ const fb = await sumFallback(api, signal)
483
+ diag.push({ path: 'fallback', sessions: fb.sessions, covered: fb.covered })
484
+ writeDiag(diag)
485
+ return { acc: fb, diag }
486
+ }
487
+
488
+ // Opt-in diagnostics: set DSH_WEAPP_COST_DEBUG=1 to record which read path
489
+ // answered. Off by default so a healthy gateway never touches the disk.
490
+ function writeDiag(diag) {
491
+ if (process.env.DSH_WEAPP_COST_DEBUG !== '1') return
492
+ try {
493
+ const file = process.env.DSH_WEAPP_COST_STATE_FILE
494
+ ? process.env.DSH_WEAPP_COST_STATE_FILE.replace(/\.json$/, '-diag.json')
495
+ : path.join(os.homedir(), '.dsh', 'storages', 'weapp-cost-meter-diag.json')
496
+ fs.mkdirSync(path.dirname(file), { recursive: true })
497
+ fs.writeFileSync(file, JSON.stringify({ ts: Date.now(), diag }, null, 2))
498
+ } catch (_) { /* diagnostics must never break a query */ }
499
+ }
500
+
501
+ // ---------------------------------------------------------------------------
502
+ // RPC fallback: only when the projection is not populated on this host, and
503
+ // strictly bounded so the client's 8s budget can never be blown.
504
+ // ---------------------------------------------------------------------------
505
+
506
+ function historyAvailable(api) {
507
+ return !!(api && api.sessions && typeof api.sessions.history === 'function')
508
+ }
509
+
510
+ function unwrapHistory(history) {
511
+ if (!history || typeof history !== 'object' || history.kind === 'error') return { events: [], hasMore: false }
512
+ const raw = Array.isArray(history.events) ? history.events : []
513
+ const events = []
514
+ for (const entry of raw) {
515
+ const event = entry && typeof entry === 'object' && entry.event && typeof entry.event === 'object' ? entry.event : entry
516
+ if (event && typeof event.type === 'string') events.push(event)
517
+ }
518
+ return { events, hasMore: history.hasMore === true }
519
+ }
520
+
521
+ async function sumFallback(api, signal) {
522
+ const acc = { days: {}, months: {}, total: emptyBucket(), sessions: 0, covered: 0 }
523
+ if (!historyAvailable(api) || typeof api.sessions.list !== 'function') return acc
524
+ const ids = []
525
+ try {
526
+ const result = await api.sessions.list({}, signal)
527
+ const items = Array.isArray(result) ? result : Array.isArray(result?.items) ? result.items : []
528
+ for (const item of items.slice(0, MAX_FALLBACK_SESSIONS)) {
529
+ const id = item && typeof item.sessionId === 'string' ? item.sessionId : ''
530
+ if (id !== '') ids.push(id)
531
+ }
532
+ } catch (_) {
533
+ return acc
534
+ }
535
+ for (const sessionId of ids) {
536
+ if (signal && signal.aborted) break
537
+ acc.sessions += 1
538
+ let history
539
+ try {
540
+ history = await api.sessions.history({ sessionId }, signal)
541
+ } catch (_) {
542
+ continue
543
+ }
544
+ const page = unwrapHistory(history)
545
+ if (page.events.length === 0) continue
546
+ const state = createCostState()
547
+ for (const event of page.events) applyCostEvent(state, event)
548
+ if (state.total.calls === 0) continue
549
+ acc.covered += 1
550
+ for (const [key, bucket] of Object.entries(state.days)) {
551
+ if (!acc.days[key]) acc.days[key] = emptyBucket()
552
+ addRow(acc.days[key], bucket)
553
+ }
554
+ for (const [key, bucket] of Object.entries(state.months)) {
555
+ if (!acc.months[key]) acc.months[key] = emptyBucket()
556
+ addRow(acc.months[key], bucket)
557
+ }
558
+ addRow(acc.total, state.total)
559
+ }
560
+ return acc
561
+ }
562
+
563
+ // ---------------------------------------------------------------------------
564
+ // Config persistence (best-effort; never throws)
565
+ // ---------------------------------------------------------------------------
566
+
567
+ function configFile() {
568
+ return process.env.DSH_WEAPP_COST_STATE_FILE
569
+ || path.join(os.homedir(), '.dsh', 'storages', 'weapp-cost-meter.json')
570
+ }
571
+
572
+ function readConfig() {
573
+ const merged = { ...DEFAULT_CONFIG, budget: { ...DEFAULT_CONFIG.budget }, balance: { ...DEFAULT_CONFIG.balance } }
574
+ try {
575
+ const parsed = JSON.parse(fs.readFileSync(configFile(), 'utf8'))
576
+ const cfg = parsed && typeof parsed === 'object' && parsed.config && typeof parsed.config === 'object' ? parsed.config : null
577
+ if (cfg) {
578
+ if (typeof cfg.currency === 'string') merged.currency = cfg.currency
579
+ merged.exchangeRate = numeric(cfg.exchangeRate, merged.exchangeRate) || merged.exchangeRate
580
+ merged.hideBudget = cfg.hideBudget === true
581
+ merged.showTotalWithPlan = cfg.showTotalWithPlan === true
582
+ merged.includeSubagentCost = cfg.includeSubagentCost !== false
583
+ if (typeof cfg.locale === 'string') merged.locale = cfg.locale
584
+ if (cfg.budget && typeof cfg.budget === 'object') Object.assign(merged.budget, cfg.budget)
585
+ if (cfg.balance && typeof cfg.balance === 'object') Object.assign(merged.balance, cfg.balance)
586
+ }
587
+ } catch { /* defaults */ }
588
+ return merged
589
+ }
590
+
591
+ function writeConfig(config) {
592
+ const file = configFile()
593
+ try {
594
+ fs.mkdirSync(path.dirname(file), { recursive: true })
595
+ const temp = `${file}.${process.pid}.tmp`
596
+ fs.writeFileSync(temp, JSON.stringify({ version: 1, updatedAt: Date.now(), config }, null, 2), { mode: 0o600 })
597
+ fs.renameSync(temp, file)
598
+ return true
599
+ } catch (_) {
600
+ return false
601
+ }
602
+ }
603
+
604
+ // ---------------------------------------------------------------------------
605
+ // Wire projection
606
+ // ---------------------------------------------------------------------------
607
+
608
+ function amountOf(bucket, config) {
609
+ const base = bucket ? numeric(bucket.cost) : 0
610
+ const rate = numeric(config.exchangeRate, 1) || 1
611
+ return config.currency === 'USD' ? base : base * rate
612
+ }
613
+
614
+ function publicBucket(bucket, config) {
615
+ if (!bucket || numeric(bucket.calls) === 0) return null
616
+ return { amount: amountOf(bucket, config), tokens: numeric(bucket.tokens), calls: numeric(bucket.calls) }
617
+ }
618
+
619
+ function summaryOf(acc, config, now = Date.now()) {
620
+ const currency = ['CNY', 'USD', 'EUR'].includes(config.currency) ? config.currency : 'CNY'
621
+ const todayKey = dayKeyOf(now)
622
+ const monthKey = monthKeyOf(now)
623
+ const budget = config.budget || {}
624
+ const budgetAmount = numeric(budget.amount)
625
+ const budgetBucket = budget.period === 'day'
626
+ ? acc.days[todayKey]
627
+ : budget.period === 'all' ? acc.total : acc.months[monthKey]
628
+ const budgetUsed = budgetAmount > 0 ? amountOf(budgetBucket, config) : 0
629
+ const budgetPercent = budgetAmount > 0 ? Math.min(999, Math.round(budgetUsed / budgetAmount * 1000) / 10) : null
630
+ return {
631
+ available: true,
632
+ currency,
633
+ today: publicBucket(acc.days[todayKey], config),
634
+ month: publicBucket(acc.months[monthKey], config),
635
+ total: publicBucket(acc.total, config),
636
+ budget: {
637
+ enabled: budget.enabled === true,
638
+ amount: budgetAmount,
639
+ period: budget.period || 'month',
640
+ used: budgetUsed,
641
+ percent: budgetPercent,
642
+ },
643
+ balance: null,
644
+ updatedAt: now,
645
+ }
646
+ }
647
+
648
+ function configOf(config) {
649
+ return {
650
+ currency: ['CNY', 'USD', 'EUR'].includes(config.currency) ? config.currency : 'CNY',
651
+ budget: {
652
+ enabled: config.budget?.enabled === true,
653
+ amount: numeric(config.budget?.amount),
654
+ period: ['day', 'month', 'all'].includes(config.budget?.period) ? config.budget.period : 'month',
655
+ },
656
+ balance: { display: ['sidebar', 'settings', 'both', 'off'].includes(config.balance?.display) ? config.balance.display : 'both' },
657
+ hideBudget: config.hideBudget === true,
658
+ showTotalWithPlan: config.showTotalWithPlan === true,
659
+ locale: ['zh', 'en', 'auto'].includes(config.locale) ? config.locale : 'zh',
660
+ includeSubagentCost: config.includeSubagentCost !== false,
661
+ }
662
+ }
663
+
664
+ function validatePatch(patch) {
665
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return { ok: false, message: 'patch 必须是对象' }
666
+ const errors = []
667
+ if (patch.currency !== undefined && !['CNY', 'USD', 'EUR'].includes(patch.currency)) errors.push('currency 必须是 CNY、USD 或 EUR')
668
+ const b = patch.budget
669
+ if (b !== undefined) {
670
+ if (!b || typeof b !== 'object' || Array.isArray(b)) errors.push('budget 必须是对象')
671
+ else {
672
+ if (b.enabled !== undefined && typeof b.enabled !== 'boolean') errors.push('budget.enabled 必须是布尔值')
673
+ if (b.amount !== undefined && !FINITE(b.amount)) errors.push('budget.amount 必须是非负数')
674
+ if (b.period !== undefined && !['day', 'month', 'all'].includes(b.period)) errors.push('budget.period 必须是 day、month 或 all')
675
+ }
676
+ }
677
+ const bal = patch.balance
678
+ if (bal !== undefined && bal !== null) {
679
+ if (!bal || typeof bal !== 'object') errors.push('balance 必须是对象')
680
+ else if (bal.display !== undefined && !['sidebar', 'settings', 'both', 'off'].includes(bal.display)) errors.push('balance.display 必须是 sidebar、settings、both 或 off')
681
+ }
682
+ if (patch.hideBudget !== undefined && typeof patch.hideBudget !== 'boolean') errors.push('hideBudget 必须是布尔值')
683
+ if (patch.showTotalWithPlan !== undefined && typeof patch.showTotalWithPlan !== 'boolean') errors.push('showTotalWithPlan 必须是布尔值')
684
+ if (patch.includeSubagentCost !== undefined && typeof patch.includeSubagentCost !== 'boolean') errors.push('includeSubagentCost 必须是布尔值')
685
+ return { ok: errors.length === 0, message: errors.join(';') || null }
686
+ }
687
+
688
+ // ---------------------------------------------------------------------------
689
+ // Cache
690
+ // ---------------------------------------------------------------------------
691
+
692
+ let cachedAt = 0
693
+ let cachedValue = null
694
+ function dropCache() { cachedAt = 0; cachedValue = null }
695
+
696
+ // ---------------------------------------------------------------------------
697
+ // Handler
698
+ // ---------------------------------------------------------------------------
699
+
700
+ /**
701
+ * Serve one `cost-meter` request.
702
+ *
703
+ * @param {object} api DSH host adapter (only used by the bounded fallback)
704
+ * @param {{requestId?:string, action?:string, patch?:object}} msg mobile frame
705
+ * @param {{now?:Function, signal?:object, configOverride?:object}} [options] tests
706
+ * @returns {Promise<object>} wire frame
707
+ */
708
+ export async function handleCostMeter(api, msg, options) {
709
+ const requestId = typeof msg?.requestId === 'string' ? msg.requestId.slice(0, MAX_REQUEST_ID) : ''
710
+ if (!requestId) return { kind: 'error', requestType: 'cost-meter', code: 'bad-request', message: 'cost-meter requires a requestId' }
711
+ const action = typeof msg?.action === 'string' ? msg.action : 'summary'
712
+ const now = options && typeof options.now === 'function' ? options.now() : Date.now()
713
+ const signal = options && options.signal
714
+ const config = options && options.configOverride
715
+ ? {
716
+ ...DEFAULT_CONFIG,
717
+ ...options.configOverride,
718
+ budget: { ...DEFAULT_CONFIG.budget, ...(options.configOverride.budget || {}) },
719
+ balance: { ...DEFAULT_CONFIG.balance, ...(options.configOverride.balance || {}) },
720
+ }
721
+ : readConfig()
722
+
723
+ const fail = (code, message) => ({ kind: 'error', requestType: 'cost-meter', requestId, code, message })
724
+
725
+ if (action === 'config') {
726
+ return { kind: 'cost-meter', requestId, action: 'config', config: configOf(config) }
727
+ }
728
+
729
+ if (action === 'update') {
730
+ const validated = validatePatch(msg.patch)
731
+ if (!validated.ok) return fail('bad-request', validated.message)
732
+ const patch = msg.patch
733
+ const next = { ...config, budget: { ...config.budget }, balance: { ...config.balance } }
734
+ if (patch.currency !== undefined) next.currency = patch.currency
735
+ if (patch.budget !== undefined) Object.assign(next.budget, patch.budget)
736
+ if (patch.balance !== undefined && patch.balance !== null) Object.assign(next.balance, patch.balance)
737
+ if (patch.hideBudget !== undefined) next.hideBudget = patch.hideBudget
738
+ if (patch.showTotalWithPlan !== undefined) next.showTotalWithPlan = patch.showTotalWithPlan
739
+ if (patch.includeSubagentCost !== undefined) next.includeSubagentCost = patch.includeSubagentCost
740
+ writeConfig(next)
741
+ dropCache()
742
+ const { acc } = await collectTotals(api, signal)
743
+ return { kind: 'cost-meter', requestId, action: 'update', summary: summaryOf(acc, next, now), config: configOf(next) }
744
+ }
745
+
746
+ if (action !== 'summary' && action !== 'refresh') {
747
+ return fail('bad-request', `未知的 cost-meter action: ${action}`)
748
+ }
749
+
750
+ if (action === 'refresh') dropCache()
751
+ else if (cachedValue !== null && Date.now() - cachedAt < CACHE_TTL_MS
752
+ && JSON.stringify(cachedValue.config) === JSON.stringify(configOf(config))) {
753
+ return { kind: 'cost-meter', requestId, action: 'summary', summary: cachedValue.summary }
754
+ }
755
+
756
+ try {
757
+ const { acc } = await collectTotals(api, signal)
758
+ if (acc.covered === 0) return fail('cost-meter-unavailable', 'Host cost projection is unavailable')
759
+ const summary = summaryOf(acc, config, now)
760
+ cachedAt = Date.now()
761
+ cachedValue = { summary, config: configOf(config) }
762
+ return { kind: 'cost-meter', requestId, action: 'summary', summary }
763
+ } catch (e) {
764
+ writeDiag([{ path: 'throw', error: e && e.message ? String(e.message) : String(e), code: e && e.code }])
765
+ return fail('cost-meter-unavailable', '费用数据读取失败')
766
+ }
767
+ }
768
+
769
+ /** Test seam: reset captured host services and the cache. */
770
+ /**
771
+ * Per-day provider/model buckets for local Coding Plan estimates (SCNet/Qwen).
772
+ * Reads the same bounded projection ladder as the summary and never fails the
773
+ * dashboard when a host does not expose projections.
774
+ */
775
+ export async function collectUsageDays(api, signal) {
776
+ try {
777
+ const { acc } = await collectTotals(api, signal)
778
+ const days = {}
779
+ for (const [date, bucket] of Object.entries(acc.days || {})) {
780
+ if (bucket && bucket.byProviderModel && Object.keys(bucket.byProviderModel).length > 0) {
781
+ days[date] = { byProviderModel: bucket.byProviderModel }
782
+ }
783
+ }
784
+ return days
785
+ } catch (_) {
786
+ return {}
787
+ }
788
+ }
789
+
790
+ export function resetCostMeterForTests() {
791
+ hostSessions = null
792
+ hostProjections = null
793
+ dropCache()
794
+ }