@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/config.mjs ADDED
@@ -0,0 +1,461 @@
1
+ // Config resolution and validation for mydsh-auto-model-router (level-based design).
2
+ //
3
+ // The router works over four capability levels (L0…L3); each level is a
4
+ // provider/model route the user assigns. Three routing modes live on top:
5
+ //
6
+ // MODE A heuristic lock (default): counts the last N user inputs' text
7
+ // width and maps ranges to levels (a cheap L0/L1 lock). Crossing the top
8
+ // threshold asks the user once how to proceed.
9
+ // MODE B fixed model: user picked an explicit level; re-asks every
10
+ // askEveryInputs user inputs.
11
+ // MODE C fully-auto: keyword-scored rules (rules + scoring) decide first;
12
+ // zero score falls through to the LLM classifier (llmClassifier), which
13
+ // judges on the window of 本次输入 + 上次输入 + 上次回复.
14
+ //
15
+ // levels: { L0: route, L1: route, L2: route, L3: route }
16
+ // heuristic: { windowSize, counting, thresholds }
17
+ // rules: [{ match, level }] (scored per hit)
18
+ // scoring: { weights, bands }
19
+ // costControl: { enabled, mode, defaultLevel, tokenBudgetPerSession }
20
+ // llmClassifier: { enabled, model, requestTimeoutMs }
21
+ // askEveryInputs: fixed-model re-ask cadence
22
+ // fallbackChain: [route, ...] (model-level, appended with the default)
23
+ // maxRetries: per-route retry budget on request-error
24
+
25
+ export const PLUGIN_VERSION = '0.2.1'
26
+
27
+ export const LEVEL_IDS = Object.freeze(['L0', 'L1', 'L2', 'L3'])
28
+ export const COST_MODES = Object.freeze(['cost-first', 'quality-first', 'balanced'])
29
+ export const DOWNGRADE_BEHAVIORS = Object.freeze(['silent', 'notify', 'ask'])
30
+ export const DEFAULT_LEVEL = 'L1'
31
+ /** Highest level a free route serves when `freeQuota.routes[].maxLevel` is omitted. */
32
+ export const DEFAULT_FREE_MAX_LEVEL = 'L1'
33
+ export const COUNTING_MODES = Object.freeze(['cjk2', 'tokens'])
34
+
35
+ const DEFAULT_CONFIG = Object.freeze({
36
+ // Factory defaults must be valid on the 0.2.x host family: the DeepSeek
37
+ // catalog advertises `deepseek-flash` (V4.1 Flash) and the adapter accepts
38
+ // only off/low/high/max. The previous defaults (`deepseek-v4-flash`,
39
+ // `medium`) were stale and made a fresh install without a levels block fail.
40
+ levels: {
41
+ L0: { provider: 'deepseek-official', model: 'deepseek-flash', reasoningEffort: 'off' },
42
+ L1: { provider: 'deepseek-official', model: 'deepseek-flash', reasoningEffort: 'low' },
43
+ L2: { provider: 'deepseek-official', model: 'deepseek-flash', reasoningEffort: 'high' },
44
+ L3: { provider: 'deepseek-official', model: 'deepseek-flash', reasoningEffort: 'max' },
45
+ },
46
+ // Keyword rules only take part in MODE C (fully-auto). Default empty.
47
+ rules: [],
48
+ heuristic: {
49
+ windowSize: 3,
50
+ counting: 'cjk2',
51
+ thresholds: [
52
+ { maxChars: 10, level: 'L0' },
53
+ { maxChars: 100, level: 'L1' },
54
+ ],
55
+ },
56
+ scoring: {
57
+ weights: { L1: 1, L2: 2, L3: 3 },
58
+ bands: [
59
+ { maxScore: 0, level: 'L0' },
60
+ { maxScore: 5, level: 'L1' },
61
+ { maxScore: 15, level: 'L2' },
62
+ { maxScore: null, level: 'L3' },
63
+ ],
64
+ },
65
+ costControl: {
66
+ enabled: true,
67
+ mode: 'balanced',
68
+ defaultLevel: DEFAULT_LEVEL,
69
+ // 0 = no budget limit: the cost layer never downgrades from budget
70
+ // exhaustion (downgradeBehavior is then inert). Set a positive value
71
+ // to cap per-session spend.
72
+ tokenBudgetPerSession: 0,
73
+ downgradeBehavior: 'notify',
74
+ },
75
+ llmClassifier: {
76
+ enabled: true,
77
+ model: { provider: 'deepseek-official', model: 'deepseek-flash' },
78
+ requestTimeoutMs: 10_000,
79
+ },
80
+ // Tier 3: free-quota preference. Routes declared free are preferred for the
81
+ // levels they cover; a free route that fails is demoted for the session and
82
+ // the level falls back to its paid route instead of failing the turn.
83
+ freeQuota: {
84
+ // Auto-disabled when nothing is declared free (no behaviour change for
85
+ // existing users): see resolveConfig.
86
+ enabled: true,
87
+ // Extra free routes that are not levels themselves. `maxLevel` is the
88
+ // highest level the free route is trusted for (inclusive), default L1.
89
+ routes: [],
90
+ // Demote a free route for the rest of the session as soon as one request
91
+ // fails on it (quota exhausted, rate limited, rejected, ...).
92
+ demoteOnError: true,
93
+ // Tell the user once per session when a free route was demoted.
94
+ notify: true,
95
+ },
96
+ // Tier 5: context-window guard. A level whose declared `contextWindow`
97
+ // cannot hold the conversation is skipped — the router drifts up to the
98
+ // nearest level that fits — so a long session never lands on a model that
99
+ // has no room left for output (which surfaces as a truncated answer).
100
+ contextGuard: {
101
+ // Auto-disabled when no level declares a contextWindow.
102
+ enabled: true,
103
+ // A level "fits" while the conversation stays under window * headroom,
104
+ // leaving room for the answer.
105
+ headroom: 0.85,
106
+ },
107
+ // Tier 6: catalog freshness guard. Level/free routes are validated against
108
+ // what each provider actually advertises (`ctx.llm.listModels`); a route the
109
+ // provider no longer lists is stale — the level skips to the nearest fresh
110
+ // one and `/status` names it with same-family candidate ids. Inert (fail
111
+ // open) whenever a catalog cannot be read or comes back empty.
112
+ modelsGuard: {
113
+ enabled: true,
114
+ // Re-read provider catalogs every N ms (also validated once at activation).
115
+ refreshMs: 600_000,
116
+ },
117
+ askEveryInputs: 3,
118
+ fallbackChain: [],
119
+ maxRetries: 1,
120
+ })
121
+
122
+ /**
123
+ * Resolve and validate the effective router configuration.
124
+ * @param {object} input - the `config:` block from the cordis patch.
125
+ * @returns {object} validated, defaults-filled config.
126
+ * @throws {Error} naming the offending field on invalid input.
127
+ */
128
+ export function resolveConfig(input = {}) {
129
+ const cfg = {
130
+ ...DEFAULT_CONFIG,
131
+ ...input,
132
+ }
133
+ cfg.levels = normalizeLevels(cfg.levels)
134
+ cfg.rules = normalizeRules(cfg.rules)
135
+ cfg.heuristic = normalizeHeuristic(cfg.heuristic)
136
+ cfg.scoring = normalizeScoring(cfg.scoring)
137
+ cfg.costControl = normalizeCostControl(cfg.costControl)
138
+ cfg.llmClassifier = normalizeClassifier(cfg.llmClassifier)
139
+ cfg.freeQuota = normalizeFreeQuota(cfg.freeQuota)
140
+ cfg.contextGuard = normalizeContextGuard(cfg.contextGuard)
141
+ cfg.modelsGuard = normalizeModelsGuard(cfg.modelsGuard)
142
+ cfg.askEveryInputs = clampInt(cfg.askEveryInputs, 1, 100, DEFAULT_CONFIG.askEveryInputs)
143
+ cfg.fallbackChain = normalizeChain(cfg.fallbackChain)
144
+ cfg.maxRetries = clampInt(cfg.maxRetries, 0, 5, DEFAULT_CONFIG.maxRetries)
145
+ // Nothing declared free (neither a level flag nor a freeQuota.routes entry)
146
+ // means the whole layer is inert — keep existing profiles byte-identical in
147
+ // behaviour.
148
+ cfg.freeQuota.enabled = cfg.freeQuota.enabled
149
+ && (cfg.freeQuota.routes.length > 0 || LEVEL_IDS.some((id) => cfg.levels[id]?.freeQuota === true))
150
+ // Nothing declares a window → the guard is inert (no behaviour change).
151
+ cfg.contextGuard.enabled = cfg.contextGuard.enabled
152
+ && LEVEL_IDS.some((id) => Number.isFinite(cfg.levels[id]?.contextWindow))
153
+ // Exposed to the status endpoint and the browser settings page so a bug
154
+ // report always names the build that produced it.
155
+ cfg.pluginVersion = PLUGIN_VERSION
156
+ return cfg
157
+ }
158
+
159
+ /**
160
+ * Normalize the context-window guard. `headroom` is clamped into (0, 1].
161
+ */
162
+ function normalizeContextGuard(contextGuard) {
163
+ const guard = {
164
+ ...DEFAULT_CONFIG.contextGuard,
165
+ ...(contextGuard ?? {}),
166
+ }
167
+ guard.enabled = guard.enabled !== false
168
+ let headroom = Number(guard.headroom)
169
+ if (!Number.isFinite(headroom) || headroom <= 0 || headroom > 1) {
170
+ headroom = DEFAULT_CONFIG.contextGuard.headroom
171
+ }
172
+ guard.headroom = headroom
173
+ return guard
174
+ }
175
+
176
+ /**
177
+ * Normalize the catalog freshness guard. `refreshMs` is clamped to a sane
178
+ * range so a typo cannot turn the periodic re-check into a hot loop.
179
+ */
180
+ function normalizeModelsGuard(modelsGuard) {
181
+ const guard = {
182
+ ...DEFAULT_CONFIG.modelsGuard,
183
+ ...(modelsGuard ?? {}),
184
+ }
185
+ guard.enabled = guard.enabled !== false
186
+ guard.refreshMs = clampInt(guard.refreshMs, 30_000, 24 * 60 * 60 * 1000, DEFAULT_CONFIG.modelsGuard.refreshMs)
187
+ return guard
188
+ }
189
+
190
+ /**
191
+ * Normalize the free-quota layer. `routes` entries are provider/model pairs
192
+ * with an optional inclusive `maxLevel` ceiling.
193
+ */
194
+ function normalizeFreeQuota(freeQuota) {
195
+ const fq = {
196
+ ...DEFAULT_CONFIG.freeQuota,
197
+ ...(freeQuota ?? {}),
198
+ }
199
+ fq.enabled = fq.enabled !== false
200
+ fq.demoteOnError = fq.demoteOnError !== false
201
+ fq.notify = fq.notify !== false
202
+ if (!Array.isArray(fq.routes)) {
203
+ throw new Error('mydsh-auto-model-router: freeQuota.routes must be an array of provider/model routes')
204
+ }
205
+ fq.routes = fq.routes.map((route, index) => {
206
+ const where = `freeQuota.routes[${index}]`
207
+ const normalized = normalizeRoute(route, where)
208
+ const maxLevel = route.maxLevel === undefined || route.maxLevel === null
209
+ ? DEFAULT_FREE_MAX_LEVEL
210
+ : String(route.maxLevel).trim()
211
+ if (!LEVEL_IDS.includes(maxLevel)) {
212
+ throw new Error(
213
+ `mydsh-auto-model-router: ${where}.maxLevel must be one of ${LEVEL_IDS.join(', ')}, got "${maxLevel}"`,
214
+ )
215
+ }
216
+ // Ride along with an explicit effort, exactly like a level route, so the
217
+ // dispatched request never falls back to the adapter's own default
218
+ // thinking level by accident.
219
+ return {
220
+ ...normalized,
221
+ reasoningEffort: normalized.reasoningEffort ?? 'off',
222
+ maxLevel,
223
+ }
224
+ })
225
+ return fq
226
+ }
227
+
228
+ function normalizeHeuristic(heuristic) {
229
+ const h = {
230
+ ...DEFAULT_CONFIG.heuristic,
231
+ ...(heuristic ?? {}),
232
+ }
233
+ h.windowSize = clampInt(h.windowSize, 1, 20, DEFAULT_CONFIG.heuristic.windowSize)
234
+ if (!COUNTING_MODES.includes(h.counting)) {
235
+ throw new Error(
236
+ `mydsh-auto-model-router: heuristic.counting must be one of ${COUNTING_MODES.join(', ')}, got "${h.counting}"`,
237
+ )
238
+ }
239
+ if (!Array.isArray(h.thresholds) || h.thresholds.length === 0) {
240
+ throw new Error('mydsh-auto-model-router: heuristic.thresholds must be a non-empty array')
241
+ }
242
+ h.thresholds = h.thresholds.map((t, index) => {
243
+ if (!LEVEL_IDS.includes(t.level)) {
244
+ throw new Error(`mydsh-auto-model-router: heuristic.thresholds[${index}].level must be one of ${LEVEL_IDS.join(', ')}`)
245
+ }
246
+ const maxChars = t.maxChars === null || t.maxChars === undefined
247
+ ? null
248
+ : clampInt(t.maxChars, 0, 1_000_000, 0)
249
+ return { maxChars, level: t.level }
250
+ })
251
+ return h
252
+ }
253
+
254
+ function normalizeScoring(scoring) {
255
+ const s = {
256
+ ...DEFAULT_CONFIG.scoring,
257
+ ...(scoring ?? {}),
258
+ }
259
+ const weights = {}
260
+ for (const id of LEVEL_IDS) {
261
+ const value = Number(s.weights?.[id])
262
+ weights[id] = Number.isFinite(value) && value > 0 ? value : (id === 'L0' ? 0 : 1)
263
+ }
264
+ s.weights = weights
265
+ if (!Array.isArray(s.bands) || s.bands.length === 0) {
266
+ throw new Error('mydsh-auto-model-router: scoring.bands must be a non-empty array')
267
+ }
268
+ s.bands = s.bands.map((b, index) => {
269
+ if (!LEVEL_IDS.includes(b.level)) {
270
+ throw new Error(`mydsh-auto-model-router: scoring.bands[${index}].level must be one of ${LEVEL_IDS.join(', ')}`)
271
+ }
272
+ return {
273
+ maxScore: b.maxScore === null || b.maxScore === undefined
274
+ ? null
275
+ : clampInt(b.maxScore, 0, 1_000_000, 0),
276
+ level: b.level,
277
+ }
278
+ })
279
+ return s
280
+ }
281
+
282
+ /**
283
+ * Every level may be omitted except the cost-control default level, which must
284
+ * exist. Returns a level map keyed by LEVEL_IDS with undefined holes. Each
285
+ * configured level carries an explicit reasoningEffort (default 'off').
286
+ */
287
+ function normalizeLevels(levels) {
288
+ const source = levels ?? {}
289
+ const out = {}
290
+ for (const id of LEVEL_IDS) {
291
+ const route = source[id]
292
+ out[id] = route === undefined || route === null
293
+ ? undefined
294
+ : normalizeLevelRoute(route, `levels.${id}`)
295
+ }
296
+ return out
297
+ }
298
+
299
+ function normalizeLevelRoute(route, where) {
300
+ const normalized = normalizeRoute(route, where)
301
+ // Levels always carry an explicit reasoning effort; absent → 'off' (no
302
+ // thinking mode) so every level spells out its thinking intensity.
303
+ const contextWindow = route.contextWindow === undefined || route.contextWindow === null
304
+ ? undefined
305
+ : Number(route.contextWindow)
306
+ if (contextWindow !== undefined && (!Number.isSafeInteger(contextWindow) || contextWindow <= 0)) {
307
+ throw new Error(`mydsh-auto-model-router: "${where}.contextWindow" must be a positive integer, got ${JSON.stringify(route.contextWindow)}`)
308
+ }
309
+ return {
310
+ ...normalized,
311
+ reasoningEffort: normalized.reasoningEffort ?? 'off',
312
+ // A level may declare that its route is a free-quota route. The flag rides
313
+ // along with the route so the router can prefer/demote it.
314
+ ...route.freeQuota === true ? { freeQuota: true } : {},
315
+ // A level may declare its model's context window for the guard.
316
+ ...contextWindow !== undefined ? { contextWindow } : {},
317
+ }
318
+ }
319
+
320
+ function normalizeRoute(route, where) {
321
+ if (route === undefined || route === null) {
322
+ throw new Error(`mydsh-auto-model-router: "${where}" requires a provider/model route`)
323
+ }
324
+ const provider = String(route.provider ?? '').trim()
325
+ const model = String(route.model ?? '').trim()
326
+ if (!provider || !model) {
327
+ throw new Error(
328
+ `mydsh-auto-model-router: "${where}" needs both provider and model, got ${JSON.stringify(route)}`,
329
+ )
330
+ }
331
+ const reasoningEffort = route.reasoningEffort === undefined
332
+ ? undefined
333
+ : String(route.reasoningEffort).trim() || undefined
334
+ return {
335
+ provider,
336
+ model,
337
+ ...reasoningEffort === undefined ? {} : { reasoningEffort },
338
+ }
339
+ }
340
+
341
+ function normalizeRules(rules) {
342
+ if (!Array.isArray(rules)) {
343
+ throw new Error('mydsh-auto-model-router: "rules" must be an array')
344
+ }
345
+ return rules.map((rule, index) => {
346
+ if (typeof rule.match !== 'string' || rule.match.trim() === '') {
347
+ throw new Error(`mydsh-auto-model-router: rules[${index}] needs a non-empty "match" pattern`)
348
+ }
349
+ if (!LEVEL_IDS.includes(rule.level)) {
350
+ throw new Error(
351
+ `mydsh-auto-model-router: rules[${index}].level must be one of ${LEVEL_IDS.join(', ')}, got "${rule.level}"`,
352
+ )
353
+ }
354
+ return {
355
+ match: rule.match.trim(),
356
+ regex: compilePattern(rule.match.trim(), index),
357
+ level: rule.level,
358
+ }
359
+ })
360
+ }
361
+
362
+ function compilePattern(pattern, index) {
363
+ // A pattern surrounded by slashes is treated as a RegExp literal,
364
+ // anything else as a plain substring (case-insensitive).
365
+ const trimmed = pattern.trim()
366
+ if (trimmed.startsWith('/') && trimmed.lastIndexOf('/') > 0) {
367
+ const lastSlash = trimmed.lastIndexOf('/')
368
+ const body = trimmed.slice(1, lastSlash)
369
+ const flags = trimmed.slice(lastSlash + 1)
370
+ try {
371
+ return new RegExp(body, flags.includes('i') ? flags : `${flags}i`)
372
+ } catch (error) {
373
+ throw new Error(
374
+ `mydsh-auto-model-router: rules[${index}] has an invalid regex "${pattern}": ${error instanceof Error ? error.message : String(error)}`,
375
+ )
376
+ }
377
+ }
378
+ return null
379
+ }
380
+
381
+ function normalizeCostControl(costControl) {
382
+ const cc = {
383
+ ...DEFAULT_CONFIG.costControl,
384
+ ...(costControl ?? {}),
385
+ }
386
+ cc.enabled = cc.enabled !== false
387
+ if (!COST_MODES.includes(cc.mode)) {
388
+ throw new Error(
389
+ `mydsh-auto-model-router: costControl.mode must be one of ${COST_MODES.join(', ')}, got "${cc.mode}"`,
390
+ )
391
+ }
392
+ if (!LEVEL_IDS.includes(cc.defaultLevel)) {
393
+ throw new Error(
394
+ `mydsh-auto-model-router: costControl.defaultLevel must be one of ${LEVEL_IDS.join(', ')}, got "${cc.defaultLevel}"`,
395
+ )
396
+ }
397
+ cc.tokenBudgetPerSession = clampInt(
398
+ cc.tokenBudgetPerSession,
399
+ 0, // 0 = no budget limit
400
+ 100_000_000,
401
+ DEFAULT_CONFIG.costControl.tokenBudgetPerSession,
402
+ )
403
+ if (!DOWNGRADE_BEHAVIORS.includes(cc.downgradeBehavior)) {
404
+ throw new Error(
405
+ `mydsh-auto-model-router: costControl.downgradeBehavior must be one of ${DOWNGRADE_BEHAVIORS.join(', ')}, got "${cc.downgradeBehavior}"`,
406
+ )
407
+ }
408
+ return cc
409
+ }
410
+
411
+ function normalizeClassifier(classifier) {
412
+ const cl = {
413
+ ...DEFAULT_CONFIG.llmClassifier,
414
+ ...(classifier ?? {}),
415
+ }
416
+ cl.enabled = cl.enabled === true
417
+ if (cl.enabled) {
418
+ cl.model = cl.model === undefined
419
+ ? undefined
420
+ : normalizeRoute(cl.model, 'llmClassifier.model')
421
+ }
422
+ cl.requestTimeoutMs = clampInt(cl.requestTimeoutMs, 1_000, 120_000, 10_000)
423
+ return cl
424
+ }
425
+
426
+ function normalizeChain(chain) {
427
+ if (chain === undefined || chain === null) return []
428
+ if (!Array.isArray(chain)) {
429
+ throw new Error('mydsh-auto-model-router: "fallbackChain" must be an array of provider/model routes')
430
+ }
431
+ return chain.map((route, index) => normalizeRoute(route, `fallbackChain[${index}]`))
432
+ }
433
+
434
+ /**
435
+ * Level helper: the level index 0…3 for a LEVEL_IDS entry.
436
+ * @param {string} level
437
+ * @returns {number} 0-based index.
438
+ */
439
+ export function levelIndex(level) {
440
+ return LEVEL_IDS.indexOf(level)
441
+ }
442
+
443
+ /**
444
+ * Clamp a level id by at most one step in a direction, staying inside L0…L3.
445
+ * @param {string} level - starting level.
446
+ * @param {'up'|'down'|0} direction - drift direction.
447
+ * @returns {string}
448
+ */
449
+ export function driftLevel(level, direction) {
450
+ const index = levelIndex(level)
451
+ if (index === -1) return level
452
+ if (direction === 'up') return LEVEL_IDS[Math.min(3, index + 1)]
453
+ if (direction === 'down') return LEVEL_IDS[Math.max(0, index - 1)]
454
+ return level
455
+ }
456
+
457
+ function clampInt(value, minimum, maximum, fallback) {
458
+ const number = Math.round(Number(value))
459
+ if (!Number.isFinite(number)) return fallback
460
+ return Math.max(minimum, Math.min(maximum, number))
461
+ }
@@ -0,0 +1,11 @@
1
+ # mydsh-auto-model-router — the cordis patch layer. Mounts the router inside an
2
+ # isolated group; the runtime registers model-facing hooks on agent scopes.
3
+ - insert:
4
+ - id: mydsh-auto-model-router
5
+ name: '@deepseek-ai/cordis-plugin-group'
6
+ group: true
7
+ isolate:
8
+ modelRouter: true
9
+ config:
10
+ - id: mydsh-auto-model-router-runtime
11
+ name: '@apinggithub/mydsh-auto-model-router'
@@ -0,0 +1,159 @@
1
+ // Cost-control layer (tier 2): the user picks a mode, and that mode decides
2
+ // (a) the default level for unmatched tasks and (b) the drift direction when
3
+ // the router must break a tie (ambiguous classification, budget pressure).
4
+ //
5
+ // cost-first: default to the LOWEST configured level; ambiguity drifts DOWN.
6
+ // quality-first: default to the HIGHEST configured level; ambiguity drifts UP.
7
+ // balanced: default to costControl.defaultLevel; ambiguity stays put.
8
+ //
9
+ // The session token budget forces a pin to the cheap level when exhausted.
10
+
11
+ import { LEVEL_IDS, levelIndex, driftLevel } from './config.mjs'
12
+
13
+ export class CostControl {
14
+ /**
15
+ * @param {object} config - normalized costControl config from resolveConfig.
16
+ * @param {object} levels - normalized levels map from resolveConfig.
17
+ */
18
+ constructor(config, levels) {
19
+ this.config = config
20
+ this.levels = levels
21
+ /** @type {Map<string, {spentTokens: number}>} */
22
+ this.states = new Map()
23
+ }
24
+
25
+ enabled() {
26
+ return this.config.enabled
27
+ }
28
+
29
+ /**
30
+ * The default level for a task that matched no rule.
31
+ * @returns {string} a LEVEL_IDS entry.
32
+ */
33
+ defaultLevel() {
34
+ const mode = this.config.mode
35
+ if (mode === 'cost-first') return this.lowestConfiguredLevel()
36
+ if (mode === 'quality-first') return this.highestConfiguredLevel()
37
+ return this.config.defaultLevel
38
+ }
39
+
40
+ /**
41
+ * Drift a level by one step in the mode's preferred direction when the
42
+ * router faces ambiguity (classifier returned a level but with low
43
+ * confidence, or a level with no configured model).
44
+ * @param {string} level
45
+ * @param {boolean} [ambiguous=true]
46
+ * @returns {string}
47
+ */
48
+ resolveAmbiguous(level, ambiguous = true) {
49
+ if (!ambiguous) return level
50
+ const mode = this.config.mode
51
+ if (mode === 'cost-first') return driftLevel(level, 'down')
52
+ if (mode === 'quality-first') return driftLevel(level, 'up')
53
+ return level
54
+ }
55
+
56
+ /**
57
+ * Whether a session has exhausted its budget and should be pinned to the
58
+ * cheap level for the rest of the session.
59
+ * @param {string} sessionId
60
+ * @param {number} [measuredTokens]
61
+ * @returns {boolean}
62
+ */
63
+ isOverBudget(sessionId, measuredTokens) {
64
+ if (!this.config.enabled) return false
65
+ // 0 = no budget limit.
66
+ if (this.config.tokenBudgetPerSession <= 0) return false
67
+ const state = this.states.get(sessionId)
68
+ if (!state) return false
69
+ const spent = measuredTokens ?? state.spentTokens
70
+ return spent >= this.config.tokenBudgetPerSession
71
+ }
72
+
73
+ /**
74
+ * Record token spend for one session.
75
+ * @param {string} sessionId
76
+ * @param {number} tokens
77
+ */
78
+ addSpend(sessionId, tokens) {
79
+ if (!this.config.enabled || !Number.isFinite(tokens) || tokens <= 0) return
80
+ const state = this.stateFor(sessionId)
81
+ state.spentTokens += Math.round(tokens)
82
+ }
83
+
84
+ /**
85
+ * Read the current spend for a session.
86
+ * @param {string} sessionId
87
+ * @returns {number}
88
+ */
89
+ spentTokens(sessionId) {
90
+ return this.states.get(sessionId)?.spentTokens ?? 0
91
+ }
92
+
93
+ /** Forget all per-session state (e.g. between runs in tests). */
94
+ reset() {
95
+ this.states.clear()
96
+ }
97
+
98
+ /** Delete one session's state (on session-start). */
99
+ resetSession(sessionId) {
100
+ this.states.delete(sessionId)
101
+ }
102
+
103
+ lowestConfiguredLevel() {
104
+ for (const id of LEVEL_IDS) {
105
+ if (this.levels[id]) return id
106
+ }
107
+ return this.config.defaultLevel
108
+ }
109
+
110
+ highestConfiguredLevel() {
111
+ for (let i = LEVEL_IDS.length - 1; i >= 0; i--) {
112
+ if (this.levels[LEVEL_IDS[i]]) return LEVEL_IDS[i]
113
+ }
114
+ return this.config.defaultLevel
115
+ }
116
+
117
+ /**
118
+ * The cheap level the budget pin uses: the lowest level with a model.
119
+ * @returns {string}
120
+ */
121
+ cheapLevel() {
122
+ return this.lowestConfiguredLevel()
123
+ }
124
+
125
+ stateFor(sessionId) {
126
+ let state = this.states.get(sessionId)
127
+ if (!state) {
128
+ state = { spentTokens: 0 }
129
+ this.states.set(sessionId, state)
130
+ }
131
+ return state
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Estimate tokens for a message the cheap way (chars/4, CJK at 1.5/char)
137
+ * used only when ctx.tokenMeter is absent.
138
+ * @param {object} message
139
+ * @returns {number}
140
+ */
141
+ export function estimateMessageTokens(message) {
142
+ const text = textOf(message)
143
+ let cjk = 0
144
+ for (let i = 0; i < text.length; i++) {
145
+ if (text.charCodeAt(i) >= 0x3000) cjk++
146
+ }
147
+ return Math.ceil(cjk * 1.5 + (text.length - cjk) / 4)
148
+ }
149
+
150
+ function textOf(message) {
151
+ const content = message?.content
152
+ if (typeof content === 'string') return content
153
+ if (Array.isArray(content)) {
154
+ return content
155
+ .map((block) => (typeof block?.text === 'string' ? block.text : ''))
156
+ .join('\n')
157
+ }
158
+ return ''
159
+ }
package/fallback.mjs ADDED
@@ -0,0 +1,62 @@
1
+ // Fallback layer (tier 4): on agent/request-error, walk the fallback chain
2
+ // and retry with the next route. The chain is appended with the router's
3
+ // default route (the cost default level's model) when the user does not list
4
+ // it explicitly, so a fully custom chain still ends at a sane terminal route.
5
+
6
+ import { routesEqual } from './router.mjs'
7
+
8
+ export class FallbackChain {
9
+ /**
10
+ * @param {object[]} fallbackChain - normalized fallbackChain from resolveConfig.
11
+ * @param {object} defaultRoute - the router's default route to append.
12
+ * @param {number} maxRetries - per-route retry budget on request-error.
13
+ */
14
+ constructor(fallbackChain, defaultRoute, maxRetries = 1) {
15
+ this.config = { maxRetries }
16
+ this.chain = buildChain(fallbackChain ?? [], defaultRoute)
17
+ }
18
+
19
+ /**
20
+ * Find the next route after `current` in the chain, wrapping around to the
21
+ * head after the tail. Returns null when the chain has < 2 distinct routes
22
+ * (nothing to fall back to).
23
+ * @param {object} current - { provider, model }.
24
+ * @returns {object | null}
25
+ */
26
+ nextAfter(current) {
27
+ if (this.chain.length < 2) return null
28
+ const index = this.chain.findIndex((route) => routesEqual(route, current))
29
+ if (index === -1) return this.chain[0]
30
+ return this.chain[(index + 1) % this.chain.length]
31
+ }
32
+
33
+ /**
34
+ * Whether retrying makes sense after a failure on `current` given how many
35
+ * retries this session already burned on this route.
36
+ * @param {object} current
37
+ * @param {number} retriesOnRoute
38
+ * @returns {boolean}
39
+ */
40
+ canRetry(current, retriesOnRoute) {
41
+ if (retriesOnRoute >= this.config?.maxRetries ?? 1) return false
42
+ const next = this.nextAfter(current)
43
+ return next !== null && !routesEqual(next, current)
44
+ }
45
+ }
46
+
47
+ function buildChain(fallbackChain, defaultRoute) {
48
+ const chain = [...fallbackChain]
49
+ // Ensure a terminal member so a custom chain that omits the default still
50
+ // ends at the router's default route.
51
+ if (!chain.some((route) => routesEqual(route, defaultRoute))) {
52
+ chain.push(defaultRoute)
53
+ }
54
+ // De-duplicate consecutive identical routes (harmless but noisy).
55
+ const unique = []
56
+ for (const route of chain) {
57
+ if (route && (unique.length === 0 || !routesEqual(unique[unique.length - 1], route))) {
58
+ unique.push(route)
59
+ }
60
+ }
61
+ return unique
62
+ }