pi-pr-review 1.1.2 → 1.2.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 CHANGED
@@ -63,15 +63,17 @@ The `/pr-review-config` command maps three labels to models:
63
63
  ```
64
64
  /pr-review-config # open the settings menu (like /settings & /nervous:config)
65
65
  /pr-review-config show # print the current mapping
66
- /pr-review-config light=<spec> medium=<spec> heavy=<spec> # set non-interactively
66
+ /pr-review-config light=<spec> medium=<spec> heavy=<spec> # set primary tier models
67
+ /pr-review-config heavy_fallbacks=<spec>,<spec> # retry chain for quota/rate-limit failures
67
68
  /pr-review-config medium=unset # clear a tier (back to pi default)
69
+ /pr-review-config heavy_fallbacks=unset # clear a fallback chain
68
70
  /pr-review-config tools=read,bash,grep,find,ls # tools granted to each subagent
69
71
  ```
70
72
 
71
73
  Running `/pr-review-config` with no arguments in the TUI opens an interactive settings menu that mirrors pi's `/settings` and the NERVous `/nervous:config`:
72
74
 
73
- - One row per tier (`light` / `medium` / `heavy` model) plus a `subagent tools` row.
74
- - Press Enter on a tier to pick a model from a searchable list (or `(unset — pi default)`); Enter/Space on `subagent tools` cycles presets.
75
+ - One primary-model row and one fallback-model row per tier (`light` / `medium` / `heavy`) plus a `subagent tools` row.
76
+ - Press Enter on a primary or fallback row to pick a model from a searchable list (or unset it); Enter/Space on `subagent tools` cycles presets. The menu sets one fallback model at a time; use the `key=value` form for longer fallback chains.
75
77
  - Selections apply and persist **immediately**; Esc closes the menu.
76
78
  - Type to search, and tab-completion is available for the `key=value` form.
77
79
 
@@ -91,15 +93,20 @@ Example `pr-review.json`:
91
93
  "medium": "<balanced-model-spec>",
92
94
  "heavy": "<strong-model-spec:high>"
93
95
  },
96
+ "fallbacks": {
97
+ "light": ["<backup-fast-model>"],
98
+ "medium": ["<backup-balanced-model>"],
99
+ "heavy": ["<backup-strong-model:high>", "<balanced-model-spec>"]
100
+ },
94
101
  "tools": ["read", "bash", "grep", "find", "ls"]
95
102
  }
