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 +57 -52
- package/README.md +5 -1
- package/docs/usage.md +2 -0
- package/extensions/index.ts +58 -15
- package/lib/config.ts +185 -4
- package/package.json +5 -5
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.
|
|
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
|
+
[](https://discord.gg/4945dXZVW5)
|
|
4
|
+
|
|
3
5
|
[](https://github.com/eiei114/pi-model-fallback/actions/workflows/ci.yml)
|
|
4
6
|
[](https://github.com/eiei114/pi-model-fallback/actions/workflows/publish.yml)
|
|
5
7
|
[](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
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
56
|
-
"@earendil-works/pi-tui": "^0.
|
|
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": "^
|
|
59
|
+
"typescript": "^7.0.2"
|
|
60
60
|
}
|
|
61
61
|
}
|