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.
- package/PROTOCOL.md +168 -1
- package/README.md +63 -14
- package/THIRD-PARTY-NOTICES/dsh-cost-meter.txt +31 -0
- package/bin/setup-ip.mjs +0 -0
- package/lib/account-bridge.mjs +57 -0
- package/lib/client.js +5 -5
- package/lib/context-bridge.mjs +1715 -0
- package/lib/cost-meter-bridge.mjs +794 -0
- package/lib/cost-meter-dashboard-bridge.mjs +1449 -0
- package/lib/dsh-host-adapter.mjs +1 -1
- package/lib/index.mjs +111 -15
- package/lib/plugin-updates.mjs +103 -10
- package/lib/vendor/cost-meter/aliyun-balance.mjs +123 -0
- package/lib/vendor/cost-meter/coding-plans.mjs +1668 -0
- package/lib/vendor/cost-meter/credentials-shim.mjs +8 -0
- package/lib/vendor/cost-meter/custom-balance.mjs +281 -0
- package/lib/vendor/cost-meter/factory-quota.mjs +69 -0
- package/lib/vendor/cost-meter/gateway-quota-adapters.mjs +1006 -0
- package/lib/vendor/cost-meter/gateway-quotas.mjs +664 -0
- package/lib/vendor/cost-meter/home-paths-shim.mjs +13 -0
- package/lib/vendor/cost-meter/net.mjs +183 -0
- package/lib/vendor/cost-meter/opendesign.mjs +204 -0
- package/lib/vendor/cost-meter/tokenrhythm-balance.mjs +95 -0
- package/lib/vendor/cost-meter/workbuddy-accounts.mjs +44 -0
- package/lib/vendor/cost-meter/workbuddy-transport.mjs +38 -0
- package/lib/vendor/cost-meter/workbuddy.mjs +356 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +54 -54
|
@@ -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
|
+
}
|