@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.
- package/CHANGELOG.md +37 -0
- package/LICENSE +177 -0
- package/README.md +318 -0
- package/README.zh-CN.md +318 -0
- package/cordis.patch.yml +16 -0
- package/lib/client.js +4074 -0
- package/lib/index.js +1105 -0
- package/lib/provider-quotas.js +526 -0
- package/lib/quota-controls.js +479 -0
- package/package.json +58 -0
- package/screenshots/controls.png +0 -0
- package/screenshots/icon.svg +5 -0
- package/screenshots/quotas.png +0 -0
- package/screenshots/usage.png +0 -0
|
@@ -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
|
+
}
|