pi-better-harness 0.40.2 → 0.40.3

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,12 @@ 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 after any error, exclude the failed provider for the rest of that user turn, so the retry uses the other candidate.
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 that list changes, so a long session does not append one state entry per request.
110
- - Stop with an error when all candidates are unavailable or excluded. A new user turn clears exclusions.
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 1M context window and 384k output limit. 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
+ - Stop with an error when every candidate is unavailable or rejected. A new user turn clears the exclusions.
111
113
  - Direct requests, such as compaction summaries, compare prices independently.
112
114
 
113
115
  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,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-model-router",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Price-aware virtual model routing for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,56 +1,99 @@
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?: { failedProviders: string[] };
17
+ state?: RouteState;
16
18
  }
17
- interface RouteResult { model: Model<Api>; thinkingLevel: Level; state?: { failedProviders: string[] } }
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
- function sameList(left: readonly string[], right: readonly string[]): boolean {
25
+ // Both candidate families declare a 1M context window, so the router declares the shared
26
+ // floor. Pi needs it whenever a response names no physical model, which is exactly the
27
+ // router's own routing error: with no declared limit Pi falls back to a zero window, sees
28
+ // every conversation as over the threshold, and compacts after each routing error.
29
+ const CONTEXT_WINDOW = 1_000_000;
30
+ const MAX_TOKENS = 384_000;
31
+ function sameList(left: readonly string[] = [], right: readonly string[] = []): boolean {
23
32
  return left.length === right.length && left.every((value, index) => value === right[index]);
24
33
  }
34
+ function sameState(left: RouteState, right: RouteState): boolean {
35
+ return sameList(left.failedProviders, right.failedProviders) &&
36
+ sameList(left.transientProviders, right.transientProviders);
37
+ }
38
+ function addProvider(list: readonly string[], provider: string): string[] {
39
+ return [...new Set([...list.filter(entry => entry !== provider), provider])];
40
+ }
41
+ function withoutProvider(list: readonly string[], provider: string): string[] {
42
+ return list.filter(entry => entry !== provider);
43
+ }
25
44
  export function route(request: RouteRequest, ctx: ExtensionContext, now = new Date()): RouteResult {
26
45
  const config = loadConfig(getAgentDir(), ctx.cwd);
27
46
  const models = ctx.modelRegistry.getAvailable();
28
47
  const available = resolveCandidates(models, config);
29
48
  const find = (physical?: Physical) => physical && available.find(c =>
30
49
  c.provider === physical.model.provider && c.model === physical.model.id);
31
- let failedProviders = request.reason === "user" || request.reason === "direct"
32
- ? [] : request.state?.failedProviders ?? [];
50
+ // Pi retries a context overflow after compacting the conversation. That retry keeps the
51
+ // failed model, because a switch would pay a cold cache right after compaction.
52
+ if (request.reason === "retry" && request.failed && isContextOverflow(request.failed.message.errorMessage ?? "")) {
53
+ if (!find(request.failed)) throw new Error("Model router: failed model is no longer available.");
54
+ return { model: request.failed.model, thinkingLevel: request.failed.thinkingLevel ?? request.thinkingLevel };
55
+ }
56
+ const startsTurn = request.reason === "user" || request.reason === "direct";
57
+ // A new user turn clears every exclusion. Inside a turn a hard failure keeps that
58
+ // provider out; a transient one only sends the next attempt to the other candidate.
59
+ let failedProviders = startsTurn ? [] : request.state?.failedProviders ?? [];
60
+ let transientProviders = startsTurn ? [] : request.state?.transientProviders ?? [];
33
61
  if (request.reason === "retry" && request.failed) {
34
- const errorMessage = request.failed.message.errorMessage ?? "";
35
- if (isContextOverflow(errorMessage)) {
36
- if (!find(request.failed)) throw new Error("Model router: failed model is no longer available.");
37
- return { model: request.failed.model, thinkingLevel: request.failed.thinkingLevel ?? request.thinkingLevel };
62
+ const { provider } = request.failed.model;
63
+ if (isTransient(request.failed.message.errorMessage ?? "")) {
64
+ transientProviders = addProvider(transientProviders, provider);
65
+ failedProviders = withoutProvider(failedProviders, provider);
66
+ } else {
67
+ failedProviders = addProvider(failedProviders, provider);
68
+ transientProviders = withoutProvider(transientProviders, provider);
38
69
  }
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
70
  }
42
- const eligible = available.filter(c => !failedProviders.includes(c.provider));
43
- const sticky = request.reason === "continuation" ? find(request.previous) : undefined;
71
+ const previous = find(request.previous);
72
+ let eligible = available.filter(c => !failedProviders.includes(c.provider) && !transientProviders.includes(c.provider));
73
+ let recovered = false;
74
+ if (!eligible.length && transientProviders.length) {
75
+ // Retrying a provider that timed out beats ending the turn with no route at all.
76
+ eligible = available.filter(c => !failedProviders.includes(c.provider));
77
+ transientProviders = [];
78
+ recovered = true;
79
+ }
80
+ const sticky = request.reason === "continuation" ? previous : undefined;
44
81
  const keep = sticky !== undefined && eligible.includes(sticky) && isCheapest(config, sticky, eligible, now);
45
- const candidate = keep ? sticky! : chooseCandidate(config, eligible, now, request.previous?.model);
82
+ // Reusing the model the turn already used preserves its prompt cache; the price policy
83
+ // can still move the turn at the next price boundary.
84
+ let reuse: typeof previous;
85
+ if (keep) reuse = sticky;
86
+ else if (recovered && previous !== undefined && eligible.includes(previous)) reuse = previous;
87
+ const candidate = reuse ?? chooseCandidate(config, eligible, now, request.previous?.model);
46
88
  const model = models.find(m => m.provider === candidate.provider && m.id === candidate.model)!;
89
+ const state: RouteState = { failedProviders, ...(transientProviders.length ? { transientProviders } : {}) };
47
90
  // Pi appends a session entry for every state object it receives, so report the
48
91
  // excluded providers only when that list changes.
49
- const stored = request.state?.failedProviders ?? [];
50
92
  return {
51
93
  model,
52
- thinkingLevel: keep ? request.previous?.thinkingLevel ?? request.thinkingLevel : request.thinkingLevel,
53
- ...(sameList(stored, failedProviders) ? {} : { state: { failedProviders } }),
94
+ thinkingLevel: reuse !== undefined
95
+ ? request.previous?.thinkingLevel ?? request.thinkingLevel : request.thinkingLevel,
96
+ ...(sameState(request.state ?? { failedProviders: [] }, state) ? {} : { state }),
54
97
  };
55
98
  }
56
99
  export default function (pi: ExtensionAPI) {
@@ -61,6 +104,7 @@ export default function (pi: ExtensionAPI) {
61
104
  host.registerVirtualModel({
62
105
  provider: "router", id: "deepseek-flash-auto", name: "DeepSeek Flash Auto",
63
106
  thinkingLevels: ["off", "minimal", "low", "medium", "high", "xhigh", "max"],
107
+ contextWindow: CONTEXT_WINDOW, maxTokens: MAX_TOKENS,
64
108
  route,
65
109
  });
66
110
  }
@@ -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.2",
3
+ "version": "0.40.3",
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.2",
60
+ "pi-better-model-router": "0.1.3",
61
61
  "pi-ollama-cloud": "0.13.1",
62
62
  "pi-better-sandbox": "0.14.1",
63
63
  "pi-better-ssh": "0.1.4",