oira666_pi-limits-wait 0.4.14 → 0.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Pi Limits Wait
2
2
 
3
- [![npm](https://img.shields.io/npm/v/oira666_pi-limits-wait?style=flat-square&logo=npm&logoColor=white&label=npm&color=7c3aed)](https://www.npmjs.com/package/oira666_pi-limits-wait) [![node](https://img.shields.io/badge/node-%3E%3D18-7c3aed?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org)
3
+ [![npm](https://img.shields.io/npm/v/oira666_pi-limits-wait?style=flat-square&logo=npm&logoColor=white&label=npm&color=7c3aed)](https://www.npmjs.com/package/oira666_pi-limits-wait) [![node](https://img.shields.io/badge/node-%3E%3D22.19-7c3aed?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org)
4
4
 
5
5
  Pi extension that keeps requests alive when a provider rate-limits you or returns `server_is_overloaded`.
6
6
 
@@ -14,16 +14,23 @@ Restart Pi or run `/reload` after installing.
14
14
 
15
15
  ## What it does
16
16
 
17
- - Works with all Pi model providers/APIs that use `streamSimple`.
17
+ - Targets Pi 0.80.10+ and works with model providers that use `ModelRuntime.streamSimple`.
18
+ - Intercepts the current runtime without registering synthetic providers; every retry and fallback re-enters Pi's normal provider/auth/base URL/configured-header composition, including OAuth refresh checks.
19
+ - Reload-safe ownership ensures `/reload` transfers interception cleanly and aborts waits still owned by the previous extension instance. The interception has one process-global active extension owner, matching the interactive Pi CLI's single active session runtime lifecycle; SDK processes running multiple concurrent Pi sessions are not supported.
18
20
  - On rate-limit errors (`429`, `rate_limit`, `too many requests`, quota/reset messages), waits and retries in a loop.
19
- - Uses provider retry timing when available (`retry-after`, `retry-after-ms`, `retry in ...`, reset messages).
21
+ - Uses provider retry timing when exposed through response callbacks or provider errors (`retry-after`, `retry-after-ms`, `retry in ...`, reset messages).
20
22
  - If no retry timing is available for a rate limit, waits 30 minutes before retrying.
21
23
  - On `server_is_overloaded`, waits 5 minutes, then retries. If the provider is still overloaded after Pi's normal retries, it waits another 5 minutes and repeats.
22
24
  - On transient network/transport failures — including undici idle-timeout aborts (`UND_ERR_HEADERS_TIMEOUT` / `UND_ERR_BODY_TIMEOUT`), `fetch failed`, `terminated`, `ECONNRESET`, `ETIMEDOUT`, etc. — treats the error as retryable with a short backoff (default 15s, or the provider's `retry-after`) instead of giving up. This prevents a stalled streaming request from turning into a silent hang.
23
25
  - For any other (unclassified) non-retryable error, retries the same model a few times (default 3) before falling back / freezing it, so a one-off hiccup does not immediately sideline a model.
24
26
  - Reports retry/fallback waits in chat notifications without adding persistent TUI status lines.
25
27
  - Press Enter during a retry wait to skip the wait and retry immediately.
26
- - Optionally falls back to configured models when the current/default model is rate-limited.
28
+ - Optionally falls back to configured models when the current/default model is rate-limited. Every fallback to a different model—even on the same provider—deliberately discards request-owned credentials, headers, environment overrides, and provider-specific options so the target runtime resolves its own configuration.
29
+ - Preserves Pi's `before_provider_request` / `after_provider_response` callback flow and sanitises only the exact Anthropic OAuth system-block shape generated by current Pi; Pi remains responsible for OAuth identity insertion.
30
+
31
+ ### Current runtime limitation
32
+
33
+ Pi 0.80.10 does not expose a public factory for rebuilding the SDK's target-model-aware `transformHeaders` callback. Reusing the source request's closure on a fallback would apply source-model attribution and could leak extension-added headers, so limits-wait intentionally omits it for every different-model fallback. The target `ModelRuntime` still resolves target auth, base URL, configured provider/model headers, environment, and provider implementation canonically, but `before_provider_headers` cannot be re-fired for that fallback through the current public API.
27
34
 
28
35
  ## Optional fallback models
29
36
 
@@ -62,7 +69,7 @@ Each `fallback-models` entry supports:
62
69
 
63
70
  - `provider` — required. The Pi provider name, for example `anthropic`, `openai`, `google`, etc.
64
71
  - `modelname` — required. The model id/name as Pi knows it.
65
- - `reasoning effort` — optional. One of `off`, `minimal`, `low`, `medium`, `high`, `xhigh`. If omitted, Pi's current/default reasoning level is used.
72
+ - `reasoning effort` — optional. One of `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. If omitted, Pi's current/default reasoning level is used.
66
73
 
67
74
  Fallback behavior:
68
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-limits-wait",
3
- "version": "0.4.14",
3
+ "version": "0.5.0",
4
4
  "description": "Extension for PI that allows to automatically wait for limits to reset and retry the request",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,7 +11,7 @@
11
11
  "LICENSE"
12
12
  ],
13
13
  "engines": {
14
- "node": ">=18"
14
+ "node": ">=22.19.0"
15
15
  },
16
16
  "repository": {
17
17
  "type": "git",
@@ -37,12 +37,12 @@
37
37
  },
38
38
  "dependencies": {},
39
39
  "peerDependencies": {
40
- "@mariozechner/pi-ai": "*",
41
- "@mariozechner/pi-coding-agent": "*"
40
+ "@earendil-works/pi-ai": "^0.80.10",
41
+ "@earendil-works/pi-coding-agent": "^0.80.10"
42
42
  },
43
43
  "devDependencies": {
44
- "@mariozechner/pi-ai": "^0.66.1",
45
- "@mariozechner/pi-coding-agent": "^0.66.1",
44
+ "@earendil-works/pi-ai": "^0.80.10",
45
+ "@earendil-works/pi-coding-agent": "^0.80.10",
46
46
  "tsx": "^4.20.6",
47
47
  "typescript": "^5.9.3"
48
48
  }
package/src/constants.ts CHANGED
@@ -3,13 +3,20 @@ export const CLAUDE_CODE_IDENTITY =
3
3
 
4
4
  export const PI_REMOVAL_ANCHORS = [
5
5
  "pi-coding-agent",
6
- "@mariozechner/pi-coding-agent",
6
+ "@earendil-works/pi-coding-agent",
7
7
  "badlogic/pi-mono",
8
8
  ] as const;
9
9
 
10
10
  export const PI_IDENTITY_SENTENCE_PATTERN =
11
11
  /(?:^|\n)\s*You are pi\b[^.!?\n]*(?:[.!?](?=\s|$)|(?=\n|$))/gi;
12
12
 
13
+ // Strips the host-harness identity clause pi prepends to its system prompt, e.g.
14
+ // "You are an expert coding assistant operating inside pi, a coding agent harness."
15
+ // The Claude Code identity block already establishes who the assistant is, so
16
+ // leaving this clause in tells the model (and Anthropic) it is running inside pi.
17
+ export const PI_HARNESS_IDENTITY_PATTERN =
18
+ /\s+operating inside pi,\s*a coding agent harness/gi;
19
+
13
20
  export const CLAUDE_CODE_IDENTITY_PATTERN =
14
21
  /(?:^|\n)\s*You are Claude Code, Anthropic's official CLI for Claude\.\s*/gi;
15
22
 
package/src/index.ts CHANGED
@@ -1,10 +1,11 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { isInternalSyntheticModel } from "./models.js";
3
+ import { sanitiseAnthropicPayloadSystem } from "./prompt.js";
3
4
  import { loadFallbackSettings } from "./settings.js";
4
- import { state } from "./state.js";
5
- import { registerWrappedApi } from "./stream.js";
5
+ import { consumeExpectedModelSelection, state } from "./state.js";
6
+ import { installModelRuntimeInterception } from "./stream.js";
6
7
 
7
- export { sanitiseSystemPrompt } from "./prompt.js";
8
+ export { sanitiseAnthropicPayloadSystem, sanitiseSystemPrompt } from "./prompt.js";
8
9
  export {
9
10
  getRetryableError,
10
11
  isAuthenticationRefreshError,
@@ -23,27 +24,24 @@ export {
23
24
  export {
24
25
  __configureFallbackModelsForTests,
25
26
  __setNonRetryableTuningForTests,
27
+ installModelRuntimeInterception,
26
28
  streamWithLimitsRetry,
27
29
  streamWithRateLimitRetry,
28
30
  } from "./stream.js";
29
31
  export { freezingEnabled, waitForRateLimit, waitForRetry } from "./ui.js";
30
32
  export type { FallbackModel } from "./types.js";
31
33
 
32
- function registerContextApis(pi: ExtensionAPI, ctx: ExtensionContext): void {
33
- for (const model of ctx.modelRegistry.getAll()) {
34
- if (!isInternalSyntheticModel(model)) registerWrappedApi(pi, model.api);
35
- }
36
- if (ctx.model && !isInternalSyntheticModel(ctx.model)) registerWrappedApi(pi, ctx.model.api);
37
- }
38
-
39
34
  export default function (pi: ExtensionAPI) {
40
35
  state.extensionApi = pi;
36
+ const releaseInterception = installModelRuntimeInterception();
37
+
38
+ // Current pi-ai inserts the Claude Code identity as the first system block
39
+ // for Anthropic OAuth. Sanitise only that exact payload shape; authentication,
40
+ // identity insertion and all request composition remain owned by ModelRuntime.
41
+ pi.on("before_provider_request", (event) => sanitiseAnthropicPayloadSystem(event.payload));
41
42
 
42
43
  pi.on("model_select", (event) => {
43
- if (state.suppressNextModelSelect) {
44
- state.suppressNextModelSelect = false;
45
- return;
46
- }
44
+ if (consumeExpectedModelSelection(event.model)) return;
47
45
  if (isInternalSyntheticModel(event.model)) return;
48
46
  state.primaryModel = event.model;
49
47
  state.primaryThinkingLevel = pi.getThinkingLevel();
@@ -54,23 +52,20 @@ export default function (pi: ExtensionAPI) {
54
52
  if (!state.primaryModel && ctx.model && !isInternalSyntheticModel(ctx.model)) state.primaryModel = ctx.model;
55
53
  state.primaryThinkingLevel ??= pi.getThinkingLevel();
56
54
  loadFallbackSettings(ctx);
57
-
58
- // Some extensions may register providers after this extension loads. Wrap
59
- // any APIs that exist by the time an agent starts too.
60
- registerContextApis(pi, ctx);
61
-
62
- // Anthropic subscription/OAuth identity is applied per provider request in
63
- // streamWithLimitsRetry(). Doing it there ensures every retry/fallback
64
- // attempt gets the right prompt for its actual target model, while
65
- // non-Anthropic providers never inherit the Claude Code identity.
66
55
  });
67
56
 
68
- // Run once more after all before_agent_start handlers have completed and the
69
- // agent loop is about to make its first provider request. This catches
70
- // providers that another extension registered during before_agent_start after
71
- // our handler had already run.
72
57
  pi.on("agent_start", (_event, ctx) => {
73
58
  state.sharedCtx = ctx;
74
- registerContextApis(pi, ctx);
59
+ });
60
+
61
+ pi.on("session_shutdown", () => {
62
+ // An old extension instance must not disable or clear state owned by a
63
+ // newer /reload instance.
64
+ if (!releaseInterception()) return;
65
+ state.ambientStatusCleanup?.();
66
+ state.modelStatusCleanup?.();
67
+ state.expectedModelSelections.clear();
68
+ state.sharedCtx = undefined;
69
+ state.extensionApi = undefined;
75
70
  });
76
71
  }
package/src/models.ts CHANGED
@@ -1,8 +1,6 @@
1
- import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
- import type { Api, Model, SimpleStreamOptions } from "@mariozechner/pi-ai";
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
3
2
  import { DEFAULT_NON_RETRYABLE_FREEZE_MS } from "./constants.js";
4
- import { isRateLimitError, parseRetryDelayMs } from "./retry-errors.js";
5
- import { state } from "./state.js";
3
+ import { expectModelSelection, state } from "./state.js";
6
4
  import type { FallbackModel, RetryableError } from "./types.js";
7
5
  import { formatErrorDetail, formatDuration, reasonLabel } from "./ui.js";
8
6
 
@@ -34,9 +32,14 @@ export function getPrimaryModel(current: Model<Api>): Model<Api> {
34
32
  return state.primaryModel ?? current;
35
33
  }
36
34
 
35
+ export function configuredAttempt(model: Model<Api>): FallbackModel {
36
+ return state.fallbackModels.find((entry) => modelKey(entry.model) === modelKey(model))
37
+ ?? { model, reasoningEffort: state.primaryThinkingLevel };
38
+ }
39
+
37
40
  export function candidateOrder(current: Model<Api>): FallbackModel[] {
38
41
  const primary = getPrimaryModel(current);
39
- const order: FallbackModel[] = [{ model: primary, reasoningEffort: state.primaryThinkingLevel }];
42
+ const order: FallbackModel[] = [configuredAttempt(primary)];
40
43
  const seen = new Set([modelKey(primary)]);
41
44
  for (const entry of state.fallbackModels) {
42
45
  const key = modelKey(entry.model);
@@ -103,33 +106,6 @@ export function rememberNonRetryableFailure(model: Model<Api>, errorMessage: str
103
106
  ensureRateLimitedModelsStatus();
104
107
  }
105
108
 
106
- export function recentObservedHttpError(maxAgeMs = 60_000): string | undefined {
107
- if (!state.lastObservedHttpError) return undefined;
108
- if (Date.now() - state.lastObservedHttpError.at > maxAgeMs) return undefined;
109
- return state.lastObservedHttpError.message;
110
- }
111
-
112
- export function recentObservedRateLimitError(maxAgeMs = 60_000): string | undefined {
113
- const message = recentObservedHttpError(maxAgeMs);
114
- return message && isRateLimitError(message) ? message : undefined;
115
- }
116
-
117
- export function errorMessageWithRecentHttpStatus(errorMessage: string): string {
118
- const observed = recentObservedHttpError();
119
- if (!observed || errorMessage.includes(observed)) return errorMessage;
120
-
121
- // SDK errors often keep only "429 Too Many Requests" and drop the response
122
- // headers. Keep the observed fetch response when it carries retry timing, so
123
- // Claude subscription limits use their exact reset/retry time instead of the
124
- // generic 30 minute fallback.
125
- const currentHasRetryTiming = parseRetryDelayMs(errorMessage) !== undefined;
126
- const observedHasRetryTiming = parseRetryDelayMs(observed) !== undefined;
127
- if (!currentHasRetryTiming && observedHasRetryTiming) return `${observed}; Error: ${errorMessage}`;
128
-
129
- if (/(?:^|\D)[1-5]\d\d(?:\D|$)/.test(errorMessage)) return errorMessage;
130
- return `${observed}; Error: ${errorMessage}`;
131
- }
132
-
133
109
  export function earliestCandidateDeadline(current: Model<Api>): number | undefined {
134
110
  const deadlines = candidateOrder(current)
135
111
  .map((entry) => activeLimit(entry.model)?.deadline ?? activeNonRetryableFailure(entry.model)?.deadline)
@@ -138,45 +114,42 @@ export function earliestCandidateDeadline(current: Model<Api>): number | undefin
138
114
  }
139
115
 
140
116
  export function initialAttempt(model: Model<Api>): FallbackModel {
141
- const configured = state.fallbackModels.find((entry) => modelKey(entry.model) === modelKey(model));
142
- const current = configured ?? { model, reasoningEffort: state.primaryThinkingLevel };
117
+ const current = configuredAttempt(model);
143
118
  if (!fallbackEnabled() || (!activeLimit(model) && !hasNonRetryableFailure(model))) return current;
144
119
  return nextAvailableCandidate(model) ?? current;
145
120
  }
146
121
 
147
- export async function optionsForModel(
148
- originalModel: Model<Api>,
149
- target: FallbackModel,
150
- options?: SimpleStreamOptions,
151
- ctx: ExtensionContext | undefined = state.sharedCtx,
152
- ): Promise<SimpleStreamOptions | undefined> {
153
- const level = target.reasoningEffort ?? state.primaryThinkingLevel;
154
- const reasoning = level && level !== "off" ? level : undefined;
155
- if (modelKey(originalModel) === modelKey(target.model)) {
156
- return reasoning ? { ...options, reasoning } : options;
157
- }
158
- const auth = await ctx?.modelRegistry.getApiKeyAndHeaders(target.model);
159
- if (!auth?.ok) throw new Error(auth ? auth.error : "Model registry is unavailable.");
160
- return {
161
- ...options,
162
- ...(reasoning ? { reasoning } : {}),
163
- apiKey: auth.apiKey,
164
- headers: auth.headers || options?.headers ? { ...auth.headers, ...options?.headers } : undefined,
165
- } as SimpleStreamOptions;
166
- }
167
-
168
- export async function switchPiModel(entry: FallbackModel): Promise<void> {
169
- const pi = state.extensionApi;
170
- if (!pi) return;
171
- const current = state.sharedCtx?.model;
172
- if (current && modelKey(current) === modelKey(entry.model)) return;
173
- state.suppressNextModelSelect = true;
174
- const ok = await pi.setModel(entry.model);
175
- if (!ok) {
176
- state.suppressNextModelSelect = false;
177
- return;
178
- }
179
- const level = entry.reasoningEffort ?? state.primaryThinkingLevel;
180
- if (level) pi.setThinkingLevel(level);
181
- state.sharedCtx?.ui.notify(`Switched to ${formatModel(entry.model)}${level ? ` (${level})` : ""}.`, "info");
122
+ let modelSwitchQueue = Promise.resolve();
123
+
124
+ export async function switchPiModel(entry: FallbackModel, signal?: AbortSignal): Promise<void> {
125
+ const operation = modelSwitchQueue.then(async () => {
126
+ if (signal?.aborted) return;
127
+ const pi = state.extensionApi;
128
+ if (!pi) return;
129
+ const current = state.sharedCtx?.model;
130
+ if (current && modelKey(current) === modelKey(entry.model)) return;
131
+ if (signal?.aborted) return;
132
+
133
+ const cancelExpectedSelection = expectModelSelection(entry.model);
134
+ try {
135
+ const ok = await pi.setModel(entry.model);
136
+ if (!ok) {
137
+ cancelExpectedSelection();
138
+ return;
139
+ }
140
+ if (signal?.aborted) return;
141
+ const level = entry.reasoningEffort ?? state.primaryThinkingLevel;
142
+ if (level) pi.setThinkingLevel(level);
143
+ if (!signal?.aborted) {
144
+ state.sharedCtx?.ui.notify(`Switched to ${formatModel(entry.model)}${level ? ` (${level})` : ""}.`, "info");
145
+ }
146
+ } catch {
147
+ // The request can outlive its session during /reload or replacement. The
148
+ // interception owner aborts it; never let a stale UI/model switch strand
149
+ // the output stream.
150
+ cancelExpectedSelection();
151
+ }
152
+ });
153
+ modelSwitchQueue = operation.catch(() => undefined);
154
+ await operation;
182
155
  }
package/src/prompt.ts CHANGED
@@ -1,89 +1,64 @@
1
- import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
- import type { Api, Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai";
3
- import { CLAUDE_CODE_IDENTITY, CLAUDE_CODE_IDENTITY_PATTERN, PI_IDENTITY_SENTENCE_PATTERN, PI_REMOVAL_ANCHORS } from "./constants.js";
4
- import { state } from "./state.js";
5
-
6
- export function sanitiseSystemPrompt(raw: string): string {
7
- const paragraphs = raw.split(/\n\n+/);
8
- const filtered = paragraphs.filter((p) =>
9
- !PI_REMOVAL_ANCHORS.some((anchor) => p.includes(anchor)),
10
- );
11
-
12
- return filtered
13
- .join("\n\n")
14
- .replace(CLAUDE_CODE_IDENTITY_PATTERN, "")
15
- .replace(PI_IDENTITY_SENTENCE_PATTERN, "")
16
- .replace(/\n{3,}/g, "\n\n")
17
- .trim();
18
- }
19
-
20
- /**
21
- * Returns true if Anthropic OAuth is configured for this session, regardless
22
- * of which model is currently active. This handles cases where a synthetic or
23
- * temporary model is active at before_agent_start time.
24
- */
25
- export function isAnthropicOAuthSession(ctx: ExtensionContext): boolean {
26
- return ctx.modelRegistry.isUsingOAuth(
27
- { provider: "anthropic" } as Parameters<typeof ctx.modelRegistry.isUsingOAuth>[0],
28
- );
29
- }
30
-
31
- export function isAnthropicOAuthToken(value: string | undefined): boolean {
32
- return Boolean(value?.includes("sk-ant-oat"));
33
- }
34
-
35
- function headerValue(headers: Record<string, string> | undefined, name: string): string | undefined {
36
- if (!headers) return undefined;
37
- const direct = headers[name];
38
- if (direct !== undefined) return direct;
39
- const lower = name.toLowerCase();
40
- return Object.entries(headers).find(([key]) => key.toLowerCase() === lower)?.[1];
41
- }
42
-
43
- function requestAuthIsAnthropicOAuth(options?: SimpleStreamOptions): boolean | undefined {
44
- if (typeof options?.apiKey === "string" && options.apiKey.length > 0) {
45
- return isAnthropicOAuthToken(options.apiKey);
46
- }
47
-
48
- const authorization = headerValue(options?.headers, "authorization");
49
- if (authorization) {
50
- const bearer = authorization.match(/^\s*Bearer\s+(.+?)\s*$/i)?.[1] ?? authorization;
51
- return isAnthropicOAuthToken(bearer);
52
- }
53
-
54
- return undefined;
55
- }
56
-
57
- export function isAnthropicSubscriptionRequest(
58
- model: Model<Api>,
59
- options?: SimpleStreamOptions,
60
- ctx: ExtensionContext | undefined = state.sharedCtx,
61
- ): boolean {
62
- if (model.provider !== "anthropic") return false;
63
-
64
- // Prefer the actual per-request auth. This avoids both false negatives
65
- // (OAuth token supplied via options/fallback auth while modelRegistry does not
66
- // report OAuth) and false positives (registry has OAuth, but this request was
67
- // explicitly made with a normal API key).
68
- const requestAuth = requestAuthIsAnthropicOAuth(options);
69
- if (requestAuth !== undefined) return requestAuth;
70
-
71
- return Boolean(ctx?.modelRegistry.isUsingOAuth(model));
72
- }
73
-
74
- export function anthropicSubscriptionContext(
75
- model: Model<Api>,
76
- context: Context,
77
- options?: SimpleStreamOptions,
78
- ctx: ExtensionContext | undefined = state.sharedCtx,
79
- ): Context {
80
- if (!isAnthropicSubscriptionRequest(model, options, ctx)) return context;
81
-
82
- const sanitised = sanitiseSystemPrompt(context.systemPrompt ?? "");
83
- return {
84
- ...context,
85
- systemPrompt: sanitised
86
- ? `${CLAUDE_CODE_IDENTITY}\n\n${sanitised}`
87
- : CLAUDE_CODE_IDENTITY,
88
- };
89
- }
1
+ import { CLAUDE_CODE_IDENTITY, CLAUDE_CODE_IDENTITY_PATTERN, PI_HARNESS_IDENTITY_PATTERN, PI_IDENTITY_SENTENCE_PATTERN, PI_REMOVAL_ANCHORS } from "./constants.js";
2
+
3
+ export function sanitiseSystemPrompt(raw: string): string {
4
+ const paragraphs = raw.split(/\n\n+/);
5
+ const filtered = paragraphs.filter((p) =>
6
+ !PI_REMOVAL_ANCHORS.some((anchor) => p.includes(anchor)),
7
+ );
8
+
9
+ return filtered
10
+ .join("\n\n")
11
+ .replace(CLAUDE_CODE_IDENTITY_PATTERN, "")
12
+ .replace(PI_IDENTITY_SENTENCE_PATTERN, "")
13
+ .replace(PI_HARNESS_IDENTITY_PATTERN, "")
14
+ .replace(/\n{3,}/g, "\n\n")
15
+ .trim();
16
+ }
17
+
18
+ interface SystemTextBlock {
19
+ type: string;
20
+ text?: unknown;
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ function isPiClaudeCodeIdentityBlock(block: unknown): block is SystemTextBlock {
25
+ if (!block || typeof block !== "object") return false;
26
+ const candidate = block as SystemTextBlock;
27
+ return candidate.type === "text"
28
+ && typeof candidate.text === "string"
29
+ && candidate.text.trim() === CLAUDE_CODE_IDENTITY;
30
+ }
31
+
32
+ /**
33
+ * Sanitise the host prompt in the exact Anthropic OAuth shape generated by the
34
+ * current pi-ai runtime: the Claude Code identity is the first system block.
35
+ * Looking only at that exact first block avoids treating a later user-authored
36
+ * identity mention as proof of OAuth. The runtime remains solely responsible
37
+ * for inserting the identity and selecting OAuth headers.
38
+ */
39
+ export function sanitiseAnthropicPayloadSystem(payload: unknown): unknown {
40
+ if (!payload || typeof payload !== "object") return payload;
41
+ const system = (payload as { system?: unknown }).system;
42
+ if (!Array.isArray(system) || !isPiClaudeCodeIdentityBlock(system[0])) return payload;
43
+
44
+ let changed = false;
45
+ const newSystem: unknown[] = [system[0]];
46
+ for (let i = 1; i < system.length; i++) {
47
+ const block = system[i];
48
+ if (
49
+ block && typeof block === "object"
50
+ && (block as SystemTextBlock).type === "text"
51
+ && typeof (block as SystemTextBlock).text === "string"
52
+ ) {
53
+ const original = (block as SystemTextBlock).text as string;
54
+ const sanitised = sanitiseSystemPrompt(original);
55
+ if (sanitised !== original) changed = true;
56
+ if (sanitised) newSystem.push({ ...block, text: sanitised });
57
+ else changed = true;
58
+ } else {
59
+ newSystem.push(block);
60
+ }
61
+ }
62
+
63
+ return changed ? { ...payload, system: newSystem } : payload;
64
+ }
@@ -0,0 +1,52 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ type ResponseObserver = (response: Response, requestUrl: string | undefined) => void;
4
+ interface FetchObserverSlot {
5
+ storage: AsyncLocalStorage<ResponseObserver>;
6
+ previous: typeof fetch;
7
+ wrapper: typeof fetch;
8
+ }
9
+
10
+ const FETCH_OBSERVER_SYMBOL = Symbol.for("oira666.pi-limits-wait.attempt-fetch-observer.v1");
11
+ type InterceptableGlobal = typeof globalThis & { [FETCH_OBSERVER_SYMBOL]?: FetchObserverSlot };
12
+
13
+ function requestUrl(input: Parameters<typeof fetch>[0]): string | undefined {
14
+ if (typeof input === "string") return input;
15
+ if (input instanceof URL) return input.href;
16
+ return input.url;
17
+ }
18
+
19
+ function observerSlot(): FetchObserverSlot | undefined {
20
+ if (typeof globalThis.fetch !== "function") return undefined;
21
+ const target = globalThis as InterceptableGlobal;
22
+ const existing = target[FETCH_OBSERVER_SYMBOL];
23
+ if (existing) {
24
+ // Never stack another limits-wait wrapper if another owner replaced fetch
25
+ // after us. In that case onResponse/provider errors remain the fallback.
26
+ return globalThis.fetch === existing.wrapper ? existing : undefined;
27
+ }
28
+
29
+ const slot = {} as FetchObserverSlot;
30
+ slot.storage = new AsyncLocalStorage<ResponseObserver>();
31
+ slot.previous = globalThis.fetch.bind(globalThis);
32
+ slot.wrapper = (async (...args: Parameters<typeof fetch>) => {
33
+ const observer = slot.storage.getStore();
34
+ const url = requestUrl(args[0]);
35
+ const response = await slot.previous(...args);
36
+ observer?.(response, response.url || url);
37
+ return response;
38
+ }) as typeof fetch;
39
+ Object.defineProperty(target, FETCH_OBSERVER_SYMBOL, { configurable: true, value: slot });
40
+ globalThis.fetch = slot.wrapper;
41
+ return slot;
42
+ }
43
+
44
+ /**
45
+ * Observe fetch responses only in the async execution chain of one provider
46
+ * attempt. This is observational only: it never throws or consumes a body.
47
+ * Outside that chain the stable process trampoline is transparent.
48
+ */
49
+ export function withAttemptResponseObserver<T>(observer: ResponseObserver, callback: () => T): T {
50
+ const slot = observerSlot();
51
+ return slot ? slot.storage.run(observer, callback) : callback();
52
+ }
package/src/settings.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { join, resolve } from "path";
4
- import { getAgentDir, type ExtensionContext } from "@mariozechner/pi-coding-agent";
5
- import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
4
+ import { getAgentDir, type ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
6
6
  import { FALLBACK_MODELS_KEY, SETTINGS_FILE_NAME } from "./constants.js";
7
7
  import { formatModel, modelKey } from "./models.js";
8
8
  import { state } from "./state.js";
9
9
  import type { ConfiguredModel } from "./types.js";
10
10
 
11
- function isThinkingLevel(value: unknown): value is ThinkingLevel {
12
- return ["off", "minimal", "low", "medium", "high", "xhigh"].includes(String(value));
11
+ function isThinkingLevel(value: unknown): value is ModelThinkingLevel {
12
+ return ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(String(value));
13
13
  }
14
14
 
15
15
  export function parseConfiguredModels(raw: unknown): ConfiguredModel[] {