@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/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
+ }