@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 +15 -4
- package/config.mjs +1 -1
- package/package.json +1 -1
- package/router.mjs +96 -4
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
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|