@apinggithub/mydsh-auto-model-router 0.2.6 → 0.3.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.
package/config.mjs CHANGED
@@ -22,7 +22,7 @@
22
22
  // fallbackChain: [route, ...] (model-level, appended with the default)
23
23
  // maxRetries: per-route retry budget on request-error
24
24
 
25
- export const PLUGIN_VERSION = '0.2.6'
25
+ export const PLUGIN_VERSION = '0.3.1'
26
26
 
27
27
  export const LEVEL_IDS = Object.freeze(['L0', 'L1', 'L2', 'L3'])
28
28
  export const COST_MODES = Object.freeze(['cost-first', 'quality-first', 'balanced'])
@@ -114,6 +114,33 @@ const DEFAULT_CONFIG = Object.freeze({
114
114
  // Re-read provider catalogs every N ms (also validated once at activation).
115
115
  refreshMs: 600_000,
116
116
  },
117
+ // Tier 7: quota watchdog. Reads the cloud bill (read-only, via the provider's
118
+ // billing API) and retires a route the moment a "free" model starts billing;
119
+ // a balance floor stops paid routes entirely. Off by default — it needs
120
+ // cloud credentials.
121
+ quotaWatch: {
122
+ enabled: false,
123
+ // Which configured provider the bill belongs to (routes to retire).
124
+ provider: 'bailian-dell-vostro',
125
+ // Cloud product code the bill is filtered by (`sfm` = 百炼).
126
+ productCode: 'sfm',
127
+ pollMs: 1_800_000,
128
+ // Stop choosing paid routes once the available balance drops below this.
129
+ // 0 disables the floor.
130
+ balanceFloorCny: 10,
131
+ // Retire a route whose model shows any spend in the current billing cycle.
132
+ demoteOnSpend: true,
133
+ // Per-provider floor for DeepSeek's own platform balance (`/user/balance`),
134
+ // which no cloud bill covers. 0 disables it.
135
+ deepseekBalanceFloor: 0,
136
+ deepseekApiKeyRef: 'DEEPSEEK_API_KEY',
137
+ deepseekProvider: 'deepseek-official',
138
+ deepseekEndpoint: 'https://api.deepseek.com/user/balance',
139
+ // Credential refs resolved through ctx.credentials (never inline secrets).
140
+ accessKeyIdRef: 'ALIBABA_CLOUD_ACCESS_KEY_ID',
141
+ accessKeySecretRef: 'ALIBABA_CLOUD_ACCESS_KEY_SECRET',
142
+ timeoutMs: 20_000,
143
+ },
117
144
  askEveryInputs: 3,
118
145
  fallbackChain: [],
119
146
  maxRetries: 1,
@@ -139,6 +166,7 @@ export function resolveConfig(input = {}) {
139
166
  cfg.freeQuota = normalizeFreeQuota(cfg.freeQuota)
140
167
  cfg.contextGuard = normalizeContextGuard(cfg.contextGuard)
141
168
  cfg.modelsGuard = normalizeModelsGuard(cfg.modelsGuard)
169
+ cfg.quotaWatch = normalizeQuotaWatch(cfg.quotaWatch)
142
170
  cfg.askEveryInputs = clampInt(cfg.askEveryInputs, 1, 100, DEFAULT_CONFIG.askEveryInputs)
143
171
  cfg.fallbackChain = normalizeChain(cfg.fallbackChain)
144
172
  cfg.maxRetries = clampInt(cfg.maxRetries, 0, 5, DEFAULT_CONFIG.maxRetries)
@@ -251,11 +279,63 @@ function normalizeHeuristic(heuristic) {
251
279
  return h
252
280
  }
253
281
 
282
+ /**
283
+ * The quota watchdog: read-only cloud-billing polling.
284
+ *
285
+ * `provider` names which configured provider the bill describes (that is what
286
+ * gets retired when a model starts billing); the credential refs are resolved
287
+ * through `ctx.credentials`, so no secret ever lands in the profile.
288
+ */
289
+ function normalizeQuotaWatch(quotaWatch) {
290
+ const q = {
291
+ ...DEFAULT_CONFIG.quotaWatch,
292
+ ...(quotaWatch ?? {}),
293
+ }
294
+ q.enabled = q.enabled === true
295
+ q.demoteOnSpend = q.demoteOnSpend !== false
296
+ q.provider = String(q.provider ?? '').trim()
297
+ q.productCode = String(q.productCode ?? '').trim()
298
+ q.accessKeyIdRef = String(q.accessKeyIdRef ?? '').trim()
299
+ q.accessKeySecretRef = String(q.accessKeySecretRef ?? '').trim()
300
+ q.pollMs = clampInt(q.pollMs, 60_000, 24 * 60 * 60 * 1000, DEFAULT_CONFIG.quotaWatch.pollMs)
301
+ q.balanceFloorCny = Number.isFinite(Number(q.balanceFloorCny)) && Number(q.balanceFloorCny) >= 0
302
+ ? Number(q.balanceFloorCny)
303
+ : DEFAULT_CONFIG.quotaWatch.balanceFloorCny
304
+ q.timeoutMs = clampInt(q.timeoutMs, 5_000, 120_000, DEFAULT_CONFIG.quotaWatch.timeoutMs)
305
+ q.deepseekBalanceFloor = Number.isFinite(Number(q.deepseekBalanceFloor)) && Number(q.deepseekBalanceFloor) >= 0
306
+ ? Number(q.deepseekBalanceFloor)
307
+ : DEFAULT_CONFIG.quotaWatch.deepseekBalanceFloor
308
+ q.deepseekApiKeyRef = String(q.deepseekApiKeyRef ?? '').trim()
309
+ q.deepseekProvider = String(q.deepseekProvider ?? '').trim()
310
+ q.deepseekEndpoint = String(q.deepseekEndpoint ?? '').trim() || DEFAULT_CONFIG.quotaWatch.deepseekEndpoint
311
+ if (q.deepseekBalanceFloor > 0) {
312
+ for (const [field, value] of [
313
+ ['deepseekApiKeyRef', q.deepseekApiKeyRef],
314
+ ['deepseekProvider', q.deepseekProvider],
315
+ ]) {
316
+ if (value === '') {
317
+ throw new Error(`mydsh-auto-model-router: quotaWatch.${field} must be a non-empty string when deepseekBalanceFloor is set`)
318
+ }
319
+ }
320
+ }
321
+ if (q.enabled) {
322
+ for (const [field, value] of [
323
+ ['provider', q.provider],
324
+ ['accessKeyIdRef', q.accessKeyIdRef],
325
+ ['accessKeySecretRef', q.accessKeySecretRef],
326
+ ]) {
327
+ if (value === '') {
328
+ throw new Error(`mydsh-auto-model-router: quotaWatch.${field} must be a non-empty string`)
329
+ }
330
+ }
331
+ }
332
+ return q
333
+ }
334
+
254
335
  function normalizeScoring(scoring) {
255
336
  const s = {
256
337
  ...DEFAULT_CONFIG.scoring,
257
- ...(scoring ?? {}),
258
- }
338
+ ...(scoring ?? {}), }
259
339
  const weights = {}
260
340
  for (const id of LEVEL_IDS) {
261
341
  const value = Number(s.weights?.[id])
package/index.mjs CHANGED
@@ -29,6 +29,7 @@
29
29
  import { resolveConfig } from './config.mjs'
30
30
  import { ModelRouter } from './router.mjs'
31
31
  import { createAutoAdapter, isAutoSelection } from './auto-adapter.mjs'
32
+ import { createQuotaWatch } from './quota-watch.mjs'
32
33
  import {
33
34
  reportStatus, persistStatusReport, statusReport, notifyDowngrade,
34
35
  askDowngrade, resolveLocale, askModeSelection, askFixedLevel,
@@ -135,6 +136,30 @@ export function apply(ctx, input = {}) {
135
136
  return () => clearInterval(timer)
136
137
  }, 'modelRouter.catalogWatch()')
137
138
 
139
+ // ── quota watchdog: retire routes that stopped being free ───────────────
140
+ // Read-only billing poll (Aliyun RPC via a cloud key resolved from
141
+ // ctx.credentials); off unless configured, and inert when the host exposes no
142
+ // credentials service.
143
+ let quotaWatch
144
+ if (config.quotaWatch.enabled) {
145
+ const credentials = readService(ctx, 'credentials')
146
+ if (typeof credentials?.resolve === 'function') {
147
+ quotaWatch = createQuotaWatch({
148
+ config: config.quotaWatch,
149
+ router,
150
+ credentials,
151
+ logger: ctx.logger,
152
+ })
153
+ ctx.effect?.(() => quotaWatch.start(), 'modelRouter.quotaWatch()')
154
+ } else {
155
+ warn(
156
+ 'quotaWatch',
157
+ 'quotaWatch is enabled but the host exposes no credentials service; '
158
+ + 'the watchdog stays off and routes are never retired for spend.',
159
+ )
160
+ }
161
+ }
162
+
138
163
  // ── HTTP status endpoint for the browser settings section ─────────────
139
164
  // The client fetch()es /api/mydsh-auto-model-router/status to render the
140
165
  // current routing policy (no localStorage / DOM-transcript dependency).
@@ -159,6 +184,7 @@ export function apply(ctx, input = {}) {
159
184
  freeQuota: config.freeQuota,
160
185
  contextGuard: config.contextGuard,
161
186
  modelsGuard: config.modelsGuard,
187
+ quotaWatch: quotaWatch?.snapshot() ?? { enabled: false },
162
188
  staleLevels: router.staleLevels(),
163
189
  rules: config.rules,
164
190
  scoring: config.scoring,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apinggithub/mydsh-auto-model-router",
3
- "version": "0.2.6",
3
+ "version": "0.3.1",
4
4
  "description": "Hybrid model router for DeepSeek Harness: declarative rules, user-configurable cost control (cost-first / quality-first / balanced), optional LLM task classifier, and failure fallback chains.",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
@@ -20,6 +20,7 @@
20
20
  "notify.mjs",
21
21
  "auto-adapter.mjs",
22
22
  "compat.mjs",
23
+ "quota-watch.mjs",
23
24
  "text-width.mjs",
24
25
  "scoring.mjs",
25
26
  "client.js",
@@ -0,0 +1,333 @@
1
+ // Quota watchdog: read the cloud bill and retire routes that stopped being free.
2
+ //
3
+ // A "free" tier is only free until the provider silently switches it to
4
+ // pay-as-you-go; the router cannot see that from the request path (no error is
5
+ // raised). This module asks the billing API instead: once a model that should
6
+ // be free shows any spend in the current cycle, its route is retired for the
7
+ // session and the router drifts to the next candidate. A balance floor stops
8
+ // paid routes entirely before the account runs dry.
9
+ //
10
+ // Read-only: two GETs per poll (`QueryAccountBalance`, `DescribeInstanceBill`),
11
+ // signed with the Aliyun RPC scheme (HMAC-SHA1). No SDK, no dependencies.
12
+ import { createHmac, randomUUID } from 'node:crypto'
13
+
14
+ /** Default bailian product code on the Aliyun bill. */
15
+ export const BILL_PRODUCT_CODE = 'sfm'
16
+
17
+ /** RFC 3986 encoding as Aliyun's RPC signature expects. */
18
+ function percentEncode(value) {
19
+ return encodeURIComponent(String(value))
20
+ .replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
21
+ .replace(/%7E/g, '~')
22
+ }
23
+
24
+ /**
25
+ * Build a signed Aliyun RPC GET URL.
26
+ * @param {object} options
27
+ * @param {string} options.action
28
+ * @param {Record<string, string|number>} [options.params]
29
+ * @param {string} options.accessKeyId
30
+ * @param {string} options.accessKeySecret
31
+ * @param {string} [options.endpoint]
32
+ * @param {string} [options.version]
33
+ * @param {Date} [options.now]
34
+ * @returns {string}
35
+ */
36
+ export function signedUrl({
37
+ action,
38
+ params = {},
39
+ accessKeyId,
40
+ accessKeySecret,
41
+ endpoint = 'https://business.aliyuncs.com/',
42
+ version = '2017-12-14',
43
+ now = new Date(),
44
+ }) {
45
+ const common = {
46
+ Format: 'JSON',
47
+ Version: version,
48
+ AccessKeyId: accessKeyId,
49
+ SignatureMethod: 'HMAC-SHA1',
50
+ SignatureVersion: '1.0',
51
+ SignatureNonce: randomUUID(),
52
+ Timestamp: now.toISOString().replace(/\.\d{3}Z$/, 'Z'),
53
+ Action: action,
54
+ ...params,
55
+ }
56
+ const canonical = Object.keys(common)
57
+ .sort()
58
+ .map((key) => `${percentEncode(key)}=${percentEncode(common[key])}`)
59
+ .join('&')
60
+ const stringToSign = `GET&${percentEncode('/')}&${percentEncode(canonical)}`
61
+ const signature = createHmac('sha1', `${accessKeySecret}&`).update(stringToSign).digest('base64')
62
+ return `${endpoint}?${canonical}&Signature=${percentEncode(signature)}`
63
+ }
64
+
65
+ /** Extract the model id from a 百炼 bill line (`id;instance;model;item;..`). */
66
+ export function modelOfBillItem(item) {
67
+ const parts = String(item?.InstanceID ?? '').split(';')
68
+ return (parts[2] ?? '').trim()
69
+ }
70
+
71
+ /**
72
+ * Sum this cycle's bill by model.
73
+ * @param {object[]} items - DescribeInstanceBill `Items`
74
+ * @returns {{ byModel: Record<string, number>, total: number, lines: number }}
75
+ */
76
+ export function aggregateBillItems(items = []) {
77
+ const byModel = {}
78
+ let total = 0
79
+ let lines = 0
80
+ for (const item of items) {
81
+ const model = modelOfBillItem(item)
82
+ if (model === '') continue
83
+ const cost = Number(item.AfterDiscountAmount ?? item.PretaxGrossAmount ?? 0)
84
+ if (!Number.isFinite(cost)) continue
85
+ byModel[model] = (byModel[model] ?? 0) + cost
86
+ total += cost
87
+ lines += 1
88
+ }
89
+ return { byModel, total, lines }
90
+ }
91
+
92
+ /** Read the account's available balance in CNY. */
93
+ export async function fetchBalance({ accessKeyId, accessKeySecret, timeoutMs = 20_000, fetchImpl = fetch }) {
94
+ const url = signedUrl({ action: 'QueryAccountBalance', accessKeyId, accessKeySecret })
95
+ const response = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) })
96
+ const body = JSON.parse(await response.text())
97
+ const data = body?.Data ?? {}
98
+ return {
99
+ availableCny: Number(data.AvailableAmount ?? 0),
100
+ currency: data.Currency ?? 'CNY',
101
+ raw: data,
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Read the DeepSeek platform balance (`GET /user/balance`).
107
+ *
108
+ * The `deepseek-official` provider bills against that account rather than any
109
+ * cloud bill, so it needs its own floor: the Aliyun watch cannot see it.
110
+ *
111
+ * @param {object} options
112
+ * @param {string} options.apiKey
113
+ * @param {string} [options.endpoint]
114
+ * @param {number} [options.timeoutMs]
115
+ * @param {Function} [options.fetchImpl]
116
+ */
117
+ export async function fetchDeepSeekBalance({
118
+ apiKey,
119
+ endpoint = 'https://api.deepseek.com/user/balance',
120
+ timeoutMs = 20_000,
121
+ fetchImpl = fetch,
122
+ }) {
123
+ const response = await fetchImpl(endpoint, {
124
+ headers: { authorization: `Bearer ${apiKey}` },
125
+ signal: AbortSignal.timeout(timeoutMs),
126
+ })
127
+ const body = JSON.parse(await response.text())
128
+ const infos = Array.isArray(body?.balance_infos) ? body.balance_infos : []
129
+ const cny = infos.find((info) => info?.currency === 'CNY') ?? infos[0]
130
+ return {
131
+ available: body?.is_available !== false,
132
+ totalCny: Number(cny?.total_balance ?? 0),
133
+ grantedCny: Number(cny?.granted_balance ?? 0),
134
+ toppedUpCny: Number(cny?.topped_up_balance ?? 0),
135
+ currency: cny?.currency ?? 'CNY',
136
+ }
137
+ }
138
+
139
+ /** Read one billing cycle, following NextToken until exhausted. */
140
+ export async function fetchBill({
141
+ accessKeyId,
142
+ accessKeySecret,
143
+ cycle = new Date().toISOString().slice(0, 7),
144
+ maxPages = 10,
145
+ timeoutMs = 20_000,
146
+ fetchImpl = fetch,
147
+ }) {
148
+ const items = []
149
+ let token
150
+ for (let page = 0; page < maxPages; page += 1) {
151
+ const url = signedUrl({
152
+ action: 'DescribeInstanceBill',
153
+ params: { BillingCycle: cycle, MaxResults: 300, ...token === undefined ? {} : { NextToken: token } },
154
+ accessKeyId,
155
+ accessKeySecret,
156
+ })
157
+ const response = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) })
158
+ const body = JSON.parse(await response.text())
159
+ const data = body?.Data ?? {}
160
+ items.push(...(data.Items ?? []))
161
+ token = data.NextToken
162
+ if (token === undefined || (data.Items ?? []).length === 0) break
163
+ }
164
+ return { cycle, items, ...aggregateBillItems(items) }
165
+ }
166
+
167
+ /**
168
+ * Build the watchdog bound to one plugin mount.
169
+ *
170
+ * @param {object} options
171
+ * @param {object} options.config - resolved `quotaWatch` config
172
+ * @param {object} options.router - ModelRouter
173
+ * @param {object} [options.credentials] - ctx.credentials (`resolve(ref) → {value}`)
174
+ * @param {object} [options.logger]
175
+ * @param {Function} [options.fetchImpl] - injection point for tests
176
+ * @param {() => Date} [options.now]
177
+ */
178
+ export function createQuotaWatch({ config, router, credentials, logger, fetchImpl = fetch, now = () => new Date() }) {
179
+ let timer
180
+ let running = false
181
+ const retired = new Set()
182
+ let balanceWarned = false
183
+ let deepseekWarned = false
184
+ let snapshot = {
185
+ enabled: config.enabled === true,
186
+ lastCheckAt: null,
187
+ lastError: null,
188
+ balanceCny: null,
189
+ cycle: null,
190
+ spendByModel: {},
191
+ totalCny: null,
192
+ exhausted: [],
193
+ balanceLow: false,
194
+ deepseekBalanceCny: null,
195
+ deepseekBlocked: false,
196
+ deepseekError: null,
197
+ }
198
+
199
+ async function readSecret(ref) {
200
+ if (typeof credentials?.resolve !== 'function') {
201
+ throw new Error(`mydsh-auto-model-router: no credentials service for "${ref}"`)
202
+ }
203
+ const resolved = await credentials.resolve(ref)
204
+ const value = typeof resolved === 'string' ? resolved : resolved?.value
205
+ if (typeof value !== 'string' || value === '') {
206
+ throw new Error(`mydsh-auto-model-router: credential "${ref}" is not set`)
207
+ }
208
+ return value
209
+ }
210
+
211
+ /** Routes this watchdog guards: levels plus declared free routes. */
212
+ function guardedRoutes() {
213
+ const routes = []
214
+ for (const route of Object.values(router.levels ?? {})) if (route) routes.push(route)
215
+ for (const route of router.config?.freeQuota?.routes ?? []) if (route) routes.push(route)
216
+ return routes.filter((route) => route.provider === config.provider)
217
+ }
218
+
219
+ async function check() {
220
+ if (running) return snapshot
221
+ running = true
222
+ try {
223
+ const accessKeyId = await readSecret(config.accessKeyIdRef)
224
+ const accessKeySecret = await readSecret(config.accessKeySecretRef)
225
+ const io = { accessKeyId, accessKeySecret, timeoutMs: config.timeoutMs, fetchImpl }
226
+ const balance = await fetchBalance(io)
227
+ const bill = await fetchBill({ ...io, cycle: now().toISOString().slice(0, 7) })
228
+
229
+ const floor = Number(config.balanceFloorCny) || 0
230
+ const low = floor > 0 && balance.availableCny < floor
231
+ if (low !== router.isBalanceLow()) {
232
+ router.setBalanceLow(low)
233
+ if (low) {
234
+ logger?.warn?.(`[mydsh-auto-model-router] balance ¥${balance.availableCny.toFixed(2)} is below the ¥${floor} floor: paid routes are paused`)
235
+ balanceWarned = true
236
+ } else if (balanceWarned) {
237
+ logger?.info?.(`[mydsh-auto-model-router] balance ¥${balance.availableCny.toFixed(2)} is above the floor again: paid routes resumed`)
238
+ balanceWarned = false
239
+ }
240
+ }
241
+
242
+ // DeepSeek bills against its own platform account, which the cloud bill
243
+ // never covers: give that provider its own floor.
244
+ let deepseekBalance = null
245
+ let deepseekError = null
246
+ const dsFloor = Number(config.deepseekBalanceFloor) || 0
247
+ if (dsFloor > 0) {
248
+ try {
249
+ const apiKey = await readSecret(config.deepseekApiKeyRef)
250
+ deepseekBalance = await fetchDeepSeekBalance({
251
+ apiKey,
252
+ endpoint: config.deepseekEndpoint,
253
+ timeoutMs: config.timeoutMs,
254
+ fetchImpl,
255
+ })
256
+ const blocked = deepseekBalance.totalCny < dsFloor
257
+ router.setProviderBlocked(config.deepseekProvider, blocked)
258
+ if (blocked && !deepseekWarned) {
259
+ deepseekWarned = true
260
+ logger?.warn?.(
261
+ `[mydsh-auto-model-router] ${config.deepseekProvider} balance ¥${deepseekBalance.totalCny.toFixed(2)} `
262
+ + `is below the ¥${dsFloor} floor: that provider is paused`,
263
+ )
264
+ } else if (!blocked && deepseekWarned) {
265
+ deepseekWarned = false
266
+ logger?.info?.(`[mydsh-auto-model-router] ${config.deepseekProvider} balance recovered: provider resumed`)
267
+ }
268
+ } catch (error) {
269
+ deepseekError = String(error?.message ?? error)
270
+ logger?.warn?.(`[mydsh-auto-model-router] deepseek balance check failed: ${deepseekError}`)
271
+ }
272
+ }
273
+
274
+ if (config.demoteOnSpend) {
275
+ for (const route of guardedRoutes()) {
276
+ const spend = bill.byModel[route.model]
277
+ if (!(spend > 0)) continue
278
+ router.markRouteExhausted(route)
279
+ const key = `${route.provider}/${route.model}`
280
+ if (!retired.has(key)) {
281
+ retired.add(key)
282
+ logger?.warn?.(
283
+ `[mydsh-auto-model-router] ${key} billed ¥${spend.toFixed(4)} this cycle — `
284
+ + 'it is not a free route any more; retired for this session',
285
+ )
286
+ }
287
+ }
288
+ }
289
+
290
+ snapshot = {
291
+ enabled: true,
292
+ lastCheckAt: now().toISOString(),
293
+ lastError: null,
294
+ balanceCny: balance.availableCny,
295
+ cycle: bill.cycle,
296
+ spendByModel: bill.byModel,
297
+ totalCny: bill.total,
298
+ exhausted: router.exhaustedRouteKeys(),
299
+ balanceLow: router.isBalanceLow(),
300
+ deepseekBalanceCny: deepseekBalance?.totalCny ?? null,
301
+ deepseekBlocked: router.isProviderBlocked(config.deepseekProvider),
302
+ deepseekError,
303
+ }
304
+ } catch (error) {
305
+ snapshot = { ...snapshot, lastCheckAt: now().toISOString(), lastError: String(error?.message ?? error) }
306
+ logger?.warn?.(`[mydsh-auto-model-router] quota check failed: ${error?.message ?? error}`)
307
+ } finally {
308
+ running = false
309
+ }
310
+ return snapshot
311
+ }
312
+
313
+ return {
314
+ check,
315
+ snapshot: () => snapshot,
316
+ /** Start polling; returns a disposer. */
317
+ start() {
318
+ if (config.enabled !== true) return () => {}
319
+ if (timer !== undefined) return () => stop()
320
+ // Kick off immediately, then on the configured cadence.
321
+ void check()
322
+ timer = setInterval(() => void check(), config.pollMs)
323
+ timer.unref?.()
324
+ return () => stop()
325
+ },
326
+ stop,
327
+ }
328
+
329
+ function stop() {
330
+ if (timer !== undefined) clearInterval(timer)
331
+ timer = undefined
332
+ }
333
+ }
package/router.mjs CHANGED
@@ -63,6 +63,12 @@ export class ModelRouter {
63
63
  /** provider/model → context window learned from the host catalog. */
64
64
  this.contextWindows = new Map()
65
65
  this.contextWindowsAt = 0
66
+ /** Routes retired by the quota watchdog (provider/model keys). */
67
+ this.exhaustedRoutes = new Set()
68
+ /** True while the account balance sits below the configured floor. */
69
+ this.balanceLow = false
70
+ /** Providers paused by a per-provider balance floor (e.g. deepseek-official). */
71
+ this.blockedProviders = new Set()
66
72
  /** @type {Map<string, object>} */
67
73
  this.states = new Map()
68
74
  }
