@sangyaahub/pi-marg 1.0.1 → 1.0.4
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/.omp-plugin/marketplace.json +2 -2
- package/CHANGELOG.md +19 -0
- package/MODEL-SETUP.md +53 -9
- package/README.md +20 -6
- package/adapters/omp/extensions/model-router.ts +212 -15
- package/adapters/pi/extensions/model-router.ts +112 -2
- package/adapters/pi/prompts/auto-models.md +3 -3
- package/agents/auto-intake.md +1 -1
- package/assets/pi-marg-banner.png +0 -0
- package/commands/auto-models.md +3 -3
- package/core/model-routing.ts +617 -17
- package/package.json +1 -1
- package/scripts/install.sh +1 -1
- package/scripts/validate.sh +44 -1
- package/skills/auto-mode-router/SKILL.md +1 -1
- package/skills/auto-mode-router/references/intake-and-state.md +17 -3
- package/skills/auto-mode-router/references/models-and-tools.md +15 -2
- package/skills/auto-mode-router/references/session-ledger.md +5 -0
package/core/model-routing.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type AutoStage = "A" | "B" | "C" | "D";
|
|
2
|
+
export type FallbackLevel = "high" | "low";
|
|
2
3
|
export type CandidateTier = "frontier" | "execution" | "general";
|
|
3
4
|
export type RuntimeName = "OMP" | "Pi";
|
|
4
5
|
|
|
@@ -36,16 +37,30 @@ export interface ModelSelection extends ModelCandidate {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
export interface ModelRouteState {
|
|
39
|
-
version:
|
|
40
|
+
version: 2;
|
|
40
41
|
workType?: number;
|
|
41
42
|
selections: Partial<Record<AutoStage, ModelSelection>>;
|
|
43
|
+
fallbacks: Partial<Record<FallbackLevel, ModelSelection>>;
|
|
44
|
+
activeStage?: AutoStage;
|
|
45
|
+
activeFallback?: FallbackLevel;
|
|
46
|
+
exhaustedSelectors?: string[];
|
|
47
|
+
handledFailureKeys?: string[];
|
|
48
|
+
lastFailover?: {
|
|
49
|
+
stage?: AutoStage;
|
|
50
|
+
failedSelector: string;
|
|
51
|
+
fallbackLevel: FallbackLevel;
|
|
52
|
+
activatedSelector: string;
|
|
53
|
+
switchedAt: string;
|
|
54
|
+
};
|
|
42
55
|
}
|
|
43
56
|
|
|
44
57
|
export interface ModelRouteParams {
|
|
45
|
-
action: "catalog" | "select" | "activate" | "status";
|
|
58
|
+
action: "catalog" | "select" | "select_fallback" | "activate" | "status";
|
|
46
59
|
stage?: AutoStage;
|
|
60
|
+
fallback?: FallbackLevel;
|
|
47
61
|
target?: string;
|
|
48
62
|
workType?: number;
|
|
63
|
+
provider?: string;
|
|
49
64
|
}
|
|
50
65
|
|
|
51
66
|
export interface ModelRoutePort {
|
|
@@ -62,8 +77,66 @@ export interface ModelRouteExecution {
|
|
|
62
77
|
response: ReturnType<typeof toolResult>;
|
|
63
78
|
}
|
|
64
79
|
|
|
80
|
+
export interface AutomaticFailoverExecution {
|
|
81
|
+
state: ModelRouteState;
|
|
82
|
+
switched?: {
|
|
83
|
+
level: FallbackLevel;
|
|
84
|
+
selection: ModelSelection;
|
|
85
|
+
};
|
|
86
|
+
exhausted: boolean;
|
|
87
|
+
stateChanged: boolean;
|
|
88
|
+
reusedCurrent?: boolean;
|
|
89
|
+
ignoredDuplicate?: boolean;
|
|
90
|
+
persistenceError?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface TerminalUsageFailure {
|
|
94
|
+
role: "assistant";
|
|
95
|
+
stopReason: "error";
|
|
96
|
+
errorMessage: string;
|
|
97
|
+
provider?: string;
|
|
98
|
+
model?: string;
|
|
99
|
+
responseId?: string;
|
|
100
|
+
timestamp?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ToolUsageFailure {
|
|
104
|
+
selector: string;
|
|
105
|
+
key: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
65
108
|
export const MODEL_STATE_TYPE = "co.sangyaa.pi-marg.model-routing.v1";
|
|
66
109
|
export const STAGES: AutoStage[] = ["A", "B", "C", "D"];
|
|
110
|
+
export const FALLBACK_LEVELS: FallbackLevel[] = ["high", "low"];
|
|
111
|
+
|
|
112
|
+
const USAGE_LIMIT_PATTERNS = [
|
|
113
|
+
/usage[\s_-]*limit/i,
|
|
114
|
+
/quota[^\n]{0,40}(?:exceeded|exhausted|reached|depleted|limit)/i,
|
|
115
|
+
/insufficient[_ -]?quota/i,
|
|
116
|
+
/monthly\s+usage\s+limit/i,
|
|
117
|
+
/weekly\s+(?:usage\s+)?limit/i,
|
|
118
|
+
/available\s+balance[^\n]{0,40}(?:\b0\b|depleted|exhausted|insufficient)/i,
|
|
119
|
+
/out\s+of\s+(?:budget|credits?)/i,
|
|
120
|
+
/credits?\s+(?:are\s+)?exhausted/i,
|
|
121
|
+
/agent\s+compute\s+units?[^\n]{0,40}(?:exhausted|depleted|limit|remaining\s*:?\s*0)/i,
|
|
122
|
+
/\bACUs?\b[^\n]*(?:exhausted|limit|remaining\s*:?\s*0)/i,
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
const ERROR_TEXT_KEYS = ["error", "errorMessage", "message", "stderr", "output", "content"];
|
|
126
|
+
const ERROR_CONTAINER_KEYS = ["details", "result"];
|
|
127
|
+
const MAX_ERROR_TEXT_LENGTH = 32_000;
|
|
128
|
+
const MAX_ERROR_NODES = 200;
|
|
129
|
+
const MAX_HANDLED_FAILURE_KEYS = 32;
|
|
130
|
+
const EXACT_MODEL_EXECUTION_TOOLS = new Set([
|
|
131
|
+
"task",
|
|
132
|
+
"subagent",
|
|
133
|
+
"spawn_agent",
|
|
134
|
+
"run_agent",
|
|
135
|
+
"agent",
|
|
136
|
+
"workflowz",
|
|
137
|
+
"orchestrate",
|
|
138
|
+
"advisor",
|
|
139
|
+
]);
|
|
67
140
|
|
|
68
141
|
const FRONTIER_PATTERNS: Array<[ModelCandidate["choice"], RegExp]> = [
|
|
69
142
|
["claude-frontier", /(?:claude[^\n]*(?:fable|opus)|(?:fable|opus)[^\n]*claude)/i],
|
|
@@ -78,7 +151,87 @@ const EXECUTION_PATTERNS: Array<[ModelCandidate["choice"], RegExp]> = [
|
|
|
78
151
|
];
|
|
79
152
|
|
|
80
153
|
export function emptyModelRouteState(): ModelRouteState {
|
|
81
|
-
return { version:
|
|
154
|
+
return { version: 2, selections: {}, fallbacks: {} };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function isFallbackLevel(value: unknown): value is FallbackLevel {
|
|
158
|
+
return typeof value === "string" && FALLBACK_LEVELS.includes(value as FallbackLevel);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function isUsageLimitErrorText(value: unknown): boolean {
|
|
162
|
+
if (typeof value !== "string" || value.trim().length === 0) return false;
|
|
163
|
+
return USAGE_LIMIT_PATTERNS.some((pattern) => pattern.test(value));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function hasConfiguredFallback(state: ModelRouteState): boolean {
|
|
167
|
+
const high = state.fallbacks.high;
|
|
168
|
+
const low = state.fallbacks.low;
|
|
169
|
+
return Boolean(
|
|
170
|
+
high
|
|
171
|
+
&& low
|
|
172
|
+
&& high.access === "runtime-model"
|
|
173
|
+
&& low.access === "runtime-model"
|
|
174
|
+
&& high.identity !== low.identity,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function findTerminalUsageLimitFailure(messages: readonly unknown[]): TerminalUsageFailure | undefined {
|
|
179
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
180
|
+
const message = asRecord(messages[index]);
|
|
181
|
+
if (message?.role !== "assistant") continue;
|
|
182
|
+
if (message.stopReason !== "error" || typeof message.errorMessage !== "string") return undefined;
|
|
183
|
+
if (!isUsageLimitErrorText(message.errorMessage)) return undefined;
|
|
184
|
+
return {
|
|
185
|
+
role: "assistant",
|
|
186
|
+
stopReason: "error",
|
|
187
|
+
errorMessage: message.errorMessage,
|
|
188
|
+
...(typeof message.provider === "string" ? { provider: message.provider } : {}),
|
|
189
|
+
...(typeof message.model === "string" ? { model: message.model } : {}),
|
|
190
|
+
...(typeof message.responseId === "string" ? { responseId: message.responseId } : {}),
|
|
191
|
+
...(typeof message.timestamp === "number" ? { timestamp: message.timestamp } : {}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function failedModelSelector(message: TerminalUsageFailure, current?: ModelLike): string {
|
|
198
|
+
if (message.provider && message.model) {
|
|
199
|
+
return `${message.provider}/${message.model}`;
|
|
200
|
+
}
|
|
201
|
+
return current?.provider && current?.id ? `${current.provider}/${current.id}` : "unknown-model";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function modelFailureKey(message: TerminalUsageFailure): string {
|
|
205
|
+
if (message.responseId) return `model:${message.responseId}`;
|
|
206
|
+
return `model:${message.provider ?? "unknown"}/${message.model ?? "unknown"}:${message.timestamp ?? "no-time"}:${message.errorMessage}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function modelToolUsageLimitFailure(event: unknown): ToolUsageFailure | undefined {
|
|
210
|
+
const record = asRecord(event);
|
|
211
|
+
const toolName = typeof record?.toolName === "string" ? record.toolName : "";
|
|
212
|
+
if (record?.isError !== true || !isModelExecutionTool(toolName)) return undefined;
|
|
213
|
+
if (!isUsageLimitErrorText(boundedErrorText(record.result))) return undefined;
|
|
214
|
+
return {
|
|
215
|
+
selector: isDevinExecutionTool(toolName) ? "devin/external-session" : `tool/${toolName}`,
|
|
216
|
+
key: `tool:${typeof record.toolCallId === "string" ? record.toolCallId : `${toolName}:${boundedErrorText(record.result)}`}`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function formatFailoverRecoveryMessage(
|
|
221
|
+
state: ModelRouteState,
|
|
222
|
+
failedSelector: string,
|
|
223
|
+
level: FallbackLevel,
|
|
224
|
+
target: string,
|
|
225
|
+
reusedCurrent = false,
|
|
226
|
+
): string {
|
|
227
|
+
const stage = state.activeStage ? `Stage ${state.activeStage}` : "the active PiMarg stage";
|
|
228
|
+
return [
|
|
229
|
+
`[PiMarg auto-failover] ${failedSelector} reached its usage limit.`,
|
|
230
|
+
reusedCurrent
|
|
231
|
+
? `Kept the active ${level} backup: ${target}.`
|
|
232
|
+
: `Switched to the ${level} backup: ${target}.`,
|
|
233
|
+
`Resume the current ${stage} work from the last safe point. Inspect existing files, tool results, and external state before repeating any side effect. Do not restart intake or the approved plan.`,
|
|
234
|
+
].join("\n");
|
|
82
235
|
}
|
|
83
236
|
|
|
84
237
|
export function isStage(value: unknown): value is AutoStage {
|
|
@@ -89,7 +242,7 @@ export function requiredStagesForWorkType(workType: number): AutoStage[] {
|
|
|
89
242
|
switch (workType) {
|
|
90
243
|
case 2:
|
|
91
244
|
case 3:
|
|
92
|
-
return ["A", "
|
|
245
|
+
return ["A", "B", "C", "D"];
|
|
93
246
|
case 4:
|
|
94
247
|
return ["A", "B", "D"];
|
|
95
248
|
case 1:
|
|
@@ -102,6 +255,25 @@ export function requiredStagesForWorkType(workType: number): AutoStage[] {
|
|
|
102
255
|
}
|
|
103
256
|
}
|
|
104
257
|
|
|
258
|
+
export function ascendingStages(stages: readonly AutoStage[]): AutoStage[] {
|
|
259
|
+
return STAGES.filter((stage) => stages.includes(stage));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function emptyCatalogSetupMessage(runtimeName: RuntimeName): string {
|
|
263
|
+
if (runtimeName === "OMP") {
|
|
264
|
+
return [
|
|
265
|
+
"No authenticated and enabled OMP models were found.",
|
|
266
|
+
"Set up any provider subscription with /login, verify with `omp models` or /model, then run /pi-marg again.",
|
|
267
|
+
"PiMarg picks up whatever models you configure in OMP; see MODEL-SETUP.md.",
|
|
268
|
+
].join(" ");
|
|
269
|
+
}
|
|
270
|
+
return [
|
|
271
|
+
"No authenticated and available Pi models were found.",
|
|
272
|
+
"Configure any provider subscription or API credentials, verify with `pi --list-models`, then retry.",
|
|
273
|
+
"PiMarg picks up whatever models you configure in Pi; see MODEL-SETUP.md.",
|
|
274
|
+
].join(" ");
|
|
275
|
+
}
|
|
276
|
+
|
|
105
277
|
export function normalizeModelIdentity(value: string): string {
|
|
106
278
|
let normalized = value
|
|
107
279
|
.toLowerCase()
|
|
@@ -191,6 +363,115 @@ export function buildModelCatalog(models: ModelLike[], hasExternalDevin: boolean
|
|
|
191
363
|
} satisfies Record<AutoStage, ModelCandidate[]>;
|
|
192
364
|
}
|
|
193
365
|
|
|
366
|
+
export function buildFallbackCatalog(catalog: Record<AutoStage, ModelCandidate[]>) {
|
|
367
|
+
return {
|
|
368
|
+
high: catalog.A.filter((candidate) => candidate.access === "runtime-model"),
|
|
369
|
+
low: catalog.B.filter((candidate) => candidate.access === "runtime-model"),
|
|
370
|
+
} satisfies Record<FallbackLevel, ModelCandidate[]>;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function numberedOptionLabel(index: number, text: string): string {
|
|
374
|
+
return `${index + 1}. ${text}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function matchNumberedOption<T>(
|
|
378
|
+
answer: string | undefined,
|
|
379
|
+
items: readonly T[],
|
|
380
|
+
labelOf: (item: T) => string,
|
|
381
|
+
): T | undefined {
|
|
382
|
+
if (!answer) return undefined;
|
|
383
|
+
return items.find((item, index) => numberedOptionLabel(index, labelOf(item)) === answer);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function uniqueProviders(candidates: readonly ModelCandidate[]): string[] {
|
|
387
|
+
return [...new Set(candidates.map((candidate) => candidate.provider))].sort((left, right) => left.localeCompare(right));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function candidatesForProvider(
|
|
391
|
+
candidates: readonly ModelCandidate[],
|
|
392
|
+
provider: string,
|
|
393
|
+
): ModelCandidate[] {
|
|
394
|
+
return candidates.filter((candidate) => candidate.provider === provider);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function formatCatalogPresentation(
|
|
398
|
+
runtimeName: RuntimeName,
|
|
399
|
+
catalog: Record<AutoStage, ModelCandidate[]>,
|
|
400
|
+
requiredStages: AutoStage[],
|
|
401
|
+
providerFilter?: string,
|
|
402
|
+
) {
|
|
403
|
+
const notes = catalogNotes(runtimeName, catalog);
|
|
404
|
+
const orderedStages = ascendingStages(requiredStages);
|
|
405
|
+
const stages: Record<string, {
|
|
406
|
+
providers: Array<{ index: number; name: string; count: number }>;
|
|
407
|
+
providerFilter: string | null;
|
|
408
|
+
count: number;
|
|
409
|
+
options: Array<{
|
|
410
|
+
index: number;
|
|
411
|
+
selector: string;
|
|
412
|
+
label: string;
|
|
413
|
+
provider: string;
|
|
414
|
+
tier: CandidateTier;
|
|
415
|
+
choice: ModelCandidate["choice"];
|
|
416
|
+
}>;
|
|
417
|
+
}> = {};
|
|
418
|
+
const lines: string[] = [
|
|
419
|
+
`${runtimeName} model catalog`,
|
|
420
|
+
`Required stages: ${orderedStages.join(", ")}`,
|
|
421
|
+
"",
|
|
422
|
+
];
|
|
423
|
+
|
|
424
|
+
for (const stage of orderedStages) {
|
|
425
|
+
const all = catalog[stage];
|
|
426
|
+
const providers = uniqueProviders(all).map((name, index) => ({
|
|
427
|
+
index: index + 1,
|
|
428
|
+
name,
|
|
429
|
+
count: candidatesForProvider(all, name).length,
|
|
430
|
+
}));
|
|
431
|
+
const shown = providerFilter ? candidatesForProvider(all, providerFilter) : [];
|
|
432
|
+
const options = shown.map((candidate, index) => ({
|
|
433
|
+
index: index + 1,
|
|
434
|
+
selector: candidate.selector,
|
|
435
|
+
label: candidate.label,
|
|
436
|
+
provider: candidate.provider,
|
|
437
|
+
tier: candidate.tier,
|
|
438
|
+
choice: candidate.choice,
|
|
439
|
+
}));
|
|
440
|
+
stages[stage] = {
|
|
441
|
+
providers,
|
|
442
|
+
providerFilter: providerFilter ?? null,
|
|
443
|
+
count: providerFilter ? shown.length : all.length,
|
|
444
|
+
options,
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
lines.push(`## Stage ${stage} (${all.length} models across ${providers.length} providers)`);
|
|
448
|
+
lines.push("Providers:");
|
|
449
|
+
for (const provider of providers) {
|
|
450
|
+
const marker = providerFilter === provider.name ? " ← selected" : "";
|
|
451
|
+
lines.push(` ${provider.index}. ${provider.name} (${provider.count})${marker}`);
|
|
452
|
+
}
|
|
453
|
+
if (!providerFilter) {
|
|
454
|
+
lines.push("Call catalog again with provider=<name> to list every model for that provider.");
|
|
455
|
+
} else {
|
|
456
|
+
lines.push(`Models for ${providerFilter} (${shown.length}):`);
|
|
457
|
+
for (const option of options) {
|
|
458
|
+
lines.push(` ${option.index}. ${option.label} — ${option.selector} [${option.tier}]`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
lines.push("");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
for (const note of notes) lines.push(`- ${note}`);
|
|
465
|
+
lines.push("- Present every provider as 1,2,… then every model for the chosen provider as 1,2,… Never omit a logged-in provider.");
|
|
466
|
+
lines.push("- Select with action=select using the exact selector string.");
|
|
467
|
+
lines.push("- Also save two distinct runtime-only backups with action=select_fallback: high first, then low.");
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
text: lines.join("\n"),
|
|
471
|
+
presentation: { runtime: runtimeName, requiredStages: orderedStages, stages, notes },
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
194
475
|
export function validateDistinctSelection(
|
|
195
476
|
state: ModelRouteState,
|
|
196
477
|
stage: AutoStage,
|
|
@@ -208,29 +489,183 @@ export function validateDistinctSelection(
|
|
|
208
489
|
export function restoreModelRouteState(entries: readonly any[]): ModelRouteState {
|
|
209
490
|
let restored = emptyModelRouteState();
|
|
210
491
|
for (const entry of entries) {
|
|
211
|
-
if (entry?.type
|
|
212
|
-
|
|
492
|
+
if (entry?.type !== "custom" || entry.customType !== MODEL_STATE_TYPE) continue;
|
|
493
|
+
if (entry.data?.version === 2) {
|
|
494
|
+
restored = {
|
|
495
|
+
...entry.data,
|
|
496
|
+
version: 2,
|
|
497
|
+
selections: entry.data.selections ?? {},
|
|
498
|
+
fallbacks: entry.data.fallbacks ?? {},
|
|
499
|
+
} as ModelRouteState;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (entry.data?.version === 1) {
|
|
503
|
+
restored = {
|
|
504
|
+
version: 2,
|
|
505
|
+
workType: entry.data.workType,
|
|
506
|
+
selections: entry.data.selections ?? {},
|
|
507
|
+
fallbacks: {},
|
|
508
|
+
};
|
|
213
509
|
}
|
|
214
510
|
}
|
|
215
511
|
return restored;
|
|
216
512
|
}
|
|
217
513
|
|
|
514
|
+
export async function executeAutomaticFailover(
|
|
515
|
+
currentState: ModelRouteState,
|
|
516
|
+
failedSelector: string,
|
|
517
|
+
port: ModelRoutePort,
|
|
518
|
+
failureKey?: string,
|
|
519
|
+
): Promise<AutomaticFailoverExecution> {
|
|
520
|
+
const failed = failedSelector.trim() || "unknown-model";
|
|
521
|
+
const handledFailureKeys = currentState.handledFailureKeys ?? [];
|
|
522
|
+
if (failureKey && handledFailureKeys.includes(failureKey)) {
|
|
523
|
+
return {
|
|
524
|
+
state: currentState,
|
|
525
|
+
exhausted: false,
|
|
526
|
+
stateChanged: false,
|
|
527
|
+
ignoredDuplicate: true,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
const nextHandledFailureKeys = failureKey
|
|
531
|
+
? [...handledFailureKeys, failureKey].slice(-MAX_HANDLED_FAILURE_KEYS)
|
|
532
|
+
: handledFailureKeys;
|
|
533
|
+
const exhausted = new Set(currentState.exhaustedSelectors ?? []);
|
|
534
|
+
exhausted.add(failed);
|
|
535
|
+
const activeLevel = currentState.activeFallback;
|
|
536
|
+
const activeSelection = activeLevel ? currentState.fallbacks[activeLevel] : undefined;
|
|
537
|
+
|
|
538
|
+
if (activeLevel && activeSelection && activeSelection.selector !== failed) {
|
|
539
|
+
const nextState: ModelRouteState = {
|
|
540
|
+
...currentState,
|
|
541
|
+
handledFailureKeys: nextHandledFailureKeys,
|
|
542
|
+
exhaustedSelectors: [...exhausted],
|
|
543
|
+
};
|
|
544
|
+
const persistenceError = persistFailoverState(port, nextState);
|
|
545
|
+
return {
|
|
546
|
+
state: nextState,
|
|
547
|
+
switched: { level: activeLevel, selection: activeSelection },
|
|
548
|
+
exhausted: false,
|
|
549
|
+
stateChanged: true,
|
|
550
|
+
reusedCurrent: true,
|
|
551
|
+
...(persistenceError ? { persistenceError } : {}),
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const startingIndex = activeLevel ? FALLBACK_LEVELS.indexOf(activeLevel) + 1 : 0;
|
|
556
|
+
|
|
557
|
+
for (const level of FALLBACK_LEVELS.slice(startingIndex)) {
|
|
558
|
+
const selection = currentState.fallbacks[level];
|
|
559
|
+
if (!selection || selection.access !== "runtime-model" || exhausted.has(selection.selector)) continue;
|
|
560
|
+
const model = port.models.find((item) => `${item.provider}/${item.id}` === selection.selector);
|
|
561
|
+
if (!model) {
|
|
562
|
+
exhausted.add(selection.selector);
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
let activated: boolean | void;
|
|
566
|
+
try {
|
|
567
|
+
activated = await port.activate(model);
|
|
568
|
+
} catch {
|
|
569
|
+
exhausted.add(selection.selector);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (activated === false) {
|
|
573
|
+
exhausted.add(selection.selector);
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const nextState: ModelRouteState = {
|
|
578
|
+
...currentState,
|
|
579
|
+
version: 2,
|
|
580
|
+
activeFallback: level,
|
|
581
|
+
exhaustedSelectors: [...exhausted],
|
|
582
|
+
handledFailureKeys: nextHandledFailureKeys,
|
|
583
|
+
lastFailover: {
|
|
584
|
+
stage: currentState.activeStage,
|
|
585
|
+
failedSelector: failed,
|
|
586
|
+
fallbackLevel: level,
|
|
587
|
+
activatedSelector: selection.selector,
|
|
588
|
+
switchedAt: port.now?.() ?? new Date().toISOString(),
|
|
589
|
+
},
|
|
590
|
+
};
|
|
591
|
+
const persistenceError = persistFailoverState(port, nextState);
|
|
592
|
+
return {
|
|
593
|
+
state: nextState,
|
|
594
|
+
switched: { level, selection },
|
|
595
|
+
exhausted: false,
|
|
596
|
+
stateChanged: true,
|
|
597
|
+
...(persistenceError ? { persistenceError } : {}),
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const exhaustedSelectors = [...exhausted];
|
|
602
|
+
const priorExhausted = new Set(currentState.exhaustedSelectors ?? []);
|
|
603
|
+
const stateChanged = exhaustedSelectors.length !== priorExhausted.size
|
|
604
|
+
|| exhaustedSelectors.some((selector) => !priorExhausted.has(selector))
|
|
605
|
+
|| nextHandledFailureKeys.length !== handledFailureKeys.length;
|
|
606
|
+
if (!stateChanged) {
|
|
607
|
+
return { state: currentState, exhausted: true, stateChanged: false };
|
|
608
|
+
}
|
|
609
|
+
const nextState: ModelRouteState = {
|
|
610
|
+
...currentState,
|
|
611
|
+
version: 2,
|
|
612
|
+
exhaustedSelectors,
|
|
613
|
+
handledFailureKeys: nextHandledFailureKeys,
|
|
614
|
+
};
|
|
615
|
+
const persistenceError = persistFailoverState(port, nextState);
|
|
616
|
+
return {
|
|
617
|
+
state: nextState,
|
|
618
|
+
exhausted: true,
|
|
619
|
+
stateChanged: true,
|
|
620
|
+
...(persistenceError ? { persistenceError } : {}),
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
218
624
|
export async function executeModelRoute(
|
|
219
625
|
currentState: ModelRouteState,
|
|
220
626
|
params: ModelRouteParams,
|
|
221
627
|
port: ModelRoutePort,
|
|
222
628
|
): Promise<ModelRouteExecution> {
|
|
223
|
-
const catalog = buildModelCatalog(port.models, port.hasExternalDevin);
|
|
224
|
-
|
|
225
629
|
if (params.action === "catalog") {
|
|
630
|
+
const catalog = buildModelCatalog(port.models, port.hasExternalDevin);
|
|
631
|
+
const fallbackCatalog = buildFallbackCatalog(catalog);
|
|
632
|
+
if (port.models.length === 0) {
|
|
633
|
+
return {
|
|
634
|
+
state: currentState,
|
|
635
|
+
response: toolResult(
|
|
636
|
+
emptyCatalogSetupMessage(port.runtimeName),
|
|
637
|
+
{ runtime: port.runtimeName, catalog, fallbackCatalog, state: currentState },
|
|
638
|
+
true,
|
|
639
|
+
),
|
|
640
|
+
};
|
|
641
|
+
}
|
|
226
642
|
const workType = Number(params.workType);
|
|
227
643
|
const requiredStages = Number.isInteger(workType) ? requiredStagesForWorkType(workType) : STAGES;
|
|
644
|
+
const providerFilter = typeof params.provider === "string" && params.provider.trim() ? params.provider.trim() : undefined;
|
|
645
|
+
if (providerFilter) {
|
|
646
|
+
const known = STAGES.some((stage) => catalog[stage].some((candidate) => candidate.provider === providerFilter));
|
|
647
|
+
if (!known) {
|
|
648
|
+
return {
|
|
649
|
+
state: currentState,
|
|
650
|
+
response: toolResult(
|
|
651
|
+
`Provider ${providerFilter} is not in the live authenticated catalog. Refresh catalog without a provider filter.`,
|
|
652
|
+
{ runtime: port.runtimeName, requiredStages, catalog, state: currentState },
|
|
653
|
+
true,
|
|
654
|
+
),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const formatted = formatCatalogPresentation(port.runtimeName, catalog, requiredStages, providerFilter);
|
|
228
659
|
return {
|
|
229
660
|
state: currentState,
|
|
230
|
-
response: toolResult(
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
661
|
+
response: toolResult(formatted.text, {
|
|
662
|
+
runtime: port.runtimeName,
|
|
663
|
+
requiredStages: formatted.presentation.requiredStages,
|
|
664
|
+
catalog,
|
|
665
|
+
fallbackCatalog,
|
|
666
|
+
presentation: formatted.presentation,
|
|
667
|
+
state: currentState,
|
|
668
|
+
}),
|
|
234
669
|
};
|
|
235
670
|
}
|
|
236
671
|
|
|
@@ -238,12 +673,59 @@ export async function executeModelRoute(
|
|
|
238
673
|
return { state: currentState, response: toolResult(JSON.stringify(currentState, null, 2), { state: currentState }) };
|
|
239
674
|
}
|
|
240
675
|
|
|
676
|
+
if (params.action === "select_fallback") {
|
|
677
|
+
const catalog = buildModelCatalog(port.models, port.hasExternalDevin);
|
|
678
|
+
const fallbackCatalog = buildFallbackCatalog(catalog);
|
|
679
|
+
if (!isFallbackLevel(params.fallback)) {
|
|
680
|
+
return { state: currentState, response: toolResult("A fallback level of high or low is required.", { state: currentState }, true) };
|
|
681
|
+
}
|
|
682
|
+
if (typeof params.target !== "string") {
|
|
683
|
+
return { state: currentState, response: toolResult("Select an exact runtime selector from the current catalog.", { fallbackCatalog, state: currentState }, true) };
|
|
684
|
+
}
|
|
685
|
+
const level = params.fallback;
|
|
686
|
+
if (level === "low" && !currentState.fallbacks.high) {
|
|
687
|
+
return {
|
|
688
|
+
state: currentState,
|
|
689
|
+
response: toolResult("Select the high fallback before the low fallback so the recovery order is explicit.", { fallbackCatalog, state: currentState }, true),
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
const selected = fallbackCatalog[level].find((item) => item.selector === params.target);
|
|
693
|
+
if (!selected) {
|
|
694
|
+
return { state: currentState, response: toolResult(`Target ${params.target} is not an available ${level} fallback choice. Refresh the catalog.`, { fallbackCatalog, state: currentState }, true) };
|
|
695
|
+
}
|
|
696
|
+
const otherLevel: FallbackLevel = level === "high" ? "low" : "high";
|
|
697
|
+
const other = currentState.fallbacks[otherLevel];
|
|
698
|
+
if (other?.identity === selected.identity) {
|
|
699
|
+
return { state: currentState, response: toolResult(`The ${level} fallback must use a different model from the ${otherLevel} fallback.`, { fallbackCatalog, state: currentState }, true) };
|
|
700
|
+
}
|
|
701
|
+
const nextState: ModelRouteState = {
|
|
702
|
+
...currentState,
|
|
703
|
+
version: 2,
|
|
704
|
+
fallbacks: {
|
|
705
|
+
...currentState.fallbacks,
|
|
706
|
+
[level]: { ...selected, selectedAt: port.now?.() ?? new Date().toISOString() },
|
|
707
|
+
},
|
|
708
|
+
activeFallback: undefined,
|
|
709
|
+
exhaustedSelectors: [],
|
|
710
|
+
handledFailureKeys: [],
|
|
711
|
+
};
|
|
712
|
+
port.persist(nextState);
|
|
713
|
+
return {
|
|
714
|
+
state: nextState,
|
|
715
|
+
response: toolResult(
|
|
716
|
+
`Saved ${selected.label} (${selected.selector}) as the ${level} fallback. It will activate only after a usage-limit failure.`,
|
|
717
|
+
{ state: nextState, selected, fallback: level },
|
|
718
|
+
),
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
241
722
|
if (!isStage(params.stage)) {
|
|
242
723
|
return { state: currentState, response: toolResult("A valid stage A, B, C, or D is required.", { state: currentState }, true) };
|
|
243
724
|
}
|
|
244
725
|
|
|
245
726
|
const stage = params.stage;
|
|
246
727
|
if (params.action === "select") {
|
|
728
|
+
const catalog = buildModelCatalog(port.models, port.hasExternalDevin);
|
|
247
729
|
if (typeof params.target !== "string") {
|
|
248
730
|
return { state: currentState, response: toolResult("Select an exact selector from the current catalog.", { catalog, state: currentState }, true) };
|
|
249
731
|
}
|
|
@@ -263,12 +745,17 @@ export async function executeModelRoute(
|
|
|
263
745
|
|
|
264
746
|
const workType = Number(params.workType);
|
|
265
747
|
const nextState: ModelRouteState = {
|
|
266
|
-
|
|
748
|
+
...currentState,
|
|
749
|
+
version: 2,
|
|
267
750
|
workType: Number.isInteger(workType) && workType >= 1 && workType <= 7 ? workType : currentState.workType,
|
|
268
751
|
selections: {
|
|
269
752
|
...currentState.selections,
|
|
270
753
|
[stage]: { ...selected, selectedAt: port.now?.() ?? new Date().toISOString() },
|
|
271
754
|
},
|
|
755
|
+
activeStage: stage,
|
|
756
|
+
activeFallback: undefined,
|
|
757
|
+
exhaustedSelectors: [],
|
|
758
|
+
handledFailureKeys: [],
|
|
272
759
|
};
|
|
273
760
|
port.persist(nextState);
|
|
274
761
|
return {
|
|
@@ -288,13 +775,113 @@ export async function executeModelRoute(
|
|
|
288
775
|
}
|
|
289
776
|
if (selected.access === "external-agent") {
|
|
290
777
|
if (!port.hasExternalDevin) return { state: currentState, response: toolResult("Devin was selected but no external Devin tool is available.", { state: currentState }, true) };
|
|
291
|
-
|
|
778
|
+
const nextState: ModelRouteState = {
|
|
779
|
+
...currentState,
|
|
780
|
+
activeStage: stage,
|
|
781
|
+
activeFallback: undefined,
|
|
782
|
+
exhaustedSelectors: [],
|
|
783
|
+
handledFailureKeys: [],
|
|
784
|
+
};
|
|
785
|
+
port.persist(nextState);
|
|
786
|
+
return { state: nextState, response: toolResult(`Stage ${stage} uses an external Devin session. Delegate only after post-web approval.`, { state: nextState, selected }) };
|
|
292
787
|
}
|
|
293
788
|
const model = port.models.find((item) => `${item.provider}/${item.id}` === selected.selector);
|
|
294
789
|
if (!model) return { state: currentState, response: toolResult(`Saved model ${selected.selector} is unavailable. Select a replacement.`, { state: currentState }, true) };
|
|
295
790
|
const activated = await port.activate(model);
|
|
296
791
|
if (activated === false) return { state: currentState, response: toolResult(`Could not activate ${selected.selector}; verify provider authentication.`, { state: currentState }, true) };
|
|
297
|
-
|
|
792
|
+
const nextState: ModelRouteState = {
|
|
793
|
+
...currentState,
|
|
794
|
+
activeStage: stage,
|
|
795
|
+
activeFallback: undefined,
|
|
796
|
+
exhaustedSelectors: [],
|
|
797
|
+
handledFailureKeys: [],
|
|
798
|
+
};
|
|
799
|
+
port.persist(nextState);
|
|
800
|
+
return { state: nextState, response: toolResult(`Activated ${selected.label} (${selected.selector}) for Stage ${stage}.`, { state: nextState, selected }) };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function boundedErrorText(value: unknown): string {
|
|
804
|
+
const parts: string[] = [];
|
|
805
|
+
const seen = new Set<object>();
|
|
806
|
+
let remaining = MAX_ERROR_TEXT_LENGTH;
|
|
807
|
+
let nodes = 0;
|
|
808
|
+
|
|
809
|
+
const append = (text: string) => {
|
|
810
|
+
if (remaining <= 0 || text.length === 0) return;
|
|
811
|
+
const fragment = text.slice(0, remaining);
|
|
812
|
+
parts.push(fragment);
|
|
813
|
+
remaining -= fragment.length;
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
const visit = (item: unknown, includePlainText = true) => {
|
|
817
|
+
if (remaining <= 0 || nodes >= MAX_ERROR_NODES || item == null) return;
|
|
818
|
+
nodes += 1;
|
|
819
|
+
if (typeof item === "string") {
|
|
820
|
+
if (includePlainText) append(item);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (typeof item !== "object") {
|
|
824
|
+
if (includePlainText) append(String(item));
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (seen.has(item)) return;
|
|
828
|
+
seen.add(item);
|
|
829
|
+
|
|
830
|
+
if (item instanceof Error) {
|
|
831
|
+
append(item.message);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (Array.isArray(item)) {
|
|
835
|
+
for (const child of item) visit(child, includePlainText);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const record = item as Record<string, unknown>;
|
|
840
|
+
if (includePlainText && record.type === "text" && typeof record.text === "string") append(record.text);
|
|
841
|
+
for (const key of ERROR_TEXT_KEYS) {
|
|
842
|
+
if (!(key in record)) continue;
|
|
843
|
+
try {
|
|
844
|
+
visit(record[key], true);
|
|
845
|
+
} catch {
|
|
846
|
+
// Ignore getters that throw while inspecting an external tool error.
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
for (const key of ERROR_CONTAINER_KEYS) {
|
|
850
|
+
if (!(key in record)) continue;
|
|
851
|
+
try {
|
|
852
|
+
visit(record[key], false);
|
|
853
|
+
} catch {
|
|
854
|
+
// Ignore getters that throw while inspecting an external tool error.
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
visit(value);
|
|
860
|
+
return parts.join("\n");
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
864
|
+
return typeof value === "object" && value !== null ? value as Record<string, unknown> : undefined;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function isDevinExecutionTool(toolName: string): boolean {
|
|
868
|
+
return /^(?:mcp__)?devin(?:__|_|$)/i.test(toolName);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function isModelExecutionTool(toolName: string): boolean {
|
|
872
|
+
const normalized = toolName.toLowerCase();
|
|
873
|
+
return EXACT_MODEL_EXECUTION_TOOLS.has(normalized)
|
|
874
|
+
|| /^(?:spawn_agent|run_agent|subagent)(?:__|_|$)/.test(normalized)
|
|
875
|
+
|| /^(?:mcp__)?(?:devin|workflowz)(?:__|_|$)/.test(normalized);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function persistFailoverState(port: ModelRoutePort, state: ModelRouteState): string | undefined {
|
|
879
|
+
try {
|
|
880
|
+
port.persist(state);
|
|
881
|
+
return undefined;
|
|
882
|
+
} catch (error) {
|
|
883
|
+
return error instanceof Error ? error.message : String(error);
|
|
884
|
+
}
|
|
298
885
|
}
|
|
299
886
|
|
|
300
887
|
function candidate(
|
|
@@ -354,10 +941,14 @@ function toolResult(text: string, details: unknown, isError = false) {
|
|
|
354
941
|
};
|
|
355
942
|
}
|
|
356
943
|
|
|
944
|
+
function catalogHasProvider(catalog: Record<AutoStage, ModelCandidate[]>, provider: string): boolean {
|
|
945
|
+
return STAGES.some((stage) => catalog[stage].some((candidate) => candidate.provider === provider));
|
|
946
|
+
}
|
|
947
|
+
|
|
357
948
|
function catalogNotes(runtimeName: RuntimeName, catalog: Record<AutoStage, ModelCandidate[]>): string[] {
|
|
358
949
|
const notes = [
|
|
359
|
-
`The catalog shows every model in ${runtimeName}'s authenticated, enabled registry at call time; selectors are never guessed or
|
|
360
|
-
"A and C rank frontier fits first. B and D rank execution fits first. Every live model remains selectable for every stage.",
|
|
950
|
+
`The catalog shows every model in ${runtimeName}'s authenticated, enabled registry at call time; selectors are never guessed, pinned, or limited to a maintainer's subscriptions.`,
|
|
951
|
+
"A and C rank recognizable frontier fits first when present. B and D rank recognizable execution fits first when present. Every live model remains selectable for every stage.",
|
|
361
952
|
"Zero-metered describes the runtime's reported token price; it does not promise unlimited subscription usage.",
|
|
362
953
|
];
|
|
363
954
|
if (!catalog.A.some((candidate) => candidate.tier === "frontier")) {
|
|
@@ -366,5 +957,14 @@ function catalogNotes(runtimeName: RuntimeName, catalog: Record<AutoStage, Model
|
|
|
366
957
|
if (!catalog.B.some((candidate) => candidate.tier === "execution")) {
|
|
367
958
|
notes.push("No preferred execution matches are authenticated and no external Devin tool was detected; choose another available model.");
|
|
368
959
|
}
|
|
960
|
+
if (
|
|
961
|
+
runtimeName === "OMP"
|
|
962
|
+
&& catalogHasProvider(catalog, "cursor")
|
|
963
|
+
&& catalogHasProvider(catalog, "openai-codex")
|
|
964
|
+
) {
|
|
965
|
+
notes.push(
|
|
966
|
+
"OMP compatibility warning (@oh-my-pi/pi-coding-agent 18.1.17): mid-session switches from cursor to openai-codex can reject tool call_id values longer than 64 characters when prior cursor-agent history is replayed. Start a fresh session that does not replay the affected tool history; branching alone is insufficient if that history is retained. This is a harness limitation, not a PiMarg model-routing failure.",
|
|
967
|
+
);
|
|
968
|
+
}
|
|
369
969
|
return notes;
|
|
370
970
|
}
|