@sligqoer/dsh-usage-stats 0.1.2

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,526 @@
1
+ /** Read-only quotas for pi-ai providers shipped by DSH. Secrets stay on the host. */
2
+ import { createHash, createHmac, randomUUID } from 'node:crypto'
3
+
4
+ const PROVIDERS = {
5
+ anthropic: { name: 'Claude 订阅', host: 'api.anthropic.com', url: 'https://api.anthropic.com/api/oauth/usage', auth: 'oauth', ttl: 300_000, headers: { 'anthropic-beta': 'oauth-2025-04-20' }, parse: parseAnthropic },
6
+ deepseek: { name: 'DeepSeek', kind: 'balance', env: 'DEEPSEEK_API_KEY', host: 'api.deepseek.com', url: 'https://api.deepseek.com/user/balance', parse: parseDeepSeek },
7
+ // GET /v1/accounts returns { object, type, balance, total_cash_balance,
8
+ // total_voucher_balance }. There is no /step_plan/... route: that path 404s.
9
+ stepfun: { name: 'StepFun 阶跃星辰', kind: 'balance', env: 'STEPFUN_API_KEY', host: 'api.stepfun.com', url: 'https://api.stepfun.com/v1/accounts', parse: parseStepFun },
10
+ 'openai-codex': { name: 'OpenAI Codex 订阅', host: 'chatgpt.com', url: 'https://chatgpt.com/backend-api/wham/usage', auth: 'oauth', parse: parseCodex },
11
+ 'github-copilot': { name: 'GitHub Copilot', host: ['api.githubcopilot.com', 'api.individual.githubcopilot.com'], url: 'https://api.github.com/copilot_internal/user', auth: 'github', headers: { accept: 'application/vnd.github+json', 'user-agent': 'dsh-usage-stats' }, parse: parseCopilot },
12
+ 'opencode-go': { name: 'OpenCode Go', kind: 'subscription', configuredOnly: true, showUnqueried: true, host: 'opencode.ai', url: 'https://opencode.ai/zen/go/v1/usage', auth: 'configured-key', env: 'OPENCODE_API_KEY', parse: parseOpencodeGo, description: '只使用 DSH 中此供应商的凭据查询官方订阅额度。' },
13
+ // The Go usage API requires a Go subscription. Zen's wallet has no public
14
+ // key-authenticated balance API; leave it available for a user-defined query.
15
+ opencode: { name: 'OpenCode Zen', kind: 'balance', configuredOnly: true, queryable: false, host: 'opencode.ai', auth: 'configured-key', env: 'OPENCODE_API_KEY', description: '官方暂未提供可用的 Zen 余额查询接口,可配置自定义查询。' },
16
+ openrouter: { name: 'OpenRouter', kind: 'balance', env: 'OPENROUTER_API_KEY', host: 'openrouter.ai', url: 'https://openrouter.ai/api/v1/key', auth: 'oauth-or-key', parse: parseOpenRouter },
17
+ moonshotai: { name: 'Moonshot AI', kind: 'balance', env: 'MOONSHOT_API_KEY', host: 'api.moonshot.ai', url: 'https://api.moonshot.ai/v1/users/me/balance', parse: (body) => parseMoonshot(body, 'USD') },
18
+ 'moonshotai-cn': { name: 'Moonshot AI 国内', kind: 'balance', env: 'MOONSHOT_API_KEY', host: 'api.moonshot.cn', url: 'https://api.moonshot.cn/v1/users/me/balance', parse: (body) => parseMoonshot(body, 'CNY') },
19
+ 'kimi-coding': { name: 'Kimi Coding', env: 'KIMI_API_KEY', host: 'api.kimi.com', url: 'https://api.kimi.com/coding/v1/usages', auth: 'oauth-or-key', parse: parseKimi },
20
+ minimax: { name: 'MiniMax 国际', env: 'MINIMAX_API_KEY', host: 'api.minimax.io', url: 'https://www.minimax.io/v1/token_plan/remains', parse: parseMiniMax },
21
+ 'minimax-cn': { name: 'MiniMax 国内', env: 'MINIMAX_CN_API_KEY', host: 'api.minimaxi.com', url: 'https://www.minimaxi.com/v1/token_plan/remains', parse: parseMiniMax },
22
+ zai: { name: 'Z.AI', env: 'ZAI_API_KEY', host: 'api.z.ai', url: 'https://api.z.ai/api/monitor/usage/quota/limit', parse: parseZai },
23
+ 'zai-coding-cn': { name: '智谱 GLM Coding', env: 'ZAI_CODING_CN_API_KEY', host: 'open.bigmodel.cn', url: 'https://open.bigmodel.cn/api/monitor/usage/quota/limit', parse: parseZai },
24
+ 'qwen-token-plan-cn': { name: '阿里云 Token Plan 国内', host: 'token-plan.cn-beijing.maas.aliyuncs.com', url: 'https://modelstudio.cn-beijing.aliyuncs.com/tokenplan/subscription/stats', auth: 'alibaba', parse: parseQwenTokenPlan },
25
+ xai: { name: 'xAI', kind: 'balance', host: 'api.x.ai', auth: 'management', parse: parseXai },
26
+ }
27
+
28
+ const TTL_MS = 60_000
29
+
30
+ function nonnegative(value) {
31
+ if (typeof value !== 'number' && typeof value !== 'string') return null
32
+ if (typeof value === 'string' && !/^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(value.trim())) return null
33
+ const number = Number(value)
34
+ return Number.isFinite(number) && number >= 0 ? number : null
35
+ }
36
+
37
+ function percentage(value) {
38
+ const number = nonnegative(value)
39
+ return number !== null && number <= 100 ? number : null
40
+ }
41
+
42
+ function resetAt(value) {
43
+ if (typeof value === 'number' && Number.isFinite(value)) {
44
+ const date = new Date(value < 1e12 ? value * 1000 : value)
45
+ return Number.isFinite(date.getTime()) ? date.toISOString() : null
46
+ }
47
+ if (typeof value !== 'string' || !value.trim()) return null
48
+ const date = new Date(value)
49
+ return Number.isFinite(date.getTime()) ? date.toISOString() : null
50
+ }
51
+
52
+ function amount(label, remaining, currency) {
53
+ const value = nonnegative(remaining)
54
+ return value === null ? null : { label, kind: 'amount', remaining: value, currency }
55
+ }
56
+
57
+ function windowMetric(label, remainingPercent, reset, remaining = null, total = null) {
58
+ const value = percentage(remainingPercent)
59
+ if (value === null) return null
60
+ return { label, kind: 'window', remainingPercent: value, resetAt: resetAt(reset), remaining: nonnegative(remaining), total: nonnegative(total) }
61
+ }
62
+
63
+ function parseOpenRouter(body) {
64
+ const data = body && body.data
65
+ if (!data || typeof data !== 'object') return []
66
+ const remaining = nonnegative(data.limit_remaining)
67
+ if (remaining === null) return [] // An unlimited key has no readable remaining credit.
68
+ const limit = nonnegative(data.limit)
69
+ const label = data.limit_reset === 'monthly' ? '本月密钥额度' : '密钥额度'
70
+ return [{ label, kind: 'amount', remaining, total: limit, currency: 'USD' }]
71
+ }
72
+
73
+ function parseOpencodeGo(body) {
74
+ const windows = body?.usage
75
+ if (!windows || typeof windows !== 'object') return []
76
+ return [['rolling', '5 小时剩余'], ['weekly', '周剩余'], ['monthly', '月剩余']].map(([key, label]) => {
77
+ const row = windows[key]
78
+ const used = nonnegative(row?.percent)
79
+ return used === null ? null : windowMetric(label, Math.max(0, 100 - used), row.resetsAt)
80
+ }).filter(Boolean)
81
+ }
82
+
83
+ function parseOpenRouterCredits(body) {
84
+ const data = body && body.data
85
+ const total = nonnegative(data && data.total_credits)
86
+ const used = nonnegative(data && data.total_usage)
87
+ return total === null || used === null ? [] : [{ label: '账户可用额度', kind: 'amount', remaining: Math.max(0, total - used), total, currency: 'USD' }]
88
+ }
89
+
90
+ function parseMoonshot(body, currency) {
91
+ const data = body && body.data
92
+ if (!data || typeof data !== 'object') return []
93
+ const available = data.available_balance ?? data.total_balance
94
+ const metric = amount('账户可用余额', available, currency)
95
+ return metric ? [metric] : []
96
+ }
97
+
98
+ function parseDeepSeek(body) {
99
+ const infos = body && Array.isArray(body.balance_infos) ? body.balance_infos : []
100
+ return infos.map((row) => amount('账户可用余额', row && row.total_balance, row && row.currency)).filter(Boolean)
101
+ }
102
+
103
+ /**
104
+ * StepFun reports a single CNY balance at the response ROOT — not under `data`,
105
+ * which is where the generic prefill looks. `total_cash_balance` and
106
+ * `total_voucher_balance` split that total into paid and gift credit, so they
107
+ * are surfaced as extra rows when the account reports them.
108
+ */
109
+ function parseStepFun(body) {
110
+ if (!body || typeof body !== 'object') return []
111
+ const metrics = []
112
+ const available = amount('账户可用余额', body.balance, 'CNY')
113
+ if (available) metrics.push(available)
114
+ const cash = amount('充值余额', body.total_cash_balance, 'CNY')
115
+ if (cash) metrics.push(cash)
116
+ const voucher = amount('赠送余额', body.total_voucher_balance, 'CNY')
117
+ if (voucher) metrics.push(voucher)
118
+ return metrics
119
+ }
120
+
121
+ function parseAnthropic(body) {
122
+ if (!body || typeof body !== 'object') return []
123
+ const names = { five_hour: '5 小时剩余', seven_day: '周剩余', seven_day_sonnet: 'Sonnet 周剩余', seven_day_opus: 'Opus 周剩余' }
124
+ const metrics = []
125
+ for (const [key, label] of Object.entries(names)) {
126
+ const item = body[key]
127
+ if (!item || typeof item !== 'object') continue
128
+ const used = percentage(item.utilization ?? item.used_percentage)
129
+ const metric = used === null ? null : windowMetric(label, 100 - used, item.resets_at)
130
+ if (metric) metrics.push(metric)
131
+ }
132
+ const rows = Array.isArray(body) ? body : Array.isArray(body.windows) ? body.windows : []
133
+ for (const row of rows) {
134
+ if (!row || typeof row !== 'object') continue
135
+ const used = percentage(row.percent)
136
+ if (used === null) continue
137
+ const model = row.scope?.model?.display_name
138
+ const label = row.group === 'session' ? '5 小时剩余' : model ? model + ' 周剩余' : row.group === 'weekly' ? '周剩余' : null
139
+ const metric = label ? windowMetric(label, 100 - used, row.resets_at) : null
140
+ if (metric) metrics.push(metric)
141
+ }
142
+ return metrics
143
+ }
144
+
145
+ function codexWindow(row, prefix = '') {
146
+ if (!row || typeof row !== 'object') return null
147
+ const used = percentage(row.used_percent)
148
+ if (used === null) return null
149
+ const seconds = nonnegative(row.limit_window_seconds)
150
+ const period = seconds >= 172_800 ? '周剩余' : seconds >= 3600 ? Math.round(seconds / 3600) + ' 小时剩余' : '窗口剩余'
151
+ return windowMetric(prefix + period, 100 - used, row.reset_at)
152
+ }
153
+
154
+ function parseCodex(body) {
155
+ if (!body || typeof body !== 'object') return []
156
+ const rate = body.rate_limit || {}
157
+ const metrics = [codexWindow(rate.primary_window), codexWindow(rate.secondary_window)]
158
+ if (Array.isArray(body.additional_rate_limits)) {
159
+ for (const item of body.additional_rate_limits) {
160
+ if (!item || typeof item !== 'object') continue
161
+ const prefix = String(item.limit_name || item.metered_feature || '其他模型') + ' · '
162
+ metrics.push(codexWindow(item.rate_limit?.primary_window, prefix), codexWindow(item.rate_limit?.secondary_window, prefix))
163
+ }
164
+ }
165
+ return metrics.filter(Boolean)
166
+ }
167
+
168
+ function parseCopilot(body) {
169
+ const snapshots = body && (body.quota_snapshots || body.quotaSnapshots)
170
+ if (!snapshots || typeof snapshots !== 'object') return []
171
+ const names = { premium_interactions: '高级请求剩余', chat: '聊天请求剩余', completions: '补全请求剩余' }
172
+ const metrics = []
173
+ for (const [key, label] of Object.entries(names)) {
174
+ const row = snapshots[key]
175
+ if (!row || typeof row !== 'object' || row.is_unlimited_entitlement === true || row.isUnlimitedEntitlement === true) continue
176
+ const total = nonnegative(row.entitlement ?? row.entitlement_requests ?? row.entitlementRequests)
177
+ const used = nonnegative(row.used ?? row.used_requests ?? row.usedRequests)
178
+ const left = nonnegative(row.remaining ?? row.quota_remaining)
179
+ const remaining = left ?? (total !== null && used !== null ? Math.max(0, total - used) : null)
180
+ const percent = percentage(row.percent_remaining ?? row.remaining_percentage ?? row.remainingPercentage) ?? (total > 0 && remaining !== null ? remaining / total * 100 : null)
181
+ if (total === null || total <= 0 || percent === null) continue
182
+ const metric = windowMetric(label, percent, row.reset_date ?? row.resetDate ?? body.quota_reset_date_utc, remaining, total)
183
+ if (metric) metrics.push(metric)
184
+ }
185
+ return metrics
186
+ }
187
+
188
+ function parseXai(body) {
189
+ const cents = nonnegative(body && body.total && body.total.val)
190
+ const metric = cents === null ? null : amount('预付费余额', cents / 100, 'USD')
191
+ return metric ? [metric] : []
192
+ }
193
+
194
+ function parseQwenTokenPlan(body) {
195
+ if (body?.Success !== true || !Array.isArray(body.Data?.Items)) return []
196
+ return body.Data.Items.map((row) => {
197
+ const total = nonnegative(row?.SeatCredits)
198
+ const remaining = nonnegative(row?.SeatRemainingCredits)
199
+ if (total === null || total <= 0 || remaining === null || remaining > total) return null
200
+ const seat = typeof row.SeatType === 'string' && /^(standard|pro|max)$/i.test(row.SeatType) ? row.SeatType : '席位'
201
+ return windowMetric(seat + ' 积分剩余', remaining / total * 100, row.SeatRefreshTime, remaining, total)
202
+ }).filter(Boolean)
203
+ }
204
+
205
+ function parseMiniMax(body) {
206
+ if (body && body.base_resp && Number(body.base_resp.status_code) !== 0) return []
207
+ const rows = body && Array.isArray(body.model_remains) ? body.model_remains : []
208
+ const metrics = []
209
+ for (const row of rows) {
210
+ if (!row || typeof row !== 'object') continue
211
+ const model = typeof row.model_name === 'string' ? row.model_name : ''
212
+ if (model === 'video') continue // DSH's MiniMax provider is a text-model route.
213
+ const prefix = model && model !== 'general' ? model + ' · ' : ''
214
+ const intervalTotal = nonnegative(row.current_interval_total_count)
215
+ const intervalLeft = nonnegative(row.current_interval_usage_count)
216
+ const weeklyTotal = nonnegative(row.current_weekly_total_count)
217
+ const weeklyLeft = nonnegative(row.current_weekly_usage_count)
218
+ const intervalPercent = row.current_interval_remaining_percent ?? (intervalTotal > 0 && intervalLeft !== null ? intervalLeft / intervalTotal * 100 : null)
219
+ const weeklyPercent = row.current_weekly_remaining_percent ?? (weeklyTotal > 0 && weeklyLeft !== null ? weeklyLeft / weeklyTotal * 100 : null)
220
+ const fiveHour = Number(row.current_interval_status) === 3 ? null : windowMetric(prefix + '5 小时剩余', intervalPercent, row.end_time, intervalLeft, intervalTotal)
221
+ const weekly = Number(row.current_weekly_status) === 3 ? null : windowMetric(prefix + '周剩余', weeklyPercent, row.weekly_end_time, weeklyLeft, weeklyTotal)
222
+ if (fiveHour) metrics.push(fiveHour)
223
+ if (weekly) metrics.push(weekly)
224
+ }
225
+ return metrics
226
+ }
227
+
228
+ function parseZai(body) {
229
+ if (!body || (body.code !== undefined && Number(body.code) !== 200 && Number(body.code) !== 0)) return []
230
+ const limits = body.data && Array.isArray(body.data.limits) ? body.data.limits : []
231
+ const metrics = []
232
+ for (const row of limits) {
233
+ if (!row || (row.type !== 'TOKENS_LIMIT' && row.type !== 'CREDIT_LIMIT')) continue
234
+ const used = percentage(row.percentage)
235
+ if (used === null) continue
236
+ const label = Number(row.unit) === 3 ? '5 小时剩余' : Number(row.unit) === 6 ? '周剩余' : row.type === 'CREDIT_LIMIT' ? '积分剩余' : 'Token 配额剩余'
237
+ const metric = windowMetric(label, 100 - used, row.nextResetTime)
238
+ if (metric) metrics.push(metric)
239
+ }
240
+ return metrics
241
+ }
242
+
243
+ function parseKimiRow(row, label) {
244
+ if (!row || typeof row !== 'object') return null
245
+ const limit = nonnegative(row.limit)
246
+ const remaining = nonnegative(row.remaining)
247
+ const used = nonnegative(row.used)
248
+ if (limit === null || limit <= 0 || (remaining === null && used === null)) return null
249
+ const left = Math.max(0, Math.min(limit, remaining === null ? limit - used : remaining))
250
+ return windowMetric(label, left / limit * 100, row.reset_at ?? row.resetAt ?? row.reset_time ?? row.resetTime, left, limit)
251
+ }
252
+
253
+ function kimiLimitLabel(item, index) {
254
+ const explicit = item.name || item.title || item.scope
255
+ if (typeof explicit === 'string' && explicit.trim()) return explicit.trim()
256
+ const window = item.window && typeof item.window === 'object' ? item.window : item
257
+ const duration = nonnegative(window.duration)
258
+ const unit = String(window.timeUnit || '').toUpperCase()
259
+ if (duration === 300 && unit.includes('MINUTE')) return '5 小时剩余'
260
+ if (duration === 7 && unit.includes('DAY')) return '周剩余'
261
+ if (duration !== null && unit.includes('HOUR')) return duration + ' 小时剩余'
262
+ return '配额窗口 ' + (index + 1)
263
+ }
264
+
265
+ function parseKimi(body) {
266
+ if (!body || typeof body !== 'object') return []
267
+ const metrics = []
268
+ const weekly = parseKimiRow(body.usage, '周剩余')
269
+ if (weekly) metrics.push(weekly)
270
+ if (Array.isArray(body.limits)) {
271
+ for (let index = 0; index < body.limits.length; index++) {
272
+ const item = body.limits[index]
273
+ if (!item || typeof item !== 'object') continue
274
+ const detail = item.detail && typeof item.detail === 'object' ? item.detail : item
275
+ const metric = parseKimiRow(detail, kimiLimitLabel(item, index))
276
+ if (metric) metrics.push(metric)
277
+ }
278
+ }
279
+ return metrics
280
+ }
281
+
282
+ export function officialProfile(profile, host) {
283
+ if (!profile || !profile.baseURL) return true
284
+ try {
285
+ const url = new URL(profile.baseURL)
286
+ return url.protocol === 'https:' && (Array.isArray(host) ? host.includes(url.hostname) : url.hostname === host) && !url.username && !url.password
287
+ } catch {
288
+ return false
289
+ }
290
+ }
291
+
292
+ /** Alibaba Cloud ACS3 signing, as required by Model Studio's Token Plan OpenAPI. */
293
+ export function signAlibabaRequest({ url, action, version, id, secret, token, date, nonce, method = 'GET', body = '' }) {
294
+ const target = new URL(url)
295
+ const payloadHash = createHash('sha256').update(body).digest('hex')
296
+ const headers = {
297
+ host: target.host,
298
+ 'x-acs-action': action,
299
+ 'x-acs-content-sha256': payloadHash,
300
+ 'x-acs-date': date,
301
+ 'x-acs-signature-nonce': nonce,
302
+ 'x-acs-version': version,
303
+ }
304
+ if (token) headers['x-acs-security-token'] = token
305
+ const names = Object.keys(headers).sort()
306
+ const signedHeaders = names.join(';')
307
+ const canonicalHeaders = names.map((name) => name + ':' + headers[name].trim() + '\n').join('')
308
+ const encode = (value) => encodeURIComponent(value).replace(/[!'()*]/g, (char) => '%' + char.charCodeAt(0).toString(16).toUpperCase())
309
+ const query = [...target.searchParams].sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => encode(key) + '=' + encode(value)).join('&')
310
+ const canonical = [method, target.pathname, query, canonicalHeaders, signedHeaders, payloadHash].join('\n')
311
+ const toSign = 'ACS3-HMAC-SHA256\n' + createHash('sha256').update(canonical).digest('hex')
312
+ const signature = createHmac('sha256', secret).update(toSign).digest('hex')
313
+ return { ...headers, authorization: 'ACS3-HMAC-SHA256 Credential=' + id + ',SignedHeaders=' + signedHeaders + ',Signature=' + signature, accept: 'application/json' }
314
+ }
315
+
316
+ async function providerAuth(credentials, profile, provider, spec) {
317
+ if (spec.auth === 'configured-key') {
318
+ if (!profile || typeof profile !== 'object') return null
319
+ let key
320
+ if (typeof profile.apiKeyEnv === 'string') {
321
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(profile.apiKeyEnv)) return null
322
+ key = (await credentials?.resolve?.(profile.apiKeyEnv))?.value
323
+ } else {
324
+ const record = await credentials?.readRecord?.('llm-pi-ai/' + provider)
325
+ if (record?.kind === 'api-key') key = record.key || record.env?.[spec.env]
326
+ }
327
+ return typeof key === 'string' && key.trim() ? { key: key.trim(), headers: {} } : null
328
+ }
329
+ if (!spec.auth || spec.auth === 'oauth-or-key') {
330
+ if (spec.auth === 'oauth-or-key' && !profile?.apiKeyEnv) {
331
+ const record = await credentials?.readRecord('llm-pi-ai/' + provider)
332
+ const grant = record?.kind === 'grant' ? record.payload : null
333
+ if (grant?.type === 'oauth' && typeof grant.access === 'string' && grant.access && Number.isFinite(grant.expires) && grant.expires > Date.now() + 30_000) {
334
+ return { key: grant.access, headers: {} }
335
+ }
336
+ }
337
+ const key = await providerKey(credentials, profile, provider, spec.env)
338
+ return key ? { key, headers: {} } : null
339
+ }
340
+ if (spec.auth === 'management') {
341
+ const key = await optionalCredential(credentials, 'XAI_MANAGEMENT_API_KEY')
342
+ const team = await optionalCredential(credentials, 'XAI_TEAM_ID')
343
+ if (!key || !team || !/^[A-Za-z0-9-]{1,80}$/.test(team)) return null
344
+ return { key, url: 'https://management-api.x.ai/v1/billing/teams/' + team + '/prepaid/balance', headers: {} }
345
+ }
346
+ if (spec.auth === 'alibaba') {
347
+ const id = await optionalCredential(credentials, 'ALIBABA_CLOUD_ACCESS_KEY_ID')
348
+ const secret = await optionalCredential(credentials, 'ALIBABA_CLOUD_ACCESS_KEY_SECRET')
349
+ const token = await optionalCredential(credentials, 'ALIBABA_CLOUD_SECURITY_TOKEN')
350
+ if (!id || !secret || !/^[A-Za-z0-9_-]+$/.test(id)) return null
351
+ return { key: id, secret, token, headers: {} }
352
+ }
353
+ if (profile?.apiKeyEnv) return null // A configured API key is not a subscription OAuth grant.
354
+ const record = await credentials?.readRecord('llm-pi-ai/' + provider)
355
+ const grant = record?.kind === 'grant' ? record.payload : null
356
+ if (!grant || grant.type !== 'oauth') return null
357
+ if (spec.auth === 'github') {
358
+ if (grant.enterpriseUrl || typeof grant.refresh !== 'string' || !grant.refresh) return null
359
+ return { key: grant.refresh, headers: {} }
360
+ }
361
+ if (typeof grant.access !== 'string' || !grant.access || !Number.isFinite(grant.expires) || grant.expires <= Date.now() + 30_000) return null
362
+ if (provider === 'openai-codex') {
363
+ if (typeof grant.accountId !== 'string' || !grant.accountId) return null
364
+ return { key: grant.access, headers: { 'chatgpt-account-id': grant.accountId } }
365
+ }
366
+ return { key: grant.access, headers: {} }
367
+ }
368
+
369
+ async function optionalCredential(credentials, ref) {
370
+ const hit = await credentials?.resolve?.(ref)
371
+ const value = hit?.value ?? process.env[ref]
372
+ return typeof value === 'string' && value.trim() ? value.trim() : null
373
+ }
374
+
375
+ export async function providerKey(credentials, profile, provider, defaultEnv) {
376
+ const ref = profile && typeof profile.apiKeyEnv === 'string' ? profile.apiKeyEnv : null
377
+ if (ref) {
378
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(ref)) return null
379
+ const hit = await credentials?.resolve(ref)
380
+ const value = hit?.value ?? process.env[ref]
381
+ return typeof value === 'string' && value.trim() ? value.trim() : null
382
+ }
383
+ const record = await credentials?.readRecord('llm-pi-ai/' + provider)
384
+ if (record) {
385
+ if (record.kind !== 'api-key') return null // OAuth grants require their owner to refresh them.
386
+ if (typeof record.key === 'string' && record.key.trim()) return record.key.trim()
387
+ const inRecord = record.env && record.env[defaultEnv]
388
+ if (typeof inRecord === 'string' && inRecord.trim()) return inRecord.trim()
389
+ }
390
+ if (!defaultEnv) return null
391
+ const hit = await credentials?.resolve(defaultEnv)
392
+ const value = hit?.value ?? process.env[defaultEnv]
393
+ return typeof value === 'string' && value.trim() ? value.trim() : null
394
+ }
395
+
396
+ /** Resolve official catalog entries and make bounded, single-flight reads. */
397
+ export function createProviderQuotaReader(ctx, request = fetch) {
398
+ const cache = new Map()
399
+ return async function readProviderQuotas(fresh = false, excluded = []) {
400
+ const llm = ctx.get('llm')
401
+ const settings = ctx.get('settings')
402
+ const credentials = ctx.get('credentials')
403
+ if (!llm || typeof llm.listConfigurableProviders !== 'function' || typeof settings?.describe !== 'function') return { ok: true, quotas: [] }
404
+ const directory = llm.listConfigurableProviders()
405
+ const descriptors = settings.describe({ redactSecrets: true })
406
+ const entries = Array.isArray(directory) ? directory.filter((entry) => entry && typeof entry.provider === 'string' && !excluded.includes(entry.provider)) : []
407
+ const quotas = await Promise.all(entries.map(async (entry) => {
408
+ const descriptor = Array.isArray(descriptors) ? descriptors.find((row) => row.ns === entry.settingsNs) : null
409
+ if (!descriptor) return null
410
+ const profile = descriptor?.value?.providers?.[entry.provider]
411
+ if (profile == null ? entry.declared !== false : typeof profile !== 'object') return null
412
+ const spec = officialQuotaSpec(entry.provider, profile)
413
+ if (!spec || !hasBuiltinQuotaQuery(entry.provider, profile)) return null
414
+ try {
415
+ const auth = await providerAuth(credentials, profile, entry.provider, spec)
416
+ if (!auth) return null
417
+ const key = auth.key
418
+ const identity = createHash('sha256').update(JSON.stringify([key, auth.secret, auth.token, auth.url || spec.url, auth.headers['chatgpt-account-id']])).digest('hex')
419
+ const cached = cache.get(entry.provider)
420
+ if (!fresh && cached?.identity === identity && Date.now() - cached.at < (spec.ttl || TTL_MS)) return cached.result
421
+ if (cached?.identity === identity && cached.promise) return cached.promise
422
+ const promise = (async () => {
423
+ let metrics = []
424
+ try {
425
+ const headers = spec.auth === 'alibaba'
426
+ ? signAlibabaRequest({ url: spec.url, action: 'GetSubscriptionStats', version: '2026-02-10', id: key, secret: auth.secret, token: auth.token, date: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), nonce: randomUUID() })
427
+ : { authorization: 'Bearer ' + key, accept: 'application/json', ...spec.headers, ...auth.headers }
428
+ const response = await request(auth.url || spec.url, {
429
+ method: 'GET',
430
+ headers,
431
+ redirect: 'error',
432
+ signal: AbortSignal.timeout(8000),
433
+ })
434
+ if (response.ok) metrics = spec.parse(await response.json())
435
+ } catch {
436
+ // An optional account-credit read can still succeed for OpenRouter.
437
+ }
438
+ if (entry.provider === 'openrouter') {
439
+ const managementKey = await optionalCredential(credentials, 'OPENROUTER_MANAGEMENT_API_KEY')
440
+ if (managementKey) {
441
+ try {
442
+ const credits = await request('https://openrouter.ai/api/v1/credits', {
443
+ method: 'GET',
444
+ headers: { authorization: 'Bearer ' + managementKey, accept: 'application/json' },
445
+ redirect: 'error',
446
+ signal: AbortSignal.timeout(8000),
447
+ })
448
+ if (credits.ok) metrics = parseOpenRouterCredits(await credits.json()).concat(metrics)
449
+ } catch {
450
+ // The model key's own limit can still be shown.
451
+ }
452
+ }
453
+ }
454
+ const name = entry.displayName && entry.displayName !== entry.provider ? entry.displayName : spec.name
455
+ return metrics.length ? { provider: entry.provider, name, metrics, syncedAt: Date.now() } : null
456
+ })()
457
+ cache.set(entry.provider, { identity, at: 0, result: null, promise })
458
+ try {
459
+ const result = await promise
460
+ cache.set(entry.provider, { identity, at: Date.now(), result })
461
+ return result
462
+ } catch {
463
+ if (cache.get(entry.provider)?.promise === promise) cache.delete(entry.provider)
464
+ return null
465
+ }
466
+ } catch {
467
+ // Invalid, expired, or unreachable providers are intentionally absent.
468
+ return null
469
+ }
470
+ }))
471
+ return { ok: true, quotas: quotas.filter(Boolean) }
472
+ }
473
+ }
474
+
475
+ /** Generic /balance examples do not count as a provider-specific query. */
476
+ export function hasBuiltinQuotaQuery(provider, profile) {
477
+ const spec = officialQuotaSpec(provider, profile)
478
+ return !!spec && spec.queryable !== false && (!spec.configuredOnly || !!profile && typeof profile === 'object') && officialProfile(profile, spec.host)
479
+ }
480
+
481
+ /** Query templates carry field paths and credential references, never keys. */
482
+ export function quotaTemplate(provider, profile) {
483
+ const spec = hasBuiltinQuotaQuery(provider, profile) ? officialQuotaSpec(provider, profile) : null
484
+ let baseURL = ''
485
+ try {
486
+ const base = new URL(profile?.baseURL)
487
+ if (!base.username && !base.password) baseURL = base.origin + base.pathname.replace(/\/$/, '')
488
+ } catch {}
489
+ const url = provider === 'xai' && spec ? 'https://management-api.x.ai/v1/billing/teams/{{teamId}}/prepaid/balance' : spec?.url || (provider === 'opencode' ? '' : baseURL ? baseURL + '/balance' : '')
490
+ let response = spec ? { format: 'official' } : { metrics: [{ label: '账户可用余额', kind: 'amount', remaining: 'data.balance', currency: 'CNY' }] }
491
+ if (spec && provider === 'deepseek') response = { rows: 'balance_infos', metrics: [{ label: '账户可用余额', kind: 'amount', remaining: 'total_balance', currencyPath: 'currency' }] }
492
+ if (spec && provider === 'openrouter') response = { metrics: [{ label: '密钥额度', kind: 'amount', remaining: 'data.limit_remaining', total: 'data.limit', currency: 'USD' }] }
493
+ if (spec && (provider === 'moonshotai' || provider === 'moonshotai-cn')) response = { metrics: [{ label: '账户可用余额', kind: 'amount', remaining: 'data.available_balance', currency: provider === 'moonshotai-cn' ? 'CNY' : 'USD' }] }
494
+ // StepFun's balance sits at the response ROOT. The generic `data.balance`
495
+ // guess prefilled for unknown providers silently yields no metric, so give
496
+ // this one the real paths — all three, so the editable form shows the same
497
+ // cash/voucher split the built-in parser reports.
498
+ if (spec === PROVIDERS.stepfun) response = {
499
+ metrics: [
500
+ { label: '账户可用余额', kind: 'amount', remaining: 'balance', currency: 'CNY' },
501
+ { label: '充值余额', kind: 'amount', remaining: 'total_cash_balance', currency: 'CNY' },
502
+ { label: '赠送余额', kind: 'amount', remaining: 'total_voucher_balance', currency: 'CNY' },
503
+ ],
504
+ }
505
+ return { url, method: 'GET', auth: 'provider', headers: { accept: 'application/json' }, response }
506
+ }
507
+
508
+ export function officialQuotaSpec(provider, profile) {
509
+ if (Object.hasOwn(PROVIDERS, provider)) return PROVIDERS[provider]
510
+ // Model settings allow arbitrary custom ids (including "setpfun"). Match
511
+ // StepFun by its verified HTTPS host, while credentials keep the original id.
512
+ if (profile?.baseURL && officialProfile(profile, PROVIDERS.stepfun.host)) return PROVIDERS.stepfun
513
+ return null
514
+ }
515
+
516
+ export async function customQuotaAuth(ctx, profile, provider, url, method, body) {
517
+ const spec = officialQuotaSpec(provider, profile)
518
+ const credentials = ctx.get('credentials')
519
+ const useOfficial = spec && officialProfile(profile, spec.host)
520
+ const auth = useOfficial || spec?.auth === 'configured-key'
521
+ ? await providerAuth(credentials, profile, provider, spec)
522
+ : { key: await providerKey(credentials, profile, provider, spec?.env) }
523
+ if (!auth?.key) return null
524
+ if (useOfficial && spec.auth === 'alibaba') return { headers: signAlibabaRequest({ url, method, body, action: 'GetSubscriptionStats', version: '2026-02-10', id: auth.key, secret: auth.secret, token: auth.token, date: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), nonce: randomUUID() }) }
525
+ return { headers: { authorization: 'Bearer ' + auth.key, ...(useOfficial ? spec.headers : {}), ...auth.headers }, url: auth.url }
526
+ }