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 +12 -5
- package/package.json +6 -6
- package/src/constants.ts +8 -1
- package/src/index.ts +24 -29
- package/src/models.ts +42 -69
- package/src/prompt.ts +64 -89
- package/src/response-observer.ts +52 -0
- package/src/settings.ts +4 -4
- package/src/state.ts +59 -39
- package/src/stream.ts +572 -372
- package/src/types.ts +48 -41
- package/src/fetch-observer.ts +0 -49
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Pi Limits Wait
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/oira666_pi-limits-wait) [](https://www.npmjs.com/package/oira666_pi-limits-wait) [](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
|
-
-
|
|
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
|
|
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.
|
|
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": ">=
|
|
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
|
-
"@
|
|
41
|
-
"@
|
|
40
|
+
"@earendil-works/pi-ai": "^0.80.10",
|
|
41
|
+
"@earendil-works/pi-coding-agent": "^0.80.10"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@
|
|
45
|
-
"@
|
|
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
|
-
"@
|
|
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
|
|
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 {
|
|
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 (
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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[] = [
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
.
|
|
14
|
-
.replace(
|
|
15
|
-
.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 "@
|
|
5
|
-
import type {
|
|
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
|
|
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[] {
|