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,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
|
+
}
|