96
103
  ```
97
104
 
98
- Each tier runs in an **isolated `pi` subprocess** on its configured model. The `review_subagents` batch tool runs independent passes concurrently (default `max_parallel: 4`, capped at 6) and returns ordered per-pass results; the older single-pass `review_subagent` tool remains available as a compatibility fallback. If a tier is unset, that subagent falls back to the nearest configured tier, then to your pi default model.
105
+ Each tier runs in an **isolated `pi` subprocess** on its configured model. The `review_subagents` batch tool runs independent passes concurrently (default `max_parallel: 4`, capped at 6) and returns ordered per-pass results; the older single-pass `review_subagent` tool remains available as a compatibility fallback. If a tier model fails with a retryable quota/rate-limit/capacity error, the subprocess retries that tier's configured `fallbacks` in order. Non-quota failures do not blindly cycle through fallbacks. If a tier is unset, that subagent falls back to the nearest configured tier, then to your pi default model.
99
106
 
100
- ### 2. The orchestrator / fallback model
107
+ ### 2. The orchestrator / inline-fallback model
101
108
 
102
- The orchestrator (which fetches the PR, merges findings, and emits the JSON) and the inline-fallback path both run on your pi session model:
109
+ The orchestrator (which fetches the PR, merges findings, and emits the JSON) and the inline fallback path both run on your pi session model:
103
110
 
104
111
  - **Per run:** `pi --model <model-id> "/pr-review 123"`
105
112
  - **Persistent default (user):** `~/.pi/agent/settings.json` → `{ "defaultModel": "<model-id>", "defaultThinkingLevel": "high" }`
@@ -182,7 +189,7 @@ pi-pr-review/
182
189
 
183
190
  - The `review_subagents` batch tool and `review_subagent` fallback spawn isolated `pi` subprocesses (`--mode json -p --no-session`) on your configured tier models. Subagents are read-only reviewers (`read,bash,grep,find,ls` by default) and never post comments or edit files.
184
191
  - Project-local `pr-review.json` is only read when the project is trusted.
185
- - Tiered review calls multiple models per PR, now concurrently for independent passes. Point `light` at a cheap model for overview/risk scan; reserve `heavy` for the deep passes.
192
+ - Tiered review calls multiple models per PR, now concurrently for independent passes. Point `light` at a cheap model for overview/risk scan; reserve `heavy` for the deep passes, and configure per-tier `fallbacks` only for acceptable backup models because retries can increase cost.
186
193
 
187
194
  ## Design notes
188
195
 
@@ -4,7 +4,8 @@
4
4
  * Adds configurable, tiered review subagents to the /pr-review workflow.
5
5
  *
6
6
  * - Config surface: `pr-review.json` (user: ~/.pi/agent, project: <repo>/.pi) maps
7
- * the labels `light` / `medium` / `heavy` to whatever models you choose.
7
+ * the labels `light` / `medium` / `heavy` to whatever models you choose,
8
+ * plus optional per-tier fallback chains for quota/capacity failures.
8
9
  * No model names are hardcoded here — you configure them.
9
10
  * - Tool: `review_subagent` spawns one isolated `pi` subprocess on the model
10
11
  * bound to the requested tier and returns its review report.
@@ -66,6 +67,8 @@ const TIER_PURPOSE: Record<Tier, string> = {
66
67
  interface PrReviewConfig {
67
68
  /** Tier label -> model spec (e.g. "anthropic/model", "openai/model:high"). */
68
69
  tiers: Partial<Record<Tier, string>>;
70
+ /** Tier label -> ordered fallback model specs used for quota/capacity failures. */
71
+ fallbacks: Partial<Record<Tier, string[]>>;
69
72
  /** Tools granted to each review subagent process. */
70
73
  tools: string[];
71
74
  }
@@ -91,6 +94,25 @@ function readConfigFile(filePath: string): Partial<PrReviewConfig> {
91
94
  }
92
95
  }
93
96
 
97
+ function normalizeFallbacks(
98
+ raw: Partial<Record<Tier, unknown>> | undefined,
99
+ preserveEmpty = false,
100
+ ): Partial<Record<Tier, string[]>> {
101
+ const out: Partial<Record<Tier, string[]>> = {};
102
+ if (!raw || typeof raw !== "object") return out;
103
+ for (const tier of TIERS) {
104
+ const value = raw[tier];
105
+ if (value === undefined) continue;
106
+ const list = Array.isArray(value)
107
+ ? value.map((v) => String(v).trim()).filter(Boolean)
108
+ : typeof value === "string"
109
+ ? value.split(",").map((v) => v.trim()).filter(Boolean)
110
+ : [];
111
+ if (list.length > 0 || preserveEmpty) out[tier] = [...new Set(list)];
112
+ }
113
+ return out;
114
+ }
115
+
94
116
  /** User config, overlaid by project config when the project is trusted. */
95
117
  function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): PrReviewConfig {
96
118
  const user = readConfigFile(userConfigPath());
@@ -102,6 +124,7 @@ function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): Pr
102
124
  }
103
125
  return {
104
126
  tiers: { ...(user.tiers ?? {}), ...(project.tiers ?? {}) },
127
+ fallbacks: { ...normalizeFallbacks(user.fallbacks, true), ...normalizeFallbacks(project.fallbacks, true) },
105
128
  tools: project.tools ?? user.tools ?? DEFAULT_TOOLS,
106
129
  };
107
130
  }
@@ -109,7 +132,11 @@ function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): Pr
109
132
  /** User-level config only (the scope the config command edits), with defaults. */
110
133
  function readUserConfig(): PrReviewConfig {
111
134
  const raw = readConfigFile(userConfigPath());
112
- return { tiers: { ...(raw.tiers ?? {}) }, tools: raw.tools ?? [...DEFAULT_TOOLS] };
135
+ return {
136
+ tiers: { ...(raw.tiers ?? {}) },
137
+ fallbacks: normalizeFallbacks(raw.fallbacks),
138
+ tools: raw.tools ?? [...DEFAULT_TOOLS],
139
+ };
113
140
  }
114
141
 
115
142
  function writeUserConfig(next: PrReviewConfig): string {
@@ -119,19 +146,50 @@ function writeUserConfig(next: PrReviewConfig): string {
119
146
  return filePath;
120
147
  }
121
148
 
122
- /** Resolve the model spec for a tier, falling back to the nearest configured tier. */
123
- function resolveModelSpec(config: PrReviewConfig, tier: Tier): { spec?: string; usedTier?: Tier } {
124
- if (config.tiers[tier]) return { spec: config.tiers[tier], usedTier: tier };
125
- // Preference order: search outward from the requested tier.
126
- const order: Record<Tier, Tier[]> = {
127
- light: ["light", "medium", "heavy"],
128
- medium: ["medium", "heavy", "light"],
129
- heavy: ["heavy", "medium", "light"],
149
+ interface ModelAttempt {
150
+ spec?: string;
151
+ usedTier?: Tier;
152
+ kind: "primary" | "fallback" | "nearest" | "default";
153
+ fallbackIndex?: number;
154
+ }
155
+
156
+ const NEAREST_TIER_ORDER: Record<Tier, Tier[]> = {
157
+ light: ["light", "medium", "heavy"],
158
+ medium: ["medium", "heavy", "light"],
159
+ heavy: ["heavy", "medium", "light"],
160
+ };
161
+
162
+ /** Resolve the ordered model attempts for a tier, preserving nearest-tier/default behavior when the tier is unset. */
163
+ function resolveModelAttempts(config: PrReviewConfig, tier: Tier): ModelAttempt[] {
164
+ const attempts: ModelAttempt[] = [];
165
+ const seen = new Set<string>();
166
+ const add = (attempt: ModelAttempt) => {
167
+ const key = attempt.spec ?? "__pi_default__";
168
+ if (seen.has(key)) return;
169
+ seen.add(key);
170
+ attempts.push(attempt);
130
171
  };
131
- for (const candidate of order[tier]) {
132
- if (config.tiers[candidate]) return { spec: config.tiers[candidate], usedTier: candidate };
172
+
173
+ const primary = config.tiers[tier];
174
+ if (primary) {
175
+ add({ spec: primary, usedTier: tier, kind: "primary" });
176
+ } else {
177
+ let foundNearest = false;
178
+ for (const candidate of NEAREST_TIER_ORDER[tier]) {
179
+ const spec = config.tiers[candidate];
180
+ if (!spec) continue;
181
+ add({ spec, usedTier: candidate, kind: "nearest" });
182
+ foundNearest = true;
183
+ break;
184
+ }
185
+ if (!foundNearest) add({ kind: "default" });
186
+ }
187
+
188
+ for (const [index, spec] of (config.fallbacks[tier] ?? []).entries()) {
189
+ add({ spec, usedTier: tier, kind: "fallback", fallbackIndex: index });
133
190
  }
134
- return {};
191
+
192
+ return attempts;
135
193
  }
136
194
 
137
195
  // ---------------------------------------------------------------------------
@@ -297,6 +355,18 @@ interface SubagentPassRequest {
297
355
  context?: string;
298
356
  }
299
357
 
358
+ interface ModelAttemptReport {
359
+ kind: ModelAttempt["kind"];
360
+ spec?: string;
361
+ usedTier?: Tier;
362
+ model?: string;
363
+ exitCode: number;
364
+ status: "completed" | "failed";
365
+ retryable: boolean;
366
+ stopReason?: string;
367
+ errorMessage?: string;
368
+ }
369
+
300
370
  interface SubagentPassResult {
301
371
  id: string;
302
372
  tier: Tier;
@@ -309,39 +379,63 @@ interface SubagentPassResult {
309
379
  stderr?: string;
310
380
  stopReason?: string;
311
381
  errorMessage?: string;
382
+ attempts: ModelAttemptReport[];
383
+ fallbackUsed: boolean;
384
+ retryableFailure: boolean;
385
+ }
386
+
387
+ function noticeForAttempt(tier: Tier, attempt: ModelAttempt): string {
388
+ if (!attempt.spec) {
389
+ return `tier=${tier} (no tier configured; using pi default model — run /pr-review-config to set tiers)`;
390
+ }
391
+ if (attempt.kind === "fallback") {
392
+ return `tier=${tier} fallback #${(attempt.fallbackIndex ?? 0) + 1} model=${attempt.spec}`;
393
+ }
394
+ if (attempt.usedTier && attempt.usedTier !== tier) {
395
+ return `tier=${tier} (not configured; using ${attempt.usedTier} model=${attempt.spec})`;
396
+ }
397
+ return `tier=${tier} model=${attempt.spec}`;
312
398
  }
