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,1449 @@
1
+ /**
2
+ * Native quota dashboard for the WeChat gateway.
3
+ *
4
+ * Integrates the useful quota sources of the dsh-cost-meter plugin WITHOUT
5
+ * requiring that plugin: official DeepSeek balance, OpenCode Go, the Coding
6
+ * Plan vendors, CLIProxyAPI gateway quotas and configurable custom Provider
7
+ * balances all live in the gateway. The mini program only talks to these
8
+ * actions.
9
+ *
10
+ * Wire (mobile -> gateway):
11
+ * { type:'cost-meter', requestId, action, patch?, ...actionArgs }
12
+ * Actions:
13
+ * summary | config | refresh | update | refresh-source | source-config
14
+ * set-source-key | clear-source-key
15
+ * set-go-key | clear-go-key | set-balance-key | clear-balance-key (legacy)
16
+ *
17
+ * Secrets are written to the DSH credential store only. The persisted settings
18
+ * file never contains a key value, and responses only expose `configured`
19
+ * booleans.
20
+ */
21
+ import fs from 'node:fs'
22
+ import path from 'node:path'
23
+ import os from 'node:os'
24
+ import { handleCostMeter as handleBaseCostMeter, collectUsageDays } from './cost-meter-bridge.mjs'
25
+ import {
26
+ CODING_PLAN_PROVIDER_IDS,
27
+ queryCodingPlan,
28
+ normalizeMiniMaxBaseUrl,
29
+ scnetTokenPlanWindows,
30
+ qwenTokenPlanWindows,
31
+ scnetCanonModelId,
32
+ SCNET_CREDIT_RATES,
33
+ QWEN_CREDIT_RATES,
34
+ } from './vendor/cost-meter/coding-plans.mjs'
35
+ import { queryCustomBalance } from './vendor/cost-meter/custom-balance.mjs'
36
+ import {
37
+ queryGatewayQuota,
38
+ normalizeGatewaySource,
39
+ validateGatewaySource,
40
+ managementKeyVarOf,
41
+ } from './vendor/cost-meter/gateway-quotas.mjs'
42
+
43
+ const GO_USAGE_URL = 'https://opencode.ai/zen/go/v1/usage'
44
+ const GO_KEY_REF = 'OPENCODE_GO_API_KEY'
45
+ const BALANCE_API_URL = 'https://api.deepseek.com/user/balance'
46
+ const BALANCE_KEY_REF = 'DSH_WEAPP_DEEPSEEK_BALANCE_API_KEY'
47
+ const CUSTOM_KEY_PREFIX = 'WEAPP_CUSTOM_BALANCE_'
48
+ const MAX_CUSTOM_BALANCES = 8
49
+ const MAX_GATEWAY_SOURCES = 4
50
+ const DISPLAYS = ['sidebar', 'settings', 'both', 'off']
51
+ const SIDEBAR_STYLES = ['summary', 'simple', 'standard', 'compact']
52
+ const CUSTOM_ADAPTERS = ['custom', 'aliyun', 'tokenrhythm']
53
+ const DEFAULT_PEAK_WINDOWS = Object.freeze([{ start: 1, end: 4 }, { start: 6, end: 10 }])
54
+
55
+ const DEFAULT_SETTINGS = Object.freeze({
56
+ currency: 'CNY',
57
+ symbol: '¥',
58
+ exchangeRate: 7.2,
59
+ decimals: 3,
60
+ display: { sidebarStyle: 'standard', sidebarVisible: false, sidebarTodayMetric: 'both', peakStyle: 'progress' },
61
+ balance: { enabled: true, display: 'both', refreshMinutes: 5 },
62
+ goQuota: { enabled: false, display: 'both', refreshMinutes: 15, detail: true, main: 'rolling', barDirection: 'used' },
63
+ peak: { enabled: true, display: 'both', effectiveAt: '', windows: DEFAULT_PEAK_WINDOWS, holidays: [] },
64
+ budget: { display: 'both' },
65
+ codingPlans: {},
66
+ customBalances: [],
67
+ gatewayQuotas: { sources: [] },
68
+ })
69
+
70
+ /** Vendor order mirrors the plugin's quota page. */
71
+ const PLAN_LABELS = {
72
+ opendesign: 'OpenDesign',
73
+ factory: 'Factory (Droid)',
74
+ anthropic: 'Anthropic Claude',
75
+ zai: 'Z.ai / 智谱 GLM',
76
+ minimax: 'MiniMax Token Plan',
77
+ kimi: 'Kimi / Moonshot',
78
+ openrouter: 'OpenRouter',
79
+ siliconflow: 'SiliconFlow 硅基流动',
80
+ commandcode: 'CommandCode',
81
+ clinepass: 'Cline Pass',
82
+ scnet: 'SCNet Token Plan',
83
+ volcengine: '火山方舟 Coding Plan',
84
+ qwen: '千问 Token Plan',
85
+ mimo: '小米 MiMo Token Plan',
86
+ workbuddy: 'WorkBuddy 积分',
87
+ }
88
+
89
+ /** Credential refs resolved from the DSH credential store or the environment. */
90
+ const PLAN_KEY_REFS = {
91
+ opendesign: ['OPENDESIGN_CONTROL_KEY', 'VELA_CONTROL_KEY'],
92
+ factory: ['FACTORY_API_KEY'],
93
+ anthropic: ['ANTHROPIC_OAUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'],
94
+ zai: ['ZAI_API_KEY', 'BIGMODEL_API_KEY'],
95
+ minimax: ['MINIMAX_API_KEY'],
96
+ kimi: ['KIMI_CODING_API_KEY', 'MOONSHOT_API_KEY', 'KIMI_API_KEY'],
97
+ openrouter: ['OPENROUTER_API_KEY'],
98
+ siliconflow: ['SILICONFLOW_API_KEY'],
99
+ commandcode: ['COMMANDCODE_API_KEY'],
100
+ clinepass: ['CLINE_PASS_API_KEY'],
101
+ scnet: [],
102
+ volcengine: ['VOLC_ACCESSKEY', 'VOLCENGINE_ACCESS_KEY_ID', 'VOLCENGINE_ACCESS_KEY', 'ARK_ACCESS_KEY_ID'],
103
+ qwen: [],
104
+ mimo: ['MIMO_COOKIE', 'XIAOMI_MIMO_COOKIE'],
105
+ workbuddy: [],
106
+ }
107
+ const VOLC_SECRET_REFS = ['VOLC_SECRETKEY', 'VOLCENGINE_SECRET_ACCESS_KEY', 'VOLCENGINE_SECRET_KEY', 'ARK_SECRET_ACCESS_KEY']
108
+ const PLAN_EXTRA_FIELDS = {
109
+ opendesign: ['workspaceId'],
110
+ factory: ['orgId', 'factoryPool'],
111
+ minimax: ['baseUrl'],
112
+ scnet: ['planCredits', 'planStart'],
113
+ qwen: ['planCredits', 'planStart'],
114
+ }
115
+
116
+ const SCNET_MODELS = new Set(Object.keys(SCNET_CREDIT_RATES).map(scnetCanonModelId))
117
+ const QWEN_MODELS = new Set(Object.keys(QWEN_CREDIT_RATES).map(scnetCanonModelId))
118
+
119
+ const GO_CACHE_TTL_MS = 60_000
120
+ const BALANCE_CACHE_TTL_MS = 60_000
121
+ let goCache = { at: 0, value: null }
122
+ let balanceCache = { at: 0, value: null }
123
+ const sourceCache = new Map()
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Settings persistence
127
+ // ---------------------------------------------------------------------------
128
+
129
+ function settingsFile() {
130
+ if (process.env.DSH_WEAPP_COST_DASHBOARD_FILE) return process.env.DSH_WEAPP_COST_DASHBOARD_FILE
131
+ const configuredHome = String(process.env.DSH_HOME || '').trim()
132
+ const dshHome = configuredHome === '~' ? os.homedir()
133
+ : configuredHome.startsWith('~/') ? path.join(os.homedir(), configuredHome.slice(2))
134
+ : configuredHome ? path.resolve(configuredHome) : path.join(os.homedir(), '.dsh')
135
+ return path.join(dshHome, 'storages', 'weapp-cost-dashboard.json')
136
+ }
137
+
138
+ function finite(value, fallback = 0) {
139
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
140
+ }
141
+
142
+ function text(value) {
143
+ return typeof value === 'string' ? value.trim() : ''
144
+ }
145
+
146
+ function record(value) {
147
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null
148
+ }
149
+
150
+ /** Bound host projection reads so a slow account service cannot time out the whole config request. */
151
+ function withTimeout(promise, ms, fallback) {
152
+ return Promise.race([
153
+ Promise.resolve(promise).catch(() => fallback),
154
+ new Promise(resolve => setTimeout(() => resolve(fallback), ms)),
155
+ ])
156
+ }
157
+
158
+ function displayOf(value, fallback = 'both') {
159
+ return DISPLAYS.includes(value) ? value : fallback
160
+ }
161
+
162
+ function refreshOf(value, fallback = 15) {
163
+ const n = Math.floor(Number(value))
164
+ return Number.isInteger(n) ? Math.max(1, Math.min(1440, n)) : fallback
165
+ }
166
+
167
+ function normalizePeakWindows(rows) {
168
+ const windows = Array.isArray(rows) ? rows
169
+ .filter(row => row && Number.isInteger(row.start) && row.start >= 0 && row.start <= 23
170
+ && Number.isInteger(row.end) && row.end >= 0 && row.end <= 24 && row.start !== row.end)
171
+ .slice(0, 8).map(row => ({ start: row.start, end: row.end })) : []
172
+ return windows.length ? windows : DEFAULT_PEAK_WINDOWS.map(row => ({ ...row }))
173
+ }
174
+
175
+ function normalizeCustomEntry(raw) {
176
+ const input = record(raw) || {}
177
+ const request = record(input.request) || {}
178
+ const extract = record(input.extract) || {}
179
+ const headers = {}
180
+ for (const [name, value] of Object.entries(record(request.headers) || {})) {
181
+ if (typeof name !== 'string' || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,80}$/.test(name)) continue
182
+ if (typeof value !== 'string' || value.length > 1024) continue
183
+ headers[name] = value
184
+ }
185
+ const allowedHosts = Array.isArray(input.allowedHosts)
186
+ ? [...new Set(input.allowedHosts.map(item => text(item).toLowerCase()).filter(Boolean))].slice(0, 16) : []
187
+ const adapter = CUSTOM_ADAPTERS.includes(input.adapter) ? input.adapter : 'custom'
188
+ const method = ['GET', 'POST'].includes(String(request.method || '').toUpperCase())
189
+ ? String(request.method).toUpperCase() : 'GET'
190
+ const copyRule = rule => {
191
+ if (typeof rule === 'string' && rule.trim()) return rule.trim().slice(0, 200)
192
+ const value = record(rule)
193
+ if (!value) return ''
194
+ if (Array.isArray(value.paths) && value.paths.every(item => typeof item === 'string')) {
195
+ return { op: value.op === 'add' ? 'add' : 'subtract', paths: value.paths.map(item => item.slice(0, 200)).slice(0, 4) }
196
+ }
197
+ return ''
198
+ }
199
+ return {
200
+ enabled: input.enabled === true,
201
+ adapter,
202
+ label: text(input.label).slice(0, 40),
203
+ unit: text(input.unit).slice(0, 16) || 'USD',
204
+ display: displayOf(input.display),
205
+ refreshMinutes: refreshOf(input.refreshMinutes, 15),
206
+ convertToDisplayCurrency: input.convertToDisplayCurrency === true,
207
+ allowInsecureHttp: input.allowInsecureHttp === true,
208
+ allowedHosts,
209
+ secretHeader: text(input.secretHeader).slice(0, 80),
210
+ request: {
211
+ url: text(request.url).slice(0, 2048),
212
+ method,
213
+ headers,
214
+ ...(typeof request.body === 'string' && request.body.length <= 8192
215
+ ? { body: request.body }
216
+ : request.body !== undefined && request.body !== null && typeof request.body === 'object'
217
+ ? { body: request.body } : {}),
218
+ },
219
+ extract: {
220
+ remaining: copyRule(extract.remaining),
221
+ maxBudget: copyRule(extract.maxBudget),
222
+ spend: copyRule(extract.spend),
223
+ ...(typeof extract.unit === 'string' && extract.unit.trim() ? { unit: extract.unit.trim().slice(0, 16) } : {}),
224
+ },
225
+ }
226
+ }
227
+
228
+ function normalizePlanSettings(id, raw) {
229
+ const input = record(raw) || {}
230
+ const extra = PLAN_EXTRA_FIELDS[id] || []
231
+ const settings = {
232
+ enabled: input.enabled === true,
233
+ display: displayOf(input.display),
234
+ refreshMinutes: refreshOf(input.refreshMinutes, 15),
235
+ }
236
+ if (extra.includes('workspaceId')) settings.workspaceId = text(input.workspaceId).slice(0, 128)
237
+ if (extra.includes('orgId')) settings.orgId = text(input.orgId).slice(0, 128)
238
+ if (extra.includes('factoryPool')) settings.factoryPool = ['standard', 'enterprise'].includes(input.factoryPool) ? input.factoryPool : 'standard'
239
+ if (extra.includes('baseUrl')) {
240
+ const normalized = normalizeMiniMaxBaseUrl(input.baseUrl)
241
+ settings.baseUrl = normalized === null ? '' : normalized
242
+ }
243
+ if (extra.includes('planCredits')) {
244
+ const credits = Number(input.planCredits)
245
+ settings.planCredits = Number.isFinite(credits) && credits > 0 ? credits : 0
246
+ settings.planStart = typeof input.planStart === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(input.planStart) ? input.planStart : ''
247
+ }
248
+ return settings
249
+ }
250
+
251
+ function normalizeSettings(raw) {
252
+ const input = record(raw) || {}
253
+ const go = record(input.goQuota) || {}
254
+ const peak = record(input.peak) || {}
255
+ const display = record(input.display) || {}
256
+ const balance = record(input.balance) || {}
257
+ const budget = record(input.budget) || {}
258
+ const codingPlans = {}
259
+ for (const id of CODING_PLAN_PROVIDER_IDS) {
260
+ const saved = record((record(input.codingPlans) || {})[id])
261
+ if (saved) codingPlans[id] = normalizePlanSettings(id, saved)
262
+ }
263
+ const customBalances = Array.isArray(input.customBalances)
264
+ ? input.customBalances.slice(0, MAX_CUSTOM_BALANCES).map(normalizeCustomEntry) : []
265
+ const rawSources = record(input.gatewayQuotas)
266
+ const gatewaySources = Array.isArray(rawSources && rawSources.sources)
267
+ ? rawSources.sources.slice(0, MAX_GATEWAY_SOURCES).map(source => {
268
+ try { return normalizeGatewaySource(source) } catch { return null }
269
+ }).filter(Boolean)
270
+ : []
271
+ return {
272
+ currency: input.currency === 'USD' ? 'USD' : 'CNY',
273
+ symbol: typeof input.symbol === 'string' && input.symbol.trim() && input.symbol.trim().length <= 8
274
+ ? input.symbol.trim() : DEFAULT_SETTINGS.symbol,
275
+ exchangeRate: Math.max(0.01, Math.min(1000, finite(input.exchangeRate, DEFAULT_SETTINGS.exchangeRate))),
276
+ decimals: Math.max(2, Math.min(6, Math.floor(finite(input.decimals, DEFAULT_SETTINGS.decimals)))),
277
+ display: {
278
+ sidebarStyle: SIDEBAR_STYLES.includes(display.sidebarStyle) ? display.sidebarStyle : 'standard',
279
+ sidebarVisible: display.sidebarVisible === true,
280
+ sidebarTodayMetric: ['amount', 'tokens', 'both'].includes(display.sidebarTodayMetric) ? display.sidebarTodayMetric : 'both',
281
+ peakStyle: display.peakStyle === 'classic' ? 'classic' : 'progress',
282
+ },
283
+ balance: {
284
+ enabled: balance.enabled !== false,
285
+ display: displayOf(balance.display),
286
+ refreshMinutes: refreshOf(balance.refreshMinutes, 5),
287
+ },
288
+ goQuota: {
289
+ enabled: go.enabled === true,
290
+ display: displayOf(go.display),
291
+ refreshMinutes: refreshOf(go.refreshMinutes, 15),
292
+ detail: go.detail !== false,
293
+ main: ['weekly', 'monthly'].includes(go.main) ? go.main : 'rolling',
294
+ barDirection: go.barDirection === 'remaining' ? 'remaining' : 'used',
295
+ },
296
+ peak: {
297
+ enabled: peak.enabled !== false,
298
+ display: displayOf(peak.display),
299
+ effectiveAt: typeof peak.effectiveAt === 'string' ? peak.effectiveAt.slice(0, 32) : '',
300
+ windows: normalizePeakWindows(peak.windows),
301
+ holidays: Array.isArray(peak.holidays)
302
+ ? peak.holidays.filter(value => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)).slice(0, 100) : [],
303
+ },
304
+ budget: { display: displayOf(budget.display) },
305
+ codingPlans,
306
+ customBalances,
307
+ gatewayQuotas: { sources: gatewaySources },
308
+ }
309
+ }
310
+
311
+ function readSettings() {
312
+ try {
313
+ const parsed = JSON.parse(fs.readFileSync(settingsFile(), 'utf8'))
314
+ return normalizeSettings(parsed && parsed.settings)
315
+ } catch {
316
+ return normalizeSettings(DEFAULT_SETTINGS)
317
+ }
318
+ }
319
+
320
+ function writeSettings(settings) {
321
+ const file = settingsFile()
322
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`
323
+ try {
324
+ fs.mkdirSync(path.dirname(file), { recursive: true })
325
+ fs.writeFileSync(temp, JSON.stringify({ version: 2, updatedAt: Date.now(), settings }, null, 2), { mode: 0o600 })
326
+ fs.renameSync(temp, file)
327
+ return true
328
+ } catch {
329
+ try { fs.unlinkSync(temp) } catch { /* ignore cleanup errors */ }
330
+ return false
331
+ }
332
+ }
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // Credentials
336
+ // ---------------------------------------------------------------------------
337
+
338
+ function credentialService(hostContext) {
339
+ try { return hostContext && typeof hostContext.get === 'function' ? hostContext.get('credentials') : null }
340
+ catch { return null }
341
+ }
342
+
343
+ function normalizeKey(value) {
344
+ if (typeof value !== 'string') return null
345
+ const key = value.trim().replace(/^Bearer[ \t]+/i, '').trim()
346
+ return key && /^[\x21-\x7e]+$/.test(key) ? key : null
347
+ }
348
+
349
+ async function resolveRef(hostContext, ref) {
350
+ const credentials = credentialService(hostContext)
351
+ try {
352
+ const hit = await credentials?.resolve?.(ref)
353
+ const value = normalizeKey(hit?.value)
354
+ if (value) return value
355
+ } catch { /* fall through to env */ }
356
+ return normalizeKey(process.env[ref])
357
+ }
358
+
359
+ async function resolveFirstRef(hostContext, refs) {
360
+ for (const ref of refs || []) {
361
+ const value = await resolveRef(hostContext, ref)
362
+ if (value) return { value, ref }
363
+ }
364
+ return null
365
+ }
366
+
367
+ async function resolveVolcengineKeys(hostContext) {
368
+ const access = await resolveFirstRef(hostContext, PLAN_KEY_REFS.volcengine)
369
+ if (!access) return null
370
+ const secret = await resolveFirstRef(hostContext, VOLC_SECRET_REFS)
371
+ if (!secret) return null
372
+ return { accessKeyId: access.value, secretAccessKey: secret.value }
373
+ }
374
+
375
+ /** Reads the Claude Code OAuth token file when no credential/env token is set. */
376
+ function claudeOauthToken() {
377
+ const files = [
378
+ path.join(os.homedir(), '.claude', '.credentials.json'),
379
+ process.env.CLAUDE_CONFIG_DIR ? path.join(process.env.CLAUDE_CONFIG_DIR, '.credentials.json') : '',
380
+ ].filter(Boolean)
381
+ for (const file of files) {
382
+ try {
383
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
384
+ const token = normalizeKey(parsed?.claudeAiOauth?.accessToken || parsed?.accessToken)
385
+ if (token) return token
386
+ } catch { /* Missing or invalid credentials file. */ }
387
+ }
388
+ return null
389
+ }
390
+
391
+ async function resolvePlanKey(id, hostContext) {
392
+ if (id === 'volcengine') return resolveVolcengineKeys(hostContext)
393
+ if (id === 'workbuddy') return {}
394
+ const refs = PLAN_KEY_REFS[id]
395
+ if (!refs || refs.length === 0) return null
396
+ const hit = await resolveFirstRef(hostContext, refs)
397
+ if (hit) return hit.value
398
+ if (id === 'anthropic') return claudeOauthToken()
399
+ return null
400
+ }
401
+
402
+ async function planConfigured(id, hostContext) {
403
+ if (id === 'scnet' || id === 'qwen' || id === 'workbuddy') return true
404
+ const key = await resolvePlanKey(id, hostContext)
405
+ if (key === null) return false
406
+ if (typeof key === 'string') return key.length > 0
407
+ return Object.keys(key || {}).length > 0
408
+ }
409
+
410
+ async function resolveGoKey(hostContext) {
411
+ const refs = new Set([GO_KEY_REF])
412
+ try {
413
+ const providers = hostContext?.get?.('settings')?.get?.('llm-pi-ai')?.providers
414
+ for (const profile of Object.values(providers || {})) {
415
+ if (typeof profile?.baseURL !== 'string' || typeof profile?.apiKeyEnv !== 'string') continue
416
+ const url = new URL(profile.baseURL)
417
+ if (url.origin === 'https://opencode.ai' && /^\/zen\/go\/v1\/?$/.test(url.pathname)
418
+ && !url.username && !url.password && !url.search && !url.hash && /^[A-Za-z_][A-Za-z0-9_]*$/.test(profile.apiKeyEnv)) {
419
+ refs.add(profile.apiKeyEnv)
420
+ }
421
+ }
422
+ } catch { /* Older hosts may not expose the model settings service. */ }
423
+ refs.add('OPENCODE_API_KEY')
424
+ for (const ref of refs) {
425
+ const key = await resolveRef(hostContext, ref)
426
+ if (key) return key
427
+ }
428
+ if (process.env.OPENCODE_AUTH_CONTENT) {
429
+ try {
430
+ const auth = JSON.parse(process.env.OPENCODE_AUTH_CONTENT)
431
+ const key = normalizeKey(auth?.['opencode-go']?.key)
432
+ if (key) return key
433
+ } catch { /* Invalid OpenCode auth JSON falls back to local files. */ }
434
+ }
435
+ const authFiles = [
436
+ process.env.XDG_DATA_HOME ? path.join(process.env.XDG_DATA_HOME, 'opencode', 'auth.json') : '',
437
+ path.join(os.homedir(), '.local', 'share', 'opencode', 'auth.json'),
438
+ process.env.XDG_CONFIG_HOME ? path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'auth.json') : '',
439
+ path.join(os.homedir(), '.config', 'opencode', 'auth.json'),
440
+ ].filter(Boolean)
441
+ for (const file of authFiles) {
442
+ try {
443
+ const auth = JSON.parse(fs.readFileSync(file, 'utf8'))
444
+ const entry = auth?.['opencode-go']
445
+ const key = (entry?.type === undefined || entry.type === 'api') ? normalizeKey(entry?.key) : null
446
+ if (key) return key
447
+ } catch { /* Missing or invalid OpenCode login file. */ }
448
+ }
449
+ return null
450
+ }
451
+
452
+ async function resolveBalanceKey(hostContext) {
453
+ const refs = new Set([BALANCE_KEY_REF, 'DEEPSEEK_API_KEY'])
454
+ try {
455
+ const providers = hostContext?.get?.('settings')?.get?.('llm-pi-ai')?.providers
456
+ for (const profile of Object.values(providers || {})) {
457
+ if (typeof profile?.baseURL !== 'string' || typeof profile?.apiKeyEnv !== 'string') continue
458
+ const url = new URL(profile.baseURL)
459
+ if (url.origin === 'https://api.deepseek.com' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(profile.apiKeyEnv)) refs.add(profile.apiKeyEnv)
460
+ }
461
+ } catch { /* Older hosts may not expose the model settings service. */ }
462
+ for (const ref of refs) {
463
+ const key = await resolveRef(hostContext, ref)
464
+ if (key) return key
465
+ }
466
+ return null
467
+ }
468
+
469
+ async function writeCredential(hostContext, ref, value) {
470
+ const key = normalizeKey(value)
471
+ if (!key || key.length > 4096) throw new Error('请输入有效的凭据')
472
+ const credentials = credentialService(hostContext)
473
+ if (!credentials || typeof credentials.set !== 'function') throw new Error('DSH 凭据库暂不可用,未保存凭据')
474
+ await credentials.set(ref, key)
475
+ }
476
+
477
+ async function clearCredential(hostContext, ref) {
478
+ const credentials = credentialService(hostContext)
479
+ if (!credentials || typeof credentials.unset !== 'function') throw new Error('DSH 凭据库暂不可用,未清除凭据')
480
+ try { await credentials.unset(ref) } catch { /* missing ref is fine */ }
481
+ }
482
+
483
+ async function keyConfigured(hostContext) {
484
+ return Boolean(await resolveGoKey(hostContext))
485
+ }
486
+
487
+ async function balanceKeyConfigured(hostContext) {
488
+ return Boolean(await resolveBalanceKey(hostContext))
489
+ }
490
+
491
+ // ---------------------------------------------------------------------------
492
+ // Caches
493
+ // ---------------------------------------------------------------------------
494
+
495
+ function sourceKey(kind, id) {
496
+ return kind + ':' + id
497
+ }
498
+
499
+ function cacheFingerprint(value) {
500
+ try { return JSON.stringify(value) } catch { return '' }
501
+ }
502
+
503
+ function cachedSource(key, fingerprint, refreshMinutes, now) {
504
+ const entry = sourceCache.get(key)
505
+ if (!entry || entry.fingerprint !== fingerprint) return null
506
+ const ttl = Math.max(60_000, refreshOf(refreshMinutes, 15) * 60_000)
507
+ return now - entry.at < ttl ? entry.value : null
508
+ }
509
+
510
+ function storeSource(key, fingerprint, value) {
511
+ sourceCache.set(key, { at: Date.now(), fingerprint, value })
512
+ if (sourceCache.size > 96) {
513
+ const oldest = [...sourceCache.entries()].sort((a, b) => a[1].at - b[1].at)[0]
514
+ if (oldest) sourceCache.delete(oldest[0])
515
+ }
516
+ return value
517
+ }
518
+
519
+ function dropQuotaCaches() {
520
+ sourceCache.clear()
521
+ goCache = { at: 0, value: null }
522
+ balanceCache = { at: 0, value: null }
523
+ }
524
+
525
+ // ---------------------------------------------------------------------------
526
+ // Official balance
527
+ // ---------------------------------------------------------------------------
528
+
529
+ function chooseWallet(wallets) {
530
+ if (!Array.isArray(wallets)) return null
531
+ const rows = wallets.map(row => ({ currency: String(row?.currency || '').toUpperCase(), amount: Number(row?.balance) }))
532
+ .filter(row => ['CNY', 'USD', 'EUR'].includes(row.currency) && Number.isFinite(row.amount))
533
+ const positive = rows.filter(row => row.amount > 0)
534
+ return positive.find(row => row.currency === 'CNY') || positive[0] || rows.find(row => row.currency === 'CNY') || rows[0] || null
535
+ }
536
+
537
+ function balanceFromDetails(details) {
538
+ if (!details || details.status !== 'ready') {
539
+ return { status: details?.status === 'failed' ? 'error' : 'off', message: '未登录官方账号或余额暂不可用', currency: '', totalBalance: 0, granted: 0, toppedUp: 0, fetchedAt: Date.now() }
540
+ }
541
+ const totals = new Map()
542
+ const granted = new Map()
543
+ const toppedUp = new Map()
544
+ for (const row of [...(details.value || []), ...(details.bonusWallets || [])]) {
545
+ const currency = String(row?.currency || '').toUpperCase()
546
+ const amount = Number(row?.balance)
547
+ if (!['CNY', 'USD', 'EUR'].includes(currency) || !Number.isFinite(amount)) continue
548
+ totals.set(currency, (totals.get(currency) || 0) + amount)
549
+ }
550
+ for (const row of details.bonusWallets || []) {
551
+ const currency = String(row?.currency || '').toUpperCase()
552
+ const amount = Number(row?.balance)
553
+ if (Number.isFinite(amount) && ['CNY', 'USD', 'EUR'].includes(currency)) granted.set(currency, (granted.get(currency) || 0) + amount)
554
+ }
555
+ for (const row of details.value || []) {
556
+ const currency = String(row?.currency || '').toUpperCase()
557
+ const amount = Number(row?.balance)
558
+ if (Number.isFinite(amount) && ['CNY', 'USD', 'EUR'].includes(currency)) toppedUp.set(currency, (toppedUp.get(currency) || 0) + amount)
559
+ }
560
+ const picked = chooseWallet([...totals].map(([currency, balance]) => ({ currency, balance })))
561
+ if (!picked) return { status: 'error', message: '官方账号未返回余额信息', currency: '', totalBalance: 0, granted: 0, toppedUp: 0, fetchedAt: Date.now() }
562
+ return {
563
+ status: 'ok',
564
+ message: '',
565
+ currency: picked.currency,
566
+ totalBalance: picked.amount,
567
+ granted: granted.get(picked.currency) || 0,
568
+ toppedUp: toppedUp.get(picked.currency) || 0,
569
+ fetchedAt: Date.now(),
570
+ }
571
+ }
572
+
573
+ function balanceFromApiPayload(data) {
574
+ const rows = Array.isArray(data?.balance_infos) ? data.balance_infos.map(row => ({
575
+ currency: String(row?.currency || '').toUpperCase(),
576
+ balance: typeof row?.total_balance === 'number' && Number.isFinite(row.total_balance)
577
+ ? row.total_balance
578
+ : typeof row?.total_balance === 'string' && row.total_balance.trim() !== '' && Number.isFinite(Number(row.total_balance))
579
+ ? Number(row.total_balance) : NaN,
580
+ })) : []
581
+ const picked = chooseWallet(rows)
582
+ return picked ? { status: 'ok', message: '', currency: picked.currency, totalBalance: picked.amount, granted: 0, toppedUp: picked.amount, fetchedAt: Date.now() } : null
583
+ }
584
+
585
+ async function fetchOfficialApiBalance(hostContext, options, now) {
586
+ const key = await resolveBalanceKey(hostContext)
587
+ if (!key) return null
588
+ const fetchImpl = options.fetch || globalThis.fetch
589
+ if (typeof fetchImpl !== 'function') return { status: 'error', message: '当前网关运行环境不支持网络请求', currency: '', totalBalance: 0, granted: 0, toppedUp: 0, fetchedAt: now }
590
+ const controller = new AbortController()
591
+ const timeout = setTimeout(() => controller.abort(), 15000)
592
+ try {
593
+ const response = await fetchImpl(BALANCE_API_URL, {
594
+ method: 'GET', redirect: 'error', signal: controller.signal,
595
+ headers: { authorization: `Bearer ${key}`, accept: 'application/json' },
596
+ })
597
+ if (!response.ok) return { status: 'error', message: `DeepSeek 余额接口返回 HTTP ${response.status}`, currency: '', totalBalance: 0, granted: 0, toppedUp: 0, fetchedAt: now }
598
+ const value = balanceFromApiPayload(await response.json())
599
+ return value || { status: 'error', message: '官方余额响应格式无效', currency: '', totalBalance: 0, granted: 0, toppedUp: 0, fetchedAt: now }
600
+ } catch (error) {
601
+ return { status: 'error', message: error?.name === 'AbortError' ? '官方余额查询超时' : '官方余额查询失败,请检查网络或 API Key', currency: '', totalBalance: 0, granted: 0, toppedUp: 0, fetchedAt: now }
602
+ } finally { clearTimeout(timeout) }
603
+ }
604
+
605
+ async function fetchBalance(api, hostContext, options = {}, force = false) {
606
+ const now = (options.now || Date.now)()
607
+ if (!force && balanceCache.value && now - balanceCache.at < BALANCE_CACHE_TTL_MS) return balanceCache.value
608
+ const locale = options.locale === 'en' ? 'en' : 'zh'
609
+ let accountValue = null
610
+ try {
611
+ if (api?.account && typeof api.account.getBalance === 'function') {
612
+ const details = await api.account.getBalance({ version: 'dsh-weapp/1', locale, timezoneOffsetSeconds: -new Date(now).getTimezoneOffset() * 60 })
613
+ accountValue = balanceFromDetails(details)
614
+ if (accountValue.status === 'ok') {
615
+ balanceCache = { at: now, value: accountValue }
616
+ return accountValue
617
+ }
618
+ }
619
+ } catch {
620
+ accountValue = null
621
+ }
622
+ const apiValue = await fetchOfficialApiBalance(hostContext, options, now)
623
+ const value = apiValue || accountValue || { status: 'off', message: '请登录官方账号或配置 DeepSeek API Key', currency: '', totalBalance: 0, granted: 0, toppedUp: 0, fetchedAt: 0 }
624
+ balanceCache = { at: now, value }
625
+ return value
626
+ }
627
+
628
+ // ---------------------------------------------------------------------------
629
+ // OpenCode Go
630
+ // ---------------------------------------------------------------------------
631
+
632
+ function normalizeWindow(raw) {
633
+ if (!raw || typeof raw !== 'object') return null
634
+ const percent = Number(raw.percent)
635
+ if (!Number.isFinite(percent)) return null
636
+ return { percent: Math.max(0, Math.min(100, percent)), resetsAt: typeof raw.resetsAt === 'string' ? raw.resetsAt : '' }
637
+ }
638
+
639
+ async function fetchGoQuota(hostContext, options = {}, force = false) {
640
+ const now = (options.now || Date.now)()
641
+ if (!force && goCache.value && now - goCache.at < GO_CACHE_TTL_MS) return goCache.value
642
+ const key = await resolveGoKey(hostContext)
643
+ if (!key) return { status: 'off', message: '请在额度设置中配置 OpenCode Go API Key', fetchedAt: 0, rolling: null, weekly: null, monthly: null }
644
+ const fetchImpl = options.fetch || globalThis.fetch
645
+ if (typeof fetchImpl !== 'function') return { status: 'error', message: '当前网关运行环境不支持网络请求', fetchedAt: now, rolling: null, weekly: null, monthly: null }
646
+ const controller = new AbortController()
647
+ const timeout = setTimeout(() => controller.abort(), 15000)
648
+ try {
649
+ const response = await fetchImpl(GO_USAGE_URL, {
650
+ method: 'GET',
651
+ redirect: 'error',
652
+ signal: controller.signal,
653
+ headers: {
654
+ authorization: `Bearer ${key}`,
655
+ accept: 'application/json',
656
+ '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',
657
+ },
658
+ })
659
+ if (!response.ok) {
660
+ const message = response.status === 401 ? 'OpenCode Go API Key 未通过认证 (HTTP 401)'
661
+ : response.status === 403 ? '当前 API Key 没有 OpenCode Go 订阅权限 (HTTP 403)'
662
+ : `OpenCode Go 额度接口返回 HTTP ${response.status}`
663
+ const value = { status: 'error', message, fetchedAt: now, rolling: null, weekly: null, monthly: null }
664
+ if (response.status === 401 || response.status === 403) goCache = { at: now, value }
665
+ return value
666
+ }
667
+ const data = await response.json()
668
+ const usage = data && typeof data.usage === 'object' ? data.usage : {}
669
+ const windows = { rolling: normalizeWindow(usage.rolling), weekly: normalizeWindow(usage.weekly), monthly: normalizeWindow(usage.monthly) }
670
+ if (!Object.values(windows).some(Boolean)) throw new Error('额度接口没有返回有效用量窗口')
671
+ const value = { status: 'ok', message: '', fetchedAt: now, ...windows }
672
+ goCache = { at: now, value }
673
+ return value
674
+ } catch (error) {
675
+ return { status: 'error', message: error?.name === 'AbortError' ? 'OpenCode Go 额度查询超时' : 'OpenCode Go 额度查询失败,请检查网络或 Key', fetchedAt: now, rolling: null, weekly: null, monthly: null }
676
+ } finally {
677
+ clearTimeout(timeout)
678
+ }
679
+ }
680
+
681
+ // ---------------------------------------------------------------------------
682
+ // Coding Plans
683
+ // ---------------------------------------------------------------------------
684
+
685
+ function tmsg(locale, code, vars = {}) {
686
+ const zh = {
687
+ codingPlanKeyMissing: '未配置 {provider} 的凭据',
688
+ codingPlanUnauthorized: '{provider} 凭据无效或没有订阅 (HTTP {code})',
689
+ codingPlanHttp: '{provider} 额度接口返回 HTTP {code}',
690
+ codingPlanInvalidJson: '{provider} 返回了无效数据',
691
+ codingPlanNoUsage: '{provider} 没有返回有效用量窗口',
692
+ codingPlanUnknown: '未知的 Coding Plan 厂商',
693
+ codingPlanCandidatesFailed: '{provider} 的额度端点均不可用',
694
+ mimoCookieMissing: '未配置 {provider} 控制台 Cookie',
695
+ mimoCookieInvalid: '{provider} 控制台 Cookie 无效',
696
+ mimoLoginExpired: '{provider} 登录态已过期,请重新复制 Cookie',
697
+ workbuddyUnlimited: '{provider} 积分无限',
698
+ }
699
+ const template = zh[code] || code
700
+ return template.replace(/\{(\w+)\}/g, (match, name) => String(vars[name] ?? match))
701
+ }
702
+
703
+ function planBase(id, settings) {
704
+ return {
705
+ id,
706
+ label: PLAN_LABELS[id] || id,
707
+ enabled: settings ? settings.enabled === true : false,
708
+ display: settings ? settings.display : 'settings',
709
+ refreshMinutes: settings ? settings.refreshMinutes : 15,
710
+ status: 'pending',
711
+ message: '',
712
+ windows: null,
713
+ fetchedAt: 0,
714
+ }
715
+ }
716
+
717
+ async function queryPlanSource(id, settings, hostContext, options) {
718
+ if (!settings || settings.enabled !== true || settings.display === 'off') return { ...planBase(id, settings), status: 'off' }
719
+ if (id === 'scnet' || id === 'qwen') {
720
+ const credits = Number(settings.planCredits)
721
+ if (!Number.isFinite(credits) || credits <= 0) {
722
+ return { ...planBase(id, settings), status: 'unconfigured', message: '请填写月度 Credits 额度' }
723
+ }
724
+ const days = options.usageDays || {}
725
+ const entry = { planCredits: credits, planStart: settings.planStart }
726
+ const belongsToPlan = (provider, model) => {
727
+ const canon = scnetCanonModelId(String(model || ''))
728
+ if (id === 'qwen') return QWEN_MODELS.has(canon) || String(provider || '').toLowerCase().includes('qwen')
729
+ return SCNET_MODELS.has(canon) || String(provider || '').toLowerCase().includes('scnet')
730
+ }
731
+ const compute = id === 'scnet' ? scnetTokenPlanWindows : qwenTokenPlanWindows
732
+ const result = compute(days, entry, Date.now(), null, belongsToPlan)
733
+ if (!result) return { ...planBase(id, settings), status: 'error', message: '本地估算失败' }
734
+ return {
735
+ ...planBase(id, settings),
736
+ status: 'ok',
737
+ message: '按本地会话用量估算',
738
+ windows: result.windows,
739
+ estimate: { used: result.used, total: result.total, byModel: result.byModel },
740
+ fetchedAt: Date.now(),
741
+ }
742
+ }
743
+ const key = await resolvePlanKey(id, hostContext)
744
+ if (key === null || (typeof key === 'string' && key.length === 0)) {
745
+ return { ...planBase(id, settings), status: 'unconfigured', message: tmsg(options.locale, 'codingPlanKeyMissing', { provider: PLAN_LABELS[id] || id }) }
746
+ }
747
+ try {
748
+ const result = await queryCodingPlan(id, key, options.locale === 'en' ? 'en' : 'zh', tmsg, {
749
+ baseUrl: settings.baseUrl,
750
+ orgId: settings.orgId,
751
+ workspaceId: settings.workspaceId,
752
+ factoryPool: settings.factoryPool,
753
+ signal: options.signal,
754
+ })
755
+ return {
756
+ ...planBase(id, settings),
757
+ status: 'ok',
758
+ message: result && result.message ? result.message : '',
759
+ windows: result && result.windows ? result.windows : null,
760
+ endpoint: result && result.endpoint ? result.endpoint : '',
761
+ fetchedAt: Date.now(),
762
+ }
763
+ } catch (error) {
764
+ const soft = error && error.soft === true
765
+ return {
766
+ ...planBase(id, settings),
767
+ status: soft ? 'off' : 'error',
768
+ message: (error && error.message) || '额度查询失败',
769
+ fetchedAt: Date.now(),
770
+ }
771
+ }
772
+ }
773
+
774
+ // ---------------------------------------------------------------------------
775
+ // Custom provider balances
776
+ // ---------------------------------------------------------------------------
777
+
778
+ function customBase(index, entry) {
779
+ return {
780
+ index,
781
+ label: entry.label || '自定义余额',
782
+ unit: entry.unit || 'USD',
783
+ enabled: entry.enabled === true,
784
+ display: entry.display,
785
+ refreshMinutes: entry.refreshMinutes,
786
+ convertToDisplayCurrency: entry.convertToDisplayCurrency === true,
787
+ secretHeader: entry.secretHeader || '',
788
+ status: 'pending',
789
+ message: '',
790
+ remaining: null,
791
+ maxBudget: null,
792
+ spend: null,
793
+ percent: null,
794
+ fetchedAt: 0,
795
+ }
796
+ }
797
+
798
+ async function queryCustomSource(index, entry, hostContext, options) {
799
+ const base = customBase(index, entry)
800
+ if (!entry.enabled || entry.display === 'off') return { ...base, status: 'off' }
801
+ if (entry.adapter === 'custom' && !entry.request.url) {
802
+ return { ...base, status: 'unconfigured', message: '请填写余额接口地址' }
803
+ }
804
+ const headers = { ...(entry.request.headers || {}) }
805
+ if (entry.secretHeader && headers[entry.secretHeader] === undefined) headers[entry.secretHeader] = `{{${CUSTOM_KEY_PREFIX}${index}}}`
806
+ const config = {
807
+ customBalance: {
808
+ ...entry,
809
+ request: { ...entry.request, headers },
810
+ },
811
+ }
812
+ try {
813
+ const result = await queryCustomBalance(hostContext, config, { signal: options.signal })
814
+ const maxBudget = Number.isFinite(result.maxBudget) ? result.maxBudget : null
815
+ const percent = maxBudget > 0 ? Math.max(0, Math.min(100, (result.remaining / maxBudget) * 100)) : null
816
+ return {
817
+ ...base,
818
+ status: 'ok',
819
+ message: '',
820
+ remaining: result.remaining,
821
+ maxBudget,
822
+ spend: Number.isFinite(result.spend) ? result.spend : null,
823
+ percent,
824
+ unit: result.unit || base.unit,
825
+ label: result.label || base.label,
826
+ fetchedAt: Date.now(),
827
+ }
828
+ } catch (error) {
829
+ return {
830
+ ...base,
831
+ status: error && error.soft === true ? 'off' : 'error',
832
+ message: (error && error.message) || '自定义余额查询失败',
833
+ fetchedAt: Date.now(),
834
+ }
835
+ }
836
+ }
837
+
838
+ // ---------------------------------------------------------------------------
839
+ // CLIProxyAPI gateway quotas
840
+ // ---------------------------------------------------------------------------
841
+
842
+ function gatewayBase(source) {
843
+ return {
844
+ id: source.id,
845
+ type: source.type,
846
+ label: source.label,
847
+ enabled: source.enabled !== false,
848
+ display: source.display,
849
+ refreshMinutes: source.refreshMinutes,
850
+ baseURL: source.baseURL,
851
+ allowInsecureHttp: source.allowInsecureHttp === true,
852
+ allowedHosts: source.allowedHosts,
853
+ includeProviders: source.includeProviders,
854
+ keyVar: managementKeyVarOf(source),
855
+ status: 'pending',
856
+ message: '',
857
+ accounts: [],
858
+ fetchedAt: 0,
859
+ keyConfigured: false,
860
+ }
861
+ }
862
+
863
+ async function queryGatewaySource(source, hostContext, options) {
864
+ if (!source.enabled || source.display === 'off') return { ...gatewayBase(source), status: 'off' }
865
+ try {
866
+ const result = await queryGatewayQuota(hostContext, source, { signal: options.signal, fetchImpl: options.fetch })
867
+ return {
868
+ ...gatewayBase(source),
869
+ ...result,
870
+ accounts: Array.isArray(result.accounts) ? result.accounts.slice(0, 24) : [],
871
+ }
872
+ } catch (error) {
873
+ return { ...gatewayBase(source), status: 'error', message: error?.code || error?.message || '网关额度查询失败' }
874
+ }
875
+ }
876
+
877
+ // ---------------------------------------------------------------------------
878
+ // Dashboard assembly
879
+ // ---------------------------------------------------------------------------
880
+
881
+ async function runPool(tasks, limit) {
882
+ let cursor = 0
883
+ const workers = Array.from({ length: Math.min(limit, tasks.length) }, async () => {
884
+ while (cursor < tasks.length) {
885
+ const index = cursor
886
+ cursor += 1
887
+ try { await tasks[index]() } catch { /* each task records its own failure */ }
888
+ }
889
+ })
890
+ await Promise.all(workers)
891
+ }
892
+
893
+ function planAppliesToScope(settings, scope) {
894
+ if (!settings || settings.enabled !== true) return false
895
+ if (scope === 'all') return true
896
+ return settings.display === 'sidebar' || settings.display === 'both'
897
+ }
898
+
899
+ function customAppliesToScope(entry, scope) {
900
+ if (!entry || entry.enabled !== true) return false
901
+ return scope === 'all' || entry.display === 'sidebar' || entry.display === 'both'
902
+ }
903
+
904
+ function gatewayAppliesToScope(source, scope) {
905
+ if (!source || source.enabled === false) return false
906
+ return scope === 'all' || source.display === 'sidebar' || source.display === 'both'
907
+ }
908
+
909
+ function needsUsageDays(id) {
910
+ return id === 'scnet' || id === 'qwen'
911
+ }
912
+
913
+ async function dashboardOf(api, hostContext, settings, options = {}, { force = false, scope = 'all', autoRefresh = false, cacheOnly = false } = {}) {
914
+ const now = Date.now()
915
+ const locale = options.locale === 'en' ? 'en' : 'zh'
916
+ const needsUsage = CODING_PLAN_PROVIDER_IDS.some(id => needsUsageDays(id) && planAppliesToScope(settings.codingPlans[id], scope))
917
+ const usageDays = needsUsage ? await collectUsageDays(api, options.signal) : {}
918
+
919
+ const plans = []
920
+ const planResults = []
921
+ const tasks = []
922
+
923
+ for (const id of CODING_PLAN_PROVIDER_IDS) {
924
+ const cfg = settings.codingPlans[id]
925
+ const index = plans.length
926
+ plans.push({ id, settings: cfg || null })
927
+ if (!planAppliesToScope(cfg, scope)) {
928
+ planResults[index] = { ...planBase(id, cfg), status: 'off' }
929
+ continue
930
+ }
931
+ const fingerprint = cacheFingerprint({ id, cfg, configured: await planConfigured(id, hostContext) })
932
+ const cached = !force ? cachedSource(sourceKey('plan', id), fingerprint, cfg.refreshMinutes, now) : null
933
+ if (cached) { planResults[index] = cached; continue }
934
+ if (!force && !autoRefresh) {
935
+ planResults[index] = { ...planBase(id, cfg), status: 'pending' }
936
+ continue
937
+ }
938
+ tasks.push(async () => {
939
+ const value = await queryPlanSource(id, cfg, hostContext, { locale, signal: options.signal, usageDays })
940
+ planResults[index] = storeSource(sourceKey('plan', id), fingerprint, value)
941
+ })
942
+ }
943
+
944
+ const customEntries = []
945
+ for (let index = 0; index < settings.customBalances.length; index += 1) {
946
+ const entry = settings.customBalances[index]
947
+ const resultIndex = customEntries.length
948
+ customEntries.push({ index, entry, value: customBase(index, entry) })
949
+ if (!customAppliesToScope(entry, scope)) {
950
+ customEntries[resultIndex].value = { ...customBase(index, entry), status: 'off' }
951
+ continue
952
+ }
953
+ const key = sourceKey('custom', index)
954
+ const fingerprint = cacheFingerprint({ entry })
955
+ const cached = !force ? cachedSource(key, fingerprint, entry.refreshMinutes, now) : null
956
+ if (cached) { customEntries[resultIndex].value = cached; continue }
957
+ if (!force && !autoRefresh) continue
958
+ tasks.push(async () => {
959
+ const value = await queryCustomSource(index, entry, hostContext, { signal: options.signal })
960
+ customEntries[resultIndex].value = storeSource(key, fingerprint, value)
961
+ })
962
+ }
963
+
964
+ const gatewayEntries = []
965
+ for (const source of settings.gatewayQuotas.sources) {
966
+ const resultIndex = gatewayEntries.length
967
+ gatewayEntries.push({ source, value: gatewayBase(source) })
968
+ if (!gatewayAppliesToScope(source, scope)) {
969
+ gatewayEntries[resultIndex].value = { ...gatewayBase(source), status: 'off' }
970
+ continue
971
+ }
972
+ const key = sourceKey('gateway', source.id)
973
+ const fingerprint = cacheFingerprint({ source })
974
+ const cached = !force ? cachedSource(key, fingerprint, source.refreshMinutes, now) : null
975
+ if (cached) { gatewayEntries[resultIndex].value = cached; continue }
976
+ if (!force && !autoRefresh) continue
977
+ tasks.push(async () => {
978
+ const value = await queryGatewaySource(source, hostContext, { signal: options.signal })
979
+ gatewayEntries[resultIndex].value = storeSource(key, fingerprint, value)
980
+ })
981
+ }
982
+
983
+ await runPool(tasks, 4)
984
+
985
+ const balanceWanted = settings.balance.enabled && (scope === 'all' || settings.balance.display === 'sidebar' || settings.balance.display === 'both')
986
+ const emptyBalance = () => ({ status: 'pending', message: '未刷新', currency: '', totalBalance: 0, granted: 0, toppedUp: 0, fetchedAt: 0 })
987
+ let balanceValue
988
+ if (!settings.balance.enabled) {
989
+ balanceValue = { status: 'off', message: '', currency: '', totalBalance: 0, granted: 0, toppedUp: 0, fetchedAt: 0 }
990
+ } else if (balanceWanted && !cacheOnly) {
991
+ balanceValue = await withTimeout(fetchBalance(api, hostContext, { signal: options.signal, locale }, force), 8000, emptyBalance())
992
+ } else {
993
+ balanceValue = balanceCache.value || emptyBalance()
994
+ }
995
+
996
+ const goWanted = settings.goQuota.enabled && (scope === 'all' || settings.goQuota.display === 'sidebar' || settings.goQuota.display === 'both')
997
+ const emptyGo = { status: 'pending', message: '未刷新', fetchedAt: 0, rolling: null, weekly: null, monthly: null }
998
+ const goValue = !goWanted
999
+ ? { status: 'off', message: '', fetchedAt: 0, rolling: null, weekly: null, monthly: null }
1000
+ : cacheOnly
1001
+ ? (goCache.value || emptyGo)
1002
+ : await withTimeout(fetchGoQuota(hostContext, { signal: options.signal, fetch: options.fetch }, force), 8000, emptyGo)
1003
+
1004
+ return {
1005
+ currency: settings.currency,
1006
+ symbol: settings.symbol,
1007
+ exchangeRate: settings.exchangeRate,
1008
+ decimals: settings.decimals,
1009
+ display: settings.display,
1010
+ balance: {
1011
+ enabled: settings.balance.enabled,
1012
+ display: settings.balance.display,
1013
+ refreshMinutes: settings.balance.refreshMinutes,
1014
+ keyConfigured: await balanceKeyConfigured(hostContext),
1015
+ ...balanceValue,
1016
+ },
1017
+ customBalances: customEntries.map(item => item.value),
1018
+ goQuota: {
1019
+ ...settings.goQuota,
1020
+ configured: await keyConfigured(hostContext),
1021
+ ...goValue,
1022
+ },
1023
+ plans: plans.map((plan, index) => ({
1024
+ ...(planResults[index] || planBase(plan.id, plan.settings)),
1025
+ ...(plan.settings ? { enabled: plan.settings.enabled, display: plan.settings.display, refreshMinutes: plan.settings.refreshMinutes } : {}),
1026
+ })),
1027
+ gatewayQuotas: gatewayEntries.map(item => item.value),
1028
+ peak: { ...settings.peak },
1029
+ settings: {
1030
+ display: settings.display,
1031
+ balance: settings.balance,
1032
+ goQuota: settings.goQuota,
1033
+ peak: settings.peak,
1034
+ budget: settings.budget,
1035
+ codingPlans: settings.codingPlans,
1036
+ customBalances: settings.customBalances,
1037
+ gatewayQuotas: settings.gatewayQuotas,
1038
+ goKeyConfigured: await keyConfigured(hostContext),
1039
+ balanceKeyConfigured: await balanceKeyConfigured(hostContext),
1040
+ },
1041
+ }
1042
+ }
1043
+
1044
+ // ---------------------------------------------------------------------------
1045
+ // Validation
1046
+ // ---------------------------------------------------------------------------
1047
+
1048
+ function validateCustomEntries(raw) {
1049
+ if (!Array.isArray(raw)) throw new Error('自定义余额配置无效')
1050
+ if (raw.length > MAX_CUSTOM_BALANCES) throw new Error('最多配置 8 条自定义余额')
1051
+ return raw.map((entry, index) => {
1052
+ const normalized = normalizeCustomEntry(entry)
1053
+ if (normalized.adapter === 'custom' && normalized.request.url) {
1054
+ if (!/^https?:\/\//i.test(normalized.request.url)) throw new Error(`第 ${index + 1} 条自定义余额接口地址必须是 http(s)`)
1055
+ if (normalized.request.url.length > 2048) throw new Error('接口地址过长')
1056
+ }
1057
+ if (normalized.enabled === true && normalized.adapter === 'custom' && !normalized.request.url) {
1058
+ throw new Error(`第 ${index + 1} 条自定义余额缺少接口地址`)
1059
+ }
1060
+ return normalized
1061
+ })
1062
+ }
1063
+
1064
+ function validatePlanPatch(raw) {
1065
+ const input = record(raw)
1066
+ if (!input) throw new Error('Coding Plan 配置无效')
1067
+ const next = readSettings().codingPlans
1068
+ for (const [id, value] of Object.entries(input)) {
1069
+ if (!CODING_PLAN_PROVIDER_IDS.includes(id)) throw new Error('未知的 Coding Plan 厂商')
1070
+ if (!record(value)) throw new Error('Coding Plan 配置无效')
1071
+ next[id] = normalizePlanSettings(id, { ...(next[id] || {}), ...value })
1072
+ }
1073
+ return next
1074
+ }
1075
+
1076
+ function validatePatch(raw) {
1077
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('额度配置无效')
1078
+ const current = readSettings()
1079
+ const patch = {}
1080
+ if (raw.currency !== undefined) {
1081
+ if (!['CNY', 'USD'].includes(raw.currency)) throw new Error('显示币种仅支持 CNY 或 USD')
1082
+ patch.currency = raw.currency
1083
+ }
1084
+ if (raw.symbol !== undefined) {
1085
+ if (typeof raw.symbol !== 'string' || !raw.symbol.trim() || raw.symbol.trim().length > 8) throw new Error('货币符号无效')
1086
+ patch.symbol = raw.symbol.trim()
1087
+ }
1088
+ if (raw.exchangeRate !== undefined) {
1089
+ const rate = Number(raw.exchangeRate)
1090
+ if (!Number.isFinite(rate) || rate <= 0 || rate > 1000) throw new Error('汇率必须大于 0 且不超过 1000')
1091
+ patch.exchangeRate = rate
1092
+ }
1093
+ if (raw.decimals !== undefined) {
1094
+ const decimals = Number(raw.decimals)
1095
+ if (!Number.isInteger(decimals) || decimals < 2 || decimals > 6) throw new Error('小数位数应为 2–6')
1096
+ patch.decimals = decimals
1097
+ }
1098
+ if (raw.display !== undefined) {
1099
+ const display = record(raw.display)
1100
+ if (!display) throw new Error('显示配置无效')
1101
+ const next = { ...current.display }
1102
+ if (display.sidebarStyle !== undefined) {
1103
+ if (!SIDEBAR_STYLES.includes(display.sidebarStyle)) throw new Error('侧栏样式无效')
1104
+ next.sidebarStyle = display.sidebarStyle
1105
+ }
1106
+ if (display.sidebarVisible !== undefined) next.sidebarVisible = display.sidebarVisible === true
1107
+ if (display.sidebarTodayMetric !== undefined) {
1108
+ if (!['amount', 'tokens', 'both'].includes(display.sidebarTodayMetric)) throw new Error('今日显示项无效')
1109
+ next.sidebarTodayMetric = display.sidebarTodayMetric
1110
+ }
1111
+ if (display.peakStyle !== undefined) next.peakStyle = display.peakStyle === 'classic' ? 'classic' : 'progress'
1112
+ patch.display = next
1113
+ }
1114
+ if (raw.balance !== undefined) {
1115
+ const balance = record(raw.balance)
1116
+ if (!balance) throw new Error('余额配置无效')
1117
+ const next = { ...current.balance }
1118
+ if (balance.enabled !== undefined) next.enabled = balance.enabled === true
1119
+ if (balance.display !== undefined) next.display = displayOf(balance.display, next.display)
1120
+ if (balance.refreshMinutes !== undefined) next.refreshMinutes = refreshOf(balance.refreshMinutes, next.refreshMinutes)
1121
+ patch.balance = next
1122
+ }
1123
+ if (raw.goQuota !== undefined) {
1124
+ const go = record(raw.goQuota)
1125
+ if (!go) throw new Error('OpenCode Go 配置无效')
1126
+ const next = { ...current.goQuota }
1127
+ if (go.enabled !== undefined) next.enabled = go.enabled === true
1128
+ if (go.display !== undefined) next.display = displayOf(go.display, next.display)
1129
+ if (go.refreshMinutes !== undefined) next.refreshMinutes = refreshOf(go.refreshMinutes, next.refreshMinutes)
1130
+ if (go.detail !== undefined) next.detail = go.detail !== false
1131
+ if (go.main !== undefined) {
1132
+ if (!['rolling', 'weekly', 'monthly'].includes(go.main)) throw new Error('用量窗口无效')
1133
+ next.main = go.main
1134
+ }
1135
+ if (go.barDirection !== undefined) {
1136
+ if (!['used', 'remaining'].includes(go.barDirection)) throw new Error('进度条方向无效')
1137
+ next.barDirection = go.barDirection
1138
+ }
1139
+ patch.goQuota = next
1140
+ }
1141
+ if (raw.peak !== undefined) {
1142
+ const peak = record(raw.peak)
1143
+ if (!peak) throw new Error('峰谷配置无效')
1144
+ const next = { ...current.peak }
1145
+ if (peak.enabled !== undefined) next.enabled = peak.enabled !== false
1146
+ if (peak.display !== undefined) next.display = displayOf(peak.display, next.display)
1147
+ if (peak.effectiveAt !== undefined) next.effectiveAt = typeof peak.effectiveAt === 'string' ? peak.effectiveAt.slice(0, 32) : ''
1148
+ if (peak.windows !== undefined) {
1149
+ if (!Array.isArray(peak.windows)) throw new Error('峰时段配置无效')
1150
+ next.windows = normalizePeakWindows(peak.windows)
1151
+ }
1152
+ if (peak.holidays !== undefined) {
1153
+ if (!Array.isArray(peak.holidays) || peak.holidays.some(day => typeof day !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(day))) throw new Error('节假日应使用 YYYY-MM-DD 格式')
1154
+ next.holidays = peak.holidays.slice(0, 100)
1155
+ }
1156
+ patch.peak = next
1157
+ }
1158
+ if (raw.budget !== undefined) {
1159
+ const budget = record(raw.budget)
1160
+ if (!budget) throw new Error('预算配置无效')
1161
+ const next = { ...current.budget }
1162
+ if (budget.display !== undefined) next.display = displayOf(budget.display, next.display)
1163
+ patch.budget = next
1164
+ }
1165
+ if (raw.codingPlans !== undefined) patch.codingPlans = validatePlanPatch(raw.codingPlans)
1166
+ if (raw.customBalances !== undefined) patch.customBalances = validateCustomEntries(raw.customBalances)
1167
+ if (raw.gatewayQuotas !== undefined) {
1168
+ const gateway = record(raw.gatewayQuotas)
1169
+ if (!gateway || !Array.isArray(gateway.sources)) throw new Error('网关额度来源配置无效')
1170
+ if (gateway.sources.length > MAX_GATEWAY_SOURCES) throw new Error('最多配置 4 个网关额度来源')
1171
+ patch.gatewayQuotas = {
1172
+ sources: gateway.sources.map(source => {
1173
+ const draft = record(source) || {}
1174
+ if (!text(draft.baseURL) && draft.enabled !== true) return normalizeGatewaySource(draft)
1175
+ return validateGatewaySource(source)
1176
+ }),
1177
+ }
1178
+ }
1179
+ return patch
1180
+ }
1181
+
1182
+ function mergeSettings(current, patch) {
1183
+ return normalizeSettings({ ...current, ...patch,
1184
+ display: { ...current.display, ...(patch.display || {}) },
1185
+ balance: { ...current.balance, ...(patch.balance || {}) },
1186
+ goQuota: { ...current.goQuota, ...(patch.goQuota || {}) },
1187
+ peak: { ...current.peak, ...(patch.peak || {}) },
1188
+ budget: { ...current.budget, ...(patch.budget || {}) },
1189
+ codingPlans: patch.codingPlans || current.codingPlans,
1190
+ customBalances: patch.customBalances !== undefined ? patch.customBalances : current.customBalances,
1191
+ gatewayQuotas: patch.gatewayQuotas || current.gatewayQuotas,
1192
+ })
1193
+ }
1194
+
1195
+ // ---------------------------------------------------------------------------
1196
+ // Legacy credential helpers (Go / official balance)
1197
+ // ---------------------------------------------------------------------------
1198
+
1199
+ async function saveGoKey(hostContext, value) {
1200
+ const key = normalizeKey(value)
1201
+ if (!key || key.length > 4096) throw new Error('请输入有效的 OpenCode Go API Key')
1202
+ await writeCredential(hostContext, GO_KEY_REF, key)
1203
+ }
1204
+
1205
+ async function clearGoKey(hostContext) {
1206
+ await clearCredential(hostContext, GO_KEY_REF)
1207
+ }
1208
+
1209
+ async function saveBalanceKey(hostContext, value) {
1210
+ const key = normalizeKey(value)
1211
+ if (!key || key.length > 4096) throw new Error('请输入有效的 DeepSeek API Key')
1212
+ await writeCredential(hostContext, BALANCE_KEY_REF, key)
1213
+ }
1214
+
1215
+ async function clearBalanceKey(hostContext) {
1216
+ await clearCredential(hostContext, BALANCE_KEY_REF)
1217
+ }
1218
+
1219
+ // ---------------------------------------------------------------------------
1220
+ // Source credential actions
1221
+ // ---------------------------------------------------------------------------
1222
+
1223
+ function parseSourceRef(raw) {
1224
+ const value = text(raw)
1225
+ const [kind, ...rest] = value.split(':')
1226
+ const id = rest.join(':')
1227
+ if (kind === 'plan' && CODING_PLAN_PROVIDER_IDS.includes(id)) return { kind, id }
1228
+ if (kind === 'gateway' && id) return { kind, id }
1229
+ if (kind === 'custom' && id) return { kind, id }
1230
+ return null
1231
+ }
1232
+
1233
+ async function setSourceKey(hostContext, rawSource, value, secret, settings) {
1234
+ const ref = parseSourceRef(rawSource)
1235
+ if (!ref) throw new Error('未知的额度来源')
1236
+ if (ref.kind === 'plan') {
1237
+ if (ref.id === 'scnet' || ref.id === 'qwen' || ref.id === 'workbuddy') throw new Error('该厂商不需要填写凭据')
1238
+ if (ref.id === 'volcengine') {
1239
+ if (!secret) throw new Error('火山方舟需要 AccessKeyID 与 SecretAccessKey')
1240
+ await writeCredential(hostContext, PLAN_KEY_REFS.volcengine[0], value)
1241
+ await writeCredential(hostContext, VOLC_SECRET_REFS[0], secret)
1242
+ return
1243
+ }
1244
+ const target = (PLAN_KEY_REFS[ref.id] || [])[0]
1245
+ if (!target) throw new Error('该厂商不需要填写凭据')
1246
+ await writeCredential(hostContext, target, value)
1247
+ return
1248
+ }
1249
+ if (ref.kind === 'gateway') {
1250
+ const source = settings.gatewayQuotas.sources.find(item => item.id === ref.id)
1251
+ if (!source) throw new Error('网关额度来源不存在')
1252
+ await writeCredential(hostContext, managementKeyVarOf(source), value)
1253
+ return
1254
+ }
1255
+ const index = Number(ref.id)
1256
+ const entry = Number.isInteger(index) ? settings.customBalances[index] : null
1257
+ if (!entry) throw new Error('自定义余额条目不存在')
1258
+ if (!entry.secretHeader) throw new Error('请先填写密钥请求头名称')
1259
+ await writeCredential(hostContext, CUSTOM_KEY_PREFIX + index, value)
1260
+ }
1261
+
1262
+ async function clearSourceKey(hostContext, rawSource, settings) {
1263
+ const ref = parseSourceRef(rawSource)
1264
+ if (!ref) throw new Error('未知的额度来源')
1265
+ if (ref.kind === 'plan') {
1266
+ for (const target of PLAN_KEY_REFS[ref.id] || []) await clearCredential(hostContext, target)
1267
+ if (ref.id === 'volcengine') for (const target of VOLC_SECRET_REFS) await clearCredential(hostContext, target)
1268
+ return
1269
+ }
1270
+ if (ref.kind === 'gateway') {
1271
+ const source = settings.gatewayQuotas.sources.find(item => item.id === ref.id)
1272
+ if (source) await clearCredential(hostContext, managementKeyVarOf(source))
1273
+ return
1274
+ }
1275
+ const index = Number(ref.id)
1276
+ if (Number.isInteger(index)) await clearCredential(hostContext, CUSTOM_KEY_PREFIX + index)
1277
+ }
1278
+
1279
+ // ---------------------------------------------------------------------------
1280
+ // UI source snapshot
1281
+ // ---------------------------------------------------------------------------
1282
+
1283
+ async function sourcesSnapshot(settings, hostContext) {
1284
+ const plans = []
1285
+ for (const id of CODING_PLAN_PROVIDER_IDS) {
1286
+ plans.push({
1287
+ id,
1288
+ label: PLAN_LABELS[id] || id,
1289
+ fields: PLAN_EXTRA_FIELDS[id] || [],
1290
+ needsSecret: id === 'volcengine',
1291
+ noKey: (PLAN_KEY_REFS[id] || []).length === 0,
1292
+ configured: await planConfigured(id, hostContext),
1293
+ settings: settings.codingPlans[id] || normalizePlanSettings(id, {}),
1294
+ })
1295
+ }
1296
+ return {
1297
+ plans,
1298
+ customBalances: settings.customBalances.map((entry, index) => ({
1299
+ index,
1300
+ configured: Boolean(entry.secretHeader),
1301
+ keyRef: CUSTOM_KEY_PREFIX + index,
1302
+ })),
1303
+ gatewayQuotas: settings.gatewayQuotas.sources.map(source => ({
1304
+ id: source.id,
1305
+ keyRef: managementKeyVarOf(source),
1306
+ })),
1307
+ }
1308
+ }
1309
+
1310
+ // ---------------------------------------------------------------------------
1311
+ // Handler
1312
+ // ---------------------------------------------------------------------------
1313
+
1314
+ /** Maintain the session summary while providing balances and quotas natively. */
1315
+ export async function handleCostMeterWithDashboard(api, msg, options = {}) {
1316
+ const requestId = typeof msg?.requestId === 'string' ? msg.requestId : ''
1317
+ const action = typeof msg?.action === 'string' ? msg.action : 'summary'
1318
+ const hostContext = options.hostContext
1319
+ try {
1320
+ let settings = readSettings()
1321
+ let baseForward = null
1322
+ if (action === 'update') {
1323
+ const rawPatch = record(msg.patch) || {}
1324
+ baseForward = {}
1325
+ for (const key of ['currency', 'budget', 'hideBudget', 'showTotalWithPlan', 'includeSubagentCost']) {
1326
+ if (rawPatch[key] !== undefined) baseForward[key] = rawPatch[key]
1327
+ }
1328
+ const patch = validatePatch(msg.patch || {})
1329
+ settings = mergeSettings(settings, patch)
1330
+ if (!writeSettings(settings)) throw new Error('额度设置无法写入 DSH 本机存储')
1331
+ dropQuotaCaches()
1332
+ } else if (action === 'set-go-key') {
1333
+ await saveGoKey(hostContext, msg.value)
1334
+ settings = mergeSettings(settings, { goQuota: { ...settings.goQuota, enabled: true } })
1335
+ if (!writeSettings(settings)) throw new Error('额度设置无法写入 DSH 本机存储')
1336
+ dropQuotaCaches()
1337
+ } else if (action === 'clear-go-key') {
1338
+ await clearGoKey(hostContext)
1339
+ dropQuotaCaches()
1340
+ } else if (action === 'set-balance-key') {
1341
+ await saveBalanceKey(hostContext, msg.value)
1342
+ dropQuotaCaches()
1343
+ } else if (action === 'clear-balance-key') {
1344
+ await clearBalanceKey(hostContext)
1345
+ dropQuotaCaches()
1346
+ } else if (action === 'set-source-key') {
1347
+ await setSourceKey(hostContext, msg.source, msg.value, msg.secret, settings)
1348
+ dropQuotaCaches()
1349
+ } else if (action === 'clear-source-key') {
1350
+ await clearSourceKey(hostContext, msg.source, settings)
1351
+ dropQuotaCaches()
1352
+ } else if (action === 'refresh-source') {
1353
+ const ref = parseSourceRef(msg.source)
1354
+ if (!ref) throw new Error('未知的额度来源')
1355
+ const dashboard = await dashboardOf(api, hostContext, settings, options, { force: false, scope: 'all', autoRefresh: false })
1356
+ if (ref.kind === 'plan') {
1357
+ const cfg = settings.codingPlans[ref.id]
1358
+ const value = await queryPlanSource(ref.id, cfg, hostContext, {
1359
+ locale: options.locale,
1360
+ signal: options.signal,
1361
+ usageDays: needsUsageDays(ref.id) ? await collectUsageDays(api, options.signal) : {},
1362
+ })
1363
+ const fingerprint = cacheFingerprint({ id: ref.id, cfg, configured: await planConfigured(ref.id, hostContext) })
1364
+ storeSource(sourceKey('plan', ref.id), fingerprint, value)
1365
+ dashboard.plans = dashboard.plans.map(item => item.id === ref.id ? { ...item, ...value } : item)
1366
+ } else if (ref.kind === 'custom') {
1367
+ const index = Number(ref.id)
1368
+ const entry = settings.customBalances[index]
1369
+ if (!entry) throw new Error('自定义余额条目不存在')
1370
+ const value = await queryCustomSource(index, entry, hostContext, { signal: options.signal })
1371
+ storeSource(sourceKey('custom', index), cacheFingerprint({ entry }), value)
1372
+ dashboard.customBalances = dashboard.customBalances.map(item => item.index === index ? value : item)
1373
+ } else {
1374
+ const source = settings.gatewayQuotas.sources.find(item => item.id === ref.id)
1375
+ if (!source) throw new Error('网关额度来源不存在')
1376
+ const value = await queryGatewaySource(source, hostContext, { signal: options.signal })
1377
+ storeSource(sourceKey('gateway', ref.id), cacheFingerprint({ source }), value)
1378
+ dashboard.gatewayQuotas = dashboard.gatewayQuotas.map(item => item.id === ref.id ? value : item)
1379
+ }
1380
+ return { kind: 'cost-meter', requestId, action, dashboard }
1381
+ } else if (action === 'source-config') {
1382
+ return { kind: 'cost-meter', requestId, action, sources: await sourcesSnapshot(settings, hostContext) }
1383
+ } else if (!['summary', 'config', 'refresh'].includes(action)) {
1384
+ return handleBaseCostMeter(api, msg)
1385
+ }
1386
+
1387
+ let base
1388
+ if (action === 'update' && baseForward && Object.keys(baseForward).length > 0) {
1389
+ base = await handleBaseCostMeter(api, { ...msg, action: 'update', patch: baseForward })
1390
+ } else {
1391
+ const baseAction = ['summary', 'refresh'].includes(action) ? action : 'config'
1392
+ base = await handleBaseCostMeter(api, { ...msg, action: baseAction })
1393
+ }
1394
+ const force = action === 'refresh'
1395
+ const scope = action === 'summary'
1396
+ ? (settings.display.sidebarVisible === true ? 'sidebar' : 'none')
1397
+ : 'all'
1398
+ const autoRefresh = action === 'summary' || action === 'refresh'
1399
+ const dashboard = await dashboardOf(api, hostContext, settings, options, { force, scope, autoRefresh, cacheOnly: action === 'config' })
1400
+ return {
1401
+ ...(base && base.kind === 'cost-meter' ? base : { kind: 'cost-meter', requestId }),
1402
+ requestId,
1403
+ action,
1404
+ config: {
1405
+ currency: settings.currency,
1406
+ symbol: settings.symbol,
1407
+ exchangeRate: settings.exchangeRate,
1408
+ decimals: settings.decimals,
1409
+ display: settings.display,
1410
+ balance: { ...settings.balance, keyConfigured: dashboard.balance.keyConfigured },
1411
+ goQuota: { ...settings.goQuota, keyConfigured: dashboard.goQuota.configured },
1412
+ peak: settings.peak,
1413
+ codingPlans: settings.codingPlans,
1414
+ customBalances: settings.customBalances,
1415
+ gatewayQuotas: settings.gatewayQuotas,
1416
+ },
1417
+ dashboard,
1418
+ ...(action === 'config' ? { sources: await sourcesSnapshot(settings, hostContext) } : {}),
1419
+ }
1420
+ } catch (error) {
1421
+ if (['set-go-key', 'set-balance-key', 'set-source-key'].includes(action)) {
1422
+ return { kind: 'error', requestType: 'cost-meter', requestId, code: 'cost-meter-credential-write-failed', message: error?.message || '凭据保存失败,请检查 DSH 凭据库' }
1423
+ }
1424
+ if (['clear-go-key', 'clear-balance-key', 'clear-source-key'].includes(action)) {
1425
+ return { kind: 'error', requestType: 'cost-meter', requestId, code: 'cost-meter-credential-write-failed', message: error?.message || '凭据清除失败,请检查 DSH 凭据库' }
1426
+ }
1427
+ if (action === 'update') return { kind: 'error', requestType: 'cost-meter', requestId, code: 'bad-request', message: error?.message || '额度设置无效' }
1428
+ return { kind: 'error', requestType: 'cost-meter', requestId, code: 'cost-meter-dashboard-error', message: error?.message || '额度数据读取失败' }
1429
+ }
1430
+ }
1431
+
1432
+ /** Exported for unit tests: reset module caches. */
1433
+ export function resetCostDashboardForTests() {
1434
+ dropQuotaCaches()
1435
+ sourceCache.clear()
1436
+ }
1437
+
1438
+ export const __test = {
1439
+ normalizeSettings,
1440
+ validatePatch,
1441
+ mergeSettings,
1442
+ normalizeCustomEntry,
1443
+ normalizePlanSettings,
1444
+ managementKeyVarOf,
1445
+ PLAN_KEY_REFS,
1446
+ writeSettings,
1447
+ readSettings,
1448
+ dropQuotaCaches,
1449
+ }