@apinggithub/mydsh-auto-model-router 0.0.0-stage → 0.2.0

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/client.js ADDED
@@ -0,0 +1,123 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@apinggithub/mydsh-auto-model-router",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ var _react = require("react");
8
+ //#region client/gate.mjs
9
+ const PLUGIN_ID = '@apinggithub/mydsh-auto-model-router'
10
+ const NS = 'mydsh-auto-model-router'
11
+
12
+ const EN_COPY = {
13
+ nav: 'Auto Router',
14
+ title: 'Auto Model Router',
15
+ description: 'Routes each user input through the configured L0–L3 levels.',
16
+ hintTitle: 'How to change this configuration',
17
+ hintBody: 'Tell the AI in conversation, e.g. "change L2 to deepseek-v4-pro with '
18
+ + 'max reasoning" or "switch cost mode to cost-first". The AI edits '
19
+ + 'cordis.patch.yml; restart DSH to apply.',
20
+ noReport: 'No router status report found yet. Send a message in a session '
21
+ + 'first — the host injects the current policy on session start.',
22
+ reportLabel: 'Current policy',
23
+ }
24
+
25
+ const ZH_COPY = {
26
+ nav: 'Auto Router',
27
+ title: 'Auto 模型路由',
28
+ description: '按配置的 L0–L3 层级,为每次用户输入自动选择模型。',
29
+ hintTitle: '如何修改配置',
30
+ hintBody: '直接在对话中告诉 AI,例如:"把 L2 改成 deepseek-v4-pro 且 max 思考"'
31
+ + '或"成本模式切换为 cost-first"。AI 会帮你编辑 cordis.patch.yml,'
32
+ + '重启 DSH 后生效。',
33
+ noReport: '尚未找到路由状态报告。先在会话中发一条消息——host 会在'
34
+ + '会话开始时注入当前策略。',
35
+ reportLabel: '当前策略',
36
+ }
37
+
38
+ const copy = () => {
39
+ const lang = document.documentElement.lang || navigator.language || ''
40
+ return /^zh(?:-|$)/i.test(lang) ? ZH_COPY : EN_COPY
41
+ }
42
+
43
+ const STATUS_ENDPOINT = '/api/mydsh-auto-model-router/status'
44
+
45
+ /**
46
+ * The settings section component. Rendered with _react.createElement from the
47
+ * module-table react entry; no JSX build step. Fetches the current policy
48
+ * from the host HTTP endpoint on mount.
49
+ */
50
+ function RouterSection() {
51
+ const t = copy()
52
+ const styles = {
53
+ section: {
54
+ display: 'flex', flexDirection: 'column', gap: '16px',
55
+ fontSize: '14px', lineHeight: '22px',
56
+ },
57
+ title: { margin: 0, fontSize: '16px', fontWeight: 600 },
58
+ description: { margin: 0, color: 'var(--dsw-alias-label-secondary, #666)' },
59
+ block: {
60
+ margin: 0, padding: '12px 14px',
61
+ border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
62
+ borderRadius: '12px',
63
+ background: 'var(--dsw-alias-bg-layer-1, rgba(0,0,0,0.03))',
64
+ font: '12px/1.7 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
65
+ whiteSpace: 'pre-wrap', wordBreak: 'break-word',
66
+ },
67
+ hint: {
68
+ margin: 0, padding: '10px 12px',
69
+ borderLeft: '3px solid var(--dsw-alias-state-info-primary, #2b7fff)',
70
+ background: 'var(--dsw-alias-bg-layer-1, rgba(0,0,0,0.03))',
71
+ },
72
+ hintTitle: { margin: '0 0 4px', fontWeight: 600 },
73
+ hintBody: { margin: 0 },
74
+ }
75
+ const h = (tag, props, ...children) => _react.createElement(tag, props ?? {}, ...children)
76
+
77
+ const [report, setReport] = _react.useState(null)
78
+ _react.useEffect(() => {
79
+ let cancelled = false
80
+ fetch(STATUS_ENDPOINT, { cache: 'no-store' })
81
+ .then((response) => (response.ok ? response.json() : null))
82
+ .then((data) => {
83
+ if (cancelled || data?.report === undefined) return
84
+ setReport(data.report)
85
+ })
86
+ .catch(() => { /* offline / no host route: keep "no report" */ })
87
+ return () => { cancelled = true }
88
+ }, [])
89
+
90
+ return h('div', { style: styles.section },
91
+ h('h3', { style: styles.title }, t.title),
92
+ h('p', { style: styles.description }, t.description),
93
+ h('p', { style: styles.block },
94
+ report === null ? t.noReport : `【${t.reportLabel}】\n${report}`),
95
+ h('div', { style: styles.hint },
96
+ h('p', { style: styles.hintTitle }, t.hintTitle),
97
+ h('p', { style: styles.hintBody }, t.hintBody)),
98
+ )
99
+ }
100
+
101
+ const name = PLUGIN_ID
102
+ const inject = ['slots', 'locale']
103
+
104
+ function apply(ctx) {
105
+ const t = () => copy()
106
+ ctx.effect(() => {
107
+ return ctx.slots.inject('settings.section', () => ctx.slots.register({
108
+ name: 'settings.section',
109
+ id: 'auto-router',
110
+ order: 60,
111
+ label: () => t().nav,
112
+ locale: NS,
113
+ }, RouterSection))
114
+ }, 'mydsh-auto-model-router: settings section')
115
+ }
116
+ //#endregion
117
+ //#region client/index.mjs
118
+
119
+ //#endregion
120
+ module.exports = { name, inject, apply };
121
+ return module.exports;
122
+ }
123
+ });
package/config.mjs ADDED
@@ -0,0 +1,458 @@
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.1.0'
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
+ return cfg
154
+ }
155
+
156
+ /**
157
+ * Normalize the context-window guard. `headroom` is clamped into (0, 1].
158
+ */
159
+ function normalizeContextGuard(contextGuard) {
160
+ const guard = {
161
+ ...DEFAULT_CONFIG.contextGuard,
162
+ ...(contextGuard ?? {}),
163
+ }
164
+ guard.enabled = guard.enabled !== false
165
+ let headroom = Number(guard.headroom)
166
+ if (!Number.isFinite(headroom) || headroom <= 0 || headroom > 1) {
167
+ headroom = DEFAULT_CONFIG.contextGuard.headroom
168
+ }
169
+ guard.headroom = headroom
170
+ return guard
171
+ }
172
+
173
+ /**
174
+ * Normalize the catalog freshness guard. `refreshMs` is clamped to a sane
175
+ * range so a typo cannot turn the periodic re-check into a hot loop.
176
+ */
177
+ function normalizeModelsGuard(modelsGuard) {
178
+ const guard = {
179
+ ...DEFAULT_CONFIG.modelsGuard,
180
+ ...(modelsGuard ?? {}),
181
+ }
182
+ guard.enabled = guard.enabled !== false
183
+ guard.refreshMs = clampInt(guard.refreshMs, 30_000, 24 * 60 * 60 * 1000, DEFAULT_CONFIG.modelsGuard.refreshMs)
184
+ return guard
185
+ }
186
+
187
+ /**
188
+ * Normalize the free-quota layer. `routes` entries are provider/model pairs
189
+ * with an optional inclusive `maxLevel` ceiling.
190
+ */
191
+ function normalizeFreeQuota(freeQuota) {
192
+ const fq = {
193
+ ...DEFAULT_CONFIG.freeQuota,
194
+ ...(freeQuota ?? {}),
195
+ }
196
+ fq.enabled = fq.enabled !== false
197
+ fq.demoteOnError = fq.demoteOnError !== false
198
+ fq.notify = fq.notify !== false
199
+ if (!Array.isArray(fq.routes)) {
200
+ throw new Error('mydsh-auto-model-router: freeQuota.routes must be an array of provider/model routes')
201
+ }
202
+ fq.routes = fq.routes.map((route, index) => {
203
+ const where = `freeQuota.routes[${index}]`
204
+ const normalized = normalizeRoute(route, where)
205
+ const maxLevel = route.maxLevel === undefined || route.maxLevel === null
206
+ ? DEFAULT_FREE_MAX_LEVEL
207
+ : String(route.maxLevel).trim()
208
+ if (!LEVEL_IDS.includes(maxLevel)) {
209
+ throw new Error(
210
+ `mydsh-auto-model-router: ${where}.maxLevel must be one of ${LEVEL_IDS.join(', ')}, got "${maxLevel}"`,
211
+ )
212
+ }
213
+ // Ride along with an explicit effort, exactly like a level route, so the
214
+ // dispatched request never falls back to the adapter's own default
215
+ // thinking level by accident.
216
+ return {
217
+ ...normalized,
218
+ reasoningEffort: normalized.reasoningEffort ?? 'off',
219
+ maxLevel,
220
+ }
221
+ })
222
+ return fq
223
+ }
224
+
225
+ function normalizeHeuristic(heuristic) {
226
+ const h = {
227
+ ...DEFAULT_CONFIG.heuristic,
228
+ ...(heuristic ?? {}),
229
+ }
230
+ h.windowSize = clampInt(h.windowSize, 1, 20, DEFAULT_CONFIG.heuristic.windowSize)
231
+ if (!COUNTING_MODES.includes(h.counting)) {
232
+ throw new Error(
233
+ `mydsh-auto-model-router: heuristic.counting must be one of ${COUNTING_MODES.join(', ')}, got "${h.counting}"`,
234
+ )
235
+ }
236
+ if (!Array.isArray(h.thresholds) || h.thresholds.length === 0) {
237
+ throw new Error('mydsh-auto-model-router: heuristic.thresholds must be a non-empty array')
238
+ }
239
+ h.thresholds = h.thresholds.map((t, index) => {
240
+ if (!LEVEL_IDS.includes(t.level)) {
241
+ throw new Error(`mydsh-auto-model-router: heuristic.thresholds[${index}].level must be one of ${LEVEL_IDS.join(', ')}`)
242
+ }
243
+ const maxChars = t.maxChars === null || t.maxChars === undefined
244
+ ? null
245
+ : clampInt(t.maxChars, 0, 1_000_000, 0)
246
+ return { maxChars, level: t.level }
247
+ })
248
+ return h
249
+ }
250
+
251
+ function normalizeScoring(scoring) {
252
+ const s = {
253
+ ...DEFAULT_CONFIG.scoring,
254
+ ...(scoring ?? {}),
255
+ }
256
+ const weights = {}
257
+ for (const id of LEVEL_IDS) {
258
+ const value = Number(s.weights?.[id])
259
+ weights[id] = Number.isFinite(value) && value > 0 ? value : (id === 'L0' ? 0 : 1)
260
+ }
261
+ s.weights = weights
262
+ if (!Array.isArray(s.bands) || s.bands.length === 0) {
263
+ throw new Error('mydsh-auto-model-router: scoring.bands must be a non-empty array')
264
+ }
265
+ s.bands = s.bands.map((b, index) => {
266
+ if (!LEVEL_IDS.includes(b.level)) {
267
+ throw new Error(`mydsh-auto-model-router: scoring.bands[${index}].level must be one of ${LEVEL_IDS.join(', ')}`)
268
+ }
269
+ return {
270
+ maxScore: b.maxScore === null || b.maxScore === undefined
271
+ ? null
272
+ : clampInt(b.maxScore, 0, 1_000_000, 0),
273
+ level: b.level,
274
+ }
275
+ })
276
+ return s
277
+ }
278
+
279
+ /**
280
+ * Every level may be omitted except the cost-control default level, which must
281
+ * exist. Returns a level map keyed by LEVEL_IDS with undefined holes. Each
282
+ * configured level carries an explicit reasoningEffort (default 'off').
283
+ */
284
+ function normalizeLevels(levels) {
285
+ const source = levels ?? {}
286
+ const out = {}
287
+ for (const id of LEVEL_IDS) {
288
+ const route = source[id]
289
+ out[id] = route === undefined || route === null
290
+ ? undefined
291
+ : normalizeLevelRoute(route, `levels.${id}`)
292
+ }
293
+ return out
294
+ }
295
+
296
+ function normalizeLevelRoute(route, where) {
297
+ const normalized = normalizeRoute(route, where)
298
+ // Levels always carry an explicit reasoning effort; absent → 'off' (no
299
+ // thinking mode) so every level spells out its thinking intensity.
300
+ const contextWindow = route.contextWindow === undefined || route.contextWindow === null
301
+ ? undefined
302
+ : Number(route.contextWindow)
303
+ if (contextWindow !== undefined && (!Number.isSafeInteger(contextWindow) || contextWindow <= 0)) {
304
+ throw new Error(`mydsh-auto-model-router: "${where}.contextWindow" must be a positive integer, got ${JSON.stringify(route.contextWindow)}`)
305
+ }
306
+ return {
307
+ ...normalized,
308
+ reasoningEffort: normalized.reasoningEffort ?? 'off',
309
+ // A level may declare that its route is a free-quota route. The flag rides
310
+ // along with the route so the router can prefer/demote it.
311
+ ...route.freeQuota === true ? { freeQuota: true } : {},
312
+ // A level may declare its model's context window for the guard.
313
+ ...contextWindow !== undefined ? { contextWindow } : {},
314
+ }
315
+ }
316
+
317
+ function normalizeRoute(route, where) {
318
+ if (route === undefined || route === null) {
319
+ throw new Error(`mydsh-auto-model-router: "${where}" requires a provider/model route`)
320
+ }
321
+ const provider = String(route.provider ?? '').trim()
322
+ const model = String(route.model ?? '').trim()
323
+ if (!provider || !model) {
324
+ throw new Error(
325
+ `mydsh-auto-model-router: "${where}" needs both provider and model, got ${JSON.stringify(route)}`,
326
+ )
327
+ }
328
+ const reasoningEffort = route.reasoningEffort === undefined
329
+ ? undefined
330
+ : String(route.reasoningEffort).trim() || undefined
331
+ return {
332
+ provider,
333
+ model,
334
+ ...reasoningEffort === undefined ? {} : { reasoningEffort },
335
+ }
336
+ }
337
+
338
+ function normalizeRules(rules) {
339
+ if (!Array.isArray(rules)) {
340
+ throw new Error('mydsh-auto-model-router: "rules" must be an array')
341
+ }
342
+ return rules.map((rule, index) => {
343
+ if (typeof rule.match !== 'string' || rule.match.trim() === '') {
344
+ throw new Error(`mydsh-auto-model-router: rules[${index}] needs a non-empty "match" pattern`)
345
+ }
346
+ if (!LEVEL_IDS.includes(rule.level)) {
347
+ throw new Error(
348
+ `mydsh-auto-model-router: rules[${index}].level must be one of ${LEVEL_IDS.join(', ')}, got "${rule.level}"`,
349
+ )
350
+ }
351
+ return {
352
+ match: rule.match.trim(),
353
+ regex: compilePattern(rule.match.trim(), index),
354
+ level: rule.level,
355
+ }
356
+ })
357
+ }
358
+
359
+ function compilePattern(pattern, index) {
360
+ // A pattern surrounded by slashes is treated as a RegExp literal,
361
+ // anything else as a plain substring (case-insensitive).
362
+ const trimmed = pattern.trim()
363
+ if (trimmed.startsWith('/') && trimmed.lastIndexOf('/') > 0) {
364
+ const lastSlash = trimmed.lastIndexOf('/')
365
+ const body = trimmed.slice(1, lastSlash)
366
+ const flags = trimmed.slice(lastSlash + 1)
367
+ try {
368
+ return new RegExp(body, flags.includes('i') ? flags : `${flags}i`)
369
+ } catch (error) {
370
+ throw new Error(
371
+ `mydsh-auto-model-router: rules[${index}] has an invalid regex "${pattern}": ${error instanceof Error ? error.message : String(error)}`,
372
+ )
373
+ }
374
+ }
375
+ return null
376
+ }
377
+
378
+ function normalizeCostControl(costControl) {
379
+ const cc = {
380
+ ...DEFAULT_CONFIG.costControl,
381
+ ...(costControl ?? {}),
382
+ }
383
+ cc.enabled = cc.enabled !== false
384
+ if (!COST_MODES.includes(cc.mode)) {
385
+ throw new Error(
386
+ `mydsh-auto-model-router: costControl.mode must be one of ${COST_MODES.join(', ')}, got "${cc.mode}"`,
387
+ )
388
+ }
389
+ if (!LEVEL_IDS.includes(cc.defaultLevel)) {
390
+ throw new Error(
391
+ `mydsh-auto-model-router: costControl.defaultLevel must be one of ${LEVEL_IDS.join(', ')}, got "${cc.defaultLevel}"`,
392
+ )
393
+ }
394
+ cc.tokenBudgetPerSession = clampInt(
395
+ cc.tokenBudgetPerSession,
396
+ 0, // 0 = no budget limit
397
+ 100_000_000,
398
+ DEFAULT_CONFIG.costControl.tokenBudgetPerSession,
399
+ )
400
+ if (!DOWNGRADE_BEHAVIORS.includes(cc.downgradeBehavior)) {
401
+ throw new Error(
402
+ `mydsh-auto-model-router: costControl.downgradeBehavior must be one of ${DOWNGRADE_BEHAVIORS.join(', ')}, got "${cc.downgradeBehavior}"`,
403
+ )
404
+ }
405
+ return cc
406
+ }
407
+
408
+ function normalizeClassifier(classifier) {
409
+ const cl = {
410
+ ...DEFAULT_CONFIG.llmClassifier,
411
+ ...(classifier ?? {}),
412
+ }
413
+ cl.enabled = cl.enabled === true
414
+ if (cl.enabled) {
415
+ cl.model = cl.model === undefined
416
+ ? undefined
417
+ : normalizeRoute(cl.model, 'llmClassifier.model')
418
+ }
419
+ cl.requestTimeoutMs = clampInt(cl.requestTimeoutMs, 1_000, 120_000, 10_000)
420
+ return cl
421
+ }
422
+
423
+ function normalizeChain(chain) {
424
+ if (chain === undefined || chain === null) return []
425
+ if (!Array.isArray(chain)) {
426
+ throw new Error('mydsh-auto-model-router: "fallbackChain" must be an array of provider/model routes')
427
+ }
428
+ return chain.map((route, index) => normalizeRoute(route, `fallbackChain[${index}]`))
429
+ }
430
+
431
+ /**
432
+ * Level helper: the level index 0…3 for a LEVEL_IDS entry.
433
+ * @param {string} level
434
+ * @returns {number} 0-based index.
435
+ */
436
+ export function levelIndex(level) {
437
+ return LEVEL_IDS.indexOf(level)
438
+ }
439
+
440
+ /**
441
+ * Clamp a level id by at most one step in a direction, staying inside L0…L3.
442
+ * @param {string} level - starting level.
443
+ * @param {'up'|'down'|0} direction - drift direction.
444
+ * @returns {string}
445
+ */
446
+ export function driftLevel(level, direction) {
447
+ const index = levelIndex(level)
448
+ if (index === -1) return level
449
+ if (direction === 'up') return LEVEL_IDS[Math.min(3, index + 1)]
450
+ if (direction === 'down') return LEVEL_IDS[Math.max(0, index - 1)]
451
+ return level
452
+ }
453
+
454
+ function clampInt(value, minimum, maximum, fallback) {
455
+ const number = Math.round(Number(value))
456
+ if (!Number.isFinite(number)) return fallback
457
+ return Math.max(minimum, Math.min(maximum, number))
458
+ }
@@ -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'