@oh-my-pi/pi-ai 18.4.0 → 18.4.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 (49) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +12 -0
  3. package/dist/types/auth/oauth.d.ts +2 -2
  4. package/dist/types/auth/refresh.d.ts +3 -3
  5. package/dist/types/auth/store.d.ts +4 -2
  6. package/dist/types/auth/types.d.ts +20 -3
  7. package/dist/types/auth/usage-cache.d.ts +9 -2
  8. package/dist/types/auth/usage.d.ts +6 -5
  9. package/dist/types/auth-broker/client.d.ts +2 -2
  10. package/dist/types/auth-broker/remote-store.d.ts +2 -2
  11. package/dist/types/error/flags.d.ts +9 -2
  12. package/dist/types/error/rate-limit.d.ts +3 -2
  13. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  14. package/dist/types/providers/cursor-pi-args.d.ts +13 -18
  15. package/dist/types/stream.d.ts +7 -0
  16. package/dist/types/usage/cursor.d.ts +3 -1
  17. package/dist/types/usage/zai.d.ts +2 -0
  18. package/dist/types/usage.d.ts +6 -0
  19. package/package.json +6 -6
  20. package/src/auth/cascade.ts +1 -0
  21. package/src/auth/oauth.ts +4 -3
  22. package/src/auth/refresh.ts +105 -16
  23. package/src/auth/resets.ts +6 -2
  24. package/src/auth/select.ts +11 -1
  25. package/src/auth/store.ts +4 -0
  26. package/src/auth/types.ts +22 -2
  27. package/src/auth/usage-cache.ts +12 -8
  28. package/src/auth/usage.ts +45 -19
  29. package/src/auth-broker/client.ts +8 -3
  30. package/src/auth-broker/remote-store.ts +4 -2
  31. package/src/auth-broker/server.ts +7 -1
  32. package/src/auth-gateway/dispatch.ts +1 -0
  33. package/src/auth-retry.ts +5 -1
  34. package/src/error/flags.ts +10 -3
  35. package/src/error/rate-limit.ts +23 -3
  36. package/src/providers/anthropic.ts +62 -53
  37. package/src/providers/cowork-fetch.ts +45 -3
  38. package/src/providers/cursor/exec-modern.ts +1 -1
  39. package/src/providers/cursor-pi-args.ts +13 -18
  40. package/src/providers/cursor.ts +179 -57
  41. package/src/providers/devin.ts +43 -13
  42. package/src/providers/openai-completions.ts +122 -30
  43. package/src/stream.ts +77 -16
  44. package/src/usage/claude.ts +5 -0
  45. package/src/usage/cursor.ts +43 -1
  46. package/src/usage/devin.ts +8 -7
  47. package/src/usage/registry.ts +2 -1
  48. package/src/usage/zai.ts +20 -0
  49. package/src/usage.ts +6 -0
package/src/stream.ts CHANGED
@@ -175,6 +175,9 @@ const PROVIDER_INFLIGHT_HEARTBEAT_MS = 5_000;
175
175
  const PROVIDER_INFLIGHT_SIGNAL_FALLBACK_MS = 250;
176
176
  const PROVIDER_INFLIGHT_HEARTBEAT_FLUSH_TIMEOUT_MS = 1_000;
177
177
  const PROVIDER_INFLIGHT_RELEASE_TIMEOUT_MS = 5_000;
178
+ const PROVIDER_INFLIGHT_LOCK_RETRY_INITIAL_MS = 25;
179
+ const PROVIDER_INFLIGHT_LOCK_RETRY_MAX_MS = 250;
180
+ const PROVIDER_INFLIGHT_LOCK_RETRY_BUDGET_MS = 3_000;
178
181
 
179
182
  let configuredProviderMaxInFlightRequests: Record<string, number> = {};
180
183
  let providerInFlightRootOverride: string | undefined;
@@ -187,6 +190,9 @@ let providerInFlightLeaseRemoverOverride: ((leasePath: string) => Promise<void>)
187
190
  let providerInFlightWaitObserverOverride: ((provider: string) => void) | undefined;
