pi-model-fallback 0.3.3 → 0.3.4

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
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ### Changed
6
+
7
+ - Bump package version to `0.3.4` for the next patch release.
8
+
9
+ - Declare `@earendil-works/pi-agent-core` as a peerDependency to align with pi-extension-template.
5
10
  - Add Buy Me a Coffee sponsor button to README and native GitHub funding link via `.github/FUNDING.yml`.
6
11
 
7
12
  ## [0.3.3] - 2026-07-04
package/README.md CHANGED
@@ -90,6 +90,15 @@ Rule fields:
90
90
  - `cooldownMs`: optional persistent fallback window
91
91
  - `fallback`: target model Pi should switch to
92
92
 
93
+ ### Cooldowns
94
+
95
+ When a rule does not set `cooldownMs`, the extension uses these defaults:
96
+
97
+ - `429` → 72 hours
98
+ - `5xx` → 10 minutes
99
+
100
+ When the provider response includes `Retry-After` or `x-ratelimit-reset*` headers, those values override `cooldownMs` and the defaults for the persisted fallback window.
101
+
93
102
  ## State and paths
94
103
 
95
104
  The extension stores:
@@ -103,7 +112,7 @@ If the package is installed project-locally and the current project references i
103
112
 
104
113
  - Successful responses do nothing.
105
114
  - Matching failures from `after_provider_response` can trigger fallback immediately.
106
- - Assistant error messages parsed at `turn_end` can also persist fallback state for SDK/provider failures that do not emit the normal response hook.
115
+ - Assistant error messages parsed at `turn_end` can also persist fallback state for SDK/provider failures that do not emit the normal response hook. Status extraction looks for HTTP-style tokens (for example `status 429`, `HTTP 503`, or `rate limit`) rather than any bare 3-digit number in the message.
107
116
  - The failed request is not automatically replayed.
108
117
 
109
118
  ## Development
