@apinggithub/mydsh-auto-model-router 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 +522 -3
- package/README.zh.md +463 -0
- package/auto-adapter.mjs +132 -0
- package/classifier.mjs +86 -0
- package/client.js +123 -0
- package/compat.mjs +199 -0
- package/config.mjs +461 -0
- package/cordis.patch.yml +11 -0
- package/cost-control.mjs +159 -0
- package/fallback.mjs +62 -0
- package/index.mjs +429 -0
- package/notify.mjs +653 -0
- package/package.json +74 -6
- package/router.mjs +751 -0
- package/rules.mjs +59 -0
- package/scoring.mjs +69 -0
- package/text-width.mjs +73 -0
package/notify.mjs
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
// Plugin status reporting and one-time user consultation (bilingual).
|
|
2
|
+
//
|
|
3
|
+
// Surfaces, all host-side (no client bundle needed):
|
|
4
|
+
//
|
|
5
|
+
// 1. REPORT: on the first session of a plugin load (including hot reload), a
|
|
6
|
+
// durable plugin user-message is injected describing the active routing
|
|
7
|
+
// policy — levels→models, cost mode, budget, fallback chain. Language
|
|
8
|
+
// follows the user's DSH locale preference (settings locale namespace),
|
|
9
|
+
// falling back to 'en'.
|
|
10
|
+
//
|
|
11
|
+
// 2. ASK (three questions, once per plugin load):
|
|
12
|
+
// Q1 Tier-1 rules: keep, or view the current rules to decide?
|
|
13
|
+
// Q2 Tier-2 cost mode: keep, or switch to cost-first/balanced/quality-first?
|
|
14
|
+
// Q3 Other config: skip, or check a named setting?
|
|
15
|
+
// Answers apply live to the router runtime: Q2 switches the mode, Q1/Q3
|
|
16
|
+
// inject the requested current values so the user can decide (the durable
|
|
17
|
+
// change itself stays in cordis.patch.yml, which the message says).
|
|
18
|
+
//
|
|
19
|
+
// 3. DOWNGRADE: when the session budget trips, notify or ask per
|
|
20
|
+
// costControl.downgradeBehavior (also bilingual).
|
|
21
|
+
|
|
22
|
+
export const PLUGIN_SOURCE = 'mydsh-auto-model-router'
|
|
23
|
+
const ASKED_KEY = Symbol('mydsh-auto-model-router.asked')
|
|
24
|
+
|
|
25
|
+
// ── i18n ─────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const STRINGS = {
|
|
28
|
+
en: {
|
|
29
|
+
statusTitle: 'Model router is active. Current routing policy:',
|
|
30
|
+
levels: 'Levels:',
|
|
31
|
+
notConfigured: '(not configured)',
|
|
32
|
+
costMode: 'Cost mode',
|
|
33
|
+
defaultLevel: 'Default level',
|
|
34
|
+
budget: 'Session token budget',
|
|
35
|
+
rules: 'Rules',
|
|
36
|
+
classifierEnabled: 'LLM classifier: enabled',
|
|
37
|
+
classifierDisabled: 'LLM classifier: disabled',
|
|
38
|
+
fallback: 'Fallback chain',
|
|
39
|
+
statusFooter: "Adjust these in your profile's cordis.patch.yml under the mydsh-auto-model-router config block, or answer the question below to switch the cost mode now.",
|
|
40
|
+
askHeader: 'Model Router',
|
|
41
|
+
q1: 'Do you want to change the Tier-1 matching rules?',
|
|
42
|
+
q1Keep: 'Keep current rules',
|
|
43
|
+
q1View: 'View current rules',
|
|
44
|
+
q2: 'Do you want to switch the Tier-2 cost mode?',
|
|
45
|
+
q2Keep: 'Keep current mode',
|
|
46
|
+
q2CostFirst: 'cost-first',
|
|
47
|
+
q2Balanced: 'balanced',
|
|
48
|
+
q2QualityFirst: 'quality-first',
|
|
49
|
+
q3: 'Do you want to adjust any other configuration?',
|
|
50
|
+
q3Skip: 'Skip',
|
|
51
|
+
q3Check: 'Check a setting',
|
|
52
|
+
q3CustomHint: 'Type which setting to inspect (e.g. budget, fallbackChain, classifier)',
|
|
53
|
+
rulesView: 'Current Tier-1 rules:',
|
|
54
|
+
rulesViewEmpty: 'No rules configured.',
|
|
55
|
+
rulesViewFooter: 'To change a rule, edit the mydsh-auto-model-router `rules` block in cordis.patch.yml.',
|
|
56
|
+
settingUnknown: 'Unknown setting "{{name}}". Known: budget, fallbackChain, classifier, rules.',
|
|
57
|
+
budgetDetail: 'costControl.tokenBudgetPerSession = {{value}} tokens',
|
|
58
|
+
fallbackDetail: 'fallbackChain: {{value}}',
|
|
59
|
+
classifierDetail: 'llmClassifier.enabled = {{value}}',
|
|
60
|
+
downgradeTitle: 'Model Router — budget exhausted',
|
|
61
|
+
downgradeQuestion: 'Session {{session}} hit its token budget. Switch to the cheaper model for the rest of this session?',
|
|
62
|
+
downgradeAccept: 'Use cheaper model',
|
|
63
|
+
downgradeKeep: 'Keep current model',
|
|
64
|
+
lockCrossedQuestion: 'This input crossed the heuristic lock ceiling. How should the router proceed?',
|
|
65
|
+
lockCrossedDetail: 'input width exceeded the top threshold',
|
|
66
|
+
fixedModel: 'Pin a fixed level',
|
|
67
|
+
fixedModelDesc: 'Use one model level for the next inputs; re-ask after a few inputs.',
|
|
68
|
+
enableAuto: 'Enable fully-auto routing',
|
|
69
|
+
enableAutoDesc: 'Keyword rules score first; zero-score inputs go to the LLM classifier.',
|
|
70
|
+
fixedLevelQuestion: 'Which level should this session use?',
|
|
71
|
+
fixedReelectQuestion: 'Fixed level session: pick the level for the next inputs?',
|
|
72
|
+
fixedLevelDesc: (level, config) => {
|
|
73
|
+
const route = config.levels?.[level]
|
|
74
|
+
return route ? `${route.provider}/${route.model} (effort: ${route.reasoningEffort ?? 'off'})` : 'not configured'
|
|
75
|
+
},
|
|
76
|
+
downgradeBody: [
|
|
77
|
+
'Session {{session}} has exhausted its token budget ({{budget}} tokens).',
|
|
78
|
+
'Remaining requests in this session use the cheapest level ({{model}}).',
|
|
79
|
+
'Raise costControl.tokenBudgetPerSession in cordis.patch.yml to lift the cap.',
|
|
80
|
+
].join('\n'),
|
|
81
|
+
freeQuota: 'Free-quota routes',
|
|
82
|
+
catalogGuard: 'Catalog freshness check',
|
|
83
|
+
disabled: 'disabled',
|
|
84
|
+
staleFlag: 'STALE',
|
|
85
|
+
staleIs: 'stale (id no longer advertised by the provider), candidates:',
|
|
86
|
+
staleNone: 'no same-family candidate found',
|
|
87
|
+
freeQuotaDemotedTitle: 'Model Router — free route unavailable',
|
|
88
|
+
freeQuotaDemotedQuestion: 'The free route {{from}} failed ({{code}}). Route this session through {{to}} instead?',
|
|
89
|
+
freeQuotaKeep: 'Keep retrying the free route',
|
|
90
|
+
freeQuotaAccept: 'Use the paid route',
|
|
91
|
+
freeQuotaBody: [
|
|
92
|
+
'Session {{session}} could not use the free-quota route {{from}} (level {{level}}).',
|
|
93
|
+
'Reason: {{code}}.',
|
|
94
|
+
'This session now routes that level through {{to}}; the free route is retried in the next session.',
|
|
95
|
+
'Edit freeQuota in cordis.patch.yml to change this behaviour.',
|
|
96
|
+
].join('\n'),
|
|
97
|
+
},
|
|
98
|
+
zh: {
|
|
99
|
+
statusTitle: '模型路由插件已激活。当前路由策略:',
|
|
100
|
+
levels: '层级:',
|
|
101
|
+
notConfigured: '(未配置)',
|
|
102
|
+
costMode: '成本模式',
|
|
103
|
+
defaultLevel: '默认层级',
|
|
104
|
+
budget: '会话 token 预算',
|
|
105
|
+
rules: '规则数',
|
|
106
|
+
classifierEnabled: 'LLM 分类器:已启用',
|
|
107
|
+
classifierDisabled: 'LLM 分类器:未启用',
|
|
108
|
+
fallback: '降级链',
|
|
109
|
+
statusFooter: '可在 profile 的 cordis.patch.yml 中 mydsh-auto-model-router 配置块调整以上项,或回答下面的问题立即切换成本模式。',
|
|
110
|
+
askHeader: '模型路由插件',
|
|
111
|
+
q1: '是否需要更改 Tier-1 匹配规则?',
|
|
112
|
+
q1Keep: '保持现有规则',
|
|
113
|
+
q1View: '查看现有规则',
|
|
114
|
+
q2: '是否需要切换 Tier-2 成本模式?',
|
|
115
|
+
q2Keep: '保持当前模式',
|
|
116
|
+
q2CostFirst: 'cost-first(成本优先)',
|
|
117
|
+
q2Balanced: 'balanced(均衡)',
|
|
118
|
+
q2QualityFirst: 'quality-first(效果优先)',
|
|
119
|
+
q3: '是否需要调整其他配置?',
|
|
120
|
+
q3Skip: '跳过',
|
|
121
|
+
q3Check: '检查某项配置',
|
|
122
|
+
q3CustomHint: '输入要检查的配置名(如 budget、fallbackChain、classifier)',
|
|
123
|
+
rulesView: '当前 Tier-1 规则:',
|
|
124
|
+
rulesViewEmpty: '未配置规则。',
|
|
125
|
+
rulesViewFooter: '要修改规则,请编辑 cordis.patch.yml 中 mydsh-auto-model-router 的 rules 配置块。',
|
|
126
|
+
settingUnknown: '未知配置 "{{name}}"。可选:budget、fallbackChain、classifier、rules。',
|
|
127
|
+
budgetDetail: 'costControl.tokenBudgetPerSession = {{value}} tokens',
|
|
128
|
+
fallbackDetail: 'fallbackChain:{{value}}',
|
|
129
|
+
classifierDetail: 'llmClassifier.enabled = {{value}}',
|
|
130
|
+
downgradeTitle: '模型路由插件 — 预算已耗尽',
|
|
131
|
+
downgradeQuestion: '会话 {{session}} 已达到 token 预算。本会话剩余请求切换到更便宜的模型?',
|
|
132
|
+
downgradeAccept: '使用更便宜的模型',
|
|
133
|
+
downgradeKeep: '继续使用当前模型',
|
|
134
|
+
freeQuota: '免费额度路由',
|
|
135
|
+
catalogGuard: '目录新鲜度检查',
|
|
136
|
+
disabled: '已禁用',
|
|
137
|
+
staleFlag: '已过期',
|
|
138
|
+
staleIs: '过期(provider 已不再公布该 id),候选:',
|
|
139
|
+
staleNone: '未找到同族候选',
|
|
140
|
+
freeQuotaDemotedTitle: '模型路由插件 — 免费路由不可用',
|
|
141
|
+
freeQuotaDemotedQuestion: '免费路由 {{from}} 调用失败({{code}})。本会话改用 {{to}} 继续?',
|
|
142
|
+
freeQuotaKeep: '继续重试免费路由',
|
|
143
|
+
freeQuotaAccept: '改用付费路由',
|
|
144
|
+
freeQuotaBody: [
|
|
145
|
+
'会话 {{session}} 无法继续使用免费额度路由 {{from}}(层级 {{level}})。',
|
|
146
|
+
'原因:{{code}}。',
|
|
147
|
+
'本会话该层级改用 {{to}};免费路由会在下一个会话重新尝试。',
|
|
148
|
+
'可在 cordis.patch.yml 的 freeQuota 配置块中调整此行为。',
|
|
149
|
+
].join('\n'),
|
|
150
|
+
lockCrossedQuestion: '本次输入已超过启发式锁上限,路由如何继续?',
|
|
151
|
+
lockCrossedDetail: '输入宽度超过最高阈值',
|
|
152
|
+
fixedModel: '固定使用某个层级',
|
|
153
|
+
fixedModelDesc: '接下来几次输入固定用该层级;数轮后重新询问。',
|
|
154
|
+
enableAuto: '开启全自动路由',
|
|
155
|
+
enableAutoDesc: '关键词规则先积分定级;零分输入交给 LLM 分类器判断。',
|
|
156
|
+
fixedLevelQuestion: '本会话固定使用哪个层级?',
|
|
157
|
+
fixedReelectQuestion: '固定层级会话:接下来几次输入用哪个层级?',
|
|
158
|
+
fixedLevelDesc: (level, config) => {
|
|
159
|
+
const route = config.levels?.[level]
|
|
160
|
+
return route ? `${route.provider}/${route.model}(思考: ${route.reasoningEffort ?? 'off'})` : '未配置'
|
|
161
|
+
},
|
|
162
|
+
downgradeBody: [
|
|
163
|
+
'会话 {{session}} 已耗尽 token 预算({{budget}} tokens)。',
|
|
164
|
+
'本会话剩余请求使用最便宜层级({{model}})。',
|
|
165
|
+
'调高 cordis.patch.yml 中 costControl.tokenBudgetPerSession 可提高上限。',
|
|
166
|
+
].join('\n'),
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Resolve the user's locale from the DSH settings document, falling back to
|
|
172
|
+
* 'en'. `ctx.settings` may be absent in minimal harnesses — never throw.
|
|
173
|
+
* @param {object} ctx - cordis context.
|
|
174
|
+
* @returns {'zh' | 'en'}
|
|
175
|
+
*/
|
|
176
|
+
export function resolveLocale(ctx) {
|
|
177
|
+
try {
|
|
178
|
+
const settings = ctx?.get?.('settings')
|
|
179
|
+
const locale = settings?.get?.('locale')
|
|
180
|
+
if (locale?.preference === 'zh') return 'zh'
|
|
181
|
+
} catch { /* fall through */ }
|
|
182
|
+
return 'en'
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Build the status report text from the resolved config.
|
|
187
|
+
* @param {object} config - resolved router config.
|
|
188
|
+
* @param {'zh'|'en'} [locale]
|
|
189
|
+
* @returns {string}
|
|
190
|
+
*/
|
|
191
|
+
export function statusReport(config, locale = 'en', extras = {}) {
|
|
192
|
+
const t = STRINGS[locale] ?? STRINGS.en
|
|
193
|
+
const lines = ['<mydsh-auto-model-router-status>', t.statusTitle, '', t.levels]
|
|
194
|
+
for (const id of ['L0', 'L1', 'L2', 'L3']) {
|
|
195
|
+
const route = config.levels[id]
|
|
196
|
+
const stale = extras.stale?.[id]
|
|
197
|
+
lines.push(`- ${id}: ${route
|
|
198
|
+
? `${route.provider}/${route.model} (effort: ${route.reasoningEffort ?? 'off'})${stale ? ` — ${t.staleFlag}` : ''}`
|
|
199
|
+
: t.notConfigured}`)
|
|
200
|
+
}
|
|
201
|
+
lines.push('', `${t.costMode}: ${config.costControl.mode}`)
|
|
202
|
+
lines.push(`${t.defaultLevel}: ${config.costControl.defaultLevel}`)
|
|
203
|
+
lines.push(`${t.budget}: ${config.costControl.tokenBudgetPerSession}`)
|
|
204
|
+
lines.push(`${t.rules}: ${config.rules.length} configured`)
|
|
205
|
+
lines.push(config.llmClassifier.enabled
|
|
206
|
+
? `${t.classifierEnabled} (${config.llmClassifier.model?.provider}/${config.llmClassifier.model?.model})`
|
|
207
|
+
: t.classifierDisabled)
|
|
208
|
+
if (config.fallbackChain.length > 0) {
|
|
209
|
+
lines.push(`${t.fallback}: ${config.fallbackChain.map(r => `${r.provider}/${r.model}`).join(' → ')}`)
|
|
210
|
+
}
|
|
211
|
+
const freeSummary = freeQuotaRoutesOf(config)
|
|
212
|
+
if (freeSummary.length > 0) {
|
|
213
|
+
lines.push(`${t.freeQuota}: ${freeSummary.join(' · ')}`)
|
|
214
|
+
}
|
|
215
|
+
lines.push(`${t.catalogGuard}: ${config.modelsGuard?.enabled === false ? t.disabled : `every ${Math.round((config.modelsGuard?.refreshMs ?? 600000) / 60000)} min`}`)
|
|
216
|
+
for (const [id, info] of Object.entries(extras.stale ?? {})) {
|
|
217
|
+
const candidates = info.candidates?.length > 0 ? info.candidates.join(', ') : t.staleNone
|
|
218
|
+
lines.push(`- ${id}${t.staleIs} ${info.provider}/${info.model} → ${candidates}`)
|
|
219
|
+
}
|
|
220
|
+
lines.push('', t.statusFooter, '</mydsh-auto-model-router-status>')
|
|
221
|
+
return lines.join('\n')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Describe every route declared free, for the status report:
|
|
226
|
+
* levels flagged `freeQuota: true` plus `freeQuota.routes` entries (with the
|
|
227
|
+
* level ceiling each one covers).
|
|
228
|
+
* @param {object} config - resolved router config.
|
|
229
|
+
* @returns {string[]}
|
|
230
|
+
*/
|
|
231
|
+
export function freeQuotaRoutesOf(config) {
|
|
232
|
+
const out = []
|
|
233
|
+
for (const id of ['L0', 'L1', 'L2', 'L3']) {
|
|
234
|
+
const route = config.levels[id]
|
|
235
|
+
if (route?.freeQuota === true) out.push(`${id}: ${route.provider}/${route.model}`)
|
|
236
|
+
}
|
|
237
|
+
for (const entry of config.freeQuota?.routes ?? []) {
|
|
238
|
+
out.push(`≤${entry.maxLevel}: ${entry.provider}/${entry.model}`)
|
|
239
|
+
}
|
|
240
|
+
return out
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** `provider/model` label for a route, or a placeholder. */
|
|
244
|
+
function routeLabel(route) {
|
|
245
|
+
return route && route.provider ? `${route.provider}/${route.model}` : '(unknown)'
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── message injection ────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
// The message constructor comes from @deepseek-ai/dsh-llm (exact-pinned peer);
|
|
251
|
+
// when that package is absent (unit tests without node_modules), fall back to
|
|
252
|
+
// the plain shape DSH accepts. In a real profile the peer is always present.
|
|
253
|
+
let cachedCreateUserMessage
|
|
254
|
+
async function createPluginUserMessage(content, form = 'status') {
|
|
255
|
+
if (cachedCreateUserMessage === undefined) {
|
|
256
|
+
try {
|
|
257
|
+
const llm = await import('@deepseek-ai/dsh-llm')
|
|
258
|
+
cachedCreateUserMessage = llm.createUserMessage
|
|
259
|
+
} catch {
|
|
260
|
+
cachedCreateUserMessage = null
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const payload = {
|
|
264
|
+
content: [{ type: 'text', text: content }],
|
|
265
|
+
source: { kind: 'plugin', plugin: PLUGIN_SOURCE, form },
|
|
266
|
+
}
|
|
267
|
+
return cachedCreateUserMessage ? cachedCreateUserMessage(payload) : { role: 'user', ...payload }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Inject a message into an idle agent.
|
|
272
|
+
* @param {object} agent - live agent.
|
|
273
|
+
* @param {string} content
|
|
274
|
+
* @param {string} form
|
|
275
|
+
* @returns {Promise<boolean>}
|
|
276
|
+
*/
|
|
277
|
+
async function injectInto(agent, content, form) {
|
|
278
|
+
if (agent.status !== 'idle') return false
|
|
279
|
+
agent.inject(await createPluginUserMessage(content, form))
|
|
280
|
+
return true
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Inject the status report into the first session of this plugin load.
|
|
285
|
+
* @param {object} agent - the live agent.
|
|
286
|
+
* @param {object} config - resolved router config.
|
|
287
|
+
* @param {'zh'|'en'} [locale]
|
|
288
|
+
* @returns {Promise<boolean>}
|
|
289
|
+
*/
|
|
290
|
+
export async function reportStatus(agent, config, locale = 'en') {
|
|
291
|
+
return injectInto(agent, statusReport(config, locale), 'status')
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export const STATUS_STORAGE_KEY = 'mydsh-auto-model-router:status'
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Persist the status report to localStorage so the browser settings section
|
|
298
|
+
* can render it without depending on the DOM transcript. Best-effort:
|
|
299
|
+
* storage failures (private mode, quota) only skip persistence.
|
|
300
|
+
* @param {object} config - resolved router config.
|
|
301
|
+
* @param {'zh'|'en'} [locale]
|
|
302
|
+
* @returns {string} the persisted report text.
|
|
303
|
+
*/
|
|
304
|
+
export function persistStatusReport(config, locale = 'en') {
|
|
305
|
+
const text = statusReport(config, locale)
|
|
306
|
+
try {
|
|
307
|
+
if (typeof localStorage !== 'undefined') {
|
|
308
|
+
localStorage.setItem(STATUS_STORAGE_KEY, text)
|
|
309
|
+
}
|
|
310
|
+
} catch { /* best-effort */ }
|
|
311
|
+
return text
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── three-question consultation ──────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
function modeFromLabel(label, locale) {
|
|
317
|
+
const t = STRINGS[locale] ?? STRINGS.en
|
|
318
|
+
const map = {
|
|
319
|
+
[t.q2CostFirst]: 'cost-first',
|
|
320
|
+
[t.q2Balanced]: 'balanced',
|
|
321
|
+
[t.q2QualityFirst]: 'quality-first',
|
|
322
|
+
}
|
|
323
|
+
return map[label] ?? null
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Ask the user three questions once per plugin load (bilingual):
|
|
328
|
+
* Q1 rules, Q2 cost mode, Q3 other config.
|
|
329
|
+
* Degrades silently when the user-questions channel is unavailable.
|
|
330
|
+
*
|
|
331
|
+
* @param {object} ctx - cordis context.
|
|
332
|
+
* @param {object} agent - the live root agent.
|
|
333
|
+
* @param {object} router - the ModelRouter instance (config mutated on answer).
|
|
334
|
+
* @returns {Promise<object|null>} summary of the user's answers, or null when
|
|
335
|
+
* the channel was unavailable. { viewedRules, mode, checkedSetting }.
|
|
336
|
+
*/
|
|
337
|
+
export async function askOnce(ctx, agent, router) {
|
|
338
|
+
if (ctx[ASKED_KEY]) return null
|
|
339
|
+
ctx[ASKED_KEY] = true
|
|
340
|
+
const userQuestions = ctx.get?.('userQuestions')
|
|
341
|
+
if (!userQuestions) return null
|
|
342
|
+
const locale = resolveLocale(ctx)
|
|
343
|
+
const t = STRINGS[locale] ?? STRINGS.en
|
|
344
|
+
try {
|
|
345
|
+
const answer = await userQuestions.ask({
|
|
346
|
+
agent,
|
|
347
|
+
questions: [
|
|
348
|
+
{
|
|
349
|
+
id: 'mydsh-auto-model-router.q1-rules',
|
|
350
|
+
header: t.askHeader,
|
|
351
|
+
question: t.q1,
|
|
352
|
+
detail: statusReport(router.config, locale),
|
|
353
|
+
options: [
|
|
354
|
+
{ label: t.q1Keep, description: 'Tier-1 rules stay as configured.' },
|
|
355
|
+
{ label: t.q1View, description: 'Show the current rules in this session.' },
|
|
356
|
+
],
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
id: 'mydsh-auto-model-router.q2-cost-mode',
|
|
360
|
+
header: t.askHeader,
|
|
361
|
+
question: t.q2,
|
|
362
|
+
detail: statusReport(router.config, locale),
|
|
363
|
+
options: [
|
|
364
|
+
{ label: t.q2Keep, description: `Keep ${router.config.costControl.mode}.` },
|
|
365
|
+
{ label: t.q2CostFirst, description: 'Prefer cheaper models; drift down on ambiguity.' },
|
|
366
|
+
{ label: t.q2Balanced, description: 'Default level; rules decide, budget caps.' },
|
|
367
|
+
{ label: t.q2QualityFirst, description: 'Prefer stronger models; drift up on ambiguity.' },
|
|
368
|
+
],
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
id: 'mydsh-auto-model-router.q3-other',
|
|
372
|
+
header: t.askHeader,
|
|
373
|
+
question: t.q3,
|
|
374
|
+
detail: statusReport(router.config, locale),
|
|
375
|
+
options: [
|
|
376
|
+
{ label: t.q3Skip, description: 'Leave everything else unchanged.' },
|
|
377
|
+
{ label: t.q3Check, description: t.q3CustomHint },
|
|
378
|
+
],
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
const byId = new Map((answer?.answers ?? []).map(item => [item.id, item]))
|
|
384
|
+
const q1 = byId.get('mydsh-auto-model-router.q1-rules')
|
|
385
|
+
const q2 = byId.get('mydsh-auto-model-router.q2-cost-mode')
|
|
386
|
+
const q3 = byId.get('mydsh-auto-model-router.q3-other')
|
|
387
|
+
|
|
388
|
+
const viewedRules = q1?.selected?.includes(t.q1View) ?? false
|
|
389
|
+
if (viewedRules) {
|
|
390
|
+
await injectInto(agent, rulesReport(router.config, locale), 'rules')
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const mode = q2 ? modeFromLabel(q2.selected?.[0], locale) : null
|
|
394
|
+
if (mode) router.config.costControl.mode = mode
|
|
395
|
+
|
|
396
|
+
const checked = q3?.selected?.includes(t.q3Check) ?? false
|
|
397
|
+
const customSetting = String(q3?.custom ?? '').trim() || undefined
|
|
398
|
+
if (checked) {
|
|
399
|
+
await injectInto(agent, settingReport(router.config, locale, customSetting), 'setting')
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return { viewedRules, mode, checkedSetting: checked ? (customSetting ?? '') : undefined }
|
|
403
|
+
} catch {
|
|
404
|
+
// No provider / not live / aborted: report-only is fine.
|
|
405
|
+
return null
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Build a human-readable listing of the current rules.
|
|
411
|
+
* @param {object} config
|
|
412
|
+
* @param {'zh'|'en'} locale
|
|
413
|
+
* @returns {string}
|
|
414
|
+
*/
|
|
415
|
+
export function rulesReport(config, locale = 'en') {
|
|
416
|
+
const t = STRINGS[locale] ?? STRINGS.en
|
|
417
|
+
const lines = ['<mydsh-auto-model-router-rules>', t.rulesView]
|
|
418
|
+
if (config.rules.length === 0) {
|
|
419
|
+
lines.push(`- ${t.rulesViewEmpty}`)
|
|
420
|
+
} else {
|
|
421
|
+
for (const rule of config.rules) {
|
|
422
|
+
lines.push(`- ${rule.match} → ${rule.level}`)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
lines.push(t.rulesViewFooter, '</mydsh-auto-model-router-rules>')
|
|
426
|
+
return lines.join('\n')
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Build a report of one named setting (or a hint when unknown).
|
|
431
|
+
* @param {object} config
|
|
432
|
+
* @param {'zh'|'en'} locale
|
|
433
|
+
* @param {string} [name]
|
|
434
|
+
* @returns {string}
|
|
435
|
+
*/
|
|
436
|
+
export function settingReport(config, locale = 'en', name) {
|
|
437
|
+
const t = STRINGS[locale] ?? STRINGS.en
|
|
438
|
+
const key = String(name ?? '').toLowerCase()
|
|
439
|
+
let detail
|
|
440
|
+
if (key === 'budget' || key === 'tokenbudget') {
|
|
441
|
+
detail = t.budgetDetail.replace('{{value}}', String(config.costControl.tokenBudgetPerSession))
|
|
442
|
+
} else if (key === 'fallbackchain' || key === 'fallback') {
|
|
443
|
+
const value = config.fallbackChain.length > 0
|
|
444
|
+
? config.fallbackChain.map(r => `${r.provider}/${r.model}`).join(' → ')
|
|
445
|
+
: '(none)'
|
|
446
|
+
detail = t.fallbackDetail.replace('{{value}}', value)
|
|
447
|
+
} else if (key === 'classifier') {
|
|
448
|
+
detail = t.classifierDetail.replace('{{value}}', String(config.llmClassifier.enabled))
|
|
449
|
+
} else if (key === 'rules') {
|
|
450
|
+
return rulesReport(config, locale)
|
|
451
|
+
} else if (key === '') {
|
|
452
|
+
detail = t.settingUnknown.replace('{{name}}', '(none)')
|
|
453
|
+
} else {
|
|
454
|
+
detail = t.settingUnknown.replace('{{name}}', name)
|
|
455
|
+
}
|
|
456
|
+
return ['<mydsh-auto-model-router-setting>', detail, '</mydsh-auto-model-router-setting>'].join('\n')
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── budget downgrade ─────────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Build the budget-downgrade notice text.
|
|
463
|
+
* @param {object} config - resolved router config.
|
|
464
|
+
* @param {string} sessionId
|
|
465
|
+
* @param {'zh'|'en'} locale
|
|
466
|
+
* @returns {string}
|
|
467
|
+
*/
|
|
468
|
+
export function downgradeNotice(config, sessionId, locale = 'en') {
|
|
469
|
+
const t = STRINGS[locale] ?? STRINGS.en
|
|
470
|
+
const cheap = config.levels.L0
|
|
471
|
+
? `${config.levels.L0.provider}/${config.levels.L0.model}`
|
|
472
|
+
: 'L0 (not configured)'
|
|
473
|
+
const body = t.downgradeBody
|
|
474
|
+
.replaceAll('{{session}}', sessionId)
|
|
475
|
+
.replaceAll('{{budget}}', config.costControl.tokenBudgetPerSession.toLocaleString())
|
|
476
|
+
.replaceAll('{{model}}', cheap)
|
|
477
|
+
return `<mydsh-auto-model-router-downgrade>\n${body}\n</mydsh-auto-model-router-downgrade>`
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Inject a user-visible downgrade notice (downgradeBehavior: notify).
|
|
482
|
+
* @param {object} agent - the live agent.
|
|
483
|
+
* @param {object} config - resolved router config.
|
|
484
|
+
* @param {string} sessionId
|
|
485
|
+
* @param {'zh'|'en'} locale
|
|
486
|
+
* @returns {Promise<boolean>}
|
|
487
|
+
*/
|
|
488
|
+
export async function notifyDowngrade(agent, config, sessionId, locale = 'en') {
|
|
489
|
+
return injectInto(agent, downgradeNotice(config, sessionId, locale), 'downgrade')
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Build the free-quota demotion notice text.
|
|
494
|
+
* @param {string} sessionId
|
|
495
|
+
* @param {{ level?: string, from?: object, to?: object, code?: string }} info
|
|
496
|
+
* @param {'zh'|'en'} locale
|
|
497
|
+
* @returns {string}
|
|
498
|
+
*/
|
|
499
|
+
export function freeQuotaNotice(sessionId, info, locale = 'en') {
|
|
500
|
+
const t = STRINGS[locale] ?? STRINGS.en
|
|
501
|
+
const body = t.freeQuotaBody
|
|
502
|
+
.replaceAll('{{session}}', sessionId)
|
|
503
|
+
.replaceAll('{{level}}', info?.level ?? '-')
|
|
504
|
+
.replaceAll('{{from}}', routeLabel(info?.from))
|
|
505
|
+
.replaceAll('{{to}}', routeLabel(info?.to))
|
|
506
|
+
.replaceAll('{{code}}', info?.code ?? 'request failed')
|
|
507
|
+
return `<mydsh-auto-model-router-free-quota>\n${body}\n</mydsh-auto-model-router-free-quota>`
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Inject a user-visible notice that a free-quota route was demoted for this
|
|
512
|
+
* session (freeQuota.notify).
|
|
513
|
+
* @param {object} agent - the live agent.
|
|
514
|
+
* @param {string} sessionId
|
|
515
|
+
* @param {{ level?: string, from?: object, to?: object, code?: string }} info
|
|
516
|
+
* @param {'zh'|'en'} locale
|
|
517
|
+
* @returns {Promise<boolean>}
|
|
518
|
+
*/
|
|
519
|
+
export async function notifyFreeQuotaFallback(agent, sessionId, info, locale = 'en') {
|
|
520
|
+
return injectInto(agent, freeQuotaNotice(sessionId, info, locale), 'free-quota')
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Ask the user whether to keep the cheap model or continue with the
|
|
525
|
+
* pre-budget model (downgradeBehavior: ask). Returns true when the user
|
|
526
|
+
* wants to keep the original model (caller waives the budget), false when
|
|
527
|
+
* they accept the downgrade or the channel is unavailable.
|
|
528
|
+
*
|
|
529
|
+
* @param {object} ctx - cordis context.
|
|
530
|
+
* @param {object} agent - the live root agent.
|
|
531
|
+
* @param {object} router - the ModelRouter instance.
|
|
532
|
+
* @param {string} sessionId
|
|
533
|
+
* @returns {Promise<boolean>} true = keep original model (waive budget).
|
|
534
|
+
*/
|
|
535
|
+
export async function askDowngrade(ctx, agent, router, sessionId) {
|
|
536
|
+
const userQuestions = ctx.get?.('userQuestions')
|
|
537
|
+
if (!userQuestions) return false
|
|
538
|
+
const locale = resolveLocale(ctx)
|
|
539
|
+
const t = STRINGS[locale] ?? STRINGS.en
|
|
540
|
+
try {
|
|
541
|
+
const answer = await userQuestions.ask({
|
|
542
|
+
agent,
|
|
543
|
+
questions: [{
|
|
544
|
+
id: 'mydsh-auto-model-router.downgrade',
|
|
545
|
+
header: t.downgradeTitle,
|
|
546
|
+
question: t.downgradeQuestion.replace('{{session}}', sessionId),
|
|
547
|
+
detail: downgradeNotice(router.config, sessionId, locale),
|
|
548
|
+
options: [
|
|
549
|
+
{ label: t.downgradeAccept, description: 'Continue with the cheapest level; stays under budget.' },
|
|
550
|
+
{ label: t.downgradeKeep, description: 'Ignore the budget for the rest of this session.' },
|
|
551
|
+
],
|
|
552
|
+
}],
|
|
553
|
+
})
|
|
554
|
+
const selected = answer?.answers?.[0]?.selected?.[0]
|
|
555
|
+
// Default (no answer, aborted) = accept the downgrade.
|
|
556
|
+
return selected === t.downgradeKeep
|
|
557
|
+
} catch {
|
|
558
|
+
// No provider / not live: accept the downgrade silently.
|
|
559
|
+
return false
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Ask the user how to proceed once the heuristic lock is crossed
|
|
565
|
+
* (width > top threshold). Options: pick a fixed level, or enable fully-auto
|
|
566
|
+
* (keyword rules + LLM classifier).
|
|
567
|
+
*
|
|
568
|
+
* @param {object} ctx - cordis context.
|
|
569
|
+
* @param {object} agent - the live root agent.
|
|
570
|
+
* @param {object} router - the ModelRouter instance.
|
|
571
|
+
* @param {string} sessionId
|
|
572
|
+
* @returns {Promise<{kind:'fixed', level:string}|{kind:'auto'}|null>}
|
|
573
|
+
* null when the channel is unavailable or the user dismissed.
|
|
574
|
+
*/
|
|
575
|
+
export async function askModeSelection(ctx, agent, router, sessionId) {
|
|
576
|
+
const userQuestions = ctx.get?.('userQuestions')
|
|
577
|
+
if (!userQuestions) return null
|
|
578
|
+
const locale = resolveLocale(ctx)
|
|
579
|
+
const t = STRINGS[locale] ?? STRINGS.en
|
|
580
|
+
try {
|
|
581
|
+
const answer = await userQuestions.ask({
|
|
582
|
+
agent,
|
|
583
|
+
questions: [{
|
|
584
|
+
id: 'mydsh-auto-model-router.mode',
|
|
585
|
+
header: t.askHeader,
|
|
586
|
+
question: t.lockCrossedQuestion,
|
|
587
|
+
options: [
|
|
588
|
+
{ label: t.fixedModel, description: t.fixedModelDesc },
|
|
589
|
+
{ label: t.enableAuto, description: t.enableAutoDesc },
|
|
590
|
+
],
|
|
591
|
+
}],
|
|
592
|
+
})
|
|
593
|
+
const selected = answer?.answers?.[0]?.selected?.[0]
|
|
594
|
+
if (selected === t.enableAuto) return { kind: 'auto' }
|
|
595
|
+
if (selected === t.fixedModel) {
|
|
596
|
+
// Second question: which level to pin.
|
|
597
|
+
const levelAnswer = await userQuestions.ask({
|
|
598
|
+
agent,
|
|
599
|
+
questions: [{
|
|
600
|
+
id: 'mydsh-auto-model-router.fixed-level',
|
|
601
|
+
header: t.askHeader,
|
|
602
|
+
question: t.fixedLevelQuestion,
|
|
603
|
+
options: ['L0', 'L1', 'L2', 'L3'].map(level => ({
|
|
604
|
+
label: level,
|
|
605
|
+
description: t.fixedLevelDesc(level, router.config),
|
|
606
|
+
})),
|
|
607
|
+
}],
|
|
608
|
+
})
|
|
609
|
+
const level = levelAnswer?.answers?.[0]?.selected?.[0]
|
|
610
|
+
if (level && ['L0', 'L1', 'L2', 'L3'].includes(level)) {
|
|
611
|
+
return { kind: 'fixed', level }
|
|
612
|
+
}
|
|
613
|
+
return null
|
|
614
|
+
}
|
|
615
|
+
return null
|
|
616
|
+
} catch {
|
|
617
|
+
return null
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Re-elect a fixed level after askEveryInputs inputs in fixed mode.
|
|
623
|
+
* @param {object} ctx
|
|
624
|
+
* @param {object} agent
|
|
625
|
+
* @param {object} router
|
|
626
|
+
* @param {string} sessionId
|
|
627
|
+
* @returns {Promise<string|null>} a LEVEL_IDS entry or null.
|
|
628
|
+
*/
|
|
629
|
+
export async function askFixedLevel(ctx, agent, router, sessionId) {
|
|
630
|
+
const userQuestions = ctx.get?.('userQuestions')
|
|
631
|
+
if (!userQuestions) return null
|
|
632
|
+
const locale = resolveLocale(ctx)
|
|
633
|
+
const t = STRINGS[locale] ?? STRINGS.en
|
|
634
|
+
try {
|
|
635
|
+
const answer = await userQuestions.ask({
|
|
636
|
+
agent,
|
|
637
|
+
questions: [{
|
|
638
|
+
id: 'mydsh-auto-model-router.fixed-reelect',
|
|
639
|
+
header: t.askHeader,
|
|
640
|
+
question: t.fixedReelectQuestion,
|
|
641
|
+
options: ['L0', 'L1', 'L2', 'L3'].map(level => ({
|
|
642
|
+
label: level,
|
|
643
|
+
description: t.fixedLevelDesc(level, router.config),
|
|
644
|
+
})),
|
|
645
|
+
}],
|
|
646
|
+
})
|
|
647
|
+
const level = answer?.answers?.[0]?.selected?.[0]
|
|
648
|
+
if (level && ['L0', 'L1', 'L2', 'L3'].includes(level)) return level
|
|
649
|
+
return null
|
|
650
|
+
} catch {
|
|
651
|
+
return null
|
|
652
|
+
}
|
|
653
|
+
}
|