pi-model-fallback 0.3.4 → 0.3.6

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/CHANGELOG.md CHANGED
@@ -1,10 +1,16 @@
1
- # Changelog
2
-
1
+ # Changelog
2
+
3
3
  ## Unreleased
4
4
 
5
+ ### Fixed
6
+
7
+ - Warn on completely shadowed fallback rules in config validation, save confirmations, status output, and config details without rejecting warning-bearing configs.
8
+ - Document the missing `git add`/`git commit` steps in `CONTRIBUTING.md` release instructions so maintainers can trigger auto-release after `npm version patch --no-git-tag-version`.
9
+ - Clarify that maintainers should update `CHANGELOG.md` before staging release files.
10
+
5
11
  ### Changed
6
12
 
7
- - Bump package version to `0.3.4` for the next patch release.
13
+ - Bump package version to `0.3.5` for the next patch release.
8
14
 
9
15
  - Declare `@earendil-works/pi-agent-core` as a peerDependency to align with pi-extension-template.
10
16
  - Add Buy Me a Coffee sponsor button to README and native GitHub funding link via `.github/FUNDING.yml`.
@@ -21,52 +27,51 @@
21
27
 
22
28
  - Publish only from release refs, print Node/npm versions, and publish with `--provenance --access public` so npm Trusted Publishing uses the same known-good path as `pi-spotify-widget`.
23
29
  - Release instructions now use `npm version patch --no-git-tag-version` so local tags do not race the auto-release workflow.
24
-
25
- ## 0.3.0
26
-
27
- - Use `agent_start` for persistent preselect; `session_start` was not a Pi extension event.
28
- - Persist fallback state from assistant error messages on `turn_end`, covering provider SDK errors that do not emit `after_provider_response`.
29
-
30
- ## 0.2.0
31
-
32
- - Add persistent failover state. A matching provider failure now writes `model-fallback/state.json`, and future sessions preselect the fallback model before the first provider call until the cooldown expires.
33
- - Parse retry/reset headers when available; otherwise default 429 cooldown to 72h and 5xx cooldown to 10m.
34
- - `/model-fallback:reset` now clears persistent state.
35
-
36
- All notable changes to this project will be documented in this file.
37
-
38
- This project follows semantic versioning.
39
-
40
- ## [0.1.3] - 2026-06-19
41
-
42
- ### Changed
43
-
44
- - CONTRIBUTING now reminds maintainers to run `npm pack --dry-run` after changing which `docs/` files ship in the package.
45
-
46
- ### Fixed
47
-
48
- - README License section no longer ends with a literal `\n` placeholder.
49
-
50
- ## [0.1.2] - 2026-06-04
51
-
52
- ### Changed
53
-
54
- - README and `docs/template-checklist.md` now follow the Pi OSS minimal-docs policy: `docs/` is optional, with explicit post-generation cleanup for template bootstrap docs.
55
- - Template bootstrap docs (`github-template.md`, `repository-settings.md`, `typescript.md`) are labeled for delete-or-merge after setup.
56
-
57
- ## [0.1.1] - 2026-06-01
58
-
59
- ### Changed
60
-
61
- - Publish workflow now supports npm publishing on merged package version bumps in addition to tags, releases, and manual dispatch.
62
- - Publish workflow now installs a current npm CLI so npm Trusted Publishing OIDC is supported.
63
- - CI and publish workflow commands no longer include literal trailing `\\n` text.
64
-
65
- ## [0.1.0] - YYYY-MM-DD
66
-
67
- ### Added
68
-
69
- - Initial Pi package template.
70
- - Example extension, Agent Skill, prompt, and theme.
71
- - CI and npm Trusted Publishing workflow.
72
-
30
+
31
+ ## 0.3.0
32
+
33
+ - Use `agent_start` for persistent preselect; `session_start` was not a Pi extension event.
34
+ - Persist fallback state from assistant error messages on `turn_end`, covering provider SDK errors that do not emit `after_provider_response`.
35
+
36
+ ## 0.2.0
37
+
38
+ - Add persistent failover state. A matching provider failure now writes `model-fallback/state.json`, and future sessions preselect the fallback model before the first provider call until the cooldown expires.
39
+ - Parse retry/reset headers when available; otherwise default 429 cooldown to 72h and 5xx cooldown to 10m.
40
+ - `/model-fallback:reset` now clears persistent state.
41
+
42
+ All notable changes to this project will be documented in this file.
43
+
44
+ This project follows semantic versioning.
45
+
46
+ ## [0.1.3] - 2026-06-19
47
+
48
+ ### Changed
49
+
50
+ - CONTRIBUTING now reminds maintainers to run `npm pack --dry-run` after changing which `docs/` files ship in the package.
51
+
52
+ ### Fixed
53
+
54
+ - README License section no longer ends with a literal `\n` placeholder.
55
+
56
+ ## [0.1.2] - 2026-06-04
57
+
58
+ ### Changed
59
+
60
+ - README and `docs/template-checklist.md` now follow the Pi OSS minimal-docs policy: `docs/` is optional, with explicit post-generation cleanup for template bootstrap docs.
61
+ - Template bootstrap docs (`github-template.md`, `repository-settings.md`, `typescript.md`) are labeled for delete-or-merge after setup.
62
+
63
+ ## [0.1.1] - 2026-06-01
64
+
65
+ ### Changed
66
+
67
+ - Publish workflow now supports npm publishing on merged package version bumps in addition to tags, releases, and manual dispatch.
68
+ - Publish workflow now installs a current npm CLI so npm Trusted Publishing OIDC is supported.
69
+ - CI and publish workflow commands no longer include literal trailing `\\n` text.
70
+
71
+ ## [0.1.0] - YYYY-MM-DD
72
+
73
+ ### Added
74
+
75
+ - Initial Pi package template.
76
+ - Example extension, Agent Skill, prompt, and theme.
77
+ - CI and npm Trusted Publishing workflow.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # pi-model-fallback
2
2
 
