@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +2 -0
  3. package/dist/types/auth/sqlite-credential-store.d.ts +2 -1
  4. package/dist/types/auth-broker/remote-store.d.ts +17 -0
  5. package/dist/types/auth-gateway/index.d.ts +1 -0
  6. package/dist/types/auth-gateway/session-state.d.ts +118 -0
  7. package/dist/types/auth-storage.d.ts +17 -0
  8. package/dist/types/error/body-error.d.ts +15 -0
  9. package/dist/types/error/flags.d.ts +16 -0
  10. package/dist/types/error/index.d.ts +1 -0
  11. package/dist/types/index.d.ts +1 -0
  12. package/dist/types/oneshot-retry.d.ts +6 -0
  13. package/dist/types/provider-session-state.d.ts +46 -0
  14. package/dist/types/providers/amazon-bedrock.d.ts +3 -0
  15. package/dist/types/providers/aws-sigv4.d.ts +12 -0
  16. package/dist/types/providers/openai-codex/request-transformer.d.ts +27 -0
  17. package/dist/types/providers/openai-responses.d.ts +15 -0
  18. package/dist/types/providers/openai-shared.d.ts +20 -3
  19. package/dist/types/registry/oauth/perplexity.d.ts +1 -7
  20. package/dist/types/registry/oauth/types.d.ts +8 -0
  21. package/dist/types/stream.d.ts +2 -0
  22. package/dist/types/types.d.ts +3 -1
  23. package/dist/types/usage/openai-codex.d.ts +3 -1
  24. package/dist/types/usage.d.ts +11 -1
  25. package/dist/types/utils/block-symbols.d.ts +36 -0
  26. package/dist/types/utils/openai-http.d.ts +2 -0
  27. package/dist/types/utils/retry-after.d.ts +2 -0
  28. package/dist/types/utils/schema/wire.d.ts +4 -5
  29. package/dist/types/utils.d.ts +9 -0
  30. package/package.json +6 -6
  31. package/src/auth/sqlite-credential-store.ts +8 -33
  32. package/src/auth-broker/remote-store.ts +73 -8
  33. package/src/auth-broker/wire-schemas.ts +1 -0
  34. package/src/auth-gateway/index.ts +1 -0
  35. package/src/auth-gateway/server.ts +186 -74
  36. package/src/auth-gateway/session-state.ts +312 -0
  37. package/src/auth-storage.ts +146 -15
  38. package/src/error/body-error.ts +310 -0
  39. package/src/error/flags.ts +63 -13
  40. package/src/error/index.ts +1 -0
  41. package/src/error/retryable.ts +2 -0
  42. package/src/index.ts +1 -0
  43. package/src/oneshot-retry.ts +13 -3
  44. package/src/provider-session-state.ts +56 -0
  45. package/src/providers/amazon-bedrock.ts +20 -3
  46. package/src/providers/anthropic-messages-server.ts +104 -23
  47. package/src/providers/anthropic-signature.ts +5 -2
  48. package/src/providers/anthropic.ts +101 -15
  49. package/src/providers/aws-sigv4.ts +16 -5
  50. package/src/providers/cursor.ts +60 -10
  51. package/src/providers/devin.ts +82 -28
  52. package/src/providers/openai-chat-server.ts +4 -0
  53. package/src/providers/openai-codex/request-transformer.ts +36 -0
  54. package/src/providers/openai-codex-responses.ts +35 -12
  55. package/src/providers/openai-completions.ts +49 -12
  56. package/src/providers/openai-reasoning-fallback.ts +6 -6
  57. package/src/providers/openai-responses-server.ts +2 -1
  58. package/src/providers/openai-responses.ts +52 -4
  59. package/src/providers/openai-shared.ts +199 -51
  60. package/src/registry/oauth/perplexity.ts +94 -28
  61. package/src/registry/oauth/types.ts +9 -0
  62. package/src/stream.ts +23 -2
  63. package/src/types.ts +3 -0
  64. package/src/usage/claude.ts +33 -0
  65. package/src/usage/google-antigravity.ts +8 -2
  66. package/src/usage/openai-codex.ts +94 -11
  67. package/src/usage.ts +8 -1
  68. package/src/utils/block-symbols.ts +57 -0
  69. package/src/utils/http-inspector.ts +20 -0
  70. package/src/utils/openai-http.ts +39 -3
  71. package/src/utils/retry-after.ts +12 -0
  72. package/src/utils/schema/normalize.ts +3 -3
  73. package/src/utils/schema/stamps.ts +33 -45
  74. package/src/utils/schema/wire.ts +9 -7
  75. 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
+ }
@@ -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 body-read failure may not
507
- // match TRANSIENT_TRANSPORT_PATTERN. Flag it explicitly so AIError.retriable and
508
- // the turn-recovery layer treat it as retryable, matching the provider
509
- // retry path (isProviderRetryableError). Separate `if` (not chained onto
510
- // the else-if) so a timeout whose text also reads as a truncation keeps
511
- // Flag.Timeout alongside Flag.Transient. The string arm applies the strict
512
- // STREAM_PARSE_DIAGNOSTIC_PATTERN, per the rationale on isTransientStreamParseError.
513
- // Skip a truncation phrase that rides on a terminal 4xx (e.g. a malformed
514
- // request rejected as "400 unexpected EOF"): that is a deterministic client
515
- // error that replays identically, so keep it terminal. classify() carries
516
- // the outer terminal status down the cause chain so a wrapped truncation
517
- // (ProviderHttpError 400 → cause "unexpected EOF") is caught here too.
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) || CODEX_HTTP_BODY_READ_ERROR_PATTERN.test(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 (
@@ -2,6 +2,7 @@ export * from "./abort";
2
2
  export * from "./auth";
3
3
  export * from "./auth-classify";
4
4
  export * from "./aws";
5
+ export * from "./body-error";
5
6
  export * from "./classes";
6
7
  export * from "./finalize";
7
8
  export * from "./flags";
@@ -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";
@@ -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 { getHeadersFromError, getRetryAfterMsFromHeaders, type HeadersLike } from "./utils/retry-after";
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 = extractRetryHint(undefined, errorMessage);
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
- const host = `bedrock-runtime.${region}.amazonaws.com`;
457
- const url = `https://${host}/model/${encodeURIComponent(model.id)}/converse-stream`;
458
- const urlPath = `/model/${encodeURIComponent(model.id)}/converse-stream`;
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",