@soffits/dsh-fallback-models 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/LICENSE +21 -0
- package/README.md +150 -2
- package/client.js +511 -0
- package/cordis.patch.yml +8 -0
- package/icon.svg +8 -0
- package/index.js +20 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +89 -4
- package/src/config.js +33 -0
- package/src/fallback.js +354 -0
- package/src/policy.js +134 -0
- package/src/refusal.js +163 -0
package/package.json
CHANGED
|
@@ -1,6 +1,91 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soffits/dsh-fallback-models",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Fallback model chain for DeepSeek Harness: when a model request keeps failing after DSH's own retries, the work continues on the next model in a configured list.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./client": "./client.js",
|
|
10
|
+
"./locale/*.json": "./locale/*.json",
|
|
11
|
+
"./icon": "./icon.svg",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"index.js",
|
|
16
|
+
"client.js",
|
|
17
|
+
"src/*.js",
|
|
18
|
+
"cordis.patch.yml",
|
|
19
|
+
"locale/*.json",
|
|
20
|
+
"icon.svg",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"dsh": {
|
|
25
|
+
"bundle": {
|
|
26
|
+
"patch": "./cordis.patch.yml"
|
|
27
|
+
},
|
|
28
|
+
"client": {
|
|
29
|
+
"platform": "web",
|
|
30
|
+
"immediately": true,
|
|
31
|
+
"inject": [
|
|
32
|
+
"@deepseek-ai/dsh-client-locale",
|
|
33
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
34
|
+
"@deepseek-ai/dsh-client-ui-plugin-manager",
|
|
35
|
+
"@deepseek-ai/dsh-api-remotes",
|
|
36
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
37
|
+
"@deepseek-ai/dsh-client-ui-chat"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=22",
|
|
43
|
+
"dsh": ">=0.2.2-rc.1 <0.3.0-0"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "node --test \"test/*.test.js\"",
|
|
47
|
+
"check": "for f in index.js client.js src/*.js test/*.js; do node --check \"$f\" || exit 1; done"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@deepseek-ai/schemastery": "^3.18.5"
|
|
51
|
+
},
|
|
52
|
+
"keywords": [
|
|
53
|
+
"dsh-plugin",
|
|
54
|
+
"deepseek-harness",
|
|
55
|
+
"dsh",
|
|
56
|
+
"llm",
|
|
57
|
+
"fallback",
|
|
58
|
+
"failover"
|
|
59
|
+
],
|
|
60
|
+
"author": "Sakina",
|
|
61
|
+
"license": "MIT",
|
|
62
|
+
"repository": {
|
|
63
|
+
"type": "git",
|
|
64
|
+
"url": "git+https://github.com/soffits/dsh-fallback-models.git"
|
|
65
|
+
},
|
|
66
|
+
"homepage": "https://github.com/soffits/dsh-fallback-models#readme",
|
|
67
|
+
"bugs": {
|
|
68
|
+
"url": "https://github.com/soffits/dsh-fallback-models/issues"
|
|
69
|
+
},
|
|
70
|
+
"publishConfig": {
|
|
71
|
+
"access": "public"
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@deepseek-ai/cordis": "4.1.0",
|
|
75
|
+
"@deepseek-ai/dsh-agent": "0.2.2-rc.1",
|
|
76
|
+
"@deepseek-ai/dsh-agent-loop": "0.2.2-rc.1",
|
|
77
|
+
"@deepseek-ai/dsh-agent-loop-testkit": "0.2.2-rc.1",
|
|
78
|
+
"@deepseek-ai/dsh-brand": "0.2.2-rc.1",
|
|
79
|
+
"@deepseek-ai/dsh-fs-local": "0.2.2-rc.1",
|
|
80
|
+
"@deepseek-ai/dsh-llm": "0.2.2-rc.1",
|
|
81
|
+
"@deepseek-ai/dsh-llm-retry": "0.2.2-rc.1",
|
|
82
|
+
"@deepseek-ai/dsh-session": "0.2.2-rc.1",
|
|
83
|
+
"@deepseek-ai/dsh-session-projection": "0.2.2-rc.1",
|
|
84
|
+
"@deepseek-ai/dsh-system-prompt": "0.2.2-rc.1",
|
|
85
|
+
"@deepseek-ai/dsh-timeout": "0.2.2-rc.1",
|
|
86
|
+
"@deepseek-ai/dsh-tools": "0.2.2-rc.1",
|
|
87
|
+
"@deepseek-ai/dsh-working-directory": "0.2.2-rc.1",
|
|
88
|
+
"react": "19.3.1",
|
|
89
|
+
"react-dom": "19.3.1"
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import z from '@deepseek-ai/schemastery'
|
|
2
|
+
import { DEFAULT_CODES } from './policy.js'
|
|
3
|
+
import { DEFAULT_REFUSAL_MAX_LENGTH, DEFAULT_REFUSAL_PATTERNS } from './refusal.js'
|
|
4
|
+
|
|
5
|
+
const Route = z.object({
|
|
6
|
+
provider: z.string().required().description('Provider id, as in the model picker (for example deepseek).'),
|
|
7
|
+
model: z.string().required().description('Model id within that provider.'),
|
|
8
|
+
reasoningEffort: z.string().description('Reasoning effort id for this model. The model\'s default when empty.'),
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const Chain = z.array(Route)
|
|
12
|
+
|
|
13
|
+
// Every field is volatile: the Settings page and config-file edits apply to
|
|
14
|
+
// the next failure without restarting the plugin.
|
|
15
|
+
export const Config = z.object({
|
|
16
|
+
chain: Chain.default([]).volatile()
|
|
17
|
+
.description('Fallback models, tried in order when a model request keeps failing. Empty turns the plugin off.'),
|
|
18
|
+
presets: z.dict(Chain).default({}).volatile()
|
|
19
|
+
.description('Per agent preset: a preset id mapped to its own fallback list, used instead of the global one. An empty list turns fallback off for that preset.'),
|
|
20
|
+
codes: z.array(z.string()).default([...DEFAULT_CODES]).volatile()
|
|
21
|
+
.description('Failure codes that switch to the next model (after DSH\'s own retries). ABORTED never switches. REFUSAL is the code for a refused or filter-blocked answer, detected by refusalPatterns or reported by an adapter itself; leave it out to keep such answers. Add AUTH to also switch on rejected credentials. Errors raised before a request is sent (unknown model, missing credential) end the turn without reaching this plugin.'),
|
|
22
|
+
scope: z.union(['turn', 'session']).default('turn').volatile()
|
|
23
|
+
.description('turn: go back to the session\'s own model when the turn that used the fallback ends. session: keep the fallback for the rest of the session.'),
|
|
24
|
+
refusalPatterns: z.array(z.string()).default([...DEFAULT_REFUSAL_PATTERNS]).volatile()
|
|
25
|
+
.description('Refusal signatures: regular expressions, matched case-insensitively. A reply counts as refused (code REFUSAL) only when one matches within its first 160 characters (not right after a quotation mark), the reply is at most refusalMaxLength characters, it calls no tools, and no tool call succeeded since the user\'s last message. The default list covers provider filter notices and common English and Chinese refusals. Empty turns refusal detection off.'),
|
|
26
|
+
refusalMaxLength: z.natural().default(DEFAULT_REFUSAL_MAX_LENGTH).volatile()
|
|
27
|
+
.description('Longest reply, in characters, that can count as a refusal.'),
|
|
28
|
+
notify: z.object({
|
|
29
|
+
via: z.string().description('Name of a running DSH service with a send({ to, text, files, from }) method, such as a Telegram plugin\'s topic service. Empty: no message.'),
|
|
30
|
+
to: z.string().description('Address that service understands, such as a topic\'s thread id.'),
|
|
31
|
+
}).default({}).volatile()
|
|
32
|
+
.description('Optional: also send one short message per switch through another plugin\'s service. Nothing is required; the switch is always shown in the conversation and emitted as the fallback-models/switch event.'),
|
|
33
|
+
})
|
package/src/fallback.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// The fallback chain on DSH's model-request recovery point.
|
|
2
|
+
//
|
|
3
|
+
// DSH's agent loop fixes a step's provider/model before the step starts and
|
|
4
|
+
// re-runs the same selection when a recovery listener answers "retry"
|
|
5
|
+
// (agent/request now rejects a changed model). So a switch cannot happen
|
|
6
|
+
// inside the failed step. Instead, once nothing downstream retries, this
|
|
7
|
+
// plugin records the fallback as the session's next selection
|
|
8
|
+
// (agent.model.select, never the saved default model), interrupts the failed
|
|
9
|
+
// turn with a "hook" cancel that keeps the inbox, and queues one short notice
|
|
10
|
+
// that starts the next turn on the fallback. The cancel avoids the error path,
|
|
11
|
+
// so a subagent's parent or the session list does not see a failed turn that
|
|
12
|
+
// is in fact continuing.
|
|
13
|
+
//
|
|
14
|
+
// A refused or filter-blocked answer is a successful response to DSH. The
|
|
15
|
+
// plugin's `llm/stream` listener (src/refusal.js) turns one into a REFUSAL
|
|
16
|
+
// failure, so it reaches the same recovery point as any other failure. An
|
|
17
|
+
// adapter may also end a refused request with code REFUSAL itself.
|
|
18
|
+
//
|
|
19
|
+
// Each switch is announced three ways, none of which needs another plugin:
|
|
20
|
+
// a banner in the Web UI conversation (client.js renders the interrupted
|
|
21
|
+
// turn's end), a `fallback-models/switch` event other plugins may listen to,
|
|
22
|
+
// and, when `notify` names a service, one `send({ to, text, from })` call to
|
|
23
|
+
// it (for example a Telegram plugin's topic service).
|
|
24
|
+
import { randomUUID } from 'node:crypto'
|
|
25
|
+
import {
|
|
26
|
+
REFUSAL_CODE, boundSummary, candidates, chainFor, failureText, isRefusal, read,
|
|
27
|
+
routeKey, routeLabel, sameRoute, switches, toSelection,
|
|
28
|
+
} from './policy.js'
|
|
29
|
+
import {
|
|
30
|
+
DEFAULT_REFUSAL_MAX_LENGTH, DEFAULT_REFUSAL_PATTERNS, compilePatterns,
|
|
31
|
+
toolSucceededThisTurn, watchRefusal,
|
|
32
|
+
} from './refusal.js'
|
|
33
|
+
|
|
34
|
+
/** User-message source kind of the continuation notice. */
|
|
35
|
+
export const NOTICE_KIND = 'model-fallback'
|
|
36
|
+
|
|
37
|
+
/** Prefix of the cancel reason recorded on the interrupted turn's turn/end. */
|
|
38
|
+
export const REASON_PREFIX = 'dsh-fallback-models'
|
|
39
|
+
|
|
40
|
+
/** Event emitted once per switch: `(payload: SwitchInfo) => void`. */
|
|
41
|
+
export const SWITCH_EVENT = 'fallback-models/switch'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The cancel reason of an interrupted turn. client.js parses this exact form
|
|
45
|
+
* to draw the banner, so keep the two in step.
|
|
46
|
+
*/
|
|
47
|
+
export function switchReason(from, to, failure) {
|
|
48
|
+
const what = isRefusal(failure) ? 'refused to answer' : 'failed'
|
|
49
|
+
return `${REASON_PREFIX}: ${routeLabel(from)} ${what} (${failure.code}); continuing on ${routeLabel(to)}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The message sent through `notify` for one switch. A refusal is worded
|
|
54
|
+
* apart: the reply was hidden, not lost to an error.
|
|
55
|
+
*/
|
|
56
|
+
export function switchText(info) {
|
|
57
|
+
const from = routeLabel(info.from)
|
|
58
|
+
const to = routeLabel(info.to)
|
|
59
|
+
if (info.refusal) {
|
|
60
|
+
return `Model fallback: ${from} refused to answer or its filter blocked the reply, in session ${info.sessionId}. `
|
|
61
|
+
+ `The refused reply was hidden, and ${to} is answering instead.`
|
|
62
|
+
}
|
|
63
|
+
return `Model fallback: ${from} failed (${failureText(info.failure)}) in session ${info.sessionId}. `
|
|
64
|
+
+ `The work continues on ${to}.`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function freeze(value) {
|
|
68
|
+
if (value && typeof value === 'object') {
|
|
69
|
+
for (const key of Object.keys(value)) freeze(value[key])
|
|
70
|
+
Object.freeze(value)
|
|
71
|
+
}
|
|
72
|
+
return value
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The continuation message. It is model-visible on purpose: DSH starts a turn
|
|
77
|
+
* only for inbox input, and the model has to know why the reply restarts on
|
|
78
|
+
* another model. The `notice` form makes the Web UI show it as one collapsed
|
|
79
|
+
* row with the summary.
|
|
80
|
+
*/
|
|
81
|
+
export function noticeMessage(from, to, failure) {
|
|
82
|
+
// The refusal wording stays neutral and omits the refused text: the new
|
|
83
|
+
// model should answer the request, not weigh another model's refusal.
|
|
84
|
+
const text = isRefusal(failure)
|
|
85
|
+
? `[model fallback: the reply from ${routeLabel(from)} could not be used. `
|
|
86
|
+
+ `Continuing on ${routeLabel(to)}: answer the latest request.]`
|
|
87
|
+
: `[model fallback: the request to ${routeLabel(from)} failed (${failureText(failure)}). `
|
|
88
|
+
+ `Continuing on ${routeLabel(to)}: resume the interrupted work where it stopped.]`
|
|
89
|
+
const summary = isRefusal(failure)
|
|
90
|
+
? `${routeLabel(from)} refused to answer; switched to ${routeLabel(to)}`
|
|
91
|
+
: `${routeLabel(from)} failed (${failure.code}); switched to ${routeLabel(to)}`
|
|
92
|
+
return freeze({
|
|
93
|
+
id: randomUUID(),
|
|
94
|
+
role: 'user',
|
|
95
|
+
content: [{ type: 'text', text }],
|
|
96
|
+
source: { kind: NOTICE_KIND, form: 'notice', summary: boundSummary(summary) },
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The route the failed request used: the logged request header when it
|
|
102
|
+
* belongs to the failing provider, else the agent's current selection.
|
|
103
|
+
*/
|
|
104
|
+
function failedRoute(agent, provider) {
|
|
105
|
+
const header = agent.session.requestHeader?.()?.config
|
|
106
|
+
if (header && header.provider === provider) return toSelection(header)
|
|
107
|
+
const current = agent.model.current()
|
|
108
|
+
if (current && current.provider === provider) return toSelection(current)
|
|
109
|
+
return { provider, model: current?.model ?? '' }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Install the fallback chain.
|
|
114
|
+
* @param ctx - plugin context; uses `ctx.llm` to skip unusable fallbacks.
|
|
115
|
+
* @param config - Config value; fields may be volatile references.
|
|
116
|
+
*/
|
|
117
|
+
export function installFallback(ctx, config) {
|
|
118
|
+
const logger = ctx.logger('fallback-models')
|
|
119
|
+
// One failure chain per Session: what the session used before the first
|
|
120
|
+
// switch, the routes tried since, and the turn that was interrupted last.
|
|
121
|
+
const chains = new Map()
|
|
122
|
+
let disposed = false
|
|
123
|
+
|
|
124
|
+
function settings() {
|
|
125
|
+
return {
|
|
126
|
+
chain: read(config.chain) ?? [],
|
|
127
|
+
presets: read(config.presets) ?? {},
|
|
128
|
+
codes: read(config.codes),
|
|
129
|
+
scope: read(config.scope) ?? 'turn',
|
|
130
|
+
refusalPatterns: read(config.refusalPatterns) ?? DEFAULT_REFUSAL_PATTERNS,
|
|
131
|
+
refusalMaxLength: read(config.refusalMaxLength) ?? DEFAULT_REFUSAL_MAX_LENGTH,
|
|
132
|
+
notify: read(config.notify),
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Compiled refusal patterns, rebuilt when the configured list changes.
|
|
137
|
+
let compiled = { key: undefined, patterns: [] }
|
|
138
|
+
function patterns(sources) {
|
|
139
|
+
const key = JSON.stringify(sources)
|
|
140
|
+
if (compiled.key !== key) {
|
|
141
|
+
compiled = {
|
|
142
|
+
key,
|
|
143
|
+
patterns: compilePatterns(sources, (source, message) => logger.warn('ignoring refusal pattern %s: %s', source, message)),
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return compiled.patterns
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function presetOf(agent) {
|
|
150
|
+
try {
|
|
151
|
+
return ctx.get('agentPresets')?.presetForSession?.(agent.session) ?? undefined
|
|
152
|
+
} catch {
|
|
153
|
+
return undefined
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** First candidate whose provider is registered and whose call config DSH accepts. */
|
|
158
|
+
async function pick(list, tried, signal) {
|
|
159
|
+
let providers
|
|
160
|
+
try {
|
|
161
|
+
providers = new Set(ctx.llm.listProviders().map(info => info.id))
|
|
162
|
+
} catch {
|
|
163
|
+
providers = undefined
|
|
164
|
+
}
|
|
165
|
+
for (const route of list) {
|
|
166
|
+
tried.add(routeKey(route))
|
|
167
|
+
if (providers && !providers.has(route.provider)) {
|
|
168
|
+
logger.warn('skipping fallback %s: provider is not registered', routeLabel(route))
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
await ctx.llm.resolveCallConfig(route, signal)
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (signal.aborted) return undefined
|
|
175
|
+
logger.warn('skipping fallback %s: %s', routeLabel(route), error instanceof Error ? error.message : String(error))
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
return route
|
|
179
|
+
}
|
|
180
|
+
return undefined
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function recover(payload, next) {
|
|
184
|
+
// DSH's own retry and any other recovery go first; act only when nobody
|
|
185
|
+
// downstream retried the request.
|
|
186
|
+
const downstream = await next()
|
|
187
|
+
if (downstream?.kind === 'retry') return downstream
|
|
188
|
+
const { agent, failure, signal, provider, turn } = payload
|
|
189
|
+
if (disposed || signal.aborted || !agent || !failure) return downstream
|
|
190
|
+
const current = settings()
|
|
191
|
+
if (!switches(current.codes, failure.code)) return downstream
|
|
192
|
+
const chain = chainFor(current, presetOf(agent))
|
|
193
|
+
if (chain.length === 0) return downstream
|
|
194
|
+
|
|
195
|
+
const session = agent.session
|
|
196
|
+
const failed = failedRoute(agent, provider)
|
|
197
|
+
let state = chains.get(session)
|
|
198
|
+
if (state === undefined) {
|
|
199
|
+
const original = agent.model.current()
|
|
200
|
+
state = { original: original ? toSelection(original) : failed, tried: new Set(), target: undefined, turn }
|
|
201
|
+
}
|
|
202
|
+
state.tried.add(routeKey(failed))
|
|
203
|
+
const target = await pick(candidates(chain, failed, state.tried), state.tried, signal)
|
|
204
|
+
if (disposed || signal.aborted) return downstream
|
|
205
|
+
if (target === undefined) {
|
|
206
|
+
// The turn ends with the original error; its turn/end restores the
|
|
207
|
+
// session's own model when an earlier switch made one.
|
|
208
|
+
logger.warn('%s failed (%s); no untried fallback left', routeLabel(failed), failureText(failure))
|
|
209
|
+
return downstream
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
state.agent = agent
|
|
213
|
+
state.target = target
|
|
214
|
+
state.turn = turn
|
|
215
|
+
chains.set(session, state)
|
|
216
|
+
const reason = switchReason(failed, target, failure)
|
|
217
|
+
try {
|
|
218
|
+
agent.model.select(target)
|
|
219
|
+
agent.cancel({ kind: 'hook', reason }, { keepInbox: true })
|
|
220
|
+
const notice = noticeMessage(failed, target, failure)
|
|
221
|
+
agent.followup(notice)
|
|
222
|
+
// Run the continuation before anything the user queued meanwhile.
|
|
223
|
+
if (agent.inbox.nextTurn[0]?.id !== notice.id && agent.inbox.remove(notice.id)) {
|
|
224
|
+
agent.inbox.prepend('next-turn', notice)
|
|
225
|
+
}
|
|
226
|
+
} catch (error) {
|
|
227
|
+
logger.error('could not switch %s to %s: %s', routeLabel(failed), routeLabel(target),
|
|
228
|
+
error instanceof Error ? error.message : String(error))
|
|
229
|
+
// No continuation will run, so let this turn's end restore the model.
|
|
230
|
+
state.turn = turn - 1
|
|
231
|
+
return downstream
|
|
232
|
+
}
|
|
233
|
+
logger.warn('%s failed (%s); switched to %s', routeLabel(failed), failureText(failure), routeLabel(target))
|
|
234
|
+
announce({
|
|
235
|
+
agent,
|
|
236
|
+
sessionId: String(session.id),
|
|
237
|
+
turn,
|
|
238
|
+
from: failed,
|
|
239
|
+
to: target,
|
|
240
|
+
failure,
|
|
241
|
+
refusal: isRefusal(failure),
|
|
242
|
+
}, current.notify)
|
|
243
|
+
return undefined
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Tell listeners and the configured notifier about one switch. */
|
|
247
|
+
function announce(info, notify) {
|
|
248
|
+
try {
|
|
249
|
+
ctx.emit(SWITCH_EVENT, info)
|
|
250
|
+
} catch (error) {
|
|
251
|
+
logger.warn('a %s listener failed: %s', SWITCH_EVENT, error instanceof Error ? error.message : String(error))
|
|
252
|
+
}
|
|
253
|
+
const via = typeof notify?.via === 'string' ? notify.via.trim() : ''
|
|
254
|
+
if (via === '') return
|
|
255
|
+
let service
|
|
256
|
+
try {
|
|
257
|
+
service = ctx.get(via)
|
|
258
|
+
} catch {
|
|
259
|
+
service = undefined
|
|
260
|
+
}
|
|
261
|
+
if (typeof service?.send !== 'function') {
|
|
262
|
+
logger.warn('notify: no running service %s with a send method', via)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
const request = {
|
|
266
|
+
to: typeof notify.to === 'string' ? notify.to.trim() : notify.to,
|
|
267
|
+
text: switchText(info),
|
|
268
|
+
files: [],
|
|
269
|
+
from: { label: 'dsh-fallback-models', sessionId: info.sessionId },
|
|
270
|
+
}
|
|
271
|
+
Promise.resolve()
|
|
272
|
+
.then(() => service.send(request))
|
|
273
|
+
.catch(error => logger.warn('notify through %s failed: %s', via, error instanceof Error ? error.message : String(error)))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Whether a refusal of this request should become a REFUSAL failure: the
|
|
278
|
+
* code switches, the agent's chain has a model that is not used up, and no
|
|
279
|
+
* tool call succeeded in this turn. Otherwise the reply stays the answer.
|
|
280
|
+
*/
|
|
281
|
+
async function refusalSwitches(options, current, signal) {
|
|
282
|
+
if (disposed || signal?.aborted || !switches(current.codes, REFUSAL_CODE)) return false
|
|
283
|
+
if (toolSucceededThisTurn(options.messages)) return false
|
|
284
|
+
let agent
|
|
285
|
+
try {
|
|
286
|
+
agent = ctx.get('agents')?.get(options.sessionId)
|
|
287
|
+
} catch {
|
|
288
|
+
agent = undefined
|
|
289
|
+
}
|
|
290
|
+
if (!agent) return false
|
|
291
|
+
const chain = chainFor(current, presetOf(agent))
|
|
292
|
+
if (chain.length === 0) return false
|
|
293
|
+
const failed = { provider: options.provider, model: options.model }
|
|
294
|
+
const tried = new Set(chains.get(agent.session)?.tried ?? [])
|
|
295
|
+
tried.add(routeKey(failed))
|
|
296
|
+
const target = await pick(candidates(chain, failed, tried), tried, signal ?? new AbortController().signal)
|
|
297
|
+
return target !== undefined
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Watch only conversation requests the agent loop builds: they carry the
|
|
301
|
+
// session id and no auxiliary purpose (titles, summaries and the like do).
|
|
302
|
+
ctx.on('llm/stream', (options, next) => {
|
|
303
|
+
const current = settings()
|
|
304
|
+
const list = patterns(current.refusalPatterns)
|
|
305
|
+
if (disposed || list.length === 0 || options?.sessionId === undefined || options.purpose !== undefined
|
|
306
|
+
|| !switches(current.codes, REFUSAL_CODE) || chainFor(current, undefined).length === 0 && Object.keys(current.presets).length === 0) {
|
|
307
|
+
return next()
|
|
308
|
+
}
|
|
309
|
+
return watchRefusal(next(), () => refusalSwitches(options, settings(), options.signal), {
|
|
310
|
+
patterns: list,
|
|
311
|
+
maxLength: current.refusalMaxLength,
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
ctx.on('agent/request-error', recover)
|
|
316
|
+
|
|
317
|
+
ctx.on('agent/disposed', ({ agent }) => {
|
|
318
|
+
if (chains.get(agent.session)?.agent === agent) chains.delete(agent.session)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
// When the plugin goes away mid-chain, hand turn-scoped sessions back.
|
|
322
|
+
ctx.effect(() => () => {
|
|
323
|
+
disposed = true
|
|
324
|
+
const pending = [...chains.values()]
|
|
325
|
+
chains.clear()
|
|
326
|
+
if (settings().scope !== 'turn') return
|
|
327
|
+
for (const state of pending) restore(state)
|
|
328
|
+
}, 'fallback-models: lifetime')
|
|
329
|
+
|
|
330
|
+
// A failure chain ends with the first turn that ends after the last switch:
|
|
331
|
+
// the continuation finished, failed for good, or was stopped by the user.
|
|
332
|
+
ctx.on('session/event', (session, event) => {
|
|
333
|
+
if (event.type !== 'turn/end') return
|
|
334
|
+
const state = chains.get(session)
|
|
335
|
+
if (state === undefined || event.data?.turn <= state.turn) return
|
|
336
|
+
chains.delete(session)
|
|
337
|
+
// A session refuses appends while it publishes one, so select after this
|
|
338
|
+
// event; the microtask still runs before the loop prepares another turn.
|
|
339
|
+
if (!disposed && settings().scope === 'turn') queueMicrotask(() => restore(state))
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
function restore(state) {
|
|
343
|
+
const agent = state.agent
|
|
344
|
+
if (!agent || state.target === undefined) return
|
|
345
|
+
try {
|
|
346
|
+
// Leave it alone when someone picked another model meanwhile.
|
|
347
|
+
if (!sameRoute(agent.model.current(), state.target)) return
|
|
348
|
+
agent.model.select(state.original)
|
|
349
|
+
logger.info('back to %s', routeLabel(state.original))
|
|
350
|
+
} catch (error) {
|
|
351
|
+
logger.warn('could not restore %s: %s', routeLabel(state.original), error instanceof Error ? error.message : String(error))
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
package/src/policy.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Pure decisions of the fallback chain: which chain applies, which failures
|
|
2
|
+
// switch, and which model comes next. No DSH services here, so every rule is
|
|
3
|
+
// unit-testable on plain data.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Failure codes that switch to the next model by default. They mean the model
|
|
7
|
+
* could not answer right now (overloaded, down, out of quota), so another
|
|
8
|
+
* model can. DSH's own retry runs first for the transient ones.
|
|
9
|
+
*
|
|
10
|
+
* Only failures a model stream reports reach the recovery point. Errors DSH
|
|
11
|
+
* raises while preparing a request (UNKNOWN_MODEL, MODEL_UNSUPPORTED,
|
|
12
|
+
* MISSING_CREDENTIAL, NO_ADAPTER) end the turn before any request is sent, so
|
|
13
|
+
* listing them would do nothing. PI_AI_ERROR is the pi-ai adapter's code for
|
|
14
|
+
* a provider error it cannot classify, such as a 404 for a model the provider
|
|
15
|
+
* no longer serves.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_CODES = Object.freeze([
|
|
18
|
+
'RATE_LIMIT',
|
|
19
|
+
'SERVER',
|
|
20
|
+
'TIMEOUT',
|
|
21
|
+
'TRANSPORT',
|
|
22
|
+
'EMPTY_RESPONSE',
|
|
23
|
+
'QUOTA',
|
|
24
|
+
'ACCOUNT_QUOTA',
|
|
25
|
+
'PI_AI_ERROR',
|
|
26
|
+
'REFUSAL',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The code for a refused or filter-blocked answer. Most adapters deliver a
|
|
31
|
+
* refusal as an ordinary successful response, and the plugin's `llm/stream`
|
|
32
|
+
* listener turns a detected one into a failure with this code (see
|
|
33
|
+
* src/refusal.js). An adapter that knows a reply was refused may report the
|
|
34
|
+
* code itself (dsh-oh-my-claude's `refusalAsFailure`); that failure switches
|
|
35
|
+
* like any other.
|
|
36
|
+
*/
|
|
37
|
+
export const REFUSAL_CODE = 'REFUSAL'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Codes that never switch, whatever the config says: a cancelled request is
|
|
41
|
+
* the user's (or a plugin's) decision, not a model failure.
|
|
42
|
+
*/
|
|
43
|
+
export const NEVER_CODES = Object.freeze(['ABORTED'])
|
|
44
|
+
|
|
45
|
+
/** Read a schemastery volatile field (`{ get() }`) or a plain value. */
|
|
46
|
+
export function read(value) {
|
|
47
|
+
return value !== null && typeof value === 'object' && typeof value.get === 'function' ? value.get() : value
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Stable identity of a provider/model route; effort is not part of it. */
|
|
51
|
+
export function routeKey(route) {
|
|
52
|
+
return JSON.stringify([route.provider, route.model])
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function sameRoute(a, b) {
|
|
56
|
+
return a !== undefined && b !== undefined && a.provider === b.provider && a.model === b.model
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `provider/model`, the way the Web UI names a route. */
|
|
60
|
+
export function routeLabel(route) {
|
|
61
|
+
return `${route.provider}/${route.model}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A detached selection, omitting an absent or empty reasoning effort. */
|
|
65
|
+
export function toSelection(route) {
|
|
66
|
+
const effort = route.reasoningEffort
|
|
67
|
+
return {
|
|
68
|
+
provider: route.provider,
|
|
69
|
+
model: route.model,
|
|
70
|
+
...(typeof effort === 'string' && effort !== '' ? { reasoningEffort: effort } : {}),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The chain for one agent: its preset's own list when the config names that
|
|
76
|
+
* preset (an empty list there turns fallback off for the preset), otherwise
|
|
77
|
+
* the global list. Routes missing a provider or model are dropped.
|
|
78
|
+
* @param config - resolved config values `{ chain, presets }`.
|
|
79
|
+
* @param preset - the agent's preset id, or undefined.
|
|
80
|
+
*/
|
|
81
|
+
export function chainFor(config, preset) {
|
|
82
|
+
const presets = config.presets ?? {}
|
|
83
|
+
const list = preset !== undefined && preset !== null && Object.hasOwn(presets, preset)
|
|
84
|
+
? presets[preset]
|
|
85
|
+
: config.chain
|
|
86
|
+
return (list ?? []).filter(route => route && route.provider && route.model).map(toSelection)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether a failure code may switch models under the configured codes. */
|
|
90
|
+
export function switches(codes, code) {
|
|
91
|
+
if (NEVER_CODES.includes(code)) return false
|
|
92
|
+
return (codes ?? DEFAULT_CODES).includes(code)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Candidates after a failure, in order: when the failed route is in the
|
|
97
|
+
* chain, the entries after it and then the ones before it (so the chain moves
|
|
98
|
+
* forward); otherwise the whole chain. The failed route and everything
|
|
99
|
+
* already tried in this failure chain are left out, so each model is tried at
|
|
100
|
+
* most once per failure chain.
|
|
101
|
+
* @param chain - ordered fallback selections.
|
|
102
|
+
* @param failed - the route whose request failed.
|
|
103
|
+
* @param tried - route keys already used in this failure chain.
|
|
104
|
+
*/
|
|
105
|
+
export function candidates(chain, failed, tried) {
|
|
106
|
+
const at = chain.findIndex(route => sameRoute(route, failed))
|
|
107
|
+
const rest = at === -1 ? chain : [...chain.slice(at + 1), ...chain.slice(0, at)]
|
|
108
|
+
const seen = new Set(tried)
|
|
109
|
+
const out = []
|
|
110
|
+
for (const route of rest) {
|
|
111
|
+
const key = routeKey(route)
|
|
112
|
+
if (sameRoute(route, failed) || seen.has(key)) continue
|
|
113
|
+
seen.add(key)
|
|
114
|
+
out.push(route)
|
|
115
|
+
}
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Short account of a failure for notices and logs. */
|
|
120
|
+
export function failureText(failure) {
|
|
121
|
+
const message = String(failure.message ?? '').replace(/\s+/g, ' ').trim()
|
|
122
|
+
const short = message.length > 160 ? `${message.slice(0, 159)}…` : message
|
|
123
|
+
return short ? `${failure.code}: ${short}` : String(failure.code)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Whether a failure is a refused answer rather than a failed request. */
|
|
127
|
+
export function isRefusal(failure) {
|
|
128
|
+
return failure?.code === REFUSAL_CODE
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Bound a one-line UI summary to DSH's notice limit (120 characters). */
|
|
132
|
+
export function boundSummary(text) {
|
|
133
|
+
return text.length <= 120 ? text : `${text.slice(0, 119)}…`
|
|
134
|
+
}
|