@oisin-ee/oisinpi 0.7.2 → 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/CHANGELOG.md +23 -0
- package/node_modules/@oisin-ee/pi-guardrails/package.json +1 -1
- package/node_modules/@oisin-ee/pi-prewalk/README.md +2 -1
- package/node_modules/@oisin-ee/pi-prewalk/package.json +1 -1
- package/node_modules/@oisin-ee/pi-prewalk/src/adapter.ts +2 -0
- package/node_modules/@oisin-ee/pi-prewalk/src/generated/prewalk_core.d.ts +4 -0
- package/node_modules/@oisin-ee/pi-prewalk/src/generated/prewalk_core_bg.wasm +0 -0
- package/node_modules/@oisin-ee/pi-prewalk/src/ports.ts +1 -1
- package/node_modules/@oisin-ee/pi-prewalk/src/tasks.ts +48 -0
- package/node_modules/@oisin-ee/pi-tiers/README.md +65 -0
- package/node_modules/@oisin-ee/pi-tiers/package.json +35 -0
- package/node_modules/@oisin-ee/pi-tiers/src/branch.ts +75 -0
- package/node_modules/@oisin-ee/pi-tiers/src/config.ts +48 -0
- package/node_modules/@oisin-ee/pi-tiers/src/health.ts +92 -0
- package/node_modules/@oisin-ee/pi-tiers/src/index.ts +70 -0
- package/node_modules/@oisin-ee/pi-tiers/src/lock.ts +53 -0
- package/node_modules/@oisin-ee/pi-tiers/src/policy.ts +85 -0
- package/node_modules/@oisin-ee/pi-tiers/src/ports.ts +29 -0
- package/node_modules/@oisin-ee/pi-tiers/src/quota.ts +81 -0
- package/node_modules/@oisin-ee/pi-tiers/src/recovery.ts +114 -0
- package/node_modules/@oisin-ee/pi-tiers/src/responses.ts +44 -0
- package/node_modules/@oisin-ee/pi-tiers/src/router.ts +176 -0
- package/node_modules/@oisin-ee/pi-tiers/src/source.ts +258 -0
- package/node_modules/@oisin-ee/pi-todo/package.json +1 -1
- package/node_modules/@oisin-ee/pi-todo/src/contract-fixture.ts +103 -0
- package/node_modules/@oisin-ee/pi-todo/src/display.ts +8 -2
- package/node_modules/@oisin-ee/pi-todo/src/gate.ts +79 -0
- package/node_modules/@oisin-ee/pi-todo/src/generated/todo_core.d.ts +342 -5
- package/node_modules/@oisin-ee/pi-todo/src/generated/todo_core.js +222 -5
- package/node_modules/@oisin-ee/pi-todo/src/generated/todo_core_bg.wasm +0 -0
- package/node_modules/@oisin-ee/pi-todo/src/generated/todo_core_bg.wasm.d.ts +8 -1
- package/node_modules/@oisin-ee/pi-todo/src/guidance.ts +5 -1
- package/node_modules/@oisin-ee/pi-todo/src/handoff.ts +84 -0
- package/node_modules/@oisin-ee/pi-todo/src/import-fixture.ts +63 -0
- package/node_modules/@oisin-ee/pi-todo/src/index.ts +17 -43
- package/node_modules/@oisin-ee/pi-todo/src/lease-fixture.ts +95 -0
- package/node_modules/@oisin-ee/pi-todo/src/provider-fixture.ts +50 -0
- package/node_modules/@oisin-ee/pi-todo/src/provider.ts +289 -0
- package/node_modules/@oisin-ee/pi-todo/src/registration-fixture.ts +126 -0
- package/node_modules/@oisin-ee/pi-todo/src/repository.ts +31 -0
- package/node_modules/@oisin-ee/pi-todo/src/runtime-fixture.ts +209 -0
- package/node_modules/@oisin-ee/pi-todo/src/schema.ts +86 -0
- package/node_modules/@oisin-ee/pi-todo/src/seed-fixture.ts +35 -0
- package/node_modules/@oisin-ee/pi-todo/src/seed-transport.ts +52 -0
- package/node_modules/@oisin-ee/pi-todo/src/source.ts +33 -3
- package/node_modules/@oisin-ee/pi-todo/src/state.ts +78 -1
- package/node_modules/@oisin-ee/pi-todo/src/tool.ts +15 -14
- package/node_modules/@oisin-ee/pi-todo/src/wasm.ts +26 -0
- package/package.json +8 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# @oisin-ee/oisinpi
|
|
2
2
|
|
|
3
|
+
## 0.9.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#21](https://github.com/oisin-ee/oisinpi/pull/21) [`5e80e12`](https://github.com/oisin-ee/oisinpi/commit/5e80e1209af813427b16049c77de3f068d928fd1) Thanks [@momo-momokaya](https://github.com/apps/momo-momokaya)! - Add sticky tier virtual models with bounded recovery, shared gateway circuits, and quota-aware pool selection.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [[`5e80e12`](https://github.com/oisin-ee/oisinpi/commit/5e80e1209af813427b16049c77de3f068d928fd1)]:
|
|
12
|
+
- @oisin-ee/pi-tiers@0.9.0
|
|
13
|
+
- @oisin-ee/pi-guardrails@0.9.0
|
|
14
|
+
- @oisin-ee/pi-prewalk@0.9.0
|
|
15
|
+
- @oisin-ee/pi-todo@0.9.0
|
|
16
|
+
|
|
17
|
+
## 0.8.0
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- Updated dependencies [[`50877ec`](https://github.com/oisin-ee/oisinpi/commit/50877ec0f9bba71b7f70d8bf8d3754191ac73364)]:
|
|
22
|
+
- @oisin-ee/pi-todo@0.8.0
|
|
23
|
+
- @oisin-ee/pi-prewalk@0.8.0
|
|
24
|
+
- @oisin-ee/pi-guardrails@0.8.0
|
|
25
|
+
|
|
3
26
|
## 0.7.2
|
|
4
27
|
|
|
5
28
|
### Patch Changes
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# @oisin-ee/pi-prewalk
|
|
2
2
|
|
|
3
|
-
Prewalk starts with the active planner model. After the planning nudge,
|
|
3
|
+
Prewalk starts with the active planner model. After the planning nudge, validated local task activation opens the handoff gate.
|
|
4
|
+
Without the task-provider capability, a successful `todo` call opens the gate instead. Imported workers need no bookkeeping call.
|
|
4
5
|
The first subsequent `edit` or `write` result switches the session to the target model, even if that result failed.
|
|
5
6
|
Nested calls through codemode also count. Without a callable `todo` tool, the gate is open.
|
|
6
7
|
|
|
@@ -4,6 +4,7 @@ import type { BoundaryResult, SessionBoundaryDraft } from "@earendil-works/pi-co
|
|
|
4
4
|
import type { Context, Files, Host } from "./ports.ts";
|
|
5
5
|
import { branchRecords, calls } from "./branch.ts";
|
|
6
6
|
import type { CallEvidence } from "./branch.ts";
|
|
7
|
+
import { activeTask } from "./tasks.ts";
|
|
7
8
|
import { decide } from "./wasm.ts";
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -117,6 +118,7 @@ class Adapter {
|
|
|
117
118
|
todo.exposure === "deferred" ||
|
|
118
119
|
this.#pi.getActiveTools().includes("todo"));
|
|
119
120
|
return {
|
|
121
|
+
active_unit: activeTask(this.#pi.events),
|
|
120
122
|
available: ctx.modelRegistry
|
|
121
123
|
.getAvailable()
|
|
122
124
|
.map((candidate) => `${candidate.provider}/${candidate.id}`),
|
|
@@ -55,6 +55,10 @@ export interface Facts {
|
|
|
55
55
|
* Is todo callable (directly or through codemode)?
|
|
56
56
|
*/
|
|
57
57
|
todo: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Current provider activation; absent preserves the legacy successful-todo rule.
|
|
60
|
+
*/
|
|
61
|
+
active_unit?: boolean | undefined;
|
|
58
62
|
/**
|
|
59
63
|
* Todo source identity for status.
|
|
60
64
|
*/
|
|
Binary file
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
interface ActiveProvider {
|
|
4
|
+
readonly active: () => unknown;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Validates only the capability method this consumer needs.
|
|
9
|
+
* @param {unknown} value Capability response.
|
|
10
|
+
* @returns {boolean} Whether the active query is callable.
|
|
11
|
+
*/
|
|
12
|
+
const isProvider = (value: unknown): value is ActiveProvider =>
|
|
13
|
+
typeof value === "object" &&
|
|
14
|
+
value !== null &&
|
|
15
|
+
"active" in value &&
|
|
16
|
+
typeof value.active === "function",
|
|
17
|
+
/**
|
|
18
|
+
* Queries current activation without loading todo or interpreting its snapshots.
|
|
19
|
+
* @param {Readonly<ExtensionAPI["events"]>} events Extension capability bus.
|
|
20
|
+
* @returns {boolean | undefined} Activation, or unavailable legacy capability.
|
|
21
|
+
*/
|
|
22
|
+
activeTask = (events: Readonly<ExtensionAPI["events"]>): boolean | undefined => {
|
|
23
|
+
const replies: unknown[] = [];
|
|
24
|
+
events.emit("oisin.tasks.acquire.v1", {
|
|
25
|
+
reply: (provider: unknown): void => {
|
|
26
|
+
replies.push(provider);
|
|
27
|
+
},
|
|
28
|
+
version: 1,
|
|
29
|
+
});
|
|
30
|
+
const [provider] = replies,
|
|
31
|
+
one = 1;
|
|
32
|
+
if (replies.length !== one || !isProvider(provider)) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
const result: unknown = provider.active();
|
|
36
|
+
if (
|
|
37
|
+
typeof result !== "object" ||
|
|
38
|
+
result === null ||
|
|
39
|
+
!("ok" in result) ||
|
|
40
|
+
result.ok !== true ||
|
|
41
|
+
!("value" in result)
|
|
42
|
+
) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return result.value !== null && result.value !== undefined;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export { activeTask };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# @oisin-ee/pi-tiers
|
|
2
|
+
|
|
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
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@oisin-ee/pi-tiers",
|
|
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
|
+
}
|
|
@@ -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 };
|
|
@@ -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 };
|
|
@@ -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 };
|
|
@@ -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;
|
|
@@ -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 };
|
|
@@ -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 };
|