@oisin-ee/pi-tiers 0.0.0-stage → 0.9.0
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/README.md +64 -2
- package/package.json +33 -4
- package/src/branch.ts +75 -0
- package/src/config.ts +48 -0
- package/src/health.ts +92 -0
- package/src/index.ts +70 -0
- package/src/lock.ts +53 -0
- package/src/policy.ts +85 -0
- package/src/ports.ts +29 -0
- package/src/quota.ts +81 -0
- package/src/recovery.ts +114 -0
- package/src/responses.ts +44 -0
- package/src/router.ts +176 -0
- package/src/source.ts +258 -0
package/README.md
CHANGED
|
@@ -1,3 +1,65 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @oisin-ee/pi-tiers
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Tier virtual models keep each branch on its chosen physical model until a failure requires recovery.
|
|
4
|
+
The selected thinking level passes through to the physical model. Pi clamps unsupported levels.
|
|
5
|
+
A model switch loses prompt caches and thinking signatures.
|
|
6
|
+
|
|
7
|
+
## Config
|
|
8
|
+
|
|
9
|
+
Put `tiers.json` in the Pi agent directory (`~/.pi/agent` by default):
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"sol": ["cliproxy/gpt-5.6", "cliproxy/claude-opus-4-6"],
|
|
14
|
+
"luna": ["cliproxy/minimax-m3", "cliproxy/gpt-5.6"]
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Select `tier/sol` or `tier/luna`. Each target must identify a configured physical model.
|
|
19
|
+
An absent file registers no tiers. Invalid config stops registration. Reload Pi after config changes.
|
|
20
|
+
This package does not load `model-alias.json` or register `alias/*` models.
|
|
21
|
+
|
|
22
|
+
## Recovery
|
|
23
|
+
|
|
24
|
+
Gateway failures open a provider-wide circuit. Recovery waits, then retries the same target.
|
|
25
|
+
The delay starts at two seconds and doubles to one minute. Gateway failures never cool individual models or pools.
|
|
26
|
+
|
|
27
|
+
Quota failures exclude a pool. Model IDs that start with `claude-`, `gpt-`, or `minimax-` use the corresponding shared pool.
|
|
28
|
+
Other models use their provider ID as the pool.
|
|
29
|
+
The snapshot reset time controls the exclusion when the pool is exhausted.
|
|
30
|
+
Without that reset, cooldown starts at five minutes and doubles to one hour.
|
|
31
|
+
|
|
32
|
+
Pi handles native transient retries first. After native retries end, boundary recovery selects the next target.
|
|
33
|
+
Unknown models and request, authentication, or configuration failures stop without failover. Abort never requests recovery.
|
|
34
|
+
Each user turn permits two recoveries. Native transient retries retain Pi's separate retry budget.
|
|
35
|
+
|
|
36
|
+
Boundary recovery removes only the failed assistant entry from model context.
|
|
37
|
+
A state-only custom entry records the next route. Recovery never creates user messages or rewrites their text.
|
|
38
|
+
The chosen target, attempted targets, and remaining budget follow the session branch.
|
|
39
|
+
|
|
40
|
+
## Quota and shared state
|
|
41
|
+
|
|
42
|
+
Before routing, the extension runs `dogsled quota --json --cached` with a two-second timeout.
|
|
43
|
+
The result stays in memory for one minute. Missing Dogsled or an invalid snapshot disables quota filtering.
|
|
44
|
+
The command never requests a CLIProxy refresh.
|
|
45
|
+
|
|
46
|
+
A pool is exhausted only when every account has a full window with a future reset.
|
|
47
|
+
For multiple blocked windows, an account becomes available after its last reset.
|
|
48
|
+
The first available account ends the pool exclusion.
|
|
49
|
+
|
|
50
|
+
The shared store is `~/.pi/agent/state/pi-tiers/state.json`.
|
|
51
|
+
An exclusive `lock/` directory protects each read-modify-write transaction. Its `owner.json` records the process ID and creation time.
|
|
52
|
+
A temporary state file and atomic rename protect readers. Lock acquisition stops after two seconds.
|
|
53
|
+
A dead owner or a lock older than 30 seconds permits takeover through atomic directory rename.
|
|
54
|
+
An ownerless directory also expires after 30 seconds. Retired generations remain for 60 seconds to exclude competing takeovers.
|
|
55
|
+
Invalid state and filesystem failures stop routing rather than discard shared health.
|
|
56
|
+
|
|
57
|
+
Pi 1.3.0 response events contain no model or request identity.
|
|
58
|
+
HTTP status applies only when an error includes the response's `x-request-id` or `request-id` value.
|
|
59
|
+
Otherwise, the classifier uses error text. Unrelated response status never affects routing.
|
|
60
|
+
|
|
61
|
+
## Development
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
proto run moon -- run pi-tiers:lint pi-tiers:test
|
|
65
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oisin-ee/pi-tiers",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Sticky virtual model tiers with quota-aware recovery for pi.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-extension",
|
|
7
|
+
"pi-package",
|
|
8
|
+
"tiers"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/oisin-ee/oisinpi.git",
|
|
14
|
+
"directory": "packages/pi-tiers"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src/",
|
|
18
|
+
"!src/**/*.test.ts",
|
|
19
|
+
"!src/fixture.ts"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@earendil-works/pi-ai": "catalog:",
|
|
24
|
+
"@earendil-works/pi-coding-agent": "catalog:"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@earendil-works/pi-ai": "*",
|
|
28
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
29
|
+
},
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./src/index.ts"
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/branch.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { State } from "./policy.ts";
|
|
3
|
+
import { VIRTUAL_MODEL_STATE_ENTRY } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
const ZERO = 0,
|
|
6
|
+
RECOVERY = "pi-tiers.recovery",
|
|
7
|
+
/**
|
|
8
|
+
* Validates branch data before policy uses it.
|
|
9
|
+
* @param {unknown} value Stored state.
|
|
10
|
+
* @returns {boolean} Valid state.
|
|
11
|
+
*/
|
|
12
|
+
isState = (value: unknown): value is State =>
|
|
13
|
+
typeof value === "object" &&
|
|
14
|
+
value !== null &&
|
|
15
|
+
"chosen" in value &&
|
|
16
|
+
typeof value.chosen === "string" &&
|
|
17
|
+
"budget" in value &&
|
|
18
|
+
typeof value.budget === "number" &&
|
|
19
|
+
Number.isSafeInteger(value.budget) &&
|
|
20
|
+
value.budget >= ZERO &&
|
|
21
|
+
"attempted" in value &&
|
|
22
|
+
Array.isArray(value.attempted) &&
|
|
23
|
+
value.attempted.every((item: unknown) => typeof item === "string"),
|
|
24
|
+
/**
|
|
25
|
+
* Reads state for one tier, including an unconsumed recovery marker.
|
|
26
|
+
* A subsequent Pi state entry consumes the marker without editing history.
|
|
27
|
+
* @param {readonly SessionEntry[]} branch Active branch.
|
|
28
|
+
* @param {string} tier Virtual model ID.
|
|
29
|
+
* @returns {State | undefined} Latest branch-owned state.
|
|
30
|
+
*/
|
|
31
|
+
branchState = (branch: readonly SessionEntry[], tier: string): State | undefined => {
|
|
32
|
+
for (const entry of branch.toReversed()) {
|
|
33
|
+
if (
|
|
34
|
+
entry.type === "custom" &&
|
|
35
|
+
(entry.customType === VIRTUAL_MODEL_STATE_ENTRY || entry.customType === RECOVERY)
|
|
36
|
+
) {
|
|
37
|
+
const data: unknown = entry.data;
|
|
38
|
+
if (
|
|
39
|
+
typeof data === "object" &&
|
|
40
|
+
data !== null &&
|
|
41
|
+
"provider" in data &&
|
|
42
|
+
data.provider === "tier" &&
|
|
43
|
+
"modelId" in data &&
|
|
44
|
+
data.modelId === tier &&
|
|
45
|
+
"state" in data &&
|
|
46
|
+
isState(data.state)
|
|
47
|
+
) {
|
|
48
|
+
return data.state;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
},
|
|
54
|
+
/**
|
|
55
|
+
* Detects a real user entry after the latest route or recovery marker.
|
|
56
|
+
* Pi can label recovery as user after the failed assistant is omitted.
|
|
57
|
+
* @param {readonly SessionEntry[]} branch Active branch.
|
|
58
|
+
* @returns {boolean} Whether a fresh user turn started.
|
|
59
|
+
*/
|
|
60
|
+
newUserTurn = (branch: readonly SessionEntry[]): boolean => {
|
|
61
|
+
for (const entry of branch.toReversed()) {
|
|
62
|
+
if (entry.type === "message" && entry.message.role === "user") {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
if (
|
|
66
|
+
entry.type === "custom" &&
|
|
67
|
+
(entry.customType === RECOVERY || entry.customType === VIRTUAL_MODEL_STATE_ENTRY)
|
|
68
|
+
) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export { branchState, newUserTurn, RECOVERY };
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { target } from "./policy.ts";
|
|
2
|
+
|
|
3
|
+
/** Validated tier names and ordered physical targets. */
|
|
4
|
+
type Tiers = ReadonlyMap<string, readonly string[]>;
|
|
5
|
+
|
|
6
|
+
const ZERO = 0,
|
|
7
|
+
/**
|
|
8
|
+
* Validates a tier's ordered model references.
|
|
9
|
+
* @param {string} name Tier name.
|
|
10
|
+
* @param {unknown} models External list.
|
|
11
|
+
* @returns {readonly string[]} Unique physical targets.
|
|
12
|
+
*/
|
|
13
|
+
parseTargets = (name: string, models: unknown): readonly string[] => {
|
|
14
|
+
if (
|
|
15
|
+
name.length === ZERO ||
|
|
16
|
+
!Array.isArray(models) ||
|
|
17
|
+
models.length === ZERO ||
|
|
18
|
+
!models.every((model: unknown): model is string => typeof model === "string")
|
|
19
|
+
) {
|
|
20
|
+
throw new Error(`Invalid tier: ${name}`);
|
|
21
|
+
}
|
|
22
|
+
for (const model of models) {
|
|
23
|
+
target(model);
|
|
24
|
+
}
|
|
25
|
+
return [...new Set(models)];
|
|
26
|
+
},
|
|
27
|
+
/**
|
|
28
|
+
* Validates user config before registration.
|
|
29
|
+
* @param {string | undefined} source User JSON.
|
|
30
|
+
* @returns {Tiers} Tier definitions.
|
|
31
|
+
*/
|
|
32
|
+
parseConfig = (source?: string): Tiers => {
|
|
33
|
+
if (source === undefined) {
|
|
34
|
+
return new Map();
|
|
35
|
+
}
|
|
36
|
+
const value: unknown = JSON.parse(source);
|
|
37
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
38
|
+
throw new Error("tiers.json must contain a tier-to-model map.");
|
|
39
|
+
}
|
|
40
|
+
const tiers = new Map<string, readonly string[]>();
|
|
41
|
+
for (const [name, models] of Object.entries(value)) {
|
|
42
|
+
tiers.set(name, parseTargets(name, models));
|
|
43
|
+
}
|
|
44
|
+
return tiers;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export { parseConfig };
|
|
48
|
+
export type { Tiers };
|
package/src/health.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Backoff record shared by all sessions on one host. */
|
|
2
|
+
interface Backoff {
|
|
3
|
+
readonly failures: number;
|
|
4
|
+
readonly until: number;
|
|
5
|
+
}
|
|
6
|
+
/** Provider circuits and pool cooldowns have separate namespaces. */
|
|
7
|
+
interface Health {
|
|
8
|
+
readonly circuits: Readonly<Record<string, Backoff>>;
|
|
9
|
+
readonly pools: Readonly<Record<string, Backoff>>;
|
|
10
|
+
}
|
|
11
|
+
/** One atomic update to shared health. */
|
|
12
|
+
interface Change {
|
|
13
|
+
readonly kind: "gateway" | "quota";
|
|
14
|
+
readonly key: string;
|
|
15
|
+
readonly now: number;
|
|
16
|
+
readonly reset: number | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ZERO = 0,
|
|
20
|
+
ONE = 1,
|
|
21
|
+
TWO = 2,
|
|
22
|
+
QUOTA_DELAY = 300_000,
|
|
23
|
+
QUOTA_MAX = 3_600_000,
|
|
24
|
+
GATEWAY_DELAY = 2000,
|
|
25
|
+
GATEWAY_MAX = 60_000,
|
|
26
|
+
/**
|
|
27
|
+
* Creates an empty health store.
|
|
28
|
+
* @returns {Health} Healthy endpoints and pools.
|
|
29
|
+
*/
|
|
30
|
+
emptyHealth = (): Health => ({ circuits: {}, pools: {} }),
|
|
31
|
+
/**
|
|
32
|
+
* Computes exponential backoff while preserving distinct failure domains.
|
|
33
|
+
* @param {Health} health Current store.
|
|
34
|
+
* @param {Change} change Failure observation.
|
|
35
|
+
* @returns {Health} Updated store.
|
|
36
|
+
*/
|
|
37
|
+
updateHealth = (health: Health, change: Change): Health => {
|
|
38
|
+
const namespace = change.kind === "gateway" ? "circuits" : "pools",
|
|
39
|
+
previous = health[namespace][change.key],
|
|
40
|
+
base = change.kind === "gateway" ? GATEWAY_DELAY : QUOTA_DELAY,
|
|
41
|
+
maximum = change.kind === "gateway" ? GATEWAY_MAX : QUOTA_MAX,
|
|
42
|
+
failures = (previous?.failures ?? ZERO) + ONE,
|
|
43
|
+
delay = Math.min(maximum, base * TWO ** (failures - ONE)),
|
|
44
|
+
record = { failures, until: change.reset ?? change.now + delay };
|
|
45
|
+
return Object.assign({}, health, {
|
|
46
|
+
[namespace]: Object.assign({}, health[namespace], { [change.key]: record }),
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
/**
|
|
50
|
+
* Validates persisted backoff records.
|
|
51
|
+
* @param {unknown} value External map.
|
|
52
|
+
* @returns {boolean} Whether all records are valid.
|
|
53
|
+
*/
|
|
54
|
+
isBackoffs = (value: unknown): value is Record<string, Backoff> =>
|
|
55
|
+
typeof value === "object" &&
|
|
56
|
+
value !== null &&
|
|
57
|
+
!Array.isArray(value) &&
|
|
58
|
+
Object.values(value).every(
|
|
59
|
+
(entry: unknown) =>
|
|
60
|
+
typeof entry === "object" &&
|
|
61
|
+
entry !== null &&
|
|
62
|
+
"failures" in entry &&
|
|
63
|
+
typeof entry.failures === "number" &&
|
|
64
|
+
Number.isSafeInteger(entry.failures) &&
|
|
65
|
+
entry.failures >= ZERO &&
|
|
66
|
+
"until" in entry &&
|
|
67
|
+
typeof entry.until === "number" &&
|
|
68
|
+
Number.isSafeInteger(entry.until) &&
|
|
69
|
+
entry.until >= ZERO,
|
|
70
|
+
),
|
|
71
|
+
/**
|
|
72
|
+
* Rejects corrupt stores rather than silently discarding cooldowns.
|
|
73
|
+
* @param {string} source Persisted JSON.
|
|
74
|
+
* @returns {Health} Validated health state.
|
|
75
|
+
*/
|
|
76
|
+
parseHealth = (source: string): Health => {
|
|
77
|
+
const value: unknown = JSON.parse(source);
|
|
78
|
+
if (
|
|
79
|
+
typeof value !== "object" ||
|
|
80
|
+
value === null ||
|
|
81
|
+
!("circuits" in value) ||
|
|
82
|
+
!("pools" in value) ||
|
|
83
|
+
!isBackoffs(value.circuits) ||
|
|
84
|
+
!isBackoffs(value.pools)
|
|
85
|
+
) {
|
|
86
|
+
throw new Error("Invalid pi-tiers health state.");
|
|
87
|
+
}
|
|
88
|
+
return { circuits: value.circuits, pools: value.pools };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export { emptyHealth, parseHealth, updateHealth };
|
|
92
|
+
export type { Change, Health };
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AfterProviderResponseEvent,
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ModelRoute,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Store, readConfig, wait } from "./source.ts";
|
|
7
|
+
import type { Health } from "./health.ts";
|
|
8
|
+
import { Recovery } from "./recovery.ts";
|
|
9
|
+
import type { Request } from "./ports.ts";
|
|
10
|
+
import { Router } from "./router.ts";
|
|
11
|
+
import type { State } from "./policy.ts";
|
|
12
|
+
import { parseConfig } from "./config.ts";
|
|
13
|
+
|
|
14
|
+
const QUOTA_TIMEOUT = 2000,
|
|
15
|
+
SUCCESS = 0,
|
|
16
|
+
/**
|
|
17
|
+
* Registers user-defined tiers and the final-boundary recovery adapter.
|
|
18
|
+
* @param {ExtensionAPI} pi Pi extension authority.
|
|
19
|
+
* @returns {Promise<void>} Registration completion.
|
|
20
|
+
*/
|
|
21
|
+
extension = async (pi: ExtensionAPI): Promise<void> => {
|
|
22
|
+
const tiers = parseConfig(await readConfig()),
|
|
23
|
+
store = new Store(),
|
|
24
|
+
router = new Router({
|
|
25
|
+
change: async (change): Promise<void> => store.change(change),
|
|
26
|
+
now: Date.now,
|
|
27
|
+
quota: async (): Promise<string> => {
|
|
28
|
+
try {
|
|
29
|
+
const result = await pi.exec("dogsled", ["quota", "--json", "--cached"], {
|
|
30
|
+
timeout: QUOTA_TIMEOUT,
|
|
31
|
+
});
|
|
32
|
+
return result.code === SUCCESS ? result.stdout : "";
|
|
33
|
+
} catch {
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
read: async (): Promise<Health> => store.read(),
|
|
38
|
+
wait,
|
|
39
|
+
}),
|
|
40
|
+
recovery = new Recovery(router, tiers);
|
|
41
|
+
for (const [id, targets] of tiers) {
|
|
42
|
+
pi.registerVirtualModel<State>({
|
|
43
|
+
id,
|
|
44
|
+
name: `Tier: ${id}`,
|
|
45
|
+
provider: "tier",
|
|
46
|
+
route: async (request: Request, ctx): Promise<ModelRoute<State>> =>
|
|
47
|
+
router.route(targets, request, ctx),
|
|
48
|
+
thinkingLevels: ["off", "minimal", "low", "medium", "high", "xhigh", "max"],
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
pi.on(
|
|
52
|
+
"after_provider_response",
|
|
53
|
+
(
|
|
54
|
+
event: Readonly<Omit<AfterProviderResponseEvent, "headers">> & {
|
|
55
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
56
|
+
},
|
|
57
|
+
) => {
|
|
58
|
+
router.responses.observe(event.headers, event.status);
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
pi.on("agent_before_settle", async (event, ctx) => recovery.settle(event, ctx));
|
|
62
|
+
pi.on("agent_settled", () => {
|
|
63
|
+
router.responses.clear();
|
|
64
|
+
});
|
|
65
|
+
pi.on("session_start", () => {
|
|
66
|
+
router.responses.clear();
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export default extension;
|
package/src/lock.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** A lock generation identifies its owning process and creation time. */
|
|
2
|
+
interface Owner {
|
|
3
|
+
readonly created_ms: number;
|
|
4
|
+
readonly pid: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const ZERO = 0,
|
|
8
|
+
STALE_MS = 30_000,
|
|
9
|
+
RETIRED_MS = 60_000,
|
|
10
|
+
/**
|
|
11
|
+
* Validates lock metadata before process checks or path construction.
|
|
12
|
+
* @param {string} source Owner JSON.
|
|
13
|
+
* @returns {Owner | undefined} Complete metadata, or an interrupted write.
|
|
14
|
+
*/
|
|
15
|
+
parseOwner = (source: string): Owner | undefined => {
|
|
16
|
+
try {
|
|
17
|
+
const value: unknown = JSON.parse(source);
|
|
18
|
+
if (
|
|
19
|
+
typeof value !== "object" ||
|
|
20
|
+
value === null ||
|
|
21
|
+
!("pid" in value) ||
|
|
22
|
+
!("created_ms" in value) ||
|
|
23
|
+
typeof value.pid !== "number" ||
|
|
24
|
+
!Number.isSafeInteger(value.pid) ||
|
|
25
|
+
value.pid <= ZERO ||
|
|
26
|
+
typeof value.created_ms !== "number" ||
|
|
27
|
+
!Number.isSafeInteger(value.created_ms) ||
|
|
28
|
+
value.created_ms < ZERO
|
|
29
|
+
) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return { created_ms: value.created_ms, pid: value.pid };
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
/**
|
|
38
|
+
* Names a retired generation without using untrusted path fragments.
|
|
39
|
+
* @param {Owner} owner Observed generation.
|
|
40
|
+
* @returns {string} Tombstone directory name.
|
|
41
|
+
*/
|
|
42
|
+
retiredName = (owner: Owner): string => `retired-${owner.pid}-${owner.created_ms}`,
|
|
43
|
+
/**
|
|
44
|
+
* Compares a fresh observation with a previously observed generation.
|
|
45
|
+
* @param {Owner | undefined} current Fresh metadata.
|
|
46
|
+
* @param {Owner} expected Original metadata.
|
|
47
|
+
* @returns {boolean} Whether retirement still applies.
|
|
48
|
+
*/
|
|
49
|
+
sameOwner = (current: Owner | undefined, expected: Owner): boolean =>
|
|
50
|
+
current?.pid === expected.pid && current.created_ms === expected.created_ms;
|
|
51
|
+
|
|
52
|
+
export { parseOwner, RETIRED_MS, retiredName, sameOwner, STALE_MS };
|
|
53
|
+
export type { Owner };
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Failure classes control recovery, not provider retry settings. */
|
|
2
|
+
type Failure = "gateway" | "quota" | "transient" | "request";
|
|
3
|
+
/** Durable router state on one session branch. */
|
|
4
|
+
interface State {
|
|
5
|
+
readonly attempted: readonly string[];
|
|
6
|
+
readonly budget: number;
|
|
7
|
+
readonly chosen: string;
|
|
8
|
+
}
|
|
9
|
+
/** Parsed physical model reference. */
|
|
10
|
+
interface Target {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly pool: string;
|
|
13
|
+
readonly provider: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ZERO = 0,
|
|
17
|
+
ONE = 1,
|
|
18
|
+
BAD_GATEWAY = 502,
|
|
19
|
+
UNAVAILABLE = 503,
|
|
20
|
+
BAD_REQUEST = 400,
|
|
21
|
+
UNAUTHORIZED = 401,
|
|
22
|
+
FORBIDDEN = 403,
|
|
23
|
+
BUDGET = 2,
|
|
24
|
+
QUOTA_STATUS = 429,
|
|
25
|
+
GATEWAY_STATUSES = new Set([BAD_GATEWAY, UNAVAILABLE]),
|
|
26
|
+
REQUEST_STATUSES = new Set([BAD_REQUEST, UNAUTHORIZED, FORBIDDEN]),
|
|
27
|
+
/**
|
|
28
|
+
* Classifies exactly once, with permanent failures taking precedence.
|
|
29
|
+
* @param {string} message Provider error text.
|
|
30
|
+
* @param {number | undefined} status Correlated HTTP status.
|
|
31
|
+
* @returns {Failure} Recovery class.
|
|
32
|
+
*/
|
|
33
|
+
classify = (message: string, status?: number): Failure => {
|
|
34
|
+
if (
|
|
35
|
+
REQUEST_STATUSES.has(status ?? ZERO) ||
|
|
36
|
+
/unknown model|\b(?:status|code|HTTP)\s*:?\s*(?:400|401|403)\b|unauthorized|forbidden/iu.test(
|
|
37
|
+
message,
|
|
38
|
+
)
|
|
39
|
+
) {
|
|
40
|
+
return "request";
|
|
41
|
+
}
|
|
42
|
+
if (
|
|
43
|
+
GATEWAY_STATUSES.has(status ?? ZERO) ||
|
|
44
|
+
/\b(?:status|code|HTTP)\s*:?\s*(?:502|503)\b|no available server|bad gateway|unable to connect/iu.test(
|
|
45
|
+
message,
|
|
46
|
+
)
|
|
47
|
+
) {
|
|
48
|
+
return "gateway";
|
|
49
|
+
}
|
|
50
|
+
if (
|
|
51
|
+
status === QUOTA_STATUS ||
|
|
52
|
+
/\b(?:status|code|HTTP)\s*:?\s*429\b|usage.limit|rate_limit|insufficient_quota|quota exceeded/iu.test(
|
|
53
|
+
message,
|
|
54
|
+
)
|
|
55
|
+
) {
|
|
56
|
+
return "quota";
|
|
57
|
+
}
|
|
58
|
+
return /overloaded|timeout|timed out|websocket.*1006|server_busy|retry after/iu.test(message)
|
|
59
|
+
? "transient"
|
|
60
|
+
: "request";
|
|
61
|
+
},
|
|
62
|
+
/**
|
|
63
|
+
* Parses a provider-qualified model and assigns its quota pool.
|
|
64
|
+
* @param {string} reference Model reference.
|
|
65
|
+
* @returns {Target} Physical target.
|
|
66
|
+
*/
|
|
67
|
+
target = (reference: string): Target => {
|
|
68
|
+
const separator = reference.indexOf("/"),
|
|
69
|
+
provider = reference.slice(ZERO, separator),
|
|
70
|
+
id = reference.slice(separator + ONE);
|
|
71
|
+
if (separator < ONE || id.length === ZERO) {
|
|
72
|
+
throw new Error(`Invalid tier target: ${reference}`);
|
|
73
|
+
}
|
|
74
|
+
const pools = [
|
|
75
|
+
["claude-", "claude"],
|
|
76
|
+
["gpt-", "codex"],
|
|
77
|
+
["minimax-", "minimax"],
|
|
78
|
+
],
|
|
79
|
+
pool =
|
|
80
|
+
pools.find(([prefix]: readonly string[]) => id.startsWith(prefix ?? ""))?.[ONE] ?? provider;
|
|
81
|
+
return { id, pool, provider };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export { BUDGET, classify, target };
|
|
85
|
+
export type { Failure, State, Target };
|
package/src/ports.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Change, Health } from "./health.ts";
|
|
2
|
+
import type { ExtensionContext, ModelRouteRequest } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { State } from "./policy.ts";
|
|
4
|
+
|
|
5
|
+
/** Pi authority used by routing and settlement. */
|
|
6
|
+
interface Context {
|
|
7
|
+
readonly model: ExtensionContext["model"];
|
|
8
|
+
readonly modelRegistry: Pick<ExtensionContext["modelRegistry"], "getAvailable">;
|
|
9
|
+
readonly sessionManager: Pick<ExtensionContext["sessionManager"], "getBranch">;
|
|
10
|
+
readonly signal?: AbortSignal | undefined;
|
|
11
|
+
}
|
|
12
|
+
/** External effects owned by the host adapter. */
|
|
13
|
+
interface Effects {
|
|
14
|
+
readonly change: (change: Change) => Promise<void>;
|
|
15
|
+
readonly now: () => number;
|
|
16
|
+
readonly quota: () => Promise<string>;
|
|
17
|
+
readonly read: () => Promise<Health>;
|
|
18
|
+
readonly wait: (delay: number, signal?: AbortSignal) => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
/** Read-only Pi routing request. */
|
|
21
|
+
type Request = Readonly<
|
|
22
|
+
Pick<ModelRouteRequest<State>, "model" | "reason" | "signal" | "state" | "thinkingLevel">
|
|
23
|
+
> & {
|
|
24
|
+
readonly failed?: {
|
|
25
|
+
readonly message: { readonly errorMessage?: string; readonly stopReason: string };
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type { Context, Effects, Request };
|
package/src/quota.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** One validated account window from Dogsled. */
|
|
2
|
+
interface Window {
|
|
3
|
+
readonly account: string;
|
|
4
|
+
readonly pool: string;
|
|
5
|
+
readonly resets_ms: number | null;
|
|
6
|
+
readonly used_pct: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const ZERO = 0,
|
|
10
|
+
VERSION = 1,
|
|
11
|
+
FULL = 100,
|
|
12
|
+
/**
|
|
13
|
+
* Validates the fields used by quota policy.
|
|
14
|
+
* @param {unknown} value External window.
|
|
15
|
+
* @returns {boolean} Whether the window is usable.
|
|
16
|
+
*/
|
|
17
|
+
isWindow = (value: unknown): value is Window =>
|
|
18
|
+
typeof value === "object" &&
|
|
19
|
+
value !== null &&
|
|
20
|
+
"account" in value &&
|
|
21
|
+
typeof value.account === "string" &&
|
|
22
|
+
"pool" in value &&
|
|
23
|
+
typeof value.pool === "string" &&
|
|
24
|
+
"used_pct" in value &&
|
|
25
|
+
typeof value.used_pct === "number" &&
|
|
26
|
+
Number.isFinite(value.used_pct) &&
|
|
27
|
+
value.used_pct >= ZERO &&
|
|
28
|
+
value.used_pct <= FULL &&
|
|
29
|
+
"resets_ms" in value &&
|
|
30
|
+
(value.resets_ms === null ||
|
|
31
|
+
(typeof value.resets_ms === "number" &&
|
|
32
|
+
Number.isSafeInteger(value.resets_ms) &&
|
|
33
|
+
value.resets_ms >= ZERO)),
|
|
34
|
+
/**
|
|
35
|
+
* Rejects malformed or unsupported snapshots instead of filtering from partial data.
|
|
36
|
+
* @param {string} source JSON snapshot.
|
|
37
|
+
* @returns {readonly Window[]} Validated windows, or no filtering.
|
|
38
|
+
*/
|
|
39
|
+
parseQuota = (source: string): readonly Window[] => {
|
|
40
|
+
try {
|
|
41
|
+
const value: unknown = JSON.parse(source);
|
|
42
|
+
if (
|
|
43
|
+
typeof value !== "object" ||
|
|
44
|
+
value === null ||
|
|
45
|
+
!("version" in value) ||
|
|
46
|
+
value.version !== VERSION ||
|
|
47
|
+
!("pools" in value) ||
|
|
48
|
+
!Array.isArray(value.pools) ||
|
|
49
|
+
!value.pools.every(isWindow)
|
|
50
|
+
) {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
return value.pools;
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
/**
|
|
59
|
+
* Finds when any account in an exhausted pool first becomes available.
|
|
60
|
+
* @param {readonly Window[]} windows Snapshot windows.
|
|
61
|
+
* @param {string} pool Pool name.
|
|
62
|
+
* @param {number} now Current time.
|
|
63
|
+
* @returns {number | undefined} Pool reset, only when every account is exhausted.
|
|
64
|
+
*/
|
|
65
|
+
exhaustedUntil = (windows: readonly Window[], pool: string, now: number): number | undefined => {
|
|
66
|
+
const accounts = new Map<string, number>();
|
|
67
|
+
for (const window of windows.filter((entry) => entry.pool === pool)) {
|
|
68
|
+
const reset =
|
|
69
|
+
window.used_pct >= FULL && window.resets_ms !== null && window.resets_ms > now
|
|
70
|
+
? window.resets_ms
|
|
71
|
+
: ZERO;
|
|
72
|
+
accounts.set(window.account, Math.max(accounts.get(window.account) ?? ZERO, reset));
|
|
73
|
+
}
|
|
74
|
+
const resets = [...accounts.values()];
|
|
75
|
+
return resets.length > ZERO && resets.every((reset) => reset > now)
|
|
76
|
+
? Math.min(...resets)
|
|
77
|
+
: undefined;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export { exhaustedUntil, parseQuota };
|
|
81
|
+
export type { Window };
|
package/src/recovery.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentBeforeSettleEvent,
|
|
3
|
+
BoundaryResult,
|
|
4
|
+
SessionEntry,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { Failure, State } from "./policy.ts";
|
|
7
|
+
import { RECOVERY, branchState } from "./branch.ts";
|
|
8
|
+
import type { Context } from "./ports.ts";
|
|
9
|
+
import type { Router } from "./router.ts";
|
|
10
|
+
import type { Tiers } from "./config.ts";
|
|
11
|
+
import { classify } from "./policy.ts";
|
|
12
|
+
|
|
13
|
+
const ZERO = 0,
|
|
14
|
+
ONE = 1;
|
|
15
|
+
|
|
16
|
+
/** Final-boundary recovery runs only after Pi's automatic retry policy ends. */
|
|
17
|
+
class Recovery {
|
|
18
|
+
readonly #router: Readonly<Pick<Router, "failed" | "next">> & {
|
|
19
|
+
readonly responses: Readonly<Router["responses"]>;
|
|
20
|
+
};
|
|
21
|
+
readonly #tiers: Readonly<Tiers>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Connects boundary recovery to routing policy.
|
|
25
|
+
* @param {Readonly<Router>} router Routing owner.
|
|
26
|
+
* @param {Readonly<Tiers>} tiers Ordered targets.
|
|
27
|
+
*/
|
|
28
|
+
public constructor(
|
|
29
|
+
router: Readonly<Pick<Router, "failed" | "next">> & {
|
|
30
|
+
readonly responses: Readonly<Router["responses"]>;
|
|
31
|
+
},
|
|
32
|
+
tiers: Readonly<Tiers>,
|
|
33
|
+
) {
|
|
34
|
+
this.#router = router;
|
|
35
|
+
this.#tiers = tiers;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Applies a classified failure to the recovery budget and target choice.
|
|
40
|
+
* @param {Failure} failure Failure class.
|
|
41
|
+
* @param {State} state Branch state.
|
|
42
|
+
* @param {readonly string[]} targets Tier targets.
|
|
43
|
+
* @returns {Promise<State | undefined>} Recovery state, when permitted.
|
|
44
|
+
*/
|
|
45
|
+
async #next(
|
|
46
|
+
failure: Failure,
|
|
47
|
+
state: State,
|
|
48
|
+
targets: readonly string[],
|
|
49
|
+
): Promise<State | undefined> {
|
|
50
|
+
await this.#router.failed(failure, state.chosen);
|
|
51
|
+
if (failure === "request" || state.budget === ZERO) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const attempted = failure === "gateway" ? state.attempted : [...state.attempted, state.chosen],
|
|
55
|
+
next = Object.assign({}, state, { attempted, budget: state.budget - ONE }),
|
|
56
|
+
chosen = failure === "gateway" ? state.chosen : await this.#router.next(targets, next);
|
|
57
|
+
return chosen === undefined ? undefined : Object.assign({}, next, { chosen });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Omits only the failed assistant and appends a state-only recovery marker.
|
|
62
|
+
* @param {AgentBeforeSettleEvent} event Final actionable boundary.
|
|
63
|
+
* @param {Context} ctx Pi authority.
|
|
64
|
+
* @returns {Promise<BoundaryResult | undefined>} Bounded recovery, or no action.
|
|
65
|
+
*/
|
|
66
|
+
public async settle(
|
|
67
|
+
event: AgentBeforeSettleEvent,
|
|
68
|
+
ctx: Context,
|
|
69
|
+
): Promise<BoundaryResult | undefined> {
|
|
70
|
+
if (
|
|
71
|
+
event.outcome !== "error" ||
|
|
72
|
+
ctx.signal?.aborted === true ||
|
|
73
|
+
ctx.model?.provider !== "tier"
|
|
74
|
+
) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const tier = ctx.model.id,
|
|
78
|
+
state = branchState(ctx.sessionManager.getBranch(), tier),
|
|
79
|
+
targets = this.#tiers.get(tier),
|
|
80
|
+
message = event.context.contextMessages.findLast((item) => item.role === "assistant"),
|
|
81
|
+
entry = event.context.contextEntries.findLast(
|
|
82
|
+
(item: Readonly<{ sourceEntry: SessionEntry; messages: readonly unknown[] }>) =>
|
|
83
|
+
item.messages.includes(message),
|
|
84
|
+
);
|
|
85
|
+
if (
|
|
86
|
+
state === undefined ||
|
|
87
|
+
targets === undefined ||
|
|
88
|
+
entry === undefined ||
|
|
89
|
+
message?.role !== "assistant" ||
|
|
90
|
+
message.stopReason !== "error" ||
|
|
91
|
+
`${message.provider}/${message.model}` !== state.chosen
|
|
92
|
+
) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const text = message.errorMessage ?? "",
|
|
96
|
+
next = await this.#next(classify(text, this.#router.responses.status(text)), state, targets);
|
|
97
|
+
if (next === undefined || Boolean(ctx.signal?.aborted)) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
continue: true,
|
|
102
|
+
entries: [
|
|
103
|
+
{ replacement: null, targetId: entry.sourceEntry.id, type: "context_edit" },
|
|
104
|
+
{
|
|
105
|
+
customType: RECOVERY,
|
|
106
|
+
data: { modelId: tier, provider: "tier", state: next },
|
|
107
|
+
type: "custom",
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export { Recovery };
|
package/src/responses.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const ZERO = 0,
|
|
2
|
+
ONE = 1,
|
|
3
|
+
LIMIT = 128;
|
|
4
|
+
|
|
5
|
+
/** Correlates HTTP status only when the error identifies the same response. */
|
|
6
|
+
class Responses {
|
|
7
|
+
readonly #statuses = new Map<string, number>();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Records bounded response metadata, never headers or credentials.
|
|
11
|
+
* @param {Readonly<Record<string, string>>} headers Response headers.
|
|
12
|
+
* @param {number} status HTTP status.
|
|
13
|
+
*/
|
|
14
|
+
public observe(headers: Readonly<Record<string, string>>, status: number): void {
|
|
15
|
+
const id = headers["x-request-id"] ?? headers["request-id"];
|
|
16
|
+
if (this.#statuses.size >= LIMIT) {
|
|
17
|
+
this.#statuses.clear();
|
|
18
|
+
}
|
|
19
|
+
if (id !== undefined && id.length > ZERO) {
|
|
20
|
+
this.#statuses.set(id, status);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Uses status only when a provider error explicitly includes its request ID.
|
|
26
|
+
* @param {string} message Error text.
|
|
27
|
+
* @returns {number | undefined} Correlated status.
|
|
28
|
+
*/
|
|
29
|
+
public status(message: string): number | undefined {
|
|
30
|
+
const tokens: readonly string[] = message.match(/[a-z0-9_-]+/giu) ?? [],
|
|
31
|
+
matches = [...this.#statuses].filter(([id]: readonly [string, number]) =>
|
|
32
|
+
tokens.includes(id),
|
|
33
|
+
),
|
|
34
|
+
[match] = matches;
|
|
35
|
+
return matches.length === ONE ? match?.[ONE] : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Removes metadata after settlement or session navigation. */
|
|
39
|
+
public clear(): void {
|
|
40
|
+
this.#statuses.clear();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { Responses };
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { BUDGET, classify, target } from "./policy.ts";
|
|
2
|
+
import type { Context, Effects, Request } from "./ports.ts";
|
|
3
|
+
import type { Failure, State } from "./policy.ts";
|
|
4
|
+
import { branchState, newUserTurn } from "./branch.ts";
|
|
5
|
+
import { exhaustedUntil, parseQuota } from "./quota.ts";
|
|
6
|
+
import type { Health } from "./health.ts";
|
|
7
|
+
import type { ModelRoute } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Responses } from "./responses.ts";
|
|
9
|
+
import type { Window } from "./quota.ts";
|
|
10
|
+
|
|
11
|
+
const ZERO = 0,
|
|
12
|
+
ONE = 1,
|
|
13
|
+
CACHE_MS = 60_000;
|
|
14
|
+
|
|
15
|
+
/** Sticky routing with host-wide quota and gateway health. */
|
|
16
|
+
class Router {
|
|
17
|
+
readonly #effects: Effects;
|
|
18
|
+
public readonly responses = new Responses();
|
|
19
|
+
#quota: readonly Window[] = [];
|
|
20
|
+
#quotaUntil = ZERO;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Connects policy to host effects.
|
|
24
|
+
* @param {Effects} effects External authority.
|
|
25
|
+
*/
|
|
26
|
+
public constructor(effects: Effects) {
|
|
27
|
+
this.#effects = effects;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Refreshes only the local cached quota snapshot, at most once per minute.
|
|
32
|
+
* @returns {Promise<void>} Completion.
|
|
33
|
+
*/
|
|
34
|
+
async #refresh(): Promise<void> {
|
|
35
|
+
if (this.#effects.now() >= this.#quotaUntil) {
|
|
36
|
+
this.#quota = parseQuota(await this.#effects.quota());
|
|
37
|
+
this.#quotaUntil = this.#effects.now() + CACHE_MS;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Checks both explicit cooldowns and account windows.
|
|
43
|
+
* @param {string} reference Model reference.
|
|
44
|
+
* @param {Health} health Shared snapshot.
|
|
45
|
+
* @returns {boolean} Availability.
|
|
46
|
+
*/
|
|
47
|
+
#available(reference: string, health: Health): boolean {
|
|
48
|
+
const { pool } = target(reference),
|
|
49
|
+
now = this.#effects.now();
|
|
50
|
+
return (
|
|
51
|
+
(health.pools[pool]?.until ?? ZERO) <= now &&
|
|
52
|
+
exhaustedUntil(this.#quota, pool, now) === undefined
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Chooses the first unattempted available target without changing state.
|
|
58
|
+
* @param {readonly string[]} targets Ordered tier.
|
|
59
|
+
* @param {State} state Branch state.
|
|
60
|
+
* @returns {Promise<string | undefined>} Next target.
|
|
61
|
+
*/
|
|
62
|
+
public async next(targets: readonly string[], state: State): Promise<string | undefined> {
|
|
63
|
+
await this.#refresh();
|
|
64
|
+
const health = await this.#effects.read();
|
|
65
|
+
return targets.find(
|
|
66
|
+
(candidate) => !state.attempted.includes(candidate) && this.#available(candidate, health),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Records only gateway circuits or quota cooldowns.
|
|
72
|
+
* @param {Failure} failure Classified failure.
|
|
73
|
+
* @param {string} reference Failed physical model.
|
|
74
|
+
* @returns {Promise<void>} Completion.
|
|
75
|
+
*/
|
|
76
|
+
public async failed(failure: Failure, reference: string): Promise<void> {
|
|
77
|
+
if (failure === "gateway" || failure === "quota") {
|
|
78
|
+
await this.#refresh();
|
|
79
|
+
const parsed = target(reference),
|
|
80
|
+
now = this.#effects.now();
|
|
81
|
+
await this.#effects.change({
|
|
82
|
+
key: failure === "gateway" ? parsed.provider : parsed.pool,
|
|
83
|
+
kind: failure,
|
|
84
|
+
now,
|
|
85
|
+
reset: failure === "quota" ? exhaustedUntil(this.#quota, parsed.pool, now) : undefined,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Keeps native transient retries on their current physical model.
|
|
92
|
+
* @param {Request} request Pi request.
|
|
93
|
+
* @param {State} state Current branch state.
|
|
94
|
+
* @returns {Promise<State>} State after a native retry failure.
|
|
95
|
+
*/
|
|
96
|
+
async #retry(request: Request, state: State): Promise<State> {
|
|
97
|
+
const { failed } = request;
|
|
98
|
+
if (failed === undefined) {
|
|
99
|
+
return state;
|
|
100
|
+
}
|
|
101
|
+
const message = failed.message.errorMessage ?? "",
|
|
102
|
+
failure = classify(message, this.responses.status(message));
|
|
103
|
+
if (failed.message.stopReason === "aborted" || failure === "request") {
|
|
104
|
+
throw new Error(message || "Tier request stopped.");
|
|
105
|
+
}
|
|
106
|
+
await this.failed(failure, state.chosen);
|
|
107
|
+
if (failure === "quota" && state.budget === ZERO) {
|
|
108
|
+
throw new Error("Tier recovery budget exhausted.");
|
|
109
|
+
}
|
|
110
|
+
return failure === "quota"
|
|
111
|
+
? Object.assign({}, state, {
|
|
112
|
+
attempted: [...state.attempted, state.chosen],
|
|
113
|
+
budget: state.budget - ONE,
|
|
114
|
+
})
|
|
115
|
+
: state;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Dispatches with the selected thinking level after checking shared health.
|
|
120
|
+
* @param {Request} request Pi request.
|
|
121
|
+
* @param {Context} ctx Pi authority.
|
|
122
|
+
* @param {State} state Chosen state.
|
|
123
|
+
* @returns {Promise<ModelRoute<State>>} Physical route.
|
|
124
|
+
*/
|
|
125
|
+
async #dispatch(request: Request, ctx: Context, state: State): Promise<ModelRoute<State>> {
|
|
126
|
+
const parsed = target(state.chosen),
|
|
127
|
+
model = ctx.modelRegistry
|
|
128
|
+
.getAvailable()
|
|
129
|
+
.find((candidate) => candidate.provider === parsed.provider && candidate.id === parsed.id);
|
|
130
|
+
if (model === undefined) {
|
|
131
|
+
throw new Error(`Unknown model: ${state.chosen}`);
|
|
132
|
+
}
|
|
133
|
+
const health = await this.#effects.read(),
|
|
134
|
+
until = health.circuits[parsed.provider]?.until ?? ZERO;
|
|
135
|
+
await this.#effects.wait(until - this.#effects.now(), request.signal);
|
|
136
|
+
request.signal?.throwIfAborted();
|
|
137
|
+
return { model, state, thinkingLevel: request.thinkingLevel };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Routes a tier without fabricating conversation messages.
|
|
142
|
+
* @param {readonly string[]} targets Ordered physical models.
|
|
143
|
+
* @param {Request} request Pi request.
|
|
144
|
+
* @param {Context} ctx Pi authority.
|
|
145
|
+
* @returns {Promise<ModelRoute<State>>} Physical dispatch.
|
|
146
|
+
*/
|
|
147
|
+
public async route(
|
|
148
|
+
targets: readonly string[],
|
|
149
|
+
request: Request,
|
|
150
|
+
ctx: Context,
|
|
151
|
+
): Promise<ModelRoute<State>> {
|
|
152
|
+
request.signal?.throwIfAborted();
|
|
153
|
+
await this.#refresh();
|
|
154
|
+
const previous =
|
|
155
|
+
request.reason === "direct"
|
|
156
|
+
? undefined
|
|
157
|
+
: (branchState(ctx.sessionManager.getBranch(), request.model.id) ?? request.state),
|
|
158
|
+
initial = previous ?? { attempted: [], budget: BUDGET, chosen: targets[ZERO] ?? "" },
|
|
159
|
+
current =
|
|
160
|
+
request.reason === "user" && newUserTurn(ctx.sessionManager.getBranch())
|
|
161
|
+
? Object.assign({}, initial, { attempted: [], budget: BUDGET })
|
|
162
|
+
: initial,
|
|
163
|
+
state = await this.#retry(request, current),
|
|
164
|
+
health = await this.#effects.read(),
|
|
165
|
+
chosen =
|
|
166
|
+
!state.attempted.includes(state.chosen) && this.#available(state.chosen, health)
|
|
167
|
+
? state.chosen
|
|
168
|
+
: await this.next(targets, state);
|
|
169
|
+
if (chosen === undefined) {
|
|
170
|
+
throw new Error("No available target in tier.");
|
|
171
|
+
}
|
|
172
|
+
return this.#dispatch(request, ctx, Object.assign({}, state, { chosen }));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export { Router };
|
package/src/source.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { Change, Health } from "./health.ts";
|
|
2
|
+
import { RETIRED_MS, STALE_MS, parseOwner, retiredName, sameOwner } from "./lock.ts";
|
|
3
|
+
import { emptyHealth, parseHealth, updateHealth } from "./health.ts";
|
|
4
|
+
import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import type { Owner } from "./lock.ts";
|
|
6
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import process from "node:process";
|
|
9
|
+
import { setTimeout } from "node:timers/promises";
|
|
10
|
+
|
|
11
|
+
const ZERO = 0,
|
|
12
|
+
LOCK_WAIT = 25,
|
|
13
|
+
IO_TIMEOUT = 2000,
|
|
14
|
+
/**
|
|
15
|
+
* Recognizes a specific filesystem error without suppressing other failures.
|
|
16
|
+
* @param {unknown} error Failure.
|
|
17
|
+
* @param {string} code Expected code.
|
|
18
|
+
* @returns {boolean} Match.
|
|
19
|
+
*/
|
|
20
|
+
hasCode = (error: unknown, code: string): boolean =>
|
|
21
|
+
error instanceof Error && "code" in error && error.code === code,
|
|
22
|
+
/**
|
|
23
|
+
* Reads optional JSON source.
|
|
24
|
+
* @param {string} file Path.
|
|
25
|
+
* @returns {Promise<string | undefined>} Source, absent only for missing files.
|
|
26
|
+
*/
|
|
27
|
+
readOptional = async (file: string): Promise<string | undefined> => {
|
|
28
|
+
try {
|
|
29
|
+
return await readFile(file, "utf8");
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (hasCode(error, "ENOENT")) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
/**
|
|
38
|
+
* Sleeps without retaining an abort listener after completion.
|
|
39
|
+
* @param {number} delay Milliseconds.
|
|
40
|
+
* @param {AbortSignal | undefined} signal Cancellation.
|
|
41
|
+
* @returns {Promise<void>} Completion.
|
|
42
|
+
*/
|
|
43
|
+
wait = async (delay: number, signal?: AbortSignal): Promise<void> => {
|
|
44
|
+
await setTimeout(Math.max(ZERO, delay), undefined, { signal });
|
|
45
|
+
},
|
|
46
|
+
/**
|
|
47
|
+
* Probes process existence without treating permission failures as death.
|
|
48
|
+
* @param {number} pid Recorded process.
|
|
49
|
+
* @returns {boolean} Whether the process is gone.
|
|
50
|
+
*/
|
|
51
|
+
dead = (pid: number): boolean => {
|
|
52
|
+
try {
|
|
53
|
+
process.kill(pid, ZERO);
|
|
54
|
+
return false;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return hasCode(error, "ESRCH");
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
/**
|
|
60
|
+
* Reads a complete generation, ignoring missing metadata during creation.
|
|
61
|
+
* @param {string} directory Lock directory.
|
|
62
|
+
* @returns {Promise<Owner | undefined>} Owner.
|
|
63
|
+
*/
|
|
64
|
+
ownerAt = async (directory: string): Promise<Owner | undefined> => {
|
|
65
|
+
const source = await readOptional(path.join(directory, "owner.json"));
|
|
66
|
+
return source === undefined ? undefined : parseOwner(source);
|
|
67
|
+
},
|
|
68
|
+
/**
|
|
69
|
+
* Gives an abandoned empty directory stable metadata and a nonempty tombstone.
|
|
70
|
+
* @param {string} directory Lock directory.
|
|
71
|
+
* @returns {Promise<void>} Completion.
|
|
72
|
+
*/
|
|
73
|
+
identifyAbandoned = async (directory: string): Promise<void> => {
|
|
74
|
+
try {
|
|
75
|
+
const details = await stat(directory);
|
|
76
|
+
if (Date.now() - details.mtimeMs > STALE_MS) {
|
|
77
|
+
await writeFile(
|
|
78
|
+
path.join(directory, "owner.json"),
|
|
79
|
+
JSON.stringify({ created_ms: Math.floor(details.mtimeMs), pid: process.pid }),
|
|
80
|
+
{ flag: "wx" },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (!hasCode(error, "ENOENT") && !hasCode(error, "EEXIST")) {
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
/**
|
|
90
|
+
* Finds a stale generation, including interrupted directory creation.
|
|
91
|
+
* @param {string} directory Lock directory.
|
|
92
|
+
* @returns {Promise<Owner | undefined>} Stale owner.
|
|
93
|
+
*/
|
|
94
|
+
staleOwner = async (directory: string): Promise<Owner | undefined> => {
|
|
95
|
+
const owner = await ownerAt(directory);
|
|
96
|
+
if (owner === undefined) {
|
|
97
|
+
await identifyAbandoned(directory);
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
return Date.now() - owner.created_ms > STALE_MS || dead(owner.pid) ? owner : undefined;
|
|
101
|
+
},
|
|
102
|
+
/**
|
|
103
|
+
* Retires only a stale, unchanged generation to its unique nonempty directory.
|
|
104
|
+
* @param {string} directory Lock directory.
|
|
105
|
+
* @returns {Promise<boolean>} Whether this waiter won retirement.
|
|
106
|
+
*/
|
|
107
|
+
retire = async (directory: string): Promise<boolean> => {
|
|
108
|
+
const owner = await staleOwner(directory);
|
|
109
|
+
if (owner === undefined || !sameOwner(await ownerAt(directory), owner)) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
await rename(directory, path.join(path.dirname(directory), retiredName(owner)));
|
|
114
|
+
return true;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (hasCode(error, "ENOENT") || hasCode(error, "ENOTEMPTY") || hasCode(error, "EEXIST")) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
/**
|
|
123
|
+
* Removes only retired directories outside the contention retention window.
|
|
124
|
+
* @param {string} directory State directory.
|
|
125
|
+
* @returns {Promise<void>} Completion.
|
|
126
|
+
*/
|
|
127
|
+
cleanRetired = async (directory: string): Promise<void> => {
|
|
128
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
129
|
+
await Promise.all(
|
|
130
|
+
entries
|
|
131
|
+
.filter(
|
|
132
|
+
(entry: Readonly<{ name: string; isDirectory: () => boolean }>) =>
|
|
133
|
+
entry.isDirectory() && /^retired-\d+-\d+$/u.test(entry.name),
|
|
134
|
+
)
|
|
135
|
+
.map(async (entry: Readonly<{ name: string }>) => {
|
|
136
|
+
const retired = path.join(directory, entry.name);
|
|
137
|
+
try {
|
|
138
|
+
const details = await stat(retired);
|
|
139
|
+
if (Date.now() - details.ctimeMs > RETIRED_MS) {
|
|
140
|
+
await rm(retired, { force: true, recursive: true });
|
|
141
|
+
}
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (!hasCode(error, "ENOENT")) {
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
},
|
|
150
|
+
/**
|
|
151
|
+
* Publishes complete owner metadata after exclusive directory creation.
|
|
152
|
+
* @param {string} directory Acquired directory.
|
|
153
|
+
* @returns {Promise<Owner>} Published generation.
|
|
154
|
+
*/
|
|
155
|
+
publishOwner = async (directory: string): Promise<Owner> => {
|
|
156
|
+
const owner = { created_ms: Date.now(), pid: process.pid };
|
|
157
|
+
await writeFile(path.join(directory, "owner.tmp"), JSON.stringify(owner), { flag: "wx" });
|
|
158
|
+
await rename(path.join(directory, "owner.tmp"), path.join(directory, "owner.json"));
|
|
159
|
+
await cleanRetired(path.dirname(directory));
|
|
160
|
+
return owner;
|
|
161
|
+
},
|
|
162
|
+
/**
|
|
163
|
+
* Acquires a recoverable directory lock with a bounded wait.
|
|
164
|
+
* @param {string} directory Lock directory.
|
|
165
|
+
* @param {number} deadline Deadline.
|
|
166
|
+
* @returns {Promise<Owner>} Acquired generation.
|
|
167
|
+
*/
|
|
168
|
+
acquire = async (directory: string, deadline: number): Promise<Owner> => {
|
|
169
|
+
try {
|
|
170
|
+
await mkdir(directory);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (!hasCode(error, "EEXIST") || Date.now() >= deadline) {
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
await retire(directory);
|
|
176
|
+
await wait(LOCK_WAIT);
|
|
177
|
+
return acquire(directory, deadline);
|
|
178
|
+
}
|
|
179
|
+
return publishOwner(directory);
|
|
180
|
+
},
|
|
181
|
+
/**
|
|
182
|
+
* Releases only the generation this transaction acquired.
|
|
183
|
+
* @param {string} directory Lock directory.
|
|
184
|
+
* @param {Owner} owner Acquired generation.
|
|
185
|
+
* @returns {Promise<void>} Completion.
|
|
186
|
+
*/
|
|
187
|
+
release = async (directory: string, owner: Owner): Promise<void> => {
|
|
188
|
+
if (sameOwner(await ownerAt(directory), owner)) {
|
|
189
|
+
await rm(directory, { recursive: true });
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** Atomic host-local circuit and cooldown persistence. */
|
|
194
|
+
class Store {
|
|
195
|
+
readonly #directory: string;
|
|
196
|
+
readonly #file: string;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Selects the agent state directory.
|
|
200
|
+
* @param {string} directory State root.
|
|
201
|
+
*/
|
|
202
|
+
public constructor(directory = path.join(getAgentDir(), "state", "pi-tiers")) {
|
|
203
|
+
this.#directory = directory;
|
|
204
|
+
this.#file = path.join(directory, "state.json");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Reads an atomic state snapshot.
|
|
209
|
+
* @returns {Promise<Health>} Current shared health.
|
|
210
|
+
*/
|
|
211
|
+
public async read(): Promise<Health> {
|
|
212
|
+
const source = await readOptional(this.#file);
|
|
213
|
+
return source === undefined ? emptyHealth() : parseHealth(source);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Writes and syncs a temporary snapshot before atomic replacement.
|
|
218
|
+
* @param {Health} health New state.
|
|
219
|
+
* @returns {Promise<void>} Durable replacement.
|
|
220
|
+
*/
|
|
221
|
+
async #write(health: Health): Promise<void> {
|
|
222
|
+
const temporary = path.join(this.#directory, `${crypto.randomUUID()}.tmp`),
|
|
223
|
+
file = await open(temporary, "wx");
|
|
224
|
+
try {
|
|
225
|
+
await file.writeFile(JSON.stringify(health));
|
|
226
|
+
await file.sync();
|
|
227
|
+
await rename(temporary, this.#file);
|
|
228
|
+
} finally {
|
|
229
|
+
await file.close();
|
|
230
|
+
await rm(temporary, { force: true });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Holds the lock across the entire read-modify-rename transaction.
|
|
236
|
+
* @param {Change} change Failure to record.
|
|
237
|
+
* @returns {Promise<void>} Durable completion.
|
|
238
|
+
*/
|
|
239
|
+
public async change(change: Change): Promise<void> {
|
|
240
|
+
await mkdir(this.#directory, { recursive: true });
|
|
241
|
+
const lockPath = path.join(this.#directory, "lock"),
|
|
242
|
+
lock = await acquire(lockPath, Date.now() + IO_TIMEOUT);
|
|
243
|
+
try {
|
|
244
|
+
await this.#write(updateHealth(await this.read(), change));
|
|
245
|
+
} finally {
|
|
246
|
+
await release(lockPath, lock);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Reads the user-owned tier map.
|
|
253
|
+
* @returns {Promise<string | undefined>} Config source.
|
|
254
|
+
*/
|
|
255
|
+
const readConfig = async (): Promise<string | undefined> =>
|
|
256
|
+
readOptional(path.join(getAgentDir(), "tiers.json"));
|
|
257
|
+
|
|
258
|
+
export { acquire, readConfig, release, retire, Store, wait };
|