@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/cost-control.mjs
ADDED
|
@@ -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
|
+
}
|