@diegopetrucci/pi-contrarian 0.1.0 → 0.1.2

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.
@@ -1 +1 @@
1
- 0.79.10
1
+ 0.80.3
package/index.ts CHANGED
@@ -72,7 +72,20 @@ const COLLAPSED_LINE_LIMIT = 8;
72
72
  const CONTRARIAN_STATUS_ID = "contrarian";
73
73
  const CONTRARIAN_WIDGET_ID = "contrarian";
74
74
  const CONTRARIAN_CONFIG_FILE = "contrarian.json";
75
- const CONTRARIAN_MODEL_PREFERENCES = ["gpt-5.5", "claude-opus-4-8", "claude-opus-4.8"];
75
+ const CONTRARIAN_MODEL_PREFERENCES = [
76
+ "gpt-5.5",
77
+ "claude-opus-4-8",
78
+ "claude-opus-4.8",
79
+ "claude-sonnet-5-0",
80
+ "claude-sonnet-5.0",
81
+ "claude-sonnet-5",
82
+ "claude-sonnet-4-6",
83
+ "claude-sonnet-4.6",
84
+ "claude-sonnet-4-5",
85
+ "claude-sonnet-4.5",
86
+ "claude-sonnet-4-0",
87
+ "claude-sonnet-4",
88
+ ];
76
89
 
