@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.
@@ -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
+ }
package/index.mjs ADDED
@@ -0,0 +1,307 @@
1
+ // mydsh-auto-model-router plugin entry. Registers the routing layers onto the
2
+ // DSH agent lifecycle:
3
+ //
4
+ // agent/pre-step → token accounting + user-input boundary detection
5
+ // system-prompt/assemble → keep {{model}} consistent with routing
6
+ // agent/request → reuse the per-input decision (no re-judging per step)
7
+ // agent/request-error → fallback chain (retry with next route)
8
+ // session/event → token spend tracking
9
+ // agent/session-start → per-session state reset + one-time status
10
+ // report & cost-mode question (no client UI)
11
+ //
12
+ // A NEW user input is detected when the last user message's text changes
13
+ // between requests. Only then does the router make a fresh decision (heuristic
14
+ // lock / fixed model / fully-auto). All agent/request steps within the same
15
+ // user input reuse that decision — the LLM classifier is never called per step.
16
+ //
17
+ // The plugin also registers a virtual `auto` provider adapter so the model
18
+ // selector shows an "Auto Router" entry. When the user picks Auto, this
19
+ // plugin routes the session; any real provider/model picked by the user passes
20
+ // through untouched.
21
+
22
+ import { resolveConfig } from './config.mjs'
23
+ import { ModelRouter } from './router.mjs'
24
+ import { AutoAdapter, isAutoSelection } from './auto-adapter.mjs'
25
+ import {
26
+ reportStatus, persistStatusReport, statusReport, notifyDowngrade,
27
+ askDowngrade, resolveLocale, askModeSelection, askFixedLevel,
28
+ notifyFreeQuotaFallback,
29
+ } from './notify.mjs'
30
+
31
+ export const name = 'mydsh-auto-model-router'
32
+ export const inject = ['agents', 'sessions', 'tools', 'llm', 'tokenMeter']
33
+
34
+ export function apply(ctx, input = {}) {
35
+ const config = resolveConfig(input)
36
+ const router = new ModelRouter(config, {
37
+ llm: ctx.llm,
38
+ tokenMeter: ctx.tokenMeter,
39
+ })
40
+ ctx.provide('modelRouter', router)
41
+ // Global backstop so the virtual auto adapter can forward a request that
42
+ // reaches it without an agent/request interception.
43
+ globalThis.__dshAutoModelRouter = router
44
+ globalThis.__dshLl = ctx.llm
45
+ ctx.effect(
46
+ () => () => {
47
+ router.cost.reset()
48
+ delete globalThis.__dshAutoModelRouter
49
+ delete globalThis.__dshLl
50
+ },
51
+ 'modelRouter.reset()',
52
+ )
53
+
54
+ // Register the virtual "auto" provider so the model selector shows it.
55
+ const autoRegistration = ctx.llm?.registerAdapter?.(
56
+ ['auto'],
57
+ AutoAdapter,
58
+ )
59
+ ctx.effect(
60
+ () => () => autoRegistration?.(),
61
+ 'modelRouter.unregisterAutoAdapter()',
62
+ )
63
+
64
+ // ── catalog freshness: read provider catalogs at activation, then poll ──
65
+ // A retired model id makes its level stale; the router then drifts around it
66
+ // and the status report names same-family candidates for the one-line fix.
67
+ ctx.effect(() => {
68
+ if (typeof ctx.llm?.listModels !== 'function') return undefined
69
+ void router.refreshCatalogs()
70
+ const timer = setInterval(() => void router.refreshCatalogs(), config.modelsGuard.refreshMs)
71
+ timer.unref?.()
72
+ return () => clearInterval(timer)
73
+ }, 'modelRouter.catalogWatch()')
74
+
75
+ // ── HTTP status endpoint for the browser settings section ─────────────
76
+ // The client fetch()es /api/mydsh-auto-model-router/status to render the
77
+ // current routing policy (no localStorage / DOM-transcript dependency).
78
+ //
79
+ // The webserver is claimed through a *scoped injection* instead of a hard
80
+ // `inject` entry: headless deployments have no webserver and must still
81
+ // route. A plain `ctx.get('webServer')` is not enough — at boot the service
82
+ // is not up yet, so the lookup returns nothing, the registration is skipped,
83
+ // and the exact route is never claimed (requests then fall through to the
84
+ // `/api` prefix owner and answer 401). `ctx.inject` runs the callback when —
85
+ // and only when — the service exists.
86
+ const statusRoute = {
87
+ kind: 'exact',
88
+ path: '/api/mydsh-auto-model-router/status',
89
+ handler: (_req, res) => {
90
+ const body = JSON.stringify({
91
+ ok: true,
92
+ report: statusReport(config, resolveLocale(ctx), { stale: router.staleLevels() }),
93
+ levels: config.levels,
94
+ costControl: config.costControl,
95
+ freeQuota: config.freeQuota,
96
+ contextGuard: config.contextGuard,
97
+ modelsGuard: config.modelsGuard,
98
+ staleLevels: router.staleLevels(),
99
+ rules: config.rules,
100
+ scoring: config.scoring,
101
+ fallbackChain: config.fallbackChain,
102
+ })
103
+ res.writeHead(200, {
104
+ 'content-type': 'application/json; charset=utf-8',
105
+ 'cache-control': 'no-cache',
106
+ })
107
+ res.end(body)
108
+ },
109
+ }
110
+ const registerStatusRoute = (scope) => scope.effect(
111
+ () => scope.webServer.register(statusRoute),
112
+ 'modelRouter.statusEndpoint()',
113
+ )
114
+ if (typeof ctx.inject === 'function') {
115
+ ctx.inject(['webServer'], registerStatusRoute)
116
+ } else {
117
+ // Fallback for contexts without scoped injection (tests, older hosts).
118
+ const webServer = ctx.get?.('webServer')
119
+ if (webServer?.register !== undefined) {
120
+ registerStatusRoute({ effect: (fn) => fn(), webServer })
121
+ }
122
+ }
123
+
124
+ // ── session start: fresh routing state per session ─────────────────────
125
+ // Injects the status report into the session (visible, non-blocking) but
126
+ // never pops a dialog — configuration changes are guided through the
127
+ // Auto Router settings section instead.
128
+ ctx.on('agent/session-start', async ({ agent }) => {
129
+ router.resetSession(agent.session.id)
130
+ if (agent.status === 'idle') {
131
+ const locale = resolveLocale(ctx)
132
+ persistStatusReport(config, locale)
133
+ await reportStatus(agent, config, locale)
134
+ }
135
+ return true
136
+ })
137
+
138
+ // ── pre-step: token accounting + cache the latest user input ──────────
139
+ // The latest user message text changes only here (a new user input); every
140
+ // subsequent agent/request step of the SAME input reuses this cached text,
141
+ // so we never re-scan the full derived message history per step.
142
+ ctx.on('agent/pre-step', async ({ agent, messages, signal }, next) => {
143
+ const decision = await next()
144
+ if (decision.kind !== 'enter' || signal.aborted) return decision
145
+ const last = messages.at(-1)
146
+ if (last) router.recordTokens(agent.session.id, last)
147
+ // Cache the latest user text ONLY when the newest message is a user
148
+ // message — that is the event that opens a new user input.
149
+ if (last?.role === 'user') {
150
+ const text = messageText(last)
151
+ if (text !== '') router.rememberInput(agent.session.id, text)
152
+ }
153
+ return decision
154
+ })
155
+
156
+ // ── prompt assembly: keep {{model}} consistent with the routed model ────
157
+ ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
158
+ const assembled = await next()
159
+ const sessionId = assembly.variables?.sessionId
160
+ if (!sessionId) return assembled
161
+ if (!router.isAutoActive(sessionId)) return assembled
162
+ const route = router.current(sessionId)
163
+ return {
164
+ ...assembled,
165
+ variables: {
166
+ ...assembled.variables,
167
+ provider: route.provider,
168
+ model: route.model,
169
+ },
170
+ }
171
+ })
172
+
173
+ // ── ★ agent/request: reuse the per-input decision ──────────────────────
174
+ // Only a NEW user input triggers a fresh decision (and only when the
175
+ // session picked the virtual auto provider). Steps within the same input
176
+ // reuse state.current — no per-step re-judging, no per-step classifier.
177
+ ctx.on('agent/request', async ({ agent }, next) => {
178
+ const proposed = await next()
179
+ const sessionId = agent.session.id
180
+ ctx.logger?.info?.(
181
+ `[mydsh-auto-model-router] agent/request session=${sessionId} proposed=${proposed?.provider}/${proposed?.model} isAuto=${isAutoSelection(proposed)}`,
182
+ )
183
+ if (!isAutoSelection(proposed)) {
184
+ router.markAutoInactive(sessionId)
185
+ return proposed
186
+ }
187
+ router.markAutoActive(sessionId)
188
+
189
+ // Use the cached latest user input (set in agent/pre-step) — do NOT
190
+ // re-scan the full derived message history on every step.
191
+ const input = router.currentInput(sessionId) ?? ''
192
+ const decision = await router.decideOnInput(sessionId, {
193
+ inputText: input,
194
+ prevInputText: router.lastInputText(sessionId) ?? '',
195
+ lastReplyText: router.lastReplyText(sessionId) ?? '',
196
+ })
197
+
198
+ if (decision?.needsAsk) {
199
+ const locale = resolveLocale(ctx)
200
+ if (decision.needsAsk === 'above-lock') {
201
+ // Ask: 固定模型 or 开启全自动.
202
+ const picked = await askModeSelection(ctx, agent, router, sessionId)
203
+ if (picked?.kind === 'fixed') {
204
+ router.setMode(sessionId, 'fixed', { fixedLevel: picked.level })
205
+ } else if (picked?.kind === 'auto') {
206
+ router.setMode(sessionId, 'auto')
207
+ }
208
+ // fallback: user dismissed → stay in heuristic mode.
209
+ const route = router.current(sessionId)
210
+ return { ...proposed, provider: route.provider, model: route.model,
211
+ ...route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort } }
212
+ }
213
+ if (decision.needsAsk === 'fixed-reelect') {
214
+ const picked = await askFixedLevel(ctx, agent, router, sessionId)
215
+ if (picked) router.setMode(sessionId, 'fixed', { fixedLevel: picked })
216
+ const route = router.current(sessionId)
217
+ return { ...proposed, provider: route.provider, model: route.model,
218
+ ...route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort } }
219
+ }
220
+ }
221
+
222
+ const route = router.current(sessionId)
223
+ const downgraded = router.consumeDowngradeFlag(sessionId)
224
+ if (downgraded) {
225
+ const behavior = config.costControl.downgradeBehavior
226
+ const locale = resolveLocale(ctx)
227
+ if (behavior === 'notify') {
228
+ await notifyDowngrade(agent, config, sessionId, locale)
229
+ } else if (behavior === 'ask') {
230
+ const keep = await askDowngrade(ctx, agent, router, sessionId)
231
+ if (keep) router.waiveBudget(sessionId)
232
+ }
233
+ }
234
+ // A free route that burned its quota earlier in this session was replaced
235
+ // by a paid one while routing — tell the user once.
236
+ const demotion = router.consumeFreeDemotionFlag(sessionId)
237
+ if (demotion && config.freeQuota.notify) {
238
+ await notifyFreeQuotaFallback(agent, sessionId, demotion, resolveLocale(ctx))
239
+ }
240
+ return {
241
+ ...proposed,
242
+ provider: route.provider,
243
+ model: route.model,
244
+ // Levels always carry an explicit reasoningEffort (default 'off'), so
245
+ // the routed effort is always stated — including 'off' for no thinking.
246
+ reasoningEffort: route.reasoningEffort,
247
+ }
248
+ })
249
+
250
+ // ── agent/request-error: fallback chain (auto mode only) ────────────────
251
+ // A free-quota route that fails (quota exhausted, rate limited, rejected) is
252
+ // demoted for the rest of the session: the retry runs on the next viable
253
+ // route, and every later decision for that level skips the burned route.
254
+ ctx.on('agent/request-error', async ({ agent, failure }, next) => {
255
+ const sessionId = agent.session.id
256
+ if (!router.isAutoActive(sessionId)) return next()
257
+ const failedRoute = router.current(sessionId)
258
+ const burned = config.freeQuota.enabled && config.freeQuota.demoteOnError
259
+ ? router.markFreeExhausted(sessionId, failedRoute)
260
+ : false
261
+ const fallback = router.nextOnFailure(sessionId, failedRoute)
262
+ if (fallback) {
263
+ ctx.logger?.info?.(
264
+ `[mydsh-auto-model-router] ${sessionId}: request failed (${failure?.error?.code ?? 'unknown'})`
265
+ + `${burned ? ' on a free route, which is now demoted for this session' : ''},`
266
+ + ` falling back to ${fallback.provider}/${fallback.model}`,
267
+ )
268
+ // The user-visible notice is owned by the routing path, which knows the
269
+ // level and the paid replacement: announcing it here too would report the
270
+ // same demotion twice.
271
+ return { kind: 'retry' }
272
+ }
273
+ return next()
274
+ })
275
+
276
+ // ── session/event: track spend from assistant messages ──────────────────
277
+ ctx.on('session/event', (session, event) => {
278
+ if (event.type === 'assistant/message') {
279
+ router.recordTokens(session.id, event.data?.message)
280
+ // The input side of the request that produced this message is the best
281
+ // context-size signal the context guard has.
282
+ router.recordUsage(session.id, event.data?.usage)
283
+ }
284
+ if (event.type === 'message') {
285
+ const message = event.data?.message
286
+ if (message?.role === 'assistant' && Array.isArray(message.content)) {
287
+ const text = message.content
288
+ .map((block) => (typeof block?.text === 'string' ? block.text : ''))
289
+ .join('\n')
290
+ if (text) router.rememberReply(session.id, text)
291
+ }
292
+ }
293
+ })
294
+ }
295
+
296
+ /** Extract the text content of a single message. */
297
+ function messageText(message) {
298
+ const content = message?.content
299
+ if (typeof content === 'string') return content.trim()
300
+ if (Array.isArray(content)) {
301
+ return content
302
+ .map((block) => (typeof block?.text === 'string' ? block.text : ''))
303
+ .join('\n')
304
+ .trim()
305
+ }
306
+ return ''
307
+ }