@ponydev/dsh-model-usage 0.0.0-stage → 1.0.1

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.
Binary file
Binary file
Binary file
@@ -0,0 +1,465 @@
1
+ // Host 侧额度适配器:deepseek-official / deepseek-account / zai-coding-cn(zai)
2
+ // 设计约束:只请求写死的白名单域名;凭据只在 Host 内存中出现;错误 message 脱敏。
3
+
4
+ export function err(code, message) {
5
+ const e = new Error(message)
6
+ e.code = code
7
+ return e
8
+ }
9
+
10
+ export function safeMsg(e) {
11
+ return String((e && e.message) || e)
12
+ .replace(/(sk-|Bearer\s+)[A-Za-z0-9._-]+/gi, '$1***')
13
+ .slice(0, 200)
14
+ }
15
+
16
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
17
+
18
+ export function clampPct(v) {
19
+ const n = Number(v)
20
+ if (!Number.isFinite(n)) return 0
21
+ return Math.max(0, Math.min(100, n))
22
+ }
23
+
24
+ /** 业务层错误提炼:有些接口(智谱)key 不对也返回 HTTP 200,真问题在 body 的 code/msg */
25
+ export function bizError(d) {
26
+ if (!d || typeof d !== 'object') return ''
27
+ if (d.success === false || d.ok === false) {
28
+ return String(d.msg || d.message || d.error || 'success=false').slice(0, 160)
29
+ }
30
+ const c = Number(d.code)
31
+ if (Number.isFinite(c) && c !== 0 && c !== 200 && d.msg) return String(d.msg).slice(0, 160)
32
+ if (typeof d.error === 'string' && d.error) return d.error.slice(0, 160)
33
+ return ''
34
+ }
35
+
36
+ /** 带超时的 fetch;网络层失败重试一次(undici 偶发 IPv6 优先导致 open.bigmodel.cn 连不上) */
37
+ export async function fetchT(url, init, ms) {
38
+ const doFetch = async () => {
39
+ const ctl = new AbortController()
40
+ const t = setTimeout(() => ctl.abort(), ms || 10000)
41
+ try {
42
+ return await fetch(url, { ...init, signal: ctl.signal })
43
+ } finally {
44
+ clearTimeout(t)
45
+ }
46
+ }
47
+ try {
48
+ return await doFetch()
49
+ } catch (e) {
50
+ await sleep(400)
51
+ return await doFetch()
52
+ }
53
+ }
54
+
55
+ /** 按顺序解析凭据 ref:优先注入作用域的 credentials 服务,再回退环境变量 */
56
+ export async function resolveKey(env) {
57
+ const cred = env.credentials
58
+ for (const ref of env.refs || []) {
59
+ if (!ref) continue
60
+ if (cred && typeof cred.resolve === 'function') {
61
+ for (let attempt = 0; attempt < 2; attempt++) {
62
+ try {
63
+ const hit = await cred.resolve(ref)
64
+ if (hit && hit.value) return hit.value
65
+ break
66
+ } catch (e) {
67
+ if (attempt === 0) {
68
+ await sleep(700)
69
+ continue
70
+ }
71
+ }
72
+ }
73
+ }
74
+ if (process.env[ref]) return process.env[ref]
75
+ }
76
+ return null
77
+ }
78
+
79
+ export function safeGet(ctx, name) {
80
+ try {
81
+ return typeof ctx.get === 'function' ? ctx.get(name) : null
82
+ } catch {
83
+ return null
84
+ }
85
+ }
86
+
87
+ /** GLM data.limits[] 按 type+unit 选窗口条目(数组顺序不保证,不能按下标) */
88
+ export function pickWindow(limits, unit) {
89
+ if (!Array.isArray(limits)) return null
90
+ for (const it of limits) {
91
+ if (!it || typeof it !== 'object') continue
92
+ const type = String(it.type || '')
93
+ if (type !== 'TOKENS_LIMIT' && type !== 'CREDIT_LIMIT') continue
94
+ if (Number(it.unit) !== unit) continue
95
+ return it
96
+ }
97
+ return null
98
+ }
99
+
100
+ // ---------------------------------------------------------------- adapters
101
+
102
+ const deepseekBalance = {
103
+ id: 'deepseek-balance',
104
+ refs: (cfg, route) => [cfg.credentials[route] || 'DEEPSEEK_API_KEY'],
105
+ async read(env) {
106
+ const key = await resolveKey(env)
107
+ if (!key) {
108
+ return { status: 'needs-credential', credential: { refs: env.refs, configured: false } }
109
+ }
110
+ const custom = process.env.DEEPSEEK_BASE_URL
111
+ if (custom) {
112
+ try {
113
+ if (new URL(custom).host !== 'api.deepseek.com') {
114
+ return {
115
+ status: 'unsupported',
116
+ credential: { refs: env.refs, configured: true },
117
+ error: { code: 'model-usage/custom-endpoint', message: 'DEEPSEEK_BASE_URL 指向自定义网关,该端点没有余额接口' },
118
+ }
119
+ }
120
+ } catch {}
121
+ }
122
+ const res = await fetchT('https://api.deepseek.com/user/balance', {
123
+ headers: { authorization: 'Bearer ' + key },
124
+ }, 10000)
125
+ if (res.status === 401 || res.status === 403) throw err('model-usage/unauthorized', 'HTTP ' + res.status + '(API key 无效或过期)')
126
+ if (!res.ok) throw err('model-usage/http', 'HTTP ' + res.status)
127
+ const data = await res.json()
128
+ const biz = bizError(data)
129
+ if (biz) return { status: 'error', error: { code: 'model-usage/biz', message: biz } }
130
+ const infos = Array.isArray(data && data.balance_infos) ? data.balance_infos : []
131
+ const pick = infos.find((i) => i && i.currency === 'CNY') || infos[0]
132
+ if (!pick) {
133
+ return { status: 'unsupported', error: { code: 'model-usage/empty', message: '余额响应缺少 balance_infos 字段' } }
134
+ }
135
+ return {
136
+ status: 'ok',
137
+ fetchedAt: Date.now(),
138
+ credential: { refs: env.refs, configured: true },
139
+ balance: {
140
+ currency: String(pick.currency || 'CNY').toUpperCase(),
141
+ total: String(pick.total_balance ?? ''),
142
+ granted: String(pick.granted_balance ?? '0'),
143
+ toppedUp: String(pick.topped_up_balance ?? '0'),
144
+ available: data.is_available !== false,
145
+ },
146
+ }
147
+ },
148
+ }
149
+
150
+ const deepseekAccount = {
151
+ id: 'deepseek-account',
152
+ refs: () => [],
153
+ async read(env) {
154
+ const acct = env.getAccount ? env.getAccount() : null
155
+ if (!acct || typeof acct.getBalance !== 'function') {
156
+ return { status: 'unsupported', error: { code: 'model-usage/no-service', message: '当前 DSH 没有账号余额服务' } }
157
+ }
158
+ const meta = {
159
+ version: String(process.env.DSH_CLIENT_VERSION || process.env.DSH_VERSION || 'unknown'),
160
+ locale: 'zh_CN',
161
+ timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60,
162
+ }
163
+ const res = await Promise.race([
164
+ acct.getBalance(meta).catch(() => null),
165
+ new Promise((r) => setTimeout(() => r('timeout'), 15000)),
166
+ ])
167
+ if (res === 'timeout') throw err('model-usage/timeout', '账号余额请求超时(15s)')
168
+ if (!res) return { status: 'needs-signin' }
169
+ if (res.status !== 'ready') {
170
+ return { status: 'error', error: { code: 'model-usage/account', message: '账号余额接口未就绪(' + String(res.status || 'unknown') + ')' } }
171
+ }
172
+ const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null }
173
+ const usable = (list) => (Array.isArray(list) ? list : [])
174
+ .map((w) => ({ cur: String((w && w.currency) || 'CNY').toUpperCase(), v: num(w && w.balance) }))
175
+ .filter((w) => w.v !== null)
176
+ const recharge = usable(res.value)
177
+ const bonus = usable(res.bonusWallets)
178
+ const all = recharge.concat(bonus)
179
+ if (!all.length) {
180
+ return { status: 'unsupported', error: { code: 'model-usage/empty', message: '账号没有可用的余额钱包' } }
181
+ }
182
+ const nonzero = all.filter((w) => w.v > 0)
183
+ const cur = ((nonzero.find((w) => w.cur === 'CNY') || nonzero[0] || all.find((w) => w.cur === 'CNY') || all[0])).cur
184
+ const sum = (l) => l.filter((w) => w.cur === cur).reduce((s, w) => s + w.v, 0)
185
+ const fmt = (n) => n.toFixed(2)
186
+ return {
187
+ status: 'ok',
188
+ fetchedAt: Date.now(),
189
+ credential: { refs: [], configured: true },
190
+ balance: {
191
+ currency: cur,
192
+ total: fmt(sum(recharge) + sum(bonus)),
193
+ granted: fmt(sum(bonus)),
194
+ toppedUp: fmt(sum(recharge)),
195
+ available: true,
196
+ },
197
+ }
198
+ },
199
+ }
200
+
201
+ const glmCoding = {
202
+ id: 'glm-coding',
203
+ refs: (cfg, route) => [cfg.credentials[route] || (route === 'zai' ? 'ZAI_API_KEY' : 'ZAI_CODING_CN_API_KEY')],
204
+ async read(env) {
205
+ const key = await resolveKey(env)
206
+ if (!key) {
207
+ return { status: 'needs-credential', credential: { refs: env.refs, configured: false } }
208
+ }
209
+ const host = env.route === 'zai' ? 'https://api.z.ai' : 'https://open.bigmodel.cn'
210
+ const g = (env.config && env.config.glm) || {}
211
+ const qs = []
212
+ if (g.planType) qs.push('type=' + encodeURIComponent(String(g.planType)))
213
+ const url = host + '/api/monitor/usage/quota/limit' + (qs.length ? '?' + qs.join('&') : '')
214
+ const headers = {
215
+ authorization: key,
216
+ accept: 'application/json',
217
+ }
218
+ if (g.organization) headers['bigmodel-organization'] = String(g.organization)
219
+ if (g.project) headers['bigmodel-project'] = String(g.project)
220
+ let res = await fetchT(url, { headers }, 10000)
221
+ if (res.status === 401) {
222
+ const retry = { ...headers, authorization: 'Bearer ' + key }
223
+ res = await fetchT(url, { headers: retry }, 10000)
224
+ }
225
+ if (res.status === 401 || res.status === 403) {
226
+ throw err('model-usage/unauthorized', 'HTTP ' + res.status + '(key 无效或过期)')
227
+ }
228
+ if (!res.ok) throw err('model-usage/http', 'HTTP ' + res.status)
229
+ const data = await res.json()
230
+ const biz = bizError(data)
231
+ if (biz) {
232
+ const unsupported = /coding plan/i.test(biz)
233
+ return {
234
+ status: unsupported ? 'unsupported' : 'error',
235
+ error: { code: 'model-usage/biz', message: unsupported ? biz + '(该 key 可能不是 Coding Plan 订阅账号)' : biz },
236
+ }
237
+ }
238
+ const d = data && data.data
239
+ const limits = d && Array.isArray(d.limits) ? d.limits : null
240
+ if (!limits) {
241
+ return { status: 'unsupported', error: { code: 'model-usage/shape', message: '响应缺少 data.limits 字段(接口可能改版)' } }
242
+ }
243
+ const windows = []
244
+ for (const [k, label, unit] of [['rolling', '5h', 3], ['weekly', '周', 6]]) {
245
+ const it = pickWindow(limits, unit)
246
+ if (!it) continue
247
+ windows.push({
248
+ key: k,
249
+ label,
250
+ usedPct: clampPct(it.percentage),
251
+ resetAt: Number(it.nextResetTime) || null,
252
+ })
253
+ }
254
+ if (!windows.length) {
255
+ const types = limits.map((l) => (l && l.type) + '/' + (l && l.unit)).join(', ')
256
+ return {
257
+ status: 'unsupported',
258
+ error: { code: 'model-usage/shape', message: 'data.limits 中未找到 5h/周窗口条目(got: ' + types + ')' },
259
+ }
260
+ }
261
+ return {
262
+ status: 'ok',
263
+ fetchedAt: Date.now(),
264
+ credential: { refs: env.refs, configured: true },
265
+ plan: typeof d.level === 'string' ? d.level : '',
266
+ windows,
267
+ }
268
+ },
269
+ }
270
+
271
+ function dotPath(obj, path) {
272
+ let cur = obj
273
+ for (const part of String(path || '').split('.')) {
274
+ if (cur == null) return undefined
275
+ cur = cur[part]
276
+ }
277
+ return cur
278
+ }
279
+ function num(v) {
280
+ const n = Number(v)
281
+ return Number.isFinite(n) ? n : null
282
+ }
283
+
284
+ // Kimi Coding Plan(会员套餐):GET https://api.kimi.com/coding/v1/usages
285
+ // 实测 schema(社区代码对线上接口验证 2026-07):
286
+ // usage:{limit,used,remaining,resetTime} → 周窗口
287
+ // limits:[{window:{duration,timeUnit},detail:{limit,used,resetTime}}] → 滚动窗口(300 分钟=5h)
288
+ const kimiCoding = {
289
+ id: 'kimi-coding',
290
+ refs: (cfg, route) => [cfg.credentials[route] || 'KIMI_CODING_API_KEY', 'KIMI_API_KEY', 'MOONSHOT_API_KEY'],
291
+ async read(env) {
292
+ const key = await resolveKey(env)
293
+ if (!key) return { status: 'needs-credential', credential: { refs: env.refs, configured: false } }
294
+ const res = await fetchT('https://api.kimi.com/coding/v1/usages', {
295
+ headers: { authorization: 'Bearer ' + key, accept: 'application/json' },
296
+ }, 10000)
297
+ if (res.status === 401 || res.status === 403) throw err('model-usage/unauthorized', 'HTTP ' + res.status + '(Kimi 凭据无效)')
298
+ if (!res.ok) throw err('model-usage/http', 'HTTP ' + res.status)
299
+ const data = await res.json()
300
+ const windows = []
301
+ const weekly = data && data.usage
302
+ if (weekly) {
303
+ const limit = num(weekly.limit)
304
+ const used = num(weekly.used)
305
+ if (limit && limit > 0 && used != null) {
306
+ windows.push({
307
+ key: 'weekly',
308
+ label: '周',
309
+ usedPct: clampPct((used / limit) * 100),
310
+ resetAt: weekly.resetTime ? Date.parse(weekly.resetTime) || null : null,
311
+ })
312
+ }
313
+ }
314
+ const limits = data && Array.isArray(data.limits) ? data.limits : []
315
+ for (const entry of limits) {
316
+ const detail = (entry && entry.detail) || {}
317
+ const limit = num(detail.limit)
318
+ const used = num(detail.used)
319
+ if (!limit || limit <= 0 || used == null) continue
320
+ const w = (entry.window || {})
321
+ const dur = num(w.duration) || 0
322
+ const unit = String(w.timeUnit || '')
323
+ let label = '滚动窗口'
324
+ if (unit === 'TIME_UNIT_MINUTE' && dur > 0) label = dur % 60 === 0 ? (dur / 60) + 'h' : dur + 'm'
325
+ else if (unit === 'TIME_UNIT_HOUR' && dur > 0) label = dur + 'h'
326
+ else if (unit === 'TIME_UNIT_DAY' && dur > 0) label = dur + 'd'
327
+ windows.push({
328
+ key: dur >= 240 && dur <= 360 ? 'rolling' : 'w' + Math.round(dur) + unit,
329
+ label,
330
+ usedPct: clampPct((used / limit) * 100),
331
+ resetAt: detail.resetTime ? Date.parse(detail.resetTime) || null : null,
332
+ })
333
+ }
334
+ if (!windows.length) {
335
+ return { status: 'unsupported', error: { code: 'model-usage/shape', message: 'Kimi 响应中未找到额度窗口' } }
336
+ }
337
+ const level = data && data.user && data.user.membership && data.user.membership.level
338
+ return {
339
+ status: 'ok',
340
+ fetchedAt: Date.now(),
341
+ credential: { refs: env.refs, configured: true },
342
+ plan: level ? String(level).replace(/^LEVEL_/, '').toLowerCase() : '',
343
+ windows,
344
+ }
345
+ },
346
+ }
347
+
348
+ // OpenRouter 额度:GET https://openrouter.ai/api/v1/credits → data:{total_credits,total_usage}
349
+ // 兜底:GET /api/v1/key → data:{limit,usage,limit_remaining}
350
+ const openrouter = {
351
+ id: 'openrouter',
352
+ refs: (cfg, route) => [cfg.credentials[route] || 'OPENROUTER_API_KEY'],
353
+ async read(env) {
354
+ const key = await resolveKey(env)
355
+ if (!key) return { status: 'needs-credential', credential: { refs: env.refs, configured: false } }
356
+ const headers = { authorization: 'Bearer ' + key, accept: 'application/json' }
357
+ const res = await fetchT('https://openrouter.ai/api/v1/credits', { headers }, 10000)
358
+ if (res.status === 401 || res.status === 403) throw err('model-usage/unauthorized', 'HTTP ' + res.status + '(OpenRouter 凭据无效)')
359
+ if (res.ok) {
360
+ const body = await res.json()
361
+ const d = (body && body.data) || {}
362
+ const total = num(d.total_credits)
363
+ const used = num(d.total_usage)
364
+ if (total != null && used != null) {
365
+ const remain = Math.max(0, total - used)
366
+ return {
367
+ status: 'ok',
368
+ fetchedAt: Date.now(),
369
+ credential: { refs: env.refs, configured: true },
370
+ balance: {
371
+ currency: 'USD',
372
+ total: (Math.round(remain * 100) / 100).toFixed(2),
373
+ toppedUp: (Math.round(total * 100) / 100).toFixed(2),
374
+ granted: '0.00',
375
+ available: remain > 0,
376
+ },
377
+ }
378
+ }
379
+ }
380
+ const res2 = await fetchT('https://openrouter.ai/api/v1/key', { headers }, 10000)
381
+ if (!res2.ok) throw err('model-usage/http', 'HTTP ' + res2.status)
382
+ const body2 = await res2.json()
383
+ const d2 = (body2 && body2.data) || {}
384
+ const remain2 = num(d2.limit_remaining)
385
+ const limit = num(d2.limit)
386
+ const usage = num(d2.usage) || 0
387
+ if (remain2 == null && limit == null) {
388
+ return { status: 'unsupported', error: { code: 'model-usage/shape', message: 'OpenRouter 未返回额度字段' } }
389
+ }
390
+ const total = remain2 != null ? remain2 : Math.max(0, (limit || 0) - usage)
391
+ return {
392
+ status: 'ok',
393
+ fetchedAt: Date.now(),
394
+ credential: { refs: env.refs, configured: true },
395
+ balance: { currency: 'USD', total: (Math.round(total * 100) / 100).toFixed(2), toppedUp: limit != null ? (Math.round(limit * 100) / 100).toFixed(2) : '', granted: '0.00', available: total > 0 },
396
+ }
397
+ },
398
+ }
399
+
400
+ // 通用自定义额度:任何供应商都能通过 cordis.patch.yml 的 customQuota 配置接入
401
+ // customQuota: { [route]: { url, auth:'bearer'|'raw'|'x-api-key'|'none', keyRef, headers,
402
+ // balance:{path,currencyPath,currency}, windows:[{key,label,usedPath,limitPath,resetAtPath,resetAtUnit}] } }
403
+ const customQuota = {
404
+ id: 'custom-quota',
405
+ refs: (cfg, route) => {
406
+ const c = (cfg.customQuota && cfg.customQuota[route]) || {}
407
+ return [c.keyRef || (cfg.credentials && cfg.credentials[route]) || route.toUpperCase().replace(/[^A-Z0-9]+/g, '_') + '_API_KEY']
408
+ },
409
+ async read(env) {
410
+ const c = (env.config.customQuota && env.config.customQuota[env.route]) || null
411
+ if (!c || !c.url) return { status: 'unsupported', error: { code: 'model-usage/config', message: '缺少 customQuota 配置' } }
412
+ const u = new URL(String(c.url))
413
+ const key = await resolveKey(env)
414
+ const headers = { accept: 'application/json', ...(c.headers || {}) }
415
+ if (key) {
416
+ if (c.auth === 'raw') headers.authorization = key
417
+ else if (c.auth === 'x-api-key') headers['x-api-key'] = key
418
+ else headers.authorization = 'Bearer ' + key
419
+ }
420
+ const res = await fetchT(u.toString(), { headers }, 10000)
421
+ if (res.status === 401 || res.status === 403) throw err('model-usage/unauthorized', 'HTTP ' + res.status)
422
+ if (!res.ok) throw err('model-usage/http', 'HTTP ' + res.status)
423
+ const body = await res.json()
424
+ if (c.balance && c.balance.path) {
425
+ const total = num(dotPath(body, c.balance.path))
426
+ if (total == null) return { status: 'unsupported', error: { code: 'model-usage/shape', message: 'customQuota.balance.path 未取到数值' } }
427
+ const currency = c.balance.currencyPath ? String(dotPath(body, c.balance.currencyPath) || c.balance.currency || 'USD') : (c.balance.currency || 'USD')
428
+ return {
429
+ status: 'ok',
430
+ fetchedAt: Date.now(),
431
+ credential: { refs: env.refs, configured: true },
432
+ balance: { currency, total: total.toFixed(2), toppedUp: '', granted: '', available: total > 0 },
433
+ }
434
+ }
435
+ if (Array.isArray(c.windows) && c.windows.length) {
436
+ const windows = []
437
+ for (const w of c.windows) {
438
+ const limit = num(dotPath(body, w.limitPath))
439
+ const used = num(dotPath(body, w.usedPath))
440
+ if (!limit || limit <= 0 || used == null) continue
441
+ let resetAt = null
442
+ if (w.resetAtPath) {
443
+ const raw = dotPath(body, w.resetAtPath)
444
+ if (w.resetAtUnit === 'iso' || typeof raw === 'string') resetAt = Date.parse(raw) || null
445
+ else if (w.resetAtUnit === 's') resetAt = Number(raw) * 1000 || null
446
+ else resetAt = Number(raw) || null
447
+ }
448
+ windows.push({ key: w.key || 'w' + windows.length, label: w.label || w.key || '窗口', usedPct: clampPct((used / limit) * 100), resetAt })
449
+ }
450
+ if (!windows.length) return { status: 'unsupported', error: { code: 'model-usage/shape', message: 'customQuota.windows 未取到有效窗口' } }
451
+ return { status: 'ok', fetchedAt: Date.now(), credential: { refs: env.refs, configured: true }, windows }
452
+ }
453
+ return { status: 'unsupported', error: { code: 'model-usage/config', message: 'customQuota 需要 balance 或 windows 配置' } }
454
+ },
455
+ }
456
+
457
+ export function adapterFor(route, cfg) {
458
+ if (route === 'deepseek-official') return deepseekBalance
459
+ if (route === 'deepseek-account') return deepseekAccount
460
+ if (route === 'zai-coding-cn' || route === 'zai') return glmCoding
461
+ if (route && /^openrouter/i.test(route)) return openrouter
462
+ if (route && /^(kimi|moonshot)/i.test(route)) return kimiCoding
463
+ if (cfg && cfg.customQuota && cfg.customQuota[route] && cfg.customQuota[route].url) return customQuota
464
+ return null
465
+ }