oira666_pi-limits-wait 0.4.15 → 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 +1 -1
- package/src/index.ts +20 -36
- package/src/models.ts +42 -69
- package/src/prompt.ts +64 -148
- 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
package/src/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { ExtensionAPI
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { isInternalSyntheticModel } from "./models.js";
|
|
3
3
|
import { sanitiseAnthropicPayloadSystem } from "./prompt.js";
|
|
4
4
|
import { loadFallbackSettings } from "./settings.js";
|
|
5
|
-
import { state } from "./state.js";
|
|
6
|
-
import {
|
|
5
|
+
import { consumeExpectedModelSelection, state } from "./state.js";
|
|
6
|
+
import { installModelRuntimeInterception } from "./stream.js";
|
|
7
7
|
|
|
8
8
|
export { sanitiseAnthropicPayloadSystem, sanitiseSystemPrompt } from "./prompt.js";
|
|
9
9
|
export {
|
|
@@ -24,37 +24,24 @@ export {
|
|
|
24
24
|
export {
|
|
25
25
|
__configureFallbackModelsForTests,
|
|
26
26
|
__setNonRetryableTuningForTests,
|
|
27
|
+
installModelRuntimeInterception,
|
|
27
28
|
streamWithLimitsRetry,
|
|
28
29
|
streamWithRateLimitRetry,
|
|
29
30
|
} from "./stream.js";
|
|
30
31
|
export { freezingEnabled, waitForRateLimit, waitForRetry } from "./ui.js";
|
|
31
32
|
export type { FallbackModel } from "./types.js";
|
|
32
33
|
|
|
33
|
-
function registerContextApis(pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
34
|
-
for (const model of ctx.modelRegistry.getAll()) {
|
|
35
|
-
if (!isInternalSyntheticModel(model)) registerWrappedApi(pi, model.api);
|
|
36
|
-
}
|
|
37
|
-
if (ctx.model && !isInternalSyntheticModel(ctx.model)) registerWrappedApi(pi, ctx.model.api);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
34
|
export default function (pi: ExtensionAPI) {
|
|
41
35
|
state.extensionApi = pi;
|
|
36
|
+
const releaseInterception = installModelRuntimeInterception();
|
|
42
37
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
// tokens but passes the host system prompt through as a second block, which
|
|
47
|
-
// trips the check. Sanitise that block on the outbound payload right before
|
|
48
|
-
// it is sent. This hook fires for every provider request and only mutates
|
|
49
|
-
// Anthropic OAuth payloads (identified by the Claude Code identity block), so
|
|
50
|
-
// API-key Anthropic calls and non-Anthropic providers are untouched.
|
|
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.
|
|
51
41
|
pi.on("before_provider_request", (event) => sanitiseAnthropicPayloadSystem(event.payload));
|
|
52
42
|
|
|
53
43
|
pi.on("model_select", (event) => {
|
|
54
|
-
if (
|
|
55
|
-
state.suppressNextModelSelect = false;
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
44
|
+
if (consumeExpectedModelSelection(event.model)) return;
|
|
58
45
|
if (isInternalSyntheticModel(event.model)) return;
|
|
59
46
|
state.primaryModel = event.model;
|
|
60
47
|
state.primaryThinkingLevel = pi.getThinkingLevel();
|
|
@@ -65,23 +52,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
65
52
|
if (!state.primaryModel && ctx.model && !isInternalSyntheticModel(ctx.model)) state.primaryModel = ctx.model;
|
|
66
53
|
state.primaryThinkingLevel ??= pi.getThinkingLevel();
|
|
67
54
|
loadFallbackSettings(ctx);
|
|
68
|
-
|
|
69
|
-
// Some extensions may register providers after this extension loads. Wrap
|
|
70
|
-
// any APIs that exist by the time an agent starts too.
|
|
71
|
-
registerContextApis(pi, ctx);
|
|
72
|
-
|
|
73
|
-
// Anthropic subscription/OAuth identity is applied per provider request in
|
|
74
|
-
// streamWithLimitsRetry(). Doing it there ensures every retry/fallback
|
|
75
|
-
// attempt gets the right prompt for its actual target model, while
|
|
76
|
-
// non-Anthropic providers never inherit the Claude Code identity.
|
|
77
55
|
});
|
|
78
56
|
|
|
79
|
-
// Run once more after all before_agent_start handlers have completed and the
|
|
80
|
-
// agent loop is about to make its first provider request. This catches
|
|
81
|
-
// providers that another extension registered during before_agent_start after
|
|
82
|
-
// our handler had already run.
|
|
83
57
|
pi.on("agent_start", (_event, ctx) => {
|
|
84
58
|
state.sharedCtx = ctx;
|
|
85
|
-
|
|
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;
|
|
86
70
|
});
|
|
87
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,148 +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
|
-
return
|
|
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
|
-
// Prefer the actual per-request auth. This avoids both false negatives
|
|
66
|
-
// (OAuth token supplied via options/fallback auth while modelRegistry does not
|
|
67
|
-
// report OAuth) and false positives (registry has OAuth, but this request was
|
|
68
|
-
// explicitly made with a normal API key).
|
|
69
|
-
const requestAuth = requestAuthIsAnthropicOAuth(options);
|
|
70
|
-
if (requestAuth !== undefined) return requestAuth;
|
|
71
|
-
|
|
72
|
-
return Boolean(ctx?.modelRegistry.isUsingOAuth(model));
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function anthropicSubscriptionContext(
|
|
76
|
-
model: Model<Api>,
|
|
77
|
-
context: Context,
|
|
78
|
-
options?: SimpleStreamOptions,
|
|
79
|
-
ctx: ExtensionContext | undefined = state.sharedCtx,
|
|
80
|
-
): Context {
|
|
81
|
-
if (!isAnthropicSubscriptionRequest(model, options, ctx)) return context;
|
|
82
|
-
|
|
83
|
-
const sanitised = sanitiseSystemPrompt(context.systemPrompt ?? "");
|
|
84
|
-
return {
|
|
85
|
-
...context,
|
|
86
|
-
systemPrompt: sanitised
|
|
87
|
-
? `${CLAUDE_CODE_IDENTITY}\n\n${sanitised}`
|
|
88
|
-
: CLAUDE_CODE_IDENTITY,
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
interface SystemTextBlock {
|
|
93
|
-
type: string;
|
|
94
|
-
text?: unknown;
|
|
95
|
-
[key: string]: unknown;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function isClaudeCodeIdentityBlock(block: unknown): block is SystemTextBlock {
|
|
99
|
-
if (!block || typeof block !== "object") return false;
|
|
100
|
-
const b = block as SystemTextBlock;
|
|
101
|
-
return b.type === "text" && typeof b.text === "string" && b.text.includes(CLAUDE_CODE_IDENTITY);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Sanitise the `system` blocks of an Anthropic provider request payload.
|
|
106
|
-
*
|
|
107
|
-
* Anthropic now rejects Claude Pro/Max (OAuth) requests whose system prompt
|
|
108
|
-
* still carries third-party-agent fingerprints (e.g. pi-coding-agent paths or
|
|
109
|
-
* "You are pi" identity). pi-ai already prepends the Claude Code identity as
|
|
110
|
-
* the first `system` block for OAuth tokens, but passes the host agent's full
|
|
111
|
-
* system prompt through as a second block unchanged, which trips the check.
|
|
112
|
-
*
|
|
113
|
-
* This runs from the `before_provider_request` extension hook, which fires for
|
|
114
|
-
* every provider request and whose return value replaces the outbound payload.
|
|
115
|
-
* It only touches payloads that already carry the Claude Code identity block
|
|
116
|
-
* (i.e. Anthropic OAuth requests), so API-key Anthropic calls and non-Anthropic
|
|
117
|
-
* providers are left untouched. Each remaining text block is run through
|
|
118
|
-
* `sanitiseSystemPrompt`; blocks that become empty are dropped.
|
|
119
|
-
*/
|
|
120
|
-
export function sanitiseAnthropicPayloadSystem(payload: unknown): unknown {
|
|
121
|
-
if (!payload || typeof payload !== "object") return payload;
|
|
122
|
-
const system = (payload as { system?: unknown }).system;
|
|
123
|
-
if (!Array.isArray(system)) return payload;
|
|
124
|
-
|
|
125
|
-
const identityIndex = system.findIndex(isClaudeCodeIdentityBlock);
|
|
126
|
-
if (identityIndex === -1) return payload;
|
|
127
|
-
|
|
128
|
-
const newSystem: unknown[] = [];
|
|
129
|
-
for (let i = 0; i < system.length; i++) {
|
|
130
|
-
const block = system[i];
|
|
131
|
-
if (i === identityIndex) {
|
|
132
|
-
newSystem.push(block);
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
if (
|
|
136
|
-
block && typeof block === "object"
|
|
137
|
-
&& (block as SystemTextBlock).type === "text"
|
|
138
|
-
&& typeof (block as SystemTextBlock).text === "string"
|
|
139
|
-
) {
|
|
140
|
-
const sanitised = sanitiseSystemPrompt((block as SystemTextBlock).text as string);
|
|
141
|
-
if (sanitised) newSystem.push({ ...block, text: sanitised });
|
|
142
|
-
} else {
|
|
143
|
-
newSystem.push(block);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
return { ...payload, system: newSystem };
|
|
148
|
-
}
|
|
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[] {
|