pi-better-harness 0.40.2 → 0.40.4
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.
|
@@ -104,10 +104,14 @@ DeepSeek also excludes Chinese public holidays. The bundled holiday list is curr
|
|
|
104
104
|
- Compare estimated uncached input and output cost using catalog rates, bundled schedules, and policy overrides.
|
|
105
105
|
- On equal cost, prefer DeepSeek by default. If the preferred provider is unavailable, preserve the previous eligible model, then use candidate order.
|
|
106
106
|
- Keep a turn's model while it is still the cheapest, and move the turn to the other provider when that provider becomes strictly cheaper at a price boundary. Equal cost keeps the current provider, so a switch never pays a cold prompt cache for no gain.
|
|
107
|
-
- On a retry
|
|
107
|
+
- On a retry, send the request to the other candidate. A rejected request, such as a `400`, a bad schema, or an invalid key, keeps that provider out for the rest of the user turn; a timeout, connection failure, `429`, or overloaded endpoint only demotes it, so the turn keeps both routes.
|
|
108
|
+
- When every candidate is excluded but at least one failed transiently, retry the model the turn already uses instead of ending the turn. Only rejected requests can end it with no route left.
|
|
108
109
|
- Preserve the failed model only after a context overflow, because Pi retries that request after compacting the conversation and a switch would pay a cold cache.
|
|
109
|
-
- Report excluded providers as router state only when
|
|
110
|
-
-
|
|
110
|
+
- Report excluded and demoted providers as router state only when those lists change, so a long session does not append one state entry per request.
|
|
111
|
+
- Declare the virtual model's context limits as a pair that fits the provider window. Pi reads them whenever a response names no physical model, which is what the router's own routing error looks like, and an undeclared limit counts as zero. A zero window makes every conversation look over the compaction threshold, so Pi would compact after each routing error at any context size.
|
|
112
|
+
|
|
113
|
+
Both candidates share one 1,048,576-token window between input and output, but Pi treats `contextWindow` as the input allowance and `maxTokens` as room beyond it. The router therefore declares an input budget of `1,048,576 - 64,000` beside a 64,000-token output budget, so the two add up to the provider window. Declaring the raw 1,048,576 window beside a large output budget promises capacity no endpoint has: a 746,917-token conversation plus a 384,000-token completion is 1,130,917 tokens, which the provider rejects. The same pair also keeps Pi's compaction trigger below the point where output stops fitting.
|
|
114
|
+
- Stop with an error when every candidate is unavailable or rejected. A new user turn clears the exclusions.
|
|
111
115
|
- Direct requests, such as compaction summaries, compare prices independently.
|
|
112
116
|
|
|
113
117
|
Pi records the physical provider and model on responses and displays its native routed-model indicator. Pi clamps the selected thinking level to the physical model's supported levels.
|
|
@@ -1,56 +1,107 @@
|
|
|
1
1
|
import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
3
3
|
import { loadConfig } from "./config.ts";
|
|
4
|
-
import { chooseCandidate, isCheapest, isContextOverflow, resolveCandidates } from "./routing.ts";
|
|
4
|
+
import { chooseCandidate, isCheapest, isContextOverflow, isTransient, resolveCandidates } from "./routing.ts";
|
|
5
5
|
|
|
6
6
|
// Structural contract keeps this standalone package buildable alongside the
|
|
7
7
|
// monorepo's older SDK. Pi 1.4's native virtual-model API is required at runtime.
|
|
8
8
|
type Level = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
9
9
|
interface Physical { model: Model<Api>; thinkingLevel?: Level }
|
|
10
|
+
/** Providers this turn left behind. Hard failures end the turn; transient ones stay retryable. */
|
|
11
|
+
export interface RouteState { failedProviders: string[]; transientProviders?: string[] }
|
|
10
12
|
export interface RouteRequest {
|
|
11
13
|
reason: "user" | "continuation" | "retry" | "direct";
|
|
12
14
|
thinkingLevel: Level;
|
|
13
15
|
previous?: Physical;
|
|
14
16
|
failed?: Physical & { message: { errorMessage?: string } };
|
|
15
|
-
state?:
|
|
17
|
+
state?: RouteState;
|
|
16
18
|
}
|
|
17
|
-
interface RouteResult { model: Model<Api>; thinkingLevel: Level; state?:
|
|
19
|
+
interface RouteResult { model: Model<Api>; thinkingLevel: Level; state?: RouteState }
|
|
18
20
|
export interface VirtualDefinition {
|
|
19
21
|
provider: string; id: string; name: string; thinkingLevels: Level[];
|
|
22
|
+
contextWindow: number; maxTokens: number;
|
|
20
23
|
route(request: RouteRequest, ctx: ExtensionContext): RouteResult;
|
|
21
24
|
}
|
|
22
|
-
|
|
25
|
+
// Pi needs a declared window whenever a response names no physical model, which is exactly
|
|
26
|
+
// the router's own routing error: with no declared limit Pi falls back to a zero window, sees
|
|
27
|
+
// every conversation as over the threshold, and compacts after each routing error.
|
|
28
|
+
//
|
|
29
|
+
// Both candidate families share one 1,048,576-token window between input and output, but Pi
|
|
30
|
+
// treats contextWindow as the input allowance and maxTokens as room beyond it. Declaring the
|
|
31
|
+
// raw window beside a large output budget therefore promises capacity the provider does not
|
|
32
|
+
// have: 746,917 tokens of history plus a 384,000-token completion is 1,130,917, and the
|
|
33
|
+
// request is rejected for exceeding 1,048,576. Declare the input budget the shared window
|
|
34
|
+
// leaves after the output budget instead, so both the compaction threshold and the
|
|
35
|
+
// request-time clamp stay inside the real window.
|
|
36
|
+
const PROVIDER_CONTEXT_WINDOW = 1_048_576;
|
|
37
|
+
const MAX_TOKENS = 64_000;
|
|
38
|
+
const CONTEXT_WINDOW = PROVIDER_CONTEXT_WINDOW - MAX_TOKENS;
|
|
39
|
+
function sameList(left: readonly string[] = [], right: readonly string[] = []): boolean {
|
|
23
40
|
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
24
41
|
}
|
|
42
|
+
function sameState(left: RouteState, right: RouteState): boolean {
|
|
43
|
+
return sameList(left.failedProviders, right.failedProviders) &&
|
|
44
|
+
sameList(left.transientProviders, right.transientProviders);
|
|
45
|
+
}
|
|
46
|
+
function addProvider(list: readonly string[], provider: string): string[] {
|
|
47
|
+
return [...new Set([...list.filter(entry => entry !== provider), provider])];
|
|
48
|
+
}
|
|
49
|
+
function withoutProvider(list: readonly string[], provider: string): string[] {
|
|
50
|
+
return list.filter(entry => entry !== provider);
|
|
51
|
+
}
|
|
25
52
|
export function route(request: RouteRequest, ctx: ExtensionContext, now = new Date()): RouteResult {
|
|
26
53
|
const config = loadConfig(getAgentDir(), ctx.cwd);
|
|
27
54
|
const models = ctx.modelRegistry.getAvailable();
|
|
28
55
|
const available = resolveCandidates(models, config);
|
|
29
56
|
const find = (physical?: Physical) => physical && available.find(c =>
|
|
30
57
|
c.provider === physical.model.provider && c.model === physical.model.id);
|
|
31
|
-
|
|
32
|
-
|
|
58
|
+
// Pi retries a context overflow after compacting the conversation. That retry keeps the
|
|
59
|
+
// failed model, because a switch would pay a cold cache right after compaction.
|
|
60
|
+
if (request.reason === "retry" && request.failed && isContextOverflow(request.failed.message.errorMessage ?? "")) {
|
|
61
|
+
if (!find(request.failed)) throw new Error("Model router: failed model is no longer available.");
|
|
62
|
+
return { model: request.failed.model, thinkingLevel: request.failed.thinkingLevel ?? request.thinkingLevel };
|
|
63
|
+
}
|
|
64
|
+
const startsTurn = request.reason === "user" || request.reason === "direct";
|
|
65
|
+
// A new user turn clears every exclusion. Inside a turn a hard failure keeps that
|
|
66
|
+
// provider out; a transient one only sends the next attempt to the other candidate.
|
|
67
|
+
let failedProviders = startsTurn ? [] : request.state?.failedProviders ?? [];
|
|
68
|
+
let transientProviders = startsTurn ? [] : request.state?.transientProviders ?? [];
|
|
33
69
|
if (request.reason === "retry" && request.failed) {
|
|
34
|
-
const
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
70
|
+
const { provider } = request.failed.model;
|
|
71
|
+
if (isTransient(request.failed.message.errorMessage ?? "")) {
|
|
72
|
+
transientProviders = addProvider(transientProviders, provider);
|
|
73
|
+
failedProviders = withoutProvider(failedProviders, provider);
|
|
74
|
+
} else {
|
|
75
|
+
failedProviders = addProvider(failedProviders, provider);
|
|
76
|
+
transientProviders = withoutProvider(transientProviders, provider);
|
|
38
77
|
}
|
|
39
|
-
// Any other failed request moves to the other candidate for the rest of the turn.
|
|
40
|
-
failedProviders = [...new Set([...failedProviders, request.failed.model.provider])];
|
|
41
78
|
}
|
|
42
|
-
const
|
|
43
|
-
|
|
79
|
+
const previous = find(request.previous);
|
|
80
|
+
let eligible = available.filter(c => !failedProviders.includes(c.provider) && !transientProviders.includes(c.provider));
|
|
81
|
+
let recovered = false;
|
|
82
|
+
if (!eligible.length && transientProviders.length) {
|
|
83
|
+
// Retrying a provider that timed out beats ending the turn with no route at all.
|
|
84
|
+
eligible = available.filter(c => !failedProviders.includes(c.provider));
|
|
85
|
+
transientProviders = [];
|
|
86
|
+
recovered = true;
|
|
87
|
+
}
|
|
88
|
+
const sticky = request.reason === "continuation" ? previous : undefined;
|
|
44
89
|
const keep = sticky !== undefined && eligible.includes(sticky) && isCheapest(config, sticky, eligible, now);
|
|
45
|
-
|
|
90
|
+
// Reusing the model the turn already used preserves its prompt cache; the price policy
|
|
91
|
+
// can still move the turn at the next price boundary.
|
|
92
|
+
let reuse: typeof previous;
|
|
93
|
+
if (keep) reuse = sticky;
|
|
94
|
+
else if (recovered && previous !== undefined && eligible.includes(previous)) reuse = previous;
|
|
95
|
+
const candidate = reuse ?? chooseCandidate(config, eligible, now, request.previous?.model);
|
|
46
96
|
const model = models.find(m => m.provider === candidate.provider && m.id === candidate.model)!;
|
|
97
|
+
const state: RouteState = { failedProviders, ...(transientProviders.length ? { transientProviders } : {}) };
|
|
47
98
|
// Pi appends a session entry for every state object it receives, so report the
|
|
48
99
|
// excluded providers only when that list changes.
|
|
49
|
-
const stored = request.state?.failedProviders ?? [];
|
|
50
100
|
return {
|
|
51
101
|
model,
|
|
52
|
-
thinkingLevel:
|
|
53
|
-
|
|
102
|
+
thinkingLevel: reuse !== undefined
|
|
103
|
+
? request.previous?.thinkingLevel ?? request.thinkingLevel : request.thinkingLevel,
|
|
104
|
+
...(sameState(request.state ?? { failedProviders: [] }, state) ? {} : { state }),
|
|
54
105
|
};
|
|
55
106
|
}
|
|
56
107
|
export default function (pi: ExtensionAPI) {
|
|
@@ -61,6 +112,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
61
112
|
host.registerVirtualModel({
|
|
62
113
|
provider: "router", id: "deepseek-flash-auto", name: "DeepSeek Flash Auto",
|
|
63
114
|
thinkingLevels: ["off", "minimal", "low", "medium", "high", "xhigh", "max"],
|
|
115
|
+
contextWindow: CONTEXT_WINDOW, maxTokens: MAX_TOKENS,
|
|
64
116
|
route,
|
|
65
117
|
});
|
|
66
118
|
}
|
|
@@ -95,8 +95,11 @@ export function chooseCandidate(config: RouterConfig, available: Candidate[], no
|
|
|
95
95
|
return tied.find(candidate => candidate.provider === config.preferredProvider)
|
|
96
96
|
?? tied.find(candidate => candidate.provider === previous?.provider && candidate.model === previous.id) ?? tied[0];
|
|
97
97
|
}
|
|
98
|
+
// A retry can repair these: throttling, an overloaded or unreachable endpoint, or a
|
|
99
|
+
// connection that failed before the provider answered. Everything else is final for the
|
|
100
|
+
// turn, because the provider rejected the request itself.
|
|
98
101
|
export function isTransient(error: string): boolean {
|
|
99
|
-
return /\b429\b|\b50[0234]\b|rate.?limit|overload|temporar(?:y|ily)|service unavailable|timed? ?out|ECONNRESET|ECONNREFUSED|fetch failed/i.test(error);
|
|
102
|
+
return /\b429\b|\b50[0234]\b|rate.?limit|overload|temporar(?:y|ily)|service unavailable|timed? ?out|connection (?:error|reset|refused|closed|lost)|socket hang up|upstream http|operation was aborted|\bterminated\b|E(?:PIPE|NOTFOUND|AI_AGAIN)|ECONNRESET|ECONNREFUSED|fetch failed/i.test(error);
|
|
100
103
|
}
|
|
101
104
|
// Pi retries a context overflow after compacting the conversation. That retry keeps
|
|
102
105
|
// the failed model, because a switch would pay a cold cache right after compaction.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-harness",
|
|
3
|
-
"version": "0.40.
|
|
3
|
+
"version": "0.40.4",
|
|
4
4
|
"description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, model routing, and Outcome & Todo.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"pi-better-background-tasks": "0.14.2",
|
|
59
59
|
"pi-better-goal": "0.12.1",
|
|
60
|
-
"pi-better-model-router": "0.1.
|
|
60
|
+
"pi-better-model-router": "0.1.4",
|
|
61
61
|
"pi-ollama-cloud": "0.13.1",
|
|
62
62
|
"pi-better-sandbox": "0.14.1",
|
|
63
63
|
"pi-better-ssh": "0.1.4",
|