@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/index.mjs ADDED
@@ -0,0 +1,429 @@
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
+ // Compatibility posture: a profile's plugin tree is one composition — a bundle
23
+ // whose patch fails can keep the host from starting, and an entry that throws
24
+ // is reported as "did not activate" and silently loses the router. Every host
25
+ // API is therefore probed (see compat.mjs), every per-request path is wrapped
26
+ // so a hostile payload passes the host's proposal through untouched, and the
27
+ // whole plugin mounts in a degraded, logged mode on a host whose surface moved.
28
+
29
+ import { resolveConfig } from './config.mjs'
30
+ import { ModelRouter } from './router.mjs'
31
+ import { createAutoAdapter, isAutoSelection } from './auto-adapter.mjs'
32
+ import {
33
+ reportStatus, persistStatusReport, statusReport, notifyDowngrade,
34
+ askDowngrade, resolveLocale, askModeSelection, askFixedLevel,
35
+ notifyFreeQuotaFallback,
36
+ } from './notify.mjs'
37
+ import {
38
+ TESTED_HOST_RANGE, classifyHostVersion, probeHost, readHostVersion,
39
+ readService, registerRoute, warnOnce,
40
+ } from './compat.mjs'
41
+
42
+ export const name = 'mydsh-auto-model-router'
43
+
44
+ // `tokenMeter` is deliberately not injected: it only feeds token estimation,
45
+ // and a hard dependency would turn a future rename into a failed mount. It is
46
+ // looked up softly instead (ctx.tokenMeter ?? ctx.get('tokenMeter')).
47
+ export const inject = ['agents', 'sessions', 'tools', 'llm']
48
+
49
+ /** Session id from either the agent or a bare session-shaped object. */
50
+ function sessionIdOf(agent) {
51
+ return agent?.session?.id ?? agent?.sessionId
52
+ }
53
+
54
+ /**
55
+ * Accept both the current `{ agent }` payload and a future bare-agent payload.
56
+ * @param {object} payload
57
+ * @returns {object | undefined}
58
+ */
59
+ function agentOf(payload) {
60
+ return payload?.agent ?? payload
61
+ }
62
+
63
+ /**
64
+ * Accept both `(session, event)` and a single `{ session, event }` payload.
65
+ * @param {object} first
66
+ * @param {object} second
67
+ * @returns {{ session: object | undefined, event: object | undefined }}
68
+ */
69
+ function normalizeSessionEvent(first, second) {
70
+ if (second !== undefined) return { session: first, event: second }
71
+ if (first !== null && typeof first === 'object' && first.event !== undefined && first.session !== undefined) {
72
+ return { session: first.session, event: first.event }
73
+ }
74
+ return { session: first, event: second }
75
+ }
76
+
77
+ export function apply(ctx, input = {}) {
78
+ const config = resolveConfig(input)
79
+ const compat = probeHost(ctx)
80
+ // Never `ctx.tokenMeter`: cordis throws on a service property that is not in
81
+ // `inject`, and tokenMeter is deliberately soft (see the comment on inject).
82
+ const tokenMeter = readService(ctx, 'tokenMeter')
83
+ const router = new ModelRouter(config, {
84
+ llm: ctx.llm,
85
+ tokenMeter,
86
+ })
87
+
88
+ if (compat.provide) ctx.provide('modelRouter', router)
89
+
90
+ const warn = (key, message) => warnOnce(ctx, key, message)
91
+ const hostVersion = readHostVersion(ctx)
92
+ if (classifyHostVersion(hostVersion) === 'untested') {
93
+ warn(
94
+ 'host-version',
95
+ `host ${hostVersion} is outside the exercised range ${TESTED_HOST_RANGE}; `
96
+ + 'the plugin mounts, but an API it relies on may have moved. Report a broken '
97
+ + 'route with the plugin version and the host version.',
98
+ )
99
+ }
100
+
101
+ ctx.effect?.(
102
+ () => () => {
103
+ router.cost.reset()
104
+ },
105
+ 'modelRouter.reset()',
106
+ )
107
+
108
+ // Register the virtual "auto" provider so the model selector shows it.
109
+ let autoRegistration
110
+ if (compat.registerAdapter) {
111
+ autoRegistration = ctx.llm.registerAdapter(
112
+ ['auto'],
113
+ createAutoAdapter({ router: () => router, llm: () => ctx.llm }),
114
+ )
115
+ } else {
116
+ warn(
117
+ 'registerAdapter',
118
+ 'the host exposes no llm.registerAdapter(); the "Auto" model will not appear '
119
+ + 'in the model picker. Routing still applies to sessions already on provider "auto".',
120
+ )
121
+ }
122
+ ctx.effect?.(
123
+ () => () => autoRegistration?.(),
124
+ 'modelRouter.unregisterAutoAdapter()',
125
+ )
126
+
127
+ // ── catalog freshness: read provider catalogs at activation, then poll ──
128
+ // A retired model id makes its level stale; the router then drifts around it
129
+ // and the status report names same-family candidates for the one-line fix.
130
+ ctx.effect?.(() => {
131
+ if (!compat.listModels) return undefined
132
+ void router.refreshCatalogs()
133
+ const timer = setInterval(() => void router.refreshCatalogs(), config.modelsGuard.refreshMs)
134
+ timer.unref?.()
135
+ return () => clearInterval(timer)
136
+ }, 'modelRouter.catalogWatch()')
137
+
138
+ // ── HTTP status endpoint for the browser settings section ─────────────
139
+ // The client fetch()es /api/mydsh-auto-model-router/status to render the
140
+ // current routing policy (no localStorage / DOM-transcript dependency).
141
+ //
142
+ // The webserver is claimed through a *scoped injection* instead of a hard
143
+ // `inject` entry: headless deployments have no webserver and must still
144
+ // route. A plain `ctx.get('webServer')` is not enough — at boot the service
145
+ // is not up yet, so the lookup returns nothing, the registration is skipped,
146
+ // and the exact route is never claimed (requests then fall through to the
147
+ // `/api` prefix owner and answer 401). `ctx.inject` runs the callback when —
148
+ // and only when — the service exists.
149
+ const statusRoute = {
150
+ kind: 'exact',
151
+ path: '/api/mydsh-auto-model-router/status',
152
+ handler: (_req, res) => {
153
+ const body = JSON.stringify({
154
+ ok: true,
155
+ version: config.pluginVersion,
156
+ report: statusReport(config, resolveLocale(ctx), { stale: router.staleLevels() }),
157
+ levels: config.levels,
158
+ costControl: config.costControl,
159
+ freeQuota: config.freeQuota,
160
+ contextGuard: config.contextGuard,
161
+ modelsGuard: config.modelsGuard,
162
+ staleLevels: router.staleLevels(),
163
+ rules: config.rules,
164
+ scoring: config.scoring,
165
+ fallbackChain: config.fallbackChain,
166
+ host: {
167
+ version: hostVersion ?? null,
168
+ // Live probe: unlike the mount-time snapshot this sees the webserver,
169
+ // which is only up after the composition finished.
170
+ compat: probeHost(ctx),
171
+ mount: compat,
172
+ testedRange: TESTED_HOST_RANGE,
173
+ },
174
+ })
175
+ res.writeHead(200, {
176
+ 'content-type': 'application/json; charset=utf-8',
177
+ 'cache-control': 'no-cache',
178
+ })
179
+ res.end(body)
180
+ },
181
+ }
182
+ const registerStatusRoute = (scope) => {
183
+ if (typeof scope?.effect !== 'function') {
184
+ registerRoute(scope, statusRoute)
185
+ return
186
+ }
187
+ scope.effect(
188
+ () => registerRoute(scope, statusRoute),
189
+ 'modelRouter.statusEndpoint()',
190
+ )
191
+ }
192
+ if (compat.scopedInject) {
193
+ ctx.inject(['webServer'], registerStatusRoute)
194
+ } else {
195
+ // Fallback for contexts without scoped injection (tests, older hosts).
196
+ const webServer = readService(ctx, 'webServer')
197
+ if (webServer !== undefined && webServer !== null) {
198
+ registerStatusRoute({ effect: (fn) => fn(), webServer })
199
+ }
200
+ }
201
+
202
+ // ── session start: fresh routing state per session ─────────────────────
203
+ // Injects the status report into the session (visible, non-blocking) but
204
+ // never pops a dialog — configuration changes are guided through the
205
+ // Auto Router settings section instead.
206
+ ctx.on('agent/session-start', async (payload) => {
207
+ const agent = agentOf(payload)
208
+ const sessionId = sessionIdOf(agent)
209
+ if (sessionId === undefined) return true
210
+ try {
211
+ router.resetSession(sessionId)
212
+ if (agent.status === 'idle') {
213
+ const locale = resolveLocale(ctx)
214
+ persistStatusReport(config, locale)
215
+ await reportStatus(agent, config, locale)
216
+ }
217
+ } catch (error) {
218
+ warn('session-start', `session start bookkeeping failed for ${sessionId}: ${error?.message ?? error}`)
219
+ }
220
+ return true
221
+ })
222
+
223
+ // ── pre-step: token accounting + cache the latest user input ──────────
224
+ // The latest user message text changes only here (a new user input); every
225
+ // subsequent agent/request step of the SAME input reuses this cached text,
226
+ // so we never re-scan the full derived message history per step.
227
+ ctx.on('agent/pre-step', async (payload, next) => {
228
+ const decision = await next()
229
+ try {
230
+ if (decision?.kind !== 'enter' || payload?.signal?.aborted) return decision
231
+ const sessionId = sessionIdOf(agentOf(payload))
232
+ if (sessionId === undefined) return decision
233
+ const messages = Array.isArray(payload?.messages) ? payload.messages : []
234
+ const last = messages.at(-1)
235
+ if (last) router.recordTokens(sessionId, last)
236
+ // Cache the latest user text ONLY when the newest message is a user
237
+ // message — that is the event that opens a new user input.
238
+ if (last?.role === 'user') {
239
+ const text = messageText(last)
240
+ if (text !== '') router.rememberInput(sessionId, text)
241
+ }
242
+ return decision
243
+ } catch (error) {
244
+ warn('pre-step', `pre-step bookkeeping failed: ${error?.message ?? error}`)
245
+ return decision
246
+ }
247
+ })
248
+
249
+ // ── prompt assembly: keep {{model}} consistent with the routed model ────
250
+ ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
251
+ const assembled = await next()
252
+ try {
253
+ const sessionId = assembly?.variables?.sessionId
254
+ if (!sessionId) return assembled
255
+ if (!router.isAutoActive(sessionId)) return assembled
256
+ const route = router.current(sessionId)
257
+ return {
258
+ ...assembled,
259
+ variables: {
260
+ ...assembled.variables,
261
+ provider: route.provider,
262
+ model: route.model,
263
+ },
264
+ }
265
+ } catch (error) {
266
+ warn('assemble', `prompt assembly override skipped: ${error?.message ?? error}`)
267
+ return assembled
268
+ }
269
+ })
270
+
271
+ // ── ★ agent/request: reuse the per-input decision ──────────────────────
272
+ // Only a NEW user input triggers a fresh decision (and only when the
273
+ // session picked the virtual auto provider). Steps within the same input
274
+ // reuse state.current — no per-step re-judging, no per-step classifier.
275
+ ctx.on('agent/request', async (payload, next) => {
276
+ const proposed = await next()
277
+ const sessionId = sessionIdOf(agentOf(payload))
278
+ try {
279
+ ctx.logger?.info?.(
280
+ `[mydsh-auto-model-router] agent/request session=${sessionId} proposed=${proposed?.provider}/${proposed?.model} isAuto=${isAutoSelection(proposed)}`,
281
+ )
282
+ if (sessionId === undefined) return proposed
283
+ if (!isAutoSelection(proposed)) {
284
+ router.markAutoInactive(sessionId)
285
+ return proposed
286
+ }
287
+ router.markAutoActive(sessionId)
288
+
289
+ // Use the cached latest user input (set in agent/pre-step) — do NOT
290
+ // re-scan the full derived message history on every step.
291
+ const input = router.currentInput(sessionId) ?? ''
292
+ const decision = await router.decideOnInput(sessionId, {
293
+ inputText: input,
294
+ prevInputText: router.lastInputText(sessionId) ?? '',
295
+ lastReplyText: router.lastReplyText(sessionId) ?? '',
296
+ })
297
+
298
+ if (decision?.needsAsk) {
299
+ const locale = resolveLocale(ctx)
300
+ if (decision.needsAsk === 'above-lock') {
301
+ // Ask: 固定模型 or 开启全自动.
302
+ const picked = await askModeSelection(ctx, agentOf(payload), router, sessionId)
303
+ if (picked?.kind === 'fixed') {
304
+ router.setMode(sessionId, 'fixed', { fixedLevel: picked.level })
305
+ } else if (picked?.kind === 'auto') {
306
+ router.setMode(sessionId, 'auto')
307
+ }
308
+ // fallback: user dismissed → stay in heuristic mode.
309
+ const route = router.current(sessionId)
310
+ return { ...proposed, provider: route.provider, model: route.model,
311
+ ...route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort } }
312
+ }
313
+ if (decision.needsAsk === 'fixed-reelect') {
314
+ const picked = await askFixedLevel(ctx, agentOf(payload), router, sessionId)
315
+ if (picked) router.setMode(sessionId, 'fixed', { fixedLevel: picked })
316
+ const route = router.current(sessionId)
317
+ return { ...proposed, provider: route.provider, model: route.model,
318
+ ...route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort } }
319
+ }
320
+ }
321
+
322
+ const route = router.current(sessionId)
323
+ const downgraded = router.consumeDowngradeFlag(sessionId)
324
+ if (downgraded) {
325
+ const behavior = config.costControl.downgradeBehavior
326
+ const locale = resolveLocale(ctx)
327
+ if (behavior === 'notify') {
328
+ await notifyDowngrade(agentOf(payload), config, sessionId, locale)
329
+ } else if (behavior === 'ask') {
330
+ const keep = await askDowngrade(ctx, agentOf(payload), router, sessionId)
331
+ if (keep) router.waiveBudget(sessionId)
332
+ }
333
+ }
334
+ // A free route that burned its quota earlier in this session was replaced
335
+ // by a paid one while routing — tell the user once.
336
+ const demotion = router.consumeFreeDemotionFlag(sessionId)
337
+ if (demotion && config.freeQuota.notify) {
338
+ await notifyFreeQuotaFallback(agentOf(payload), sessionId, demotion, resolveLocale(ctx))
339
+ }
340
+ return {
341
+ ...proposed,
342
+ provider: route.provider,
343
+ model: route.model,
344
+ // Levels always carry an explicit reasoningEffort (default 'off'), so
345
+ // the routed effort is always stated — including 'off' for no thinking.
346
+ reasoningEffort: route.reasoningEffort,
347
+ }
348
+ } catch (error) {
349
+ // The router must never be able to fail a user request: hand the host's
350
+ // own proposal back untouched and surface the reason once.
351
+ ctx.logger?.warn?.(
352
+ `[mydsh-auto-model-router] routing failed for ${sessionId ?? 'unknown session'}; `
353
+ + `passing the host proposal through: ${error?.message ?? error}`,
354
+ )
355
+ return proposed
356
+ }
357
+ })
358
+
359
+ // ── agent/request-error: fallback chain (auto mode only) ────────────────
360
+ // A free-quota route that fails (quota exhausted, rate limited, rejected) is
361
+ // demoted for the rest of the session: the retry runs on the next viable
362
+ // route, and every later decision for that level skips the burned route.
363
+ ctx.on('agent/request-error', async (payload, next) => {
364
+ let retry = false
365
+ try {
366
+ const sessionId = sessionIdOf(agentOf(payload))
367
+ if (sessionId !== undefined && router.isAutoActive(sessionId)) {
368
+ const failedRoute = router.current(sessionId)
369
+ const burned = config.freeQuota.enabled && config.freeQuota.demoteOnError
370
+ ? router.markFreeExhausted(sessionId, failedRoute)
371
+ : false
372
+ const fallback = router.nextOnFailure(sessionId, failedRoute)
373
+ if (fallback) {
374
+ ctx.logger?.info?.(
375
+ `[mydsh-auto-model-router] ${sessionId}: request failed (${payload?.failure?.error?.code ?? 'unknown'})`
376
+ + `${burned ? ' on a free route, which is now demoted for this session' : ''},`
377
+ + ` falling back to ${fallback.provider}/${fallback.model}`,
378
+ )
379
+ // The user-visible notice is owned by the routing path, which knows
380
+ // the level and the paid replacement: announcing it here too would
381
+ // report the same demotion twice.
382
+ retry = true
383
+ }
384
+ }
385
+ } catch (error) {
386
+ warn('request-error', `fallback handling failed: ${error?.message ?? error}`)
387
+ }
388
+ return retry ? { kind: 'retry' } : next()
389
+ })
390
+
391
+ // ── session/event: track spend from assistant messages ──────────────────
392
+ ctx.on('session/event', (first, second) => {
393
+ const { session, event } = normalizeSessionEvent(first, second)
394
+ const sessionId = sessionIdOf(session)
395
+ if (sessionId === undefined || event === undefined) return
396
+ try {
397
+ if (event.type === 'assistant/message') {
398
+ router.recordTokens(sessionId, event.data?.message)
399
+ // The input side of the request that produced this message is the best
400
+ // context-size signal the context guard has.
401
+ router.recordUsage(sessionId, event.data?.usage)
402
+ }
403
+ if (event.type === 'message') {
404
+ const message = event.data?.message
405
+ if (message?.role === 'assistant' && Array.isArray(message.content)) {
406
+ const text = message.content
407
+ .map((block) => (typeof block?.text === 'string' ? block.text : ''))
408
+ .join('\n')
409
+ if (text) router.rememberReply(sessionId, text)
410
+ }
411
+ }
412
+ } catch (error) {
413
+ warn('session-event', `session event handling failed: ${error?.message ?? error}`)
414
+ }
415
+ })
416
+ }
417
+
418
+ /** Extract the text content of a single message. */
419
+ function messageText(message) {
420
+ const content = message?.content
421
+ if (typeof content === 'string') return content.trim()
422
+ if (Array.isArray(content)) {
423
+ return content
424
+ .map((block) => (typeof block?.text === 'string' ? block.text : ''))
425
+ .join('\n')
426
+ .trim()
427
+ }
428
+ return ''
429
+ }