77
90
  const PROVIDER_MODEL_PREFERENCES: Record<string, string[]> = {
78
91
  "amazon-bedrock": [
@@ -83,6 +96,7 @@ const PROVIDER_MODEL_PREFERENCES: Record<string, string[]> = {
83
96
  "claude-opus-4-5",
84
97
  "claude-opus-4-1",
85
98
  "claude-opus-4",
99
+ "claude-sonnet-5",
86
100
  "claude-sonnet-4-6",
87
101
  "claude-sonnet-4-5",
88
102
  "claude-sonnet-4",
@@ -107,6 +121,9 @@ const PROVIDER_MODEL_PREFERENCES: Record<string, string[]> = {
107
121
  "claude-opus-4.1",
108
122
  "claude-opus-4-0",
109
123
  "claude-opus-4",
124
+ "claude-sonnet-5-0",
125
+ "claude-sonnet-5.0",
126
+ "claude-sonnet-5",
110
127
  "claude-sonnet-4-6",
111
128
  "claude-sonnet-4.6",
112
129
  "claude-sonnet-4-5",
@@ -185,6 +202,8 @@ const PROVIDER_MODEL_PREFERENCES: Record<string, string[]> = {
185
202
  "gpt-5",
186
203
  "gemini-3.1-pro-preview",
187
204
  "gemini-3-pro-preview",
205
+ "claude-sonnet-5.0",
206
+ "claude-sonnet-5",
188
207
  "claude-sonnet-4.6",
189
208
  "claude-sonnet-4.5",
190
209
  "gemini-2.5-pro",
@@ -360,6 +379,8 @@ const PROVIDER_MODEL_PREFERENCES: Record<string, string[]> = {
360
379
  "anthropic/claude-opus-4.6",
361
380
  "anthropic/claude-opus-4.5",
362
381
  "anthropic/claude-opus-4.1",
382
+ "anthropic/claude-sonnet-5.0",
383
+ "anthropic/claude-sonnet-5",
363
384
  "anthropic/claude-sonnet-4.6",
364
385
  "openai/gpt-5.5-pro",
365
386
  "openai/gpt-5.5",
@@ -619,6 +640,19 @@ function isTransientErrorMessage(message: string): boolean {
619
640
  return TRANSIENT_ERROR_PATTERN.test(message);
620
641
  }
621
642
 
643
+ // A model the catalog advertises but the active provider/subscription cannot
644
+ // serve surfaces as a not-found/404-style error (legacy snapshots or
645
+ // access-gated tiers). These are NOT transient: the same model keeps failing,
646
+ // so callers should fall back to a different model instead of retrying it.
647
+ const MODEL_AVAILABILITY_ERROR_PATTERN =
648
+ /\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;
649
+
650
+ function isModelAvailabilityError(message: string | undefined): boolean {
651
+ if (!message) return false;
652
+ if (isTransientErrorMessage(message)) return false;
653
+ return MODEL_AVAILABILITY_ERROR_PATTERN.test(message);
654
+ }
655
+
622
656
  function formatContrarianModelError(stopReason: "error" | "aborted", errorMessage: string | undefined): string {
623
657
  const trimmed = errorMessage?.trim();
624
658
  if (stopReason === "aborted") {
@@ -810,10 +844,48 @@ async function findAvailableModel(
810
844
  return undefined;
811
845
  }
812
846
 
847
+ function toContrarianSelection(
848
+ model: PiModel,
849
+ thinkingLevelOverride: ThinkingLevel | undefined,
850
+ reason: string,
851
+ ): ContrarianSelection {
852
+ const thinking = resolveThinkingLevel(model, thinkingLevelOverride);
853
+ return {
854
+ modelRef: `${model.provider}/${model.id}`,
855
+ provider: model.provider,
856
+ modelId: model.id,
857
+ modelName: model.name,
858
+ thinkingLevel: thinking.effective,
859
+ ...(thinking.clamped ? { requestedThinkingLevel: thinking.requested, thinkingLevelClamped: true } : {}),
860
+ autoSelected: true,
861
+ selectionReason: appendThinkingLevelClampReason(reason, thinking),
862
+ };
863
+ }
864
+
865
+ function buildSessionFallbackSelection(
866
+ ctx: { model?: PiModel },
867
+ thinkingLevelOverride: ThinkingLevel | undefined,
868
+ ): ContrarianSelection | undefined {
869
+ const model = ctx.model;
870
+ if (!model) return undefined;
871
+ return toContrarianSelection(
872
+ model,
873
+ thinkingLevelOverride,
874
+ `Used the current session model ${model.provider}/${model.id} as a final fallback.`,
875
+ );
876
+ }
877
+
878
+ function withFallbackReason(selection: ContrarianSelection, previous: ContrarianSelection): ContrarianSelection {
879
+ return {
880
+ ...selection,
881
+ selectionReason: `${selection.selectionReason} (Fell back from ${previous.modelRef} after a model-availability error.)`,
882
+ };
883
+ }
884
+
813
885
  async function selectContrarianModel(
814
886
  ctx: { model?: PiModel; modelRegistry: { getAvailable(): PiModel[] | Promise<PiModel[]> } },
815
887
  thinkingLevelOverride?: ThinkingLevel,
816
- ): Promise<{ ok: true; selection: ContrarianSelection } | { ok: false; error: string }> {
888
+ ): Promise<{ ok: true; selection: ContrarianSelection; ordered: ContrarianSelection[] } | { ok: false; error: string }> {
817
889
  const available = await ctx.modelRegistry.getAvailable();
818
890
  if (available.length === 0) {
819
891
  return {
@@ -884,29 +956,25 @@ async function selectContrarianModel(
884
956
  reason = "No reasoning models were available, so the top-ranked model across all available providers was used.";
885
957
  }
886
958
 
959
+ const sorted = [...candidates].sort((a, b) => rankModel(b) - rankModel(a));
887
960
  const preferred = providerForPreferences
888
961
  ? selectPreferredModel(candidates, providerForPreferences)
889
962
  : 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,
963
+ const orderedModels = preferred ? [preferred, ...sorted.filter((model) => model !== preferred)] : sorted;
964
+
965
+ const ordered = orderedModels.map((model, index) =>
966
+ toContrarianSelection(
967
+ model,
968
+ thinkingLevelOverride,
969
+ index === 0
970
+ ? preferred
971
+ ? `Selected ${model.id} via the hardcoded preference lists while preferring an opposite provider/model family.`
972
+ : reason
973
+ : `Auto-selected ${model.provider}/${model.id} as a lower-ranked fallback.`,
974
+ ),
895
975
  );
896
976
 
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
- };
977
+ return { ok: true, selection: ordered[0], ordered };
910
978
  }
911
979
 
912
980
  function updateContrarianUi(ctx: ExtensionContext, activeRuns: Map<string, ContrarianUiRun>): void {
@@ -1137,6 +1205,13 @@ function renderCollapsedText(text: string, lineLimit = COLLAPSED_LINE_LIMIT): st
1137
1205
  return [...lines.slice(0, lineLimit), `... (${lines.length - lineLimit} more lines)`].join("\n");
1138
1206
  }
1139
1207
 
1208
+ export const __test__ = {
1209
+ findAvailableModel,
1210
+ parseModelPreference,
1211
+ resolveThinkingLevel,
1212
+ selectContrarianModel,
1213
+ };
1214
+
1140
1215
  export default function contrarianExtension(pi: ExtensionAPI) {
1141
1216
  const activeRuns = new Map<string, ContrarianUiRun>();
1142
1217
  let preferences: ContrarianPreferences = {};
@@ -1300,13 +1375,17 @@ export default function contrarianExtension(pi: ExtensionAPI) {
1300
1375
  parameters: ContrarianParams,
1301
1376
 
1302
1377
  async execute(toolCallId, params, signal, onUpdate, ctx) {
1378
+ const rawTask = (params as { task?: unknown }).task;
1379
+ const task = typeof rawTask === "string" ? rawTask.trim() : "";
1380
+ if (!task) throw new Error("Invalid parameters: expected task to be a non-empty string.");
1381
+
1303
1382
  const explicitModel = parseModelPreference(params.model);
1304
1383
  const defaultModel = parseModelPreference(preferences.model);
1305
1384
  const configuredModel = explicitModel.model ?? defaultModel.model;
1306
1385
  const configuredThinkingLevel =
1307
1386
  params.thinkingLevel ?? explicitModel.thinkingLevel ?? preferences.thinkingLevel ?? defaultModel.thinkingLevel;
1308
1387
  const uiRun: ContrarianUiRun = {
1309
- task: params.task,
1388
+ task,
1310
1389
  includeBash: params.includeBash ?? false,
1311
1390
  startedAt: Date.now(),
1312
1391
  };
@@ -1314,7 +1393,16 @@ export default function contrarianExtension(pi: ExtensionAPI) {
1314
1393
  updateContrarianUi(ctx, activeRuns);
1315
1394
 
1316
1395
  try {
1317
- let selection: ContrarianSelection;
1396
+ const attempts: ContrarianSelection[] = [];
1397
+ const seen = new Set<string>();
1398
+ const pushAttempt = (candidate: ContrarianSelection | undefined) => {
1399
+ if (!candidate) return;
1400
+ const key = `${candidate.provider}/${candidate.modelId}`.toLowerCase();
1401
+ if (seen.has(key)) return;
1402
+ seen.add(key);
1403
+ attempts.push(candidate);
1404
+ };
1405
+
1318
1406
  if (configuredModel) {
1319
1407
  const modelRef = configuredModel;
1320
1408
  const matched = await findAvailableModel(ctx, modelRef);
@@ -1333,7 +1421,7 @@ export default function contrarianExtension(pi: ExtensionAPI) {
1333
1421
  : usedToolOverride
1334
1422
  ? "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
1423
  : "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 = {
1424
+ pushAttempt({
1337
1425
  modelRef: matched ? `${matched.provider}/${matched.id}` : modelRef,
1338
1426
  provider,
1339
1427
  modelId,
@@ -1344,7 +1432,7 @@ export default function contrarianExtension(pi: ExtensionAPI) {
1344
1432
  : {}),
1345
1433
  autoSelected: false,
1346
1434
  selectionReason,
1347
- };
1435
+ });
1348
1436
  } else {
1349
1437
  const selectionResult = await selectContrarianModel(ctx, configuredThinkingLevel);
1350
1438
  if (!selectionResult.ok) {
@@ -1367,11 +1455,14 @@ export default function contrarianExtension(pi: ExtensionAPI) {
1367
1455
  },
1368
1456
  };
1369
1457
  }
1370
- selection = selectionResult.selection;
1458
+ for (const candidate of selectionResult.ordered) pushAttempt(candidate);
1371
1459
  }
1372
1460
 
1373
- uiRun.selection = selection;
1374
- updateContrarianUi(ctx, activeRuns);
1461
+ // Keep the known-good session model as a final fallback: the catalog can
1462
+ // advertise models the active provider/subscription cannot actually serve
1463
+ // (legacy snapshots, access-gated tiers), which fail with a not-found/404
1464
+ // error. Falling back lets the run degrade gracefully instead of hard-failing.
1465
+ pushAttempt(buildSessionFallbackSelection(ctx, configuredThinkingLevel));
1375
1466
 
1376
1467
  const handleUpdate = (partial: { content: Array<{ type: "text"; text: string }>; details: ContrarianDetails }) => {
1377
1468
  uiRun.preview = partial.content[0]?.text ?? uiRun.preview;
@@ -1379,17 +1470,51 @@ export default function contrarianExtension(pi: ExtensionAPI) {
1379
1470
  onUpdate?.(partial);
1380
1471
  };
1381
1472
 
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
- };
1473
+ let lastErrorResult: { ok: false; error: string; details: ContrarianDetails } | undefined;
1474
+ for (let index = 0; index < attempts.length; index++) {
1475
+ const previous = index > 0 ? attempts[index - 1] : undefined;
1476
+ const attempt = previous ? withFallbackReason(attempts[index], previous) : attempts[index];
1477
+ uiRun.selection = attempt;
1478
+ updateContrarianUi(ctx, activeRuns);
1479
+
1480
+ const result = await runContrarian(attempt, { ...params, task }, signal, handleUpdate, ctx.cwd);
1481
+ if (result.ok) {
1482
+ return {
1483
+ content: [{ type: "text", text: result.output }],
1484
+ details: result.details,
1485
+ };
1486
+ }
1487
+
1488
+ lastErrorResult = result;
1489
+ const canFallBack = index < attempts.length - 1 && isModelAvailabilityError(result.error);
1490
+ if (!canFallBack) {
1491
+ return {
1492
+ content: [{ type: "text", text: result.error }],
1493
+ details: result.details,
1494
+ };
1495
+ }
1388
1496
  }
1389
1497
 
1390
1498
  return {
1391
- content: [{ type: "text", text: result.output }],
1392
- details: result.details,
1499
+ content: [
1500
+ { type: "text", text: lastErrorResult?.error ?? "Contrarian could not select an available model." },
1501
+ ],
1502
+ details:
1503
+ lastErrorResult?.details ?? {
1504
+ modelRef: "",
1505
+ provider: ctx.model?.provider ?? "unknown",
1506
+ modelId: "",
1507
+ modelName: undefined,
1508
+ thinkingLevel: configuredThinkingLevel ?? DEFAULT_THINKING_LEVEL,
1509
+ autoSelected: true,
1510
+ selectionReason: "No authenticated models were available to run the contrarian.",
1511
+ includeBash: params.includeBash ?? false,
1512
+ usage: createEmptyUsage(),
1513
+ stderr: "",
1514
+ exitCode: 1,
1515
+ durationMs: 0,
1516
+ cwd: params.cwd ?? ctx.cwd,
1517
+ },
1393
1518
  };
1394
1519
  } finally {
1395
1520
  activeRuns.delete(toolCallId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-contrarian",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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",