@diegopetrucci/pi-extensions 0.1.52 → 0.1.53

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.
@@ -619,6 +619,19 @@ function isTransientErrorMessage(message: string): boolean {
619
619
  return TRANSIENT_ERROR_PATTERN.test(message);
620
620
  }
621
621
 
622
+ // A model the catalog advertises but the active provider/subscription cannot
623
+ // serve surfaces as a not-found/404-style error (legacy snapshots or
624
+ // access-gated tiers). These are NOT transient: the same model keeps failing,
625
+ // so callers should fall back to a different model instead of retrying it.
626
+ const MODEL_AVAILABILITY_ERROR_PATTERN =
627
+ /\b(?:404|403|not[_ ]?found(?:[_ ]?error)?|model[_ ]?not[_ ]?found(?:[_ ]?error)?|no such model|unknown model|does not exist|is not available|not available|model[_ ]?not[_ ]?available|unsupported model|invalid model|forbidden|access[ _-]?denied|permission[ _-]?denied|not[ _-]?entitled|do(?:es)? not have access)\b/i;
628
+
629
+ function isModelAvailabilityError(message: string | undefined): boolean {
630
+ if (!message) return false;
631
+ if (isTransientErrorMessage(message)) return false;
632
+ return MODEL_AVAILABILITY_ERROR_PATTERN.test(message);
633
+ }
634
+
622
635
  function formatContrarianModelError(stopReason: "error" | "aborted", errorMessage: string | undefined): string {
623
636
  const trimmed = errorMessage?.trim();
624
637
  if (stopReason === "aborted") {
@@ -810,10 +823,48 @@ async function findAvailableModel(
810
823
  return undefined;
811
824
  }
812
825
 
826
+ function toContrarianSelection(
827
+ model: PiModel,
828
+ thinkingLevelOverride: ThinkingLevel | undefined,
829
+ reason: string,
830
+ ): ContrarianSelection {
831
+ const thinking = resolveThinkingLevel(model, thinkingLevelOverride);
832
+ return {
833
+ modelRef: `${model.provider}/${model.id}`,
834
+ provider: model.provider,
835
+ modelId: model.id,
836
+ modelName: model.name,
837
+ thinkingLevel: thinking.effective,
838
+ ...(thinking.clamped ? { requestedThinkingLevel: thinking.requested, thinkingLevelClamped: true } : {}),
839
+ autoSelected: true,
840
+ selectionReason: appendThinkingLevelClampReason(reason, thinking),
841
+ };
842
+ }
843
+
844
+ function buildSessionFallbackSelection(
845
+ ctx: { model?: PiModel },
846
+ thinkingLevelOverride: ThinkingLevel | undefined,
847
+ ): ContrarianSelection | undefined {
848
+ const model = ctx.model;
849
+ if (!model) return undefined;
850
+ return toContrarianSelection(
851
+ model,
852
+ thinkingLevelOverride,
853
+ `Used the current session model ${model.provider}/${model.id} as a final fallback.`,
854
+ );
855
+ }
856
+
857
+ function withFallbackReason(selection: ContrarianSelection, previous: ContrarianSelection): ContrarianSelection {
858
+ return {
859
+ ...selection,
860
+ selectionReason: `${selection.selectionReason} (Fell back from ${previous.modelRef} after a model-availability error.)`,
861
+ };
862
+ }
863
+
813
864
  async function selectContrarianModel(
814
865
  ctx: { model?: PiModel; modelRegistry: { getAvailable(): PiModel[] | Promise<PiModel[]> } },
815
866
  thinkingLevelOverride?: ThinkingLevel,
816
- ): Promise<{ ok: true; selection: ContrarianSelection } | { ok: false; error: string }> {
867
+ ): Promise<{ ok: true; selection: ContrarianSelection; ordered: ContrarianSelection[] } | { ok: false; error: string }> {
817
868
  const available = await ctx.modelRegistry.getAvailable();
818
869
  if (available.length === 0) {
819
870
  return {
@@ -884,29 +935,25 @@ async function selectContrarianModel(
884
935
  reason = "No reasoning models were available, so the top-ranked model across all available providers was used.";
885
936
  }
886
937
 
938
+ const sorted = [...candidates].sort((a, b) => rankModel(b) - rankModel(a));
887
939
  const preferred = providerForPreferences
888
940
  ? selectPreferredModel(candidates, providerForPreferences)
889
941
  : selectPreferredAcrossProviders(candidates);
890
- const winner = preferred ?? [...candidates].sort((a, b) => rankModel(b) - rankModel(a))[0];
891
- const thinking = resolveThinkingLevel(winner, thinkingLevelOverride);
892
- const selectionReason = appendThinkingLevelClampReason(
893
- preferred ? `Selected ${winner.id} via the hardcoded preference lists while preferring an opposite provider/model family.` : reason,
894
- thinking,
942
+ const orderedModels = preferred ? [preferred, ...sorted.filter((model) => model !== preferred)] : sorted;
943
+
944
+ const ordered = orderedModels.map((model, index) =>
945
+ toContrarianSelection(
946
+ model,
947
+ thinkingLevelOverride,
948
+ index === 0
949
+ ? preferred
950
+ ? `Selected ${model.id} via the hardcoded preference lists while preferring an opposite provider/model family.`
951
+ : reason
952
+ : `Auto-selected ${model.provider}/${model.id} as a lower-ranked fallback.`,
953
+ ),
895
954
  );
896
955
 
897
- return {
898
- ok: true,
899
- selection: {
900
- modelRef: `${winner.provider}/${winner.id}`,
901
- provider: winner.provider,
902
- modelId: winner.id,
903
- modelName: winner.name,
904
- thinkingLevel: thinking.effective,
905
- ...(thinking.clamped ? { requestedThinkingLevel: thinking.requested, thinkingLevelClamped: true } : {}),
906
- autoSelected: true,
907
- selectionReason,
908
- },
909
- };
956
+ return { ok: true, selection: ordered[0], ordered };
910
957
  }
911
958
 
912
959
  function updateContrarianUi(ctx: ExtensionContext, activeRuns: Map<string, ContrarianUiRun>): void {
@@ -1314,7 +1361,16 @@ export default function contrarianExtension(pi: ExtensionAPI) {
1314
1361
  updateContrarianUi(ctx, activeRuns);
1315
1362
 
1316
1363
  try {
1317
- let selection: ContrarianSelection;
1364
+ const attempts: ContrarianSelection[] = [];
1365
+ const seen = new Set<string>();
1366
+ const pushAttempt = (candidate: ContrarianSelection | undefined) => {
1367
+ if (!candidate) return;
1368
+ const key = `${candidate.provider}/${candidate.modelId}`.toLowerCase();
1369
+ if (seen.has(key)) return;
1370
+ seen.add(key);
1371
+ attempts.push(candidate);
1372
+ };
1373
+
1318
1374
  if (configuredModel) {
1319
1375
  const modelRef = configuredModel;
1320
1376
  const matched = await findAvailableModel(ctx, modelRef);
@@ -1333,7 +1389,7 @@ export default function contrarianExtension(pi: ExtensionAPI) {
1333
1389
  : usedToolOverride
1334
1390
  ? "Used the explicit model override provided in the tool call. The model was not matched against the authenticated model list, so the reasoning level fallback was applied."
1335
1391
  : "Used the configured default contrarian model. The model was not matched against the authenticated model list, so the reasoning level fallback was applied.";
1336
- selection = {
1392
+ pushAttempt({
1337
1393
  modelRef: matched ? `${matched.provider}/${matched.id}` : modelRef,
1338
1394
  provider,
1339
1395
  modelId,
@@ -1344,7 +1400,7 @@ export default function contrarianExtension(pi: ExtensionAPI) {
1344
1400
  : {}),
1345
1401
  autoSelected: false,
1346
1402
  selectionReason,
1347
- };
1403
+ });
1348
1404
  } else {
1349
1405
  const selectionResult = await selectContrarianModel(ctx, configuredThinkingLevel);
1350
1406
  if (!selectionResult.ok) {
@@ -1367,11 +1423,14 @@ export default function contrarianExtension(pi: ExtensionAPI) {
1367
1423
  },
1368
1424
  };
1369
1425
  }
1370
- selection = selectionResult.selection;
1426
+ for (const candidate of selectionResult.ordered) pushAttempt(candidate);
1371
1427
  }
1372
1428
 
1373
- uiRun.selection = selection;
1374
- updateContrarianUi(ctx, activeRuns);
1429
+ // Keep the known-good session model as a final fallback: the catalog can
1430
+ // advertise models the active provider/subscription cannot actually serve
1431
+ // (legacy snapshots, access-gated tiers), which fail with a not-found/404
1432
+ // error. Falling back lets the run degrade gracefully instead of hard-failing.
1433
+ pushAttempt(buildSessionFallbackSelection(ctx, configuredThinkingLevel));
1375
1434
 
1376
1435
  const handleUpdate = (partial: { content: Array<{ type: "text"; text: string }>; details: ContrarianDetails }) => {
1377
1436
  uiRun.preview = partial.content[0]?.text ?? uiRun.preview;
@@ -1379,17 +1438,51 @@ export default function contrarianExtension(pi: ExtensionAPI) {
1379
1438
  onUpdate?.(partial);
1380
1439
  };
1381
1440
 
1382
- const result = await runContrarian(selection, params, signal, handleUpdate, ctx.cwd);
1383
- if (!result.ok) {
1384
- return {
1385
- content: [{ type: "text", text: result.error }],
1386
- details: result.details,
1387
- };
1441
+ let lastErrorResult: { ok: false; error: string; details: ContrarianDetails } | undefined;
1442
+ for (let index = 0; index < attempts.length; index++) {
1443
+ const previous = index > 0 ? attempts[index - 1] : undefined;
1444
+ const attempt = previous ? withFallbackReason(attempts[index], previous) : attempts[index];
1445
+ uiRun.selection = attempt;
1446
+ updateContrarianUi(ctx, activeRuns);
1447
+
1448
+ const result = await runContrarian(attempt, params, signal, handleUpdate, ctx.cwd);
1449
+ if (result.ok) {
1450
+ return {
1451
+ content: [{ type: "text", text: result.output }],
1452
+ details: result.details,
1453
+ };
1454
+ }
1455
+
1456
+ lastErrorResult = result;
1457
+ const canFallBack = index < attempts.length - 1 && isModelAvailabilityError(result.error);
1458
+ if (!canFallBack) {
1459
+ return {
1460
+ content: [{ type: "text", text: result.error }],
1461
+ details: result.details,
1462
+ };
1463
+ }
1388
1464
  }
1389
1465
 
1390
1466
  return {
1391
- content: [{ type: "text", text: result.output }],
1392
- details: result.details,
1467
+ content: [
1468
+ { type: "text", text: lastErrorResult?.error ?? "Contrarian could not select an available model." },
1469
+ ],
1470
+ details:
1471
+ lastErrorResult?.details ?? {
1472
+ modelRef: "",
1473
+ provider: ctx.model?.provider ?? "unknown",
1474
+ modelId: "",
1475
+ modelName: undefined,
1476
+ thinkingLevel: configuredThinkingLevel ?? DEFAULT_THINKING_LEVEL,
1477
+ autoSelected: true,
1478
+ selectionReason: "No authenticated models were available to run the contrarian.",
1479
+ includeBash: params.includeBash ?? false,
1480
+ usage: createEmptyUsage(),
1481
+ stderr: "",
1482
+ exitCode: 1,
1483
+ durationMs: 0,
1484
+ cwd: params.cwd ?? ctx.cwd,
1485
+ },
1393
1486
  };
1394
1487
  } finally {
1395
1488
  activeRuns.delete(toolCallId);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-contrarian",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A read-only contrarian subagent extension for pi that stress-tests plans, designs, assumptions, and conclusions.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -477,53 +477,104 @@ async function findAvailableModel(
477
477
  return undefined;
478
478
  }
479
479
 
480
- async function selectLibrarianModel(
480
+ // A model the catalog advertises but the active provider/subscription cannot
481
+ // serve surfaces as a not-found/404-style error (legacy snapshots or
482
+ // access-gated tiers). These are NOT transient: the same model keeps failing,
483
+ // so we fall back to the next candidate instead of retrying it.
484
+ const MODEL_AVAILABILITY_ERROR_PATTERN =
485
+ /\b(?:404|403|not[_ ]?found(?:[_ ]?error)?|model[_ ]?not[_ ]?found(?:[_ ]?error)?|no such model|unknown model|does not exist|is not available|not available|model[_ ]?not[_ ]?available|unsupported model|invalid model|forbidden|access[ _-]?denied|permission[ _-]?denied|not[ _-]?entitled|do(?:es)? not have access)\b/i;
486
+
487
+ function isModelAvailabilityError(message: string | undefined): boolean {
488
+ if (!message) return false;
489
+ return MODEL_AVAILABILITY_ERROR_PATTERN.test(message);
490
+ }
491
+
492
+ type LibrarianCandidate = { model: any; details: LibrarianModelDetails };
493
+
494
+ function withLibrarianFallbackReason(candidate: LibrarianCandidate, previous: LibrarianCandidate): LibrarianCandidate {
495
+ return {
496
+ model: candidate.model,
497
+ details: {
498
+ ...candidate.details,
499
+ selectionReason: `${candidate.details.selectionReason} (Fell back from ${previous.details.modelRef} after a model-availability error.)`,
500
+ },
501
+ };
502
+ }
503
+
504
+ // Build an ordered list of model candidates to try. The catalog can advertise
505
+ // models the active provider/subscription cannot actually serve, so callers run
506
+ // these in order and skip ones that fail with a model-availability error,
507
+ // ultimately falling back to the known-good current session model.
508
+ async function buildLibrarianCandidates(
481
509
  ctx: { model?: any; modelRegistry: { getAvailable(): any[] | Promise<any[]> } },
482
510
  modelPreference: string | undefined,
483
511
  thinkingLevel: ThinkingLevel,
484
- ): Promise<{ model: any; details: LibrarianModelDetails }> {
512
+ ): Promise<LibrarianCandidate[]> {
513
+ const candidates: LibrarianCandidate[] = [];
514
+ const seen = new Set<string>();
515
+ const push = (model: any, details: LibrarianModelDetails) => {
516
+ if (!model) return;
517
+ const key = `${model.provider}/${model.id}`.toLowerCase();
518
+ if (seen.has(key)) return;
519
+ seen.add(key);
520
+ candidates.push({ model, details });
521
+ };
522
+
485
523
  const normalized = modelPreference?.trim();
524
+ let configuredMatched: any | undefined;
486
525
  if (normalized) {
487
- const matched = await findAvailableModel(ctx, normalized);
488
- if (matched) {
489
- return {
490
- model: matched,
491
- details: {
492
- modelRef: modelRef(matched),
493
- provider: matched.provider,
494
- modelId: matched.id,
495
- thinkingLevel,
496
- autoSelected: false,
497
- selectionReason: "Using the configured librarian model.",
498
- },
499
- };
526
+ configuredMatched = await findAvailableModel(ctx, normalized);
527
+ if (configuredMatched) {
528
+ push(configuredMatched, {
529
+ modelRef: modelRef(configuredMatched),
530
+ provider: configuredMatched.provider,
531
+ modelId: configuredMatched.id,
532
+ thinkingLevel,
533
+ autoSelected: false,
534
+ selectionReason: "Using the configured librarian model.",
535
+ });
500
536
  }
501
537
  }
502
538
 
503
539
  const available = await ctx.modelRegistry.getAvailable();
504
540
  const preferred = available.filter(isPreferredFastLibrarianModel);
505
- const winner = preferred.length > 0
506
- ? [...preferred].sort((a, b) => rankPreferredFastLibrarianModel(a) - rankPreferredFastLibrarianModel(b))[0]
507
- : [...available].sort((a, b) => rankLibrarianModel(a) - rankLibrarianModel(b))[0];
508
- if (!winner) {
509
- throw new Error("No authenticated models are available for Librarian. Log in or configure an API key first.");
510
- }
541
+ const ranked = preferred.length > 0
542
+ ? [...preferred].sort((a, b) => rankPreferredFastLibrarianModel(a) - rankPreferredFastLibrarianModel(b))
543
+ : [...available].sort((a, b) => rankLibrarianModel(a) - rankLibrarianModel(b));
544
+ const fallbackText = normalized && !configuredMatched
545
+ ? ` Configured model ${normalized} was unavailable, so Librarian fell back to auto-selection.`
546
+ : "";
547
+ ranked.forEach((model, index) => {
548
+ const topReason = preferred.length > 0
549
+ ? `Selected a preferred fast Librarian model.${fallbackText}`
550
+ : `Selected the cheapest available model because no preferred fast Librarian model was available.${fallbackText}`;
551
+ push(model, {
552
+ modelRef: modelRef(model),
553
+ provider: model.provider,
554
+ modelId: model.id,
555
+ thinkingLevel,
556
+ autoSelected: true,
557
+ selectionReason: index === 0 ? topReason : `Auto-selected ${modelRef(model)} as a lower-ranked fallback.`,
558
+ });
559
+ });
511
560
 
512
- const fallbackText = normalized ? ` Configured model ${normalized} was unavailable, so Librarian fell back to auto-selection.` : "";
513
- const selectionReason = preferred.length > 0
514
- ? `Selected a preferred fast Librarian model.${fallbackText}`
515
- : `Selected the cheapest available model because no preferred fast Librarian model was available.${fallbackText}`;
516
- return {
517
- model: winner,
518
- details: {
519
- modelRef: modelRef(winner),
520
- provider: winner.provider,
521
- modelId: winner.id,
561
+ // Final fallback: the active session model is known to be servable.
562
+ if (ctx.model) {
563
+ push(ctx.model, {
564
+ modelRef: modelRef(ctx.model),
565
+ provider: ctx.model.provider,
566
+ modelId: ctx.model.id,
522
567
  thinkingLevel,
523
568
  autoSelected: true,
524
- selectionReason,
525
- },
526
- };
569
+ selectionReason: `Used the current session model ${modelRef(ctx.model)} as a final fallback.`,
570
+ });
571
+ }
572
+
573
+ if (candidates.length === 0) {
574
+ throw new Error("No authenticated models are available for Librarian. Log in or configure an API key first.");
575
+ }
576
+
577
+ return candidates;
527
578
  }
528
579
 
529
580
  function resolveToolPath(cwd: string, rawPath: string): string {
@@ -944,7 +995,8 @@ export default function librarianExtension(pi: ExtensionAPI) {
944
995
  parseThinkingLevel((params as { thinkingLevel?: unknown }).thinkingLevel) ??
945
996
  explicitModel.thinkingLevel ??
946
997
  thinkingPreference;
947
- const selectedModel = await selectLibrarianModel(ctx, explicitModel.model ?? modelPreference, thinkingLevel);
998
+ const candidates = await buildLibrarianCandidates(ctx, explicitModel.model ?? modelPreference, thinkingLevel);
999
+ const selectedModel = candidates[0];
948
1000
 
949
1001
  const workspaceBase = path.join(os.tmpdir(), "pi-librarian");
950
1002
  await fs.mkdir(workspaceBase, { recursive: true });
@@ -1045,62 +1097,124 @@ export default function librarianExtension(pi: ExtensionAPI) {
1045
1097
 
1046
1098
  await resourceLoader.reload();
1047
1099
 
1048
- const created = await createAgentSession({
1049
- cwd: workspace,
1050
- modelRegistry: ctx.modelRegistry,
1051
- resourceLoader,
1052
- sessionManager: SessionManager.inMemory(workspace),
1053
- model: selectedModel.model,
1054
- thinkingLevel,
1055
- tools: ["read", "bash"],
1056
- });
1100
+ const runAttempt = async (
1101
+ candidate: LibrarianCandidate,
1102
+ ): Promise<{ answer: string; lastAssistant: AssistantLikeMessage | undefined }> => {
1103
+ details.model = candidate.details;
1104
+ details.turns = 0;
1105
+ details.toolCalls = [];
1106
+ emit(true);
1107
+
1108
+ const created = await createAgentSession({
1109
+ cwd: workspace,
1110
+ modelRegistry: ctx.modelRegistry,
1111
+ resourceLoader,
1112
+ sessionManager: SessionManager.inMemory(workspace),
1113
+ model: candidate.model,
1114
+ thinkingLevel,
1115
+ tools: ["read", "bash"],
1116
+ });
1057
1117
 
1058
- session = created.session as typeof session;
1059
- unsubscribe = (created.session as any).subscribe((event: any) => {
1060
- switch (event.type) {
1061
- case "turn_end":
1062
- details.turns += 1;
1063
- emit();
1064
- break;
1065
- case "tool_execution_start":
1066
- details.toolCalls.push({
1067
- id: event.toolCallId,
1068
- name: event.toolName,
1069
- args: event.args,
1070
- startedAt: Date.now(),
1071
- });
1072
- if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
1073
- details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
1118
+ session = created.session as typeof session;
1119
+ unsubscribe = (created.session as any).subscribe((event: any) => {
1120
+ switch (event.type) {
1121
+ case "turn_end":
1122
+ details.turns += 1;
1123
+ emit();
1124
+ break;
1125
+ case "tool_execution_start":
1126
+ details.toolCalls.push({
1127
+ id: event.toolCallId,
1128
+ name: event.toolName,
1129
+ args: event.args,
1130
+ startedAt: Date.now(),
1131
+ });
1132
+ if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
1133
+ details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
1134
+ }
1135
+ emit(true);
1136
+ break;
1137
+ case "tool_execution_end": {
1138
+ const call = details.toolCalls.find((item) => item.id === event.toolCallId);
1139
+ if (call) {
1140
+ call.endedAt = Date.now();
1141
+ call.isError = event.isError;
1142
+ }
1143
+ emit(true);
1144
+ break;
1074
1145
  }
1075
- emit(true);
1076
- break;
1077
- case "tool_execution_end": {
1078
- const call = details.toolCalls.find((item) => item.id === event.toolCallId);
1079
- if (call) {
1080
- call.endedAt = Date.now();
1081
- call.isError = event.isError;
1082
- }
1083
- emit(true);
1084
- break;
1085
1146
  }
1147
+ });
1148
+
1149
+ try {
1150
+ if (!aborted) {
1151
+ const promptPromise = created.session.prompt(buildUserPrompt(query, repos, owners, maxSearchResults, details.cache), {
1152
+ expandPromptTemplates: false,
1153
+ });
1154
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
1155
+ runTimeout = setTimeout(() => {
1156
+ abort();
1157
+ reject(new Error(`Librarian timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`));
1158
+ }, MAX_RUN_MS);
1159
+ });
1160
+ await Promise.race([promptPromise, timeoutPromise]);
1161
+ }
1162
+
1163
+ const lastAssistant = session ? getLastAssistantMessage(session.state.messages) : undefined;
1164
+ const answer = session ? extractLastAssistantText(session.state.messages) : "";
1165
+ return { answer, lastAssistant };
1166
+ } finally {
1167
+ if (runTimeout) {
1168
+ clearTimeout(runTimeout);
1169
+ runTimeout = undefined;
1170
+ }
1171
+ unsubscribe?.();
1172
+ unsubscribe = undefined;
1173
+ session?.dispose();
1174
+ session = undefined;
1086
1175
  }
1087
- });
1176
+ };
1088
1177
 
1089
- if (!aborted) {
1090
- const promptPromise = created.session.prompt(buildUserPrompt(query, repos, owners, maxSearchResults, details.cache), {
1091
- expandPromptTemplates: false,
1092
- });
1093
- const timeoutPromise = new Promise<never>((_resolve, reject) => {
1094
- runTimeout = setTimeout(() => {
1095
- abort();
1096
- reject(new Error(`Librarian timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`));
1097
- }, MAX_RUN_MS);
1098
- });
1099
- await Promise.race([promptPromise, timeoutPromise]);
1178
+ let answer = "";
1179
+ let lastAssistant: AssistantLikeMessage | undefined;
1180
+ let attemptErrorReason: string | undefined;
1181
+ for (let index = 0; index < candidates.length; index++) {
1182
+ if (aborted) break;
1183
+ const candidate =
1184
+ index === 0 ? candidates[index] : withLibrarianFallbackReason(candidates[index], candidates[index - 1]);
1185
+
1186
+ let attempt: { answer: string; lastAssistant: AssistantLikeMessage | undefined };
1187
+ try {
1188
+ attempt = await runAttempt(candidate);
1189
+ } catch (error) {
1190
+ // Let aborts/timeouts propagate to the outer handler.
1191
+ if (aborted || isAbortLikeError(error)) throw error;
1192
+ // Some providers throw (rather than carry the error on the assistant
1193
+ // message) when a model is unavailable; treat that like a
1194
+ // message-carried availability error and fall back to the next model.
1195
+ const message = error instanceof Error ? error.message : String(error);
1196
+ if (index < candidates.length - 1 && isModelAvailabilityError(message)) {
1197
+ answer = "";
1198
+ lastAssistant = undefined;
1199
+ attemptErrorReason = `the internal subagent stopped with an error: ${message}`;
1200
+ continue;
1201
+ }
1202
+ throw error;
1203
+ }
1204
+
1205
+ answer = attempt.answer;
1206
+ lastAssistant = attempt.lastAssistant;
1207
+ if (answer || aborted) break;
1208
+
1209
+ attemptErrorReason = describeNoAnswerReason(lastAssistant, details.turns);
1210
+ const errorMessage =
1211
+ lastAssistant && lastAssistant.stopReason === "error" && typeof lastAssistant.errorMessage === "string"
1212
+ ? lastAssistant.errorMessage
1213
+ : undefined;
1214
+ const canFallBack = index < candidates.length - 1 && isModelAvailabilityError(errorMessage);
1215
+ if (!canFallBack) break;
1100
1216
  }
1101
1217
 
1102
- const lastAssistant = session ? getLastAssistantMessage(session.state.messages) : undefined;
1103
- const answer = session ? extractLastAssistantText(session.state.messages) : "";
1104
1218
  if (answer) {
1105
1219
  lastContent = answer;
1106
1220
  details.status = "done";
@@ -1109,7 +1223,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
1109
1223
  details.status = "aborted";
1110
1224
  } else {
1111
1225
  details.status = "error";
1112
- const reason = describeNoAnswerReason(lastAssistant, details.turns);
1226
+ const reason = attemptErrorReason ?? describeNoAnswerReason(lastAssistant, details.turns);
1113
1227
  lastContent = formatInternalFailure(details, reason);
1114
1228
  details.error = reason;
1115
1229
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-librarian",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "A pi GitHub research scout with a toggleable local repo checkout cache under the user's OS cache directory.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -612,6 +612,19 @@ function isTransientErrorMessage(message: string): boolean {
612
612
  return TRANSIENT_ERROR_PATTERN.test(message);
613
613
  }
614
614
 
615
+ // A model the catalog advertises but the active provider/subscription cannot
616
+ // serve surfaces as a not-found/404-style error (legacy snapshots or
617
+ // access-gated tiers). These are NOT transient: the same model keeps failing,
618
+ // so callers should fall back to a different model instead of retrying it.
619
+ const MODEL_AVAILABILITY_ERROR_PATTERN =
620
+ /\b(?:404|403|not[_ ]?found(?:[_ ]?error)?|model[_ ]?not[_ ]?found(?:[_ ]?error)?|no such model|unknown model|does not exist|is not available|not available|model[_ ]?not[_ ]?available|unsupported model|invalid model|forbidden|access[ _-]?denied|permission[ _-]?denied|not[ _-]?entitled|do(?:es)? not have access)\b/i;
621
+
622
+ function isModelAvailabilityError(message: string | undefined): boolean {
623
+ if (!message) return false;
624
+ if (isTransientErrorMessage(message)) return false;
625
+ return MODEL_AVAILABILITY_ERROR_PATTERN.test(message);
626
+ }
627
+
615
628
  function formatOracleModelError(stopReason: "error" | "aborted", errorMessage: string | undefined): string {
616
629
  const trimmed = errorMessage?.trim();
617
630
  if (stopReason === "aborted") {
@@ -776,10 +789,48 @@ async function findAvailableModel(
776
789
  return undefined;
777
790
  }
778
791
 
792
+ function toOracleSelection(
793
+ model: PiModel,
794
+ thinkingLevelOverride: ThinkingLevel | undefined,
795
+ reason: string,
796
+ ): OracleSelection {
797
+ const thinking = resolveThinkingLevel(model, thinkingLevelOverride);
798
+ return {
799
+ modelRef: `${model.provider}/${model.id}`,
800
+ provider: model.provider,
801
+ modelId: model.id,
802
+ modelName: model.name,
803
+ thinkingLevel: thinking.effective,
804
+ ...(thinking.clamped ? { requestedThinkingLevel: thinking.requested, thinkingLevelClamped: true } : {}),
805
+ autoSelected: true,
806
+ selectionReason: appendThinkingLevelClampReason(reason, thinking),
807
+ };
808
+ }
809
+
810
+ function buildSessionFallbackSelection(
811
+ ctx: { model?: PiModel },
812
+ thinkingLevelOverride: ThinkingLevel | undefined,
813
+ ): OracleSelection | undefined {
814
+ const model = ctx.model;
815
+ if (!model) return undefined;
816
+ return toOracleSelection(
817
+ model,
818
+ thinkingLevelOverride,
819
+ `Used the current session model ${model.provider}/${model.id} as a final fallback.`,
820
+ );
821
+ }
822
+
823
+ function withFallbackReason(selection: OracleSelection, previous: OracleSelection): OracleSelection {
824
+ return {
825
+ ...selection,
826
+ selectionReason: `${selection.selectionReason} (Fell back from ${previous.modelRef} after a model-availability error.)`,
827
+ };
828
+ }
829
+
779
830
  async function selectOracleModel(
780
831
  ctx: { model?: PiModel; modelRegistry: { getAvailable(): PiModel[] | Promise<PiModel[]> } },
781
832
  thinkingLevelOverride?: ThinkingLevel,
782
- ): Promise<{ ok: true; selection: OracleSelection } | { ok: false; error: string }> {
833
+ ): Promise<{ ok: true; selection: OracleSelection; ordered: OracleSelection[] } | { ok: false; error: string }> {
783
834
  const available = await ctx.modelRegistry.getAvailable();
784
835
  if (available.length === 0) {
785
836
  return {
@@ -810,27 +861,23 @@ async function selectOracleModel(
810
861
  reason = "No reasoning models were available, so the top-ranked model across all available providers was used.";
811
862
  }
812
863
 
864
+ const sorted = [...candidates].sort((a, b) => rankModel(b) - rankModel(a));
813
865
  const preferred = selectPreferredModel(candidates, providerForPreferences);
814
- const winner = preferred ?? [...candidates].sort((a, b) => rankModel(b) - rankModel(a))[0];
815
- const thinking = resolveThinkingLevel(winner, thinkingLevelOverride);
816
- const selectionReason = appendThinkingLevelClampReason(
817
- preferred ? `Selected ${winner.id} via the hardcoded preference list for ${winner.provider}.` : reason,
818
- thinking,
866
+ const orderedModels = preferred ? [preferred, ...sorted.filter((model) => model !== preferred)] : sorted;
867
+
868
+ const ordered = orderedModels.map((model, index) =>
869
+ toOracleSelection(
870
+ model,
871
+ thinkingLevelOverride,
872
+ index === 0
873
+ ? preferred
874
+ ? `Selected ${model.id} via the hardcoded preference list for ${model.provider}.`
875
+ : reason
876
+ : `Auto-selected ${model.provider}/${model.id} as a lower-ranked fallback.`,
877
+ ),
819
878
  );
820
879
 
821
- return {
822
- ok: true,
823
- selection: {
824
- modelRef: `${winner.provider}/${winner.id}`,
825
- provider: winner.provider,
826
- modelId: winner.id,
827
- modelName: winner.name,
828
- thinkingLevel: thinking.effective,
829
- ...(thinking.clamped ? { requestedThinkingLevel: thinking.requested, thinkingLevelClamped: true } : {}),
830
- autoSelected: true,
831
- selectionReason,
832
- },
833
- };
880
+ return { ok: true, selection: ordered[0], ordered };
834
881
  }
835
882
 
836
883
  function updateOracleUi(ctx: ExtensionContext, activeRuns: Map<string, OracleUiRun>): void {
@@ -1237,7 +1284,16 @@ export default function oracleExtension(pi: ExtensionAPI) {
1237
1284
  updateOracleUi(ctx, activeRuns);
1238
1285
 
1239
1286
  try {
1240
- let selection: OracleSelection;
1287
+ const attempts: OracleSelection[] = [];
1288
+ const seen = new Set<string>();
1289
+ const pushAttempt = (candidate: OracleSelection | undefined) => {
1290
+ if (!candidate) return;
1291
+ const key = `${candidate.provider}/${candidate.modelId}`.toLowerCase();
1292
+ if (seen.has(key)) return;
1293
+ seen.add(key);
1294
+ attempts.push(candidate);
1295
+ };
1296
+
1241
1297
  if (configuredModel) {
1242
1298
  const modelRef = configuredModel;
1243
1299
  const matched = await findAvailableModel(ctx, modelRef);
@@ -1256,7 +1312,7 @@ export default function oracleExtension(pi: ExtensionAPI) {
1256
1312
  : usedToolOverride
1257
1313
  ? "Used the explicit model override provided in the tool call. The model was not matched against the authenticated model list, so the reasoning level fallback was applied."
1258
1314
  : "Used the configured default oracle model. The model was not matched against the authenticated model list, so the reasoning level fallback was applied.";
1259
- selection = {
1315
+ pushAttempt({
1260
1316
  modelRef: matched ? `${matched.provider}/${matched.id}` : modelRef,
1261
1317
  provider,
1262
1318
  modelId,
@@ -1267,7 +1323,7 @@ export default function oracleExtension(pi: ExtensionAPI) {
1267
1323
  : {}),
1268
1324
  autoSelected: false,
1269
1325
  selectionReason,
1270
- };
1326
+ });
1271
1327
  } else {
1272
1328
  const selectionResult = await selectOracleModel(ctx, configuredThinkingLevel);
1273
1329
  if (!selectionResult.ok) {
@@ -1290,11 +1346,14 @@ export default function oracleExtension(pi: ExtensionAPI) {
1290
1346
  },
1291
1347
  };
1292
1348
  }
1293
- selection = selectionResult.selection;
1349
+ for (const candidate of selectionResult.ordered) pushAttempt(candidate);
1294
1350
  }
1295
1351
 
1296
- uiRun.selection = selection;
1297
- updateOracleUi(ctx, activeRuns);
1352
+ // Keep the known-good session model as a final fallback: the catalog can
1353
+ // advertise models the active provider/subscription cannot actually serve
1354
+ // (legacy snapshots, access-gated tiers), which fail with a not-found/404
1355
+ // error. Falling back lets the run degrade gracefully instead of hard-failing.
1356
+ pushAttempt(buildSessionFallbackSelection(ctx, configuredThinkingLevel));
1298
1357
 
1299
1358
  const handleUpdate = (partial: { content: Array<{ type: "text"; text: string }>; details: OracleDetails }) => {
1300
1359
  uiRun.preview = partial.content[0]?.text ?? uiRun.preview;
@@ -1302,17 +1361,51 @@ export default function oracleExtension(pi: ExtensionAPI) {
1302
1361
  onUpdate?.(partial);
1303
1362
  };
1304
1363
 
1305
- const result = await runOracle(selection, params, signal, handleUpdate, ctx.cwd);
1306
- if (!result.ok) {
1307
- return {
1308
- content: [{ type: "text", text: result.error }],
1309
- details: result.details,
1310
- };
1364
+ let lastErrorResult: { ok: false; error: string; details: OracleDetails } | undefined;
1365
+ for (let index = 0; index < attempts.length; index++) {
1366
+ const previous = index > 0 ? attempts[index - 1] : undefined;
1367
+ const attempt = previous ? withFallbackReason(attempts[index], previous) : attempts[index];
1368
+ uiRun.selection = attempt;
1369
+ updateOracleUi(ctx, activeRuns);
1370
+
1371
+ const result = await runOracle(attempt, params, signal, handleUpdate, ctx.cwd);
1372
+ if (result.ok) {
1373
+ return {
1374
+ content: [{ type: "text", text: result.output }],
1375
+ details: result.details,
1376
+ };
1377
+ }
1378
+
1379
+ lastErrorResult = result;
1380
+ const canFallBack = index < attempts.length - 1 && isModelAvailabilityError(result.error);
1381
+ if (!canFallBack) {
1382
+ return {
1383
+ content: [{ type: "text", text: result.error }],
1384
+ details: result.details,
1385
+ };
1386
+ }
1311
1387
  }
1312
1388
 
1313
1389
  return {
1314
- content: [{ type: "text", text: result.output }],
1315
- details: result.details,
1390
+ content: [
1391
+ { type: "text", text: lastErrorResult?.error ?? "Oracle could not select an available model." },
1392
+ ],
1393
+ details:
1394
+ lastErrorResult?.details ?? {
1395
+ modelRef: "",
1396
+ provider: ctx.model?.provider ?? "unknown",
1397
+ modelId: "",
1398
+ modelName: undefined,
1399
+ thinkingLevel: configuredThinkingLevel ?? DEFAULT_THINKING_LEVEL,
1400
+ autoSelected: true,
1401
+ selectionReason: "No authenticated models were available to run the oracle.",
1402
+ includeBash: params.includeBash ?? false,
1403
+ usage: createEmptyUsage(),
1404
+ stderr: "",
1405
+ exitCode: 1,
1406
+ durationMs: 0,
1407
+ cwd: params.cwd ?? ctx.cwd,
1408
+ },
1316
1409
  };
1317
1410
  } finally {
1318
1411
  activeRuns.delete(toolCallId);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-oracle",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "An Amp-style oracle extension for pi that consults the strongest reasoning model on your current provider.",
5
5
  "keywords": [
6
6
  "pi-package",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-extensions",
3
- "version": "0.1.52",
3
+ "version": "0.1.53",
4
4
  "description": "A collection of pi extensions and skills for annotation UIs, context management, workflow audits, contrarian review, review-comment triage, notifications, brrr push alerts, safety guards, GitHub research, repo-local knowledge, todos, tool rendering, model/provider helpers, and Xiaohei-style article illustrations.",
5
5
  "keywords": [
6
6
  "pi-package",