@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/package.json CHANGED
@@ -1,6 +1,91 @@
1
1
  {
2
2
  "name": "@soffits/dsh-fallback-models",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ })
@@ -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
+ }