@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.
Files changed (37) hide show
  1. package/.dsh-plugin/client.js +3092 -0
  2. package/.dsh-plugin/index.mjs +1651 -0
  3. package/.dsh-plugin/official-tools-remote-service.mjs +104 -0
  4. package/.dsh-plugin/shared/harness-plan.mjs +179 -0
  5. package/.dsh-plugin/shared/livebench.mjs +264 -0
  6. package/.dsh-plugin/shared/model-profiles.mjs +142 -0
  7. package/.dsh-plugin/shared/official-team-runtime.mjs +411 -0
  8. package/.dsh-plugin/shared/official-tool-executor.mjs +801 -0
  9. package/.dsh-plugin/shared/official-tool-registry.mjs +138 -0
  10. package/.dsh-plugin/shared/official-tools-remote.mjs +173 -0
  11. package/.dsh-plugin/shared/official-tools-runtime.mjs +642 -0
  12. package/.dsh-plugin/shared/router-state.mjs +207 -0
  13. package/.dsh-plugin/shared/router.mjs +1134 -0
  14. package/.dsh-plugin/shared/routing-presets.mjs +49 -0
  15. package/.dsh-plugin/shared/run-ledger.mjs +348 -0
  16. package/.dsh-plugin/shared/security-boundaries.mjs +54 -0
  17. package/.dsh-plugin/shared/subscription-billing.mjs +340 -0
  18. package/.dsh-plugin/shared/task-executors.mjs +1154 -0
  19. package/.dsh-plugin/shared/tool-health.mjs +311 -0
  20. package/.dsh-plugin/shared/vendor-mimo-grok-adapter.mjs +308 -0
  21. package/.dsh-plugin/shared/vendor-minimax-adapter.mjs +247 -0
  22. package/.dsh-plugin/shared/zcode-bundle.mjs +208 -0
  23. package/.dsh-plugin/shared/zcode-installer.mjs +247 -0
  24. package/CHANGELOG.md +36 -0
  25. package/INSTALLATION_GUIDE.zh.md +134 -0
  26. package/LICENSE +21 -0
  27. package/MIGRATION.md +53 -0
  28. package/README.i18n.yaml +3 -0
  29. package/README.md +424 -0
  30. package/README.zh.md +413 -0
  31. package/cordis.patch.yml +12 -0
  32. package/docs/assets/candidate-pruning.svg +80 -0
  33. package/docs/assets/desktop-official-tools-0.9.0.png +0 -0
  34. package/docs/assets/router-only-0.12.0.png +0 -0
  35. package/docs/assets/routing-workflow.svg +96 -0
  36. package/docs/assets/workbench-usage.svg +119 -0
  37. 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
+ }