@oh-my-pi/pi-ai 18.2.0 → 18.2.1

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 (59) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/types/auth-broker/remote-store.d.ts +17 -0
  3. package/dist/types/auth-gateway/index.d.ts +1 -0
  4. package/dist/types/auth-gateway/session-state.d.ts +65 -0
  5. package/dist/types/auth-storage.d.ts +16 -0
  6. package/dist/types/error/body-error.d.ts +15 -0
  7. package/dist/types/error/flags.d.ts +16 -0
  8. package/dist/types/error/index.d.ts +1 -0
  9. package/dist/types/oneshot-retry.d.ts +6 -0
  10. package/dist/types/providers/openai-codex/request-transformer.d.ts +27 -0
  11. package/dist/types/providers/openai-shared.d.ts +20 -3
  12. package/dist/types/registry/oauth/perplexity.d.ts +1 -7
  13. package/dist/types/registry/oauth/types.d.ts +8 -0
  14. package/dist/types/stream.d.ts +2 -0
  15. package/dist/types/types.d.ts +3 -1
  16. package/dist/types/usage.d.ts +8 -0
  17. package/dist/types/utils/block-symbols.d.ts +36 -0
  18. package/dist/types/utils/openai-http.d.ts +2 -0
  19. package/dist/types/utils/retry-after.d.ts +2 -0
  20. package/dist/types/utils/schema/wire.d.ts +4 -5
  21. package/dist/types/utils.d.ts +9 -0
  22. package/package.json +6 -6
  23. package/src/auth-broker/remote-store.ts +73 -8
  24. package/src/auth-broker/wire-schemas.ts +1 -0
  25. package/src/auth-gateway/index.ts +1 -0
  26. package/src/auth-gateway/server.ts +48 -11
  27. package/src/auth-gateway/session-state.ts +114 -0
  28. package/src/auth-storage.ts +144 -13
  29. package/src/error/body-error.ts +310 -0
  30. package/src/error/flags.ts +63 -13
  31. package/src/error/index.ts +1 -0
  32. package/src/error/retryable.ts +2 -0
  33. package/src/oneshot-retry.ts +13 -3
  34. package/src/providers/anthropic-messages-server.ts +24 -3
  35. package/src/providers/anthropic.ts +101 -15
  36. package/src/providers/cursor.ts +7 -1
  37. package/src/providers/devin.ts +82 -28
  38. package/src/providers/openai-chat-server.ts +4 -0
  39. package/src/providers/openai-codex/request-transformer.ts +36 -0
  40. package/src/providers/openai-codex-responses.ts +35 -12
  41. package/src/providers/openai-completions.ts +43 -12
  42. package/src/providers/openai-reasoning-fallback.ts +6 -6
  43. package/src/providers/openai-responses-server.ts +2 -1
  44. package/src/providers/openai-responses.ts +25 -4
  45. package/src/providers/openai-shared.ts +199 -51
  46. package/src/registry/oauth/perplexity.ts +94 -28
  47. package/src/registry/oauth/types.ts +9 -0
  48. package/src/stream.ts +23 -2
  49. package/src/types.ts +3 -0
  50. package/src/usage/claude.ts +33 -0
  51. package/src/usage/google-antigravity.ts +8 -2
  52. package/src/usage.ts +3 -0
  53. package/src/utils/block-symbols.ts +57 -0
  54. package/src/utils/openai-http.ts +39 -3
  55. package/src/utils/retry-after.ts +12 -0
  56. package/src/utils/schema/normalize.ts +3 -3
  57. package/src/utils/schema/stamps.ts +33 -45
  58. package/src/utils/schema/wire.ts +9 -7
  59. package/src/utils.ts +67 -22
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { createHash } from "node:crypto";
11
11
  import { planRequirementFor } from "@oh-my-pi/pi-catalog/compat/behavior";
