pi-model-fallback 0.1.0 → 0.3.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.
- package/CHANGELOG.md +56 -33
- package/README.md +164 -163
- package/extensions/index.ts +199 -27
- package/lib/config.ts +144 -136
- package/lib/state.ts +118 -0
- package/lib/storage.ts +37 -36
- package/package.json +59 -59
package/lib/state.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import type { ModelRef } from "./config.js";
|
|
4
|
+
|
|
5
|
+
export const STATE_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
export interface FallbackStateEntry {
|
|
8
|
+
source: ModelRef;
|
|
9
|
+
fallback: ModelRef;
|
|
10
|
+
status: number;
|
|
11
|
+
until: string;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
ruleName?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FallbackState {
|
|
17
|
+
version: 1;
|
|
18
|
+
entries: FallbackStateEntry[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function emptyState(): FallbackState {
|
|
22
|
+
return { version: STATE_VERSION, entries: [] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function readState(path: string, now = new Date()): Promise<FallbackState> {
|
|
26
|
+
const json = await readJsonIfExists(path);
|
|
27
|
+
const state = json === undefined ? emptyState() : validateStateShape(json);
|
|
28
|
+
return pruneExpiredState(state, now);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function writeState(path: string, state: FallbackState): Promise<void> {
|
|
32
|
+
await mkdir(dirname(path), { recursive: true });
|
|
33
|
+
await writeFile(path, `${JSON.stringify(validateStateShape(state), null, 2)}\n`, "utf8");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function upsertStateEntry(state: FallbackState, entry: FallbackStateEntry): FallbackState {
|
|
37
|
+
const sourceKey = modelRefKey(entry.source);
|
|
38
|
+
return {
|
|
39
|
+
version: STATE_VERSION,
|
|
40
|
+
entries: [entry, ...state.entries.filter((item) => modelRefKey(item.source) !== sourceKey)],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function modelRefKey(ref: ModelRef): string {
|
|
45
|
+
return `${ref.provider}/${ref.model}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function findActiveStateEntry(state: FallbackState, source: ModelRef, now = new Date()): FallbackStateEntry | undefined {
|
|
49
|
+
const sourceKey = modelRefKey(source);
|
|
50
|
+
return state.entries.find((entry) => modelRefKey(entry.source) === sourceKey && Date.parse(entry.until) > now.getTime());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function pruneExpiredState(state: FallbackState, now = new Date()): FallbackState {
|
|
54
|
+
const nowMs = now.getTime();
|
|
55
|
+
return {
|
|
56
|
+
version: STATE_VERSION,
|
|
57
|
+
entries: state.entries.filter((entry) => Date.parse(entry.until) > nowMs),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function validateStateShape(value: unknown): FallbackState {
|
|
62
|
+
if (!isRecord(value)) throw new Error("State must be an object.");
|
|
63
|
+
if (value.version !== STATE_VERSION) throw new Error(`Unsupported state version: ${String(value.version)}.`);
|
|
64
|
+
if (!Array.isArray(value.entries)) throw new Error("entries must be an array.");
|
|
65
|
+
return {
|
|
66
|
+
version: STATE_VERSION,
|
|
67
|
+
entries: value.entries.map((entry, index) => validateEntry(entry, index)),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function validateEntry(value: unknown, index: number): FallbackStateEntry {
|
|
72
|
+
if (!isRecord(value)) throw new Error(`entries[${index}] must be an object.`);
|
|
73
|
+
const until = readNonEmptyString(value.until, `entries[${index}].until`);
|
|
74
|
+
const createdAt = readNonEmptyString(value.createdAt, `entries[${index}].createdAt`);
|
|
75
|
+
if (Number.isNaN(Date.parse(until))) throw new Error(`entries[${index}].until must be an ISO date.`);
|
|
76
|
+
if (Number.isNaN(Date.parse(createdAt))) throw new Error(`entries[${index}].createdAt must be an ISO date.`);
|
|
77
|
+
const status = value.status;
|
|
78
|
+
if (typeof status !== "number" || !Number.isInteger(status) || status < 100 || status > 599) throw new Error(`entries[${index}].status must be an HTTP status code.`);
|
|
79
|
+
const entry: FallbackStateEntry = {
|
|
80
|
+
source: readModelRef(value.source, `entries[${index}].source`),
|
|
81
|
+
fallback: readModelRef(value.fallback, `entries[${index}].fallback`),
|
|
82
|
+
status,
|
|
83
|
+
until,
|
|
84
|
+
createdAt,
|
|
85
|
+
};
|
|
86
|
+
if (typeof value.ruleName === "string" && value.ruleName.trim() !== "") entry.ruleName = value.ruleName.trim();
|
|
87
|
+
return entry;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readModelRef(value: unknown, path: string): ModelRef {
|
|
91
|
+
if (!isRecord(value)) throw new Error(`${path} must be an object.`);
|
|
92
|
+
return {
|
|
93
|
+
provider: readNonEmptyString(value.provider, `${path}.provider`),
|
|
94
|
+
model: readNonEmptyString(value.model, `${path}.model`),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readNonEmptyString(value: unknown, path: string): string {
|
|
99
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`);
|
|
100
|
+
return value.trim();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function readJsonIfExists(path: string): Promise<unknown | undefined> {
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (isNodeError(error) && error.code === "ENOENT") return undefined;
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
113
|
+
return error instanceof Error && "code" in error;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
117
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
118
|
+
}
|
package/lib/storage.ts
CHANGED
|
@@ -1,36 +1,37 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
|
-
import { defaultConfig, validateConfigShape, type ModelFallbackConfig } from "./config.js";
|
|
4
|
-
|
|
5
|
-
export interface ModelFallbackPaths {
|
|
6
|
-
dir: string;
|
|
7
|
-
config: string;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
await
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { defaultConfig, validateConfigShape, type ModelFallbackConfig } from "./config.js";
|
|
4
|
+
|
|
5
|
+
export interface ModelFallbackPaths {
|
|
6
|
+
dir: string;
|
|
7
|
+
config: string;
|
|
8
|
+
state: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function modelFallbackPaths(agentDir: string): ModelFallbackPaths {
|
|
12
|
+
const dir = join(agentDir, "model-fallback");
|
|
13
|
+
return { dir, config: join(dir, "config.json"), state: join(dir, "state.json") };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function readConfig(path: string): Promise<ModelFallbackConfig> {
|
|
17
|
+
const json = await readJsonIfExists(path);
|
|
18
|
+
return json === undefined ? defaultConfig() : validateConfigShape(json);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function writeConfig(path: string, config: ModelFallbackConfig): Promise<void> {
|
|
22
|
+
await mkdir(dirname(path), { recursive: true });
|
|
23
|
+
await writeFile(path, `${JSON.stringify(validateConfigShape(config), null, 2)}\n`, "utf8");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function readJsonIfExists(path: string): Promise<unknown | undefined> {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (isNodeError(error) && error.code === "ENOENT") return undefined;
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
36
|
+
return error instanceof Error && "code" in error;
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,59 +1,59 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-model-fallback",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pi extension that switches to a fallback model after provider failures such as 429 rate limits.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"author": "Keisu",
|
|
8
|
-
"keywords": [
|
|
9
|
-
"pi-package",
|
|
10
|
-
"pi",
|
|
11
|
-
"model-fallback",
|
|
12
|
-
"fallback",
|
|
13
|
-
"typescript"
|
|
14
|
-
],
|
|
15
|
-
"repository": {
|
|
16
|
-
"type": "git",
|
|
17
|
-
"url": "git+https://github.com/eiei114/pi-model-fallback.git"
|
|
18
|
-
},
|
|
19
|
-
"bugs": {
|
|
20
|
-
"url": "https://github.com/eiei114/pi-model-fallback/issues"
|
|
21
|
-
},
|
|
22
|
-
"homepage": "https://github.com/eiei114/pi-model-fallback#readme",
|
|
23
|
-
"files": [
|
|
24
|
-
"extensions/",
|
|
25
|
-
"lib/",
|
|
26
|
-
"docs/",
|
|
27
|
-
"README.md",
|
|
28
|
-
"LICENSE",
|
|
29
|
-
"CHANGELOG.md"
|
|
30
|
-
],
|
|
31
|
-
"scripts": {
|
|
32
|
-
"typecheck": "tsc --noEmit",
|
|
33
|
-
"test": "node --test tests/*.test.mjs",
|
|
34
|
-
"ci": "npm run typecheck && npm test && npm run pack:check",
|
|
35
|
-
"pack:check": "npm pack --dry-run"
|
|
36
|
-
},
|
|
37
|
-
"pi": {
|
|
38
|
-
"extensions": [
|
|
39
|
-
"./extensions"
|
|
40
|
-
]
|
|
41
|
-
},
|
|
42
|
-
"publishConfig": {
|
|
43
|
-
"access": "public"
|
|
44
|
-
},
|
|
45
|
-
"peerDependencies": {
|
|
46
|
-
"@earendil-works/pi-ai": "*",
|
|
47
|
-
"@earendil-works/pi-coding-agent": "*",
|
|
48
|
-
"@earendil-works/pi-tui": "*",
|
|
49
|
-
"typebox": "*"
|
|
50
|
-
},
|
|
51
|
-
"devDependencies": {
|
|
52
|
-
"@earendil-works/pi-ai": "latest",
|
|
53
|
-
"@earendil-works/pi-coding-agent": "latest",
|
|
54
|
-
"@earendil-works/pi-tui": "latest",
|
|
55
|
-
"typebox": "latest",
|
|
56
|
-
"@types/node": "^26.0.0",
|
|
57
|
-
"typescript": "^6.0.3"
|
|
58
|
-
}
|
|
59
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-model-fallback",
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "Pi extension that switches to a fallback model after provider failures such as 429 rate limits.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Keisu",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi",
|
|
11
|
+
"model-fallback",
|
|
12
|
+
"fallback",
|
|
13
|
+
"typescript"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/eiei114/pi-model-fallback.git"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/eiei114/pi-model-fallback/issues"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/eiei114/pi-model-fallback#readme",
|
|
23
|
+
"files": [
|
|
24
|
+
"extensions/",
|
|
25
|
+
"lib/",
|
|
26
|
+
"docs/",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"CHANGELOG.md"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"test": "node --test tests/*.test.mjs",
|
|
34
|
+
"ci": "npm run typecheck && npm test && npm run pack:check",
|
|
35
|
+
"pack:check": "npm pack --dry-run"
|
|
36
|
+
},
|
|
37
|
+
"pi": {
|
|
38
|
+
"extensions": [
|
|
39
|
+
"./extensions"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@earendil-works/pi-ai": "*",
|
|
47
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
48
|
+
"@earendil-works/pi-tui": "*",
|
|
49
|
+
"typebox": "*"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@earendil-works/pi-ai": "latest",
|
|
53
|
+
"@earendil-works/pi-coding-agent": "latest",
|
|
54
|
+
"@earendil-works/pi-tui": "latest",
|
|
55
|
+
"typebox": "latest",
|
|
56
|
+
"@types/node": "^26.0.0",
|
|
57
|
+
"typescript": "^6.0.3"
|
|
58
|
+
}
|
|
59
|
+
}
|