@apinggithub/mydsh-auto-model-router 0.2.5 → 0.3.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/auto-adapter.mjs CHANGED
@@ -185,7 +185,11 @@ export function createAutoAdapter(resolve = {}) {
185
185
  * indistinguishable from the model it forwards to.
186
186
  */
187
187
  async resolveModel(provider, model) {
188
- const route = representativeRoute(readRouter())
188
+ const router = readRouter()
189
+ // Teach the router the real windows first: the guard and this metadata
190
+ // must agree on what the candidates can hold.
191
+ await router?.refreshContextWindows?.()
192
+ const route = representativeRoute(router)
189
193
  const info = await underlyingInfo(readLlm(), route)
190
194
  const efforts = reasoningEfforts(info)
191
195
  const requested = route?.reasoningEffort
@@ -193,9 +197,16 @@ export function createAutoAdapter(resolve = {}) {
193
197
  ? requested
194
198
  : info?.reasoning?.defaultEffort
195
199
  const defaultEffort = efforts.some((effort) => effort.id === wanted) ? wanted : efforts[0]?.id
196
- const contextWindow = Number.isInteger(info?.context?.contextWindow) && info.context.contextWindow > 0
197
- ? info.context.contextWindow
198
- : FALLBACK_CONTEXT_WINDOW
200
+ // Report the smallest window any candidate can hold: the host budgets
201
+ // compaction from this value, so a conversation that fits the default
202
+ // level but not a free route would otherwise be assembled for a model
203
+ // that cannot take it.
204
+ const own = info?.context?.contextWindow
205
+ const pool = [
206
+ ...(typeof router?.candidateContextWindows === 'function' ? router.candidateContextWindows() : []),
207
+ ...(Number.isInteger(own) && own > 0 ? [own] : []),
208
+ ].filter((value) => Number.isInteger(value) && value > 0)
209
+ const contextWindow = pool.length > 0 ? Math.min(...pool) : FALLBACK_CONTEXT_WINDOW
199
210
  return {
200
211
  provider,
201
212
  id: model,
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.5'
25
+ export const PLUGIN_VERSION = '0.3.0'
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,27 @@ 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
+ // Credential refs resolved through ctx.credentials (never inline secrets).
134
+ accessKeyIdRef: 'ALIBABA_CLOUD_ACCESS_KEY_ID',
135
+ accessKeySecretRef: 'ALIBABA_CLOUD_ACCESS_KEY_SECRET',
136
+ timeoutMs: 20_000,
137
+ },
117
138
  askEveryInputs: 3,
118
139
  fallbackChain: [],
119
140
  maxRetries: 1,
@@ -139,6 +160,7 @@ export function resolveConfig(input = {}) {
139
160
  cfg.freeQuota = normalizeFreeQuota(cfg.freeQuota)
140
161
  cfg.contextGuard = normalizeContextGuard(cfg.contextGuard)
141
162
  cfg.modelsGuard = normalizeModelsGuard(cfg.modelsGuard)
163
+ cfg.quotaWatch = normalizeQuotaWatch(cfg.quotaWatch)
142
164
  cfg.askEveryInputs = clampInt(cfg.askEveryInputs, 1, 100, DEFAULT_CONFIG.askEveryInputs)
143
165
  cfg.fallbackChain = normalizeChain(cfg.fallbackChain)
144
166
  cfg.maxRetries = clampInt(cfg.maxRetries, 0, 5, DEFAULT_CONFIG.maxRetries)
@@ -251,11 +273,47 @@ function normalizeHeuristic(heuristic) {
251
273
  return h
252
274
  }
253
275
 
276
+ /**
277
+ * The quota watchdog: read-only cloud-billing polling.
278
+ *
279
+ * `provider` names which configured provider the bill describes (that is what
280
+ * gets retired when a model starts billing); the credential refs are resolved
281
+ * through `ctx.credentials`, so no secret ever lands in the profile.
282
+ */
283
+ function normalizeQuotaWatch(quotaWatch) {
284
+ const q = {
285
+ ...DEFAULT_CONFIG.quotaWatch,
286
+ ...(quotaWatch ?? {}),
287
+ }
288
+ q.enabled = q.enabled === true
289
+ q.demoteOnSpend = q.demoteOnSpend !== false
290
+ q.provider = String(q.provider ?? '').trim()
291
+ q.productCode = String(q.productCode ?? '').trim()
292
+ q.accessKeyIdRef = String(q.accessKeyIdRef ?? '').trim()
293
+ q.accessKeySecretRef = String(q.accessKeySecretRef ?? '').trim()
294
+ q.pollMs = clampInt(q.pollMs, 60_000, 24 * 60 * 60 * 1000, DEFAULT_CONFIG.quotaWatch.pollMs)
295
+ q.balanceFloorCny = Number.isFinite(Number(q.balanceFloorCny)) && Number(q.balanceFloorCny) >= 0
296
+ ? Number(q.balanceFloorCny)
297
+ : DEFAULT_CONFIG.quotaWatch.balanceFloorCny
298
+ q.timeoutMs = clampInt(q.timeoutMs, 5_000, 120_000, DEFAULT_CONFIG.quotaWatch.timeoutMs)
299
+ if (q.enabled) {
300
+ for (const [field, value] of [
301
+ ['provider', q.provider],
302
+ ['accessKeyIdRef', q.accessKeyIdRef],
303
+ ['accessKeySecretRef', q.accessKeySecretRef],
304
+ ]) {
305
+ if (value === '') {
306
+ throw new Error(`mydsh-auto-model-router: quotaWatch.${field} must be a non-empty string`)
307
+ }
308
+ }
309
+ }
310
+ return q
311
+ }
312
+
254
313
  function normalizeScoring(scoring) {
255
314
  const s = {
256
315
  ...DEFAULT_CONFIG.scoring,
257
- ...(scoring ?? {}),
258
- }
316
+ ...(scoring ?? {}), }
259
317
  const weights = {}
260
318
  for (const id of LEVEL_IDS) {
261
319
  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.5",
3
+ "version": "0.3.0",
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,260 @@
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
+ /** Read one billing cycle, following NextToken until exhausted. */
106
+ export async function fetchBill({
107
+ accessKeyId,
108
+ accessKeySecret,
109
+ cycle = new Date().toISOString().slice(0, 7),
110
+ maxPages = 10,
111
+ timeoutMs = 20_000,
112
+ fetchImpl = fetch,
113
+ }) {
114
+ const items = []
115
+ let token
116
+ for (let page = 0; page < maxPages; page += 1) {
117
+ const url = signedUrl({
118
+ action: 'DescribeInstanceBill',
119
+ params: { BillingCycle: cycle, MaxResults: 300, ...token === undefined ? {} : { NextToken: token } },
120
+ accessKeyId,
121
+ accessKeySecret,
122
+ })
123
+ const response = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) })
124
+ const body = JSON.parse(await response.text())
125
+ const data = body?.Data ?? {}
126
+ items.push(...(data.Items ?? []))
127
+ token = data.NextToken
128
+ if (token === undefined || (data.Items ?? []).length === 0) break
129
+ }
130
+ return { cycle, items, ...aggregateBillItems(items) }
131
+ }
132
+
133
+ /**
134
+ * Build the watchdog bound to one plugin mount.
135
+ *
136
+ * @param {object} options
137
+ * @param {object} options.config - resolved `quotaWatch` config
138
+ * @param {object} options.router - ModelRouter
139
+ * @param {object} [options.credentials] - ctx.credentials (`resolve(ref) → {value}`)
140
+ * @param {object} [options.logger]
141
+ * @param {Function} [options.fetchImpl] - injection point for tests
142
+ * @param {() => Date} [options.now]
143
+ */
144
+ export function createQuotaWatch({ config, router, credentials, logger, fetchImpl = fetch, now = () => new Date() }) {
145
+ let timer
146
+ let running = false
147
+ const retired = new Set()
148
+ let balanceWarned = false
149
+ let snapshot = {
150
+ enabled: config.enabled === true,
151
+ lastCheckAt: null,
152
+ lastError: null,
153
+ balanceCny: null,
154
+ cycle: null,
155
+ spendByModel: {},
156
+ totalCny: null,
157
+ exhausted: [],
158
+ balanceLow: false,
159
+ }
160
+
161
+ async function readSecret(ref) {
162
+ if (typeof credentials?.resolve !== 'function') {
163
+ throw new Error(`mydsh-auto-model-router: no credentials service for "${ref}"`)
164
+ }
165
+ const resolved = await credentials.resolve(ref)
166
+ const value = typeof resolved === 'string' ? resolved : resolved?.value
167
+ if (typeof value !== 'string' || value === '') {
168
+ throw new Error(`mydsh-auto-model-router: credential "${ref}" is not set`)
169
+ }
170
+ return value
171
+ }
172
+
173
+ /** Routes this watchdog guards: levels plus declared free routes. */
174
+ function guardedRoutes() {
175
+ const routes = []
176
+ for (const route of Object.values(router.levels ?? {})) if (route) routes.push(route)
177
+ for (const route of router.config?.freeQuota?.routes ?? []) if (route) routes.push(route)
178
+ return routes.filter((route) => route.provider === config.provider)
179
+ }
180
+
181
+ async function check() {
182
+ if (running) return snapshot
183
+ running = true
184
+ try {
185
+ const accessKeyId = await readSecret(config.accessKeyIdRef)
186
+ const accessKeySecret = await readSecret(config.accessKeySecretRef)
187
+ const io = { accessKeyId, accessKeySecret, timeoutMs: config.timeoutMs, fetchImpl }
188
+ const balance = await fetchBalance(io)
189
+ const bill = await fetchBill({ ...io, cycle: now().toISOString().slice(0, 7) })
190
+
191
+ const floor = Number(config.balanceFloorCny) || 0
192
+ const low = floor > 0 && balance.availableCny < floor
193
+ if (low !== router.isBalanceLow()) {
194
+ router.setBalanceLow(low)
195
+ if (low) {
196
+ logger?.warn?.(`[mydsh-auto-model-router] balance ¥${balance.availableCny.toFixed(2)} is below the ¥${floor} floor: paid routes are paused`)
197
+ balanceWarned = true
198
+ } else if (balanceWarned) {
199
+ logger?.info?.(`[mydsh-auto-model-router] balance ¥${balance.availableCny.toFixed(2)} is above the floor again: paid routes resumed`)
200
+ balanceWarned = false
201
+ }
202
+ }
203
+
204
+ if (config.demoteOnSpend) {
205
+ for (const route of guardedRoutes()) {
206
+ const spend = bill.byModel[route.model]
207
+ if (!(spend > 0)) continue
208
+ router.markRouteExhausted(route)
209
+ const key = `${route.provider}/${route.model}`
210
+ if (!retired.has(key)) {
211
+ retired.add(key)
212
+ logger?.warn?.(
213
+ `[mydsh-auto-model-router] ${key} billed ¥${spend.toFixed(4)} this cycle — `
214
+ + 'it is not a free route any more; retired for this session',
215
+ )
216
+ }
217
+ }
218
+ }
219
+
220
+ snapshot = {
221
+ enabled: true,
222
+ lastCheckAt: now().toISOString(),
223
+ lastError: null,
224
+ balanceCny: balance.availableCny,
225
+ cycle: bill.cycle,
226
+ spendByModel: bill.byModel,
227
+ totalCny: bill.total,
228
+ exhausted: router.exhaustedRouteKeys(),
229
+ balanceLow: router.isBalanceLow(),
230
+ }
231
+ } catch (error) {
232
+ snapshot = { ...snapshot, lastCheckAt: now().toISOString(), lastError: String(error?.message ?? error) }
233
+ logger?.warn?.(`[mydsh-auto-model-router] quota check failed: ${error?.message ?? error}`)
234
+ } finally {
235
+ running = false
236
+ }
237
+ return snapshot
238
+ }
239
+
240
+ return {
241
+ check,
242
+ snapshot: () => snapshot,
243
+ /** Start polling; returns a disposer. */
244
+ start() {
245
+ if (config.enabled !== true) return () => {}
246
+ if (timer !== undefined) return () => stop()
247
+ // Kick off immediately, then on the configured cadence.
248
+ void check()
249
+ timer = setInterval(() => void check(), config.pollMs)
250
+ timer.unref?.()
251
+ return () => stop()
252
+ },
253
+ stop,
254
+ }
255
+
256
+ function stop() {
257
+ if (timer !== undefined) clearInterval(timer)
258
+ timer = undefined
259
+ }
260
+ }
package/router.mjs CHANGED
@@ -60,6 +60,13 @@ export class ModelRouter {
60
60
  // read (or is empty): the freshness guard fails OPEN for that provider.
61
61
  this.catalogs = new Map()
62
62
  this.catalogCheckedAt = 0
63
+ /** provider/model → context window learned from the host catalog. */
64
+ this.contextWindows = new Map()
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
63
70
  /** @type {Map<string, object>} */
64
71
  this.states = new Map()
65
72
  }
@@ -83,6 +90,8 @@ export class ModelRouter {
83
90
  */
84
91
  async decideOnInput(sessionId, { inputText = '', prevInputText = '', lastReplyText = '' } = {}) {
85
92
  const state = this.stateFor(sessionId)
93
+ // Lazy catalog read: the guard can only honour windows it knows about.
94
+ await this.refreshContextWindows()
86
95
 
87
96
  // Same input as the last decision (agent step replay, tool-call loop):
88
97
  // reuse the existing decision — NO re-judging, NO re-classification.
@@ -203,12 +212,80 @@ export class ModelRouter {
203
212
  const fresh = this.nearestFreshLevel(level)
204
213
  if (fresh !== null) use = fresh
205
214
  }
206
- const route = this.effectiveRoute(state, sessionId, use)
215
+ let route = this.effectiveRoute(state, sessionId, use)
216
+ // A route the quota watchdog retired (a "free" model that started billing,
217
+ // or any paid route once the balance floor is crossed) must not be picked.
218
+ if (this.routeBlocked(route)) {
219
+ for (const id of LEVEL_IDS) {
220
+ if (id === use) continue
221
+ const alternative = this.effectiveRoute(state, sessionId, id)
222
+ if (!this.routeBlocked(alternative)) {
223
+ use = id
224
+ route = alternative
225
+ break
226
+ }
227
+ }
228
+ }
207
229
  state.level = use
208
230
  state.current = route
209
231
  return route
210
232
  }
211
233
 
234
+ // ── quota watchdog state ────────────────────────────────────────────────
235
+
236
+ /**
237
+ * Retire a route: the watchdog keeps this out of routing until it is cleared.
238
+ * Fired when a route that should be free starts showing billable spend.
239
+ * @param {object} route - { provider, model }
240
+ */
241
+ markRouteExhausted(route) {
242
+ if (!route?.provider || !route?.model) return
243
+ this.exhaustedRoutes.add(this.routeKey(route))
244
+ }
245
+
246
+ /** Re-admit a retired route (the next billing cycle resets every month). */
247
+ clearRouteExhausted(route) {
248
+ if (!route?.provider || !route?.model) return
249
+ this.exhaustedRoutes.delete(this.routeKey(route))
250
+ }
251
+
252
+ /** Every route the watchdog retired, as `provider/model` keys. */
253
+ exhaustedRouteKeys() {
254
+ return [...this.exhaustedRoutes]
255
+ }
256
+
257
+ /** Whether a route was retired by the watchdog. */
258
+ isRouteExhausted(route) {
259
+ if (!route?.provider || !route?.model) return false
260
+ return this.exhaustedRoutes.has(this.routeKey(route))
261
+ }
262
+
263
+ /**
264
+ * Account balance crossed the configured floor: paid routes stop being
265
+ * chosen until the watchdog clears the flag.
266
+ * @param {boolean} low
267
+ */
268
+ setBalanceLow(low) {
269
+ this.balanceLow = low === true
270
+ }
271
+
272
+ /** Whether paid routes are currently refused. */
273
+ isBalanceLow() {
274
+ return this.balanceLow === true
275
+ }
276
+
277
+ /**
278
+ * Whether this route must not be dispatched to right now.
279
+ * @param {object} route
280
+ * @returns {boolean}
281
+ */
282
+ routeBlocked(route) {
283
+ if (!route) return true
284
+ if (this.isRouteExhausted(route)) return true
285
+ if (this.balanceLow && !this.isFreeRoute(route)) return true
286
+ return false
287
+ }
288
+
212
289
  /**
213
290
  * Set the session mode (called by the index layer after a user answer).
214
291
  * @param {string} sessionId
@@ -281,6 +358,91 @@ export class ModelRouter {
281
358
  : 0.85
282
359
  }
283
360
 
361
+ /**
362
+ * Learn every candidate route's context window from the host catalog.
363
+ *
364
+ * The context guard can only skip a route whose window it knows, so an
365
+ * undeclared window fails open — and a long session then overflows the model.
366
+ * The provider truncates generation to the few tokens that still fit, which
367
+ * surfaces to the user as "reached the output token limit" on every retry.
368
+ *
369
+ * Runs at most once a minute; a provider that cannot answer for an id leaves
370
+ * that route unknown (fail open) and is retried on the next pass.
371
+ */
372
+ async refreshContextWindows() {
373
+ if (typeof this.llm?.resolveModelInfo !== 'function') return
374
+ const now = Date.now()
375
+ if (now - this.contextWindowsAt < 60_000) return
376
+ this.contextWindowsAt = now
377
+ const routes = []
378
+ for (const id of LEVEL_IDS) {
379
+ const route = this.levels[id]
380
+ if (route) routes.push(route)
381
+ }
382
+ for (const route of this.config.freeQuota?.routes ?? []) {
383
+ if (route) routes.push(route)
384
+ }
385
+ for (const route of routes) {
386
+ const key = this.routeKey(route)
387
+ if (this.contextWindows.has(key)) continue
388
+ try {
389
+ const info = await this.llm.resolveModelInfo(route.provider, route.model)
390
+ const window = info?.context?.contextWindow
391
+ if (Number.isSafeInteger(window) && window > 0) this.contextWindows.set(key, window)
392
+ } catch {
393
+ // Provider does not answer for this id: leave the window unknown.
394
+ }
395
+ }
396
+ }
397
+
398
+ /**
399
+ * The window for one route: the value declared in config wins, then whatever
400
+ * the catalog taught us.
401
+ * @param {object | null | undefined} route
402
+ * @returns {number | undefined}
403
+ */
404
+ contextWindowOf(route) {
405
+ if (!route) return undefined
406
+ if (Number.isFinite(route.contextWindow) && route.contextWindow > 0) return route.contextWindow
407
+ return this.contextWindows.get(this.routeKey(route))
408
+ }
409
+
410
+ /**
411
+ * Whether a route can hold `size` context tokens. An unknown window is
412
+ * assumed to fit, so a host without catalog access keeps working.
413
+ * @param {object} route
414
+ * @param {number} size
415
+ * @returns {boolean}
416
+ */
417
+ fitsContext(route, size) {
418
+ const window = this.contextWindowOf(route)
419
+ if (!Number.isFinite(window) || window <= 0) return true
420
+ return size <= window * this.contextHeadroom()
421
+ }
422
+
423
+ /**
424
+ * Windows of every route a request for `level` can actually be dispatched to:
425
+ * the level itself plus the free routes covering it. The virtual `auto` model
426
+ * reports the smallest of them so the host never assembles a conversation
427
+ * that some candidate cannot hold.
428
+ * @param {string} [level]
429
+ * @returns {number[]}
430
+ */
431
+ candidateContextWindows(level = this.config.costControl?.defaultLevel ?? 'L1') {
432
+ const out = []
433
+ const own = this.contextWindowOf(this.levels?.[level])
434
+ if (Number.isFinite(own)) out.push(own)
435
+ const index = levelIndex(level)
436
+ if (index !== -1 && typeof this.freeQuotaRoutes === 'function') {
437
+ for (const entry of this.freeQuotaRoutes()) {
438
+ if (levelIndex(entry.maxLevel) < index) continue
439
+ const window = this.contextWindowOf(entry)
440
+ if (Number.isFinite(window)) out.push(window)
441
+ }
442
+ }
443
+ return out
444
+ }
445
+
284
446
  /**
285
447
  * The conversation size the guard compares against: the input side of the
286
448
  * last completed request in this session (uncached + cache-read + cache-write).
@@ -321,13 +483,10 @@ export class ModelRouter {
321
483
  if (size <= 0) return level
322
484
  const index = levelIndex(level)
323
485
  if (index === -1) return level
324
- const headroom = this.contextHeadroom()
325
486
  const fits = (id) => {
326
487
  const route = this.levels[id]
327
488
  if (!route) return false
328
- const window = route.contextWindow
329
- if (!Number.isFinite(window) || window <= 0) return true
330
- return size <= window * headroom
489
+ return this.fitsContext(route, size)
331
490
  }
332
491
  if (fits(level)) return level
333
492
  const ladder = []
@@ -513,10 +672,15 @@ export class ModelRouter {
513
672
  if (!this.freeQuotaEnabled()) return null
514
673
  const index = levelIndex(level)
515
674
  if (index === -1) return null
675
+ // A free route that cannot hold the conversation is not a candidate: the
676
+ // provider would truncate the answer to whatever fits, which reads as
677
+ // "reached the output token limit".
678
+ const size = this.stateFor(sessionId).contextTokens ?? 0
516
679
  for (const entry of this.freeQuotaRoutes()) {
517
680
  if (levelIndex(entry.maxLevel) < index) continue
518
681
  if (this.isExhausted(sessionId, entry)) continue
519
682
  if (this.routeStale(entry)) continue
683
+ if (size > 0 && !this.fitsContext(entry, size)) continue
520
684
  return entry
521
685
  }
522
686
  return null