@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/client.js
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Browser half of dsh-fallback-models: the plugin's settings page.
|
|
3
|
+
*
|
|
4
|
+
* Plain JavaScript in DSH's Client module format (no build step). React comes
|
|
5
|
+
* from the page's module table. The page is a `plugins.row.config` entry, so
|
|
6
|
+
* Plugins > @soffits/dsh-fallback-models shows a Configure control on the plugin row.
|
|
7
|
+
* Values are read and saved through the `form` the Plugins page passes in
|
|
8
|
+
* (DSH's settings service writes the profile config); the model pickers list
|
|
9
|
+
* DSH's own model catalog (`remote.session.modelCatalog`, the list the
|
|
10
|
+
* composer's model picker shows). Styles use only host theme tokens (--dsw-*).
|
|
11
|
+
*
|
|
12
|
+
* It also draws a banner in the conversation wherever the plugin switched
|
|
13
|
+
* models: a Conversation Definition (uiConversation.events) turns the
|
|
14
|
+
* interrupted turn's `turn/end` into a Chat Node, and a keyed
|
|
15
|
+
* `conversation.chat.node` renderer draws it.
|
|
16
|
+
*/
|
|
17
|
+
window.__ModuleLoader__.load({
|
|
18
|
+
id: '@soffits/dsh-fallback-models',
|
|
19
|
+
factory(require) {
|
|
20
|
+
const React = require('react')
|
|
21
|
+
const h = React.createElement
|
|
22
|
+
const { useState, useEffect, useMemo } = React
|
|
23
|
+
|
|
24
|
+
const NS = 'dsh-fallback-models'
|
|
25
|
+
const KEY = '@soffits/dsh-fallback-models#fallback-models'
|
|
26
|
+
const STYLE_ID = 'dsh-fallback-models'
|
|
27
|
+
|
|
28
|
+
// Keep in step with src/policy.js DEFAULT_CODES.
|
|
29
|
+
const DEFAULT_CODES = ['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'QUOTA', 'ACCOUNT_QUOTA', 'PI_AI_ERROR', 'REFUSAL']
|
|
30
|
+
// Codes a model stream can report that stay off by default.
|
|
31
|
+
const OPTIONAL_CODES = ['AUTH', 'INVALID_REQUEST', 'CONTEXT_WINDOW_EXCEEDED']
|
|
32
|
+
// Chat Node kind of the switch banner, and the cancel reason it reads.
|
|
33
|
+
// Keep REASON in step with src/fallback.js switchReason.
|
|
34
|
+
const NODE_KIND = 'dsh-fallback-models'
|
|
35
|
+
const REASON_PREFIX = 'dsh-fallback-models:'
|
|
36
|
+
const REASON = /^dsh-fallback-models: (\S+) (failed|refused to answer) \(([A-Za-z0-9_]+)\); continuing on (\S+)$/
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------- text
|
|
39
|
+
|
|
40
|
+
const en = {
|
|
41
|
+
'summary.off': 'Off: no fallback models',
|
|
42
|
+
'summary.on': '{count} fallback model(s)',
|
|
43
|
+
'unavailable': 'This plugin is not running, so its settings cannot be changed right now.',
|
|
44
|
+
'readOnly': 'This deployment stores settings read-only.',
|
|
45
|
+
'chain.title': 'Fallback models',
|
|
46
|
+
'chain.help': 'When a model request still fails after DSH\'s own retries, the work continues on the next model here. Each model is tried at most once per failure. An empty list turns the plugin off.',
|
|
47
|
+
'chain.empty': 'No fallback models.',
|
|
48
|
+
'chain.add': 'Add model',
|
|
49
|
+
'route.provider': 'Provider',
|
|
50
|
+
'route.model': 'Model',
|
|
51
|
+
'route.effort': 'Reasoning effort',
|
|
52
|
+
'route.effortDefault': 'Model default',
|
|
53
|
+
'route.choose': 'Choose…',
|
|
54
|
+
'route.missing': '{id} (not available now)',
|
|
55
|
+
'route.up': 'Move up',
|
|
56
|
+
'route.down': 'Move down',
|
|
57
|
+
'route.remove': 'Remove',
|
|
58
|
+
'presets.title': 'Per agent preset',
|
|
59
|
+
'presets.help': 'A preset listed here uses its own fallback list instead of the one above. Leave its list empty to turn fallback off for that preset.',
|
|
60
|
+
'presets.add': 'Add preset',
|
|
61
|
+
'presets.id': 'Preset id',
|
|
62
|
+
'presets.remove': 'Remove preset',
|
|
63
|
+
'presets.duplicate': 'Each preset can be listed once.',
|
|
64
|
+
'scope.title': 'After a fallback answers',
|
|
65
|
+
'scope.turn': 'Go back to the session\'s own model when that turn ends',
|
|
66
|
+
'scope.session': 'Keep the fallback for the rest of the session',
|
|
67
|
+
'codes.title': 'Switch on these failures',
|
|
68
|
+
'codes.help': 'DSH\'s own retries run first. A cancelled request never switches. Rejected credentials and invalid requests are off by default, because switching would hide a problem you probably want to fix.',
|
|
69
|
+
'code.RATE_LIMIT': 'Rate limited',
|
|
70
|
+
'code.SERVER': 'Provider server error',
|
|
71
|
+
'code.TIMEOUT': 'Timed out',
|
|
72
|
+
'code.TRANSPORT': 'Connection failed',
|
|
73
|
+
'code.EMPTY_RESPONSE': 'Empty response',
|
|
74
|
+
'code.QUOTA': 'Quota or balance used up',
|
|
75
|
+
'code.ACCOUNT_QUOTA': 'Account quota used up',
|
|
76
|
+
'code.PI_AI_ERROR': 'Other provider error (for example, model not found)',
|
|
77
|
+
'code.REFUSAL': 'Refused or filter-blocked answer',
|
|
78
|
+
'banner.error.title': 'Model switched: {from} failed',
|
|
79
|
+
'banner.error.body': 'The request failed ({code}). The work continues on {to}.',
|
|
80
|
+
'banner.refusal.title': 'Model switched: {from} refused to answer',
|
|
81
|
+
'banner.refusal.body': 'Its reply was a refusal or a content-filter block, so it was hidden. {to} is answering instead.',
|
|
82
|
+
'banner.other': 'Model switched: {reason}',
|
|
83
|
+
'code.AUTH': 'Authentication failed',
|
|
84
|
+
'code.CONTEXT_WINDOW_EXCEEDED': 'Context window exceeded',
|
|
85
|
+
'code.INVALID_REQUEST': 'Request rejected as invalid',
|
|
86
|
+
'catalog.loading': 'Loading models…',
|
|
87
|
+
'catalog.error': 'Could not load the model list: {message}',
|
|
88
|
+
'catalog.partial': 'Some providers did not answer; their models are missing from the lists.',
|
|
89
|
+
'save': 'Save',
|
|
90
|
+
'saving': 'Saving…',
|
|
91
|
+
'saved': 'Saved.',
|
|
92
|
+
'discard': 'Discard changes',
|
|
93
|
+
'saveFailed': 'DSH did not accept these values; they are still here to correct.',
|
|
94
|
+
'invalid': 'Every row needs a provider and a model.',
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const zh = {
|
|
98
|
+
'summary.off': '已关闭:没有备用模型',
|
|
99
|
+
'summary.on': '{count} 个备用模型',
|
|
100
|
+
'unavailable': '插件没有在运行,暂时无法修改设置。',
|
|
101
|
+
'readOnly': '此部署的设置是只读的。',
|
|
102
|
+
'chain.title': '备用模型',
|
|
103
|
+
'chain.help': '模型请求在 DSH 自己的重试之后仍然失败时,改用这里的下一个模型继续。每次失败中每个模型最多试一次。列表为空时插件不起作用。',
|
|
104
|
+
'chain.empty': '没有备用模型。',
|
|
105
|
+
'chain.add': '添加模型',
|
|
106
|
+
'route.provider': '提供方',
|
|
107
|
+
'route.model': '模型',
|
|
108
|
+
'route.effort': '推理强度',
|
|
109
|
+
'route.effortDefault': '模型默认',
|
|
110
|
+
'route.choose': '选择…',
|
|
111
|
+
'route.missing': '{id}(当前不可用)',
|
|
112
|
+
'route.up': '上移',
|
|
113
|
+
'route.down': '下移',
|
|
114
|
+
'route.remove': '移除',
|
|
115
|
+
'presets.title': '按智能体预设',
|
|
116
|
+
'presets.help': '列在这里的预设使用自己的备用列表,而不是上面的列表。列表留空即对该预设关闭。',
|
|
117
|
+
'presets.add': '添加预设',
|
|
118
|
+
'presets.id': '预设 ID',
|
|
119
|
+
'presets.remove': '移除预设',
|
|
120
|
+
'presets.duplicate': '每个预设只能列一次。',
|
|
121
|
+
'scope.title': '备用模型回答之后',
|
|
122
|
+
'scope.turn': '这一轮结束后回到会话原来的模型',
|
|
123
|
+
'scope.session': '本会话之后一直用备用模型',
|
|
124
|
+
'codes.title': '遇到这些失败时切换',
|
|
125
|
+
'codes.help': 'DSH 自己的重试先进行。取消的请求从不切换。凭据被拒和无效请求默认不切换,因为切换会掩盖你多半想修好的问题。',
|
|
126
|
+
'code.RATE_LIMIT': '被限流',
|
|
127
|
+
'code.SERVER': '提供方服务器错误',
|
|
128
|
+
'code.TIMEOUT': '超时',
|
|
129
|
+
'code.TRANSPORT': '连接失败',
|
|
130
|
+
'code.EMPTY_RESPONSE': '空回复',
|
|
131
|
+
'code.QUOTA': '额度或余额用完',
|
|
132
|
+
'code.ACCOUNT_QUOTA': '账户额度用完',
|
|
133
|
+
'code.PI_AI_ERROR': '其他提供方错误(例如找不到模型)',
|
|
134
|
+
'code.REFUSAL': '回答被拒绝或被过滤',
|
|
135
|
+
'banner.error.title': '已切换模型:{from} 失败',
|
|
136
|
+
'banner.error.body': '请求失败({code}),改由 {to} 继续。',
|
|
137
|
+
'banner.refusal.title': '已切换模型:{from} 拒绝回答',
|
|
138
|
+
'banner.refusal.body': '它的回复是拒绝或被内容过滤拦下,已隐藏,改由 {to} 回答。',
|
|
139
|
+
'banner.other': '已切换模型:{reason}',
|
|
140
|
+
'code.AUTH': '认证失败',
|
|
141
|
+
'code.CONTEXT_WINDOW_EXCEEDED': '超出上下文窗口',
|
|
142
|
+
'code.INVALID_REQUEST': '请求被判为无效',
|
|
143
|
+
'catalog.loading': '正在加载模型…',
|
|
144
|
+
'catalog.error': '无法加载模型列表:{message}',
|
|
145
|
+
'catalog.partial': '部分提供方没有响应,列表里缺少它们的模型。',
|
|
146
|
+
'save': '保存',
|
|
147
|
+
'saving': '正在保存…',
|
|
148
|
+
'saved': '已保存。',
|
|
149
|
+
'discard': '放弃修改',
|
|
150
|
+
'saveFailed': 'DSH 没有接受这些值,它们还留在这里,可以改正。',
|
|
151
|
+
'invalid': '每一行都需要选提供方和模型。',
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const CSS = `
|
|
155
|
+
.dfm-page { display: flex; flex-direction: column; gap: 20px; color: var(--dsw-alias-label-primary); font-size: 13px; }
|
|
156
|
+
.dfm-section { display: flex; flex-direction: column; gap: 8px; }
|
|
157
|
+
.dfm-title { font-weight: 600; font-size: 14px; }
|
|
158
|
+
.dfm-help { color: var(--dsw-alias-label-tertiary); line-height: 1.5; }
|
|
159
|
+
.dfm-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 8px; border: 1px solid var(--dsw-alias-border-l3); border-radius: var(--dsw-radius-md); }
|
|
160
|
+
.dfm-index { width: 20px; color: var(--dsw-alias-label-tertiary); text-align: right; }
|
|
161
|
+
.dfm-select, .dfm-input { min-width: 120px; max-width: 260px; flex: 1 1 140px; padding: 4px 6px; border: 1px solid var(--dsw-alias-border-l3); border-radius: var(--dsw-radius-sm); background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font: inherit; }
|
|
162
|
+
.dfm-button { padding: 4px 10px; border: 1px solid var(--dsw-alias-border-l3); border-radius: var(--dsw-radius-sm); background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; cursor: pointer; }
|
|
163
|
+
.dfm-button:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
|
|
164
|
+
.dfm-button:disabled { opacity: 0.5; cursor: default; }
|
|
165
|
+
.dfm-button:focus-visible, .dfm-select:focus-visible, .dfm-input:focus-visible { outline: 2px solid var(--dsw-focus-ring-color); outline-offset: 1px; }
|
|
166
|
+
.dfm-primary { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-border-l2); }
|
|
167
|
+
.dfm-icon { padding: 4px 7px; }
|
|
168
|
+
.dfm-preset { display: flex; flex-direction: column; gap: 6px; padding: 10px; border: 1px solid var(--dsw-alias-border-l4); border-radius: var(--dsw-radius-md); }
|
|
169
|
+
.dfm-preset-head { display: flex; gap: 6px; align-items: center; }
|
|
170
|
+
.dfm-check { display: flex; gap: 8px; align-items: center; }
|
|
171
|
+
.dfm-checks { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 4px 16px; }
|
|
172
|
+
.dfm-code { color: var(--dsw-alias-label-caption); font-family: var(--dsw-font-mono); font-size: 11px; }
|
|
173
|
+
.dfm-footer { display: flex; gap: 8px; align-items: center; }
|
|
174
|
+
.dfm-error { color: var(--dsw-alias-state-error-primary); }
|
|
175
|
+
.dfm-note { color: var(--dsw-alias-label-tertiary); }
|
|
176
|
+
.dfm-banner { display: flex; gap: 10px; align-items: flex-start; margin: 8px 0; padding: 10px 14px; border: 1px solid var(--dsw-alias-state-warn-primary); border-left-width: 4px; border-radius: var(--dsw-radius-md); background: var(--dsw-alias-state-warn-tertiary); color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 1.5; }
|
|
177
|
+
.dfm-banner-icon { flex: none; color: var(--dsw-alias-state-warn-primary); font-size: 16px; line-height: 21px; }
|
|
178
|
+
.dfm-banner-title { font-weight: 600; color: var(--dsw-alias-state-warn-label); }
|
|
179
|
+
.dfm-banner-body { color: var(--dsw-alias-label-secondary); }
|
|
180
|
+
`
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------- data
|
|
183
|
+
|
|
184
|
+
const clean = (route) => ({
|
|
185
|
+
provider: String(route?.provider ?? ''),
|
|
186
|
+
model: String(route?.model ?? ''),
|
|
187
|
+
...(route?.reasoningEffort ? { reasoningEffort: String(route.reasoningEffort) } : {}),
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
/** Editable copy of the accepted values. */
|
|
191
|
+
function toDraft(value) {
|
|
192
|
+
const presets = value?.presets && typeof value.presets === 'object' ? value.presets : {}
|
|
193
|
+
return {
|
|
194
|
+
chain: (Array.isArray(value?.chain) ? value.chain : []).map(clean),
|
|
195
|
+
presets: Object.keys(presets).map(preset => ({ preset, chain: (presets[preset] ?? []).map(clean) })),
|
|
196
|
+
codes: Array.isArray(value?.codes) ? [...value.codes] : [...DEFAULT_CODES],
|
|
197
|
+
scope: value?.scope === 'session' ? 'session' : 'turn',
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Draft back to config field values. */
|
|
202
|
+
function fromDraft(draft) {
|
|
203
|
+
const presets = {}
|
|
204
|
+
for (const entry of draft.presets) presets[entry.preset.trim()] = entry.chain.map(clean)
|
|
205
|
+
return { chain: draft.chain.map(clean), presets, codes: [...draft.codes], scope: draft.scope }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
|
|
209
|
+
|
|
210
|
+
function problems(draft) {
|
|
211
|
+
const chains = [draft.chain, ...draft.presets.map(entry => entry.chain)]
|
|
212
|
+
const incomplete = chains.some(chain => chain.some(route => !route.provider || !route.model))
|
|
213
|
+
const ids = draft.presets.map(entry => entry.preset.trim())
|
|
214
|
+
const badPreset = ids.some(id => id === '') || new Set(ids).size !== ids.length
|
|
215
|
+
return { incomplete, badPreset }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The shared model catalog, loaded once per page visit. */
|
|
219
|
+
function useCatalog(session) {
|
|
220
|
+
const [state, setState] = useState({ status: 'loading', groups: [], partial: false, error: null })
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
let live = true
|
|
223
|
+
Promise.resolve()
|
|
224
|
+
.then(() => session.modelCatalog())
|
|
225
|
+
.then((response) => {
|
|
226
|
+
if (!live) return
|
|
227
|
+
if (!response.ok) throw new Error(response.error?.message ?? 'error')
|
|
228
|
+
setState({ status: 'ready', groups: response.value.groups ?? [], partial: (response.value.failures ?? []).length > 0, error: null })
|
|
229
|
+
})
|
|
230
|
+
.catch((error) => {
|
|
231
|
+
if (live) setState({ status: 'error', groups: [], partial: false, error: error instanceof Error ? error.message : String(error) })
|
|
232
|
+
})
|
|
233
|
+
return () => { live = false }
|
|
234
|
+
}, [session])
|
|
235
|
+
return state
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------- view
|
|
239
|
+
|
|
240
|
+
function RouteRow({ route, index, count, groups, t, onChange, onMove, onRemove, disabled }) {
|
|
241
|
+
const group = groups.find(item => item.id === route.provider)
|
|
242
|
+
const model = group?.models.find(item => item.id === route.model)
|
|
243
|
+
const efforts = model?.reasoning?.efforts ?? []
|
|
244
|
+
const label = (text) => ({ 'aria-label': `${text} ${index + 1}` })
|
|
245
|
+
|
|
246
|
+
const providerOptions = [h('option', { key: '', value: '' }, t('route.choose'))]
|
|
247
|
+
for (const item of groups) providerOptions.push(h('option', { key: item.id, value: item.id }, item.name || item.id))
|
|
248
|
+
if (route.provider && !group) providerOptions.push(h('option', { key: route.provider, value: route.provider }, t('route.missing', { id: route.provider })))
|
|
249
|
+
|
|
250
|
+
const modelOptions = [h('option', { key: '', value: '' }, t('route.choose'))]
|
|
251
|
+
for (const item of group?.models ?? []) modelOptions.push(h('option', { key: item.id, value: item.id }, item.name || item.id))
|
|
252
|
+
if (route.model && !model) modelOptions.push(h('option', { key: route.model, value: route.model }, t('route.missing', { id: route.model })))
|
|
253
|
+
|
|
254
|
+
const effortOptions = [h('option', { key: '', value: '' }, t('route.effortDefault'))]
|
|
255
|
+
for (const item of efforts) effortOptions.push(h('option', { key: item.id, value: item.id }, item.name || item.id))
|
|
256
|
+
if (route.reasoningEffort && !efforts.some(item => item.id === route.reasoningEffort)) {
|
|
257
|
+
effortOptions.push(h('option', { key: route.reasoningEffort, value: route.reasoningEffort }, route.reasoningEffort))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return h('div', { className: 'dfm-row', role: 'group', ...label(t('route.model')) },
|
|
261
|
+
h('span', { className: 'dfm-index' }, `${index + 1}.`),
|
|
262
|
+
h('select', {
|
|
263
|
+
className: 'dfm-select', value: route.provider, disabled, ...label(t('route.provider')),
|
|
264
|
+
onChange: event => onChange({ provider: event.target.value, model: '' }),
|
|
265
|
+
}, providerOptions),
|
|
266
|
+
h('select', {
|
|
267
|
+
className: 'dfm-select', value: route.model, disabled: disabled || !route.provider, ...label(t('route.model')),
|
|
268
|
+
onChange: event => onChange({ ...route, model: event.target.value, reasoningEffort: undefined }),
|
|
269
|
+
}, modelOptions),
|
|
270
|
+
h('select', {
|
|
271
|
+
className: 'dfm-select', value: route.reasoningEffort ?? '', disabled: disabled || !route.model, ...label(t('route.effort')),
|
|
272
|
+
onChange: event => onChange({ ...route, reasoningEffort: event.target.value || undefined }),
|
|
273
|
+
}, effortOptions),
|
|
274
|
+
h('button', { type: 'button', className: 'dfm-button dfm-icon', disabled: disabled || index === 0, title: t('route.up'), 'aria-label': t('route.up'), onClick: () => onMove(-1) }, '↑'),
|
|
275
|
+
h('button', { type: 'button', className: 'dfm-button dfm-icon', disabled: disabled || index === count - 1, title: t('route.down'), 'aria-label': t('route.down'), onClick: () => onMove(1) }, '↓'),
|
|
276
|
+
h('button', { type: 'button', className: 'dfm-button dfm-icon', disabled, title: t('route.remove'), 'aria-label': t('route.remove'), onClick: onRemove }, '×'))
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function ChainEditor({ chain, groups, t, disabled, onChange }) {
|
|
280
|
+
const update = (index, route) => onChange(chain.map((item, i) => (i === index ? clean(route) : item)))
|
|
281
|
+
const move = (index, by) => {
|
|
282
|
+
const next = [...chain]
|
|
283
|
+
const [item] = next.splice(index, 1)
|
|
284
|
+
next.splice(index + by, 0, item)
|
|
285
|
+
onChange(next)
|
|
286
|
+
}
|
|
287
|
+
return h('div', { className: 'dfm-section' },
|
|
288
|
+
chain.length === 0 && h('div', { className: 'dfm-note' }, t('chain.empty')),
|
|
289
|
+
chain.map((route, index) => h(RouteRow, {
|
|
290
|
+
key: index, route, index, count: chain.length, groups, t, disabled,
|
|
291
|
+
onChange: next => update(index, next),
|
|
292
|
+
onMove: by => move(index, by),
|
|
293
|
+
onRemove: () => onChange(chain.filter((_, i) => i !== index)),
|
|
294
|
+
})),
|
|
295
|
+
h('div', null, h('button', {
|
|
296
|
+
type: 'button', className: 'dfm-button', disabled,
|
|
297
|
+
onClick: () => onChange([...chain, { provider: '', model: '' }]),
|
|
298
|
+
}, t('chain.add'))))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function SettingsPage({ form, session, t }) {
|
|
302
|
+
const state = form.state
|
|
303
|
+
const accepted = useMemo(() => toDraft(state.value), [state.value])
|
|
304
|
+
const [draft, setDraft] = useState(accepted)
|
|
305
|
+
const [status, setStatus] = useState(null)
|
|
306
|
+
// Follow Host changes while nothing is staged.
|
|
307
|
+
const [base, setBase] = useState(accepted)
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
if (same(draft, base)) setDraft(accepted)
|
|
310
|
+
setBase(accepted)
|
|
311
|
+
}, [accepted])
|
|
312
|
+
const catalog = useCatalog(session)
|
|
313
|
+
|
|
314
|
+
const disabled = !state.writable || status === 'saving'
|
|
315
|
+
const dirty = !same(fromDraft(draft), fromDraft(accepted))
|
|
316
|
+
const { incomplete, badPreset } = problems(draft)
|
|
317
|
+
|
|
318
|
+
const save = async () => {
|
|
319
|
+
const next = fromDraft(draft)
|
|
320
|
+
const current = fromDraft(accepted)
|
|
321
|
+
const ops = []
|
|
322
|
+
for (const field of ['chain', 'presets', 'codes', 'scope']) {
|
|
323
|
+
if (!same(next[field], current[field])) ops.push({ op: 'set', path: [field], value: next[field] })
|
|
324
|
+
}
|
|
325
|
+
if (ops.length === 0) return
|
|
326
|
+
setStatus('saving')
|
|
327
|
+
try {
|
|
328
|
+
setStatus((await form.mutate(ops, state.revision)) ? 'saved' : 'failed')
|
|
329
|
+
} catch {
|
|
330
|
+
setStatus('failed')
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const set = (patch) => { setStatus(null); setDraft({ ...draft, ...patch }) }
|
|
335
|
+
const codes = [...DEFAULT_CODES, ...OPTIONAL_CODES, ...draft.codes.filter(code => !DEFAULT_CODES.includes(code) && !OPTIONAL_CODES.includes(code))]
|
|
336
|
+
const toggle = (code) => set({ codes: draft.codes.includes(code) ? draft.codes.filter(item => item !== code) : [...draft.codes, code] })
|
|
337
|
+
const codeLabel = (code) => {
|
|
338
|
+
const text = t(`code.${code}`)
|
|
339
|
+
return text === `code.${code}` ? code : text
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return h('div', { className: 'dfm-page' },
|
|
343
|
+
!state.writable && h('div', { className: 'dfm-note' }, t('readOnly')),
|
|
344
|
+
catalog.status === 'loading' && h('div', { className: 'dfm-note', role: 'status' }, t('catalog.loading')),
|
|
345
|
+
catalog.status === 'error' && h('div', { className: 'dfm-error', role: 'alert' }, t('catalog.error', { message: catalog.error })),
|
|
346
|
+
catalog.partial && h('div', { className: 'dfm-note' }, t('catalog.partial')),
|
|
347
|
+
|
|
348
|
+
h('section', { className: 'dfm-section' },
|
|
349
|
+
h('div', { className: 'dfm-title' }, t('chain.title')),
|
|
350
|
+
h('div', { className: 'dfm-help' }, t('chain.help')),
|
|
351
|
+
h(ChainEditor, { chain: draft.chain, groups: catalog.groups, t, disabled, onChange: chain => set({ chain }) })),
|
|
352
|
+
|
|
353
|
+
h('section', { className: 'dfm-section' },
|
|
354
|
+
h('div', { className: 'dfm-title' }, t('scope.title')),
|
|
355
|
+
['turn', 'session'].map(scope => h('label', { key: scope, className: 'dfm-check' },
|
|
356
|
+
h('input', { type: 'radio', name: 'dfm-scope', checked: draft.scope === scope, disabled, onChange: () => set({ scope }) }),
|
|
357
|
+
t(`scope.${scope}`)))),
|
|
358
|
+
|
|
359
|
+
h('section', { className: 'dfm-section' },
|
|
360
|
+
h('div', { className: 'dfm-title' }, t('presets.title')),
|
|
361
|
+
h('div', { className: 'dfm-help' }, t('presets.help')),
|
|
362
|
+
draft.presets.map((entry, index) => h('div', { key: index, className: 'dfm-preset' },
|
|
363
|
+
h('div', { className: 'dfm-preset-head' },
|
|
364
|
+
h('input', {
|
|
365
|
+
className: 'dfm-input', value: entry.preset, placeholder: t('presets.id'), 'aria-label': t('presets.id'), disabled,
|
|
366
|
+
onChange: event => set({ presets: draft.presets.map((item, i) => (i === index ? { ...item, preset: event.target.value } : item)) }),
|
|
367
|
+
}),
|
|
368
|
+
h('button', {
|
|
369
|
+
type: 'button', className: 'dfm-button', disabled,
|
|
370
|
+
onClick: () => set({ presets: draft.presets.filter((_, i) => i !== index) }),
|
|
371
|
+
}, t('presets.remove'))),
|
|
372
|
+
h(ChainEditor, {
|
|
373
|
+
chain: entry.chain, groups: catalog.groups, t, disabled,
|
|
374
|
+
onChange: chain => set({ presets: draft.presets.map((item, i) => (i === index ? { ...item, chain } : item)) }),
|
|
375
|
+
}))),
|
|
376
|
+
h('div', null, h('button', {
|
|
377
|
+
type: 'button', className: 'dfm-button', disabled,
|
|
378
|
+
onClick: () => set({ presets: [...draft.presets, { preset: '', chain: [] }] }),
|
|
379
|
+
}, t('presets.add')))),
|
|
380
|
+
|
|
381
|
+
h('section', { className: 'dfm-section' },
|
|
382
|
+
h('div', { className: 'dfm-title' }, t('codes.title')),
|
|
383
|
+
h('div', { className: 'dfm-help' }, t('codes.help')),
|
|
384
|
+
h('div', { className: 'dfm-checks' }, codes.map(code => h('label', { key: code, className: 'dfm-check' },
|
|
385
|
+
h('input', { type: 'checkbox', checked: draft.codes.includes(code), disabled, onChange: () => toggle(code) }),
|
|
386
|
+
h('span', null, codeLabel(code), ' ', h('span', { className: 'dfm-code' }, code)))))),
|
|
387
|
+
|
|
388
|
+
h('div', { className: 'dfm-footer' },
|
|
389
|
+
h('button', {
|
|
390
|
+
type: 'button', className: 'dfm-button dfm-primary',
|
|
391
|
+
disabled: disabled || !dirty || incomplete || badPreset, onClick: save,
|
|
392
|
+
}, status === 'saving' ? t('saving') : t('save')),
|
|
393
|
+
h('button', {
|
|
394
|
+
type: 'button', className: 'dfm-button', disabled: disabled || !dirty,
|
|
395
|
+
onClick: () => { setStatus(null); setDraft(accepted) },
|
|
396
|
+
}, t('discard')),
|
|
397
|
+
incomplete && h('span', { className: 'dfm-error' }, t('invalid')),
|
|
398
|
+
badPreset && h('span', { className: 'dfm-error' }, t('presets.duplicate')),
|
|
399
|
+
status === 'saved' && !dirty && h('span', { className: 'dfm-note', role: 'status' }, t('saved')),
|
|
400
|
+
status === 'failed' && h('span', { className: 'dfm-error', role: 'alert' }, t('saveFailed'))))
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function FallbackConfig({ view, form, session, translate: t }) {
|
|
404
|
+
const value = form?.state?.value
|
|
405
|
+
if (view === 'summary') {
|
|
406
|
+
const count = Array.isArray(value?.chain) ? value.chain.length : 0
|
|
407
|
+
return count === 0 ? t('summary.off') : t('summary.on', { count })
|
|
408
|
+
}
|
|
409
|
+
if (!form || form.state.status !== 'ready') return h('div', { className: 'dfm-note' }, t('unavailable'))
|
|
410
|
+
return h(SettingsPage, { form, session, t })
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------- banner
|
|
414
|
+
|
|
415
|
+
/** Banner facts from the cancel reason the Host plugin records, or null. */
|
|
416
|
+
function parseReason(text) {
|
|
417
|
+
if (typeof text !== 'string' || !text.startsWith(REASON_PREFIX)) return null
|
|
418
|
+
const match = REASON.exec(text)
|
|
419
|
+
if (!match) return { raw: text.slice(REASON_PREFIX.length).trim() }
|
|
420
|
+
return { from: match[1], refusal: match[2] !== 'failed', code: match[3], to: match[4] }
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function hookReason(event) {
|
|
424
|
+
if (event?.type !== 'turn/end') return undefined
|
|
425
|
+
const reason = event.data?.reason
|
|
426
|
+
if (reason?.kind !== 'aborted' || reason.reason?.kind !== 'hook') return undefined
|
|
427
|
+
const text = reason.reason.reason
|
|
428
|
+
return typeof text === 'string' && text.startsWith(REASON_PREFIX) ? text : undefined
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** One banner per turn the plugin interrupted, placed at that turn's end. */
|
|
432
|
+
const switchDefinition = {
|
|
433
|
+
kind: NODE_KIND,
|
|
434
|
+
target: 'chat',
|
|
435
|
+
match: (event) => (hookReason(event) === undefined ? null : { id: String(event.data.turn), role: 'start' }),
|
|
436
|
+
start: (_context, match) => ({
|
|
437
|
+
turn: match.event.data.turn,
|
|
438
|
+
seq: match.event.seq,
|
|
439
|
+
time: match.event.time,
|
|
440
|
+
...parseReason(hookReason(match.event)),
|
|
441
|
+
}),
|
|
442
|
+
update: (context) => context.state,
|
|
443
|
+
buildViewNode: (context) => {
|
|
444
|
+
const state = context.state
|
|
445
|
+
if (state === undefined) return null
|
|
446
|
+
return {
|
|
447
|
+
key: context.key,
|
|
448
|
+
kind: NODE_KIND,
|
|
449
|
+
id: context.id,
|
|
450
|
+
target: 'chat',
|
|
451
|
+
anchorSeq: state.seq,
|
|
452
|
+
location: context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' },
|
|
453
|
+
visibility: 'visible',
|
|
454
|
+
groupTree: 'turn',
|
|
455
|
+
data: state,
|
|
456
|
+
}
|
|
457
|
+
},
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function SwitchBanner({ node, t }) {
|
|
461
|
+
const data = node?.data ?? {}
|
|
462
|
+
const title = data.raw !== undefined
|
|
463
|
+
? t('banner.other', { reason: data.raw })
|
|
464
|
+
: t(data.refusal ? 'banner.refusal.title' : 'banner.error.title', data)
|
|
465
|
+
const body = data.raw !== undefined ? null : t(data.refusal ? 'banner.refusal.body' : 'banner.error.body', data)
|
|
466
|
+
return h('div', { className: 'dfm-banner', role: 'status', 'data-model-fallback': data.refusal ? 'refusal' : 'error' },
|
|
467
|
+
h('span', { className: 'dfm-banner-icon', 'aria-hidden': true }, '⇄'),
|
|
468
|
+
h('div', null,
|
|
469
|
+
h('div', { className: 'dfm-banner-title' }, title),
|
|
470
|
+
body && h('div', { className: 'dfm-banner-body' }, body)))
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ---------------------------------------------------------------- plugin
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
inject: ['slots', 'locale', 'remote', 'remote.session', 'uiConversation'],
|
|
477
|
+
apply(ctx) {
|
|
478
|
+
ctx.effect(() => {
|
|
479
|
+
const disposers = [ctx.locale.register(NS, 'en', en), ctx.locale.register(NS, 'zh', zh)]
|
|
480
|
+
return () => { for (const dispose of disposers) dispose() }
|
|
481
|
+
}, 'fallback-models: dictionaries')
|
|
482
|
+
const t = ctx.locale.bind(NS)
|
|
483
|
+
|
|
484
|
+
ctx.effect(() => {
|
|
485
|
+
if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return () => {}
|
|
486
|
+
const tag = document.createElement('style')
|
|
487
|
+
tag.id = STYLE_ID
|
|
488
|
+
tag.dataset.plugin = 'dsh-fallback-models'
|
|
489
|
+
tag.textContent = CSS
|
|
490
|
+
document.head.appendChild(tag)
|
|
491
|
+
return () => tag.remove()
|
|
492
|
+
}, 'fallback-models: styles')
|
|
493
|
+
|
|
494
|
+
ctx.effect(() => ctx.uiConversation.events.register(switchDefinition), 'fallback-models: switch banner events')
|
|
495
|
+
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
|
|
496
|
+
name: 'conversation.chat.node',
|
|
497
|
+
key: NODE_KIND,
|
|
498
|
+
locale: NS,
|
|
499
|
+
}, SwitchBanner))
|
|
500
|
+
|
|
501
|
+
const session = ctx.remote.session
|
|
502
|
+
ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
|
|
503
|
+
name: 'plugins.row.config',
|
|
504
|
+
key: KEY,
|
|
505
|
+
locale: NS,
|
|
506
|
+
inject: () => ({ session, translate: t }),
|
|
507
|
+
}, FallbackConfig))
|
|
508
|
+
},
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
})
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Bundle layer for dsh-fallback-models. The chain is edited on the plugin's
|
|
2
|
+
# settings page (Plugins > @soffits/dsh-fallback-models > Configure) or here in the
|
|
3
|
+
# profile patch; an empty chain leaves model requests untouched.
|
|
4
|
+
- insert:
|
|
5
|
+
- id: fallback-models
|
|
6
|
+
name: '@soffits/dsh-fallback-models'
|
|
7
|
+
config:
|
|
8
|
+
chain: []
|
package/icon.svg
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
|
2
|
+
<rect x="6" y="6" width="30" height="20" rx="6" fill="#1f2937"/>
|
|
3
|
+
<path d="M15 12l8 8M23 12l-8 8" stroke="#fca5a5" stroke-width="3.5" stroke-linecap="round"/>
|
|
4
|
+
<rect x="28" y="38" width="30" height="20" rx="6" fill="#1f2937"/>
|
|
5
|
+
<path d="M36 48l5 5 9-10" fill="none" stroke="#a7f3d0" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
6
|
+
<path d="M21 30v6a6 6 0 0 0 6 6h0" fill="none" stroke="#4f46e5" stroke-width="4" stroke-linecap="round"/>
|
|
7
|
+
<path d="M23 36l5 6-6 5" fill="none" stroke="#4f46e5" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
8
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek Harness Host plugin: a fallback model chain. When a model request
|
|
3
|
+
* keeps failing after DSH's own retries, the work continues on the next model
|
|
4
|
+
* in a configured list instead of ending the turn. See src/fallback.js for
|
|
5
|
+
* how the switch is made, and README.md for the behaviour.
|
|
6
|
+
*/
|
|
7
|
+
import { Config } from './src/config.js'
|
|
8
|
+
import { installFallback } from './src/fallback.js'
|
|
9
|
+
|
|
10
|
+
export const name = 'dsh-fallback-models'
|
|
11
|
+
export const inject = ['llm']
|
|
12
|
+
export { Config }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
16
|
+
* @param {{ chain: unknown, presets: unknown, codes: unknown, scope: unknown }} config
|
|
17
|
+
*/
|
|
18
|
+
export function apply(ctx, config) {
|
|
19
|
+
installFallback(ctx, config)
|
|
20
|
+
}
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Fallback models",
|
|
4
|
+
"description": "When a model request still fails after DSH's own retries (rate limit, server error, timeout, used-up quota), or the model refuses to answer, the work continues on the next model in your fallback list instead of ending the turn, and the conversation shows a banner saying which model took over."
|
|
5
|
+
}
|
|
6
|
+
}
|