@oh-my-pi/pi-ai 18.2.0 → 18.2.2
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/CHANGELOG.md +53 -0
- package/README.md +2 -0
- package/dist/types/auth/sqlite-credential-store.d.ts +2 -1
- package/dist/types/auth-broker/remote-store.d.ts +17 -0
- package/dist/types/auth-gateway/index.d.ts +1 -0
- package/dist/types/auth-gateway/session-state.d.ts +118 -0
- package/dist/types/auth-storage.d.ts +17 -0
- package/dist/types/error/body-error.d.ts +15 -0
- package/dist/types/error/flags.d.ts +16 -0
- package/dist/types/error/index.d.ts +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/oneshot-retry.d.ts +6 -0
- package/dist/types/provider-session-state.d.ts +46 -0
- package/dist/types/providers/amazon-bedrock.d.ts +3 -0
- package/dist/types/providers/aws-sigv4.d.ts +12 -0
- package/dist/types/providers/openai-codex/request-transformer.d.ts +27 -0
- package/dist/types/providers/openai-responses.d.ts +15 -0
- package/dist/types/providers/openai-shared.d.ts +20 -3
- package/dist/types/registry/oauth/perplexity.d.ts +1 -7
- package/dist/types/registry/oauth/types.d.ts +8 -0
- package/dist/types/stream.d.ts +2 -0
- package/dist/types/types.d.ts +3 -1
- package/dist/types/usage/openai-codex.d.ts +3 -1
- package/dist/types/usage.d.ts +11 -1
- package/dist/types/utils/block-symbols.d.ts +36 -0
- package/dist/types/utils/openai-http.d.ts +2 -0
- package/dist/types/utils/retry-after.d.ts +2 -0
- package/dist/types/utils/schema/wire.d.ts +4 -5
- package/dist/types/utils.d.ts +9 -0
- package/package.json +6 -6
- package/src/auth/sqlite-credential-store.ts +8 -33
- package/src/auth-broker/remote-store.ts +73 -8
- package/src/auth-broker/wire-schemas.ts +1 -0
- package/src/auth-gateway/index.ts +1 -0
- package/src/auth-gateway/server.ts +186 -74
- package/src/auth-gateway/session-state.ts +312 -0
- package/src/auth-storage.ts +146 -15
- package/src/error/body-error.ts +310 -0
- package/src/error/flags.ts +63 -13
- package/src/error/index.ts +1 -0
- package/src/error/retryable.ts +2 -0
- package/src/index.ts +1 -0
- package/src/oneshot-retry.ts +13 -3
- package/src/provider-session-state.ts +56 -0
- package/src/providers/amazon-bedrock.ts +20 -3
- package/src/providers/anthropic-messages-server.ts +104 -23
- package/src/providers/anthropic-signature.ts +5 -2
- package/src/providers/anthropic.ts +101 -15
- package/src/providers/aws-sigv4.ts +16 -5
- package/src/providers/cursor.ts +60 -10
- package/src/providers/devin.ts +82 -28
- package/src/providers/openai-chat-server.ts +4 -0
- package/src/providers/openai-codex/request-transformer.ts +36 -0
- package/src/providers/openai-codex-responses.ts +35 -12
- package/src/providers/openai-completions.ts +49 -12
- package/src/providers/openai-reasoning-fallback.ts +6 -6
- package/src/providers/openai-responses-server.ts +2 -1
- package/src/providers/openai-responses.ts +52 -4
- package/src/providers/openai-shared.ts +199 -51
- package/src/registry/oauth/perplexity.ts +94 -28
- package/src/registry/oauth/types.ts +9 -0
- package/src/stream.ts +23 -2
- package/src/types.ts +3 -0
- package/src/usage/claude.ts +33 -0
- package/src/usage/google-antigravity.ts +8 -2
- package/src/usage/openai-codex.ts +94 -11
- package/src/usage.ts +8 -1
- package/src/utils/block-symbols.ts +57 -0
- package/src/utils/http-inspector.ts +20 -0
- package/src/utils/openai-http.ts +39 -3
- package/src/utils/retry-after.ts +12 -0
- package/src/utils/schema/normalize.ts +3 -3
- package/src/utils/schema/stamps.ts +33 -45
- package/src/utils/schema/wire.ts +9 -7
- package/src/utils.ts +67 -22
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-band provider failures: upstream 429/5xx payloads that arrive inside an
|
|
3
|
+
* HTTP 200 response body, or mid-stream after the SSE headers were already sent.
|
|
4
|
+
*
|
|
5
|
+
* Providers that front a retry/queue layer — Azure OpenAI, LiteLLM-style
|
|
6
|
+
* aggregators, Bedrock-compatible shims — answer a throttled request with
|
|
7
|
+
* `200 OK` + `text/event-stream` and put the real status in the payload:
|
|
8
|
+
* `data: {"error":{"type":"rate_limit_error"}}`, `data: {"code":429}`, or a bare
|
|
9
|
+
* non-JSON frame such as `data: 429 Too Many Requests` / an nginx throttle page.
|
|
10
|
+
* Those bodies used to be either dropped silently (the stream then looked like a
|
|
11
|
+
* successful empty completion) or surfaced as an unclassified
|
|
12
|
+
* {@link ProviderResponseError} whose `errorId` stayed 0 — so `AIError.retriable`
|
|
13
|
+
* answered "terminal" and `retry.fallbackChains` never advanced, pinning the
|
|
14
|
+
* session on a provider that was merely busy.
|
|
15
|
+
*
|
|
16
|
+
* Both probes hand the classifier the structured signal it already trusts for an
|
|
17
|
+
* out-of-band failure: a {@link ProviderHttpError} carrying a status the upstream
|
|
18
|
+
* *reported*, so the two routes reach one code path. The invariants that make
|
|
19
|
+
* that safe, and that callers rely on:
|
|
20
|
+
*
|
|
21
|
+
* - **No invented HTTP metadata.** A status is taken only from an error
|
|
22
|
+
* `status`/`code` *field* (numeric, or a 3-digit string as compat hosts send)
|
|
23
|
+
* or from {@link RETRYABLE_STATUS_BY_CODE}, and only for `429`/`5xx`. A status
|
|
24
|
+
* mentioned inside error prose is never promoted to a status: 401/403 wording
|
|
25
|
+
* stays text and cannot route the failure into the auth-retry lane.
|
|
26
|
+
* - **No credential rotation on an unreadable body.** A `429` whose body is
|
|
27
|
+
* empty, `{}`, or framing-only is opaque, and an opaque 429 is the conservative
|
|
28
|
+
* rotate-to-a-sibling-credential signal. That verdict belongs to a body the
|
|
29
|
+
* server actually sent, not to a payload we synthesised, so
|
|
30
|
+
* {@link formatInBandMessage} substitutes {@link IN_BAND_DETAIL_PLACEHOLDER}
|
|
31
|
+
* and the composed message always stays informative.
|
|
32
|
+
* - **Unknown envelopes fall through.** Anything without a retryable status
|
|
33
|
+
* field, a retryable code, or unambiguous throttle wording returns
|
|
34
|
+
* `undefined` and keeps its pre-existing handling and message.
|
|
35
|
+
*
|
|
36
|
+
* Where no numeric status exists, the returned error keeps the upstream wording
|
|
37
|
+
* and code visible in its message instead of asserting HTTP metadata the provider
|
|
38
|
+
* never sent, and carries {@link Flag.Transient} directly: throttle spellings like
|
|
39
|
+
* `Throttled` / `Please retry` are what this probe recognises but the transport
|
|
40
|
+
* text pattern is not required to match, so the retry decision is stated rather
|
|
41
|
+
* than hoped for.
|
|
42
|
+
*/
|
|
43
|
+
import { ProviderHttpError } from "./classes";
|
|
44
|
+
import { attach, create, Flag } from "./flags";
|
|
45
|
+
import { isOpaqueStatusBody } from "./rate-limit";
|
|
46
|
+
import { ProviderResponseError } from "./provider";
|
|
47
|
+
|
|
48
|
+
/** Cap on synthesized message length, mirroring the transport-level `MAX_DETAIL_CHARS`. */
|
|
49
|
+
const MAX_IN_BAND_DETAIL_CHARS = 4096;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Filler used when an in-band failure frame carries no readable detail. It has
|
|
53
|
+
* to be informative prose on purpose: an opaque message on a `429` is read as
|
|
54
|
+
* "the server gave us nothing" and rotates a credential, and that judgement must
|
|
55
|
+
* never be triggered by wording of ours.
|
|
56
|
+
*/
|
|
57
|
+
const IN_BAND_DETAIL_PLACEHOLDER = "Provider returned an in-band provider error";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Machine error codes that mean "shed this request and back off", mapped to the
|
|
61
|
+
* HTTP status the upstream would have used had it not wrapped the failure in a
|
|
62
|
+
* 200. Keys are compared after lower-casing, splitting camel/Pascal word
|
|
63
|
+
* boundaries, and collapsing `_`/`-`/`.`/spaces, so `Throttling.AllocationQuota`
|
|
64
|
+
* and `ThrottlingAllocationQuota` both resolve. The list is deliberately limited
|
|
65
|
+
* to throttle/overload spellings so an unlisted code keeps its pre-existing
|
|
66
|
+
* classification: request-validation failures (`invalid_request_error`) and
|
|
67
|
+
* account caps (`insufficient_quota`, `usage_limit_reached`) are never
|
|
68
|
+
* reinterpreted as retries.
|
|
69
|
+
*/
|
|
70
|
+
const RETRYABLE_STATUS_BY_CODE: Record<string, number> = {
|
|
71
|
+
rate_limit_error: 429,
|
|
72
|
+
rate_limit_exceeded: 429,
|
|
73
|
+
rate_limit: 429,
|
|
74
|
+
rate_limit_reached: 429,
|
|
75
|
+
rate_limited: 429,
|
|
76
|
+
ratelimit: 429,
|
|
77
|
+
too_many_requests: 429,
|
|
78
|
+
request_throttled: 429,
|
|
79
|
+
throttled: 429,
|
|
80
|
+
throttling: 429,
|
|
81
|
+
throttling_error: 429,
|
|
82
|
+
throttling_exception: 429,
|
|
83
|
+
throttling_allocation_quota: 429,
|
|
84
|
+
request_limit_exceeded: 429,
|
|
85
|
+
retry_later: 429,
|
|
86
|
+
overloaded_error: 503,
|
|
87
|
+
server_overloaded: 503,
|
|
88
|
+
model_overloaded: 503,
|
|
89
|
+
overloaded: 503,
|
|
90
|
+
service_unavailable: 503,
|
|
91
|
+
server_busy: 503,
|
|
92
|
+
high_demand: 503,
|
|
93
|
+
capacity_exceeded: 503,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Wording that identifies a throttle/overload in an error code or body. Kept as
|
|
98
|
+
* an explicit list rather than reusing the classifier's pattern, so this probe
|
|
99
|
+
* stays independent of the text rules it feeds.
|
|
100
|
+
*/
|
|
101
|
+
const IN_BAND_RETRYABLE_TEXT_PATTERN =
|
|
102
|
+
/\brate.?limit|too many requests|too\s+many\s+concurren|service.{0,20}unavailable|temporarily\s+unavailable|server.?error|internal.?error|overloaded|capacity|throttl|retry\s+(?:your\s+)?request|please\s+retry/i;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A status in the position a proxy error page puts it: the very first token,
|
|
106
|
+
* optionally after an `HTTP/1.1 ` prefix. Delimited by a word boundary so
|
|
107
|
+
* identifiers (`chatcmpl-500321`, `gpt-500x`, `req500502`) cannot fabricate a
|
|
108
|
+
* status — the same hazard `error-transient-status-boundary.test.ts` guards.
|
|
109
|
+
* Prose that merely *mentions* a number (`Too many requests (401 from …)`) is
|
|
110
|
+
* deliberately not read as status metadata.
|
|
111
|
+
*/
|
|
112
|
+
const LEADING_STATUS_PATTERN = /^\s*(?:HTTP[/.]\d(?:\.\d)?\s+)?([45]\d{2})(?:\b|$)/i;
|
|
113
|
+
|
|
114
|
+
/** Codes that mean a persistent account/billing cap or a bad request; never shed-and-retry. */
|
|
115
|
+
const NON_RETRYABLE_CODE_PATTERN =
|
|
116
|
+
/insufficient.?quota|usage.?limit|quota.?(?:exceeded|reached|insufficient)|invalid_request|content_filter|context_length|context_window|billing|balance/i;
|
|
117
|
+
|
|
118
|
+
/** Flags this module asserts for a body it has itself recognised as shed-and-retry. */
|
|
119
|
+
const IN_BAND_FLAGS = create(Flag.Transient);
|
|
120
|
+
|
|
121
|
+
function normalizeCodeToken(value: unknown): string | undefined {
|
|
122
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
123
|
+
if (typeof value !== "string") return undefined;
|
|
124
|
+
const spaced = value.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2");
|
|
125
|
+
const collapsed = spaced
|
|
126
|
+
.toLowerCase()
|
|
127
|
+
.replace(/[-.\s]+/g, "_")
|
|
128
|
+
.replace(/_+/g, "_");
|
|
129
|
+
return collapsed.length > 0 ? collapsed : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function readInBandDetail(value: unknown): string | undefined {
|
|
133
|
+
if (typeof value !== "string") return undefined;
|
|
134
|
+
// SSE joins multi-line `data:` values with `\n`, and proxy failures arrive as
|
|
135
|
+
// HTML: flatten to one line of visible text so a synthesized message cannot
|
|
136
|
+
// smuggle markup or framing into the classifier or the terminal.
|
|
137
|
+
const flattened = value
|
|
138
|
+
.replace(/<[^>]*>/g, " ")
|
|
139
|
+
.replace(/[\u0000-\u001f\u007f]+/g, " ")
|
|
140
|
+
.replace(/\s+/g, " ")
|
|
141
|
+
.trim();
|
|
142
|
+
if (flattened.length === 0) return undefined;
|
|
143
|
+
return flattened.length > MAX_IN_BAND_DETAIL_CHARS ? flattened.slice(0, MAX_IN_BAND_DETAIL_CHARS) : flattened;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A numeric HTTP status field, accepting the `"429"` string form compat hosts emit. */
|
|
147
|
+
function readStatusField(value: unknown): number | undefined {
|
|
148
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599) return value;
|
|
149
|
+
if (typeof value === "string" && /^\d{3}$/.test(value.trim())) {
|
|
150
|
+
const parsed = Number(value.trim());
|
|
151
|
+
return Number.isInteger(parsed) && parsed >= 100 && parsed <= 599 ? parsed : undefined;
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Only `429` and genuine server faults are shed-and-retry; 4xx of any other kind is not. */
|
|
157
|
+
function isRetryableStatus(status: number | undefined): status is number {
|
|
158
|
+
return status === 429 || (status !== undefined && status >= 500 && status <= 599);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readLeadingStatus(text: string | undefined): number | undefined {
|
|
162
|
+
if (text === undefined) return undefined;
|
|
163
|
+
const match = LEADING_STATUS_PATTERN.exec(text);
|
|
164
|
+
return match?.[1] ? Number(match[1]) : undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface InBandSignal {
|
|
168
|
+
/** Numeric HTTP status the upstream reported, or implied by its error code. */
|
|
169
|
+
status?: number;
|
|
170
|
+
/** Machine code from the body (`error.code` preferred over `error.type`). */
|
|
171
|
+
code?: string;
|
|
172
|
+
/** Human-readable detail from the body. */
|
|
173
|
+
detail?: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Pull the failure signal out of an OpenAI-wire frame. Accepts the nested
|
|
178
|
+
* `{ error: { code, type, status, message } }` shape, the `response.error`
|
|
179
|
+
* position of Responses-API terminal events, the flat `{ code, status, message }`
|
|
180
|
+
* bodies compat hosts emit, and string envelopes (`{ error: "..." }`).
|
|
181
|
+
*
|
|
182
|
+
* `undefined` means "not an in-band failure worth retrying". The probe is
|
|
183
|
+
* intentionally narrow: a bare `type` is present on every Responses event and so
|
|
184
|
+
* never counts as a signal by itself, statuses come only from fields (never from
|
|
185
|
+
* prose), and a frame with neither a retryable status nor throttle wording is
|
|
186
|
+
* left to the caller's existing handling.
|
|
187
|
+
*/
|
|
188
|
+
function readInBandSignal(frame: unknown): InBandSignal | undefined {
|
|
189
|
+
if (typeof frame !== "object" || frame === null || Array.isArray(frame)) return undefined;
|
|
190
|
+
const root = frame as Record<string, unknown>;
|
|
191
|
+
const nested = root.error ?? (root.response as Record<string, unknown> | undefined)?.error;
|
|
192
|
+
// Azure-compatible gates double-wrap (`{ error: { error: { code, message } } }`).
|
|
193
|
+
// Walk at most two levels so a deep payload cannot extend the parse.
|
|
194
|
+
let error: Record<string, unknown> | undefined;
|
|
195
|
+
if (typeof nested === "object" && nested !== null) {
|
|
196
|
+
error = nested as Record<string, unknown>;
|
|
197
|
+
for (let depth = 0; depth < 2; depth++) {
|
|
198
|
+
const inner = error.error;
|
|
199
|
+
if (typeof inner !== "object" || inner === null) break;
|
|
200
|
+
error = inner as Record<string, unknown>;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const code = normalizeCodeToken(error?.code ?? root.code) ?? normalizeCodeToken(error?.type ?? root.type);
|
|
204
|
+
if (code !== undefined && NON_RETRYABLE_CODE_PATTERN.test(code)) return undefined;
|
|
205
|
+
// A flat retryable code/type is itself an in-band failure: Responses-API
|
|
206
|
+
// `error` events expose `{ type: "rate_limit_error" }` with no `error` member,
|
|
207
|
+
// and their handler passes the inner error object (not the whole event).
|
|
208
|
+
const retryableCode = code !== undefined && IN_BAND_RETRYABLE_TEXT_PATTERN.test(code);
|
|
209
|
+
// Only an explicit error member, a top-level status/code/message field, or a
|
|
210
|
+
// standalone throttle type can qualify a frame as a failure; ordinary chunks
|
|
211
|
+
// carry none of these. A bare `type` is present on every Responses event, so
|
|
212
|
+
// it only counts when it is itself retryable wording.
|
|
213
|
+
if (
|
|
214
|
+
!retryableCode &&
|
|
215
|
+
nested === undefined &&
|
|
216
|
+
root.status === undefined &&
|
|
217
|
+
root.code === undefined &&
|
|
218
|
+
root.message === undefined
|
|
219
|
+
) {
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
const detail =
|
|
223
|
+
readInBandDetail(error?.message) ??
|
|
224
|
+
readInBandDetail(root.message) ??
|
|
225
|
+
(typeof nested === "string" ? readInBandDetail(nested) : undefined);
|
|
226
|
+
const holder = error ?? root;
|
|
227
|
+
// Reported statuses come from fields only: the error member's `status`, its
|
|
228
|
+
// numeric `code`, then the same on the root (the flat `{ code: 429 }` /
|
|
229
|
+
// `{ status: 429 }` bodies compat hosts emit). Anything outside 429/5xx is
|
|
230
|
+
// ignored outright — an in-band `400`/`401`/`403` field must not become a
|
|
231
|
+
// synthetic HTTP contract, or a body that merely names an auth problem would
|
|
232
|
+
// route into the credential lane.
|
|
233
|
+
const reported = holder === root ? [root.status, root.code] : [holder.status, holder.code, root.status, root.code];
|
|
234
|
+
const status =
|
|
235
|
+
reported.map(readStatusField).find(isRetryableStatus) ??
|
|
236
|
+
(code !== undefined && Object.hasOwn(RETRYABLE_STATUS_BY_CODE, code)
|
|
237
|
+
? readStatusField(RETRYABLE_STATUS_BY_CODE[code])
|
|
238
|
+
: undefined);
|
|
239
|
+
if (status !== undefined) return { status, code, detail };
|
|
240
|
+
// No reported status: classify only when the upstream *message* is itself
|
|
241
|
+
// unambiguous throttle wording. A generic code alone must not qualify —
|
|
242
|
+
// Azure uses `server_error` for terminal backend failures whose
|
|
243
|
+
// `"<code>: <message>"` envelope the caller already reports (and whose text
|
|
244
|
+
// the shared transient rule already matches), so intercepting it would
|
|
245
|
+
// change an established error format without adding retry information.
|
|
246
|
+
if (detail === undefined || !IN_BAND_RETRYABLE_TEXT_PATTERN.test(detail)) return undefined;
|
|
247
|
+
return { code, detail };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Compose the message for a status-bearing in-band failure. The numeric status
|
|
252
|
+
* leads (matching `captureOpenAIHttpError`'s `"<status> <detail>"` phrasing)
|
|
253
|
+
* unless the detail already carries it as its leading token; the machine code is
|
|
254
|
+
* appended only when it adds information the text classifier or a human reader
|
|
255
|
+
* can use. A detail that leaves the whole line opaque is replaced by the
|
|
256
|
+
* placeholder, so a body we generated can never be read as "the server said
|
|
257
|
+
* nothing".
|
|
258
|
+
*/
|
|
259
|
+
function formatInBandMessage(status: number, detail: string | undefined, code: string | undefined): string {
|
|
260
|
+
const body = detail ?? IN_BAND_DETAIL_PLACEHOLDER;
|
|
261
|
+
const suffix =
|
|
262
|
+
code !== undefined && !/^\d+$/.test(code) && !body.toLowerCase().includes(code.toLowerCase()) ? ` (${code})` : "";
|
|
263
|
+
let message = readLeadingStatus(body) === status ? body : `${status} ${body}`;
|
|
264
|
+
if (isOpaqueStatusBody(message)) message = `${status} ${IN_BAND_DETAIL_PLACEHOLDER}`;
|
|
265
|
+
return `${message}${suffix}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Build the classified error for an in-band failure frame, or `undefined` when
|
|
270
|
+
* the frame is not a retryable in-band failure (in which case the caller keeps
|
|
271
|
+
* its existing handling and message).
|
|
272
|
+
*
|
|
273
|
+
* @param frame decoded SSE `data:` payload, or the `{ error, response }` subset of one
|
|
274
|
+
*/
|
|
275
|
+
export function createInBandProviderError(frame: unknown): Error | undefined {
|
|
276
|
+
const signal = readInBandSignal(frame);
|
|
277
|
+
if (!signal) return undefined;
|
|
278
|
+
const { status, code, detail } = signal;
|
|
279
|
+
if (isRetryableStatus(status)) {
|
|
280
|
+
return attach(new ProviderHttpError(formatInBandMessage(status, detail, code), status, { code }), IN_BAND_FLAGS);
|
|
281
|
+
}
|
|
282
|
+
if (detail === undefined && code === undefined) return undefined;
|
|
283
|
+
// Keep the upstream code visible (`(<code>)`) — it is real provider data and
|
|
284
|
+
// the same convention the Anthropic provider already uses for its
|
|
285
|
+
// `(<errorType>)` suffix.
|
|
286
|
+
return attach(
|
|
287
|
+
new ProviderResponseError(`${detail ?? IN_BAND_DETAIL_PLACEHOLDER}${code ? ` (${code})` : ""}`, {
|
|
288
|
+
kind: "runtime",
|
|
289
|
+
}),
|
|
290
|
+
IN_BAND_FLAGS,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Build the classified error for a non-JSON SSE frame: gateways and reverse
|
|
296
|
+
* proxies that answer `data: 429 Too Many Requests` or an HTML throttle page
|
|
297
|
+
* instead of an OpenAI envelope. `undefined` when the text is not recognisable
|
|
298
|
+
* as a throttle, so genuinely malformed payloads keep failing loudly.
|
|
299
|
+
*/
|
|
300
|
+
export function createInBandProviderErrorFromText(text: string): Error | undefined {
|
|
301
|
+
const detail = readInBandDetail(text);
|
|
302
|
+
if (detail === undefined || !IN_BAND_RETRYABLE_TEXT_PATTERN.test(detail)) return undefined;
|
|
303
|
+
const status = readLeadingStatus(detail);
|
|
304
|
+
if (isRetryableStatus(status)) {
|
|
305
|
+
return attach(new ProviderHttpError(formatInBandMessage(status, detail, undefined), status), IN_BAND_FLAGS);
|
|
306
|
+
}
|
|
307
|
+
// A proxy status line with no machine code to preserve: report the upstream
|
|
308
|
+
// text verbatim rather than padding it with wording of ours.
|
|
309
|
+
return attach(new ProviderResponseError(detail, { kind: "runtime" }), IN_BAND_FLAGS);
|
|
310
|
+
}
|
package/src/error/flags.ts
CHANGED
|
@@ -163,6 +163,28 @@ export const PYTHON_HTTP_INCOMPLETE_CHUNK_PATTERN =
|
|
|
163
163
|
/peer closed connection without sending complete message body \(incomplete chunked read\)/;
|
|
164
164
|
/** reqwest body-frame failures forwarded by the Codex HTTP proxy. */
|
|
165
165
|
export const CODEX_HTTP_BODY_READ_ERROR_PATTERN = /\btransport error reading codex response body\b/i;
|
|
166
|
+
|
|
167
|
+
const RESPONSES_REQUEST_BODY_READ_TIMEOUT_PATTERN = /\btimed out reading request body\b/i;
|
|
168
|
+
|
|
169
|
+
/** Exact HTTP request-body-read timeout diagnostic. */
|
|
170
|
+
export function isRequestBodyReadTimeout(status: number | undefined, message: string | undefined): boolean {
|
|
171
|
+
return status === 408 && RESPONSES_REQUEST_BODY_READ_TIMEOUT_PATTERN.test(message ?? "");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Exact pre-output Responses 408 that needs a changed-request recovery path. */
|
|
175
|
+
export function isResponsesRequestBodyReadTimeout(message: {
|
|
176
|
+
api?: Api;
|
|
177
|
+
errorStatus?: number;
|
|
178
|
+
errorMessage?: string;
|
|
179
|
+
requestBodyReadTimeoutFullReplay?: boolean;
|
|
180
|
+
}): boolean {
|
|
181
|
+
return (
|
|
182
|
+
message.api === "openai-responses" &&
|
|
183
|
+
message.requestBodyReadTimeoutFullReplay === true &&
|
|
184
|
+
isRequestBodyReadTimeout(message.errorStatus, message.errorMessage)
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
166
188
|
export const TRANSIENT_TRANSPORT_PATTERN =
|
|
167
189
|
/\b(?:no[_ -]?capacity|(?:high|peak)[ _-]?demand|(?:at|over|insufficient)[ _-]?capacity|capacity[ _-]?(?:exceeded|exhausted)|peak[ _-]?load)\b|overloaded|provider.?returned.?error|rate.?limit|too many requests|auth-gateway\s+5\d{2}(?=[:\s]|$)|\b(?:429|500|502|503|504)\b|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|nghttp2_(?:internal_error|refused_stream)|stream closed with error code nghttp2_(?:internal_error|refused_stream)|malformed.?function.?call/i;
|
|
168
190
|
const AUTH_FAILURE_PATTERN =
|
|
@@ -503,21 +525,24 @@ function classifyText(
|
|
|
503
525
|
}
|
|
504
526
|
if (isTimeoutText(errorMessage)) kinds |= Flag.Transient | Flag.Timeout;
|
|
505
527
|
else if (isTransientErrorText(errorMessage)) kinds |= Flag.Transient;
|
|
506
|
-
// A stream truncation or forwarded Codex HTTP
|
|
507
|
-
// match TRANSIENT_TRANSPORT_PATTERN. Flag it
|
|
508
|
-
// the turn-recovery layer treat it as
|
|
509
|
-
// retry path (isProviderRetryableError).
|
|
510
|
-
// the else-if) so a timeout whose text also
|
|
511
|
-
// Flag.Timeout alongside Flag.Transient. The
|
|
512
|
-
// STREAM_PARSE_DIAGNOSTIC_PATTERN, per the
|
|
513
|
-
// Skip a
|
|
514
|
-
// request rejected as "400 unexpected EOF"):
|
|
515
|
-
// error that replays identically, so keep it
|
|
516
|
-
// the outer terminal status down the cause
|
|
517
|
-
// (ProviderHttpError 400 → cause "unexpected
|
|
528
|
+
// A stream truncation, transport-level stream drop, or forwarded Codex HTTP
|
|
529
|
+
// body-read failure may not match TRANSIENT_TRANSPORT_PATTERN. Flag it
|
|
530
|
+
// explicitly so AIError.retriable and the turn-recovery layer treat it as
|
|
531
|
+
// retryable, matching the provider retry path (isProviderRetryableError).
|
|
532
|
+
// Separate `if` (not chained onto the else-if) so a timeout whose text also
|
|
533
|
+
// reads as a truncation keeps Flag.Timeout alongside Flag.Transient. The
|
|
534
|
+
// string arm applies the strict STREAM_PARSE_DIAGNOSTIC_PATTERN, per the
|
|
535
|
+
// rationale on isTransientStreamParseError. Skip a phrase that rides on a
|
|
536
|
+
// terminal 4xx (e.g. a malformed request rejected as "400 unexpected EOF"):
|
|
537
|
+
// that is a deterministic client error that replays identically, so keep it
|
|
538
|
+
// terminal. classify() carries the outer terminal status down the cause
|
|
539
|
+
// chain so a wrapped truncation (ProviderHttpError 400 → cause "unexpected
|
|
540
|
+
// EOF") is caught here too.
|
|
518
541
|
if (
|
|
519
542
|
!isTerminalClientErrorStatus(statusClean) &&
|
|
520
|
-
(isTransientStreamParseError(errorMessage) ||
|
|
543
|
+
(isTransientStreamParseError(errorMessage) ||
|
|
544
|
+
isTransientStreamDropError(errorMessage) ||
|
|
545
|
+
CODEX_HTTP_BODY_READ_ERROR_PATTERN.test(errorMessage))
|
|
521
546
|
) {
|
|
522
547
|
kinds |= Flag.Transient;
|
|
523
548
|
}
|
|
@@ -871,6 +896,31 @@ export function isTransientStreamParseError(error: unknown): boolean {
|
|
|
871
896
|
return error instanceof Error && STREAM_PARSE_TRUNCATION_PATTERN.test(error.message);
|
|
872
897
|
}
|
|
873
898
|
|
|
899
|
+
/**
|
|
900
|
+
* Transport-level stream drops: the connection or upstream stream ended before a
|
|
901
|
+
* terminal event, with no JSON-parse signal and no retryable status attached.
|
|
902
|
+
*
|
|
903
|
+
* Distinct from {@link STREAM_PARSE_TRUNCATION_PATTERN} (mid-body JSON
|
|
904
|
+
* truncation) — these name the transport itself dropping (proxy/gateway closing
|
|
905
|
+
* the SSE stream, socket dying before the TLS handshake completes). The wording
|
|
906
|
+
* is the statusless twin of a `408 stream disconnected`, which the status path
|
|
907
|
+
* already retries; an identical replay recovers it, so callers under a
|
|
908
|
+
* non-terminal status treat it as transient (#11805).
|
|
909
|
+
*/
|
|
910
|
+
const STREAM_DROP_PATTERN =
|
|
911
|
+
/stream disconnected before completion|stream closed before response\.completed|stream was interrupted|stream ended before terminal (?:chunk|completion event)|socket disconnected before secure tls connection/i;
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Transport stream-drop diagnostic (see {@link STREAM_DROP_PATTERN}). Unlike
|
|
915
|
+
* {@link isTransientStreamParseError}, one pattern serves both the live `Error`
|
|
916
|
+
* and the persisted-string forms: the phrasings are high-signal enough to trust
|
|
917
|
+
* detached from a transport `Error`.
|
|
918
|
+
*/
|
|
919
|
+
export function isTransientStreamDropError(error: unknown): boolean {
|
|
920
|
+
if (typeof error === "string") return STREAM_DROP_PATTERN.test(error);
|
|
921
|
+
return error instanceof Error && STREAM_DROP_PATTERN.test(error.message);
|
|
922
|
+
}
|
|
923
|
+
|
|
874
924
|
/** Any malformed stream-envelope error (prefix-tagged or out-of-order events). */
|
|
875
925
|
export function isStreamEnvelopeError(error: unknown): boolean {
|
|
876
926
|
return (
|
package/src/error/index.ts
CHANGED
package/src/error/retryable.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { isRetryableError, isUnexpectedSocketCloseMessage } from "@oh-my-pi/pi-u
|
|
|
2
2
|
import {
|
|
3
3
|
CODEX_HTTP_BODY_READ_ERROR_PATTERN,
|
|
4
4
|
isRetryableStreamEnvelopeError,
|
|
5
|
+
isTransientStreamDropError,
|
|
5
6
|
isTransientStreamParseError,
|
|
6
7
|
isUsageLimit,
|
|
7
8
|
status,
|
|
@@ -55,6 +56,7 @@ export function isProviderRetryableError(error: unknown): boolean {
|
|
|
55
56
|
CODEX_HTTP_BODY_READ_ERROR_PATTERN.test(msg) ||
|
|
56
57
|
PROVIDER_TRANSIENT_EXTRA_PATTERN.test(msg) ||
|
|
57
58
|
isTransientStreamParseError(error) ||
|
|
59
|
+
isTransientStreamDropError(error) ||
|
|
58
60
|
isRetryableStreamEnvelopeError(error)
|
|
59
61
|
) {
|
|
60
62
|
return true;
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from "./auth-storage";
|
|
|
8
8
|
export * from "./error/rate-limit";
|
|
9
9
|
export * from "./oneshot-retry";
|
|
10
10
|
export * from "./provider-details";
|
|
11
|
+
export * from "./provider-session-state";
|
|
11
12
|
export * from "./providers/anthropic";
|
|
12
13
|
export * from "./providers/anthropic-client";
|
|
13
14
|
export * from "./providers/azure-openai-responses";
|
package/src/oneshot-retry.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { extractRetryHint } from "@oh-my-pi/pi-utils";
|
|
2
1
|
import * as AIError from "./error";
|
|
3
2
|
import type { AssistantMessage } from "./types";
|
|
4
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
extractProviderRetryHint,
|
|
5
|
+
getHeadersFromError,
|
|
6
|
+
getRetryAfterMsFromHeaders,
|
|
7
|
+
type HeadersLike,
|
|
8
|
+
} from "./utils/retry-after";
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
11
|
* Transient-failure retry for **oneshot** (non-agent-loop) completions.
|
|
@@ -66,6 +70,12 @@ export interface OneshotRetryOptions {
|
|
|
66
70
|
* Thrown errors need no wiring — headers are recovered from the error itself.
|
|
67
71
|
*/
|
|
68
72
|
getResponseHeaders?: () => HeadersLike;
|
|
73
|
+
/**
|
|
74
|
+
* Provider id of the model being retried. Selects the catalog-declared
|
|
75
|
+
* timezone for a timezone-naive absolute reset stamp (Z.AI/Zhipu report
|
|
76
|
+
* Beijing time), so an over-cap wait is not misread as UTC and discarded.
|
|
77
|
+
*/
|
|
78
|
+
provider?: string;
|
|
69
79
|
/** Observability hook. Fires immediately before sleeping. */
|
|
70
80
|
onRetry?: (info: OneshotRetryInfo) => void;
|
|
71
81
|
}
|
|
@@ -195,7 +205,7 @@ export async function retryTransientCompletion(
|
|
|
195
205
|
// errors (e.g. AnthropicApiError) carry their own headers.
|
|
196
206
|
const headers: HeadersLike = thrown !== undefined ? getHeadersFromError(thrown) : options?.getResponseHeaders?.();
|
|
197
207
|
const headerHintMs = getRetryAfterMsFromHeaders(headers);
|
|
198
|
-
const extractedTextHintMs =
|
|
208
|
+
const extractedTextHintMs = extractProviderRetryHint(options?.provider, errorMessage);
|
|
199
209
|
const suffixValue = RETRY_AFTER_MS_SUFFIX.exec(errorMessage)?.[1];
|
|
200
210
|
const parsedSuffixMs = suffixValue === undefined ? undefined : Number(suffixValue);
|
|
201
211
|
const suffixHintMs =
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential-rotation handling for a retained `providerSessionState` map.
|
|
3
|
+
*
|
|
4
|
+
* A host that keeps one provider-session map per logical conversation (the
|
|
5
|
+
* auth-gateway's server-owned store, an in-process omp session) can outlive the
|
|
6
|
+
* credential that filled it: `AuthStorage.markUsageLimitReached` and the
|
|
7
|
+
* auth-retry resolver both switch a session to a sibling account mid-flight.
|
|
8
|
+
* Most of what a provider learns is a property of the *endpoint*, so rebuilding
|
|
9
|
+
* the whole map on a switch would re-pay every rejected round-trip the map
|
|
10
|
+
* exists to avoid. A minority is a property of the *account*, and keeping that
|
|
11
|
+
* across a switch is a bug.
|
|
12
|
+
*
|
|
13
|
+
* Audit of what the retained records hold, per provider:
|
|
14
|
+
*
|
|
15
|
+
* - **Anthropic** — `fastModeDisabled` is account-scoped: the rejection reads
|
|
16
|
+
* "this model does not support fast mode for your account", i.e. a plan
|
|
17
|
+
* entitlement, so a switch to an entitled sibling must re-probe. Its
|
|
18
|
+
* siblings are endpoint-scoped and stay: `strictToolsDisabled`
|
|
19
|
+
* (grammar-too-large 400 for the model's tool schema),
|
|
20
|
+
* `replayUnsignedThinkingDisabled` / `thinkingReplayDisabled` (the endpoint
|
|
21
|
+
* is a signing proxy), `prefixDroppedThinkingBlocks` (blocks the API itself
|
|
22
|
+
* dropped), `controlStates` (per-conversation control baselines).
|
|
23
|
+
* - **OpenAI Responses** — the `previous_response_id` chain baselines are
|
|
24
|
+
* account-scoped: a stored response belongs to the account that created it.
|
|
25
|
+
* Strict-tools / reasoning-effort fallbacks, replay warmup and the chaining
|
|
26
|
+
* circuit breaker are endpoint-scoped and stay.
|
|
27
|
+
* - **OpenAI Completions** — strict-tools and reasoning-effort fallbacks only;
|
|
28
|
+
* both endpoint-scoped. Nothing to reset.
|
|
29
|
+
* - **Codex** — already sub-keys its WebSocket sessions by account id AND
|
|
30
|
+
* bearer (`getCodexWebSocketSessionKey`), so a switch naturally lands on a
|
|
31
|
+
* fresh transport session while the old one stays reachable for teardown.
|
|
32
|
+
* Resetting from the outside would close a socket a retry may still be on.
|
|
33
|
+
* - **Antigravity** — `lastGoodEndpoint` is endpoint-scoped; the agent /
|
|
34
|
+
* conversation ids are conversation-scoped. Neither depends on the account.
|
|
35
|
+
* - **GitLab Duo** — the active workflow is account-bound, but it is a live
|
|
36
|
+
* server-side workflow plus socket, and the switch happens *inside* the
|
|
37
|
+
* request that may still be resuming it. Tearing it down here would abort the
|
|
38
|
+
* very turn that rotated; it stays on its existing session-close path.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { clearAnthropicFastModeFallback } from "./providers/anthropic";
|
|
42
|
+
import { resetOpenAIResponsesAccountScopedState } from "./providers/openai-responses";
|
|
43
|
+
import type { ProviderSessionState } from "./types";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Reset the account-dependent lessons in `states`, keeping everything a
|
|
47
|
+
* provider learned about the endpoint. Call when a retained map is about to be
|
|
48
|
+
* reused for a session whose credential now resolves to a different account.
|
|
49
|
+
*/
|
|
50
|
+
export function resetAccountScopedProviderSessionState(states: Map<string, ProviderSessionState>): void {
|
|
51
|
+
if (states.size === 0) return;
|
|
52
|
+
// Fast mode is the account-scoped half of the Anthropic record; the helper
|
|
53
|
+
// the `/fast on` re-arm path already uses clears exactly that flag.
|
|
54
|
+
clearAnthropicFastModeFallback(states);
|
|
55
|
+
resetOpenAIResponsesAccountScopedState(states);
|
|
56
|
+
}
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* SigV4 signing and decodes the `application/vnd.amazon.eventstream` response.
|
|
6
6
|
* No `@aws-sdk/*`, no `@smithy/*`, no `proxy-agent`. Proxies are honored via
|
|
7
7
|
* Bun's native `HTTPS_PROXY` support.
|
|
8
|
+
*
|
|
9
|
+
* A `models.yml` `baseUrl` is the request origin verbatim (VPC endpoint, gateway, …);
|
|
10
|
+
* only AWS's own regional host is re-pointed at the resolved region. SigV4 unaffected.
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
import type { Effort } from "@oh-my-pi/pi-catalog/effort";
|
|
@@ -145,6 +148,13 @@ const INFERENCE_PROFILE_GEO_DEFAULT_REGION: Record<string, string> = {
|
|
|
145
148
|
jp: "ap-northeast-1",
|
|
146
149
|
};
|
|
147
150
|
|
|
151
|
+
/**
|
|
152
|
+
* AWS's own regional host, which every bundled catalog entry carries as a required
|
|
153
|
+
* placeholder `baseUrl` — no routing info, so its region segment is re-derived.
|
|
154
|
+
* FIPS, VPC-endpoint and gateway hosts don't match and are used as configured.
|
|
155
|
+
*/
|
|
156
|
+
const AWS_REGIONAL_BEDROCK_HOST = /^bedrock-runtime\.[a-z0-9-]+\.amazonaws\.com$/;
|
|
157
|
+
|
|
148
158
|
/** Geo prefix of a cross-region inference-profile id, e.g. `eu.anthropic.…` → `eu`. */
|
|
149
159
|
function inferenceProfileGeo(modelId: string): string | undefined {
|
|
150
160
|
const dot = modelId.indexOf(".");
|
|
@@ -453,9 +463,15 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
|
|
|
453
463
|
// raw dump so the inspector shows exactly what was sent.
|
|
454
464
|
commandInput = { ...commandInput, requestMetadata: sanitizeRequestMetadata(commandInput.requestMetadata) };
|
|
455
465
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
466
|
+
// `baseUrl` is the origin verbatim, path prefix (and query, for gateways
|
|
467
|
+
// that authenticate via a query parameter) included, so a gateway mounted
|
|
468
|
+
// under a path works. AWS's own host is re-pointed: the catalog can't know the region.
|
|
469
|
+
const base = new URL(model.baseUrl || `https://bedrock-runtime.${region}.amazonaws.com`);
|
|
470
|
+
if (AWS_REGIONAL_BEDROCK_HOST.test(base.host)) base.host = `bedrock-runtime.${region}.amazonaws.com`;
|
|
471
|
+
const host = base.host;
|
|
472
|
+
const urlPath = `${base.pathname.replace(/\/+$/, "")}/model/${encodeURIComponent(model.id)}/converse-stream`;
|
|
473
|
+
const query = base.search.slice(1) || undefined;
|
|
474
|
+
const url = `${base.origin}${urlPath}${base.search}`;
|
|
459
475
|
rawRequestDump = {
|
|
460
476
|
provider: model.provider,
|
|
461
477
|
api: output.api,
|
|
@@ -527,6 +543,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
|
|
|
527
543
|
method: "POST",
|
|
528
544
|
host,
|
|
529
545
|
path: urlPath,
|
|
546
|
+
query,
|
|
530
547
|
body,
|
|
531
548
|
region,
|
|
532
549
|
service: "bedrock",
|