@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
|
@@ -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 };
|
|
@@ -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 };
|
|
@@ -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 };
|
|
@@ -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 };
|
|
@@ -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 };
|