12
- import { $env, $envExact, extractRetryHint, getAgentDbPath, logger, untilAborted } from "@oh-my-pi/pi-utils";
12
+ import { $env, $envExact, getAgentDbPath, logger, untilAborted } from "@oh-my-pi/pi-utils";
13
13
  import {
14
14
  isSqliteCorruptionError,
15
15
  resolveCredentialIdentityKey,
@@ -31,6 +31,7 @@ import type {
31
31
  } from "./registry/oauth/types";
32
32
  import { AUTHENTICATED_SENTINEL } from "./registry/types";
33
33
  import { getEnvApiKey, getEnvApiKeyName } from "./stream";
34
+ import { extractProviderRetryHint } from "./utils/retry-after";
34
35
  import type { Provider } from "./types";
35
36
  import type {
36
37
  ClientUsageIdentity,
@@ -765,6 +766,11 @@ export { isDefinitiveOAuthFailure } from "./error/auth-classify";
765
766
  * the usage report reveals. Callers that wait the account out (instead of
766
767
  * rotating) must sleep until this, not the error-text hint alone.
767
768
  *
769
+ * `requestedBlockedUntilMs` (epoch ms) is this mark call's initial deadline,
770
+ * before usage-report correction and longest-wins merging. Callers use it to
771
+ * distinguish the call's replaceable heuristic from a longer merged block
772
+ * that credential selection will continue enforcing.
773
+ *
768
774
  * `priorBlockedUntilMs` (epoch ms) is the live block deadline the map already
769
775
  * stored for this credential before this call. The merged `blockedUntilMs`
770
776
  * masks a pre-existing block shorter than this call's own heuristic
@@ -790,6 +796,8 @@ export interface UsageLimitMarkResult {
790
796
  switched: boolean;
791
797
  retryAtMs?: number;
792
798
  blockedUntilMs?: number;
799
+ /** This mark call's initial deadline, before report correction and merging. */
800
+ requestedBlockedUntilMs?: number;
793
801
  priorBlockedUntilMs?: number;
794
802
  priorBlockedUntilTimed?: boolean;
795
803
  reportResetAtMs?: number;
@@ -1353,7 +1361,7 @@ export class AuthStorage {
1353
1361
  /** Tracks the last used credential per provider for a session (used for rate-limit switching). */
1354
1362
  #sessionLastCredential: Map<
1355
1363
  string,
1356
- Map<string, { type: AuthCredential["type"]; index: number; lastUsedAtMs?: number }>
1364
+ Map<string, { type: AuthCredential["type"]; index: number; credentialId?: number; lastUsedAtMs?: number }>
1357
1365
  > = new Map();
1358
1366
  /** Recent bearer fingerprints resolved for each durable OAuth row; used only for delayed usage-limit attribution. */
1359
1367
  #oauthBearerFingerprints: Map<string, Map<number, string[]>> = new Map();
@@ -1490,6 +1498,36 @@ export class AuthStorage {
1490
1498
  return true;
1491
1499
  }
1492
1500
 
1501
+ /**
1502
+ * Adopt credentials another process committed before selecting or rotating.
1503
+ *
1504
+ * The store is shared across every omp process, but the pool is an
1505
+ * in-process cache refreshed only by this process's own writes. Without
1506
+ * this a long-running session ranks a stale pool for its whole lifetime:
1507
+ * `omp auth` in another terminal is invisible, rotation reports no usable
1508
+ * sibling while a freshly added account sits unblocked in SQLite, and the
1509
+ * turn degrades to the fallback chain. The auth-broker path already polls;
1510
+ * direct-store sessions had no equivalent.
1511
+ *
1512
+ * A poll is two cheap reads (`PRAGMA data_version` plus the auth revision)
1513
+ * and re-lists credentials only when another connection committed, so it
1514
+ * runs on every resolution rather than on a timer that would make recovery
1515
+ * depend on wall-clock spacing. It sits on the paths that read the pool —
1516
+ * OAuth selection, and the two public usage-limit entry points — and is
1517
+ * idempotent, so a rotation reached through `markUsageLimitReached` costs
1518
+ * one extra `data_version` read and no second reload.
1519
+ */
1520
+ async #adoptExternalCredentialChanges(): Promise<void> {
1521
+ if (this.#closed || this.#store.pollExternalChanges === undefined) return;
1522
+ try {
1523
+ await this.pollExternalChanges();
1524
+ } catch (error) {
1525
+ // A failed poll must not fail credential resolution: the in-memory
1526
+ // pool is still serviceable, just possibly stale.
1527
+ logger.debug("External credential poll failed", { error: String(error) });
1528
+ }
1529
+ }
1530
+
1493
1531
  onGenerationChanged(listener: (generation: number) => void): () => void {
1494
1532
  this.#generationListeners.add(listener);
1495
1533
  return () => {
@@ -2084,12 +2122,12 @@ export class AuthStorage {
2084
2122
  ): void {
2085
2123
  if (!sessionId) return;
2086
2124
  const nowMs = lastUsedAtMs ?? Date.now();
2125
+ const credentialId = this.#getStoredCredentials(provider)[index]?.id;
2087
2126
  const sessionMap = this.#sessionLastCredential.get(provider) ?? new Map();
2088
- sessionMap.set(sessionId, { type, index, lastUsedAtMs: nowMs });
2127
+ sessionMap.set(sessionId, { type, index, credentialId, lastUsedAtMs: nowMs });
2089
2128
  this.#sessionLastCredential.set(provider, sessionMap);
2090
2129
 
2091
2130
  try {
2092
- const credentialId = this.#getStoredCredentials(provider)[index]?.id;
2093
2131
  if (credentialId !== undefined) {
2094
2132
  const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
2095
2133
  const cacheValue = JSON.stringify({
@@ -2111,11 +2149,24 @@ export class AuthStorage {
2111
2149
  #getSessionCredential(
2112
2150
  provider: string,
2113
2151
  sessionId: string | undefined,
2114
- ): { type: AuthCredential["type"]; index: number; lastUsedAtMs?: number } | undefined {
2152
+ ): { type: AuthCredential["type"]; index: number; credentialId?: number; lastUsedAtMs?: number } | undefined {
2115
2153
  if (!sessionId) return undefined;
2116
2154
  let sessionMap = this.#sessionLastCredential.get(provider);
2117
- if (sessionMap?.has(sessionId)) {
2118
- return sessionMap.get(sessionId);
2155
+ const live = sessionMap?.get(sessionId);
2156
+ if (live) {
2157
+ // Another process can add or drop rows mid-session and the pool is an
2158
+ // index-ordered snapshot, so re-resolve the pin through its durable row
2159
+ // id: a compacted array must not point the session at a different
2160
+ // account, and a deleted account must not hand its slot to a sibling.
2161
+ if (live.credentialId === undefined) return live;
2162
+ const stored = this.#getStoredCredentials(provider);
2163
+ const actualIndex = stored.findIndex(entry => entry.id === live.credentialId);
2164
+ if (actualIndex === -1 || stored[actualIndex]?.credential.type !== live.type) {
2165
+ sessionMap?.delete(sessionId);
2166
+ return undefined;
2167
+ }
2168
+ live.index = actualIndex;
2169
+ return live;
2119
2170
  }
2120
2171
  try {
2121
2172
  const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
@@ -2149,6 +2200,7 @@ export class AuthStorage {
2149
2200
  const sessionVal = {
2150
2201
  type: val.type,
2151
2202
  index: val.index,
2203
+ credentialId: val.credentialId,
2152
2204
  lastUsedAtMs: val.lastUsedAtMs,
2153
2205
  };
2154
2206
  sessionMap.set(sessionId, sessionVal);
@@ -3145,6 +3197,7 @@ export class AuthStorage {
3145
3197
  onProgress: ctrl.onProgress,
3146
3198
  onPrompt: ctrl.onPrompt,
3147
3199
  onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
3200
+ onBrowserSession: ctrl.onBrowserSession,
3148
3201
  signal: ctrl.signal,
3149
3202
  fetch: ctrl.fetch,
3150
3203
  });
@@ -4204,7 +4257,14 @@ export class AuthStorage {
4204
4257
  const credentialType = entry.credential.type;
4205
4258
  const providerKey = this.#getProviderTypeKey(provider, credentialType);
4206
4259
  let blockedUntil = this.#getCredentialBlockedUntil(provider, providerKey, index, blockScopes);
4207
- if (blockedUntil !== undefined && provider !== "openai-codex") {
4260
+ // A block under a scope the strategy can vouch for must still fetch
4261
+ // a probe report, or it outlives the recovery that report would
4262
+ // prove: no report means no reconciliation, so the credential idles
4263
+ // until the clock runs out even after quota is restored.
4264
+ if (
4265
+ blockedUntil !== undefined &&
4266
+ !this.#blockedCredentialCanHeal(provider, providerKey, index, blockScopes)
4267
+ ) {
4208
4268
  return {
4209
4269
  credentialId: entry.id,
4210
4270
  credentialType,
@@ -4231,7 +4291,7 @@ export class AuthStorage {
4231
4291
  planEligibilityByCredential.set(entry.id, getOpenAICodexPlanEligibility(report, planRequirement));
4232
4292
  }
4233
4293
 
4234
- if (provider === "openai-codex") {
4294
+ if (this.#supportsUsageBlockHealing(provider)) {
4235
4295
  blockedUntil = this.#getCredentialBlockedUntil(provider, providerKey, index, blockScopes);
4236
4296
  }
4237
4297
  if (blockedUntil !== undefined) {
@@ -4795,6 +4855,7 @@ export class AuthStorage {
4795
4855
  signal?: AbortSignal;
4796
4856
  },
4797
4857
  ): Promise<UsageLimitMarkResult> {
4858
+ await this.#adoptExternalCredentialChanges();
4798
4859
  let sessionCredential = await this.#resolveCredentialTarget(provider, sessionId, {
4799
4860
  credentialId: options?.credentialId,
4800
4861
  apiKey: options?.apiKey,
@@ -4820,7 +4881,8 @@ export class AuthStorage {
4820
4881
 
4821
4882
  const routing = this.#credentialBlockRouting(provider, credentialType, options?.modelId);
4822
4883
  const now = Date.now();
4823
- let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
4884
+ const requestedBlockedUntilMs = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
4885
+ let blockedUntil = requestedBlockedUntilMs;
4824
4886
  // Heuristic/default fallbacks are guesses; provider-stated hints and
4825
4887
  // report-derived extensions are timed.
4826
4888
  let providerTimed = options?.providerTimed === true;
@@ -4871,7 +4933,11 @@ export class AuthStorage {
4871
4933
  routing,
4872
4934
  providerTimed,
4873
4935
  );
4874
- return reportResetAtMs === undefined ? rotation : { ...rotation, reportResetAtMs };
4936
+ return {
4937
+ ...rotation,
4938
+ requestedBlockedUntilMs,
4939
+ ...(reportResetAtMs === undefined ? {} : { reportResetAtMs }),
4940
+ };
4875
4941
  }
4876
4942
 
4877
4943
  #resolveWindowResetAt(window: UsageLimit["window"]): number | undefined {
@@ -5011,7 +5077,15 @@ export class AuthStorage {
5011
5077
  );
5012
5078
  let usage: UsageReport | null = null;
5013
5079
  let usageChecked = false;
5014
- if (blockedUntil !== undefined && args.provider === "openai-codex") {
5080
+ if (
5081
+ blockedUntil !== undefined &&
5082
+ this.#blockedCredentialCanHeal(
5083
+ args.provider,
5084
+ args.providerKey,
5085
+ selection.index,
5086
+ args.blockScopes ?? args.blockScope,
5087
+ )
5088
+ ) {
5015
5089
  usage = await this.#getUsageReport(args.provider, selection.credential, {
5016
5090
  ...args.options,
5017
5091
  timeoutMs: this.#usageRequestTimeoutMs,
@@ -5136,6 +5210,7 @@ export class AuthStorage {
5136
5210
  sessionId?: string,
5137
5211
  options?: AuthApiKeyOptions,
5138
5212
  ): Promise<OAuthResolutionResult | undefined> {
5213
+ await this.#adoptExternalCredentialChanges();
5139
5214
  const credentials = this.#getCredentialsForProvider(provider)
5140
5215
  .map((credential, index) => ({ credential, index }))
5141
5216
  .filter((entry): entry is { credential: OAuthCredential; index: number } => entry.credential.type === "oauth");
@@ -5889,6 +5964,8 @@ export class AuthStorage {
5889
5964
  return configKey;
5890
5965
  }
5891
5966
 
5967
+ await this.#adoptExternalCredentialChanges();
5968
+
5892
5969
  // Precedence: a deliberate OAuth/login credential wins, then an explicit env var,
5893
5970
  // then a stored static api_key (which may be a stale broker-migrated copy) as a last resort.
5894
5971
  const oauthSelection = this.#selectCredentialByType(provider, "oauth");
@@ -6164,6 +6241,32 @@ export class AuthStorage {
6164
6241
  return true;
6165
6242
  }
6166
6243
 
6244
+ /**
6245
+ * Copy every stored credential affinity from one live session to another.
6246
+ *
6247
+ * The target receives its own sticky entries, so request resolution, usage
6248
+ * blocking, credential rotation, metadata, and persisted pins all continue
6249
+ * through the target session id without retaining a live dependency on the
6250
+ * source session.
6251
+ */
6252
+ inheritSessionCredentials(sourceSessionId: string, targetSessionId: string): number {
6253
+ if (!sourceSessionId || !targetSessionId || sourceSessionId === targetSessionId) return 0;
6254
+ let inherited = 0;
6255
+ for (const provider of this.#data.keys()) {
6256
+ const credential = this.#getSessionCredential(provider, sourceSessionId);
6257
+ if (!credential) continue;
6258
+ this.#recordSessionCredential(
6259
+ provider,
6260
+ targetSessionId,
6261
+ credential.type,
6262
+ credential.index,
6263
+ credential.lastUsedAtMs,
6264
+ );
6265
+ inherited += 1;
6266
+ }
6267
+ return inherited;
6268
+ }
6269
+
6167
6270
  /**
6168
6271
  * Resolve every stored OAuth credential for `provider` independently.
6169
6272
  *
@@ -6612,6 +6715,29 @@ export class AuthStorage {
6612
6715
  );
6613
6716
  }
6614
6717
 
6718
+ /**
6719
+ * Whether a fresh report could lift what currently blocks this credential.
6720
+ *
6721
+ * A strategy that names healable scopes can only vouch for those scopes, so
6722
+ * a live unscoped block — an Opus/Sonnet usage limit, a refresh failure —
6723
+ * keeps the credential unusable whatever the report says about a tier. A
6724
+ * probe then cannot change the outcome and must not be spent; the tier scope
6725
+ * heals on a later pass, once the block that actually holds the credential
6726
+ * has lifted. Codex heals through its meter metadata rather than named
6727
+ * scopes, so its blocks always qualify.
6728
+ */
6729
+ #blockedCredentialCanHeal(
6730
+ provider: Provider,
6731
+ providerKey: string,
6732
+ credentialIndex: number,
6733
+ blockScopeOrScopes: string | readonly string[] | undefined,
6734
+ ): boolean {
6735
+ if (!this.#supportsUsageBlockHealing(provider)) return false;
6736
+ if (this.#rankingStrategyResolver?.(provider)?.healableBlockScopes === undefined) return true;
6737
+ if (this.#getCredentialBlockedUntil(provider, providerKey, credentialIndex) !== undefined) return false;
6738
+ return this.#getCredentialBlockedUntil(provider, providerKey, credentialIndex, blockScopeOrScopes) !== undefined;
6739
+ }
6740
+
6615
6741
  /**
6616
6742
  * Self-heal stale usage-limit blocks: when a fresh live usage report says a
6617
6743
  * scope is below every limit gating it, drop its persisted and in-memory
@@ -6626,6 +6752,10 @@ export class AuthStorage {
6626
6752
  if (credentialIndex < 0) return;
6627
6753
  const strategy = this.#rankingStrategyResolver?.(provider);
6628
6754
  if (provider !== "openai-codex") {
6755
+ // Only a live report proves recovery. A broker can serve its retained
6756
+ // last-good report for hours after `/usage` starts failing, and those
6757
+ // healthy limits describe the account before the 429 that blocked it.
6758
+ if (!Number.isFinite(report.fetchedAt) || Date.now() - report.fetchedAt > USAGE_REPORT_TTL_MS) return;
6629
6759
  for (const { blockScope, limits } of strategy?.healableBlockScopes?.(report) ?? []) {
6630
6760
  if (limits.length === 0 || this.#isUsageLimitReached(limits)) continue;
6631
6761
  this.#clearHealedBlockScope(provider, providerKey, credentialId, credentialIndex, blockScope);
@@ -6861,6 +6991,7 @@ export class AuthStorage {
6861
6991
  signal?: AbortSignal;
6862
6992
  },
6863
6993
  ): Promise<boolean> {
6994
+ await this.#adoptExternalCredentialChanges();
6864
6995
  const error = options?.error;
6865
6996
  const status = AIError.status(error);
6866
6997
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
@@ -6870,7 +7001,7 @@ export class AuthStorage {
6870
7001
  // Thread the provider-specified reset window (e.g. Devin "Your limit
6871
7002
  // will reset in 13 minutes") into the block duration so the credential
6872
7003
  // is not reselected and hammered while the cap remains active.
6873
- const retryAfterMs = extractRetryHint(undefined, message);
7004
+ const retryAfterMs = extractProviderRetryHint(provider, message);
6874
7005
  return (
6875
7006
  await this.markUsageLimitReached(provider, sessionId, {
6876
7007
  retryAfterMs,
@@ -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
+ }