313
399
 
314
- function noticeForTier(tier: Tier, spec: string | undefined, usedTier: Tier | undefined): string {
315
- if (spec) {
316
- return usedTier === tier
317
- ? `tier=${tier} model=${spec}`
318
- : `tier=${tier} (not configured; using ${usedTier} model=${spec})`;
400
+ function noticeForResult(tier: Tier, attempts: ModelAttemptReport[], finalNotice: string): string {
401
+ const failedBeforeSuccess = attempts.filter((a) => a.status === "failed").length;
402
+ if (failedBeforeSuccess > 0 && attempts.some((a) => a.status === "completed")) {
403
+ return `${finalNotice} (after ${failedBeforeSuccess} retry${failedBeforeSuccess === 1 ? "" : "ies"})`;
319
404
  }
320
- return `tier=${tier} (no tier configured; using pi default model — run /pr-review-config to set tiers)`;
405
+ return finalNotice;
321
406
  }
322
407
 
323
408
  function buildPassTask(objective: string, context: string | undefined): string {
324
409
  return context ? `Objective: ${objective}\n\n--- PR context / diff ---\n${context}` : `Objective: ${objective}`;
325
410
  }
326
411
 
327
- async function runSubagentPass(
412
+ function isRetryableModelFailure(result: RunResult): boolean {
413
+ if (result.stopReason === "aborted") return false;
414
+ const haystack = [result.errorMessage, result.stderr, result.text, result.stopReason]
415
+ .filter(Boolean)
416
+ .join("\n")
417
+ .toLowerCase();
418
+ if (!haystack) return false;
419
+ return /(?:\b429\b|rate[\s_-]*limit(?:ed)?|too many requests|quota|usage[\s_-]*limit|insufficient[\s_-]*quota|resource[\s_-]*exhausted|out of credits|insufficient credits|credit limit|billing quota|billing hard limit|at capacity|overloaded|temporarily unavailable|model (?:is )?(?:temporarily|currently) unavailable|service unavailable|try again later)/i.test(
420
+ haystack,
421
+ );
422
+ }
423
+
424
+ async function runSubagentAttempt(
328
425
  config: PrReviewConfig,
329
426
  ctx: Pick<ExtensionContext, "cwd">,
330
427
  pass: SubagentPassRequest,
428
+ attempt: ModelAttempt,
331
429
  signal: AbortSignal | undefined,
332
430
  onText?: (text: string) => void,
333
- ): Promise<SubagentPassResult> {
334
- const tier = pass.tier;
335
- const { spec, usedTier } = resolveModelSpec(config, tier);
336
- const notice = noticeForTier(tier, spec, usedTier);
431
+ ): Promise<{ result: RunResult; notice: string }> {
337
432
  let tmp: { dir: string; filePath: string } | undefined;
338
-
339
433
  try {
340
434
  const args = ["--mode", "json", "-p", "--no-session"];
341
- if (spec) args.push("--model", spec);
435
+ if (attempt.spec) args.push("--model", attempt.spec);
342
436
  if (config.tools.length > 0) args.push("--tools", config.tools.join(","));
343
437
 
344
- tmp = await writeTempPrompt(tier, buildSubagentSystemPrompt(tier));
438
+ tmp = await writeTempPrompt(pass.tier, buildSubagentSystemPrompt(pass.tier));
345
439
  args.push("--append-system-prompt", tmp.filePath);
346
440
  args.push(buildPassTask(pass.objective, pass.context));
347
441
 
@@ -349,32 +443,11 @@ async function runSubagentPass(
349
443
  const result = await runReviewSubprocess(invocation.command, invocation.args, ctx.cwd, signal, (text) => {
350
444
  onText?.(text);
351
445
  });
352
-
353
- const failed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
354
- return {
355
- id: pass.id ?? tier,
356
- tier,
357
- usedTier,
358
- model: result.model ?? spec,
359
- exitCode: result.exitCode,
360
- status: failed ? "failed" : "completed",
361
- notice,
362
- text: result.text || (failed ? "" : "NO FINDINGS."),
363
- stderr: result.stderr || undefined,
364
- stopReason: result.stopReason,
365
- errorMessage: result.errorMessage,
366
- };
446
+ return { result, notice: noticeForAttempt(pass.tier, attempt) };
367
447
  } catch (e) {
368
448
  return {
369
- id: pass.id ?? tier,
370
- tier,
371
- usedTier,
372
- model: spec,
373
- exitCode: 1,
374
- status: "failed",
375
- notice,
376
- text: "",
377
- errorMessage: errMessage(e),
449
+ result: { text: "", exitCode: 1, stderr: "", errorMessage: errMessage(e) },
450
+ notice: noticeForAttempt(pass.tier, attempt),
378
451
  };
379
452
  } finally {
380
453
  if (tmp) {
@@ -387,6 +460,78 @@ async function runSubagentPass(
387
460
  }
388
461
  }
389
462
 
463
+ async function runSubagentPass(
464
+ config: PrReviewConfig,
465
+ ctx: Pick<ExtensionContext, "cwd">,
466
+ pass: SubagentPassRequest,
467
+ signal: AbortSignal | undefined,
468
+ onText?: (text: string) => void,
469
+ ): Promise<SubagentPassResult> {
470
+ const tier = pass.tier;
471
+ const attempts = resolveModelAttempts(config, tier);
472
+ const reports: ModelAttemptReport[] = [];
473
+ let lastResult: RunResult | undefined;
474
+ let lastNotice = noticeForAttempt(tier, attempts[0]!);
475
+
476
+ for (const attempt of attempts) {
477
+ const { result, notice } = await runSubagentAttempt(config, ctx, pass, attempt, signal, onText);
478
+ const failed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
479
+ const retryable = failed && isRetryableModelFailure(result);
480
+ lastResult = result;
481
+ lastNotice = notice;
482
+ reports.push({
483
+ kind: attempt.kind,
484
+ spec: attempt.spec,
485
+ usedTier: attempt.usedTier,
486
+ model: result.model ?? attempt.spec,
487
+ exitCode: result.exitCode,
488
+ status: failed ? "failed" : "completed",
489
+ retryable,
490
+ stopReason: result.stopReason,
491
+ errorMessage: result.errorMessage,
492
+ });
493
+
494
+ if (!failed) {
495
+ return {
496
+ id: pass.id ?? tier,
497
+ tier,
498
+ usedTier: attempt.usedTier,
499
+ model: result.model ?? attempt.spec,
500
+ exitCode: result.exitCode,
501
+ status: "completed",
502
+ notice: noticeForResult(tier, reports, notice),
503
+ text: result.text || "NO FINDINGS.",
504
+ stderr: result.stderr || undefined,
505
+ stopReason: result.stopReason,
506
+ errorMessage: result.errorMessage,
507
+ attempts: reports,
508
+ fallbackUsed: attempt.kind === "fallback" || reports.length > 1,
509
+ retryableFailure: false,
510
+ };
511
+ }
512
+
513
+ if (!retryable || signal?.aborted) break;
514
+ }
515
+
516
+ const final = lastResult ?? { text: "", exitCode: 1, stderr: "", errorMessage: "No model attempts were available." };
517
+ return {
518
+ id: pass.id ?? tier,
519
+ tier,
520
+ usedTier: reports.at(-1)?.usedTier,
521
+ model: reports.at(-1)?.model,
522
+ exitCode: final.exitCode,
523
+ status: "failed",
524
+ notice: noticeForResult(tier, reports, lastNotice),
525
+ text: final.text || "",
526
+ stderr: final.stderr || undefined,
527
+ stopReason: final.stopReason,
528
+ errorMessage: final.errorMessage,
529
+ attempts: reports,
530
+ fallbackUsed: reports.length > 1,
531
+ retryableFailure: reports.at(-1)?.retryable ?? false,
532
+ };
533
+ }
534
+
390
535
  function normalizeMaxParallel(raw: unknown, count: number): number {
391
536
  if (count <= 0) return 0;
392
537
  const requested = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : DEFAULT_BATCH_PARALLEL;
@@ -410,6 +555,13 @@ async function runWithConcurrency<T, R>(
410
555
  return results;
411
556
  }
412
557
 
558
+ function formatAttemptSummary(result: SubagentPassResult): string {
559
+ if (result.attempts.length <= 1) return "";
560
+ return `attempts: ${result.attempts
561
+ .map((a) => `${a.status === "completed" ? "✓" : a.retryable ? "↻" : "✗"} ${a.spec ?? "pi default"}`)
562
+ .join(" → ")}`;
563
+ }
564
+
413
565
  function formatBatchResults(results: SubagentPassResult[], maxParallel: number): string {
414
566
  const failed = results.filter((r) => r.status === "failed");
415
567
  const lines = [
@@ -422,6 +574,8 @@ function formatBatchResults(results: SubagentPassResult[], maxParallel: number):
422
574
  }
423
575
  for (const result of results) {
424
576
  lines.push("", `## Pass: ${result.id}`, `status: ${result.status}`, result.notice);
577
+ const attemptSummary = formatAttemptSummary(result);
578
+ if (attemptSummary) lines.push(attemptSummary);
425
579
  if (result.status === "failed") {
426
580
  const detail = result.errorMessage || result.stderr || result.text || "(no output)";
427
581
  lines.push(`error: ${detail}`);
@@ -535,6 +689,9 @@ export default function (pi: ExtensionAPI) {
535
689
  usedTier: result.usedTier,
536
690
  model: result.model,
537
691
  exitCode: result.exitCode,
692
+ fallbackUsed: result.fallbackUsed,
693
+ retryableFailure: result.retryableFailure,
694
+ attempts: result.attempts,
538
695
  },
539
696
  };
540
697
  }
@@ -546,6 +703,8 @@ export default function (pi: ExtensionAPI) {
546
703
  usedTier: result.usedTier,
547
704
  model: result.model,
548
705
  exitCode: result.exitCode,
706
+ fallbackUsed: result.fallbackUsed,
707
+ attempts: result.attempts,
549
708
  },
550
709
  };
551
710
  },
@@ -622,6 +781,9 @@ export default function (pi: ExtensionAPI) {
622
781
  status: r.status,
623
782
  stopReason: r.stopReason,
624
783
  errorMessage: r.errorMessage,
784
+ fallbackUsed: r.fallbackUsed,
785
+ retryableFailure: r.retryableFailure,
786
+ attempts: r.attempts,
625
787
  outputChars: r.text.length,
626
788
  })),
627
789
  },
@@ -630,7 +792,7 @@ export default function (pi: ExtensionAPI) {
630
792
  });
631
793
 
632
794
  pi.registerCommand("pr-review-config", {
633
- description: "Open the review-tier settings menu, or show/set light/medium/heavy models for /pr-review",
795
+ description: "Open the review-tier settings menu, or show/set light/medium/heavy models and fallbacks for /pr-review",
634
796
  handler: async (args, ctx) => {
635
797
  const raw = (args ?? "").trim();
636
798
  const parsed = parseConfigArgs(raw);
@@ -640,7 +802,7 @@ export default function (pi: ExtensionAPI) {
640
802
  }
641
803
 
642
804
  try {
643
- // Direct set: `/pr-review-config light=... heavy=...`
805
+ // Direct set: `/pr-review-config light=... heavy=... heavy_fallbacks=...`
644
806
  if (parsed.hasChanges) {
645
807
  const next = applyConfigPatch(readUserConfig(), parsed.patch);
646
808
  writeUserConfig(next);
@@ -701,6 +863,10 @@ function shouldOpenConfigMenu(args: string, ctx: ExtensionContext): boolean {
701
863
 
702
864
  const CONFIG_COMPLETIONS: Array<{ value: string; label: string }> = [
703
865
  ...TIERS.map((t) => ({ value: `${t}=`, label: `${t}=<model> — ${TIER_PURPOSE[t]}` })),
866
+ ...TIERS.map((t) => ({
867
+ value: `${t}_fallbacks=`,
868
+ label: `${t}_fallbacks=<model1,model2> — retry chain for quota/rate-limit failures`,
869
+ })),
704
870
  { value: "tools=", label: "tools=read,bash,grep,find,ls — tools granted to review subagents" },
705
871
  { value: "show", label: "show — print the current tier config" },
706
872
  ];
@@ -734,13 +900,27 @@ function splitConfigArgs(input: string): string[] {
734
900
  return tokens;
735
901
  }
736
902
 
903
+ function splitCommaList(value: string): string[] {
904
+ return value.split(",").map((s) => s.trim()).filter(Boolean);
905
+ }
906
+
907
+ function isFallbackKey(key: string): boolean {
908
+ for (const tier of TIERS) {
909
+ if (key === `${tier}_fallbacks` || key === `${tier}-fallbacks` || key === `${tier}.fallbacks`) {
910
+ return true;
911
+ }
912
+ }
913
+ return false;
914
+ }
915
+
737
916
  interface ConfigPatch {
738
917
  tiers: Partial<Record<Tier, string | null>>;
918
+ fallbacks: Partial<Record<Tier, string[] | null>>;
739
919
  tools?: string[];
740
920
  }
741
921
 
742
922
  function parseConfigArgs(args: string): { patch: ConfigPatch; hasChanges: boolean; errors: string[] } {
743
- const patch: ConfigPatch = { tiers: {} };
923
+ const patch: ConfigPatch = { tiers: {}, fallbacks: {} };
744
924
  const errors: string[] = [];
745
925
  const tokens = splitConfigArgs(args).filter((t) => !["show", "get", "current"].includes(t.toLowerCase()));
746
926
  for (const token of tokens) {
@@ -753,28 +933,46 @@ function parseConfigArgs(args: string): { patch: ConfigPatch; hasChanges: boolea
753
933
  const value = token.slice(eq + 1);
754
934
  if ((TIERS as string[]).includes(key)) {
755
935
  patch.tiers[key as Tier] = value === "" || value === "unset" ? null : value;
936
+ } else if (isFallbackKey(key)) {
937
+ const tier = key.split(/[_\-.]/)[0] as Tier;
938
+ patch.fallbacks[tier] = value === "" || value === "unset" || value === "none" ? null : splitCommaList(value);
756
939
  } else if (key === "tools") {
757
- patch.tools = value.split(",").map((s) => s.trim()).filter(Boolean);
940
+ patch.tools = splitCommaList(value);
758
941
  } else {
759
- errors.push(`unknown key "${key}" (expected light|medium|heavy|tools)`);
942
+ errors.push(`unknown key "${key}" (expected light|medium|heavy|<tier>_fallbacks|tools)`);
760
943
  }
761
944
  }
762
- const hasChanges = Object.keys(patch.tiers).length > 0 || patch.tools !== undefined;
945
+ const hasChanges =
946
+ Object.keys(patch.tiers).length > 0 || Object.keys(patch.fallbacks).length > 0 || patch.tools !== undefined;
763
947
  return { patch, hasChanges, errors };
764
948
  }
765
949
 
766
950
  function applyConfigPatch(base: PrReviewConfig, patch: ConfigPatch): PrReviewConfig {
767
- const next: PrReviewConfig = { tiers: { ...base.tiers }, tools: [...base.tools] };
951
+ const next: PrReviewConfig = {
952
+ tiers: { ...base.tiers },
953
+ fallbacks: normalizeFallbacks(base.fallbacks),
954
+ tools: [...base.tools],
955
+ };
768
956
  for (const tier of TIERS) {
769
- if (!(tier in patch.tiers)) continue;
770
- const value = patch.tiers[tier];
771
- if (value === null || value === undefined) delete next.tiers[tier];
772
- else next.tiers[tier] = value;
957
+ if (tier in patch.tiers) {
958
+ const value = patch.tiers[tier];
959
+ if (value === null || value === undefined) delete next.tiers[tier];
960
+ else next.tiers[tier] = value;
961
+ }
962
+ if (tier in patch.fallbacks) {
963
+ const value = patch.fallbacks[tier];
964
+ if (value === null || value === undefined || value.length === 0) delete next.fallbacks[tier];
965
+ else next.fallbacks[tier] = [...new Set(value)];
966
+ }
773
967
  }
774
968
  if (patch.tools) next.tools = patch.tools;
775
969
  return next;
776
970
  }
777
971
 
972
+ function formatModelList(models: string[] | undefined): string {
973
+ return models && models.length > 0 ? `\`${models.join(",")}\`` : "_none_";
974
+ }
975
+
778
976
  function summarizeConfig(
779
977
  user: PrReviewConfig,
780
978
  effective: PrReviewConfig,
@@ -801,6 +999,10 @@ function summarizeConfig(
801
999
  ),
802
1000
  `| \`tools\` | \`${user.tools.join(",")}\` | \`${effective.tools.join(",")}\` | tools granted to each review subagent |`,
803
1001
  "",
1002
+ "| Tier | Your fallbacks | Effective fallbacks |",
1003
+ "|---|---|---|",
1004
+ ...TIERS.map((t) => `| \`${t}\` | ${formatModelList(user.fallbacks[t])} | ${formatModelList(effective.fallbacks[t])} |`),
1005
+ "",
804
1006
  `User config: \`${userConfigPath()}\``,
805
1007
  ];
806
1008
  if (projectPath) lines.push(`Project overlay (trusted): \`${projectPath}\``);
@@ -810,7 +1012,9 @@ function summarizeConfig(
810
1012
  "- Open the settings menu: `/pr-review-config`",
811
1013
  "- Print this summary: `/pr-review-config show`",
812
1014
  "- Set directly: `/pr-review-config light=provider/model heavy=provider/model:high`",
1015
+ "- Set fallback chain: `/pr-review-config heavy_fallbacks=provider/backup:high,provider/backup2`",
813
1016
  "- Clear a tier: `/pr-review-config medium=unset`",
1017
+ "- Clear fallback chain: `/pr-review-config heavy_fallbacks=unset`",
814
1018
  "- A `<model>` is any pi model pattern (`provider/model` or `provider/model:thinking`).",
815
1019
  );
816
1020
  return lines.join("\n");
@@ -828,10 +1032,14 @@ const MODEL_LIST_ROWS = 10;
828
1032
  * search UX and fuzzyFilter's slash-token matching, so typing a model name substring
829
1033
  * finds `provider/model`). The SelectList is rebuilt as the query changes.
830
1034
  */
831
- function buildModelSubmenu(available: string[], currentSpec: string | undefined) {
1035
+ function buildModelSubmenu(
1036
+ available: string[],
1037
+ currentSpec: string | undefined,
1038
+ unsetDescription = "Fall back to the nearest configured tier, then the pi default.",
1039
+ ) {
832
1040
  return (_currentValue: string, done: (selectedValue?: string) => void) => {
833
1041
  const allItems: SelectItem[] = [
834
- { value: "__unset__", label: UNSET, description: "Fall back to the nearest configured tier, then the pi default." },
1042
+ { value: "__unset__", label: UNSET, description: unsetDescription },
835
1043
  ...available.map((spec) => ({ value: spec, label: spec })),
836
1044
  ];
837
1045
  const theme = getSelectListTheme();
@@ -890,10 +1098,23 @@ function configMenuItems(cfg: PrReviewConfig, available: string[]): SettingItem[
890
1098
  currentValue: cfg.tiers[tier] ?? UNSET,
891
1099
  submenu: buildModelSubmenu(available, cfg.tiers[tier]),
892
1100
  }));
1101
+ const fallbackItems: SettingItem[] = TIERS.map((tier) => ({
1102
+ id: `${tier}_fallbacks`,
1103
+ label: `${tier} fallback model`,
1104
+ description:
1105
+ "Retry model for quota/rate-limit/capacity failures. Press Enter to set one fallback; use key=value for chains.",
1106
+ currentValue: cfg.fallbacks[tier]?.join(",") || "(none)",
1107
+ submenu: buildModelSubmenu(
1108
+ available,
1109
+ cfg.fallbacks[tier]?.[0],
1110
+ "Clear this tier's fallback chain. Use key=value form to set multiple fallbacks.",
1111
+ ),
1112
+ }));
893
1113
  const current = cfg.tools.join(",");
894
1114
  const toolValues = [current, ...TOOLS_PRESETS.filter((p) => p !== current)];
895
1115
  return [
896
1116
  ...tierItems,
1117
+ ...fallbackItems,
897
1118
  {
898
1119
  id: "tools",
899
1120
  label: "subagent tools",
@@ -920,7 +1141,10 @@ async function showConfigMenu(
920
1141
 
921
1142
  let settingsList: SettingsList;
922
1143
  const refresh = () => {
923
- for (const tier of TIERS) settingsList.updateValue(tier, draft.tiers[tier] ?? UNSET);
1144
+ for (const tier of TIERS) {
1145
+ settingsList.updateValue(tier, draft.tiers[tier] ?? UNSET);
1146
+ settingsList.updateValue(`${tier}_fallbacks`, draft.fallbacks[tier]?.join(",") || "(none)");
1147
+ }
924
1148
  settingsList.updateValue("tools", draft.tools.join(","));
925
1149
  };
926
1150
 
@@ -928,15 +1152,23 @@ async function showConfigMenu(
928
1152
  if ((TIERS as string[]).includes(id)) {
929
1153
  if (newValue === "__unset__") delete draft.tiers[id as Tier];
930
1154
  else draft.tiers[id as Tier] = newValue;
1155
+ } else if (isFallbackKey(id)) {
1156
+ const tier = id.split(/[_\-.]/)[0] as Tier;
1157
+ if (newValue === "__unset__") delete draft.fallbacks[tier];
1158
+ else draft.fallbacks[tier] = [newValue];
931
1159
  } else if (id === "tools") {
932
- draft.tools = newValue.split(",").map((s) => s.trim()).filter(Boolean);
1160
+ draft.tools = splitCommaList(newValue);
933
1161
  } else {
934
1162
  return;
935
1163
  }
936
1164
  try {
937
1165
  writeUserConfig(draft);
938
1166
  refresh();
939
- const shown = id === "tools" ? draft.tools.join(",") : (draft.tiers[id as Tier] ?? UNSET);
1167
+ const shown = id === "tools"
1168
+ ? draft.tools.join(",")
1169
+ : isFallbackKey(id)
1170
+ ? (draft.fallbacks[id.split(/[_\-.]/)[0] as Tier]?.join(",") ?? "(none)")
1171
+ : (draft.tiers[id as Tier] ?? UNSET);
940
1172
  ctx.ui.notify(`PR review config: ${id} = ${shown}`, "info");
941
1173
  } catch (e) {
942
1174
  ctx.ui.notify(`pr-review-config failed: ${errMessage(e)}`, "error");
@@ -946,7 +1178,7 @@ async function showConfigMenu(
946
1178
 
947
1179
  settingsList = new SettingsList(
948
1180
  configMenuItems(draft, available),
949
- 6,
1181
+ 10,
950
1182
  getSettingsListTheme(),
951
1183
  (id, newValue) => persist(id, newValue),
952
1184
  () => done(undefined),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pr-review",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "Model-agnostic GitHub PR code review prompt for pi. Pass a PR number; it derives the base and head branches from the PR in the current repo, reviews the diff, and returns structured JSON findings.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -21,11 +21,11 @@
21
21
  "typebox": "*"
22
22
  },
23
23
  "pi": {
24
- "prompts": [
25
- "./prompts"
26
- ],
27
24
  "extensions": [
28
25
  "./extensions"
26
+ ],
27
+ "prompts": [
28
+ "./prompts"
29
29
  ]
30
30
  }
31
31
  }