@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/LICENSE +21 -0
- package/README.md +522 -3
- package/README.zh.md +463 -0
- package/auto-adapter.mjs +132 -0
- package/classifier.mjs +86 -0
- package/client.js +123 -0
- package/compat.mjs +199 -0
- package/config.mjs +461 -0
- package/cordis.patch.yml +11 -0
- package/cost-control.mjs +159 -0
- package/fallback.mjs +62 -0
- package/index.mjs +429 -0
- package/notify.mjs +653 -0
- package/package.json +74 -6
- package/router.mjs +751 -0
- package/rules.mjs +59 -0
- package/scoring.mjs +69 -0
- package/text-width.mjs +73 -0
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
|
+
}
|