@apinggithub/mydsh-auto-model-router 0.2.5 → 0.2.6

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.2.6'
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'])
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.2.6",
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",
package/router.mjs CHANGED
@@ -60,6 +60,9 @@ 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
63
66
  /** @type {Map<string, object>} */
64
67
  this.states = new Map()
65
68
  }
@@ -83,6 +86,8 @@ export class ModelRouter {
83
86
  */
84
87
  async decideOnInput(sessionId, { inputText = '', prevInputText = '', lastReplyText = '' } = {}) {
85
88
  const state = this.stateFor(sessionId)
89
+ // Lazy catalog read: the guard can only honour windows it knows about.
90
+ await this.refreshContextWindows()
86
91
 
87
92
  // Same input as the last decision (agent step replay, tool-call loop):
88
93
  // reuse the existing decision — NO re-judging, NO re-classification.
@@ -281,6 +286,91 @@ export class ModelRouter {
281
286
  : 0.85
282
287
  }
283
288
 
289
+ /**
290
+ * Learn every candidate route's context window from the host catalog.
291
+ *
292
+ * The context guard can only skip a route whose window it knows, so an
293
+ * undeclared window fails open — and a long session then overflows the model.
294
+ * The provider truncates generation to the few tokens that still fit, which
295
+ * surfaces to the user as "reached the output token limit" on every retry.
296
+ *
297
+ * Runs at most once a minute; a provider that cannot answer for an id leaves
298
+ * that route unknown (fail open) and is retried on the next pass.
299
+ */
300
+ async refreshContextWindows() {
301
+ if (typeof this.llm?.resolveModelInfo !== 'function') return
302
+ const now = Date.now()
303
+ if (now - this.contextWindowsAt < 60_000) return
304
+ this.contextWindowsAt = now
305
+ const routes = []
306
+ for (const id of LEVEL_IDS) {
307
+ const route = this.levels[id]
308
+ if (route) routes.push(route)
309
+ }
310
+ for (const route of this.config.freeQuota?.routes ?? []) {
311
+ if (route) routes.push(route)
312
+ }
313
+ for (const route of routes) {
314
+ const key = this.routeKey(route)
315
+ if (this.contextWindows.has(key)) continue
316
+ try {
317
+ const info = await this.llm.resolveModelInfo(route.provider, route.model)
318
+ const window = info?.context?.contextWindow
319
+ if (Number.isSafeInteger(window) && window > 0) this.contextWindows.set(key, window)
320
+ } catch {
321
+ // Provider does not answer for this id: leave the window unknown.
322
+ }
323
+ }
324
+ }
325
+
326
+ /**
327
+ * The window for one route: the value declared in config wins, then whatever
328
+ * the catalog taught us.
329
+ * @param {object | null | undefined} route
330
+ * @returns {number | undefined}
331
+ */
332
+ contextWindowOf(route) {
333
+ if (!route) return undefined
334
+ if (Number.isFinite(route.contextWindow) && route.contextWindow > 0) return route.contextWindow
335
+ return this.contextWindows.get(this.routeKey(route))
336
+ }
337
+
338
+ /**
339
+ * Whether a route can hold `size` context tokens. An unknown window is
340
+ * assumed to fit, so a host without catalog access keeps working.
341
+ * @param {object} route
342
+ * @param {number} size
343
+ * @returns {boolean}
344
+ */
345
+ fitsContext(route, size) {
346
+ const window = this.contextWindowOf(route)
347
+ if (!Number.isFinite(window) || window <= 0) return true
348
+ return size <= window * this.contextHeadroom()
349
+ }
350
+
351
+ /**
352
+ * Windows of every route a request for `level` can actually be dispatched to:
353
+ * the level itself plus the free routes covering it. The virtual `auto` model
354
+ * reports the smallest of them so the host never assembles a conversation
355
+ * that some candidate cannot hold.
356
+ * @param {string} [level]
357
+ * @returns {number[]}
358
+ */
359
+ candidateContextWindows(level = this.config.costControl?.defaultLevel ?? 'L1') {
360
+ const out = []
361
+ const own = this.contextWindowOf(this.levels?.[level])
362
+ if (Number.isFinite(own)) out.push(own)
363
+ const index = levelIndex(level)
364
+ if (index !== -1 && typeof this.freeQuotaRoutes === 'function') {
365
+ for (const entry of this.freeQuotaRoutes()) {
366
+ if (levelIndex(entry.maxLevel) < index) continue
367
+ const window = this.contextWindowOf(entry)
368
+ if (Number.isFinite(window)) out.push(window)
369
+ }
370
+ }
371
+ return out
372
+ }
373
+
284
374
  /**
285
375
  * The conversation size the guard compares against: the input side of the
286
376
  * last completed request in this session (uncached + cache-read + cache-write).
@@ -321,13 +411,10 @@ export class ModelRouter {
321
411
  if (size <= 0) return level
322
412
  const index = levelIndex(level)
323
413
  if (index === -1) return level
324
- const headroom = this.contextHeadroom()
325
414
  const fits = (id) => {
326
415
  const route = this.levels[id]
327
416
  if (!route) return false
328
- const window = route.contextWindow
329
- if (!Number.isFinite(window) || window <= 0) return true
330
- return size <= window * headroom
417
+ return this.fitsContext(route, size)
331
418
  }
332
419
  if (fits(level)) return level
333
420
  const ladder = []
@@ -513,10 +600,15 @@ export class ModelRouter {
513
600
  if (!this.freeQuotaEnabled()) return null
514
601
  const index = levelIndex(level)
515
602
  if (index === -1) return null
603
+ // A free route that cannot hold the conversation is not a candidate: the
604
+ // provider would truncate the answer to whatever fits, which reads as
605
+ // "reached the output token limit".
606
+ const size = this.stateFor(sessionId).contextTokens ?? 0
516
607
  for (const entry of this.freeQuotaRoutes()) {
517
608
  if (levelIndex(entry.maxLevel) < index) continue
518
609
  if (this.isExhausted(sessionId, entry)) continue
519
610
  if (this.routeStale(entry)) continue
611
+ if (size > 0 && !this.fitsContext(entry, size)) continue
520
612
  return entry
521
613
  }
522
614
  return null