188
191
  let providerInFlightLockCreatedObserverOverride: ((lockDir: string) => Promise<void>) | undefined;
189
192
  let providerInFlightLockIdentifiedObserverOverride: ((lockDir: string) => Promise<void>) | undefined;
193
+ let providerInFlightLockMkdirOverride: ((lockDir: string) => Promise<void>) | undefined;
194
+ let providerInFlightLockPlatformOverride: NodeJS.Platform | undefined;
195
+ let providerInFlightLockRetryTimings: { budgetMs?: number; initialDelayMs?: number; maxDelayMs?: number } | undefined;
190
196
 
191
197
  export function configureProviderMaxInFlightRequests(limits: Record<string, number> | undefined): void {
192
198
  configuredProviderMaxInFlightRequests = limits ?? {};
@@ -351,6 +357,42 @@ async function releaseProviderInFlightLockDirIfSame(
351
357
  } catch {}
352
358
  }
353
359
 
360
+ // On Windows, mkdir on a lock directory another process is concurrently deleting
361
+ // (delete-pending while a watcher, antivirus, or indexer still holds it) fails
362
+ // with ERROR_ACCESS_DENIED — mapped to EPERM/EACCES — instead of EEXIST, so
363
+ // ordinary contention looks like a permission error. On win32 only, back off and
364
+ // retry briefly; if the failure persists past the budget, rethrow the original
365
+ // error so a real permission problem still surfaces. Every other outcome
366
+ // (success, EEXIST, any other code) exits immediately, keeping non-win32 and
367
+ // non-transient behavior unchanged.
368
+ function isTransientProviderInFlightLockMkdirError(error: NodeJS.ErrnoException): boolean {
369
+ if ((providerInFlightLockPlatformOverride ?? process.platform) !== "win32") return false;
370
+ return error.code === "EPERM" || error.code === "EACCES";
371
+ }
372
+
373
+ async function mkdirProviderInFlightLockDir(lockDir: string, signal?: AbortSignal): Promise<void> {
374
+ const mkdir = providerInFlightLockMkdirOverride ?? ((dir: string) => fs.mkdir(dir));
375
+ let failures = 0;
376
+ let since = Date.now();
377
+ while (true) {
378
+ try {
379
+ await mkdir(lockDir);
380
+ return;
381
+ } catch (error) {
382
+ if (!isTransientProviderInFlightLockMkdirError(error as NodeJS.ErrnoException)) throw error;
383
+ const now = Date.now();
384
+ if (failures === 0) since = now;
385
+ const budgetMs = providerInFlightLockRetryTimings?.budgetMs ?? PROVIDER_INFLIGHT_LOCK_RETRY_BUDGET_MS;
386
+ if (now - since >= budgetMs) throw error;
387
+ const initialMs = providerInFlightLockRetryTimings?.initialDelayMs ?? PROVIDER_INFLIGHT_LOCK_RETRY_INITIAL_MS;
388
+ const maxMs = providerInFlightLockRetryTimings?.maxDelayMs ?? PROVIDER_INFLIGHT_LOCK_RETRY_MAX_MS;
389
+ const delayMs = Math.min(maxMs, initialMs * 2 ** failures);
390
+ failures++;
391
+ await untilAborted(signal, Bun.sleep(delayMs));
392
+ }
393
+ }
394
+ }
395
+
354
396
  async function acquireProviderInFlightLock(provider: string, signal?: AbortSignal): Promise<() => Promise<void>> {
355
397
  const lockDir = providerInFlightLockDir(provider);
356
398
  await fs.mkdir(path.dirname(lockDir), { recursive: true });
@@ -358,7 +400,7 @@ async function acquireProviderInFlightLock(provider: string, signal?: AbortSigna
358
400
  while (true) {
359
401
  if (signal?.aborted) throw signal.reason ?? new AIError.AbortError("Provider request aborted before dispatch");
360
402
  try {
361
- await fs.mkdir(lockDir);
403
+ await mkdirProviderInFlightLockDir(lockDir, signal);
362
404
  await providerInFlightLockCreatedObserverOverride?.(lockDir);
363
405
  let lockIdentity: ProviderInFlightLockIdentity;
364
406
  try {
@@ -632,6 +674,15 @@ export const __providerInFlightForTesting = {
632
674
  setLockIdentifiedObserver(observer: ((lockDir: string) => Promise<void>) | undefined): void {
633
675
  providerInFlightLockIdentifiedObserverOverride = observer;
634
676
  },
677
+ setLockMkdirOverride(mkdir: ((lockDir: string) => Promise<void>) | undefined): void {
678
+ providerInFlightLockMkdirOverride = mkdir;
679
+ },
680
+ setLockPlatformOverride(platform: NodeJS.Platform | undefined): void {
681
+ providerInFlightLockPlatformOverride = platform;
682
+ },
683
+ setLockRetryTimings(timings: { budgetMs?: number; initialDelayMs?: number; maxDelayMs?: number } | undefined): void {
684
+ providerInFlightLockRetryTimings = timings;
685
+ },
635
686
  providerDir(provider: string): string {
636
687
  return providerInFlightDir(provider);
637
688
  },
@@ -1881,29 +1932,39 @@ function mapOptionsForApi<TApi extends Api>(
1881
1932
  }
1882
1933
 
1883
1934
  if (ANTHROPIC_USE_INTERLEAVED_THINKING) {
1884
- return castApi<"anthropic-messages">({
1885
- ...base,
1886
- maxTokens: maxTokensWithThinking,
1887
- requestModelId: resolveWireModelId(model, reasoning),
1888
- thinkingEnabled: true,
1889
- thinkingBudgetTokens: thinkingBudget,
1890
- effort,
1891
- toolChoice: mapAnthropicToolChoice(options?.toolChoice),
1892
- thinkingDisplay: options?.hideThinkingSummary ? "omitted" : undefined,
1893
- serviceTier: options?.serviceTier,
1894
- });
1935
+ if (
1936
+ model.maxTokens !== null &&
1937
+ model.maxTokens !== undefined &&
1938
+ model.maxTokens < thinkingBudget + OUTPUT_FALLBACK_BUFFER
1939
+ ) {
1940
+ thinkingBudget = model.maxTokens - OUTPUT_FALLBACK_BUFFER;
1941
+ }
1942
+ if (thinkingBudget >= ANTHROPIC_THINKING.minimal) {
1943
+ return castApi<"anthropic-messages">({
1944
+ ...base,
1945
+ maxTokens: maxTokensWithThinking,
1946
+ requestModelId: resolveWireModelId(model, reasoning),
1947
+ thinkingEnabled: true,
1948
+ thinkingBudgetTokens: thinkingBudget,
1949
+ effort,
1950
+ toolChoice: mapAnthropicToolChoice(options?.toolChoice),
1951
+ thinkingDisplay: options?.hideThinkingSummary ? "omitted" : undefined,
1952
+ serviceTier: options?.serviceTier,
1953
+ });
1954
+ }
1895
1955
  }
1896
1956
 
1897
1957
  // Caller's maxTokens is desired output, so add thinking budget on top. With no caller/model cap, use a finite total fallback.
1898
1958
  const maxTokens = maxTokensWithThinkingBudget(base.maxTokens, model.maxTokens, thinkingBudget);
1899
1959
 
1900
- // If not enough room for thinking + output, reduce thinking budget
1901
- if (maxTokens <= thinkingBudget) {
1902
- thinkingBudget = maxTokens - MIN_OUTPUT_TOKENS;
1960
+ // Keep the provider's output buffer after thinking, reducing the
1961
+ // budget before its wire-level clamp could fall below the API minimum.
1962
+ if (maxTokens < thinkingBudget + OUTPUT_FALLBACK_BUFFER) {
1963
+ thinkingBudget = maxTokens - OUTPUT_FALLBACK_BUFFER;
1903
1964
  }
1904
1965
 
1905
1966
  // If thinking budget is too low, disable thinking
1906
- if (thinkingBudget <= 0) {
1967
+ if (thinkingBudget < ANTHROPIC_THINKING.minimal) {
1907
1968
  return castApi<"anthropic-messages">({
1908
1969
  ...base,
1909
1970
  requestModelId: resolveWireModelId(model, undefined),
@@ -702,6 +702,11 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
702
702
  const { orgId, baseUrl: _baseUrl, report: _report, ...resetCredits } = resetCreditList;
703
703
  result.resetCredits = resetCredits;
704
704
  if (orgId) result.metadata = { ...result.metadata, orgId };
705
+ } else if (ctx.previousReport?.resetCredits) {
706
+ // `null` means the reset probe failed (timeout, 429, malformed body), not
707
+ // that the account has no saved resets. Keep the block this credential
708
+ // last reported instead of dropping banked resets from the report.
709
+ result.resetCredits = ctx.previousReport.resetCredits;
705
710
  }
706
711
  result.metadata = {
707
712
  ...result.metadata,
@@ -1,6 +1,9 @@
1
+ import { quotaTierFor } from "@oh-my-pi/pi-catalog/compat/behavior";
1
2
  import { toNumber } from "@oh-my-pi/pi-catalog/utils";
2
3
  import { extractCursorAccessTokenUserId } from "../registry/oauth/cursor";
3
4
  import type {
5
+ CredentialRankingContext,
6
+ CredentialRankingStrategy,
4
7
  UsageAmount,
5
8
  UsageFetchContext,
6
9
  UsageFetchParams,
@@ -10,7 +13,7 @@ import type {
10
13
  UsageWindow,
11
14
  } from "../usage";
12
15
  import { isRecord } from "../utils";
13
- import { parseIsoTimestamp, usageStatus } from "./shared";
16
+ import { DAY_MS, parseIsoTimestamp, usageStatus } from "./shared";
14
17
 
15
18
  function parseTimestamp(value: unknown): number | undefined {
16
19
  const numeric = toNumber(value);
@@ -352,6 +355,45 @@ export function parseCursorUsage(payload: unknown, fetchedAt = Date.now()): Usag
352
355
  };
353
356
  }
354
357
 
358
+ const CURSOR_MODELS_LIMIT_ID = "cursor:usd:individual-auto";
359
+ const OTHER_MODELS_LIMIT_ID = "cursor:usd:individual-api";
360
+
361
+ function scopeCursorLimitsForModel(report: UsageReport, context: CredentialRankingContext | undefined): UsageLimit[] {
362
+ const splitLimits = report.limits.filter(
363
+ limit => limit.id === CURSOR_MODELS_LIMIT_ID || limit.id === OTHER_MODELS_LIMIT_ID,
364
+ );
365
+ if (splitLimits.length > 0) {
366
+ const modelId = context?.modelId;
367
+ if (!modelId) return [];
368
+ const tier = quotaTierFor("cursor", modelId);
369
+ const limitId = tier === "auto" ? CURSOR_MODELS_LIMIT_ID : tier === "api" ? OTHER_MODELS_LIMIT_ID : undefined;
370
+ return limitId ? splitLimits.filter(limit => limit.id === limitId) : [];
371
+ }
372
+
373
+ const combinedLimits = report.limits.filter(
374
+ limit => limit.id === "cursor:usd:individual-plan" || limit.id === "cursor:usd:individual-overall",
375
+ );
376
+ return combinedLimits.length > 0 ? combinedLimits : report.limits;
377
+ }
378
+
379
+ /** Routes Cursor credential ranking and reserve checks through the requested model's billing pool. */
380
+ export const cursorRankingStrategy: CredentialRankingStrategy = {
381
+ findWindowLimits(report, context) {
382
+ return { secondary: scopeCursorLimitsForModel(report, context)[0] };
383
+ },
384
+ scopeLimits: scopeCursorLimitsForModel,
385
+ scopeLimitsForReserve: scopeCursorLimitsForModel,
386
+ // Back off per billing pool so an exhausted Other Models pool does not
387
+ // block Grok/Composer on the same account (and vice versa).
388
+ blockScope(context) {
389
+ return context?.modelId ? `pool:${quotaTierFor("cursor", context.modelId)}` : undefined;
390
+ },
391
+ windowDefaults: {
392
+ primaryMs: 30 * DAY_MS,
393
+ secondaryMs: 30 * DAY_MS,
394
+ },
395
+ };
396
+
355
397
  export const cursorUsageProvider: UsageProvider = {
356
398
  id: "cursor",
357
399
  supports(params: UsageFetchParams): boolean {
@@ -21,7 +21,7 @@ import {
21
21
  type Timestamp,
22
22
  } from "@oh-my-pi/pi-catalog/discovery/devin-proto";
23
23
  import { create, toBinary } from "@oh-my-pi/pi-catalog/discovery/protobuf";
24
- import { DEVIN_DEFAULT_BASE_URL, devinCliMetadata, normalizeDevinSessionToken } from "@oh-my-pi/pi-catalog/wire/devin";
24
+ import { DEVIN_DEFAULT_BASE_URL, devinCliMetadata, devinWireMetadata } from "@oh-my-pi/pi-catalog/wire/devin";
25
25
  import { decodeDevinUnaryMessage } from "@oh-my-pi/pi-catalog/wire/devin-proto";
26
26
  import type {
27
27
  UsageAmount,
@@ -48,11 +48,11 @@ function timestampMs(timestamp: Timestamp): number {
48
48
  return Number(timestamp.seconds) * 1_000 + timestamp.nanos / 1_000_000;
49
49
  }
50
50
 
51
- /** Session token as the CLI sends it: the wire format carries the scheme prefix. */
52
- function devinSessionToken(credential: UsageCredential): string | undefined {
51
+ /** Credential bytes exactly as their storage kind requires on the Cascade wire. */
52
+ function devinCredential(credential: UsageCredential): string | undefined {
53
53
  const raw = credential.type === "oauth" ? credential.accessToken : credential.apiKey;
54
54
  const token = raw?.trim();
55
- return token ? normalizeDevinSessionToken(token) : undefined;
55
+ return token || undefined;
56
56
  }
57
57
 
58
58
  /** `TEAMS_TIER_DEVIN_PRO` → `Devin Pro`, for plans that ship no `plan_name`. */
@@ -261,13 +261,14 @@ function buildDevinReport(response: GetUserStatusResponse): UsageReport | null {
261
261
 
262
262
  async function fetchDevinUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise<UsageReport | null> {
263
263
  if (params.provider !== PROVIDER) return null;
264
- const token = devinSessionToken(params.credential);
264
+ const token = devinCredential(params.credential);
265
265
  if (!token) return null;
266
266
  const baseUrl = (params.baseUrl ?? DEVIN_DEFAULT_BASE_URL).replace(/\/+$/, "");
267
267
 
268
+ const metadata = params.credential.type === "oauth" ? devinCliMetadata(token) : devinWireMetadata(token);
268
269
  try {
269
270
  const request = create(GetUserStatusRequestSchema, {
270
- metadata: create(MetadataSchema, devinCliMetadata(token)),
271
+ metadata: create(MetadataSchema, metadata),
271
272
  });
272
273
  const response = await ctx.fetch(`${baseUrl}${GET_USER_STATUS_PATH}`, {
273
274
  method: "POST",
@@ -305,6 +306,6 @@ async function fetchDevinUsage(params: UsageFetchParams, ctx: UsageFetchContext)
305
306
  export const devinUsageProvider: UsageProvider = {
306
307
  id: PROVIDER,
307
308
  fetchUsage: fetchDevinUsage,
308
- supports: params => params.provider === PROVIDER && devinSessionToken(params.credential) !== undefined,
309
+ supports: params => params.provider === PROVIDER && devinCredential(params.credential) !== undefined,
309
310
  validatesCredentials: true,
310
311
  };
@@ -4,7 +4,7 @@ import { alibabaTokenPlanRankingStrategy, alibabaTokenPlanUsageProvider } from "
4
4
  import { charmHyperUsageProvider } from "./charm-hyper";
5
5
  import { claudeRankingStrategy, claudeUsageProvider } from "./claude";
6
6
  import { clinePassUsageProvider } from "./cline-pass";
7
- import { cursorUsageProvider } from "./cursor";
7
+ import { cursorRankingStrategy, cursorUsageProvider } from "./cursor";
8
8
  import { devinUsageProvider } from "./devin";
9
9
  import { googleGeminiCliUsageProvider } from "./gemini";
10
10
  import { githubCopilotUsageProvider } from "./github-copilot";
@@ -60,6 +60,7 @@ const DEFAULT_RANKING_STRATEGIES = new Map<Provider, CredentialRankingStrategy>(
60
60
  ["alibaba-token-plan", alibabaTokenPlanRankingStrategy],
61
61
  ["openai-codex", codexRankingStrategy],
62
62
  ["anthropic", claudeRankingStrategy],
63
+ ["cursor", cursorRankingStrategy],
63
64
  ["google-antigravity", antigravityRankingStrategy],
64
65
  ["kimi-code", kimiRankingStrategy],
65
66
  ["zai", zaiRankingStrategy],
package/src/usage/zai.ts CHANGED
@@ -390,6 +390,7 @@ async function fetchZaiUsage(params: UsageFetchParams, ctx: UsageFetchContext):
390
390
  return report;
391
391
  }
392
392
 
393
+ /** Fetches the account-wide quota windows for ZAI credentials. */
393
394
  export const zaiUsageProvider: UsageProvider = {
394
395
  id: "zai",
395
396
  cacheVersion: 2,
@@ -399,7 +400,10 @@ export const zaiUsageProvider: UsageProvider = {
399
400
  (params.credential.type === "oauth" ? Boolean(params.credential.accessToken) : Boolean(params.credential.apiKey)),
400
401
  };
401
402
 
403
+ /** Ranks ZAI credentials and identifies quota blocks a live report can heal. */
402
404
  export const zaiRankingStrategy: CredentialRankingStrategy = {
405
+ // Pre-scoping ZAI quota blocks are unscoped; let the usage preflight probe them.
406
+ healsGlobalBlocks: true,
403
407
  findWindowLimits(report) {
404
408
  const ranked = rankZaiRequestLimits(report);
405
409
  return { primary: ranked[0], secondary: ranked[1] };
@@ -408,6 +412,22 @@ export const zaiRankingStrategy: CredentialRankingStrategy = {
408
412
  const limits = getZaiCredentialLimits(report);
409
413
  return limits;
410
414
  },
415
+ // All GLM requests share the account's quota pool; keep reactive blocks
416
+ // separate from provider-wide failures that a usage report cannot heal.
417
+ blockScope() {
418
+ return "credits";
419
+ },
420
+ healableBlockScopes(report) {
421
+ const limits = getZaiCredentialLimits(report);
422
+ // A feature-only report cannot prove the shared credit/token pool recovered.
423
+ if (!limits.some(limit => limit.id.startsWith("zai:credits:") || limit.id.startsWith("zai:tokens:"))) {
424
+ return [];
425
+ }
426
+ return [
427
+ { blockScope: "credits", limits },
428
+ { blockScope: "", limits },
429
+ ];
430
+ },
411
431
  windowDefaults: {
412
432
  primaryMs: 5 * HOUR_MS,
413
433
  secondaryMs: WEEK_MS,
package/src/usage.ts CHANGED
@@ -396,6 +396,12 @@ export interface UsageFetchContext {
396
396
  fetch: FetchImpl;
397
397
  logger?: UsageLogger;
398
398
  retryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
399
+ /**
400
+ * Last report cached for this exact credential cache key, when one exists.
401
+ * Lets a fetcher keep a field it could not re-read this time (a failed
402
+ * secondary probe) instead of reporting it as absent.
403
+ */
404
+ previousReport?: UsageReport;
399
405
  }
400
406
 
401
407
  /** Provider implementation for fetching usage information. */