@oh-my-pi/pi-ai 16.5.0 → 16.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/types/auth-broker/remote-store.d.ts +2 -0
  3. package/dist/types/auth-broker/wire-schemas.d.ts +28 -0
  4. package/dist/types/auth-retry.d.ts +44 -20
  5. package/dist/types/auth-storage.d.ts +51 -10
  6. package/dist/types/error/flags.d.ts +1 -0
  7. package/dist/types/index.d.ts +1 -0
  8. package/dist/types/providers/openai-chat-server-schema.d.ts +3 -3
  9. package/dist/types/providers/openai-shared.d.ts +2 -0
  10. package/dist/types/registry/oauth/types.d.ts +8 -0
  11. package/dist/types/usage/cursor.d.ts +3 -0
  12. package/dist/types/usage/openai-codex-reset.d.ts +1 -1
  13. package/dist/types/usage/openai-codex.d.ts +8 -1
  14. package/dist/types/usage/zai.d.ts +2 -1
  15. package/dist/types/usage.d.ts +4 -0
  16. package/dist/types/utils/idle-iterator.d.ts +6 -6
  17. package/package.json +4 -4
  18. package/src/auth-broker/discover.ts +22 -3
  19. package/src/auth-broker/remote-store.ts +103 -10
  20. package/src/auth-broker/wire-schemas.ts +4 -0
  21. package/src/auth-gateway/server.ts +3 -1
  22. package/src/auth-retry.ts +191 -53
  23. package/src/auth-storage.ts +668 -198
  24. package/src/error/auth-classify.ts +2 -1
  25. package/src/error/flags.ts +10 -0
  26. package/src/error/provider.ts +2 -2
  27. package/src/index.ts +1 -0
  28. package/src/providers/cursor.ts +10 -1
  29. package/src/providers/openai-chat-server-schema.ts +2 -1
  30. package/src/providers/openai-codex-responses.ts +74 -36
  31. package/src/providers/openai-shared.ts +6 -3
  32. package/src/registry/oauth/anthropic.ts +57 -24
  33. package/src/registry/oauth/oauth.html +1 -1
  34. package/src/registry/oauth/types.ts +8 -0
  35. package/src/stream.ts +19 -30
  36. package/src/usage/cursor.ts +192 -0
  37. package/src/usage/openai-codex-reset.ts +6 -2
  38. package/src/usage/openai-codex.ts +33 -0
  39. package/src/usage/zai.ts +49 -6
  40. package/src/usage.ts +4 -0
  41. package/src/utils/idle-iterator.ts +6 -6
