@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
@@ -1,15 +1,6 @@
1
1
  /**
2
- * Perplexity login and token refresh.
3
- *
4
- * Login paths (in priority order):
5
- * 1. macOS native app: reads JWT from NSUserDefaults (`defaults read ai.perplexity.mac authToken`)
6
- * 2. HTTP email OTP: `GET /api/auth/csrf` → `POST /api/auth/signin-email` → `POST /api/auth/signin-otp`
7
- *
8
- * No browser or manual cookie paste required.
9
- * Refresh: Socket.IO `refreshJWT` RPC over authenticated WebSocket connection.
10
- *
11
- * Protocol: Engine.IO v4 + Socket.IO v4 over WebSocket (bypasses Cloudflare managed challenge).
12
- * Architecture reverse-engineered from Perplexity macOS app (ai.perplexity.mac).
2
+ * Perplexity login via legacy macOS session borrowing, host-managed browser SSO,
3
+ * or HTTP email OTP (including authenticator challenges).
13
4
  */
14
5
  import * as os from "node:os";
15
6
  import { $env } from "@oh-my-pi/pi-utils";
@@ -80,10 +71,7 @@ function jwtToCredentials(jwt: string, email?: string): OAuthCredentials {
80
71
  // Desktop app extraction
81
72
  // ---------------------------------------------------------------------------
82
73
 
83
- /**
84
- * Read the Perplexity JWT from the native macOS Catalyst app's UserDefaults.
85
- * Tokens are stored in NSUserDefaults (not Keychain), readable by any same-UID process.
86
- */
74
+ /** Read the legacy ai.perplexity.mac app's session; newer Mac apps use a restricted Keychain. */
87
75
  async function extractFromNativeApp(): Promise<string | null> {
88
76
  if (os.platform() !== "darwin") return null;
89
77
 
@@ -99,7 +87,7 @@ async function extractFromNativeApp(): Promise<string | null> {
99
87
  }
100
88
 
101
89
  // ---------------------------------------------------------------------------
102
- // Socket.IO email OTP login
90
+ // HTTP email OTP login
103
91
  // ---------------------------------------------------------------------------
104
92
 
105
93
  /**
@@ -274,32 +262,110 @@ async function httpEmailLogin(ctrl: OAuthController): Promise<OAuthCredentials>
274
262
  return jwtToCredentials(token, trimmedEmail);
275
263
  }
276
264
 
265
+ // ---------------------------------------------------------------------------
266
+ // Browser SSO login
267
+ // ---------------------------------------------------------------------------
268
+
269
+ const SESSION_COOKIE_NAME = "__Secure-next-auth.session-token";
270
+ const PERPLEXITY_BASE_URL = "https://www.perplexity.ai";
271
+
272
+ async function browserSsoLogin(ctrl: OAuthController): Promise<OAuthCredentials> {
273
+ if (!ctrl.onBrowserSession) {
274
+ throw new AIError.OAuthError("Browser SSO is unavailable in this client", {
275
+ kind: "validation",
276
+ provider: "perplexity",
277
+ });
278
+ }
279
+ ctrl.onProgress?.("Complete Perplexity sign-in in the browser window. Choose SSO for your organization.");
280
+ const token = (
281
+ await ctrl.onBrowserSession(
282
+ {
283
+ url: `${PERPLEXITY_BASE_URL}/auth/signin`,
284
+ cookieNames: [SESSION_COOKIE_NAME, "next-auth.session-token"],
285
+ },
286
+ ctrl.signal,
287
+ )
288
+ ).trim();
289
+ if (ctrl.signal?.aborted) throw new AIError.LoginCancelledError();
290
+ if (!token || /[\s;]/.test(token)) {
291
+ throw new AIError.OAuthError("Perplexity SSO captured an invalid session cookie", {
292
+ kind: "validation",
293
+ provider: "perplexity",
294
+ });
295
+ }
296
+
297
+ ctrl.onProgress?.("Validating Perplexity session...");
298
+ const response = await (ctrl.fetch ?? fetch)(`${PERPLEXITY_BASE_URL}/api/auth/session`, {
299
+ headers: {
300
+ Cookie: `${SESSION_COOKIE_NAME}=${token}`,
301
+ "User-Agent": APP_USER_AGENT,
302
+ "X-App-ApiVersion": API_VERSION,
303
+ },
304
+ redirect: "error",
305
+ signal: ctrl.signal,
306
+ });
307
+ if (!response.ok) {
308
+ throw new AIError.ProviderHttpError(`Perplexity session validation failed (${response.status})`, response.status);
309
+ }
310
+ let email: unknown;
311
+ try {
312
+ const session = (await response.json()) as { user?: { email?: unknown } } | null;
313
+ email = session?.user?.email;
314
+ } catch (error) {
315
+ if (ctrl.signal?.aborted) throw new AIError.LoginCancelledError();
316
+ if (!(error instanceof SyntaxError)) throw error;
317
+ throw new AIError.OAuthError("Perplexity returned an invalid session response", {
318
+ kind: "validation",
319
+ provider: "perplexity",
320
+ });
321
+ }
322
+ if (ctrl.signal?.aborted) throw new AIError.LoginCancelledError();
323
+ if (typeof email !== "string" || !email.trim()) {
324
+ throw new AIError.OAuthError("Perplexity session is invalid or expired. Sign in again.", {
325
+ kind: "validation",
326
+ provider: "perplexity",
327
+ });
328
+ }
329
+ return jwtToCredentials(token, email.trim());
330
+ }
331
+
277
332
  // ---------------------------------------------------------------------------
278
333
  // Public API
279
334
  // ---------------------------------------------------------------------------
280
335
 
281
- /**
282
- * Login to Perplexity.
283
- *
284
- * Tries auto-extraction from the desktop app, then runs HTTP email OTP login.
285
- *
286
- * No browser/manual token paste fallback is used.
287
- */
336
+ /** Prefer legacy app borrowing, then offer browser SSO when the host supports it. */
288
337
  export async function loginPerplexity(ctrl: OAuthController): Promise<OAuthCredentials> {
289
- if (!ctrl.onPrompt) {
290
- throw new AIError.OnPromptRequiredError("Perplexity");
291
- }
338
+ if (!ctrl.onPrompt) throw new AIError.OnPromptRequiredError("Perplexity");
339
+ if (ctrl.signal?.aborted) throw new AIError.LoginCancelledError();
292
340
 
293
- // Path 1: Native macOS app JWT (skip if PI_AUTH_NO_BORROW=1)
294
341
  if (!$env.PI_AUTH_NO_BORROW) {
295
342
  ctrl.onProgress?.("Checking for Perplexity desktop app...");
296
343
  const nativeJwt = await extractFromNativeApp();
344
+ if (ctrl.signal?.aborted) throw new AIError.LoginCancelledError();
297
345
  if (nativeJwt) {
298
346
  ctrl.onProgress?.("Found Perplexity JWT from native app");
299
347
  return jwtToCredentials(nativeJwt);
300
348
  }
301
349
  }
302
350
 
303
- // Path 2: HTTP email OTP
351
+ if (ctrl.onBrowserSession) {
352
+ const method = (
353
+ await ctrl.onPrompt({
354
+ message: "Login method: sso (browser) or email; blank for sso",
355
+ placeholder: "sso / email",
356
+ allowEmpty: true,
357
+ })
358
+ )
359
+ .trim()
360
+ .toLowerCase();
361
+ if (ctrl.signal?.aborted) throw new AIError.LoginCancelledError();
362
+ if (!method || method === "sso") return browserSsoLogin(ctrl);
363
+ if (method !== "email") {
364
+ throw new AIError.OAuthError("Choose sso or email for Perplexity login", {
365
+ kind: "validation",
366
+ provider: "perplexity",
367
+ });
368
+ }
369
+ }
304
370
  return httpEmailLogin(ctrl);
305
371
  }
@@ -70,12 +70,21 @@ export interface OAuthProviderInfo {
70
70
  storeCredentialsAs?: string;
71
71
  }
72
72
 
73
+ /** Sign-in URL and accepted cookies for an isolated, host-owned browser. */
74
+ export type OAuthBrowserSessionRequest = {
75
+ url: string;
76
+ /** Cookie names in preference order; return the first non-empty matching value. */
77
+ cookieNames: readonly string[];
78
+ };
79
+
73
80
  export interface OAuthController {
74
81
  onAuth?(info: OAuthAuthInfo): void;
75
82
  onProgress?(message: string): void;
76
83
  /** Request pasted callback input; stop any visible prompt when `signal` aborts. */
77
84
  onManualCodeInput?(signal?: AbortSignal): Promise<string>;
78
85
  onPrompt?(prompt: OAuthPrompt): Promise<string>;
86
+ /** Complete browser login and return one matching cookie value privately. Reject on cancellation or failure. */
87
+ onBrowserSession?(request: OAuthBrowserSessionRequest, signal?: AbortSignal): Promise<string>;
79
88
  signal?: AbortSignal;
80
89
  fetch?: FetchImpl;
81
90
  }
package/src/stream.ts CHANGED
@@ -193,6 +193,8 @@ let providerInFlightHeartbeatWriterOverride:
193
193
  | undefined;
194
194
  let providerInFlightLeaseRemoverOverride: ((leasePath: string) => Promise<void>) | undefined;
195
195
  let providerInFlightWaitObserverOverride: ((provider: string) => void) | undefined;
196
+ let providerInFlightLockCreatedObserverOverride: ((lockDir: string) => Promise<void>) | undefined;
197
+ let providerInFlightLockIdentifiedObserverOverride: ((lockDir: string) => Promise<void>) | undefined;
196
198
 
197
199
  export function configureProviderMaxInFlightRequests(limits: Record<string, number> | undefined): void {
198
200
  configuredProviderMaxInFlightRequests = limits ?? {};
@@ -365,12 +367,21 @@ async function acquireProviderInFlightLock(provider: string, signal?: AbortSigna
365
367
  if (signal?.aborted) throw signal.reason ?? new AIError.AbortError("Provider request aborted before dispatch");
366
368
  try {
367
369
  await fs.mkdir(lockDir);
368
- const lockIdentity = await readProviderInFlightLockIdentity(lockDir);
370
+ await providerInFlightLockCreatedObserverOverride?.(lockDir);
371
+ let lockIdentity: ProviderInFlightLockIdentity;
372
+ try {
373
+ lockIdentity = await readProviderInFlightLockIdentity(lockDir);
374
+ } catch (error) {
375
+ if (isEnoent(error)) continue;
376
+ throw error;
377
+ }
369
378
  const token = crypto.randomUUID();
370
379
  try {
380
+ await providerInFlightLockIdentifiedObserverOverride?.(lockDir);
371
381
  await writeProviderInFlightInfo(lockDir, token);
372
382
  } catch (error) {
373
383
  await releaseProviderInFlightLockDirIfSame(lockDir, lockIdentity);
384
+ if (isEnoent(error)) continue;
374
385
  throw error;
375
386
  }
376
387
  return async () => {
@@ -623,6 +634,12 @@ export const __providerInFlightForTesting = {
623
634
  setWaitObserver(observer: ((provider: string) => void) | undefined): void {
624
635
  providerInFlightWaitObserverOverride = observer;
625
636
  },
637
+ setLockCreatedObserver(observer: ((lockDir: string) => Promise<void>) | undefined): void {
638
+ providerInFlightLockCreatedObserverOverride = observer;
639
+ },
640
+ setLockIdentifiedObserver(observer: ((lockDir: string) => Promise<void>) | undefined): void {
641
+ providerInFlightLockIdentifiedObserverOverride = observer;
642
+ },
626
643
  providerDir(provider: string): string {
627
644
  return providerInFlightDir(provider);
628
645
  },
@@ -1061,7 +1078,11 @@ async function resolveWithThinkingLoopRetries(
1061
1078
  onAttempt?: (message: AssistantMessage) => void,
1062
1079
  ): Promise<AssistantMessage> {
1063
1080
  const dispatchAttempt = async (): Promise<AssistantMessage> => {
1064
- const message = await dispatch().result();
1081
+ const response = dispatch();
1082
+ for await (const _event of response) {
1083
+ // Completion callers do not consume deltas; drain them as they arrive to avoid retaining the response history.
1084
+ }
1085
+ const message = await response.result();
1065
1086
  onAttempt?.(message);
1066
1087
  return message;
1067
1088
  };
package/src/types.ts CHANGED
@@ -1055,6 +1055,8 @@ export interface AssistantMessage {
1055
1055
  errorMessage?: string;
1056
1056
  /** Stable recovery-classification text when errorMessage includes display-only diagnostics. */
1057
1057
  errorClassificationMessage?: string;
1058
+ /** True only when an exact request-body-read timeout failed on a full Responses replay, not a previous-response delta. */
1059
+ requestBodyReadTimeoutFullReplay?: boolean;
1058
1060
  /** Per-tool abort messages used when an aborted assistant turn needs different placeholder results per tool call. */
1059
1061
  toolCallAbortMessages?: Record<string, string>;
1060
1062
  /** HTTP status surfaced by the provider when the request failed. Populated by every provider's catch block alongside `errorMessage` so consumers (auth retry, telemetry, UI) can branch without regex-scraping the message. */
@@ -1187,6 +1189,7 @@ export type CursorTodoSyncHandler = (
1187
1189
  snapshot: CursorTodoSnapshot | null,
1188
1190
  toolCallId: string,
1189
1191
  error: string | null,
1192
+ origin?: "read" | "update",
1190
1193
  ) => ToolResultMessage;
1191
1194
 
1192
1195
  export interface CursorShellStreamCallbacks {
@@ -22,6 +22,8 @@ import { HOUR_MS, parseIsoTimestamp, WEEK_MS } from "./shared";
22
22
  const DEFAULT_ENDPOINT = "https://api.anthropic.com/api/oauth";
23
23
  const MAX_ATTEMPTS = 3;
24
24
  const BASE_RETRY_DELAY_MS = 500;
25
+ /** Shared windows that gate every Claude request, whatever the model. */
26
+ const CLAUDE_SHARED_GATE_WINDOW_IDS = ["5h", "7d"] as const;
25
27
 
26
28
  const CLAUDE_HEADERS = {
27
29
  accept: "application/json, text/plain, */*",
@@ -921,5 +923,36 @@ export const claudeRankingStrategy: CredentialRankingStrategy = {
921
923
  const kind = getClaudeModelKind(context);
922
924
  return kind === "fable" || kind === "mythos" ? `tier:${kind}` : undefined;
923
925
  },
926
+ /**
927
+ * A reactive Fable/Mythos block carries the reset the 429 reported, but
928
+ * Anthropic can restore the tier earlier (plan change, corrected counter),
929
+ * and the block then idles a usable account for days. Judge each tier scope
930
+ * against the limits that actually gate a request of that kind — its own
931
+ * weekly row plus the shared umbrella windows — so a healthy report lifts
932
+ * the block while a spent shared 5-hour wall keeps it.
933
+ *
934
+ * Only Fable/Mythos appear: {@link blockScope} scopes reactive blocks for
935
+ * those tiers alone, so no other scope can exist to heal.
936
+ */
937
+ healableBlockScopes(report) {
938
+ const sharedLimits = report.limits.filter(limit => limit.scope.shared === true);
939
+ // The endpoint returns a report as soon as one window parses, and a tier
940
+ // 429 can be caused by a shared wall. A payload missing a shared gate
941
+ // leaves the block's cause unknown, so vouch for nothing rather than
942
+ // clear a block that still holds.
943
+ const everySharedGateReported = CLAUDE_SHARED_GATE_WINDOW_IDS.every(windowId =>
944
+ sharedLimits.some(limit => limit.scope.windowId === windowId || limit.window?.id === windowId),
945
+ );
946
+ if (!everySharedGateReported) return [];
947
+ const tiers = new Set<string>();
948
+ for (const limit of report.limits) {
949
+ const tier = limit.scope.tier;
950
+ if (tier === "fable" || tier === "mythos") tiers.add(tier);
951
+ }
952
+ return [...tiers].map(tier => ({
953
+ blockScope: `tier:${tier}`,
954
+ limits: [...sharedLimits, ...report.limits.filter(limit => limit.scope.tier === tier)],
955
+ }));
956
+ },
924
957
  windowDefaults: { primaryMs: 5 * 60 * 60 * 1000, secondaryMs: 7 * 24 * 60 * 60 * 1000 },
925
958
  };
@@ -355,18 +355,24 @@ function buildQuotaSummaryReport(
355
355
  );
356
356
  const amount = buildQuotaSummaryAmount(bucket);
357
357
  const counterKeys = getQuotaSummaryCounterKeys(group, bucket);
358
+ const sharedGroup =
359
+ counterKeys.length > 1
360
+ ? `${bucket.bucketId ?? group?.displayName ?? "third-party"}:${window?.id ?? bucket.window ?? "default"}`
361
+ : undefined;
358
362
  for (const counterKey of counterKeys) {
359
363
  const counterName = getQuotaSummaryCounterName(counterKey);
360
364
  const windowId = window?.id ?? bucket.window ?? bucket.bucketId ?? "default";
365
+ const label =
366
+ sharedGroup !== undefined ? "Claude & GPT (shared)" : counterKey === "google" ? "Gemini" : counterName;
361
367
  limits.push({
362
368
  id: `${params.provider}:${counterKey}:default:${bucket.bucketId ?? windowId}`,
363
- label: counterName ? `Usage (${counterName})` : (group?.displayName ?? bucket.displayName ?? "Usage"),
369
+ label: label ?? group?.displayName ?? bucket.displayName ?? "Usage",
364
370
  scope: {
365
371
  provider: params.provider,
366
372
  accountId: params.credential.accountId,
367
373
  projectId: params.credential.projectId,
368
374
  windowId,
369
- ...(counterKeys.length > 1 ? { shared: true } : {}),
375
+ ...(sharedGroup !== undefined ? { shared: true, sharedGroup } : {}),
370
376
  },
371
377
  window,
372
378
  amount,
package/src/usage.ts CHANGED
@@ -54,6 +54,8 @@ export interface UsageScope {
54
54
  tier?: string;
55
55
  windowId?: string;
56
56
  shared?: boolean;
57
+ /** Stable identity shared by routing-specific copies of one upstream quota. */
58
+ sharedGroup?: string;
57
59
  }
58
60
 
59
61
  /** Normalized limit entry for a single window or quota bucket. */
@@ -271,6 +273,7 @@ export const usageScopeSchema = type({
271
273
  "tier?": "string",
272
274
  "windowId?": "string",
273
275
  "shared?": "boolean",
276
+ "sharedGroup?": "string",
274
277
  });
275
278
 
276
279
  export const usageLimitSchema = type({
@@ -141,3 +141,60 @@ export type SyntheticUserCarrier = object & { [kSyntheticUser]?: boolean };
141
141
  export function isSyntheticUser(message: SyntheticUserCarrier | null | undefined): boolean {
142
142
  return message?.[kSyntheticUser] === true;
143
143
  }
144
+
145
+ /**
146
+ * Marks a message synthesized by a per-call context transform rather than
147
+ * loaded from persisted conversation history.
148
+ *
149
+ * Prompt-cache boundaries must skip these messages: their content is rebuilt
150
+ * for each request and cannot anchor a prefix reused by the next turn.
151
+ * Symbol-keyed so the marker never persists or reaches the provider wire.
152
+ */
153
+ export const kPerCallContextMessage = Symbol("agent.message.perCallContext");
154
+
155
+ /** Carries per-call context provenance without exposing a string-keyed property. */
156
+ export type PerCallContextMessageCarrier = object & { [kPerCallContextMessage]?: true };
157
+
158
+ /** Marks a message as synthesized for the current provider call. */
159
+ export function markPerCallContextMessage(message: PerCallContextMessageCarrier): void {
160
+ message[kPerCallContextMessage] = true;
161
+ }
162
+
163
+ /** Copies per-call context provenance to a converted or projected message. */
164
+ export function copyPerCallContextMessage(
165
+ target: PerCallContextMessageCarrier,
166
+ source: PerCallContextMessageCarrier,
167
+ ): void {
168
+ if (source[kPerCallContextMessage] === true) target[kPerCallContextMessage] = true;
169
+ }
170
+
171
+ /** True when a message was synthesized for the current provider call. */
172
+ export function isPerCallContextMessage(message: PerCallContextMessageCarrier | null | undefined): boolean {
173
+ return message?.[kPerCallContextMessage] === true;
174
+ }
175
+
176
+ /**
177
+ * Original history position carried by a context message clone.
178
+ *
179
+ * Object-spread transforms retain this symbol, allowing the extension runner
180
+ * to distinguish byte-identical historical copies from inserted messages.
181
+ */
182
+ export const kContextHistoryIndex = Symbol("agent.message.contextHistoryIndex");
183
+
184
+ /** Carries a context message's original history position. */
185
+ export type ContextHistoryIndexCarrier = object & { [kContextHistoryIndex]?: number };
186
+
187
+ /** Reads a context message's original history position. */
188
+ export function getContextHistoryIndex(message: ContextHistoryIndexCarrier | null | undefined): number | undefined {
189
+ return message?.[kContextHistoryIndex];
190
+ }
191
+
192
+ /** Records a context message's original history position. */
193
+ export function setContextHistoryIndex(message: ContextHistoryIndexCarrier, index: number): void {
194
+ message[kContextHistoryIndex] = index;
195
+ }
196
+
197
+ /** Removes context-history tracking before provider conversion. */
198
+ export function clearContextHistoryIndex(message: ContextHistoryIndexCarrier): void {
199
+ delete message[kContextHistoryIndex];
200
+ }
@@ -14,7 +14,7 @@
14
14
  * captured response body for the strict-tools fallback and the responses
15
15
  * chain-state detectors, which regex over `error.message`.
16
16
  */
17
- import { fetchWithRetry, readSseJson, type SseEventObserver } from "@oh-my-pi/pi-utils";
17
+ import { fetchWithRetry, readSseJsonOrText, type SseEventObserver } from "@oh-my-pi/pi-utils";
18
18
  import * as AIError from "../error";
19
19
  import { OpenAIHttpError } from "../error";
20
20
 
@@ -68,6 +68,8 @@ export interface OpenAIStreamRequestInit {
68
68
  body: unknown;
69
69
  signal: AbortSignal;
70
70
  fetch?: FetchImpl;
71
+ /** Optional caller-specific gate composed with shared transport retry exclusions. */
72
+ shouldRetryResponse?: (response: Response, bodyText: string) => boolean | Promise<boolean>;
71
73
  /** Raw wire-frame observer (`onSseEvent` debug pipeline). */
72
74
  onSseEvent?: SseEventObserver;
73
75
  }
@@ -98,7 +100,9 @@ export async function postOpenAIStream<TEvent>(init: OpenAIStreamRequestInit): P
98
100
  // A proxy concurrency-admission 429 (`rate_limit_type: max_parallel_requests`)
99
101
  // surfaces immediately instead of being slept-and-retried here; session
100
102
  // recovery owns its backoff/fallback (issue #8854).
101
- shouldRetryResponse: (response, bodyText) => !isConcurrencyAdmissionRejection(response, bodyText),
103
+ shouldRetryResponse: async (response, bodyText) =>
104
+ !isConcurrencyAdmissionRejection(response, bodyText) &&
105
+ (init.shouldRetryResponse === undefined || (await init.shouldRetryResponse(response, bodyText))),
102
106
  // Bun's native fetch enforces a hard ~300s pre-response timeout (issue #2422).
103
107
  // Cold large-context streams legitimately exceed it; the caller's
104
108
  // `firstEventTimeoutMs`/`AbortSignal` already govern stuck requests.
@@ -113,12 +117,44 @@ export async function postOpenAIStream<TEvent>(init: OpenAIStreamRequestInit): P
113
117
  });
114
118
  }
115
119
  return {
116
- events: readSseJson<TEvent>(response.body, init.signal, init.onSseEvent),
120
+ events: decodeStream<TEvent>(response.body, init.signal, init.onSseEvent),
117
121
  response,
118
122
  requestId: response.headers.get("x-request-id"),
119
123
  };
120
124
  }
121
125
 
126
+ /**
127
+ * Consume `readSseJsonOrText` and turn a non-JSON `data:` frame into a
128
+ * classified in-band error. A reverse proxy that already committed to an HTTP
129
+ * 200 stream (so the status line can no longer carry the failure) answers with
130
+ * plain text — `data: 429 Too Many Requests`, an nginx throttle page — and
131
+ * that has to advance the fallback chain like a real 429 (body-error.ts).
132
+ * Frames that are not recognisable throttles rethrow the original parse error,
133
+ * preserving the pre-existing loud failure for genuinely malformed payloads.
134
+ * `readSseJsonOrText` also yields a frame that was a JSON-encoded *string* on the
135
+ * wire (a double-encoded proxy error page); it is not a usable event either, so
136
+ * it is classified the same way and then dropped — every consumer here already
137
+ * ignored a string chunk, the completions loop by its `typeof !== "object"` test.
138
+ */
139
+ async function* decodeStream<TEvent>(
140
+ body: ReadableStream<Uint8Array>,
141
+ signal: AbortSignal | undefined,
142
+ onSseEvent: SseEventObserver | undefined,
143
+ ): AsyncGenerator<TEvent> {
144
+ for await (const frame of readSseJsonOrText<TEvent>(body, signal, onSseEvent)) {
145
+ if (typeof frame === "string") {
146
+ const inBand = AIError.createInBandProviderErrorFromText(frame);
147
+ if (inBand) throw inBand;
148
+ // Not a recognisable throttle: reproduce the exact strict-parse failure the
149
+ // previous reader raised, so genuinely malformed payloads stay equally
150
+ // loud. A frame that parses again was a JSON string, not a malformed one.
151
+ JSON.parse(frame);
152
+ continue;
153
+ }
154
+ yield frame;
155
+ }
156
+ }
157
+
122
158
  /** Decode a non-2xx response into an {@link OpenAIHttpError} without consuming it twice. */
123
159
  export async function captureOpenAIHttpError(response: Response): Promise<AIError.OpenAIHttpError> {
124
160
  let bodyText: string | undefined;
@@ -1,5 +1,17 @@
1
+ import { retryResetTimezoneOffsetFor } from "@oh-my-pi/pi-catalog/compat/behavior";
2
+ import { extractRetryHint } from "@oh-my-pi/pi-utils";
3
+
1
4
  export type HeadersLike = Headers | Record<string, string | undefined> | undefined | null;
2
5
 
6
+ /** Extracts retry timing using the provider's catalog-declared timestamp timezone. */
7
+ export function extractProviderRetryHint(
8
+ provider: string | undefined,
9
+ message: string | undefined,
10
+ ): number | undefined {
11
+ const naiveResetTimezoneOffset = provider === undefined ? undefined : retryResetTimezoneOffsetFor(provider);
12
+ return extractRetryHint(undefined, message, { naiveResetTimezoneOffset });
13
+ }
14
+
3
15
  const RETRY_AFTER_HINT = "retry-after-ms=";
4
16
 
5
17
  export function formatErrorMessageWithRetryAfter(error: unknown, headers?: HeadersLike): string {
@@ -1877,9 +1877,9 @@ function inferStrictPrimitiveTypeFromEnumOrConst(node: Record<string, unknown>):
1877
1877
  }
1878
1878
 
1879
1879
  /**
1880
- * Per-schema-object memoization slot. The result of `tryEnforceStrictSchema`
1881
- * is stamped directly onto the input via `stamp(target, kStrictSchema, …)`
1882
- * so repeated calls (different providers, retries, batching) reuse the same
1880
+ * Per-schema-object memoization key. The result of `tryEnforceStrictSchema`
1881
+ * is memoized against the input via `stamp(target, kStrictSchema, …)` so
1882
+ * repeated calls (different providers, retries, batching) reuse the same
1883
1883
  * computed pair without re-walking the tree.
1884
1884
  */
1885
1885
  const kStrictSchema = Symbol("pi.schema.strict");
@@ -1,38 +1,39 @@
1
1
  /**
2
- * Symbol-keyed lazy memoization stamped directly onto the host object.
2
+ * Lazy memoization keyed by host object identity, held in a module-level
3
+ * weak side table.
3
4
  *
4
- * Faster than a module-level `WeakMap` in V8/JSC because the symbol slot is
5
- * resolved through the object's hidden class instead of a side-table hash
6
- * lookup. The slot is defined as a non-enumerable property so the stamp
7
- * does not leak through `{...spread}`, `Object.keys`, `JSON.stringify`, or
8
- * `toEqual`-style deep equality.
5
+ * The bookkeeping deliberately lives outside the host rather than in a
6
+ * non-enumerable symbol property: schema objects are caller-owned and may be
7
+ * sealed, frozen, or deep-frozen after their first traversal. A recursive
8
+ * freeze that enumerates with `Reflect.ownKeys` reaches symbol slots, so an
9
+ * on-host slot would be frozen along with the schema and every later write
10
+ * would throw. A side table is also invisible to `{...spread}`,
11
+ * `Object.keys`, `JSON.stringify`, and `toEqual`-style deep equality.
9
12
  *
10
- * Caveats: the stamp lives as long as the host object, even after callers
13
+ * Caveats: an entry lives as long as the host object, even after callers
11
14
  * release their references to the cached value — only use this for caches
12
- * whose lifetime should match the host. Frozen hosts cannot be stamped;
13
- * `define` silently skips them, so memoization/visit-tracking degrades to
14
- * best-effort (recompute on every call, no cycle protection) instead of
15
- * throwing.
15
+ * whose lifetime should match the host.
16
16
  */
17
- function define<T extends object>(target: T, key: symbol, value: unknown): void {
18
- if (Object.isFrozen(target)) return;
19
- Object.defineProperty(target, key, { value, writable: true, configurable: true });
20
- }
17
+ const memos = new WeakMap<object, Map<symbol, unknown>>();
21
18
 
22
19
  export function stamp<T extends object, V>(target: T, key: symbol, compute: (target: T) => V): V {
23
- const slot = target as Record<symbol, V | undefined>;
24
- const existing = slot[key];
20
+ let slots = memos.get(target);
21
+ if (!slots) {
22
+ slots = new Map();
23
+ memos.set(target, slots);
24
+ }
25
+ const existing = slots.get(key) as V | undefined;
25
26
  if (existing !== undefined) return existing;
26
27
  const value = compute(target);
27
- define(target, key, value);
28
+ slots.set(key, value);
28
29
  return value;
29
30
  }
30
31
 
31
32
  /**
32
- * Epoch-keyed cycle guard. Cheaper than `WeakSet` for recursive traversal
33
- * because the marker is a single property slot on the host object, written
34
- * once and overwritten in place on every subsequent traversal — the hidden
35
- * class transitions once per object lifetime, not per traversal.
33
+ * Epoch-keyed cycle guard. Cheaper than a per-call `WeakSet` for recursive
34
+ * traversal because the marker is a single side-table entry per host object,
35
+ * written once and overwritten in place on every subsequent traversal — no
36
+ * per-walk allocation.
36
37
  *
37
38
  * Usage:
38
39
  * function walk(node, epoch = epochNext()) {
@@ -40,7 +41,7 @@ export function stamp<T extends object, V>(target: T, key: symbol, compute: (tar
40
41
  * for (const child of node.children) walk(child, epoch);
41
42
  * }
42
43
  */
43
- const kEpoch = Symbol("pi.schema.epoch");
44
+ const epochs = new WeakMap<object, number>();
44
45
  let __epoch = 0;
45
46
 
46
47
  export function epochNext(): number {
@@ -53,11 +54,9 @@ export function epochNext(): number {
53
54
  * subsequent call within the same epoch.
54
55
  */
55
56
  export function once<T extends object>(target: T, epoch: number): boolean {
56
- const slot = target as Record<symbol, number | undefined>;
57
- const cur = slot[kEpoch];
57
+ const cur = epochs.get(target);
58
58
  if (cur !== undefined && cur >= epoch) return false;
59
- if (cur === undefined) define(target, kEpoch, epoch);
60
- else slot[kEpoch] = epoch;
59
+ epochs.set(target, epoch);
61
60
  return true;
62
61
  }
63
62
 
@@ -65,13 +64,9 @@ export function once<T extends object>(target: T, epoch: number): boolean {
65
64
  * Counter-based path tracker. Use when a traversal needs to distinguish
66
65
  * "currently on the recursion path" from "previously visited" — i.e. cycle
67
66
  * detection that throws while still allowing DAG sharing. Increment on
68
- * entry, decrement on exit; the slot returns to 0 after a balanced walk so
67
+ * entry, decrement on exit; the counter returns to 0 after a balanced walk so
69
68
  * subsequent top-level calls see a fresh state without any reset.
70
69
  *
71
- * Unlike a `WeakSet` with `seen.delete(...)`, the property is never deleted
72
- * — only incremented and decremented — so the host object's hidden class
73
- * is never invalidated.
74
- *
75
70
  * Usage:
76
71
  * function walk(node) {
77
72
  * if (!enter(node)) throw new Error("cycle");
@@ -79,7 +74,7 @@ export function once<T extends object>(target: T, epoch: number): boolean {
79
74
  * finally { exit(node); }
80
75
  * }
81
76
  */
82
- const kDepth = Symbol("pi.schema.depth");
77
+ const depths = new WeakMap<object, number>();
83
78
 
84
79
  /**
85
80
  * Returns `true` on first entry, `false` if `target` is already on the
@@ -89,21 +84,14 @@ const kDepth = Symbol("pi.schema.depth");
89
84
  * make every later top-level walk of the same object misreport a cycle.
90
85
  */
91
86
  export function enter<T extends object>(target: T): boolean {
92
- const slot = target as Record<symbol, number | undefined>;
93
- const cur = slot[kDepth];
94
- if (cur === undefined) {
95
- define(target, kDepth, 1);
96
- return true;
97
- }
98
- if (cur !== 0) return false;
99
- slot[kDepth] = 1;
87
+ const cur = depths.get(target);
88
+ if (cur !== undefined && cur !== 0) return false;
89
+ depths.set(target, 1);
100
90
  return true;
101
91
  }
102
92
 
103
93
  export function exit<T extends object>(target: T): void {
104
- const slot = target as Record<symbol, number | undefined>;
105
- const cur = slot[kDepth];
106
- // Frozen targets never received the kDepth stamp in `enter` — nothing to unwind.
94
+ const cur = depths.get(target);
107
95
  if (cur === undefined) return;
108
- slot[kDepth] = cur - 1;
96
+ depths.set(target, cur - 1);
109
97
  }