@@ -208,12 +214,103 @@ export class ModelRouter {
208
214
  const fresh = this.nearestFreshLevel(level)
209
215
  if (fresh !== null) use = fresh
210
216
  }
211
- const route = this.effectiveRoute(state, sessionId, use)
217
+ let route = this.effectiveRoute(state, sessionId, use)
218
+ // A route the quota watchdog retired (a "free" model that started billing,
219
+ // or any paid route once the balance floor is crossed) must not be picked.
220
+ if (this.routeBlocked(route)) {
221
+ for (const id of LEVEL_IDS) {
222
+ if (id === use) continue
223
+ const alternative = this.effectiveRoute(state, sessionId, id)
224
+ if (!this.routeBlocked(alternative)) {
225
+ use = id
226
+ route = alternative
227
+ break
228
+ }
229
+ }
230
+ }
212
231
  state.level = use
213
232
  state.current = route
214
233
  return route
215
234
  }
216
235
 
236
+ // ── quota watchdog state ────────────────────────────────────────────────
237
+
238
+ /**
239
+ * Retire a route: the watchdog keeps this out of routing until it is cleared.
240
+ * Fired when a route that should be free starts showing billable spend.
241
+ * @param {object} route - { provider, model }
242
+ */
243
+ markRouteExhausted(route) {
244
+ if (!route?.provider || !route?.model) return
245
+ this.exhaustedRoutes.add(this.routeKey(route))
246
+ }
247
+
248
+ /** Re-admit a retired route (the next billing cycle resets every month). */
249
+ clearRouteExhausted(route) {
250
+ if (!route?.provider || !route?.model) return
251
+ this.exhaustedRoutes.delete(this.routeKey(route))
252
+ }
253
+
254
+ /** Every route the watchdog retired, as `provider/model` keys. */
255
+ exhaustedRouteKeys() {
256
+ return [...this.exhaustedRoutes]
257
+ }
258
+
259
+ /** Whether a route was retired by the watchdog. */
260
+ isRouteExhausted(route) {
261
+ if (!route?.provider || !route?.model) return false
262
+ return this.exhaustedRoutes.has(this.routeKey(route))
263
+ }
264
+
265
+ /**
266
+ * Account balance crossed the configured floor: paid routes stop being
267
+ * chosen until the watchdog clears the flag.
268
+ * @param {boolean} low
269
+ */
270
+ setBalanceLow(low) {
271
+ this.balanceLow = low === true
272
+ }
273
+
274
+ /** Whether paid routes are currently refused. */
275
+ isBalanceLow() {
276
+ return this.balanceLow === true
277
+ }
278
+
279
+ /**
280
+ * Pause every route of one provider (a provider whose own balance is below
281
+ * its floor). The rest of the ladder keeps working.
282
+ * @param {string} provider
283
+ * @param {boolean} blocked
284
+ */
285
+ setProviderBlocked(provider, blocked) {
286
+ if (typeof provider !== 'string' || provider === '') return
287
+ if (blocked) this.blockedProviders.add(provider)
288
+ else this.blockedProviders.delete(provider)
289
+ }
290
+
291
+ /** Whether a provider is paused by its own balance floor. */
292
+ isProviderBlocked(provider) {
293
+ return this.blockedProviders.has(provider)
294
+ }
295
+
296
+ /** Every provider currently paused, for the status report. */
297
+ blockedProviderList() {
298
+ return [...this.blockedProviders]
299
+ }
300
+
301
+ /**
302
+ * Whether this route must not be dispatched to right now.
303
+ * @param {object} route
304
+ * @returns {boolean}
305
+ */
306
+ routeBlocked(route) {
307
+ if (!route) return true
308
+ if (this.isRouteExhausted(route)) return true
309
+ if (this.isProviderBlocked(route.provider)) return true
310
+ if (this.balanceLow && !this.isFreeRoute(route)) return true
311
+ return false
312
+ }
313
+
217
314
  /**
218
315
  * Set the session mode (called by the index layer after a user answer).
219
316
  * @param {string} sessionId