3
+ [![Join dotfield.xyz on Discord](https://img.shields.io/badge/Join%20dotfield.xyz%20on%20Discord-5865F2?logo=discord&logoColor=white)](https://discord.gg/4945dXZVW5)
4
+
3
5
  [![CI](https://github.com/eiei114/pi-model-fallback/actions/workflows/ci.yml/badge.svg)](https://github.com/eiei114/pi-model-fallback/actions/workflows/ci.yml)
4
6
  [![Publish](https://github.com/eiei114/pi-model-fallback/actions/workflows/publish.yml/badge.svg)](https://github.com/eiei114/pi-model-fallback/actions/workflows/publish.yml)
5
7
  [![npm version](https://img.shields.io/npm/v/pi-model-fallback.svg)](https://www.npmjs.com/package/pi-model-fallback)
@@ -90,6 +92,8 @@ Rule fields:
90
92
  - `cooldownMs`: optional persistent fallback window
91
93
  - `fallback`: target model Pi should switch to
92
94
 
95
+ Rules use first-match order: the first rule whose provider/model and status match wins. Put specific `matchModels` rules before broad `matchProviders` rules when they should take priority. `model_fallback_config validate`, `save`, `read`, and `status` report warning-only diagnostics when a later rule or model entry is completely shadowed by an earlier rule; `/model-fallback:status` also includes a concise warning summary for the current config. Warning-bearing config remains valid and can still be saved.
96
+
93
97
  ### Cooldowns
94
98
 
95
99
  When a rule does not set `cooldownMs`, the extension uses these defaults:
@@ -137,4 +141,4 @@ pi -e .
137
141
 
138
142
  ## License
139
143
 
140
- MIT
144
+ MIT
package/docs/usage.md CHANGED
@@ -15,6 +15,8 @@ Commands:
15
15
  /model-fallback:reset
16
16
  ```
17
17
 
18
+ Rule order is first-match: the first rule whose provider/model and status match wins. Place specific `matchModels` rules before broad `matchProviders` rules when the model-specific fallback should take priority. `model_fallback_config validate`, `save`, `read`, and `status` expose warning-only details for completely shadowed later rules or model entries, and `/model-fallback:status` includes a concise current-config warning summary; these warnings do not make the config invalid.
19
+
18
20
  Cooldown defaults when a rule omits `cooldownMs`:
19
21
 
20
22
  - `429` → 72 hours
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
  import { Type } from "typebox";
7
7
  import {
8
+ analyzeRuleWarnings,
8
9
  defaultConfig,
9
10
  findFallback,
10
11
  modelKey,
@@ -12,6 +13,7 @@ import {
12
13
  validateConfigShape,
13
14
  type ModelFallbackConfig,
14
15
  type ModelRef,
16
+ type RuleWarning,
15
17
  } from "../lib/config.js";
16
18
  import { parseStatusFromErrorMessage } from "../lib/error-status.js";
17
19
  import { emptyState, findActiveStateEntry, pruneExpiredState, readState, upsertStateEntry, validateStateShape, writeState, type FallbackState } from "../lib/state.js";
@@ -188,7 +190,7 @@ export default function modelFallback(pi: ExtensionAPI) {
188
190
  handler: async (_args, ctx) => {
189
191
  const loaded = config ?? (await loadConfig(ctx)) ?? defaultConfig();
190
192
  const loadedState = state ?? (await loadState(ctx)) ?? { version: 1 as const, entries: [] };
191
- ctx.ui.notify(formatStatus(loaded, loadedState, paths.config, paths.state, activeFallbackKey, originalModelKey, lastFallbackReason), "info");
193
+ ctx.ui.notify(formatStatus(loaded, loadedState, paths.config, paths.state, analyzeRuleWarnings(loaded), activeFallbackKey, originalModelKey, lastFallbackReason), "info");
192
194
  },
193
195
  });
194
196
 
@@ -242,25 +244,24 @@ export default function modelFallback(pi: ExtensionAPI) {
242
244
  if (params.action === "read" || params.action === "status") {
243
245
  const current = await readConfig(paths.config);
244
246
  const currentState = await readState(paths.state);
245
- return textResult(JSON.stringify(current, null, 2), {
246
- configPath: paths.config,
247
- statePath: paths.state,
248
- state: currentState,
249
- activeFallbackKey,
250
- originalModelKey,
251
- lastFallbackReason,
252
- });
247
+ const warnings = analyzeRuleWarnings(current);
248
+ const details = configToolDetails(paths.config, paths.state, currentState, warnings, activeFallbackKey, originalModelKey, lastFallbackReason);
249
+ const text = params.action === "status"
250
+ ? formatStatus(current, currentState, paths.config, paths.state, warnings, activeFallbackKey, originalModelKey, lastFallbackReason)
251
+ : JSON.stringify(current, null, 2);
252
+ return textResult(text, details);
253
253
  }
254
254
  if (!params.configJson) throw new Error("configJson is required for validate/save.");
255
255
  const nextConfig = validateConfigShape(JSON.parse(params.configJson));
256
+ const warnings = analyzeRuleWarnings(nextConfig);
256
257
  validateRegisteredModels(ctx, nextConfig);
257
- if (params.action === "validate") return textResult("Config is valid.", { config: nextConfig });
258
- if (!ctx.hasUI) return textResult("Config not saved: confirmation UI is unavailable.", { configPath: paths.config });
259
- const ok = await ctx.ui.confirm("Save model fallback config?", summarizeConfig(nextConfig));
260
- if (!ok) return textResult("Config not saved.", { configPath: paths.config });
258
+ if (params.action === "validate") return textResult(configResultText("Config is valid.", warnings), { config: nextConfig, ...warningDetails(warnings) });
259
+ if (!ctx.hasUI) return textResult("Config not saved: confirmation UI is unavailable.", { configPath: paths.config, ...warningDetails(warnings) });
260
+ const ok = await ctx.ui.confirm("Save model fallback config?", summarizeConfig(nextConfig, warnings));
261
+ if (!ok) return textResult("Config not saved.", { configPath: paths.config, ...warningDetails(warnings) });
261
262
  await writeConfig(paths.config, nextConfig);
262
263
  config = nextConfig;
263
- return textResult("Config saved.", { configPath: paths.config });
264
+ return textResult(configResultText("Config saved.", warnings), { configPath: paths.config, ...warningDetails(warnings) });
264
265
  },
265
266
  });
266
267
  }
@@ -299,6 +300,7 @@ function formatStatus(
299
300
  state: FallbackState,
300
301
  configPath: string,
301
302
  statePath: string,
303
+ warnings: RuleWarning[],
302
304
  activeFallbackKey?: string,
303
305
  originalModelKey?: string,
304
306
  reason?: string,
@@ -308,6 +310,7 @@ function formatStatus(
308
310
  `Config: ${configPath}`,
309
311
  `State: ${statePath}`,
310
312
  `Rules: ${config.rules.length}`,
313
+ `Config warnings: ${warningSummary(warnings)}`,
311
314
  `Persistent entries: ${state.entries.length}`,
312
315
  ...state.entries.map((entry) => `- ${modelRefKey(entry.source)} -> ${modelRefKey(entry.fallback)} until ${entry.until} (${entry.status})`),
313
316
  `Active fallback: ${activeFallbackKey ?? "none"}`,
@@ -316,13 +319,53 @@ function formatStatus(
316
319
  ].join("\n");
317
320
  }
318
321
 
319
- function summarizeConfig(config: ModelFallbackConfig): string {
322
+ function summarizeConfig(config: ModelFallbackConfig, warnings: RuleWarning[]): string {
320
323
  return [
321
324
  `enabled: ${config.enabled}`,
322
325
  ...config.rules.map((rule) => `${rule.name ?? "rule"}: ${rule.matchProviders?.join(",") ?? "models"} -> ${modelRefKey(rule.fallback)}`),
326
+ `warnings: ${warningSummary(warnings)}`,
323
327
  ].join("\n");
324
328
  }
325
329
 
330
+ function configToolDetails(
331
+ configPath: string,
332
+ statePath: string,
333
+ state: FallbackState,
334
+ warnings: RuleWarning[],
335
+ activeFallbackKey?: string,
336
+ originalModelKey?: string,
337
+ lastFallbackReason?: string,
338
+ ): Record<string, unknown> {
339
+ return {
340
+ configPath,
341
+ statePath,
342
+ state,
343
+ activeFallbackKey,
344
+ originalModelKey,
345
+ lastFallbackReason,
346
+ ...warningDetails(warnings),
347
+ };
348
+ }
349
+
350
+ function warningDetails(warnings: RuleWarning[]): Record<string, unknown> {
351
+ return {
352
+ warningCount: warnings.length,
353
+ warningSummary: warningSummary(warnings),
354
+ warnings,
355
+ };
356
+ }
357
+
358
+ function configResultText(prefix: string, warnings: RuleWarning[]): string {
359
+ if (warnings.length === 0) return prefix;
360
+ return `${prefix} ${warningSummary(warnings)}`;
361
+ }
362
+
363
+ function warningSummary(warnings: RuleWarning[]): string {
364
+ if (warnings.length === 0) return "none";
365
+ if (warnings.length === 1) return `1 warning: ${warnings[0].message}`;
366
+ return `${warnings.length} warnings: ${warnings.map((warning) => warning.message).join("; ")}`;
367
+ }
368
+
326
369
  function textResult(text: string, details?: Record<string, unknown>) {
327
370
  return { content: [{ type: "text" as const, text }], details: details ?? {} };
328
371
  }
package/lib/config.ts CHANGED
@@ -29,6 +29,28 @@ export interface MatchedFallback {
29
29
  fallback: ModelRef;
30
30
  }
31
31
 
32
+ export type RuleWarningCode = "provider_rule_shadows_model" | "shadowed_rule" | "duplicate_rule";
33
+
34
+ export interface RuleWarning {
35
+ severity: "warning";
36
+ code: RuleWarningCode;
37
+ message: string;
38
+ ruleIndex: number;
39
+ ruleName?: string;
40
+ shadowedByRuleIndex: number;
41
+ shadowedByRuleName?: string;
42
+ statuses: number[];
43
+ matchProviders?: string[];
44
+ matchModels?: ModelRef[];
45
+ }
46
+
47
+ interface NormalizedRuleForWarnings {
48
+ providers: Set<string>;
49
+ modelKeys: Set<string>;
50
+ models: ModelRef[];
51
+ statuses: Set<number>;
52
+ }
53
+
32
54
  export function defaultConfig(): ModelFallbackConfig {
33
55
  return {
34
56
  version: CONFIG_VERSION,
@@ -68,6 +90,43 @@ export function findFallback(config: ModelFallbackConfig, model: Pick<Model<any>
68
90
  return undefined;
69
91
  }
70
92
 
93
+ export function analyzeRuleWarnings(config: ModelFallbackConfig): RuleWarning[] {
94
+ const normalizedRules = config.rules.map((rule) => normalizeRuleForWarnings(rule));
95
+ const warnings: RuleWarning[] = [];
96
+
97
+ rules:
98
+ for (let ruleIndex = 1; ruleIndex < config.rules.length; ruleIndex += 1) {
99
+ const laterRule = config.rules[ruleIndex];
100
+ const later = normalizedRules[ruleIndex];
101
+ const reportedModelShadowKeys = new Set<string>();
102
+
103
+ for (let earlierIndex = 0; earlierIndex < ruleIndex; earlierIndex += 1) {
104
+ const earlierRule = config.rules[earlierIndex];
105
+ const earlier = normalizedRules[earlierIndex];
106
+
107
+ if (setsEqual(earlier.statuses, later.statuses) && ruleScopesEqual(earlier, later)) {
108
+ warnings.push(duplicateRuleWarning(laterRule, ruleIndex, later, earlierRule, earlierIndex));
109
+ continue rules;
110
+ }
111
+
112
+ if (statusSetCovers(earlier.statuses, later.statuses) && ruleScopeCovers(earlier, later)) {
113
+ warnings.push(scopeShadowWarning(laterRule, ruleIndex, later, earlierRule, earlierIndex, earlier));
114
+ continue rules;
115
+ }
116
+
117
+ if (earlier.providers.size === 0 || later.models.length === 0 || !statusSetCovers(earlier.statuses, later.statuses)) continue;
118
+
119
+ const shadowedModels = later.models.filter((entry) => earlier.providers.has(entry.provider) && !reportedModelShadowKeys.has(modelRefKey(entry)));
120
+ if (shadowedModels.length === 0) continue;
121
+ for (const entry of shadowedModels) reportedModelShadowKeys.add(modelRefKey(entry));
122
+
123
+ warnings.push(providerModelShadowWarning(laterRule, ruleIndex, later, earlierRule, earlierIndex, shadowedModels));
124
+ }
125
+ }
126
+
127
+ return warnings;
128
+ }
129
+
71
130
  export function modelRefKey(ref: ModelRef): string {
72
131
  return `${ref.provider}/${ref.model}`;
73
132
  }
@@ -100,6 +159,128 @@ function statusesFor(rule: FallbackRule): Set<number> {
100
159
  return new Set(rule.statuses && rule.statuses.length > 0 ? rule.statuses : DEFAULT_FALLBACK_STATUSES);
101
160
  }
102
161
 
162
+ function normalizeRuleForWarnings(rule: FallbackRule): NormalizedRuleForWarnings {
163
+ const modelsByKey = new Map((rule.matchModels ?? []).map((entry) => [modelRefKey(entry), entry]));
164
+ return {
165
+ providers: new Set(rule.matchProviders ?? []),
166
+ modelKeys: new Set(modelsByKey.keys()),
167
+ models: [...modelsByKey.values()],
168
+ statuses: statusesFor(rule),
169
+ };
170
+ }
171
+
172
+ function ruleScopesEqual(left: NormalizedRuleForWarnings, right: NormalizedRuleForWarnings): boolean {
173
+ return setsEqual(left.providers, right.providers) && setsEqual(left.modelKeys, right.modelKeys);
174
+ }
175
+
176
+ function ruleScopeCovers(earlier: NormalizedRuleForWarnings, later: NormalizedRuleForWarnings): boolean {
177
+ return setCovers(earlier.providers, later.providers) && later.models.every((entry) => earlier.providers.has(entry.provider) || earlier.modelKeys.has(modelRefKey(entry)));
178
+ }
179
+
180
+ function duplicateRuleWarning(laterRule: FallbackRule, ruleIndex: number, later: NormalizedRuleForWarnings, earlierRule: FallbackRule, earlierIndex: number): RuleWarning {
181
+ return {
182
+ severity: "warning",
183
+ code: "duplicate_rule",
184
+ message: `${ruleLabel(laterRule, ruleIndex)} is shadowed by ${ruleLabel(earlierRule, earlierIndex)} because both rules match the same providers/models and statuses; the earlier rule wins first.`,
185
+ ruleIndex,
186
+ ruleName: laterRule.name,
187
+ shadowedByRuleIndex: earlierIndex,
188
+ shadowedByRuleName: earlierRule.name,
189
+ statuses: sortedNumbers(later.statuses),
190
+ matchProviders: sortedStrings(later.providers),
191
+ matchModels: sortedModels(later.models),
192
+ };
193
+ }
194
+
195
+ function scopeShadowWarning(
196
+ laterRule: FallbackRule,
197
+ ruleIndex: number,
198
+ later: NormalizedRuleForWarnings,
199
+ earlierRule: FallbackRule,
200
+ earlierIndex: number,
201
+ earlier: NormalizedRuleForWarnings,
202
+ ): RuleWarning {
203
+ const providerCoveredModels = later.models.filter((entry) => earlier.providers.has(entry.provider));
204
+ const allModelsProviderCovered = later.providers.size === 0 && later.models.length > 0 && providerCoveredModels.length === later.models.length;
205
+ if (allModelsProviderCovered) return providerModelShadowWarning(laterRule, ruleIndex, later, earlierRule, earlierIndex, providerCoveredModels);
206
+
207
+ return {
208
+ severity: "warning",
209
+ code: "shadowed_rule",
210
+ message: `${ruleLabel(laterRule, ruleIndex)} is shadowed by ${ruleLabel(earlierRule, earlierIndex)} because the earlier rule covers its provider/model scope and statuses; the earlier rule wins first.`,
211
+ ruleIndex,
212
+ ruleName: laterRule.name,
213
+ shadowedByRuleIndex: earlierIndex,
214
+ shadowedByRuleName: earlierRule.name,
215
+ statuses: sortedNumbers(later.statuses),
216
+ matchProviders: sortedStrings(later.providers),
217
+ matchModels: sortedModels(later.models),
218
+ };
219
+ }
220
+
221
+ function providerModelShadowWarning(
222
+ laterRule: FallbackRule,
223
+ ruleIndex: number,
224
+ later: NormalizedRuleForWarnings,
225
+ earlierRule: FallbackRule,
226
+ earlierIndex: number,
227
+ shadowedModels: ModelRef[],
228
+ ): RuleWarning {
229
+ return {
230
+ severity: "warning",
231
+ code: "provider_rule_shadows_model",
232
+ message: `${ruleLabel(laterRule, ruleIndex)} has model match ${formatModelList(shadowedModels)} shadowed by ${ruleLabel(earlierRule, earlierIndex)} for statuses ${formatStatusList(later.statuses)}; the earlier provider-wide rule wins first.`,
233
+ ruleIndex,
234
+ ruleName: laterRule.name,
235
+ shadowedByRuleIndex: earlierIndex,
236
+ shadowedByRuleName: earlierRule.name,
237
+ statuses: sortedNumbers(later.statuses),
238
+ matchProviders: sortedStrings(new Set(shadowedModels.map((entry) => entry.provider))),
239
+ matchModels: sortedModels(shadowedModels),
240
+ };
241
+ }
242
+
243
+ function statusSetCovers(earlier: Set<number>, later: Set<number>): boolean {
244
+ return setCovers(earlier, later);
245
+ }
246
+
247
+ function setsEqual<T>(left: Set<T>, right: Set<T>): boolean {
248
+ return left.size === right.size && setCovers(left, right);
249
+ }
250
+
251
+ function setCovers<T>(earlier: Set<T>, later: Set<T>): boolean {
252
+ for (const value of later) {
253
+ if (!earlier.has(value)) return false;
254
+ }
255
+ return true;
256
+ }
257
+
258
+ function sortedNumbers(values: Set<number>): number[] {
259
+ return [...values].sort((left, right) => left - right);
260
+ }
261
+
262
+ function sortedStrings(values: Set<string>): string[] | undefined {
263
+ const sorted = [...values].sort();
264
+ return sorted.length > 0 ? sorted : undefined;
265
+ }
266
+
267
+ function sortedModels(models: ModelRef[]): ModelRef[] | undefined {
268
+ const sorted = [...new Map(models.map((entry) => [modelRefKey(entry), entry])).values()].sort((left, right) => modelRefKey(left).localeCompare(modelRefKey(right)));
269
+ return sorted.length > 0 ? sorted : undefined;
270
+ }
271
+
272
+ function ruleLabel(rule: FallbackRule, index: number): string {
273
+ return rule.name ? `rules[${index}] (${rule.name})` : `rules[${index}]`;
274
+ }
275
+
276
+ function formatStatusList(statuses: Set<number>): string {
277
+ return sortedNumbers(statuses).join(", ");
278
+ }
279
+
280
+ function formatModelList(models: ModelRef[]): string {
281
+ return models.map(modelRefKey).sort().join(", ");
282
+ }
283
+
103
284
  function readModelRef(value: unknown, path: string): ModelRef {
104
285
  if (!isRecord(value)) throw new Error(`${path} must be an object.`);
105
286
  return {
@@ -129,10 +310,10 @@ function readStatuses(value: unknown, path: string): number[] {
129
310
  });
130
311
  }
131
312
 
132
- function readPositiveInteger(value: unknown, path: string): number {
133
- if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) throw new Error(`${path} must be a positive integer.`);
134
- return value;
135
- }
313
+ function readPositiveInteger(value: unknown, path: string): number {
314
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) throw new Error(`${path} must be a positive integer.`);
315
+ return value;
316
+ }
136
317
 
137
318
  function readNonEmptyString(value: unknown, path: string): string {
138
319
  if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-model-fallback",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Pi extension that switches to a fallback model after provider failures such as 429 rate limits.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -50,12 +50,12 @@
50
50
  "typebox": "*"
51
51
  },
52
52
  "devDependencies": {
53
- "@earendil-works/pi-agent-core": "^0.80.6",
53
+ "@earendil-works/pi-agent-core": "^0.83.0",
54
54
  "@earendil-works/pi-ai": "^0.80.6",
55
- "@earendil-works/pi-coding-agent": "^0.80.6",
56
- "@earendil-works/pi-tui": "^0.80.6",
55
+ "@earendil-works/pi-coding-agent": "^0.83.0",
56
+ "@earendil-works/pi-tui": "^0.83.0",
57
57
  "typebox": "latest",
58
58
  "@types/node": "^26.0.0",
59
- "typescript": "^6.0.3"
59
+ "typescript": "^7.0.2"
60
60
  }
61
61
  }