@apinggithub/mydsh-auto-model-router 0.2.0 → 0.2.2
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/README.md +147 -82
- package/README.zh.md +122 -72
- package/auto-adapter.mjs +234 -104
- package/compat.mjs +199 -0
- package/config.mjs +4 -1
- package/index.mjs +429 -307
- package/package.json +74 -72
package/index.mjs
CHANGED
|
@@ -1,307 +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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
} from './
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
// `
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
return
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
// ──
|
|
139
|
-
// The
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
return
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
+
// 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
|
+
}
|