@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.
- package/CHANGELOG.md +35 -0
- package/dist/types/auth-broker/remote-store.d.ts +17 -0
- package/dist/types/auth-gateway/index.d.ts +1 -0
- package/dist/types/auth-gateway/session-state.d.ts +65 -0
- package/dist/types/auth-storage.d.ts +16 -0
- package/dist/types/error/body-error.d.ts +15 -0
- package/dist/types/error/flags.d.ts +16 -0
- package/dist/types/error/index.d.ts +1 -0
- package/dist/types/oneshot-retry.d.ts +6 -0
- package/dist/types/providers/openai-codex/request-transformer.d.ts +27 -0
- package/dist/types/providers/openai-shared.d.ts +20 -3
- package/dist/types/registry/oauth/perplexity.d.ts +1 -7
- package/dist/types/registry/oauth/types.d.ts +8 -0
- package/dist/types/stream.d.ts +2 -0
- package/dist/types/types.d.ts +3 -1
- package/dist/types/usage.d.ts +8 -0
- package/dist/types/utils/block-symbols.d.ts +36 -0
- package/dist/types/utils/openai-http.d.ts +2 -0
- package/dist/types/utils/retry-after.d.ts +2 -0
- package/dist/types/utils/schema/wire.d.ts +4 -5
- package/dist/types/utils.d.ts +9 -0
- package/package.json +6 -6
- package/src/auth-broker/remote-store.ts +73 -8
- package/src/auth-broker/wire-schemas.ts +1 -0
- package/src/auth-gateway/index.ts +1 -0
- package/src/auth-gateway/server.ts +48 -11
- package/src/auth-gateway/session-state.ts +114 -0
- package/src/auth-storage.ts +144 -13
- package/src/error/body-error.ts +310 -0
- package/src/error/flags.ts +63 -13
- package/src/error/index.ts +1 -0
- package/src/error/retryable.ts +2 -0
- package/src/oneshot-retry.ts +13 -3
- package/src/providers/anthropic-messages-server.ts +24 -3
- package/src/providers/anthropic.ts +101 -15
- package/src/providers/cursor.ts +7 -1
- package/src/providers/devin.ts +82 -28
- package/src/providers/openai-chat-server.ts +4 -0
- package/src/providers/openai-codex/request-transformer.ts +36 -0
- package/src/providers/openai-codex-responses.ts +35 -12
- package/src/providers/openai-completions.ts +43 -12
- package/src/providers/openai-reasoning-fallback.ts +6 -6
- package/src/providers/openai-responses-server.ts +2 -1
- package/src/providers/openai-responses.ts +25 -4
- package/src/providers/openai-shared.ts +199 -51
- package/src/registry/oauth/perplexity.ts +94 -28
- package/src/registry/oauth/types.ts +9 -0
- package/src/stream.ts +23 -2
- package/src/types.ts +3 -0
- package/src/usage/claude.ts +33 -0
- package/src/usage/google-antigravity.ts +8 -2
- package/src/usage.ts +3 -0
- package/src/utils/block-symbols.ts +57 -0
- package/src/utils/openai-http.ts +39 -3
- package/src/utils/retry-after.ts +12 -0
- package/src/utils/schema/normalize.ts +3 -3
- package/src/utils/schema/stamps.ts +33 -45
- package/src/utils/schema/wire.ts +9 -7
- package/src/utils.ts +67 -22
|
@@ -1,15 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Perplexity login
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
package/src/usage/claude.ts
CHANGED
|
@@ -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:
|
|
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
|
-
...(
|
|
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
|
+
}
|
package/src/utils/openai-http.ts
CHANGED
|
@@ -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,
|
|
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) =>
|
|
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:
|
|
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;
|
package/src/utils/retry-after.ts
CHANGED
|
@@ -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
|
|
1881
|
-
* is
|
|
1882
|
-
*
|
|
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
|
-
*
|
|
2
|
+
* Lazy memoization keyed by host object identity, held in a module-level
|
|
3
|
+
* weak side table.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
33
|
-
* because the marker is a single
|
|
34
|
-
* once and overwritten in place on every subsequent traversal —
|
|
35
|
-
*
|
|
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
|
|
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
|
|
57
|
-
const cur = slot[kEpoch];
|
|
57
|
+
const cur = epochs.get(target);
|
|
58
58
|
if (cur !== undefined && cur >= epoch) return false;
|
|
59
|
-
|
|
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
|
|
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
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
96
|
+
depths.set(target, cur - 1);
|
|
109
97
|
}
|