@apinggithub/mydsh-auto-model-router 0.0.0-stage → 0.2.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/router.mjs ADDED
@@ -0,0 +1,751 @@
1
+ // Core router: per-session decision state and the three-mode routing pipeline.
2
+ // Pure logic — no cordis imports here so it can be unit-tested standalone.
3
+ //
4
+ // Modes (per session, per USER INPUT — the caller guarantees one decision per
5
+ // user input, subsequent agent/request steps reuse state.current):
6
+ //
7
+ // MODE A heuristic lock (default): the last N user inputs' text width maps
8
+ // through heuristic.thresholds to a level. Widths above the lock ceiling
9
+ // return { needsAsk: true } so the caller can ask the user how to proceed.
10
+ // MODE B fixed model: the user picked an explicit level; after
11
+ // askEveryInputs inputs the caller re-asks (returns needsAsk again).
12
+ // MODE C fully-auto: keyword scoring (rules + scoring) decides; a zero
13
+ // score falls through to the LLM classifier on the window text.
14
+ //
15
+ // The window text for scoring/classification is 本次输入 + 上次输入 + 上次回复
16
+ // (current + previous user input + assistant's last reply).
17
+ //
18
+ // Budget exhaustion (costControl) pins to the cheap level in every mode;
19
+ // fallback chain retries on request-error remain model-level.
20
+
21
+ import { CostControl, estimateMessageTokens } from './cost-control.mjs'
22
+ import { FallbackChain } from './fallback.mjs'
23
+ import { LlmClassifier } from './classifier.mjs'
24
+ import { LEVEL_IDS, levelIndex } from './config.mjs'
25
+ import { windowWidth, levelForWidth, lockCeiling } from './text-width.mjs'
26
+ import { scoreRules } from './scoring.mjs'
27
+
28
+ export const MODE_HEURISTIC = 'heuristic'
29
+ export const MODE_FIXED = 'fixed'
30
+ export const MODE_AUTO = 'auto'
31
+
32
+ /**
33
+ * Test two routes for equality.
34
+ * @param {object} a
35
+ * @param {object} b
36
+ * @returns {boolean}
37
+ */
38
+ export function routesEqual(a, b) {
39
+ return Boolean(a && b && a.provider === b.provider && a.model === b.model)
40
+ }
41
+
42
+ export class ModelRouter {
43
+ /**
44
+ * @param {object} config - resolved plugin config from resolveConfig.
45
+ * @param {object} deps - { llm?: ctx.llm, tokenMeter?: ctx.tokenMeter }.
46
+ */
47
+ constructor(config, deps = {}) {
48
+ this.config = config
49
+ this.levels = config.levels
50
+ this.cost = new CostControl(config.costControl, config.levels)
51
+ this.fallback = new FallbackChain(
52
+ config.fallbackChain,
53
+ this.routeForLevel(config.costControl.defaultLevel) ?? this.firstConfiguredRoute(),
54
+ config.maxRetries,
55
+ )
56
+ this.classifier = new LlmClassifier(config.llmClassifier, deps.llm)
57
+ this.tokenMeter = deps.tokenMeter
58
+ this.llm = deps.llm ?? null
59
+ // Provider → Set<model ids> | null. null means the catalog could not be
60
+ // read (or is empty): the freshness guard fails OPEN for that provider.
61
+ this.catalogs = new Map()
62
+ this.catalogCheckedAt = 0
63
+ /** @type {Map<string, object>} */
64
+ this.states = new Map()
65
+ }
66
+
67
+ /**
68
+ * Resolve the model route for a level id.
69
+ * @param {string} level - LEVEL_IDS entry.
70
+ * @returns {object | null} route or null when the level has no model.
71
+ */
72
+ routeForLevel(level) {
73
+ return this.levels[level] ?? null
74
+ }
75
+
76
+ /**
77
+ * One decision per USER INPUT. Returns either a route or an ask request:
78
+ * { needsAsk: 'above-lock' } — width crossed the heuristic ceiling.
79
+ * { needsAsk: 'fixed-reelect' } — fixed-mode re-election due (askEveryInputs).
80
+ * @param {string} sessionId
81
+ * @param {object} options - { inputText, prevInputText, lastReplyText }.
82
+ * @returns {Promise<object>} route or ask request.
83
+ */
84
+ async decideOnInput(sessionId, { inputText = '', prevInputText = '', lastReplyText = '' } = {}) {
85
+ const state = this.stateFor(sessionId)
86
+
87
+ // Same input as the last decision (agent step replay, tool-call loop):
88
+ // reuse the existing decision — NO re-judging, NO re-classification.
89
+ if (state.lastInputText === String(inputText ?? '')) {
90
+ return state.current
91
+ }
92
+ state.lastInputText = String(inputText ?? '')
93
+ state.inputs.push(String(inputText ?? ''))
94
+ if (state.inputs.length > 20) state.inputs.shift()
95
+
96
+ // Budget exhaustion pins to the cheap level in every mode.
97
+ if (!state.budgetWaived && this.cost.isOverBudget(sessionId)) {
98
+ const cheap = this.cost.cheapLevel()
99
+ const route = this.effectiveRoute(state, sessionId, cheap)
100
+ state.level = cheap
101
+ state.mode = 'budget'
102
+ state.current = route
103
+ state.downgradePending = true
104
+ return route
105
+ }
106
+
107
+ const windowText = [String(inputText ?? ''), String(prevInputText ?? ''), String(lastReplyText ?? '')]
108
+ .filter(Boolean).join('\n')
109
+
110
+ if (state.mode === MODE_FIXED) {
111
+ state.fixedInputs = (state.fixedInputs ?? 0) + 1
112
+ if (state.fixedInputs > this.config.askEveryInputs) {
113
+ state.fixedInputs = 0
114
+ return { needsAsk: 'fixed-reelect' }
115
+ }
116
+ return this.routeFor(state, sessionId, state.fixedLevel)
117
+ }
118
+
119
+ if (state.mode === MODE_AUTO) {
120
+ return this.decideAuto(state, sessionId, windowText)
121
+ }
122
+
123
+ // MODE A heuristic.
124
+ const width = windowWidth(state.inputs, this.config.heuristic.windowSize, this.config.heuristic.counting)
125
+ const ceiling = lockCeiling(this.config.heuristic.thresholds)
126
+ if (ceiling !== null && width > ceiling) {
127
+ return { needsAsk: 'above-lock', width }
128
+ }
129
+ const level = levelForWidth(this.config.heuristic.thresholds, width)
130
+ return this.routeFor(state, sessionId, level)
131
+ }
132
+
133
+ /**
134
+ * MODE C: keyword scoring first, LLM classifier on zero score.
135
+ */
136
+ async decideAuto(state, sessionId, windowText) {
137
+ const { level: scoredLevel, score } = scoreRules(this.config.rules, this.config.scoring, windowText)
138
+ if (scoredLevel) {
139
+ return this.routeFor(state, sessionId, scoredLevel)
140
+ }
141
+ if (this.classifier.enabled()) {
142
+ const classified = await this.classifier.classify(windowText)
143
+ if (classified) {
144
+ const level = this.resolveClassifiedLevelId(classified)
145
+ const route = this.routeFor(state, sessionId, level)
146
+ state.score = score
147
+ return route
148
+ }
149
+ }
150
+ // No rules hit and no classifier → cost default.
151
+ const defaultLevel = this.cost.defaultLevel()
152
+ return this.routeFor(state, sessionId, defaultLevel)
153
+ }
154
+
155
+ /**
156
+ * Resolve the level actually used for a decision: the preferred free route
157
+ * when one covers this level, the level's own route otherwise, and the paid
158
+ * replacement when the level's own free route is already exhausted. Before
159
+ * any of that, the context guard skips levels whose declared window cannot
160
+ * hold the conversation.
161
+ * @param {object} state - per-session state.
162
+ * @param {string} sessionId
163
+ * @param {string} level
164
+ * @returns {object} route
165
+ */
166
+ effectiveRoute(state, sessionId, level) {
167
+ // 0. Context guard: never route into a window that cannot hold the
168
+ // conversation — a level with no room left produces truncated answers.
169
+ const fitted = this.levelFittingContext(state, level)
170
+ if (fitted !== level && this.levels[fitted]) {
171
+ return this.levels[fitted]
172
+ }
173
+
174
+ // 1. Preference: a declared free route covering this level wins.
175
+ const free = this.freeCandidateFor(sessionId, level)
176
+ if (free) return free
177
+
178
+ const own = this.levels[level] ?? null
179
+ if (own === null) return this.firstConfiguredRoute()
180
+
181
+ // 2. A free level whose quota is gone falls back to its paid replacement
182
+ // for the rest of the session instead of failing the turn. Each
183
+ // (level, route) demotion is queued for reporting only once.
184
+ if (this.isFreeLevel(level) && this.isExhausted(sessionId, own)) {
185
+ const paid = this.paidReplacement(level)
186
+ if (paid && !routesEqual(paid, own)) {
187
+ const demoteKey = `${level}:${this.routeKey(own)}`
188
+ if (!state.freeDemotionReported.has(demoteKey)) {
189
+ state.freeDemotionReported.add(demoteKey)
190
+ state.freeDemotionPending = { level, from: own, to: paid }
191
+ }
192
+ return paid
193
+ }
194
+ }
195
+ return own
196
+ }
197
+
198
+ routeFor(state, sessionId, level) {
199
+ // Catalog freshness first: a stale route fails the request outright, so
200
+ // drift to the nearest fresh level instead (fail open when nothing fits).
201
+ let use = level
202
+ if (this.routeStale(this.levels[level])) {
203
+ const fresh = this.nearestFreshLevel(level)
204
+ if (fresh !== null) use = fresh
205
+ }
206
+ const route = this.effectiveRoute(state, sessionId, use)
207
+ state.level = use
208
+ state.current = route
209
+ return route
210
+ }
211
+
212
+ /**
213
+ * Set the session mode (called by the index layer after a user answer).
214
+ * @param {string} sessionId
215
+ * @param {'heuristic'|'fixed'|'auto'} mode
216
+ * @param {object} [options] - { fixedLevel } for MODE_FIXED.
217
+ */
218
+ setMode(sessionId, mode, options = {}) {
219
+ const state = this.stateFor(sessionId)
220
+ state.mode = mode
221
+ if (mode === MODE_FIXED) {
222
+ state.fixedLevel = options.fixedLevel ?? this.config.costControl.defaultLevel
223
+ state.fixedInputs = 0
224
+ }
225
+ return state
226
+ }
227
+
228
+ /**
229
+ * Map a classifier level to a LEVEL_IDS entry. If the level has no model,
230
+ * drift by the mode's direction toward the nearest configured level.
231
+ * @param {string} classifiedLevel
232
+ * @returns {string} level id
233
+ */
234
+ resolveClassifiedLevelId(classifiedLevel) {
235
+ if (this.routeForLevel(classifiedLevel)) return classifiedLevel
236
+ if (this.config.costControl.mode === 'quality-first') {
237
+ return this.driftLevelId(classifiedLevel, 'up')
238
+ }
239
+ return this.driftLevelId(classifiedLevel, 'down')
240
+ }
241
+
242
+ driftLevelId(level, direction) {
243
+ const index = levelIndex(level)
244
+ if (index === -1) return LEVEL_IDS[0]
245
+ const step = direction === 'down' ? -1 : 1
246
+ for (let i = index + step; i >= 0 && i < LEVEL_IDS.length; i += step) {
247
+ if (this.routeForLevel(LEVEL_IDS[i])) return LEVEL_IDS[i]
248
+ }
249
+ return LEVEL_IDS[index]
250
+ }
251
+
252
+ /** The same drift, expressed as a route (kept for callers that want one). */
253
+ driftToConfigured(level, direction) {
254
+ return this.routeForLevel(this.driftLevelId(level, direction)) ?? this.firstConfiguredRoute()
255
+ }
256
+
257
+ firstConfiguredRoute() {
258
+ for (const id of LEVEL_IDS) {
259
+ const route = this.routeForLevel(id)
260
+ if (route) return route
261
+ }
262
+ return { provider: '', model: '' }
263
+ }
264
+
265
+ // ── context-window guard ────────────────────────────────────────────────
266
+ // A level whose declared `contextWindow` cannot hold the conversation is
267
+ // skipped: the router drifts up to the nearest level that fits. Undeclared
268
+ // windows are assumed to fit; when nothing fits the original level stands
269
+ // (fail open) — the provider will say so with a real error, which the
270
+ // fallback chain then handles.
271
+
272
+ /** Whether the guard is active for this profile. */
273
+ contextGuardEnabled() {
274
+ return this.config.contextGuard?.enabled === true
275
+ }
276
+
277
+ /** Headroom fraction the guard compares against. */
278
+ contextHeadroom() {
279
+ return Number.isFinite(this.config.contextGuard?.headroom)
280
+ ? this.config.contextGuard.headroom
281
+ : 0.85
282
+ }
283
+
284
+ /**
285
+ * The conversation size the guard compares against: the input side of the
286
+ * last completed request in this session (uncached + cache-read + cache-write).
287
+ * @param {string} sessionId
288
+ * @returns {number}
289
+ */
290
+ contextTokens(sessionId) {
291
+ return this.states.get(sessionId)?.contextTokens ?? 0
292
+ }
293
+
294
+ /**
295
+ * Remember the input side of one request (from the usage the host logs with
296
+ * each assistant message).
297
+ * @param {string} sessionId
298
+ * @param {object} usage - { inputTokens, cacheReadTokens, cacheWriteTokens, ... }
299
+ */
300
+ recordUsage(sessionId, usage) {
301
+ if (!usage || typeof usage !== 'object') return
302
+ const num = (value) => (Number.isFinite(Number(value)) ? Number(value) : 0)
303
+ const total = num(usage.inputTokens) + num(usage.cacheReadTokens ?? usage.cachedInputTokens)
304
+ + num(usage.cacheWriteTokens)
305
+ if (total <= 0) return
306
+ this.stateFor(sessionId).contextTokens = total
307
+ }
308
+
309
+ /**
310
+ * The level to actually use after the context guard: `level` itself when it
311
+ * fits (or declares no window), otherwise the nearest configured level that
312
+ * fits — upward first, because more capable levels tend to have larger
313
+ * windows. Records the skip once per (level, session) for reporting.
314
+ * @param {object} state - per-session state (for the skip record).
315
+ * @param {string} level
316
+ * @returns {string}
317
+ */
318
+ levelFittingContext(state, level) {
319
+ if (!this.contextGuardEnabled()) return level
320
+ const size = state.contextTokens ?? 0
321
+ if (size <= 0) return level
322
+ const index = levelIndex(level)
323
+ if (index === -1) return level
324
+ const headroom = this.contextHeadroom()
325
+ const fits = (id) => {
326
+ const route = this.levels[id]
327
+ if (!route) return false
328
+ const window = route.contextWindow
329
+ if (!Number.isFinite(window) || window <= 0) return true
330
+ return size <= window * headroom
331
+ }
332
+ if (fits(level)) return level
333
+ const ladder = []
334
+ for (let i = index + 1; i < LEVEL_IDS.length; i += 1) ladder.push(LEVEL_IDS[i])
335
+ for (const id of ladder) {
336
+ if (fits(id)) {
337
+ this.recordContextSkip(state, level, id)
338
+ return id
339
+ }
340
+ }
341
+ // Nothing declared fits; fail open on the original level.
342
+ this.recordContextSkip(state, level, level)
343
+ return level
344
+ }
345
+
346
+ /** Note that a level was skipped for context, once per session per level. */
347
+ recordContextSkip(state, from, to) {
348
+ if (state.contextSkips?.has(from)) return
349
+ if (state.contextSkips === undefined) state.contextSkips = new Map()
350
+ state.contextSkips.set(from, { from, to, contextTokens: state.contextTokens ?? 0 })
351
+ }
352
+
353
+ /**
354
+ * The context skips recorded for one session (level → { from, to,
355
+ * contextTokens }), for the status endpoint and tests.
356
+ * @param {string} sessionId
357
+ * @returns {object}
358
+ */
359
+ contextSkipsOf(sessionId) {
360
+ return Object.fromEntries(this.states.get(sessionId)?.contextSkips ?? [])
361
+ }
362
+
363
+ // ── catalog freshness guard ─────────────────────────────────────────────
364
+ // Model versions move on: a provider deprecates an id, the profile's static
365
+ // list rots. The guard reads what each provider actually advertises
366
+ // (`ctx.llm.listModels`) and treats a missing route as stale: the level
367
+ // drifts to the nearest fresh one and `/status` names same-family candidates
368
+ // for the one-line fix. Providers whose catalog cannot be read fail open.
369
+
370
+ /** Whether the guard can run at all (needs a catalog reader). */
371
+ catalogGuardActive() {
372
+ return this.config.modelsGuard?.enabled === true
373
+ && typeof this.llm?.listModels === 'function'
374
+ }
375
+
376
+ /**
377
+ * Re-read every provider's advertised model ids for the routes in play.
378
+ * Never throws; unreadable or empty answers record the provider as unknown.
379
+ * @returns {Promise<void>}
380
+ */
381
+ async refreshCatalogs() {
382
+ if (!this.catalogGuardActive()) return
383
+ this.catalogCheckedAt = Date.now()
384
+ const providers = new Set()
385
+ for (const id of LEVEL_IDS) {
386
+ const route = this.levels[id]
387
+ if (route?.provider) providers.add(route.provider)
388
+ }
389
+ for (const entry of this.freeQuotaRoutes()) providers.add(entry.provider)
390
+ for (const provider of providers) {
391
+ let ids = null
392
+ try {
393
+ const listed = await this.llm.listModels(provider)
394
+ if (Array.isArray(listed)) {
395
+ ids = new Set()
396
+ for (const item of listed) {
397
+ const modelId = item?.id ?? item?.model
398
+ if (typeof modelId === 'string' && modelId !== '') ids.add(modelId)
399
+ }
400
+ if (ids.size === 0) ids = null
401
+ }
402
+ } catch {
403
+ ids = null
404
+ }
405
+ this.catalogs.set(provider, ids)
406
+ }
407
+ }
408
+
409
+ /** Whether a route is missing from a known (readable, non-empty) catalog. */
410
+ routeStale(route) {
411
+ if (!route?.provider || !route?.model) return false
412
+ const ids = this.catalogs.get(route.provider)
413
+ return ids !== undefined && ids !== null && !ids.has(route.model)
414
+ }
415
+
416
+ /**
417
+ * Staleness report per level: level → { provider, model, candidates },
418
+ * where `candidates` are same-family ids the provider still advertises.
419
+ * @returns {object}
420
+ */
421
+ staleLevels() {
422
+ const out = {}
423
+ for (const id of LEVEL_IDS) {
424
+ const route = this.levels[id]
425
+ if (!route || !this.routeStale(route)) continue
426
+ const ids = this.catalogs.get(route.provider) ?? new Set()
427
+ const family = String(route.model).split(/[-._]/)[0].toLowerCase()
428
+ out[id] = {
429
+ provider: route.provider,
430
+ model: route.model,
431
+ candidates: [...ids].filter((m) => m.toLowerCase().includes(family)).slice(0, 5),
432
+ }
433
+ }
434
+ return out
435
+ }
436
+
437
+ /** The nearest non-stale level to drift to (upward first), or null. */
438
+ nearestFreshLevel(level) {
439
+ const index = levelIndex(level)
440
+ if (index === -1) return null
441
+ for (let i = index + 1; i < LEVEL_IDS.length; i += 1) {
442
+ const candidate = this.levels[LEVEL_IDS[i]]
443
+ if (candidate && !this.routeStale(candidate)) return LEVEL_IDS[i]
444
+ }
445
+ for (let i = index - 1; i >= 0; i -= 1) {
446
+ const candidate = this.levels[LEVEL_IDS[i]]
447
+ if (candidate && !this.routeStale(candidate)) return LEVEL_IDS[i]
448
+ }
449
+ return null
450
+ }
451
+
452
+ // ── free-quota layer ────────────────────────────────────────────────────
453
+ // Routes declared free (a level flag `freeQuota: true`, or an entry in
454
+ // `freeQuota.routes`) are preferred for the levels they cover. A free route
455
+ // that fails is demoted for the rest of the session, and the level it served
456
+ // falls back to a paid route — a free route never fails the turn by itself.
457
+
458
+ /** Whether the free-quota layer is active for this profile. */
459
+ freeQuotaEnabled() {
460
+ return this.config.freeQuota?.enabled === true
461
+ }
462
+
463
+ /** Declared free routes that are not levels themselves. */
464
+ freeQuotaRoutes() {
465
+ return this.config.freeQuota?.routes ?? []
466
+ }
467
+
468
+ /** Whether a level's own route is declared free. */
469
+ isFreeLevel(level) {
470
+ return this.levels[level]?.freeQuota === true
471
+ }
472
+
473
+ /** Whether a route is declared free anywhere in the configuration. */
474
+ isFreeRoute(route) {
475
+ if (!route || route.provider === '') return false
476
+ for (const id of LEVEL_IDS) {
477
+ if (this.isFreeLevel(id) && routesEqual(this.levels[id], route)) return true
478
+ }
479
+ return this.freeQuotaRoutes().some((entry) => routesEqual(entry, route))
480
+ }
481
+
482
+ /** Session key for a route. */
483
+ routeKey(route) {
484
+ return route ? `${route.provider}/${route.model}` : ''
485
+ }
486
+
487
+ /** Whether this session already burned a route's free quota. */
488
+ isExhausted(sessionId, route) {
489
+ const state = this.states.get(sessionId)
490
+ return state ? state.exhaustedFree.has(this.routeKey(route)) : false
491
+ }
492
+
493
+ /**
494
+ * Mark a free route as exhausted for this session (called from
495
+ * agent/request-error). Returns true when this call changed the state, so the
496
+ * caller can notify exactly once.
497
+ */
498
+ markFreeExhausted(sessionId, route) {
499
+ if (!this.freeQuotaEnabled()) return false
500
+ if (!this.isFreeRoute(route)) return false
501
+ const state = this.stateFor(sessionId)
502
+ const key = this.routeKey(route)
503
+ if (state.exhaustedFree.has(key)) return false
504
+ state.exhaustedFree.add(key)
505
+ return true
506
+ }
507
+
508
+ /**
509
+ * A declared free route that covers `level` and still has quota, or null.
510
+ * This is the "prefer free" half of the layer.
511
+ */
512
+ freeCandidateFor(sessionId, level) {
513
+ if (!this.freeQuotaEnabled()) return null
514
+ const index = levelIndex(level)
515
+ if (index === -1) return null
516
+ for (const entry of this.freeQuotaRoutes()) {
517
+ if (levelIndex(entry.maxLevel) < index) continue
518
+ if (this.isExhausted(sessionId, entry)) continue
519
+ if (this.routeStale(entry)) continue
520
+ return entry
521
+ }
522
+ return null
523
+ }
524
+
525
+ /**
526
+ * The paid route a free level falls back to: the nearest configured non-free
527
+ * level (upward first, so quality is not silently dropped), then the first
528
+ * non-free entry of the fallback chain.
529
+ * @param {string} level
530
+ * @returns {object | null}
531
+ */
532
+ paidReplacement(level) {
533
+ const index = levelIndex(level)
534
+ const ladder = []
535
+ for (let i = index + 1; i < LEVEL_IDS.length; i += 1) ladder.push(LEVEL_IDS[i])
536
+ for (let i = index - 1; i >= 0; i -= 1) ladder.push(LEVEL_IDS[i])
537
+ for (const id of ladder) {
538
+ if (this.isFreeLevel(id)) continue
539
+ const route = this.routeForLevel(id)
540
+ if (route) return route
541
+ }
542
+ return (this.fallback?.chain ?? []).find((route) => !this.isFreeRoute(route)) ?? null
543
+ }
544
+
545
+ /**
546
+ * The pending free-quota demotion of this session, or null. The caller
547
+ * consumes it (clears it) so the notification fires exactly once.
548
+ * @returns {{ level: string, from: object, to: object } | null}
549
+ */
550
+ consumeFreeDemotionFlag(sessionId) {
551
+ const state = this.states.get(sessionId)
552
+ if (!state?.freeDemotionPending) return null
553
+ const info = state.freeDemotionPending
554
+ state.freeDemotionPending = null
555
+ return info
556
+ }
557
+
558
+ /**
559
+ * Record a failure on the current route and return the fallback route, or
560
+ * null when the chain is exhausted. Caller returns { kind: 'retry' }.
561
+ * @param {string} sessionId
562
+ * @param {object} failedRoute
563
+ * @returns {object | null}
564
+ */
565
+ nextOnFailure(sessionId, failedRoute) {
566
+ const state = this.stateFor(sessionId)
567
+ const key = `${failedRoute.provider}/${failedRoute.model}`
568
+ const retries = state.retries.get(key) ?? 0
569
+ if (!this.fallback.canRetry(failedRoute, retries)) return null
570
+ state.retries.set(key, retries + 1)
571
+ const next = this.nextViableFallback(sessionId, failedRoute)
572
+ if (next === null) return null
573
+ state.current = next
574
+ state.level = 'fallback'
575
+ return next
576
+ }
577
+
578
+ /**
579
+ * Walk the fallback chain from `failedRoute`, skipping routes whose free
580
+ * quota this session already burned — retrying a rate-limited free route
581
+ * would just fail again. Returns null when nothing viable is left.
582
+ * @param {string} sessionId
583
+ * @param {object} failedRoute
584
+ * @returns {object | null}
585
+ */
586
+ nextViableFallback(sessionId, failedRoute) {
587
+ const size = this.fallback?.chain?.length ?? 0
588
+ const seen = new Set([this.routeKey(failedRoute)])
589
+ let current = failedRoute
590
+ for (let hop = 0; hop < size; hop += 1) {
591
+ const next = this.fallback.nextAfter(current)
592
+ if (next === null || routesEqual(next, current)) return null
593
+ const key = this.routeKey(next)
594
+ if (seen.has(key)) return null
595
+ seen.add(key)
596
+ if (!this.isExhausted(sessionId, next)) return next
597
+ current = next
598
+ }
599
+ return null
600
+ }
601
+
602
+ /** Account one message's tokens toward the session budget. */
603
+ recordTokens(sessionId, message) {
604
+ if (this.tokenMeter?.estimateMessage) {
605
+ this.cost.addSpend(sessionId, this.tokenMeter.estimateMessage(message))
606
+ } else {
607
+ this.cost.addSpend(sessionId, estimateMessageTokens(message))
608
+ }
609
+ }
610
+
611
+ /**
612
+ * The previous user input's text (the input BEFORE the current one), used
613
+ * to build the classification window. Falls back to the last remembered
614
+ * input when the current input is the first of the session.
615
+ * @param {string} sessionId
616
+ * @returns {string | null}
617
+ */
618
+ lastInputText(sessionId) {
619
+ const state = this.states.get(sessionId)
620
+ if (!state) return null
621
+ if (state.inputs.length >= 2) return state.inputs[state.inputs.length - 2]
622
+ return null
623
+ }
624
+
625
+ /**
626
+ * The assistant's last reply text, remembered from session/event.
627
+ * @param {string} sessionId
628
+ * @returns {string | null}
629
+ */
630
+ lastReplyText(sessionId) {
631
+ return this.states.get(sessionId)?.lastReply ?? null
632
+ }
633
+
634
+ /** Remember the assistant's latest reply text (session/event). */
635
+ rememberReply(sessionId, text) {
636
+ this.stateFor(sessionId).lastReply = String(text ?? '')
637
+ }
638
+
639
+ /**
640
+ * The cached latest user input text (set in agent/pre-step). The current
641
+ * input is whatever the last user message was — the router decision for
642
+ * this session should key off it until the next user message arrives.
643
+ * @param {string} sessionId
644
+ * @returns {string | null}
645
+ */
646
+ currentInput(sessionId) {
647
+ return this.states.get(sessionId)?.currentInput ?? null
648
+ }
649
+
650
+ /** Cache the latest user input text (agent/pre-step). */
651
+ rememberInput(sessionId, text) {
652
+ this.stateFor(sessionId).currentInput = String(text ?? '')
653
+ }
654
+
655
+ /** Current route for a session (for status tools / debugging). */
656
+ current(sessionId) {
657
+ return this.states.get(sessionId)?.current ?? this.firstConfiguredRoute()
658
+ }
659
+
660
+ /** Whether the session runs in Auto mode (user picked the virtual auto provider). */
661
+ isAutoActive(sessionId) {
662
+ return this.states.get(sessionId)?.autoActive === true
663
+ }
664
+
665
+ /** Mark the session as Auto-mode (the router takes over routing). */
666
+ markAutoActive(sessionId) {
667
+ this.stateFor(sessionId).autoActive = true
668
+ }
669
+
670
+ /** Mark the session as NOT Auto-mode (user picked a real model; pass through). */
671
+ markAutoInactive(sessionId) {
672
+ this.stateFor(sessionId).autoActive = false
673
+ }
674
+
675
+ /** The level label that produced the current route. */
676
+ level(sessionId) {
677
+ return this.stateFor(sessionId).level
678
+ }
679
+
680
+ /** The mode that produced the current route. */
681
+ mode(sessionId) {
682
+ return this.stateFor(sessionId).mode
683
+ }
684
+
685
+ /**
686
+ * Whether this session's last decision tripped the budget downgrade and the
687
+ * downgrade has not yet been reported/asked about. The caller consumes the
688
+ * flag (clears it) so the notification fires exactly once.
689
+ * @param {string} sessionId
690
+ * @returns {boolean}
691
+ */
692
+ consumeDowngradeFlag(sessionId) {
693
+ const state = this.states.get(sessionId)
694
+ if (!state?.downgradePending) return false
695
+ state.downgradePending = false
696
+ return true
697
+ }
698
+
699
+ /**
700
+ * Waive the budget for this session (ask mode). Session-scoped.
701
+ * @param {string} sessionId
702
+ */
703
+ waiveBudget(sessionId) {
704
+ const state = this.stateFor(sessionId)
705
+ state.budgetWaived = true
706
+ }
707
+
708
+ /** Reset per-session state (used on session-start). */
709
+ resetSession(sessionId) {
710
+ this.states.delete(sessionId)
711
+ this.cost.resetSession(sessionId)
712
+ }
713
+
714
+ measureTokens(sessionId) {
715
+ return this.cost.spentTokens(sessionId)
716
+ }
717
+
718
+ stateFor(sessionId) {
719
+ let state = this.states.get(sessionId)
720
+ if (!state) {
721
+ state = {
722
+ level: 'unset',
723
+ current: this.firstConfiguredRoute(),
724
+ mode: MODE_HEURISTIC,
725
+ inputs: [],
726
+ lastInputText: '',
727
+ currentInput: '',
728
+ fixedLevel: this.config.costControl.defaultLevel,
729
+ fixedInputs: 0,
730
+ score: 0,
731
+ lastReply: '',
732
+ retries: new Map(),
733
+ downgradePending: false,
734
+ budgetWaived: false,
735
+ autoActive: false,
736
+ // Input side of the last completed request (context guard signal).
737
+ contextTokens: 0,
738
+ // Levels skipped by the context guard, once each.
739
+ contextSkips: new Map(),
740
+ // Free routes whose quota this session already burned.
741
+ exhaustedFree: new Set(),
742
+ // A free route demoted during routing, waiting to be reported.
743
+ freeDemotionPending: null,
744
+ // Demotions already reported, so a session is told exactly once.
745
+ freeDemotionReported: new Set(),
746
+ }
747
+ this.states.set(sessionId, state)
748
+ }
749
+ return state
750
+ }
751
+ }