@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/LICENSE +21 -0
- package/README.md +456 -2
- package/README.zh.md +413 -0
- package/auto-adapter.mjs +104 -0
- package/classifier.mjs +86 -0
- package/client.js +123 -0
- package/config.mjs +458 -0
- package/cordis.patch.yml +11 -0
- package/cost-control.mjs +159 -0
- package/fallback.mjs +62 -0
- package/index.mjs +307 -0
- package/notify.mjs +653 -0
- package/package.json +70 -4
- package/router.mjs +751 -0
- package/rules.mjs +59 -0
- package/scoring.mjs +69 -0
- package/text-width.mjs +73 -0
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
|
+
}
|
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'
|