package/docs/usage.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- pi install git:github.com/eiei114/pi-model-fallback
6
+ pi install npm:pi-model-fallback
7
7
  ```
8
8
 
9
9
  Default behavior: when a `zai/*` model receives `429`, `500`, `502`, `503`, or `504`, Pi switches to `deepseek/deepseek-v4-flash` for the next prompt.
@@ -15,4 +15,11 @@ Commands:
15
15
  /model-fallback:reset
16
16
  ```
17
17
 
18
- The failed provider response is not automatically replayed in v0.1.0.
18
+ Cooldown defaults when a rule omits `cooldownMs`:
19
+
20
+ - `429` → 72 hours
21
+ - `5xx` → 10 minutes
22
+
23
+ `Retry-After` and `x-ratelimit-reset*` response headers override `cooldownMs` and those defaults.
24
+
25
+ The failed request is not automatically replayed.
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
2
3
  import { dirname, join, resolve } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -12,7 +13,8 @@ import {
12
13
  type ModelFallbackConfig,
13
14
  type ModelRef,
14
15
  } from "../lib/config.js";
15
- import { findActiveStateEntry, readState, upsertStateEntry, writeState, type FallbackState } from "../lib/state.js";
16
+ import { parseStatusFromErrorMessage } from "../lib/error-status.js";
17
+ import { emptyState, findActiveStateEntry, pruneExpiredState, readState, upsertStateEntry, validateStateShape, writeState, type FallbackState } from "../lib/state.js";
16
18
  import { modelFallbackPaths, readConfig, writeConfig, type ModelFallbackPaths } from "../lib/storage.js";
17
19
 
18
20
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -22,6 +24,7 @@ const DEFAULT_5XX_COOLDOWN_MS = 10 * 60 * 1000;
22
24
 
23
25
  export default function modelFallback(pi: ExtensionAPI) {
24
26
  let paths: ModelFallbackPaths = modelFallbackPaths(getAgentDir());
27
+ let pathsResolvedForCwd: string | undefined;
25
28
  let config: ModelFallbackConfig | undefined;
26
29
  let state: FallbackState | undefined;
27
30
  let originalModelKey: string | undefined;
@@ -29,6 +32,8 @@ export default function modelFallback(pi: ExtensionAPI) {
29
32
  let lastFallbackReason: string | undefined;
30
33
 
31
34
  function syncPaths(ctx: ExtensionContext): void {
35
+ if (pathsResolvedForCwd === ctx.cwd) return;
36
+ pathsResolvedForCwd = ctx.cwd;
32
37
  paths = shouldUseProjectLocalState(ctx) ? modelFallbackPaths(join(ctx.cwd, ".pi")) : modelFallbackPaths(getAgentDir());
33
38
  }
34
39
 
@@ -46,8 +51,18 @@ export default function modelFallback(pi: ExtensionAPI) {
46
51
  async function loadState(ctx: ExtensionContext): Promise<FallbackState | undefined> {
47
52
  try {
48
53
  syncPaths(ctx);
49
- state = await readState(paths.state);
50
- await writeState(paths.state, state);
54
+ const statePath = paths.state;
55
+ if (!existsSync(statePath)) {
56
+ state = emptyState();
57
+ return state;
58
+ }
59
+
60
+ const before = validateStateShape(JSON.parse(await readFile(statePath, "utf8")));
61
+ const after = pruneExpiredState(before);
62
+ state = after;
63
+ if (JSON.stringify(before) !== JSON.stringify(after)) {
64
+ await writeState(statePath, after);
65
+ }
51
66
  return state;
52
67
  } catch (error) {
53
68
  ctx.ui.notify(`Model fallback state error: ${errorMessage(error)}`, "warning");
@@ -343,7 +358,7 @@ function parseRetryAfter(value: string | undefined): number | undefined {
343
358
  return undefined;
344
359
  }
345
360
 
346
- function parseResetHeader(value: string | undefined): number | undefined {
361
+ function parseResetHeader(value: string | undefined): number | undefined {
347
362
  if (!value) return undefined;
348
363
  const trimmed = value.trim();
349
364
  const numeric = Number(trimmed);
@@ -354,17 +369,10 @@ function parseResetHeader(value: string | undefined): number | undefined {
354
369
  }
355
370
  const dateMs = Date.parse(trimmed);
356
371
  if (!Number.isNaN(dateMs) && dateMs > Date.now()) return dateMs - Date.now();
357
- return undefined;
358
- }
359
-
360
- function parseStatusFromErrorMessage(message: string): number | undefined {
361
- const match = message.match(/\b([1-5][0-9]{2})\b/);
362
- if (!match) return undefined;
363
- const status = Number(match[1]);
364
- return Number.isInteger(status) ? status : undefined;
365
- }
366
-
367
- function modelToRef(model: { provider: string; id: string }): ModelRef {
372
+ return undefined;
373
+ }
374
+
375
+ function modelToRef(model: { provider: string; id: string }): ModelRef {
368
376
  return { provider: model.provider, model: model.id };
369
377
  }
370
378
 
@@ -0,0 +1,18 @@
1
+ export function parseStatusFromErrorMessage(message: string): number | undefined {
2
+ const patterns: RegExp[] = [
3
+ /\b(?:status|code|http)\s*[:\s]?\s*([1-5][0-9]{2})\b/i,
4
+ /\bHTTP\/\d(?:\.\d)?\s+([1-5][0-9]{2})\b/,
5
+ /\b(?:rate[\s-]?limit(?:ed)?|too many requests)[^0-9]{0,40}([45][0-9]{2})\b/i,
6
+ /\b([45][0-9]{2})\s+(?:error|too many requests|service unavailable|bad gateway|gateway timeout|internal server error)\b/i,
7
+ /\berror\s+([45][0-9]{2})\b/i,
8
+ ];
9
+
10
+ for (const pattern of patterns) {
11
+ const match = message.match(pattern);
12
+ if (!match) continue;
13
+ const status = Number(match[1]);
14
+ if (Number.isInteger(status) && status >= 100 && status <= 599) return status;
15
+ }
16
+
17
+ return undefined;
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-model-fallback",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
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",
@@ -43,15 +43,17 @@
43
43
  "access": "public"
44
44
  },
45
45
  "peerDependencies": {
46
+ "@earendil-works/pi-agent-core": "*",
46
47
  "@earendil-works/pi-ai": "*",
47
48
  "@earendil-works/pi-coding-agent": "*",
48
49
  "@earendil-works/pi-tui": "*",
49
50
  "typebox": "*"
50
51
  },
51
52
  "devDependencies": {
52
- "@earendil-works/pi-ai": "latest",
53
- "@earendil-works/pi-coding-agent": "latest",
54
- "@earendil-works/pi-tui": "latest",
53
+ "@earendil-works/pi-agent-core": "^0.80.6",
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
57
  "typebox": "latest",
56
58
  "@types/node": "^26.0.0",
57
59
  "typescript": "^6.0.3"