@@ -0,0 +1,192 @@
1
+ import type {
2
+ UsageAmount,
3
+ UsageFetchContext,
4
+ UsageFetchParams,
5
+ UsageLimit,
6
+ UsageProvider,
7
+ UsageReport,
8
+ UsageStatus,
9
+ UsageWindow,
10
+ } from "../usage";
11
+ import { toNumber } from "./shared";
12
+
13
+ function isRecord(value: unknown): value is Record<string, unknown> {
14
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15
+ }
16
+
17
+ function parseTimestamp(value: unknown): number | undefined {
18
+ const numeric = toNumber(value);
19
+ if (numeric !== undefined) return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric;
20
+ if (typeof value !== "string" || !value.trim()) return undefined;
21
+ const parsed = Date.parse(value);
22
+ return Number.isFinite(parsed) ? parsed : undefined;
23
+ }
24
+
25
+ function normalizeCursorBaseUrl(baseUrl?: string): string {
26
+ if (!baseUrl) return "https://api2.cursor.sh";
27
+ return baseUrl.replace(/\/+$/, "");
28
+ }
29
+
30
+ function deriveResetsAt(payload: Record<string, unknown>): number | undefined {
31
+ const endKeys = ["billingCycleEnd", "endOfMonth", "resetsAt", "nextReset"];
32
+ for (const key of endKeys) {
33
+ const parsed = parseTimestamp(payload[key]);
34
+ if (parsed !== undefined) return parsed;
35
+ }
36
+
37
+ const startKeys = ["startOfMonth", "billingCycleStart", "startOfBillingCycle"];
38
+ for (const key of startKeys) {
39
+ const parsed = parseTimestamp(payload[key]);
40
+ if (parsed !== undefined) {
41
+ const date = new Date(parsed);
42
+ date.setUTCMonth(date.getUTCMonth() + 1);
43
+ return date.getTime();
44
+ }
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ export function parseCursorUsage(payload: unknown, fetchedAt = Date.now()): UsageReport | null {
50
+ if (!isRecord(payload)) return null;
51
+ const limits: UsageLimit[] = [];
52
+ const resetsAt = deriveResetsAt(payload);
53
+
54
+ const window: UsageWindow = {
55
+ id: "monthly",
56
+ label: "Monthly",
57
+ ...(resetsAt !== undefined ? { resetsAt } : {}),
58
+ };
59
+
60
+ for (const [key, value] of Object.entries(payload)) {
61
+ if (!isRecord(value)) continue;
62
+
63
+ // used can be: numRequests, used, amountUsed, usdUsed
64
+ const usedVal =
65
+ toNumber(value.numRequests) ?? toNumber(value.used) ?? toNumber(value.amountUsed) ?? toNumber(value.usdUsed);
66
+
67
+ // limit can be: maxRequestUsage, limit, amountLimit, usdLimit
68
+ const limitVal =
69
+ toNumber(value.maxRequestUsage) ??
70
+ toNumber(value.limit) ??
71
+ toNumber(value.amountLimit) ??
72
+ toNumber(value.usdLimit);
73
+
74
+ if (usedVal !== undefined && limitVal !== undefined) {
75
+ const isUsd =
76
+ key === "planUsage" ||
77
+ key.toLowerCase().includes("usd") ||
78
+ key.toLowerCase().includes("billing") ||
79
+ key.toLowerCase().includes("stripe");
80
+
81
+ const unit = isUsd ? "usd" : "requests";
82
+ const cleanBucket = key.toLowerCase().trim();
83
+ const limitId = isUsd ? `cursor:usd:${cleanBucket}` : `cursor:requests:${cleanBucket}`;
84
+
85
+ const label = isUsd ? `${key} spend` : `${key} requests`;
86
+
87
+ const amount: UsageAmount = {
88
+ used: usedVal,
89
+ limit: limitVal,
90
+ remaining: Math.max(0, limitVal - usedVal),
91
+ usedFraction: limitVal > 0 ? usedVal / limitVal : 0,
92
+ remainingFraction: limitVal > 0 ? Math.max(0, limitVal - usedVal) / limitVal : 0,
93
+ unit,
94
+ };
95
+
96
+ const usedFraction = amount.usedFraction;
97
+ let status: UsageStatus = "unknown";
98
+ if (usedFraction !== undefined) {
99
+ if (usedFraction >= 1) {
100
+ status = "exhausted";
101
+ } else if (usedFraction >= 0.9) {
102
+ status = "warning";
103
+ } else {
104
+ status = "ok";
105
+ }
106
+ }
107
+
108
+ limits.push({
109
+ id: limitId,
110
+ label,
111
+ scope: {
112
+ provider: "cursor",
113
+ ...(window ? { windowId: window.id } : {}),
114
+ },
115
+ ...(window ? { window } : {}),
116
+ amount,
117
+ status,
118
+ });
119
+ }
120
+ }
121
+
122
+ if (limits.length === 0) {
123
+ return null;
124
+ }
125
+
126
+ return {
127
+ provider: "cursor",
128
+ fetchedAt,
129
+ limits,
130
+ raw: payload,
131
+ };
132
+ }
133
+
134
+ export const cursorUsageProvider: UsageProvider = {
135
+ id: "cursor",
136
+ supports(params: UsageFetchParams): boolean {
137
+ if (params.provider !== "cursor") return false;
138
+ const { credential } = params;
139
+ if (credential.type === "oauth") {
140
+ return Boolean(credential.accessToken);
141
+ }
142
+ if (credential.type === "api_key") {
143
+ return Boolean(credential.apiKey);
144
+ }
145
+ return false;
146
+ },
147
+ async fetchUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
148
+ if (params.provider !== "cursor") return null;
149
+ const { credential } = params;
150
+ const token = credential.type === "oauth" ? credential.accessToken : credential.apiKey;
151
+ if (!token) return null;
152
+
153
+ const baseUrl = normalizeCursorBaseUrl(params.baseUrl ?? credential.apiEndpoint);
154
+ const url = `${baseUrl}/auth/usage`;
155
+
156
+ const headers: Record<string, string> = {
157
+ Accept: "application/json",
158
+ Authorization: `Bearer ${token}`,
159
+ };
160
+
161
+ try {
162
+ const response = await ctx.fetch(url, {
163
+ headers,
164
+ signal: params.signal,
165
+ });
166
+ if (!response.ok) {
167
+ ctx.logger?.warn("Cursor usage request failed", {
168
+ status: response.status,
169
+ provider: params.provider,
170
+ });
171
+ return null;
172
+ }
173
+ const payload = await response.json();
174
+ const report = parseCursorUsage(payload);
175
+ if (report) {
176
+ const metadata = {
177
+ ...(credential.email ? { email: credential.email } : {}),
178
+ ...(credential.accountId ? { accountId: credential.accountId } : {}),
179
+ ...(credential.projectId ? { projectId: credential.projectId } : {}),
180
+ };
181
+ if (Object.keys(metadata).length > 0) report.metadata = metadata;
182
+ }
183
+ return report;
184
+ } catch (error) {
185
+ ctx.logger?.warn("Cursor usage request error", {
186
+ provider: params.provider,
187
+ error: String(error),
188
+ });
189
+ return null;
190
+ }
191
+ },
192
+ };
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * GET /wham/rate-limit-reset-credits → list redeemable credits
10
10
  * POST /wham/rate-limit-reset-credits/consume → spend one credit
11
- * body: { credit_id, redeem_request_id }
11
+ * body: { credit_id, redeem_request_id, account_id? }
12
12
  *
13
13
  * `redeem_request_id` is a client-generated idempotency key (UUID). The consume
14
14
  * response carries a `code`: `"reset"` on success, otherwise a business reason
@@ -159,7 +159,11 @@ export async function consumeCodexResetCredit(
159
159
  const response = await auth.fetch(url, {
160
160
  method: "POST",
161
161
  headers: buildHeaders(auth, true),
162
- body: JSON.stringify({ credit_id: auth.creditId, redeem_request_id: redeemRequestId }),
162
+ body: JSON.stringify({
163
+ credit_id: auth.creditId,
164
+ redeem_request_id: redeemRequestId,
165
+ account_id: auth.accountId,
166
+ }),
163
167
  signal: auth.signal,
164
168
  });
165
169
  let body: unknown;
@@ -344,11 +344,44 @@ function buildAdditionalUsageLimit(args: {
344
344
  };
345
345
  }
346
346
 
347
+ /**
348
+ * Parse Codex `x-codex-{primary,secondary}-*` rate-limit response headers into
349
+ * a usage report. The backend attaches these snapshots to every response, so
350
+ * ingesting them lets credential selection block an exhausted account before
351
+ * the next request burns a wire 429.
352
+ */
353
+ export function parseCodexRateLimitHeaders(headers: Record<string, string>, now = Date.now()): UsageReport | null {
354
+ const parseWindow = (key: "primary" | "secondary"): ParsedUsageWindow | undefined => {
355
+ const usedPercent = toNumber(headers[`x-codex-${key}-used-percent`]);
356
+ if (usedPercent === undefined) return undefined;
357
+ const windowMinutes = toNumber(headers[`x-codex-${key}-window-minutes`]);
358
+ const resetAt = toNumber(headers[`x-codex-${key}-reset-at`]);
359
+ return {
360
+ usedPercent,
361
+ limitWindowSeconds: windowMinutes === undefined ? undefined : windowMinutes * 60,
362
+ resetAt,
363
+ };
364
+ };
365
+ const primary = parseWindow("primary");
366
+ const secondary = parseWindow("secondary");
367
+ if (!primary && !secondary) return null;
368
+ const limits: UsageLimit[] = [];
369
+ if (primary) limits.push(buildUsageLimit({ key: "primary", window: primary, nowMs: now }));
370
+ if (secondary) limits.push(buildUsageLimit({ key: "secondary", window: secondary, nowMs: now }));
371
+ return {
372
+ provider: "openai-codex",
373
+ fetchedAt: now,
374
+ limits,
375
+ metadata: { source: "ratelimit-headers" },
376
+ };
377
+ }
378
+
347
379
  export const openaiCodexUsageProvider: UsageProvider = {
348
380
  id: "openai-codex",
349
381
  supports(params: UsageFetchParams): boolean {
350
382
  return params.provider === "openai-codex" && params.credential.type === "oauth";
351
383
  },
384
+ parseRateLimitHeaders: parseCodexRateLimitHeaders,
352
385
  async fetchUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
353
386
  if (params.provider !== "openai-codex") return null;
354
387
  const { credential } = params;
package/src/usage/zai.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { toNumber } from "@oh-my-pi/pi-catalog/utils";
2
2
  import type {
3
+ CredentialRankingStrategy,
3
4
  UsageAmount,
4
5
  UsageFetchContext,
5
6
  UsageFetchParams,
@@ -175,12 +176,14 @@ function buildZaiWindow(parsed: ZaiUsageLimitItem): UsageWindow {
175
176
  };
176
177
  }
177
178
 
178
- function requestQuotaLabel(parsed: ZaiUsageLimitItem): string {
179
+ function isZaiFeatureRequestLimit(parsed: ZaiUsageLimitItem): boolean {
179
180
  const detailCodes =
180
181
  parsed.usageDetails?.map(detail => detail.modelCode).filter((code): code is string => !!code) ?? [];
181
- if (detailCodes.includes("search-prime") && detailCodes.includes("web-reader") && detailCodes.includes("zread")) {
182
- return "ZAI Web Search / Reader / Zread Quota";
183
- }
182
+ return detailCodes.includes("search-prime") && detailCodes.includes("web-reader") && detailCodes.includes("zread");
183
+ }
184
+
185
+ function requestQuotaLabel(parsed: ZaiUsageLimitItem): string {
186
+ if (isZaiFeatureRequestLimit(parsed)) return "ZAI Web Search / Reader / Zread Quota";
184
187
  return "ZAI Request Quota";
185
188
  }
186
189
 
@@ -191,6 +194,29 @@ function buildModelUsageUrl(baseUrl: string, now: Date): string {
191
194
  return `${baseUrl}${MODEL_USAGE_PATH}?startTime=${encodeURIComponent(startTime)}&endTime=${encodeURIComponent(endTime)}`;
192
195
  }
193
196
 
197
+ function getZaiCredentialLimits(report: UsageReport): UsageLimit[] {
198
+ const limits = report.limits.filter(
199
+ limit => limit.id.startsWith("zai:requests:") || limit.id.startsWith("zai:tokens:"),
200
+ );
201
+ return limits;
202
+ }
203
+
204
+ function rankZaiRequestLimits(report: UsageReport): UsageLimit[] {
205
+ const requestLimits = report.limits.filter(limit => limit.id.startsWith("zai:requests:"));
206
+ const credentialLimits = getZaiCredentialLimits(report);
207
+ const limits = requestLimits.length > 0 ? requestLimits : credentialLimits;
208
+ const ranked = [...limits];
209
+ ranked.sort((left, right) => {
210
+ const leftDuration = left.window?.durationMs ?? Number.POSITIVE_INFINITY;
211
+ const rightDuration = right.window?.durationMs ?? Number.POSITIVE_INFINITY;
212
+ if (leftDuration !== rightDuration) return leftDuration - rightDuration;
213
+ const leftReset = left.window?.resetsAt ?? Number.POSITIVE_INFINITY;
214
+ const rightReset = right.window?.resetsAt ?? Number.POSITIVE_INFINITY;
215
+ return leftReset - rightReset;
216
+ });
217
+ return ranked;
218
+ }
219
+
194
220
  async function fetchZaiUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
195
221
  if (params.provider !== "zai") return null;
196
222
  const credential = params.credential;
@@ -263,13 +289,15 @@ async function fetchZaiUsage(params: UsageFetchParams, ctx: UsageFetchContext):
263
289
  percentage: parsed.percentage,
264
290
  unit: "requests",
265
291
  });
292
+ const featureLimit = isZaiFeatureRequestLimit(parsed);
266
293
  limits.push({
267
- id: `zai:requests:${window.id}`,
294
+ id: featureLimit ? `zai:features:web-search-reader-zread:${window.id}` : `zai:requests:${window.id}`,
268
295
  label: requestQuotaLabel(parsed),
269
296
  scope: {
270
297
  provider: params.provider,
271
298
  windowId: window.id,
272
- shared: true,
299
+ shared: !featureLimit,
300
+ ...(featureLimit ? { tier: "web-search-reader-zread" } : {}),
273
301
  },
274
302
  window,
275
303
  amount,
@@ -319,3 +347,18 @@ export const zaiUsageProvider: UsageProvider = {
319
347
  fetchUsage: fetchZaiUsage,
320
348
  supports: params => params.provider === "zai" && params.credential.type === "api_key",
321
349
  };
350
+
351
+ export const zaiRankingStrategy: CredentialRankingStrategy = {
352
+ findWindowLimits(report) {
353
+ const ranked = rankZaiRequestLimits(report);
354
+ return { primary: ranked[0], secondary: ranked[1] };
355
+ },
356
+ scopeLimits(report) {
357
+ const limits = getZaiCredentialLimits(report);
358
+ return limits;
359
+ },
360
+ windowDefaults: {
361
+ primaryMs: 5 * HOUR_MS,
362
+ secondaryMs: WEEK_MS,
363
+ },
364
+ };
package/src/usage.ts CHANGED
@@ -260,6 +260,10 @@ export interface UsageCredential {
260
260
  accountId?: string;
261
261
  projectId?: string;
262
262
  email?: string;
263
+ /** Organization/workspace the credential is scoped to (see OAuthCredentials.orgId). */
264
+ orgId?: string;
265
+ /** Human-readable organization name for display. */
266
+ orgName?: string;
263
267
  enterpriseUrl?: string;
264
268
  metadata?: Record<string, unknown>;
265
269
  apiEndpoint?: string;
@@ -98,12 +98,12 @@ export function getOpenAIStreamFirstEventTimeoutMs(
98
98
  * pre-response request (issue #2422 regression: large `write` tool-call streams
99
99
  * died at the budget with `TimeoutError: The operation timed out.` despite
100
100
  * deltas actively flowing). This arms a `clearTimeout`-able timer instead;
101
- * callers MUST `clear()` as soon as `fetchWithRetry` resolves (headers in) so
102
- * the body stream is left to the iterator-level idle watchdog. The timer aborts
103
- * with a `TimeoutError` matching `AbortSignal.timeout`, so a genuine pre-response
104
- * stall behaves exactly as the prior code did `fetchWithRetry` normalizes the
105
- * abort to "Request was aborted" either way (only a post-headers abort ever
106
- * surfaced the raw `"The operation timed out."`, which clearing now prevents).
101
+ * callers MUST `clear()` as soon as the guarded transport attempt settles so
102
+ * the body stream is left to the iterator-level idle watchdog.
103
+ *
104
+ * Retrying callers MUST arm a fresh guard for each transport attempt and keep
105
+ * the retry loop's base signal reserved for caller cancellation. Reusing the
106
+ * guard as the loop signal makes its timeout indistinguishable from cancellation.
107
107
  *
108
108
  * Returns the caller signal unchanged (and a no-op `clear`) when no positive
109
109
  * timeout is configured.