@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/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
|
+
}
|
package/cordis.patch.yml
ADDED
|
@@ -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'
|
package/cost-control.mjs
ADDED
|
@@ -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
|
+
}
|