dsh-llm-fallbacks 0.2.1 → 0.3.0
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/README.md +82 -9
- package/README.zh-CN.md +82 -9
- package/dist/automatch.d.ts +98 -0
- package/dist/client/FallbacksCard.d.ts +30 -15
- package/dist/client/fallbacks-store.d.ts +60 -10
- package/dist/client/index.d.ts +9 -7
- package/dist/client/index.js +2267 -985
- package/dist/client/locales.d.ts +140 -35
- package/dist/commands.d.ts +22 -7
- package/dist/config.d.ts +35 -1
- package/dist/events.d.ts +19 -5
- package/dist/index.d.ts +8 -5
- package/dist/index.js +620 -43
- package/dist/role-resolution.d.ts +81 -0
- package/dist/roles.d.ts +20 -5
- package/dist/time-slots.d.ts +104 -0
- package/dist/virtual-adapter.d.ts +90 -0
- package/package.json +20 -19
- package/scripts/repair-fallbacks-switch-logs.ts +374 -0
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
3
|
-
import * as dshSession from "@deepseek-ai/dsh-session";
|
|
4
3
|
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import { LlmAdapter, LlmError, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
5
5
|
import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
6
6
|
//#region .build/host/selectors.js
|
|
7
7
|
var SelectorError = class extends Error {
|
|
@@ -35,6 +35,202 @@ function resolveWildcardEntry(failingModel, provider) {
|
|
|
35
35
|
raw: `${provider}/${failingModel}`
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
|
+
const PRESETS = {
|
|
39
|
+
"liang-peak": {
|
|
40
|
+
windows: [{
|
|
41
|
+
start: "09:00",
|
|
42
|
+
end: "12:00"
|
|
43
|
+
}, {
|
|
44
|
+
start: "14:00",
|
|
45
|
+
end: "18:00"
|
|
46
|
+
}],
|
|
47
|
+
complement: false,
|
|
48
|
+
label: "Liang Peak"
|
|
49
|
+
},
|
|
50
|
+
"liang-valley": {
|
|
51
|
+
windows: [{
|
|
52
|
+
start: "09:00",
|
|
53
|
+
end: "12:00"
|
|
54
|
+
}, {
|
|
55
|
+
start: "14:00",
|
|
56
|
+
end: "18:00"
|
|
57
|
+
}],
|
|
58
|
+
complement: true,
|
|
59
|
+
label: "Liang Valley"
|
|
60
|
+
},
|
|
61
|
+
"glm-peak": {
|
|
62
|
+
windows: [{
|
|
63
|
+
start: "14:00",
|
|
64
|
+
end: "18:00",
|
|
65
|
+
days: [
|
|
66
|
+
1,
|
|
67
|
+
2,
|
|
68
|
+
3,
|
|
69
|
+
4,
|
|
70
|
+
5
|
|
71
|
+
]
|
|
72
|
+
}],
|
|
73
|
+
complement: false,
|
|
74
|
+
label: "GLM Peak"
|
|
75
|
+
},
|
|
76
|
+
"glm-valley": {
|
|
77
|
+
windows: [{
|
|
78
|
+
start: "14:00",
|
|
79
|
+
end: "18:00",
|
|
80
|
+
days: [
|
|
81
|
+
1,
|
|
82
|
+
2,
|
|
83
|
+
3,
|
|
84
|
+
4,
|
|
85
|
+
5
|
|
86
|
+
]
|
|
87
|
+
}],
|
|
88
|
+
complement: true,
|
|
89
|
+
label: "GLM Valley"
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const HHMM_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
93
|
+
const WEEKDAY_INDEX = {
|
|
94
|
+
Sun: 0,
|
|
95
|
+
Mon: 1,
|
|
96
|
+
Tue: 2,
|
|
97
|
+
Wed: 3,
|
|
98
|
+
Thu: 4,
|
|
99
|
+
Fri: 5,
|
|
100
|
+
Sat: 6
|
|
101
|
+
};
|
|
102
|
+
const warnedMalformedRows = new WeakSet();
|
|
103
|
+
const warnedTimeZones = new Set();
|
|
104
|
+
function warnMalformed(row, reason) {
|
|
105
|
+
if (typeof row !== "object" || row === null) {
|
|
106
|
+
console.warn(`llm-fallbacks: skipping malformed time-slot row (${reason})`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (warnedMalformedRows.has(row)) return;
|
|
110
|
+
warnedMalformedRows.add(row);
|
|
111
|
+
console.warn(`llm-fallbacks: skipping malformed time-slot row (${reason}): ${JSON.stringify(row)}`);
|
|
112
|
+
}
|
|
113
|
+
function wallClock(now, tz) {
|
|
114
|
+
try {
|
|
115
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
116
|
+
timeZone: tz,
|
|
117
|
+
weekday: "short",
|
|
118
|
+
hour: "2-digit",
|
|
119
|
+
minute: "2-digit",
|
|
120
|
+
hourCycle: "h23"
|
|
121
|
+
}).formatToParts(now);
|
|
122
|
+
let weekday = 0;
|
|
123
|
+
let minutes = 0;
|
|
124
|
+
for (const part of parts) if (part.type === "weekday") weekday = WEEKDAY_INDEX[part.value] ?? 0;
|
|
125
|
+
else if (part.type === "hour") minutes += Number(part.value) * 60;
|
|
126
|
+
else if (part.type === "minute") minutes += Number(part.value);
|
|
127
|
+
return {
|
|
128
|
+
weekday,
|
|
129
|
+
minutes
|
|
130
|
+
};
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (!warnedTimeZones.has(tz)) {
|
|
133
|
+
warnedTimeZones.add(tz);
|
|
134
|
+
console.warn(`llm-fallbacks: invalid timezone "${tz}" (${error.message}) — slot matching falls back to UTC`);
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
weekday: now.getUTCDay(),
|
|
138
|
+
minutes: now.getUTCHours() * 60 + now.getUTCMinutes()
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function minutesOf(hhmm) {
|
|
143
|
+
const [hour, minute] = hhmm.split(":").map(Number);
|
|
144
|
+
return hour * 60 + minute;
|
|
145
|
+
}
|
|
146
|
+
function containsWindow(window, clock) {
|
|
147
|
+
if (!(window.days === void 0 || window.days.length === 0 || window.days.includes(clock.weekday))) return false;
|
|
148
|
+
const start = minutesOf(window.start);
|
|
149
|
+
const end = minutesOf(window.end);
|
|
150
|
+
return start <= end ? start <= clock.minutes && clock.minutes < end : start <= clock.minutes || clock.minutes < end;
|
|
151
|
+
}
|
|
152
|
+
function matchesAnyWindow(windows, clock) {
|
|
153
|
+
return windows.some((window) => containsWindow(window, clock));
|
|
154
|
+
}
|
|
155
|
+
function describeRow(row) {
|
|
156
|
+
if (!Array.isArray(row.chain) || row.chain.length === 0) {
|
|
157
|
+
warnMalformed(row, "empty chain");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (row.kind === "preset") {
|
|
161
|
+
const preset = row.preset;
|
|
162
|
+
if (typeof preset !== "string" || !Object.hasOwn(PRESETS, preset)) {
|
|
163
|
+
warnMalformed(row, `unknown preset ${JSON.stringify(preset)}`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (row.start !== void 0 || row.end !== void 0 || row.days !== void 0 && row.days.length > 0) {
|
|
167
|
+
warnMalformed(row, `preset windows are fixed — row "${preset}" cannot carry start/end/days`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
return PRESETS[preset];
|
|
171
|
+
}
|
|
172
|
+
if (row.kind === "custom") {
|
|
173
|
+
const { start, end } = row;
|
|
174
|
+
if (typeof start !== "string" || typeof end !== "string" || !HHMM_RE.test(start) || !HHMM_RE.test(end)) {
|
|
175
|
+
warnMalformed(row, `invalid custom window ${JSON.stringify(start)}-${JSON.stringify(end)} (expected HH:mm)`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
windows: [{
|
|
180
|
+
start,
|
|
181
|
+
end,
|
|
182
|
+
days: row.days
|
|
183
|
+
}],
|
|
184
|
+
complement: false
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
warnMalformed(row, `unknown kind ${JSON.stringify(row.kind)}`);
|
|
188
|
+
}
|
|
189
|
+
function labelOf(row) {
|
|
190
|
+
if (row.kind === "preset" && typeof row.preset === "string" && Object.hasOwn(PRESETS, row.preset)) return PRESETS[row.preset].label;
|
|
191
|
+
return row.name !== void 0 && row.name.trim() !== "" ? row.name : `custom ${row.start}-${row.end}`;
|
|
192
|
+
}
|
|
193
|
+
function isAllDayConforming(chain) {
|
|
194
|
+
if (chain.length < 1) return false;
|
|
195
|
+
const tail = chain[chain.length - 1];
|
|
196
|
+
return tail === "deepseek-official/deepseek-v4-flash" || tail === "deepseek-official/deepseek-v4-pro";
|
|
197
|
+
}
|
|
198
|
+
function resolveEffectiveChain(config, now, tz) {
|
|
199
|
+
const state = resolveSlotState(config, now, tz);
|
|
200
|
+
return state.winner === "all-day" ? config.rootChain : state.winner.chain;
|
|
201
|
+
}
|
|
202
|
+
function resolveSlotState(config, now, tz) {
|
|
203
|
+
if (!isAllDayConforming(config.rootChain)) return {
|
|
204
|
+
winner: "all-day",
|
|
205
|
+
label: "all-day"
|
|
206
|
+
};
|
|
207
|
+
const clock = wallClock(now, tz);
|
|
208
|
+
const seenPresets = new Set();
|
|
209
|
+
const rows = Array.isArray(config.timeSlots) ? config.timeSlots : [];
|
|
210
|
+
for (const row of rows) {
|
|
211
|
+
if (typeof row !== "object" || row === null) {
|
|
212
|
+
warnMalformed(row, "row is not an object");
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (row.kind === "preset" && typeof row.preset === "string") {
|
|
216
|
+
if (seenPresets.has(row.preset)) {
|
|
217
|
+
warnMalformed(row, `duplicate preset "${row.preset}" — only the first row takes effect`);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
seenPresets.add(row.preset);
|
|
221
|
+
}
|
|
222
|
+
const descriptor = describeRow(row);
|
|
223
|
+
if (descriptor === void 0) continue;
|
|
224
|
+
if (descriptor.complement ? !matchesAnyWindow(descriptor.windows, clock) : matchesAnyWindow(descriptor.windows, clock)) return {
|
|
225
|
+
winner: row,
|
|
226
|
+
label: labelOf(row)
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
winner: "all-day",
|
|
231
|
+
label: "all-day"
|
|
232
|
+
};
|
|
233
|
+
}
|
|
38
234
|
//#endregion
|
|
39
235
|
//#region .build/host/config.js
|
|
40
236
|
const defaultFallbacksConfig = {
|
|
@@ -53,7 +249,10 @@ const defaultFallbacksConfig = {
|
|
|
53
249
|
revertPolicy: "cooldown-expiry",
|
|
54
250
|
maxSwitchesPerStep: 8,
|
|
55
251
|
alwaysModeRetryCap: 5,
|
|
56
|
-
presets: "bundled"
|
|
252
|
+
presets: "bundled",
|
|
253
|
+
roleAutoMatch: true,
|
|
254
|
+
timeSlots: [],
|
|
255
|
+
tz: "Asia/Shanghai"
|
|
57
256
|
};
|
|
58
257
|
const INHERIT_ROLE_ID = "inherit";
|
|
59
258
|
const ROLE_ID_PATTERN = /^[a-z0-9-]{1,32}$/;
|
|
@@ -73,6 +272,7 @@ function validateFallbacksConfig(config, logger) {
|
|
|
73
272
|
if ((role.chain ?? []).length === 0) logger.warn(`llm-fallbacks: role "${role.id}" has no model config — declare at least one chain entry, or use the built-in "inherit" rule target instead`);
|
|
74
273
|
if (role.fallback !== void 0 && role.fallback !== "inherit-root" && role.fallback !== "none") logger.warn(`llm-fallbacks: role "${role.id}" has invalid fallback "${String(role.fallback)}" — expected "inherit-root" or "none"`);
|
|
75
274
|
}
|
|
275
|
+
if (config.rootChain.length > 0 && !isAllDayConforming(config.rootChain)) logger.warn("llm-fallbacks: rootChain must end with exactly one official V4 model (deepseek-official/deepseek-v4-flash or deepseek-official/deepseek-v4-pro) — time-slot rows and the virtual picker row stay inert until the all-day chain tail conforms");
|
|
76
276
|
for (const entry of config.rootChain) try {
|
|
77
277
|
parseSelector(entry);
|
|
78
278
|
} catch (error) {
|
|
@@ -80,6 +280,23 @@ function validateFallbacksConfig(config, logger) {
|
|
|
80
280
|
}
|
|
81
281
|
const validTargets = new Set([...declaredIds, INHERIT_ROLE_ID]);
|
|
82
282
|
for (const rule of config.roles.rules) if (!validTargets.has(rule.role.trim())) logger.warn(`llm-fallbacks: rule references undeclared role "${rule.role}" — expected one of roles.list ids or "inherit"`);
|
|
283
|
+
if (Array.isArray(config.timeSlots)) {
|
|
284
|
+
const seenSlotPresets = new Set();
|
|
285
|
+
for (const row of config.timeSlots) {
|
|
286
|
+
if (row.kind !== "preset") continue;
|
|
287
|
+
const preset = row.preset;
|
|
288
|
+
if (typeof preset !== "string" || !Object.hasOwn(PRESETS, preset)) {
|
|
289
|
+
logger.warn(`llm-fallbacks: unknown time-slot preset ${JSON.stringify(preset)} — row ignored`);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (seenSlotPresets.has(preset)) {
|
|
293
|
+
logger.warn(`llm-fallbacks: duplicate time-slot preset "${preset}" — only the first row takes effect`);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
seenSlotPresets.add(preset);
|
|
297
|
+
if (row.start !== void 0 || row.end !== void 0 || row.days !== void 0 && row.days.length > 0) logger.warn(`llm-fallbacks: preset "${preset}" carries its own window/day fields — preset windows are fixed code constants; the row is ignored`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
83
300
|
}
|
|
84
301
|
function detectLegacyKeys(source) {
|
|
85
302
|
const keys = [];
|
|
@@ -139,8 +356,110 @@ const Config = z.object({
|
|
|
139
356
|
revertPolicy: z.union([z.const("cooldown-expiry"), z.const("never")]).default("cooldown-expiry"),
|
|
140
357
|
maxSwitchesPerStep: z.number().default(8),
|
|
141
358
|
alwaysModeRetryCap: z.number().default(5),
|
|
142
|
-
presets: z.union([z.const("bundled"), z.const("none")]).default("bundled")
|
|
359
|
+
presets: z.union([z.const("bundled"), z.const("none")]).default("bundled"),
|
|
360
|
+
roleAutoMatch: z.boolean().default(true),
|
|
361
|
+
timeSlots: z.array(z.object({
|
|
362
|
+
kind: z.string(),
|
|
363
|
+
preset: z.string(),
|
|
364
|
+
start: z.string(),
|
|
365
|
+
end: z.string(),
|
|
366
|
+
days: z.array(z.number()),
|
|
367
|
+
chain: z.array(z.string())
|
|
368
|
+
})).default([]),
|
|
369
|
+
tz: z.string().default("Asia/Shanghai")
|
|
143
370
|
});
|
|
371
|
+
function buildAutomatchPrompt(roles, agent) {
|
|
372
|
+
const lines = [
|
|
373
|
+
"You are assigning a subagent to its best-fit role from a declared taxonomy.",
|
|
374
|
+
"",
|
|
375
|
+
"Declared roles:",
|
|
376
|
+
...roles.map((role) => `- ${role.id.trim()}: ${role.persona}`),
|
|
377
|
+
"",
|
|
378
|
+
`Agent context — origin: ${agent.session?.header?.origin ?? "root"}`
|
|
379
|
+
];
|
|
380
|
+
const preset = agent.session?.header?.agentPreset?.trim();
|
|
381
|
+
if (preset !== void 0 && preset !== "") lines.push(`agentPreset: ${preset}`);
|
|
382
|
+
lines.push("", "Reply with EXACTLY ONE of the declared role ids above, or the literal \"none\" when no role fits.", "Do not add any explanation, punctuation, or other text.");
|
|
383
|
+
return lines.join("\n");
|
|
384
|
+
}
|
|
385
|
+
function parseAutomatchAnswer(answer, roleIds) {
|
|
386
|
+
const cleaned = answer.replace(/^[^a-zA-Z0-9-]+/, "").replace(/[^a-zA-Z0-9-]+$/, "");
|
|
387
|
+
if (cleaned === "") return null;
|
|
388
|
+
const key = cleaned.toLowerCase();
|
|
389
|
+
if (key === "none") return null;
|
|
390
|
+
return roleIds.get(key) ?? null;
|
|
391
|
+
}
|
|
392
|
+
async function pickRoleByLlm(ctx, roles, agent, opts) {
|
|
393
|
+
try {
|
|
394
|
+
const list = Array.isArray(roles.list) ? roles.list : [];
|
|
395
|
+
if (list.length === 0) return null;
|
|
396
|
+
const llm = ctx.get("llm");
|
|
397
|
+
if (llm === void 0) return null;
|
|
398
|
+
const route = pickJudgmentRoute(list, agent);
|
|
399
|
+
if (route === null) return null;
|
|
400
|
+
const roleIds = new Map(list.map((role) => [role.id.trim(), role.id]));
|
|
401
|
+
const answer = await streamRoleAnswer(llm, route, buildAutomatchPrompt(list, agent), opts);
|
|
402
|
+
if (answer === null) return null;
|
|
403
|
+
return parseAutomatchAnswer(answer, roleIds);
|
|
404
|
+
} catch (error) {
|
|
405
|
+
opts.warn(`llm-fallbacks: auto-match failed — falling back to "inherit": ${describeError(error)}`);
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function pickJudgmentRoute(roles, agent) {
|
|
410
|
+
for (const role of roles) for (const entry of role.chain ?? []) try {
|
|
411
|
+
const selector = parseSelector(entry);
|
|
412
|
+
if (selector.model !== void 0) return {
|
|
413
|
+
provider: selector.provider,
|
|
414
|
+
model: selector.model
|
|
415
|
+
};
|
|
416
|
+
} catch {}
|
|
417
|
+
const provider = agent.options?.provider;
|
|
418
|
+
const model = agent.options?.model;
|
|
419
|
+
if (provider !== void 0 && model !== void 0) return {
|
|
420
|
+
provider,
|
|
421
|
+
model
|
|
422
|
+
};
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
async function streamRoleAnswer(llm, route, prompt, opts) {
|
|
426
|
+
const controller = new AbortController();
|
|
427
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 5e3);
|
|
428
|
+
let text = "";
|
|
429
|
+
let finishKind;
|
|
430
|
+
try {
|
|
431
|
+
for await (const chunk of llm.stream({
|
|
432
|
+
provider: route.provider,
|
|
433
|
+
model: route.model,
|
|
434
|
+
system: prompt,
|
|
435
|
+
messages: [createUserMessage({
|
|
436
|
+
content: [{
|
|
437
|
+
type: "text",
|
|
438
|
+
text: "Pick the best-fit role for this agent."
|
|
439
|
+
}],
|
|
440
|
+
source: { kind: "user" }
|
|
441
|
+
})],
|
|
442
|
+
maxTokens: 32,
|
|
443
|
+
signal: controller.signal
|
|
444
|
+
})) if (chunk.type === "text-delta") text += chunk.text;
|
|
445
|
+
else if (chunk.type === "finish") {
|
|
446
|
+
finishKind = chunk.reason.kind;
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
if (controller.signal.aborted) return null;
|
|
450
|
+
if (finishKind === "error" || finishKind === "aborted") return null;
|
|
451
|
+
return text;
|
|
452
|
+
} catch (error) {
|
|
453
|
+
if (controller.signal.aborted) opts.warn("llm-fallbacks: auto-match timed out — falling back to \"inherit\"");
|
|
454
|
+
else opts.warn(`llm-fallbacks: auto-match call failed — falling back to "inherit": ${describeError(error)}`);
|
|
455
|
+
return null;
|
|
456
|
+
} finally {
|
|
457
|
+
clearTimeout(timer);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function describeError(error) {
|
|
461
|
+
return error instanceof Error ? error.message : String(error);
|
|
462
|
+
}
|
|
144
463
|
//#endregion
|
|
145
464
|
//#region .build/host/chains.js
|
|
146
465
|
function resolveCandidate(entry, failing, modelExists) {
|
|
@@ -247,9 +566,8 @@ function annotateCandidates(candidates, surviving, options) {
|
|
|
247
566
|
//#endregion
|
|
248
567
|
//#region .build/host/roles.js
|
|
249
568
|
function resolveRole(agent, rules, roleIds, warn = console.warn) {
|
|
250
|
-
|
|
569
|
+
if ((agent.session?.header?.origin ?? "root") !== "subagent") return INHERIT_ROLE_ID;
|
|
251
570
|
for (const rule of rules) {
|
|
252
|
-
if (rule.origin && rule.origin !== origin) continue;
|
|
253
571
|
if (rule.provider && rule.provider !== agent.options?.provider) continue;
|
|
254
572
|
if (rule.model && rule.model !== agent.options?.model) continue;
|
|
255
573
|
const target = rule.role.trim();
|
|
@@ -264,6 +582,29 @@ function resolveRole(agent, rules, roleIds, warn = console.warn) {
|
|
|
264
582
|
return INHERIT_ROLE_ID;
|
|
265
583
|
}
|
|
266
584
|
//#endregion
|
|
585
|
+
//#region .build/host/role-resolution.js
|
|
586
|
+
async function resolveRoleAtDispatch(agent, rules, roleIds, opts) {
|
|
587
|
+
const preset = agent.session?.header?.agentPreset?.trim();
|
|
588
|
+
if (preset !== void 0 && preset !== "" && preset !== "inherit") {
|
|
589
|
+
const declared = roleIds.get(preset);
|
|
590
|
+
if (declared !== void 0) return declared;
|
|
591
|
+
}
|
|
592
|
+
const role = resolveRole(agent, rules, roleIds, opts.warn);
|
|
593
|
+
if (role !== "inherit") return role;
|
|
594
|
+
if (opts.automatchEnabled && opts.automatch !== void 0) {
|
|
595
|
+
const picked = await opts.automatch(agent);
|
|
596
|
+
if (picked !== null && picked !== void 0) {
|
|
597
|
+
const declared = roleIds.get(picked.trim());
|
|
598
|
+
if (declared !== void 0) return declared;
|
|
599
|
+
opts.warn(`llm-fallbacks: auto-match returned undeclared role "${picked}" — falling back to "inherit"`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return INHERIT_ROLE_ID;
|
|
603
|
+
}
|
|
604
|
+
function firstExactCandidate(all, wildcard) {
|
|
605
|
+
for (let index = 0; index < all.length; index += 1) if (!wildcard[index]) return all[index];
|
|
606
|
+
}
|
|
607
|
+
//#endregion
|
|
267
608
|
//#region .build/host/cooldown.js
|
|
268
609
|
var CooldownStore = class {
|
|
269
610
|
entries = new Map();
|
|
@@ -399,12 +740,25 @@ const CONFIG_KEYS = {
|
|
|
399
740
|
revertPolicy: true,
|
|
400
741
|
maxSwitchesPerStep: true,
|
|
401
742
|
alwaysModeRetryCap: true,
|
|
402
|
-
presets: true
|
|
743
|
+
presets: true,
|
|
744
|
+
roleAutoMatch: true,
|
|
745
|
+
timeSlots: true,
|
|
746
|
+
tz: true
|
|
403
747
|
};
|
|
404
748
|
const ROLES_KEYS = {
|
|
405
749
|
list: true,
|
|
406
750
|
rules: true
|
|
407
751
|
};
|
|
752
|
+
const SLOT_ROW_KEYS = {
|
|
753
|
+
kind: true,
|
|
754
|
+
preset: true,
|
|
755
|
+
start: true,
|
|
756
|
+
end: true,
|
|
757
|
+
days: true,
|
|
758
|
+
name: true,
|
|
759
|
+
chain: true
|
|
760
|
+
};
|
|
761
|
+
const SLOT_HHMM_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
408
762
|
var FallbacksConfigGateway = class extends TypertRemoteService {
|
|
409
763
|
bridge;
|
|
410
764
|
seeds;
|
|
@@ -494,9 +848,44 @@ function validateConfigPatch(patch) {
|
|
|
494
848
|
for (const nestedKey of Object.keys(roles)) if (!Object.hasOwn(ROLES_KEYS, nestedKey)) throw new Error(`dsh-llm-fallbacks: unknown config key "roles.${nestedKey}"`);
|
|
495
849
|
}
|
|
496
850
|
}
|
|
851
|
+
if (key === "timeSlots") {
|
|
852
|
+
const rows = patch[key];
|
|
853
|
+
if (rows !== null && rows !== void 0) validateTimeSlotsPatch(rows);
|
|
854
|
+
}
|
|
855
|
+
if (key === "rootChain") {
|
|
856
|
+
const chain = patch[key];
|
|
857
|
+
if (chain !== null && chain !== void 0 && (!Array.isArray(chain) || !isAllDayConforming(chain))) throw new Error("dsh-llm-fallbacks: rootChain must end with exactly one official V4 model (deepseek-official/deepseek-v4-flash or deepseek-official/deepseek-v4-pro)");
|
|
858
|
+
}
|
|
497
859
|
}
|
|
498
860
|
Config(patch);
|
|
499
861
|
}
|
|
862
|
+
function validateTimeSlotsPatch(rows) {
|
|
863
|
+
if (!Array.isArray(rows)) throw new Error("dsh-llm-fallbacks: timeSlots must be an array of slot rows");
|
|
864
|
+
const seenPresets = new Set();
|
|
865
|
+
rows.forEach((row, index) => {
|
|
866
|
+
const at = `timeSlots[${index}]`;
|
|
867
|
+
if (row === null || typeof row !== "object" || Array.isArray(row)) throw new Error(`dsh-llm-fallbacks: ${at} must be a plain object`);
|
|
868
|
+
const record = row;
|
|
869
|
+
for (const nestedKey of Object.keys(record)) if (!Object.hasOwn(SLOT_ROW_KEYS, nestedKey)) throw new Error(`dsh-llm-fallbacks: unknown config key "${at}.${nestedKey}"`);
|
|
870
|
+
if (record.kind === "preset") {
|
|
871
|
+
const preset = record.preset;
|
|
872
|
+
if (typeof preset !== "string" || !Object.hasOwn(PRESETS, preset)) throw new Error(`dsh-llm-fallbacks: ${at}.preset must be one of the four frozen preset ids (got ${JSON.stringify(preset)})`);
|
|
873
|
+
if (seenPresets.has(preset)) throw new Error(`dsh-llm-fallbacks: ${at} duplicates preset "${preset}" — at most one row per preset`);
|
|
874
|
+
seenPresets.add(preset);
|
|
875
|
+
if (record.start !== void 0 || record.end !== void 0 || Array.isArray(record.days) && record.days.length > 0) throw new Error(`dsh-llm-fallbacks: ${at} preset "${preset}" cannot carry start/end/days — preset windows are frozen code constants`);
|
|
876
|
+
if (record.name !== void 0) throw new Error(`dsh-llm-fallbacks: ${at} preset "${preset}" cannot carry a name — preset rows are named by the frozen label`);
|
|
877
|
+
} else if (record.kind === "custom") {
|
|
878
|
+
const { start, end } = record;
|
|
879
|
+
if (typeof start !== "string" || typeof end !== "string" || !SLOT_HHMM_RE.test(start) || !SLOT_HHMM_RE.test(end)) throw new Error(`dsh-llm-fallbacks: ${at} custom row requires HH:mm start and end (got ${JSON.stringify(start)}-${JSON.stringify(end)})`);
|
|
880
|
+
if (record.days !== void 0) {
|
|
881
|
+
if (!Array.isArray(record.days) || record.days.some((day) => !Number.isInteger(day) || day < 0 || day > 6)) throw new Error(`dsh-llm-fallbacks: ${at}.days must be an array of integers 0–6`);
|
|
882
|
+
}
|
|
883
|
+
if (record.name !== void 0 && typeof record.name !== "string") throw new Error(`dsh-llm-fallbacks: ${at}.name must be a string`);
|
|
884
|
+
} else throw new Error(`dsh-llm-fallbacks: ${at}.kind must be "preset" or "custom" (got ${JSON.stringify(record.kind)})`);
|
|
885
|
+
const chain = record.chain;
|
|
886
|
+
if (!Array.isArray(chain) || chain.length === 0 || chain.some((entry) => typeof entry !== "string")) throw new Error(`dsh-llm-fallbacks: ${at}.chain must be a non-empty string array`);
|
|
887
|
+
});
|
|
888
|
+
}
|
|
500
889
|
function fallbacksTypertContribution() {
|
|
501
890
|
return {
|
|
502
891
|
package: "dsh-llm-fallbacks",
|
|
@@ -567,7 +956,7 @@ const CONFIG_SUBCOMMAND_DESCRIPTION = {
|
|
|
567
956
|
const FALLBACKS_COMMAND_LOCALES = {
|
|
568
957
|
zh: {
|
|
569
958
|
title: "当前会话 fallback 诊断(只读)",
|
|
570
|
-
description: "
|
|
959
|
+
description: "查看当前会话的降级链、最近降级切换与冷却状态(只读)",
|
|
571
960
|
usageConfig: CONFIG_SUBCOMMAND_DESCRIPTION.zh,
|
|
572
961
|
usage: ` /fallbacks config ${CONFIG_SUBCOMMAND_DESCRIPTION.zh}`,
|
|
573
962
|
origin: "会话来源",
|
|
@@ -575,7 +964,8 @@ const FALLBACKS_COMMAND_LOCALES = {
|
|
|
575
964
|
chain: "链",
|
|
576
965
|
inheritRoot: "(inherit-root)",
|
|
577
966
|
chainNone: "未配置",
|
|
578
|
-
|
|
967
|
+
slot: "分时",
|
|
968
|
+
switches: "最近降级切换",
|
|
579
969
|
switchesNone: "本会话暂无 fallback 切换",
|
|
580
970
|
switchLine: "{from} → {to}(role={role},reason={reason})",
|
|
581
971
|
cooldown: "冷却",
|
|
@@ -599,12 +989,13 @@ const FALLBACKS_COMMAND_LOCALES = {
|
|
|
599
989
|
configMaxSwitches: "单步最大切换",
|
|
600
990
|
configAlwaysCap: "always 上限",
|
|
601
991
|
configPresets: "预置",
|
|
992
|
+
configRoleAutoMatch: "角色自动匹配",
|
|
602
993
|
configEdit: "编辑:~/.dsh/profiles/<profile>/cordis.patch.yml(插件行)或 $DSH_HOME/settings.yaml(fallbacks: 分节)",
|
|
603
994
|
configEditHint: "TUI 无法修改配置——只能编辑文件"
|
|
604
995
|
},
|
|
605
996
|
en: {
|
|
606
997
|
title: "Session fallback diagnostics (read-only)",
|
|
607
|
-
description: "Inspect fallback chain, recent switches, and cooldown for this session (read-only)",
|
|
998
|
+
description: "Inspect fallback chain, recent fallback switches, and cooldown for this session (read-only)",
|
|
608
999
|
usageConfig: CONFIG_SUBCOMMAND_DESCRIPTION.en,
|
|
609
1000
|
usage: ` /fallbacks config ${CONFIG_SUBCOMMAND_DESCRIPTION.en}`,
|
|
610
1001
|
origin: "Session origin",
|
|
@@ -612,7 +1003,8 @@ const FALLBACKS_COMMAND_LOCALES = {
|
|
|
612
1003
|
chain: "Chain",
|
|
613
1004
|
inheritRoot: " (inherit-root)",
|
|
614
1005
|
chainNone: "not configured",
|
|
615
|
-
|
|
1006
|
+
slot: "Time slot",
|
|
1007
|
+
switches: "Recent fallback switches",
|
|
616
1008
|
switchesNone: "No fallback switches in this session",
|
|
617
1009
|
switchLine: "{from} → {to} (role={role}, reason={reason})",
|
|
618
1010
|
cooldown: "Cooldown",
|
|
@@ -636,6 +1028,7 @@ const FALLBACKS_COMMAND_LOCALES = {
|
|
|
636
1028
|
configMaxSwitches: "Max switches/step",
|
|
637
1029
|
configAlwaysCap: "Always-mode cap",
|
|
638
1030
|
configPresets: "Presets",
|
|
1031
|
+
configRoleAutoMatch: "Auto-match",
|
|
639
1032
|
configEdit: "Edit: ~/.dsh/profiles/<profile>/cordis.patch.yml (plugin row) or $DSH_HOME/settings.yaml (fallbacks: section)",
|
|
640
1033
|
configEditHint: "TUI cannot change config — edit files only"
|
|
641
1034
|
}
|
|
@@ -709,6 +1102,7 @@ function fallbacksConfigText(summary, locale = "zh") {
|
|
|
709
1102
|
`${t.configMaxSwitches}: ${summary.maxSwitchesPerStep}`,
|
|
710
1103
|
`${t.configAlwaysCap}: ${summary.alwaysModeRetryCap}`,
|
|
711
1104
|
`${t.configPresets}: ${summary.presets}`,
|
|
1105
|
+
`${t.configRoleAutoMatch}: ${summary.roleAutoMatch ? t.configEnabled : t.configDisabled}`,
|
|
712
1106
|
"",
|
|
713
1107
|
t.configEdit,
|
|
714
1108
|
t.configEditHint
|
|
@@ -724,6 +1118,7 @@ function fallbacksCommandText(snapshot, locale = "zh") {
|
|
|
724
1118
|
const suffix = snapshot.inherit ? t.inheritRoot : "";
|
|
725
1119
|
lines.push(`${t.chain}: ${snapshot.chain.join(" → ")}${suffix}`);
|
|
726
1120
|
}
|
|
1121
|
+
lines.push(`${t.slot}: ${snapshot.slot.label}`);
|
|
727
1122
|
if (snapshot.switches.length === 0) lines.push(`${t.switches}: ${t.switchesNone}`);
|
|
728
1123
|
else {
|
|
729
1124
|
lines.push(`${t.switches} (${snapshot.switches.length}):`);
|
|
@@ -979,7 +1374,7 @@ function fallbacksChildren(canonicalPath) {
|
|
|
979
1374
|
if (canonicalPath.length === 1) return [FALLBACKS_CONFIG_NODE];
|
|
980
1375
|
return [];
|
|
981
1376
|
}
|
|
982
|
-
const FALLBACKS_PROVIDER = {
|
|
1377
|
+
const FALLBACKS_PROVIDER$1 = {
|
|
983
1378
|
root: FALLBACKS_TUI_ROOT,
|
|
984
1379
|
descriptions: {
|
|
985
1380
|
zh: FALLBACKS_COMMAND_LOCALES.zh.description,
|
|
@@ -993,7 +1388,7 @@ function installTuiClient(ctx, opts) {
|
|
|
993
1388
|
const trees = tctx.tuiCommandTrees;
|
|
994
1389
|
if (trees === void 0) return;
|
|
995
1390
|
try {
|
|
996
|
-
return trees.register(FALLBACKS_PROVIDER);
|
|
1391
|
+
return trees.register(FALLBACKS_PROVIDER$1);
|
|
997
1392
|
} catch (error) {
|
|
998
1393
|
if (!(error instanceof Error) || !error.message.includes("already registered")) throw error;
|
|
999
1394
|
tctx.logger("llm-fallbacks").debug("llm-fallbacks: tui command tree already registered — no provider on this fiber");
|
|
@@ -1002,6 +1397,151 @@ function installTuiClient(ctx, opts) {
|
|
|
1002
1397
|
});
|
|
1003
1398
|
}
|
|
1004
1399
|
//#endregion
|
|
1400
|
+
//#region .build/host/virtual-adapter.js
|
|
1401
|
+
const FALLBACKS_PROVIDER = "FallbacksChain";
|
|
1402
|
+
const FALLBACKS_CHAIN_MODEL = "Auto";
|
|
1403
|
+
const EMPTY_EFFECTIVE_CHAIN_CODE = "EMPTY_EFFECTIVE_CHAIN";
|
|
1404
|
+
const UNDISPATCHABLE_HEAD_CODE = "UNDISPATCHABLE_EFFECTIVE_HEAD";
|
|
1405
|
+
const LLM_UNAVAILABLE_CODE = "LLM_UNAVAILABLE";
|
|
1406
|
+
function firstDispatchableExactHead(chain) {
|
|
1407
|
+
for (const entry of chain) {
|
|
1408
|
+
let selector;
|
|
1409
|
+
try {
|
|
1410
|
+
selector = parseSelector(entry);
|
|
1411
|
+
} catch {
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
if (selector.model === void 0) continue;
|
|
1415
|
+
if (selector.provider === "FallbacksChain") continue;
|
|
1416
|
+
return {
|
|
1417
|
+
provider: selector.provider,
|
|
1418
|
+
model: selector.model
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
function effectiveHeadOf(config, now) {
|
|
1423
|
+
if (!isAllDayConforming(config.rootChain)) return void 0;
|
|
1424
|
+
return firstDispatchableExactHead(resolveEffectiveChain(config, now, config.tz ?? "Asia/Shanghai"));
|
|
1425
|
+
}
|
|
1426
|
+
function pickerDisplayName(config, now = new Date(), modelDisplayName) {
|
|
1427
|
+
const head = effectiveHeadOf(config, now);
|
|
1428
|
+
if (head === void 0) return FALLBACKS_CHAIN_MODEL;
|
|
1429
|
+
const slot = resolveSlotState(config, now, config.tz ?? "Asia/Shanghai");
|
|
1430
|
+
const model = modelDisplayName !== void 0 && modelDisplayName !== "" ? modelDisplayName : head.model;
|
|
1431
|
+
return `${FALLBACKS_CHAIN_MODEL}: ${model}[${slot.label}]`;
|
|
1432
|
+
}
|
|
1433
|
+
async function resolveHeadDisplayName(llm, head) {
|
|
1434
|
+
if (llm === void 0) return head.model;
|
|
1435
|
+
try {
|
|
1436
|
+
const row = (await llm.listModels(head.provider)).find((model) => model.id === head.model);
|
|
1437
|
+
if (row !== void 0 && row.name !== "") return row.name;
|
|
1438
|
+
} catch {}
|
|
1439
|
+
try {
|
|
1440
|
+
const info = await llm.resolveModelInfo(head.provider, head.model);
|
|
1441
|
+
if (info.name !== "") return info.name;
|
|
1442
|
+
} catch {}
|
|
1443
|
+
return head.model;
|
|
1444
|
+
}
|
|
1445
|
+
var FallbacksChainAdapter = class extends LlmAdapter {
|
|
1446
|
+
readConfig;
|
|
1447
|
+
getLlm;
|
|
1448
|
+
constructor(readConfig, getLlm) {
|
|
1449
|
+
super();
|
|
1450
|
+
this.readConfig = readConfig;
|
|
1451
|
+
this.getLlm = getLlm;
|
|
1452
|
+
}
|
|
1453
|
+
providerInfo(provider) {
|
|
1454
|
+
return {
|
|
1455
|
+
id: provider,
|
|
1456
|
+
name: FALLBACKS_PROVIDER
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
async listModels(provider) {
|
|
1460
|
+
const config = this.readConfig();
|
|
1461
|
+
const now = new Date();
|
|
1462
|
+
const head = effectiveHeadOf(config, now);
|
|
1463
|
+
const display = head === void 0 ? void 0 : await resolveHeadDisplayName(this.getLlm(), head);
|
|
1464
|
+
return [{
|
|
1465
|
+
provider,
|
|
1466
|
+
id: FALLBACKS_CHAIN_MODEL,
|
|
1467
|
+
name: pickerDisplayName(config, now, display)
|
|
1468
|
+
}];
|
|
1469
|
+
}
|
|
1470
|
+
async resolveModel(provider, model, signal) {
|
|
1471
|
+
const head = effectiveHeadOf(this.readConfig(), new Date());
|
|
1472
|
+
const llm = this.getLlm();
|
|
1473
|
+
if (head !== void 0 && llm !== void 0) try {
|
|
1474
|
+
const info = await llm.resolveModelInfo(head.provider, head.model, signal);
|
|
1475
|
+
return {
|
|
1476
|
+
provider,
|
|
1477
|
+
id: model,
|
|
1478
|
+
name: info.name,
|
|
1479
|
+
...info.description === void 0 ? {} : { description: info.description },
|
|
1480
|
+
...info.inputModalities === void 0 ? {} : { inputModalities: info.inputModalities },
|
|
1481
|
+
...info.context === void 0 ? {} : { context: info.context },
|
|
1482
|
+
...info.defaultMaxTokens === void 0 ? {} : { defaultMaxTokens: info.defaultMaxTokens },
|
|
1483
|
+
...info.reasoning === void 0 ? {} : { reasoning: info.reasoning }
|
|
1484
|
+
};
|
|
1485
|
+
} catch {}
|
|
1486
|
+
return {
|
|
1487
|
+
provider,
|
|
1488
|
+
id: model,
|
|
1489
|
+
name: model
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
stream(options) {
|
|
1493
|
+
const config = this.readConfig();
|
|
1494
|
+
const now = new Date();
|
|
1495
|
+
if (resolveEffectiveChain(config, now, config.tz ?? "Asia/Shanghai").length === 0) throw new LlmError("llm-fallbacks: the effective chain is empty — the virtual FallbacksChain route has no head to delegate to", EMPTY_EFFECTIVE_CHAIN_CODE);
|
|
1496
|
+
const head = effectiveHeadOf(config, now);
|
|
1497
|
+
if (head === void 0) throw new LlmError("llm-fallbacks: the effective chain head cannot be dispatched (non-conforming all-day, wildcard, malformed, or self-route) — refusing to delegate", UNDISPATCHABLE_HEAD_CODE);
|
|
1498
|
+
const llm = this.getLlm();
|
|
1499
|
+
if (llm === void 0) throw new LlmError("llm-fallbacks: the llm runtime is unavailable — cannot delegate the FallbacksChain call", LLM_UNAVAILABLE_CODE);
|
|
1500
|
+
return llm.stream({
|
|
1501
|
+
...options,
|
|
1502
|
+
provider: head.provider,
|
|
1503
|
+
model: head.model
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
};
|
|
1507
|
+
function installFallbacksAdapter(ctx, readConfig) {
|
|
1508
|
+
let llm;
|
|
1509
|
+
let disposeAdapter;
|
|
1510
|
+
let registered = false;
|
|
1511
|
+
const logger = ctx.logger("llm-fallbacks");
|
|
1512
|
+
const adapter = new FallbacksChainAdapter(readConfig, () => llm);
|
|
1513
|
+
const reconcile = () => {
|
|
1514
|
+
const shouldRegister = readConfig().enabled;
|
|
1515
|
+
if (shouldRegister && !registered) {
|
|
1516
|
+
if (llm === void 0) return;
|
|
1517
|
+
try {
|
|
1518
|
+
disposeAdapter = llm.registerAdapter([FALLBACKS_PROVIDER], adapter);
|
|
1519
|
+
registered = true;
|
|
1520
|
+
logger.info("llm-fallbacks: virtual adapter registered — FallbacksChain/Auto is selectable in the model picker");
|
|
1521
|
+
} catch (error) {
|
|
1522
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "DUPLICATE_ADAPTER") throw error;
|
|
1523
|
+
logger.debug("llm-fallbacks: virtual adapter already registered — no route on this fiber (multi-fiber dedupe)");
|
|
1524
|
+
}
|
|
1525
|
+
} else if (!shouldRegister && registered) {
|
|
1526
|
+
disposeAdapter?.();
|
|
1527
|
+
disposeAdapter = void 0;
|
|
1528
|
+
registered = false;
|
|
1529
|
+
logger.info("llm-fallbacks: virtual adapter unregistered — FallbacksChain/Auto hidden from the model picker");
|
|
1530
|
+
}
|
|
1531
|
+
};
|
|
1532
|
+
ctx.inject(["llm"], (llmCtx) => {
|
|
1533
|
+
llm = llmCtx.llm;
|
|
1534
|
+
reconcile();
|
|
1535
|
+
return () => {
|
|
1536
|
+
disposeAdapter?.();
|
|
1537
|
+
disposeAdapter = void 0;
|
|
1538
|
+
registered = false;
|
|
1539
|
+
llm = void 0;
|
|
1540
|
+
};
|
|
1541
|
+
});
|
|
1542
|
+
return reconcile;
|
|
1543
|
+
}
|
|
1544
|
+
//#endregion
|
|
1005
1545
|
//#region .build/host/index.js
|
|
1006
1546
|
const name = "llm-fallbacks";
|
|
1007
1547
|
const provide = ["llm-fallbacks"];
|
|
@@ -1042,6 +1582,10 @@ function currentModel(agent, provider) {
|
|
|
1042
1582
|
model: agent.session.requestHeader()?.config.model ?? agent.options.model ?? ""
|
|
1043
1583
|
};
|
|
1044
1584
|
}
|
|
1585
|
+
function slotWinnerKey(winner) {
|
|
1586
|
+
if (winner === "all-day") return "all-day";
|
|
1587
|
+
return winner.kind === "preset" ? `preset:${winner.preset}` : `custom:${winner.start}-${winner.end}`;
|
|
1588
|
+
}
|
|
1045
1589
|
function overrideConfig(seed, to) {
|
|
1046
1590
|
const { reasoningEffort: _inherited, ...withoutInheritedEffort } = seed;
|
|
1047
1591
|
return {
|
|
@@ -1052,15 +1596,6 @@ function overrideConfig(seed, to) {
|
|
|
1052
1596
|
}
|
|
1053
1597
|
function apply(ctx, config = defaultFallbacksConfig) {
|
|
1054
1598
|
const logger = ctx.logger("llm-fallbacks");
|
|
1055
|
-
const known = dshSession.KNOWN_SESSION_EVENT_TYPES;
|
|
1056
|
-
let sessionEventRegistered = false;
|
|
1057
|
-
if (known instanceof Set) try {
|
|
1058
|
-
known.add("fallbacks/switch");
|
|
1059
|
-
sessionEventRegistered = known.has("fallbacks/switch");
|
|
1060
|
-
} catch {
|
|
1061
|
-
sessionEventRegistered = false;
|
|
1062
|
-
}
|
|
1063
|
-
let sessionEventWarned = false;
|
|
1064
1599
|
const entry = Config(config);
|
|
1065
1600
|
const seeds = new FallbacksSeedManager(logger);
|
|
1066
1601
|
const seedsSettingsUnavailable = "llm-fallbacks: seeds: settings service is unavailable — seed roles cannot be written";
|
|
@@ -1099,6 +1634,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
1099
1634
|
ctx.logger("llm-fallbacks").debug("fallbacks service already registered — no service on this fiber (multi-fiber dedupe)");
|
|
1100
1635
|
}
|
|
1101
1636
|
let source = () => entry;
|
|
1637
|
+
const reconcileFallbacksAdapter = installFallbacksAdapter(ctx, () => source());
|
|
1102
1638
|
validateFallbacksConfig(entry, logger);
|
|
1103
1639
|
const legacyKeys = detectLegacyKeys(source());
|
|
1104
1640
|
if (legacyKeys.length > 0) logger.warn("llm-fallbacks: legacy config keys detected (chains/roles.default/undeclared role refs); see docs/configuration.md migration table — %o", legacyKeys);
|
|
@@ -1112,6 +1648,8 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
1112
1648
|
const current = source();
|
|
1113
1649
|
roleIds = new Map(current.roles.list.map((role) => [role.id.trim(), role.id]));
|
|
1114
1650
|
hasChains = current.rootChain.length > 0 || current.roles.list.some((role) => (role.chain?.length ?? 0) > 0);
|
|
1651
|
+
reconcileFallbacksAdapter();
|
|
1652
|
+
slotWinners.clear();
|
|
1115
1653
|
}
|
|
1116
1654
|
});
|
|
1117
1655
|
const bridge = { source: () => source() };
|
|
@@ -1132,6 +1670,8 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
1132
1670
|
});
|
|
1133
1671
|
const states = new FallbackStateStore();
|
|
1134
1672
|
stateStores.set(ctx, states);
|
|
1673
|
+
const dispatchInjected = new Set();
|
|
1674
|
+
const slotWinners = new Map();
|
|
1135
1675
|
async function decide(agent, turn, step, current, reason, state) {
|
|
1136
1676
|
const config = source();
|
|
1137
1677
|
if (state !== void 0) {
|
|
@@ -1139,9 +1679,10 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
1139
1679
|
if (state.stepFailures.switchCount >= config.maxSwitchesPerStep) return null;
|
|
1140
1680
|
}
|
|
1141
1681
|
const role = resolveRole(agent, config.roles.rules, roleIds, logger.warn);
|
|
1142
|
-
const
|
|
1682
|
+
const rootTail = agent.session?.header?.origin === "subagent" ? config.rootChain : resolveEffectiveChain(config, new Date(), config.tz ?? "Asia/Shanghai");
|
|
1683
|
+
const { all, wildcard } = resolveChainViews(config.roles.list, rootTail, role, current.provider, current.model, logger.warn);
|
|
1143
1684
|
if (all.length === 0) return null;
|
|
1144
|
-
const modelExists = hasWildcardEntry(config.roles.list,
|
|
1685
|
+
const modelExists = hasWildcardEntry(config.roles.list, rootTail, role) ? await makeModelExists(ctx, [...new Set(all.map((candidate) => candidate.provider))]) : void 0;
|
|
1145
1686
|
const cooldown = { isSuppressed: (key) => state !== void 0 && states.isSuppressed(state, key) };
|
|
1146
1687
|
const failed = { has: (key) => state !== void 0 && state.stepFailures.failed.has(key) };
|
|
1147
1688
|
const surviving = selectCandidates(all, wildcard, createCandidateFilter({
|
|
@@ -1151,7 +1692,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
1151
1692
|
}), modelExists);
|
|
1152
1693
|
const target = surviving[0];
|
|
1153
1694
|
if (target === void 0 || target.model === void 0) return null;
|
|
1154
|
-
logger.info("llm-fallbacks: agent \"%s\" switch %s/%s -> %s/%s (role=%s, reason=%s, candidates=%o)", agent.id, current.provider, current.model, target.provider, target.model, role, reason, annotateCandidates(all, surviving, {
|
|
1695
|
+
logger.info("llm-fallbacks: agent \"%s\" fallback switch (降级切换) %s/%s -> %s/%s (role=%s, reason=%s, candidates=%o)", agent.id, current.provider, current.model, target.provider, target.model, role, reason, annotateCandidates(all, surviving, {
|
|
1155
1696
|
current,
|
|
1156
1697
|
cooldown,
|
|
1157
1698
|
failed
|
|
@@ -1169,7 +1710,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
1169
1710
|
reason
|
|
1170
1711
|
};
|
|
1171
1712
|
}
|
|
1172
|
-
function commit(
|
|
1713
|
+
function commit(state, pending, turn, step) {
|
|
1173
1714
|
const config = source();
|
|
1174
1715
|
const fromKey = selectorKey(pending.from.provider, pending.from.model);
|
|
1175
1716
|
const until = config.revertPolicy === "never" ? Number.POSITIVE_INFINITY : Date.now() + config.cooldownMs;
|
|
@@ -1178,21 +1719,6 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
1178
1719
|
states.suppress(state, fromKey, until);
|
|
1179
1720
|
states.recordFailure(state, fromKey);
|
|
1180
1721
|
states.recordSwitch(state);
|
|
1181
|
-
if (!sessionEventRegistered) {
|
|
1182
|
-
if (!sessionEventWarned) {
|
|
1183
|
-
sessionEventWarned = true;
|
|
1184
|
-
logger.warn("llm-fallbacks: session event type \"fallbacks/switch\" is not registered with this harness — skipping the durable event (the switch itself is applied)");
|
|
1185
|
-
}
|
|
1186
|
-
return;
|
|
1187
|
-
}
|
|
1188
|
-
agent.session.append("fallbacks/switch", {
|
|
1189
|
-
turn,
|
|
1190
|
-
step,
|
|
1191
|
-
from: pending.from,
|
|
1192
|
-
to: pending.to,
|
|
1193
|
-
role: pending.role,
|
|
1194
|
-
reason: pending.reason
|
|
1195
|
-
});
|
|
1196
1722
|
}
|
|
1197
1723
|
ctx.on("agent/request-error", async ({ agent, turn, step, provider, failure }, next) => {
|
|
1198
1724
|
const config = source();
|
|
@@ -1202,7 +1728,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
1202
1728
|
try {
|
|
1203
1729
|
const pending = await decide(agent, turn, step, current, "trigger-code", states.peek(agent.id));
|
|
1204
1730
|
if (pending === null) return next();
|
|
1205
|
-
commit(
|
|
1731
|
+
commit(states.get(agent.id), pending, turn, step);
|
|
1206
1732
|
return { kind: "retry" };
|
|
1207
1733
|
} catch (error) {
|
|
1208
1734
|
logger.warn("llm-fallbacks: decision path failed, passing the original failure through: %s", error?.message ?? String(error));
|
|
@@ -1215,6 +1741,51 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
1215
1741
|
const applied = state === void 0 ? void 0 : states.applyPending(state, turn, step);
|
|
1216
1742
|
if (applied !== void 0) return overrideConfig(seed, applied.to);
|
|
1217
1743
|
const config = source();
|
|
1744
|
+
if (config.enabled && isAllDayConforming(config.rootChain) && (config.timeSlots?.length ?? 0) > 0 && agent.session?.header?.origin !== "subagent") {
|
|
1745
|
+
const slot = resolveSlotState(config, new Date(), config.tz ?? "Asia/Shanghai");
|
|
1746
|
+
const key = slotWinnerKey(slot.winner);
|
|
1747
|
+
const previous = slotWinners.get(agent.id);
|
|
1748
|
+
if (previous !== void 0 && previous.key !== key) logger.info("llm-fallbacks: agent \"%s\" time-slot switch (分时切换): %s -> %s", agent.id, previous.label, slot.label);
|
|
1749
|
+
slotWinners.set(agent.id, {
|
|
1750
|
+
key,
|
|
1751
|
+
label: slot.label
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1754
|
+
if (config.enabled && seed.provider === "FallbacksChain" && seed.model === "Auto" && agent.session?.header?.origin !== "subagent") {
|
|
1755
|
+
if (!isAllDayConforming(config.rootChain)) logger.warn("llm-fallbacks: FallbacksChain/Auto selected but the all-day rootChain is not conforming (exactly one official V4 model) — no primary override");
|
|
1756
|
+
else {
|
|
1757
|
+
const head = firstDispatchableExactHead(resolveEffectiveChain(config, new Date(), config.tz ?? "Asia/Shanghai"));
|
|
1758
|
+
if (head === void 0) logger.warn("llm-fallbacks: FallbacksChain/Auto selected but the effective chain has no exact head (empty, wildcard-only, or self-route) — no primary override");
|
|
1759
|
+
else {
|
|
1760
|
+
logger.info("llm-fallbacks: FallbacksChain/Auto selection overrides to the effective head %s/%s", head.provider, head.model);
|
|
1761
|
+
return overrideConfig(seed, head);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
if (config.enabled && hasChains && agent.session?.header?.origin === "subagent" && !dispatchInjected.has(agent.id)) {
|
|
1766
|
+
dispatchInjected.add(agent.id);
|
|
1767
|
+
try {
|
|
1768
|
+
const role = await resolveRoleAtDispatch(agent, config.roles.rules, roleIds, {
|
|
1769
|
+
automatchEnabled: config.roleAutoMatch ?? true,
|
|
1770
|
+
automatch: (agent) => pickRoleByLlm(ctx, config.roles, agent, { warn: logger.warn }),
|
|
1771
|
+
warn: logger.warn
|
|
1772
|
+
});
|
|
1773
|
+
if (role !== "inherit") {
|
|
1774
|
+
const { all, wildcard } = resolveChainViews(config.roles.list, config.rootChain, role, seed.provider, seed.model, logger.warn);
|
|
1775
|
+
const head = firstExactCandidate(all, wildcard);
|
|
1776
|
+
if (head !== void 0 && head.model !== void 0 && !(head.provider === seed.provider && head.model === seed.model)) {
|
|
1777
|
+
const to = {
|
|
1778
|
+
provider: head.provider,
|
|
1779
|
+
model: head.model
|
|
1780
|
+
};
|
|
1781
|
+
logger.info("llm-fallbacks: agent \"%s\" role-inject role=%s model=%s/%s", agent.id, role, head.provider, head.model);
|
|
1782
|
+
return overrideConfig(seed, to);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
} catch (error) {
|
|
1786
|
+
logger.warn("llm-fallbacks: dispatch role-injection failed, proceeding with the request unchanged: %s", error?.message ?? String(error));
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1218
1789
|
if (hasChains && config.enabled && config.alwaysModeRetryCap > 0 && countRetryEvents(agent.session, turn, step, seed.provider) >= config.alwaysModeRetryCap) {
|
|
1219
1790
|
const decisionState = states.peek(agent.id);
|
|
1220
1791
|
const pending = await decide(agent, turn, step, {
|
|
@@ -1223,7 +1794,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
1223
1794
|
}, "always-cap", decisionState);
|
|
1224
1795
|
if (pending !== null) {
|
|
1225
1796
|
const commitState = states.get(agent.id);
|
|
1226
|
-
commit(
|
|
1797
|
+
commit(commitState, pending, turn, step);
|
|
1227
1798
|
const appliedCap = states.applyPending(commitState, turn, step);
|
|
1228
1799
|
if (appliedCap !== void 0) return overrideConfig(seed, appliedCap.to);
|
|
1229
1800
|
}
|
|
@@ -1237,9 +1808,13 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
1237
1808
|
});
|
|
1238
1809
|
ctx.on("agent/disposed", ({ agent }) => {
|
|
1239
1810
|
states.delete(agent.id);
|
|
1811
|
+
dispatchInjected.delete(agent.id);
|
|
1812
|
+
slotWinners.delete(agent.id);
|
|
1240
1813
|
});
|
|
1241
1814
|
ctx.effect(() => () => {
|
|
1242
1815
|
states.clear();
|
|
1816
|
+
dispatchInjected.clear();
|
|
1817
|
+
slotWinners.clear();
|
|
1243
1818
|
}, "llm-fallbacks: clear per-agent state");
|
|
1244
1819
|
const fallbacksCommandController = {
|
|
1245
1820
|
getSnapshot(agent) {
|
|
@@ -1250,6 +1825,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
1250
1825
|
origin: agent.session.header?.origin ?? "root",
|
|
1251
1826
|
role,
|
|
1252
1827
|
...resolveChainForDiagnostic(config.roles.list, config.rootChain, role, logger.warn),
|
|
1828
|
+
slot: resolveSlotState(config, new Date(), config.tz ?? "Asia/Shanghai"),
|
|
1253
1829
|
switches: recentFallbacksSwitches(agent.session.events, 5),
|
|
1254
1830
|
cooldown: state === void 0 ? [] : state.cooldown.snapshot()
|
|
1255
1831
|
};
|
|
@@ -1268,7 +1844,8 @@ function apply(ctx, config = defaultFallbacksConfig) {
|
|
|
1268
1844
|
revertPolicy: config.revertPolicy,
|
|
1269
1845
|
maxSwitchesPerStep: config.maxSwitchesPerStep,
|
|
1270
1846
|
alwaysModeRetryCap: config.alwaysModeRetryCap,
|
|
1271
|
-
presets: config.presets ?? "bundled"
|
|
1847
|
+
presets: config.presets ?? "bundled",
|
|
1848
|
+
roleAutoMatch: config.roleAutoMatch ?? true
|
|
1272
1849
|
};
|
|
1273
1850
|
}
|
|
1274
1851
|
};
|