@ljwei-stak/dsh-model-router 0.13.0
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/.dsh-plugin/client.js +3092 -0
- package/.dsh-plugin/index.mjs +1651 -0
- package/.dsh-plugin/official-tools-remote-service.mjs +104 -0
- package/.dsh-plugin/shared/harness-plan.mjs +179 -0
- package/.dsh-plugin/shared/livebench.mjs +264 -0
- package/.dsh-plugin/shared/model-profiles.mjs +142 -0
- package/.dsh-plugin/shared/official-team-runtime.mjs +411 -0
- package/.dsh-plugin/shared/official-tool-executor.mjs +801 -0
- package/.dsh-plugin/shared/official-tool-registry.mjs +138 -0
- package/.dsh-plugin/shared/official-tools-remote.mjs +173 -0
- package/.dsh-plugin/shared/official-tools-runtime.mjs +642 -0
- package/.dsh-plugin/shared/router-state.mjs +207 -0
- package/.dsh-plugin/shared/router.mjs +1134 -0
- package/.dsh-plugin/shared/routing-presets.mjs +49 -0
- package/.dsh-plugin/shared/run-ledger.mjs +348 -0
- package/.dsh-plugin/shared/security-boundaries.mjs +54 -0
- package/.dsh-plugin/shared/subscription-billing.mjs +340 -0
- package/.dsh-plugin/shared/task-executors.mjs +1154 -0
- package/.dsh-plugin/shared/tool-health.mjs +311 -0
- package/.dsh-plugin/shared/vendor-mimo-grok-adapter.mjs +308 -0
- package/.dsh-plugin/shared/vendor-minimax-adapter.mjs +247 -0
- package/.dsh-plugin/shared/zcode-bundle.mjs +208 -0
- package/.dsh-plugin/shared/zcode-installer.mjs +247 -0
- package/CHANGELOG.md +36 -0
- package/INSTALLATION_GUIDE.zh.md +134 -0
- package/LICENSE +21 -0
- package/MIGRATION.md +53 -0
- package/README.i18n.yaml +3 -0
- package/README.md +424 -0
- package/README.zh.md +413 -0
- package/cordis.patch.yml +12 -0
- package/docs/assets/candidate-pruning.svg +80 -0
- package/docs/assets/desktop-official-tools-0.9.0.png +0 -0
- package/docs/assets/router-only-0.12.0.png +0 -0
- package/docs/assets/routing-workflow.svg +96 -0
- package/docs/assets/workbench-usage.svg +119 -0
- package/package.json +161 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription-first billing: which channel a route uses first (a vendor CLI
|
|
3
|
+
* account login or a coding-plan key endpoint), how quota / rate-limit
|
|
4
|
+
* exhaustion is recognised in vendor errors, and how long an exhausted
|
|
5
|
+
* subscription is skipped. Pure logic; the Host persists the quota state.
|
|
6
|
+
*/
|
|
7
|
+
import { toolForProvider } from './official-tool-registry.mjs'
|
|
8
|
+
|
|
9
|
+
export const BILLING_MODES = Object.freeze(['subscription-first', 'api-only', 'subscription-only'])
|
|
10
|
+
export const DEFAULT_BILLING_MODE = 'subscription-first'
|
|
11
|
+
export const DEFAULT_COOLDOWN_MINUTES = 60
|
|
12
|
+
/** Transient throttling (concurrency, overload) is skipped only briefly. */
|
|
13
|
+
export const RATE_LIMIT_COOLDOWN_MS = 60_000
|
|
14
|
+
const MAX_COOLDOWN_MS = 31 * 24 * 60 * 60_000
|
|
15
|
+
|
|
16
|
+
export const BILLING_MODE_LABEL = Object.freeze({
|
|
17
|
+
'subscription-first': '订阅优先',
|
|
18
|
+
'api-only': '只用 API Key',
|
|
19
|
+
'subscription-only': '只用订阅',
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Documented exhaustion messages per vendor key (registry tool id). `quota`
|
|
24
|
+
* means a plan window is used up; `rate-limit` is a short throttle. Sources
|
|
25
|
+
* are listed so unverified entries are visible.
|
|
26
|
+
*/
|
|
27
|
+
export const VENDOR_QUOTA_PATTERNS = Object.freeze({
|
|
28
|
+
'claude-code': Object.freeze([
|
|
29
|
+
{ kind: 'quota', source: 'code.claude.com/docs/en/errors', re: /you['’]?ve hit your [a-z0-9 -]{0,24}limit/i },
|
|
30
|
+
{ kind: 'quota', source: '旧版 CLI 文本(未核验)', re: /claude ai usage limit reached|usage limit reached\|\d{10}/i },
|
|
31
|
+
{ kind: 'rate-limit', source: 'code.claude.com/docs/en/errors', re: /server is temporarily limiting requests|request rejected \(429\)/i },
|
|
32
|
+
]),
|
|
33
|
+
codex: Object.freeze([
|
|
34
|
+
{ kind: 'quota', source: 'Codex usage_limit_reached(社区记录的 API 返回)', re: /usage_limit_reached|you['’]?ve hit your usage limit|usage limit has been reached/i },
|
|
35
|
+
{ kind: 'rate-limit', source: 'OpenAI 429(通用)', re: /rate_limit_exceeded|rate limit reached for/i },
|
|
36
|
+
]),
|
|
37
|
+
gemini: Object.freeze([
|
|
38
|
+
{ kind: 'quota', source: 'Google API RESOURCE_EXHAUSTED', re: /RESOURCE_EXHAUSTED|quota exceeded|exhausted your (?:daily )?quota|usage limit reached for all/i },
|
|
39
|
+
]),
|
|
40
|
+
'kimi-code': Object.freeze([
|
|
41
|
+
{ kind: 'quota', source: 'kimi.com/code/docs 错误参考', re: /you['’]?ve reached your (?:5-hour|weekly \(7-day\)|monthly) usage limit/i },
|
|
42
|
+
{ kind: 'rate-limit', source: 'kimi.com/code/docs 错误参考', re: /you['’]?ve reached your concurrent request limit|we['’]?re receiving too many requests|engine is currently overloaded/i },
|
|
43
|
+
]),
|
|
44
|
+
'minimax-code': Object.freeze([
|
|
45
|
+
{ kind: 'quota', source: 'platform.minimax.io 错误码 2056', re: /(?:\bcode\b["'\s:=]*|\[)2056\b|usage limit exceeded|token plan usage limit reached/i },
|
|
46
|
+
{ kind: 'rate-limit', source: 'platform.minimax.io 错误码 2045', re: /(?:\bcode\b["'\s:=]*|\[)2045\b|rate growth limit/i },
|
|
47
|
+
]),
|
|
48
|
+
zcode: Object.freeze([
|
|
49
|
+
{ kind: 'quota', source: 'docs.z.ai / docs.bigmodel.cn 错误码 1308–1321', re: /(?:\bcode\b["'\s:=]*|\[)13(?:08|09|10|1[6-9]|2[01])\b|usage limit reached for|weekly\/monthly limit exhausted|已达到.{0,20}使用上限|套餐已到期|每周\/每月使用上限/i },
|
|
50
|
+
{ kind: 'rate-limit', source: 'docs.z.ai 错误码 1302/1305', re: /(?:\bcode\b["'\s:=]*|\[)130[25]\b|rate limit reached for requests|速率限制|访问量过大/i },
|
|
51
|
+
]),
|
|
52
|
+
'*': Object.freeze([
|
|
53
|
+
{ kind: 'rate-limit', source: 'HTTP 429(通用)', re: /\b429\b|too many requests|rate[_ ]limit/i },
|
|
54
|
+
]),
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const clean = value => typeof value === 'string' ? value.trim() : ''
|
|
58
|
+
|
|
59
|
+
/** Vendor key for a route or tool: the registry tool id, else '*'. */
|
|
60
|
+
export function vendorKey({ toolId = null, provider = '' } = {}) {
|
|
61
|
+
return clean(toolId) || toolForProvider(provider)?.id || '*'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Parse `quotaPatternsJson`: `{ "<tool id | provider | *>": { "quota": [regex], "rateLimit": [regex] } }`.
|
|
66
|
+
* Invalid entries are dropped and reported, never thrown, so one bad regex
|
|
67
|
+
* does not stop execution.
|
|
68
|
+
*/
|
|
69
|
+
export function parseQuotaPatterns(value) {
|
|
70
|
+
const patterns = {}
|
|
71
|
+
const errors = []
|
|
72
|
+
if (value === undefined || value === null || clean(value) === '') return { patterns, errors }
|
|
73
|
+
let input
|
|
74
|
+
try { input = JSON.parse(value) } catch { return { patterns, errors: ['quotaPatternsJson 不是有效的 JSON'] } }
|
|
75
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return { patterns, errors: ['quotaPatternsJson 必须是对象'] }
|
|
76
|
+
for (const [key, entry] of Object.entries(input).slice(0, 40)) {
|
|
77
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { errors.push(`${key}: 必须是对象`); continue }
|
|
78
|
+
const list = []
|
|
79
|
+
for (const [field, kind] of [['quota', 'quota'], ['rateLimit', 'rate-limit']]) {
|
|
80
|
+
const values = entry[field]
|
|
81
|
+
if (values === undefined) continue
|
|
82
|
+
if (!Array.isArray(values)) { errors.push(`${key}.${field}: 必须是数组`); continue }
|
|
83
|
+
for (const source of values.slice(0, 20)) {
|
|
84
|
+
if (typeof source !== 'string' || !source || source.length > 200) { errors.push(`${key}.${field}: 每项必须是 1–200 字符的正则`); continue }
|
|
85
|
+
try { list.push({ kind, source: '用户配置', re: new RegExp(source, 'i') }) } catch { errors.push(`${key}.${field}: 无效正则 ${source.slice(0, 40)}`) }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (list.length) patterns[key] = list
|
|
89
|
+
}
|
|
90
|
+
return { patterns, errors }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const WEEKDAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
|
|
94
|
+
|
|
95
|
+
/** Reset time from vendor text, as epoch ms in the future, or null. */
|
|
96
|
+
export function parseResetAt(text, now = Date.now()) {
|
|
97
|
+
const value = String(text ?? '')
|
|
98
|
+
const future = at => Number.isFinite(at) && at > now && at - now <= MAX_COOLDOWN_MS ? at : null
|
|
99
|
+
let match = /"?resets_at"?\s*[:=]\s*(\d{10})\b/i.exec(value)
|
|
100
|
+
if (match && future(Number(match[1]) * 1000)) return Number(match[1]) * 1000
|
|
101
|
+
match = /"?resets_in_seconds"?\s*[:=]\s*(\d{1,8})\b/i.exec(value)
|
|
102
|
+
if (match && Number(match[1]) > 0) return future(now + Number(match[1]) * 1000)
|
|
103
|
+
match = /usage limit reached\|(\d{10})\b/i.exec(value)
|
|
104
|
+
if (match) return future(Number(match[1]) * 1000)
|
|
105
|
+
match = /retry[- ]after\s*[:=]?\s*(\d{1,6})\b/i.exec(value) ?? /retry in (\d+(?:\.\d+)?)\s*s\b/i.exec(value)
|
|
106
|
+
if (match) return future(now + Math.ceil(Number(match[1]) * 1000))
|
|
107
|
+
match = /try again in\s+(?:(\d+)\s*hours?)?[\s,]*(?:(\d+)\s*min(?:ute)?s?)?/i.exec(value)
|
|
108
|
+
if (match && (match[1] || match[2])) return future(now + ((Number(match[1] ?? 0) * 60) + Number(match[2] ?? 0)) * 60_000)
|
|
109
|
+
// GLM: "reset at 2026-10-02 18:30:00" / "将在 2026-10-02 18:30:00 重置" (host local time).
|
|
110
|
+
match = /(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/.exec(value)
|
|
111
|
+
if (match && /reset|重置|resets/i.test(value)) {
|
|
112
|
+
const [, y, mo, d, h, mi, s] = match
|
|
113
|
+
return future(new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s ?? 0)).getTime())
|
|
114
|
+
}
|
|
115
|
+
// Claude: "resets 3:45pm", "resets Mon 12:00am" (host local time).
|
|
116
|
+
match = /resets\s+(?:(sun|mon|tue|wed|thu|fri|sat)[a-z]*\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i.exec(value)
|
|
117
|
+
if (match) {
|
|
118
|
+
const [, day, hourText, minuteText, meridiem] = match
|
|
119
|
+
let hour = Number(hourText) % 12
|
|
120
|
+
if (meridiem.toLowerCase() === 'pm') hour += 12
|
|
121
|
+
const at = new Date(now)
|
|
122
|
+
at.setHours(hour, Number(minuteText ?? 0), 0, 0)
|
|
123
|
+
if (day) {
|
|
124
|
+
const target = WEEKDAYS.indexOf(day.toLowerCase().slice(0, 3))
|
|
125
|
+
let offset = (target - at.getDay() + 7) % 7
|
|
126
|
+
if (offset === 0 && at.getTime() <= now) offset = 7
|
|
127
|
+
at.setDate(at.getDate() + offset)
|
|
128
|
+
} else if (at.getTime() <= now) at.setDate(at.getDate() + 1)
|
|
129
|
+
return future(at.getTime())
|
|
130
|
+
}
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Classify a vendor failure. Returns null when it is not a quota or rate
|
|
136
|
+
* limit, else `{ kind, resetAt, source }`. Vendor patterns win over the
|
|
137
|
+
* generic 429 rule; user patterns are checked first.
|
|
138
|
+
*/
|
|
139
|
+
export function detectQuotaExhaustion(text, { vendor = '*', provider = '', extraPatterns = {}, now = Date.now() } = {}) {
|
|
140
|
+
const value = String(text ?? '')
|
|
141
|
+
if (!value.trim()) return null
|
|
142
|
+
const lists = [
|
|
143
|
+
extraPatterns[provider] ?? [], extraPatterns[vendor] ?? [], extraPatterns['*'] ?? [],
|
|
144
|
+
VENDOR_QUOTA_PATTERNS[vendor] ?? [], VENDOR_QUOTA_PATTERNS['*'],
|
|
145
|
+
]
|
|
146
|
+
for (const list of lists) {
|
|
147
|
+
for (const pattern of list) {
|
|
148
|
+
if (pattern.re.test(value)) return { kind: pattern.kind, resetAt: parseResetAt(value, now), source: pattern.source }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Until when an exhausted subscription is skipped. */
|
|
155
|
+
export function exhaustedUntil(info, { now = Date.now(), cooldownMinutes = DEFAULT_COOLDOWN_MINUTES } = {}) {
|
|
156
|
+
if (Number.isFinite(info?.resetAt) && info.resetAt > now) return info.resetAt
|
|
157
|
+
if (info?.kind === 'rate-limit') return now + RATE_LIMIT_COOLDOWN_MS
|
|
158
|
+
const minutes = Number.isFinite(cooldownMinutes) && cooldownMinutes > 0 ? cooldownMinutes : DEFAULT_COOLDOWN_MINUTES
|
|
159
|
+
return now + Math.min(MAX_COOLDOWN_MS, minutes * 60_000)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** In-memory quota state with an optional persistence callback. Keys: `cli:<tool>` or `plan:<provider>`. */
|
|
163
|
+
const finiteOr = (value, fallback) => (Number.isFinite(value) ? value : fallback)
|
|
164
|
+
|
|
165
|
+
export function createQuotaTracker({ now = Date.now, persist = null } = {}) {
|
|
166
|
+
const entries = new Map()
|
|
167
|
+
// `removed` lets a shared store merge this snapshot with other processes' entries.
|
|
168
|
+
const save = (removed = []) => { if (typeof persist === 'function') Promise.resolve(persist(Object.fromEntries(entries), { removed })).catch(() => {}) }
|
|
169
|
+
// markedAt of each entry as last seen in the shared store, so sync() can tell
|
|
170
|
+
// "another process cleared it" apart from "marked here, not persisted yet".
|
|
171
|
+
const seen = new Map()
|
|
172
|
+
const valid = entry => entry && Number.isFinite(entry.until) && entry.until > now()
|
|
173
|
+
return {
|
|
174
|
+
load(saved) {
|
|
175
|
+
for (const [key, entry] of Object.entries(saved ?? {})) {
|
|
176
|
+
if (valid(entry)) { entries.set(key, entry); seen.set(key, finiteOr(entry.markedAt, 0)) }
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
/**
|
|
180
|
+
* Adopt marks and clears made by other processes. A newer on-disk mark wins;
|
|
181
|
+
* an entry missing on disk is dropped only if this tracker saw it there before
|
|
182
|
+
* and has not re-marked it since. Returns the number of changed keys.
|
|
183
|
+
*/
|
|
184
|
+
sync(saved) {
|
|
185
|
+
let changed = 0
|
|
186
|
+
const disk = Object.fromEntries(Object.entries(saved ?? {}).filter(([, entry]) => valid(entry)))
|
|
187
|
+
for (const [key, entry] of Object.entries(disk)) {
|
|
188
|
+
const mine = entries.get(key)
|
|
189
|
+
if (!mine || finiteOr(entry.markedAt, 0) > finiteOr(mine.markedAt, 0)) { entries.set(key, { ...entry }); changed += 1 }
|
|
190
|
+
seen.set(key, finiteOr(entry.markedAt, 0))
|
|
191
|
+
}
|
|
192
|
+
for (const [key, mine] of [...entries]) {
|
|
193
|
+
if (key in disk) continue
|
|
194
|
+
if (seen.has(key) && finiteOr(mine.markedAt, 0) <= seen.get(key)) { entries.delete(key); changed += 1 }
|
|
195
|
+
seen.delete(key)
|
|
196
|
+
}
|
|
197
|
+
return changed
|
|
198
|
+
},
|
|
199
|
+
mark(key, info, { cooldownMinutes } = {}) {
|
|
200
|
+
const at = now()
|
|
201
|
+
const entry = {
|
|
202
|
+
until: exhaustedUntil(info, { now: at, cooldownMinutes }),
|
|
203
|
+
kind: info?.kind ?? 'quota',
|
|
204
|
+
resetReported: Number.isFinite(info?.resetAt) && info.resetAt > at,
|
|
205
|
+
detail: clean(info?.detail).slice(0, 300),
|
|
206
|
+
source: clean(info?.source).slice(0, 120),
|
|
207
|
+
markedAt: at,
|
|
208
|
+
}
|
|
209
|
+
entries.set(key, entry)
|
|
210
|
+
save()
|
|
211
|
+
return entry
|
|
212
|
+
},
|
|
213
|
+
status(key) {
|
|
214
|
+
const entry = entries.get(key)
|
|
215
|
+
if (!entry) return null
|
|
216
|
+
if (entry.until <= now()) { entries.delete(key); save([key]); return null }
|
|
217
|
+
return { ...entry }
|
|
218
|
+
},
|
|
219
|
+
clear(key) { if (entries.delete(key)) save([key]) },
|
|
220
|
+
snapshot() {
|
|
221
|
+
const at = now()
|
|
222
|
+
return Object.fromEntries([...entries].filter(([, entry]) => entry.until > at).map(([key, entry]) => [key, { ...entry }]))
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export const routeKeyOf = (provider, model) => `${clean(provider)}\u0000${clean(model)}`
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The subscription channel for a route and the API-key route behind it.
|
|
231
|
+
* - plan-key: a Harness catalog route whose provider is a coding-plan
|
|
232
|
+
* endpoint (profile `subscription: 'plan-key'`, optional `apiRoute`).
|
|
233
|
+
* An API route named as another plan route's `apiRoute` uses that plan first.
|
|
234
|
+
* - cli-login: a vendor CLI signed in to its own subscription account.
|
|
235
|
+
*/
|
|
236
|
+
/**
|
|
237
|
+
* Provider ids that look like a coding-plan endpoint (e.g. "glm-coding-plan",
|
|
238
|
+
* "kimi-code", "minimax-token-plan"). Used only when the profile does not say
|
|
239
|
+
* `subscription`; a heuristic, so the profile setting always wins.
|
|
240
|
+
*/
|
|
241
|
+
export const PLAN_PROVIDER_HINT = /coding[-_ ]?plan|token[-_ ]?plan|kimi[-_ ]?(?:for[-_ ]?)?coding|kimi[-_ ]?code|glm[-_ ]?coding|(?:^|[-_])plan$/i
|
|
242
|
+
|
|
243
|
+
const subscriptionOf = route => ['plan-key', 'cli-login', 'none'].includes(route?.subscription) ? route.subscription
|
|
244
|
+
: PLAN_PROVIDER_HINT.test(clean(route?.provider)) ? 'plan-key' : null
|
|
245
|
+
|
|
246
|
+
export function billingPlan(route, routes = []) {
|
|
247
|
+
const list = Array.isArray(routes) ? routes : []
|
|
248
|
+
const key = routeKeyOf(route?.provider, route?.model)
|
|
249
|
+
const self = list.find(item => routeKeyOf(item.provider, item.model) === key) ?? route
|
|
250
|
+
const mode = BILLING_MODES.includes(self?.billing) ? self.billing : DEFAULT_BILLING_MODE
|
|
251
|
+
const find = target => target ? list.find(item => routeKeyOf(item.provider, item.model) === routeKeyOf(target.provider, target.model)) ?? target : null
|
|
252
|
+
if (subscriptionOf(self) === 'plan-key') {
|
|
253
|
+
return {
|
|
254
|
+
mode,
|
|
255
|
+
subscription: { kind: 'plan-key', key: `plan:${self.provider}`, route: { provider: self.provider, model: self.model } },
|
|
256
|
+
apiRoute: self.apiRoute ? find(self.apiRoute) : null,
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const planFor = list.find(item => subscriptionOf(item) === 'plan-key' && item.apiRoute
|
|
260
|
+
&& routeKeyOf(item.apiRoute.provider, item.apiRoute.model) === key)
|
|
261
|
+
if (planFor) {
|
|
262
|
+
const planMode = BILLING_MODES.includes(self?.billing) ? self.billing : BILLING_MODES.includes(planFor.billing) ? planFor.billing : DEFAULT_BILLING_MODE
|
|
263
|
+
return {
|
|
264
|
+
mode: planMode,
|
|
265
|
+
subscription: { kind: 'plan-key', key: `plan:${planFor.provider}`, route: { provider: planFor.provider, model: planFor.model } },
|
|
266
|
+
apiRoute: self,
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const tool = subscriptionOf(self) === 'none' ? null : toolForProvider(self?.provider)
|
|
270
|
+
return {
|
|
271
|
+
mode,
|
|
272
|
+
subscription: tool ? { kind: 'cli-login', key: `cli:${tool.id}`, toolId: tool.id } : null,
|
|
273
|
+
apiRoute: self,
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export const SUBSCRIPTION_STATE_LABEL = Object.freeze({
|
|
278
|
+
'logged-in': '已登录订阅账号',
|
|
279
|
+
'plan-key': '编程套餐 Key 路线',
|
|
280
|
+
'api-key-only': '仅 API Key 登录',
|
|
281
|
+
'logged-out': '未登录',
|
|
282
|
+
exhausted: '额度已用尽',
|
|
283
|
+
unknown: '登录状态未知',
|
|
284
|
+
none: '无订阅',
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Per-provider billing overview for the health check. `loginFor(toolId)`
|
|
289
|
+
* returns 'subscription' | 'api-key' | 'logged-out' | 'unknown';
|
|
290
|
+
* `apiKeyEnvFor(toolId)` is true when the CLI's API-key variable is set.
|
|
291
|
+
* Rows are grouped by provider and subscription; never reads key values.
|
|
292
|
+
*/
|
|
293
|
+
export function billingOverview(routes, { quota = null, loginFor = () => 'unknown', apiKeyEnvFor = () => false, now = Date.now } = {}) {
|
|
294
|
+
const list = Array.isArray(routes) ? routes : []
|
|
295
|
+
const rows = new Map()
|
|
296
|
+
for (const route of list) {
|
|
297
|
+
const plan = billingPlan(route, list)
|
|
298
|
+
const sub = plan.subscription
|
|
299
|
+
const id = `${clean(route.provider)}\u0000${sub?.key ?? 'none'}\u0000${plan.mode}`
|
|
300
|
+
const existing = rows.get(id)
|
|
301
|
+
if (existing) {
|
|
302
|
+
if (!existing.models.includes(route.model)) existing.models.push(route.model)
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
const exhausted = sub ? quota?.status?.(sub.key) ?? null : null
|
|
306
|
+
const login = sub?.kind === 'cli-login' ? loginFor(sub.toolId) : null
|
|
307
|
+
const state = !sub ? 'none'
|
|
308
|
+
: exhausted ? 'exhausted'
|
|
309
|
+
: sub.kind === 'plan-key' ? 'plan-key'
|
|
310
|
+
: login === 'subscription' ? 'logged-in'
|
|
311
|
+
: login === 'api-key' ? 'api-key-only'
|
|
312
|
+
: login === 'logged-out' ? 'logged-out' : 'unknown'
|
|
313
|
+
const apiRoute = plan.apiRoute && !(sub?.kind === 'plan-key' && routeKeyOf(plan.apiRoute.provider, plan.apiRoute.model) === routeKeyOf(sub.route.provider, sub.route.model))
|
|
314
|
+
? { provider: clean(plan.apiRoute.provider), model: clean(plan.apiRoute.model) } : null
|
|
315
|
+
rows.set(id, {
|
|
316
|
+
provider: clean(route.provider),
|
|
317
|
+
models: [route.model],
|
|
318
|
+
mode: plan.mode,
|
|
319
|
+
modeLabel: BILLING_MODE_LABEL[plan.mode],
|
|
320
|
+
subscription: {
|
|
321
|
+
kind: sub?.kind ?? 'none',
|
|
322
|
+
key: sub?.key ?? null,
|
|
323
|
+
toolId: sub?.toolId ?? null,
|
|
324
|
+
planRoute: sub?.kind === 'plan-key' ? { ...sub.route } : null,
|
|
325
|
+
state,
|
|
326
|
+
stateLabel: SUBSCRIPTION_STATE_LABEL[state],
|
|
327
|
+
exhaustedUntil: exhausted?.until ?? null,
|
|
328
|
+
exhaustedKind: exhausted?.kind ?? null,
|
|
329
|
+
resetReported: exhausted?.resetReported === true,
|
|
330
|
+
detail: exhausted?.detail ?? '',
|
|
331
|
+
},
|
|
332
|
+
api: {
|
|
333
|
+
available: plan.mode !== 'subscription-only' && apiRoute !== null,
|
|
334
|
+
route: apiRoute,
|
|
335
|
+
cliKeyEnv: sub?.kind === 'cli-login' ? apiKeyEnvFor(sub.toolId) === true : false,
|
|
336
|
+
},
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
return { checkedAt: now(), providers: [...rows.values()] }
|
|
340
|
+
}
|