@sangyaahub/pi-marg 1.0.3 → 1.0.5

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.
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "PiMarg — a governed development path for Pi Agent and Oh My Pi.",
10
- "version": "1.0.3"
10
+ "version": "1.0.5"
11
11
  },
12
12
  "plugins": [
13
13
  {
14
14
  "name": "pi-marg",
15
15
  "description": "Intent-led development workflow with live A/B/C/D routing, capability detection, verification, and approval gates.",
16
16
  "source": "./",
17
- "version": "1.0.3",
17
+ "version": "1.0.5",
18
18
  "author": {
19
19
  "name": "Sangyaa",
20
20
  "email": "contact@sangyaa.co"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.5 — 2026-09-26
4
+
5
+ - Documented how to register CheaperInference in Oh My Pi so Kimi and GLM models can be chosen at every A/B/C/D stage.
6
+ - Recorded the distinct-model rule and a high/low backup example for that provider.
7
+
8
+ ## 1.0.4 — 2026-09-17
9
+
10
+ - Added two runtime-only recovery choices: a strong **high** backup followed by an economical **low** backup.
11
+ - Automatically switches and resumes after OMP or Pi settles a usage-limit/quota failure, with a visible notification and a no-repeat-side-effects continuation instruction.
12
+ - Handles direct runtime-model failures and detected model-execution tool failures (including external Devin and subagents); old v1 session routes migrate without losing their A/B/C/D selections.
13
+ - Deduplicates repeated failure events, ignores transient throttles, advances to low only when the active high model fails, restores Pi tree-navigation state, and warns without stranding the run if ledger persistence fails after activation.
14
+
3
15
  ## 1.0.3 — 2026-09-14
4
16
 
5
17
  - Made model routing explicitly model-agnostic: live OMP/Pi authenticated catalogs are the only source of truth, with soft preferred-class ranking that never requires a maintainer's subscriptions.
package/MODEL-SETUP.md CHANGED
@@ -11,6 +11,9 @@ LIVE AUTHENTICATED CATALOG
11
11
  A / C B / D
12
12
  │ │
13
13
  └─ different ─┘ within each pair
14
+ │
15
+ ▼ quota failure
16
+ high backup ──► low backup
14
17
  ```
15
18
 
16
19
  If the live catalog is empty, PiMarg stops and promotes provider/subscription setup instead of guessing models.
@@ -46,6 +49,27 @@ omp models
46
49
 
47
50
  The OMP adapter reads `ctx.models.list()`, which supplies authenticated and enabled models, and activates an exact catalog entry with `pi.setModel()`.
48
51
 
52
+ ## CheaperInference example
53
+
54
+ This is one custom Oh My Pi provider, not a pinned PiMarg default. After it is authenticated, every model below is selectable at A, B, C, and D. A and C must still be different underlying models, and B and D must still be different underlying models.
55
+
56
+ Create a CheaperInference key (`ci_live_...`) and save it as `CHEAPER_INFERENCE_API_KEY` in `~/.omp/agent/.env`. Point `providers.cheaperinference.apiKey` at that variable name. The base URL is `https://api.cheaperinference.com/v1`. Confirm ids with `GET /v1/models` before use, because the catalog can change.
57
+
58
+ Register `kimi-k3`, `glm-5.3`, `glm-5.3-flash`, and `glm-5.2` under that provider, then verify with `omp models find cheaperinference`. The copy-paste provider block is in the README section [CheaperInference (Kimi and GLM)](README.md#cheaperinference-kimi-and-glm).
59
+
60
+ One assignment that satisfies the distinct-model rule:
61
+
62
+ | Stage | Selector |
63
+ |---|---|
64
+ | A | `cheaperinference/kimi-k3` |
65
+ | C | `cheaperinference/glm-5.3` |
66
+ | B | `cheaperinference/glm-5.3` |
67
+ | D | `cheaperinference/kimi-k3` |
68
+ | high backup | `cheaperinference/kimi-k3` |
69
+ | low backup | `cheaperinference/glm-5.3-flash` |
70
+
71
+ Any of those four models can be chosen for any stage. Calls spend CheaperInference wallet balance.
72
+
49
73
  ## Selection workflow
50
74
 
51
75
  1. Run `/pi-marg` for the full native intake, or `/auto-models choose` to change saved models.
@@ -56,6 +80,8 @@ The OMP adapter reads `ctx.models.list()`, which supplies authenticated and enab
56
80
  6. Select B and D when implementation is required; recommended execution choices appear first within their lists when recognizable, but every live model remains selectable.
57
81
  7. Resolve any A=C or B=D identity conflict before continuing.
58
82
  8. Activate the saved selector immediately before its stage.
83
+ 9. Choose a runtime-only **high backup** (strongest recovery fit) and a distinct runtime-only **low backup** (economical recovery fit). External-agent entries cannot be backups because PiMarg must be able to activate them in the current session.
84
+ 10. Automatic recovery becomes active only after both backups are saved. On a positively identified terminal usage-limit failure, PiMarg preserves the stage, waits for the runtime to settle, switches high then low, notifies the user, and resumes from the last safe point. Duplicate events and temporary throttles do not consume another backup.
59
85
 
60
86
  ### Tool form (`auto_model_route`)
61
87
 
@@ -68,6 +94,10 @@ auto_model_route action=catalog workType=<n> provider=<provider-name>
68
94
 
69
95
  # 3) Persist and activate an exact selector
70
96
  auto_model_route action=select stage=<A|B|C|D> target=<provider>/<model-id> workType=<n>
97
+
98
+ # 4) Persist the automatic recovery pair (does not activate either model yet)
99
+ auto_model_route action=select_fallback fallback=high target=<provider>/<model-id>
100
+ auto_model_route action=select_fallback fallback=low target=<provider>/<model-id>
71
101
  ```
72
102
 
73
103
  Never present a hand-curated subset that omits a logged-in provider. Every authenticated and enabled runtime model remains selectable at every required stage. Soft preferred-class ranking controls order, not visibility.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <a href="https://github.com/sangyaahub/pi-marg/releases"><img alt="Version 1.0.3" src="https://img.shields.io/badge/version-1.0.3-0451F7"></a>
6
+ <a href="https://github.com/sangyaahub/pi-marg/releases"><img alt="Version 1.0.5" src="https://img.shields.io/badge/version-1.0.5-0451F7"></a>
7
7
  <a href="LICENSE"><img alt="MIT License" src="https://img.shields.io/badge/license-MIT-C27B1A"></a>
8
8
  <a href="https://pi.dev/packages?name=pi-marg"><img alt="Pi package" src="https://img.shields.io/badge/Pi-package-0338A8"></a>
9
9
  <a href="https://omp.sh"><img alt="Oh My Pi plugin" src="https://img.shields.io/badge/OMP-plugin-021E5C"></a>
@@ -11,7 +11,7 @@
11
11
 
12
12
  # PiMarg
13
13
 
14
- **Version 1.0.3 · MIT licensed · Pi Agent + Oh My Pi · by [Sangyaa](https://sangyaa.co)**
14
+ **Version 1.0.5 · MIT licensed · Pi Agent + Oh My Pi · by [Sangyaa](https://sangyaa.co)**
15
15
 
16
16
  PiMarg turns an ordinary prompt into a governed development path. It identifies the work boundary, selects one engineering method, discovers the models and tools the active runtime really provides, separates thinking from execution, records evidence, and stops before protected actions.
17
17
 
@@ -55,14 +55,14 @@ The same model-selection and approval logic lives in `core/`. Runtime-specific c
55
55
  |---|---|---|
56
56
  | Ordinary prompts enter the workflow | Pi bootstrap hook | OMP global rule |
57
57
  | Shared skills and decision flow | Yes | Yes |
58
- | Dynamic A/B/C/D model routing | Pi model registry | OMP model registry |
58
+ | Dynamic A/B/C/D model routing + high/low failover | Pi model registry | OMP model registry |
59
59
  | Approval guard | Pi event adapter | OMP event adapter |
60
60
  | Slash workflow entry points | Pi prompt templates | OMP commands |
61
61
  | CodeGraph | CLI/integration when detected | MCP integration when configured |
62
62
  | Advisor, checkpoint, security scan, Hindsight | Extension/fallback when detected | Native capability when enabled |
63
63
  | Parallel agents | Only when a compatible task extension exists | Native `task`/workflow routes when available |
64
64
 
65
- Tested locally against Pi Agent `0.85.1` and OMP `18.1.15`. Optional features are always capability-detected, so their absence produces a recorded fallback rather than a false success claim.
65
+ Tested locally against Pi Agent `0.85.1` and OMP `18.2.1`. Optional features are always capability-detected, so their absence produces a recorded fallback rather than a false success claim.
66
66
 
67
67
  ## Try the demo
68
68
 
@@ -124,6 +124,92 @@ Authenticate providers using the active runtime's normal login flow, then run:
124
124
 
125
125
  The router shows every model in the live authenticated and enabled registry. Stage-appropriate choices appear first and all other enabled choices remain selectable. It does not promise that a subscription route is free or unlimited. See [MODEL-SETUP.md](MODEL-SETUP.md).
126
126
 
127
+ ### CheaperInference (Kimi and GLM)
128
+
129
+ Oh My Pi does not ship [CheaperInference](https://cheaperinference.com). Register it once, then `/auto-models choose` and `/pi-marg` offer its models at every stage. A and C must be different models. B and D must be different models.
130
+
131
+ 1. Create a dashboard key. Current keys begin with `ci_live_`.
132
+ 2. Store it in `~/.omp/agent/.env`, which Oh My Pi loads on startup:
133
+
134
+ ```bash
135
+ CHEAPER_INFERENCE_API_KEY=ci_live_your_key
136
+ ```
137
+
138
+ 3. Add the provider to `~/.omp/agent/models.yml`. `apiKey` is the environment-variable name, not the secret. Confirm the live ids before relying on this list:
139
+
140
+ ```bash
141
+ curl https://api.cheaperinference.com/v1/models \
142
+ -H "Authorization: Bearer $CHEAPER_INFERENCE_API_KEY"
143
+ ```
144
+
145
+ ```yaml
146
+ providers:
147
+ cheaperinference:
148
+ baseUrl: https://api.cheaperinference.com/v1
149
+ api: openai-completions
150
+ apiKey: CHEAPER_INFERENCE_API_KEY
151
+ authHeader: true
152
+ compat:
153
+ supportsStore: false
154
+ supportsDeveloperRole: false
155
+ supportsReasoningEffort: true
156
+ supportsStrictMode: false
157
+ toolStrictMode: none
158
+ thinkingFormat: openai
159
+ reasoningContentField: reasoning_content
160
+ requiresReasoningContentForToolCalls: true
161
+ maxTokensField: max_tokens
162
+ reasoningEffortMap:
163
+ xhigh: max
164
+ max: max
165
+ models:
166
+ - id: kimi-k3
167
+ name: Kimi K3
168
+ reasoning: true
169
+ input: [text, image]
170
+ contextWindow: 1000000
171
+ maxTokens: 131072
172
+ tokenizer: kimi-k2
173
+ - id: glm-5.3
174
+ name: GLM 5.3
175
+ reasoning: true
176
+ input: [text]
177
+ contextWindow: 1000000
178
+ maxTokens: 131072
179
+ tokenizer: glm5
180
+ - id: glm-5.3-flash
181
+ name: GLM 5.3 Flash
182
+ reasoning: true
183
+ input: [text, image]
184
+ contextWindow: 1048576
185
+ maxTokens: 131072
186
+ tokenizer: glm5
187
+ - id: glm-5.2
188
+ name: GLM 5.2
189
+ reasoning: true
190
+ input: [text]
191
+ contextWindow: 1048576
192
+ maxTokens: 131072
193
+ tokenizer: glm5
194
+ ```
195
+
196
+ 4. Check that Oh My Pi can see them, then pick any of them at each stage:
197
+
198
+ ```bash
199
+ omp models find cheaperinference
200
+ ```
201
+
202
+ | Stage | One valid pick |
203
+ |---|---|
204
+ | A discover | `cheaperinference/kimi-k3` |
205
+ | C plan review | `cheaperinference/glm-5.3` |
206
+ | B build | `cheaperinference/glm-5.3` |
207
+ | D code review | `cheaperinference/kimi-k3` |
208
+ | high backup | `cheaperinference/kimi-k3` |
209
+ | low backup | `cheaperinference/glm-5.3-flash` |
210
+
211
+ Swap any row. The picker rejects the run only when A equals C, or B equals D. Recovery stays off until both backups are saved. This route bills a wallet on each call. The same steps are in [MODEL-SETUP.md](MODEL-SETUP.md).
212
+
127
213
  ## Start a workflow
128
214
 
129
215
  From a terminal in the repository you want to work on, run either:
@@ -141,11 +227,25 @@ pi-marg "add CSV export to this repository"
141
227
  /auto add CSV export to this repository
142
228
  ```
143
229
 
144
- The native picker asks for work boundary, work type, workflow skill, and every model required by that work type. It shows only models reported as authenticated and enabled by OMP, with recommended stage fits first.
230
+ The native picker asks for work boundary, work type, workflow skill, every model required by that work type, then two distinct runtime backups: **high** first and **low** second. Automatic recovery stays disabled until both are saved. It shows only models reported as authenticated and enabled by OMP, with recommended fits first.
231
+
232
+ ```text
233
+ stage model hits quota
234
+ │
235
+ ▼
236
+ OMP/Pi native retry
237
+ │ still failed
238
+ ▼
239
+ high backup ──fails──► low backup
240
+ │ │
241
+ └──── notify + resume┘
242
+ ```
243
+
244
+ PiMarg switches only after a positively identified terminal usage-limit/quota failure (or a settled model-execution tool failure such as external Devin or a subagent); a temporary 429/rate-limit response does not consume a backup. It records and deduplicates each failure, advances from high to low only when the active high model itself fails, notifies the user, and resumes with an instruction to inspect current state before repeating any side effect. If both backups are exhausted, it stops with an actionable request to run the model chooser again.
145
245
 
146
246
  Useful entry points:
147
247
 
148
- - `/auto-models choose` — refresh A/B/C/D choices.
248
+ - `/auto-models choose` — refresh A/B/C/D choices and the high/low recovery pair.
149
249
  - `/auto-runtime status` — show detected capabilities and fallbacks.
150
250
  - `/auto-review plan|code` — run the independent review contract.
151
251
  - `/auto-status` — show the current ledger and evidence.
@@ -1,17 +1,27 @@
1
1
  import {
2
+ FALLBACK_LEVELS,
2
3
  MODEL_STATE_TYPE,
3
4
  STAGES,
5
+ buildFallbackCatalog,
4
6
  buildModelCatalog,
5
7
  candidatesForProvider,
6
8
  emptyCatalogSetupMessage,
7
9
  emptyModelRouteState,
10
+ executeAutomaticFailover,
8
11
  executeModelRoute,
12
+ failedModelSelector,
13
+ findTerminalUsageLimitFailure,
14
+ formatFailoverRecoveryMessage,
15
+ hasConfiguredFallback,
9
16
  matchNumberedOption,
17
+ modelFailureKey,
18
+ modelToolUsageLimitFailure,
10
19
  numberedOptionLabel,
11
20
  requiredStagesForWorkType,
12
21
  restoreModelRouteState,
13
22
  uniqueProviders,
14
23
  type AutoStage,
24
+ type FallbackLevel,
15
25
  type ModelCandidate,
16
26
  type ModelLike,
17
27
  type ModelRouteParams,
@@ -45,6 +55,11 @@ const STAGE_PURPOSE: Record<AutoStage, string> = {
45
55
  D: "implementation second-eye review",
46
56
  };
47
57
 
58
+ const FALLBACK_PURPOSE: Record<FallbackLevel, string> = {
59
+ high: "strong recovery model used first",
60
+ low: "economical recovery model used if high is unavailable or exhausted",
61
+ };
62
+
48
63
  function hasExternalDevin(pi: any): boolean {
49
64
  return normalizeCapabilityNames(pi.getAllTools?.() ?? [])
50
65
  .some((name) => /(?:^|[_-])devin(?:[_-]|$)/i.test(name));
@@ -83,30 +98,79 @@ async function chooseModel(
83
98
  return undefined;
84
99
  }
85
100
 
86
- const providers = uniqueProviders(eligible);
101
+ return chooseCandidateByProvider(ctx, eligible, {
102
+ providerTitle: `Stage ${stage} provider`,
103
+ modelTitle: `Stage ${stage} model`,
104
+ purpose: STAGE_PURPOSE[stage],
105
+ providerDescription: (name) => `All authenticated ${name} models for ${STAGE_PURPOSE[stage]}`,
106
+ modelDescription: (candidate) => optionDescription(stage, candidate),
107
+ });
108
+ }
109
+
110
+ async function chooseFallbackModel(
111
+ ctx: any,
112
+ level: FallbackLevel,
113
+ candidates: ModelCandidate[],
114
+ other?: ModelCandidate,
115
+ ): Promise<ModelCandidate | undefined> {
116
+ const eligible = candidates.filter((candidate) => (
117
+ candidate.access === "runtime-model" && (!other || candidate.identity !== other.identity)
118
+ ));
119
+ const title = level === "high" ? "High" : "Low";
120
+ if (eligible.length === 0) {
121
+ ctx.ui.notify(
122
+ "Automatic recovery requires two distinct authenticated runtime models. Configure another model, then run /pi-marg again.",
123
+ "error",
124
+ );
125
+ return undefined;
126
+ }
127
+
128
+ return chooseCandidateByProvider(ctx, eligible, {
129
+ providerTitle: `${title} backup provider`,
130
+ modelTitle: `${title} backup model`,
131
+ purpose: FALLBACK_PURPOSE[level],
132
+ providerDescription: (name) => `Authenticated ${name} models · ${FALLBACK_PURPOSE[level]}`,
133
+ modelDescription: (candidate) => (
134
+ `${FALLBACK_PURPOSE[level]}${candidate.billingNote ? ` · ${candidate.billingNote}` : ""}`
135
+ ),
136
+ });
137
+ }
138
+
139
+ async function chooseCandidateByProvider(
140
+ ctx: any,
141
+ candidates: ModelCandidate[],
142
+ copy: {
143
+ providerTitle: string;
144
+ modelTitle: string;
145
+ purpose: string;
146
+ providerDescription(provider: string): string;
147
+ modelDescription(candidate: ModelCandidate): string;
148
+ },
149
+ ): Promise<ModelCandidate | undefined> {
150
+ const providers = uniqueProviders(candidates);
87
151
  const providerOptions = providers.map((name, index) => ({
88
- label: numberedOptionLabel(index, `${name} (${candidatesForProvider(eligible, name).length})`),
89
- description: `All authenticated ${name} models for ${STAGE_PURPOSE[stage]}`,
152
+ label: numberedOptionLabel(index, `${name} (${candidatesForProvider(candidates, name).length})`),
153
+ description: copy.providerDescription(name),
90
154
  }));
91
- const providerAnswer = await ctx.ui.select(`Stage ${stage} provider`, providerOptions, {
155
+ const providerAnswer = await ctx.ui.select(copy.providerTitle, providerOptions, {
92
156
  selectionMarker: "radio",
93
- helpText: `${providers.length} providers · ${eligible.length} models · ${STAGE_PURPOSE[stage]}`,
157
+ helpText: `${providers.length} providers · ${candidates.length} models · ${copy.purpose}`,
94
158
  });
95
159
  const provider = matchNumberedOption(
96
160
  providerAnswer,
97
161
  providers,
98
- (name) => `${name} (${candidatesForProvider(eligible, name).length})`,
162
+ (name) => `${name} (${candidatesForProvider(candidates, name).length})`,
99
163
  );
100
164
  if (!provider) return undefined;
101
165
 
102
- const providerModels = candidatesForProvider(eligible, provider);
166
+ const providerModels = candidatesForProvider(candidates, provider);
103
167
  const modelOptions = providerModels.map((candidate, index) => ({
104
168
  label: numberedOptionLabel(index, optionLabel(candidate)),
105
- description: optionDescription(stage, candidate),
169
+ description: copy.modelDescription(candidate),
106
170
  }));
107
- const answer = await ctx.ui.select(`Stage ${stage} model`, modelOptions, {
171
+ const answer = await ctx.ui.select(copy.modelTitle, modelOptions, {
108
172
  selectionMarker: "radio",
109
- helpText: `${providerModels.length} ${provider} choices · ${STAGE_PURPOSE[stage]}`,
173
+ helpText: `${providerModels.length} ${provider} choices · ${copy.purpose}`,
110
174
  });
111
175
  return matchNumberedOption(answer, providerModels, optionLabel);
112
176
  }
@@ -114,14 +178,18 @@ async function chooseModel(
114
178
  function selectedState(
115
179
  workType: number,
116
180
  selected: Partial<Record<AutoStage, ModelCandidate>>,
181
+ fallbacks: Record<FallbackLevel, ModelCandidate>,
117
182
  ): ModelRouteState {
118
183
  const selectedAt = new Date().toISOString();
119
184
  return {
120
- version: 1,
185
+ version: 2,
121
186
  workType,
122
187
  selections: Object.fromEntries(
123
188
  Object.entries(selected).map(([stage, candidate]) => [stage, { ...candidate, selectedAt }]),
124
189
  ) as ModelRouteState["selections"],
190
+ fallbacks: Object.fromEntries(
191
+ Object.entries(fallbacks).map(([level, candidate]) => [level, { ...candidate, selectedAt }]),
192
+ ) as ModelRouteState["fallbacks"],
125
193
  };
126
194
  }
127
195
 
@@ -131,6 +199,7 @@ function intakeMessage(
131
199
  workType: string,
132
200
  skillMode: string,
133
201
  selected: Partial<Record<AutoStage, ModelCandidate>>,
202
+ fallbacks: Record<FallbackLevel, ModelCandidate>,
134
203
  ): string {
135
204
  const models = STAGES
136
205
  .filter((stage) => selected[stage])
@@ -145,15 +214,23 @@ function intakeMessage(
145
214
  `- Work type: ${workType}`,
146
215
  `- Workflow skill: ${skillMode}`,
147
216
  models,
217
+ `- High backup: ${fallbacks.high.selector}`,
218
+ `- Low backup: ${fallbacks.low.selector}`,
148
219
  "",
149
- "Load auto-mode-router, record these choices in the session ledger, activate each saved model before its stage, and continue the workflow. Ask interactively only for information that is still missing.",
220
+ "Load auto-mode-router, record these choices in the session ledger, activate each saved model before its stage, and continue the workflow. If a terminal usage-limit failure occurs, PiMarg will switch high then low and resume from the last safe point. Ask interactively only for information that is still missing.",
150
221
  ].join("\n");
151
222
  }
152
223
 
153
224
  export default function modelRouter(pi: any) {
154
225
  const z = pi.zod;
155
226
  let state = emptyModelRouteState();
227
+ let recoveryToken: symbol | undefined;
228
+ let sessionGeneration = 0;
229
+ let pendingToolFailure: { selector: string; key: string } | undefined;
156
230
  const restore = (_event: unknown, ctx: any) => {
231
+ sessionGeneration += 1;
232
+ recoveryToken = undefined;
233
+ pendingToolFailure = undefined;
157
234
  state = restoreModelRouteState(ctx.sessionManager.getBranch());
158
235
  };
159
236
 
@@ -162,6 +239,93 @@ export default function modelRouter(pi: any) {
162
239
  pi.on("session_branch", restore);
163
240
  pi.on("session_tree", restore);
164
241
 
242
+ const recoverFromUsageLimit = async (failure: { selector: string; key: string }, ctx: any) => {
243
+ if (recoveryToken) return;
244
+ if (!hasConfiguredFallback(state)) return;
245
+ const generation = sessionGeneration;
246
+ const token = Symbol("pi-marg-recovery");
247
+ recoveryToken = token;
248
+ try {
249
+ const execution = await executeAutomaticFailover(state, failure.selector, {
250
+ runtimeName: "OMP",
251
+ models: ctx.models.list() as ModelLike[],
252
+ hasExternalDevin: hasExternalDevin(pi),
253
+ activate: (model) => pi.setModel(model),
254
+ persist: (nextState) => {
255
+ if (sessionGeneration !== generation) throw new Error("session changed during automatic recovery");
256
+ pi.appendEntry(MODEL_STATE_TYPE, nextState);
257
+ },
258
+ }, failure.key);
259
+ if (sessionGeneration !== generation) {
260
+ ctx.ui?.notify?.(
261
+ "PiMarg stopped an in-flight recovery because the session changed. Verify the active model before continuing.",
262
+ "warning",
263
+ );
264
+ return;
265
+ }
266
+ if (execution.ignoredDuplicate) return;
267
+ state = execution.state;
268
+ if (!execution.switched) {
269
+ if (!execution.stateChanged) return;
270
+ ctx.ui?.notify?.(
271
+ "PiMarg could not resume automatically because no configured backup model remains. Run /auto-models choose after restoring provider access.",
272
+ "error",
273
+ );
274
+ if (execution.persistenceError) {
275
+ ctx.ui?.notify?.(
276
+ `PiMarg updated recovery state only in memory because the session ledger could not be saved: ${execution.persistenceError}`,
277
+ "error",
278
+ );
279
+ }
280
+ return;
281
+ }
282
+
283
+ const title = execution.switched.level === "high" ? "High" : "Low";
284
+ ctx.ui?.notify?.(
285
+ execution.reusedCurrent
286
+ ? `PiMarg kept the ${title} backup ${execution.switched.selection.selector} active after ${failure.selector} reached its limit and is resuming safely.`
287
+ : `PiMarg switched ${failure.selector} to the ${title} backup ${execution.switched.selection.selector} and is resuming automatically.`,
288
+ "warning",
289
+ );
290
+ if (execution.persistenceError) {
291
+ ctx.ui?.notify?.(
292
+ `The model switched, but PiMarg could not save the recovery state to the session ledger: ${execution.persistenceError}`,
293
+ "error",
294
+ );
295
+ }
296
+ pi.sendUserMessage(
297
+ formatFailoverRecoveryMessage(
298
+ state,
299
+ failure.selector,
300
+ execution.switched.level,
301
+ execution.switched.selection.selector,
302
+ execution.reusedCurrent,
303
+ ),
304
+ { deliverAs: "aside" },
305
+ );
306
+ } finally {
307
+ if (recoveryToken === token) recoveryToken = undefined;
308
+ }
309
+ };
310
+
311
+ pi.on("agent_end", async (event: any, ctx: any) => {
312
+ if (event?.willContinue || event?.isTerminal === false) return;
313
+ const failed = findTerminalUsageLimitFailure(event?.messages ?? []);
314
+ const failure = failed
315
+ ? {
316
+ selector: failedModelSelector(failed, ctx.models?.current?.() ?? ctx.model),
317
+ key: modelFailureKey(failed),
318
+ }
319
+ : pendingToolFailure;
320
+ pendingToolFailure = undefined;
321
+ if (!failure) return;
322
+ await recoverFromUsageLimit(failure, ctx);
323
+ });
324
+
325
+ pi.on("tool_execution_end", (event: any) => {
326
+ pendingToolFailure = modelToolUsageLimitFailure(event) ?? pendingToolFailure;
327
+ });
328
+
165
329
  pi.registerCommand("pi-marg", {
166
330
  description: "Start PiMarg with interactive intake, or pass a work prompt.",
167
331
  async handler(args: string, ctx: any) {
@@ -203,6 +367,7 @@ export default function modelRouter(pi: any) {
203
367
  return;
204
368
  }
205
369
  const catalog = buildModelCatalog(liveModels, hasExternalDevin(pi));
370
+ const fallbackCatalog = buildFallbackCatalog(catalog);
206
371
  const selected: Partial<Record<AutoStage, ModelCandidate>> = {};
207
372
  for (const stage of requiredStagesForWorkType(workType)) {
208
373
  const choice = await chooseModel(ctx, stage, catalog[stage], selected);
@@ -210,20 +375,27 @@ export default function modelRouter(pi: any) {
210
375
  selected[stage] = choice;
211
376
  }
212
377
 
213
- const nextState = selectedState(workType, selected);
378
+ const high = await chooseFallbackModel(ctx, "high", fallbackCatalog.high);
379
+ if (!high) return;
380
+ const low = await chooseFallbackModel(ctx, "low", fallbackCatalog.low, high);
381
+ if (!low) return;
382
+ const fallbacks = { high, low };
383
+
384
+ const nextState = selectedState(workType, selected, fallbacks);
214
385
  state = nextState;
215
386
  pi.appendEntry(MODEL_STATE_TYPE, nextState);
216
- pi.sendUserMessage(intakeMessage(request, boundary, workTypeLabel!, skillMode, selected));
387
+ pi.sendUserMessage(intakeMessage(request, boundary, workTypeLabel!, skillMode, selected, fallbacks));
217
388
  },
218
389
  });
219
390
 
220
391
  pi.registerTool({
221
392
  name: "auto_model_route",
222
393
  label: "PiMarg Model Router",
223
- description: "Discover, select, and activate live A/B/C/D model choices while enforcing A different from C and B different from D.",
394
+ description: "Discover, select, and activate live A/B/C/D models plus distinct high/low automatic usage-limit backups.",
224
395
  parameters: z.object({
225
- action: z.enum(["catalog", "select", "activate", "status"]),
396
+ action: z.enum(["catalog", "select", "select_fallback", "activate", "status"]),
226
397
  stage: z.enum(STAGES).optional(),
398
+ fallback: z.enum(FALLBACK_LEVELS).optional(),
227
399
  target: z.string().optional(),
228
400
  workType: z.number().optional(),
229
401
  provider: z.string().optional(),