@oh-my-pi/pi-ai 18.4.0 → 18.4.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 (42) hide show
  1. package/CHANGELOG.md +18 -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/rate-limit.d.ts +3 -2
  12. package/dist/types/stream.d.ts +7 -0
  13. package/dist/types/usage/cursor.d.ts +3 -1
  14. package/dist/types/usage/zai.d.ts +2 -0
  15. package/dist/types/usage.d.ts +6 -0
  16. package/package.json +6 -6
  17. package/src/auth/cascade.ts +1 -0
  18. package/src/auth/oauth.ts +4 -3
  19. package/src/auth/refresh.ts +105 -16
  20. package/src/auth/resets.ts +6 -2
  21. package/src/auth/select.ts +11 -1
  22. package/src/auth/store.ts +4 -0
  23. package/src/auth/types.ts +22 -2
  24. package/src/auth/usage-cache.ts +12 -8
  25. package/src/auth/usage.ts +45 -19
  26. package/src/auth-broker/client.ts +8 -3
  27. package/src/auth-broker/remote-store.ts +4 -2
  28. package/src/auth-broker/server.ts +7 -1
  29. package/src/auth-gateway/dispatch.ts +1 -0
  30. package/src/auth-retry.ts +5 -1
  31. package/src/error/rate-limit.ts +23 -3
  32. package/src/providers/anthropic.ts +62 -53
  33. package/src/providers/cowork-fetch.ts +45 -3
  34. package/src/providers/devin.ts +43 -13
  35. package/src/providers/openai-completions.ts +122 -30
  36. package/src/stream.ts +52 -1
  37. package/src/usage/claude.ts +5 -0
  38. package/src/usage/cursor.ts +43 -1
  39. package/src/usage/devin.ts +8 -7
  40. package/src/usage/registry.ts +2 -1
  41. package/src/usage/zai.ts +20 -0
  42. package/src/usage.ts +6 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.4.1] - 2026-09-28
6
+
7
+ ### Fixed
8
+
9
+ - Fixed extension-provided usage reports missing from broker-connected clients when the broker does not have that provider ([#13579](https://github.com/can1357/oh-my-pi/issues/13579)).
10
+ - Fixed signed LiteLLM `thinking_blocks` being dropped on openai-completions tool-call turns ([#13407](https://github.com/can1357/oh-my-pi/issues/13407)).
11
+ - Fixed Anthropic turns ending on a bare `aborted` error with no retry when the connection dropped mid-response (typically during long thinking). The first-party Anthropic transport runs on `node:https`, whose Bun shim reports a response cut off mid-body as `Error("aborted")` (ECONNRESET) — indistinguishable from a cancellation, so it classified as unknown. It now surfaces as "The socket connection was closed unexpectedly…", the same wording native `fetch` uses, so the drop classifies as transient and the turn is retried; caller aborts keep their original error ([#13384](https://github.com/can1357/oh-my-pi/pull/13384) by [@jerryfane](https://github.com/jerryfane))
12
+ - Fixed a stale Z.AI quota block pinning sessions to a fallback model after live usage recovered ([#13343](https://github.com/can1357/oh-my-pi/issues/13343)).
13
+ - Fixed OAuth credentials being re-minted on every provider 401 during an outage; recently minted tokens are reused for auth recovery ([#13350](https://github.com/can1357/oh-my-pi/issues/13350)) ([#13485](https://github.com/can1357/oh-my-pi/pull/13485) by [@ShivamB25](https://github.com/ShivamB25)).
14
+ - Fixed Claude usage reports intermittently dropping an account's saved resets when the separate reset probe was rate-limited or timed out; the last known saved resets now stay visible until the probe answers again ([#13474](https://github.com/can1357/oh-my-pi/pull/13474) by [@schickling-assistant](https://github.com/schickling-assistant))
15
+ - Fixed transient Windows `EPERM` when creating the provider in-flight lock failing the request instead of retrying ([#13334](https://github.com/can1357/oh-my-pi/pull/13334) by [@1Morganmore](https://github.com/1Morganmore)).
16
+ - Fixed rolling per-minute TPM/RPM 429s worded as quota errors being treated as exhausted quota and ending the turn ([#13253](https://github.com/can1357/oh-my-pi/issues/13253)).
17
+ - Fixed keyless Anthropic-compatible endpoints receiving `Authorization: Bearer N/A` and `X-Api-Key: N/A` headers ([#13043](https://github.com/can1357/oh-my-pi/pull/13043) by [@jchanghong023](https://github.com/jchanghong023)).
18
+ - Fixed legacy Windsurf Enterprise API keys being rejected because they were always sent with the Devin session-token prefix ([#12960](https://github.com/can1357/oh-my-pi/pull/12960)).
19
+ - Fixed an exhausted Cursor "Other Models" pool blocking Grok and Composer, which Cursor bills to its own pool ([#13198](https://github.com/can1357/oh-my-pi/issues/13198)).
20
+ - Fixed Anthropic OAuth requests leaving the agent system prompt without its own cache breakpoint: the breakpoint on the short Claude Code identity block now moves to the last system block, so a request whose messages miss the cache (such as the first after a compaction) reads the cached system prompt instead of writing it again ([#13104](https://github.com/can1357/oh-my-pi/issues/13104), [#13556](https://github.com/can1357/oh-my-pi/pull/13556) by [@aktanazat](https://github.com/aktanazat)).
21
+ - Fixed the Anthropic system cache breakpoint covering working-directory-specific system text: it now sits before the first `<project-context>` or `<memories>` block, and on OAuth it replaces the identity-block breakpoint instead of taking a message breakpoint ([#13104](https://github.com/can1357/oh-my-pi/issues/13104)).
22
+
5
23
  ## [18.4.0] - 2026-09-28
6
24
 
7
25
  ### Breaking Changes
package/README.md CHANGED
@@ -1161,6 +1161,18 @@ const response = await complete(
1161
1161
  );
1162
1162
  ```
1163
1163
 
1164
+ When an auth-broker client requests recovery after a provider rejects an OAuth
1165
+ bearer, the broker may reuse a still-fresh token that it minted in the previous
1166
+ five minutes instead of rotating the refresh token again. The process-local
1167
+ cache is bound to the credential ID and access token. Scheduled expiry refreshes,
1168
+ direct forced refreshes such as MCP recovery, ordinary hard-auth blocks, and
1169
+ sibling rotation keep their existing behavior. Cached refresh responses rebind
1170
+ by durable credential ID so concurrent row removal cannot switch a pinned
1171
+ session to another account.
1172
+ Generic `forceRefresh: true` requests do not opt into reuse. Provider-401 retry
1173
+ paths pass `refreshReason: "auth-recovery"` explicitly; expiry sentinels and
1174
+ usage polling do not imply that reason.
1175
+
1164
1176
  ### Provider Notes
1165
1177
 
1166
1178
  **OpenAI Codex**: Requires a ChatGPT Plus or Pro subscription. Provides access to GPT-5.x Codex models with extended context windows and reasoning capabilities. The library automatically handles session-based prompt caching when `sessionId` is provided in stream options.
@@ -5,7 +5,7 @@ import type { AccountPolicies } from "./policy.js";
5
5
  import type { CredentialPool } from "./pool.js";
6
6
  import type { OAuthRefresher } from "./refresh.js";
7
7
  import type { CredentialSelector } from "./select.js";
8
- import type { AuthAccountPolicy, AuthApiKeyOptions, OAuthAccess, OAuthAccessResolution, OAuthAccountIdentity, OAuthAccountSummary, OAuthApi, OAuthCredential, OAuthLoginController, OAuthLoginIdentity, StoredOAuthRefreshOptions, StoredOAuthRefreshResult, AuthCredentialSnapshotEntry } from "./types.js";
8
+ import type { AuthAccountPolicy, AuthApiKeyOptions, AuthCredentialSnapshotEntry, OAuthAccess, OAuthAccessResolution, OAuthAccountIdentity, OAuthAccountSummary, OAuthApi, OAuthCredential, OAuthLoginController, OAuthLoginIdentity, OAuthRefreshByIdOptions, StoredOAuthRefreshOptions, StoredOAuthRefreshResult } from "./types.js";
9
9
  /** Dependencies used by the OAuth account operations. */
10
10
  export interface OAuthAccountsDeps {
11
11
  pool: CredentialPool;
@@ -87,7 +87,7 @@ export declare class OAuthAccounts implements OAuthApi {
87
87
  */
88
88
  policy(provider: string, identity: OAuthAccountIdentity): AuthAccountPolicy | undefined;
89
89
  /** Force-refresh one stored credential by its durable row id. */
90
- refresh(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry>;
90
+ refresh(id: number, signal?: AbortSignal, options?: OAuthRefreshByIdOptions): Promise<AuthCredentialSnapshotEntry>;
91
91
  /** Refresh one stored OAuth credential through the durable ownership path. */
92
92
  refreshStored<T extends OAuthCredential = OAuthCredential>(provider: string, options: StoredOAuthRefreshOptions<T>): Promise<StoredOAuthRefreshResult<T>>;
93
93
  }
@@ -3,7 +3,7 @@ import type { Provider } from "../types.js";
3
3
  import { type CredentialPool } from "./pool.js";
4
4
  import type { AccountPolicies } from "./policy.js";
5
5
  import { type AuthCredentialStore } from "./store.js";
6
- import { type AuthCredentialSnapshotEntry, type AuthStorageOptions, type OAuthCredential, type StoredOAuthRefreshOptions, type StoredOAuthRefreshResult } from "./types.js";
6
+ import { type AuthCredentialSnapshotEntry, type AuthStorageOptions, type OAuthCredential, type OAuthRefreshByIdOptions, type OAuthRefreshReason, type StoredOAuthRefreshOptions, type StoredOAuthRefreshResult } from "./types.js";
7
7
  /**
8
8
  * Refresh OAuth access tokens this many ms before their stated expiry. The
9
9
  * skew exists so callers downstream of {@link OAuthRefresher} (stream providers,
@@ -48,12 +48,12 @@ export declare class OAuthRefresher {
48
48
  * continuing.
49
49
  */
50
50
  disableDefinitiveFailure(provider: string, credentialId: number | undefined, attemptedCredential: OAuthCredential, index: number, errorMsg: string): Promise<"disabled" | "peer-rotated" | "cas-lost">;
51
- refresh(provider: Provider, credential: OAuthCredential, credentialId: number | undefined, signal?: AbortSignal): Promise<OAuthCredentials>;
51
+ refresh(provider: Provider, credential: OAuthCredential, credentialId: number | undefined, signal?: AbortSignal, reason?: OAuthRefreshReason): Promise<OAuthCredentials>;
52
52
  /**
53
53
  * Refresh the OAuth credential with the given id through a per-credential
54
54
  * single-flight. Concurrent callers for the same row await the same upstream
55
55
  * refresh attempt, which is required for providers that rotate refresh tokens
56
56
  * on every successful refresh.
57
57
  */
58
- refreshById(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry>;
58
+ refreshById(id: number, signal?: AbortSignal, options?: OAuthRefreshByIdOptions): Promise<AuthCredentialSnapshotEntry>;
59
59
  }
@@ -1,7 +1,7 @@
1
1
  import type { OAuthCredentials } from "../registry/oauth/types.js";
2
2
  import type { Provider } from "../types.js";
3
3
  import type { ClientUsageIdentity, ClientUsageReport, ClientUsageSummary, ObservedUsageEntry, UsageHistoryEntry, UsageHistoryQuery, UsageReport } from "../usage.js";
4
- import type { AuthCredential, DisabledCredentialSummary, OAuthCredential, StoredAuthCredential, StoredCredentialBlock } from "./types.js";
4
+ import type { AuthCredential, DisabledCredentialSummary, OAuthCredential, OAuthRefreshReason, StoredAuthCredential, StoredCredentialBlock } from "./types.js";
5
5
  /** Owner and timestamp that fence a durable credential refresh lease. */
6
6
  export interface CredentialRefreshLeaseFence {
7
7
  owner: string;
@@ -128,8 +128,10 @@ export interface CredentialUpstream {
128
128
  * `signal` propagates the agent's cancel (ESC, request abort, …) all the
129
129
  * way to the broker fetch so a hung connection can't strand the caller
130
130
  * for `timeoutMs * (maxRetries + 1)`.
131
+ * `reason` distinguishes provider-auth recovery from generic/MCP refreshes
132
+ * that must always mint a new access token.
131
133
  */
132
- refreshOAuthCredential(provider: Provider, credentialId: number, credential: OAuthCredential, signal?: AbortSignal): Promise<OAuthCredentials>;
134
+ refreshOAuthCredential(provider: Provider, credentialId: number, credential: OAuthCredential, signal?: AbortSignal, reason?: OAuthRefreshReason): Promise<OAuthCredentials>;
133
135
  /**
134
136
  * Optional async pre-read hook invoked after AuthStorage selects a stored
135
137
  * credential but before it returns that credential for an outbound request.
@@ -272,6 +272,8 @@ export interface CredentialDisabledEvent {
272
272
  orgId?: string;
273
273
  orgName?: string;
274
274
  }
275
+ /** Why a delegated OAuth refresh was requested. */
276
+ export type OAuthRefreshReason = "auth-recovery";
275
277
  /** Configuration supplied when constructing credential storage. */
276
278
  export type AuthStorageOptions = {
277
279
  usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
@@ -301,11 +303,14 @@ export type AuthStorageOptions = {
301
303
  * per-provider local refresh function. Receives the credential id so the
302
304
  * implementation can address remote credentials.
303
305
  *
306
+ * `reason` is `"auth-recovery"` only for a provider-auth retry; generic and
307
+ * managed MCP force-refresh calls leave it unset.
308
+ *
304
309
  * Must return updated {@link OAuthCredentials} with at least `access` and
305
310
  * `expires`. `refresh` may be an opaque sentinel (e.g. `"__remote__"`) when
306
311
  * the actual refresh token never leaves the broker.
307
312
  */
308
- refreshOAuthCredential?: (provider: Provider, credentialId: number, credential: OAuthCredential, signal?: AbortSignal) => Promise<OAuthCredentials>;
313
+ refreshOAuthCredential?: (provider: Provider, credentialId: number, credential: OAuthCredential, signal?: AbortSignal, reason?: OAuthRefreshReason) => Promise<OAuthCredentials>;
309
314
  /**
310
315
  * Human-readable description of the credential store backing this
311
316
  * AuthStorage instance. Surfaced through {@link AuthStorage.keys.describe}
@@ -414,6 +419,8 @@ export type AuthApiKeyOptions = {
414
419
  * that a peer/broker rotated out from under us is replaced before retrying.
415
420
  */
416
421
  forceRefresh?: boolean;
422
+ /** Explicit provider-401 recovery; generic force refreshes leave this unset. */
423
+ refreshReason?: OAuthRefreshReason;
417
424
  };
418
425
  /**
419
426
  * Refreshed OAuth access plus identity metadata returned by
@@ -876,6 +883,15 @@ export interface KeysApi {
876
883
  modelId?: string;
877
884
  }): ApiKeyResolver;
878
885
  }
886
+ /** Controls whether a row-id refresh may reuse a token minted by this refresher. */
887
+ export interface OAuthRefreshByIdOptions {
888
+ /**
889
+ * Return the stored credential when it still holds a fresh access token this
890
+ * refresher minted recently. Auth-recovery callers use this to avoid rotating
891
+ * refresh tokens repeatedly when a provider rejects every valid bearer.
892
+ */
893
+ reuseRecentMint?: boolean;
894
+ }
879
895
  /** OAuth login, access, account identity, and refresh operations. */
880
896
  export interface OAuthApi {
881
897
  /**
@@ -950,9 +966,10 @@ export interface OAuthApi {
950
966
  * Refresh the OAuth credential with the given id through a per-credential
951
967
  * single-flight. Concurrent callers for the same row await the same upstream
952
968
  * refresh attempt, which is required for providers that rotate refresh tokens
953
- * on every successful refresh.
969
+ * on every successful refresh. Mints unconditionally unless
970
+ * {@link OAuthRefreshByIdOptions.reuseRecentMint} is set.
954
971
  */
955
- refresh(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry>;
972
+ refresh(id: number, signal?: AbortSignal, options?: OAuthRefreshByIdOptions): Promise<AuthCredentialSnapshotEntry>;
956
973
  /**
957
974
  * Refresh one stored OAuth credential under durable row ownership.
958
975
  */
@@ -66,10 +66,17 @@ export declare class UsageCache {
66
66
  /**
67
67
  * Force the next usage fetch for `provider` to bypass the 5-min cache, so
68
68
  * `/usage` reflects a freshly-redeemed reset instead of stale numbers.
69
+ * `resetSpentCredentialId` also forgets that credential's cached saved-reset
70
+ * block, so a failed follow-up reset probe cannot carry the pre-spend
71
+ * inventory forward.
69
72
  */
70
- invalidate(provider: string, baseUrl?: string): void;
73
+ invalidate(provider: string, baseUrl?: string, options?: {
74
+ resetSpentCredentialId?: number;
75
+ }): void;
71
76
  /** A confirmed reset permits one fresh probe even during a pre-reset failure cooldown. */
72
- invalidateAfterReset(provider: Provider, baseUrl?: string): void;
77
+ invalidateAfterReset(provider: Provider, baseUrl?: string, options?: {
78
+ resetSpentCredentialId?: number;
79
+ }): void;
73
80
  /**
74
81
  * Expire cached reports for a provider after its runtime usage implementation changes.
75
82
  * This keeps a newly installed extension provider from serving a built-in snapshot.
@@ -29,6 +29,10 @@ export interface UsageServiceDeps {
29
29
  requestTimeoutMs: number;
30
30
  logger: UsageLogger;
31
31
  }
32
+ type UsageReportsOptions = {
33
+ baseUrlResolver?: (provider: Provider) => string | undefined;
34
+ signal?: AbortSignal;
35
+ };
32
36
  /** Usage reports: per-credential cached fetches, aggregate reports, header ingestion, history. */
33
37
  export declare class UsageService implements UsageApi {
34
38
  #private;
@@ -95,11 +99,7 @@ export declare class UsageService implements UsageApi {
95
99
  */
96
100
  reportingModelIds(provider: Provider, modelIds: readonly string[], reports: readonly UsageReport[]): string[];
97
101
  /** Fetch all providers’ current usage reports, sharing concurrent polls. */
98
- reports(options?: {
99
- baseUrlResolver?: (provider: Provider) => string | undefined;
100
- /** Caller's cancel signal; only rejects this caller, never the shared upstream fetch. */
101
- signal?: AbortSignal;
102
- }): Promise<UsageReport[] | null>;
102
+ reports(options?: UsageReportsOptions): Promise<UsageReport[] | null>;
103
103
  /**
104
104
  * Discard cached usage reports before a user-requested refresh. The next
105
105
  * read probes upstream serially per provider unless a failure cooldown is
@@ -107,3 +107,4 @@ export declare class UsageService implements UsageApi {
107
107
  */
108
108
  invalidate(provider?: string, signal?: AbortSignal): Promise<void>;
109
109
  }
110
+ export {};
@@ -5,7 +5,7 @@
5
5
  * `omp auth-broker status` (liveness checks). All endpoints except
6
6
  * `/v1/healthz` require a bearer token.
7
7
  */
8
- import type { AuthCredential, DisabledCredentialSummary } from "../auth-storage.js";
8
+ import type { AuthCredential, DisabledCredentialSummary, OAuthRefreshReason } from "../auth-storage.js";
9
9
  import type { ClientUsageReportRequest, ClientUsageReportResponse, ClientUsageSummaryResponse, CredentialBlockDeleteRequest, CredentialBlockRequest, CredentialBlockResponse, CredentialBlocksDeleteResponse, CredentialDisableResponse, CredentialRefreshResponse, CredentialUploadResponse, HealthzResponse, SnapshotResponse, SnapshotStreamEvent, UsageHistoryResponse, UsageResponse, UsageStaleResponse } from "./types.js";
10
10
  export interface AuthBrokerClientOptions {
11
11
  /** Base URL (e.g. `https://broker.tailnet:8765`). Trailing slashes are trimmed. */
@@ -86,7 +86,7 @@ export declare class AuthBrokerClient {
86
86
  sinceMs?: number;
87
87
  }, signal?: AbortSignal): Promise<ClientUsageSummaryResponse>;
88
88
  notifyUsageStale(signal?: AbortSignal): Promise<UsageStaleResponse>;
89
- refreshCredential(id: number, signal?: AbortSignal): Promise<CredentialRefreshResponse>;
89
+ refreshCredential(id: number, signal?: AbortSignal, reason?: OAuthRefreshReason): Promise<CredentialRefreshResponse>;
90
90
  disableCredential(id: number, cause: string, signal?: AbortSignal): Promise<CredentialDisableResponse>;
91
91
  /**
92
92
  * Disabled-credential tombstones (identity + cause, no token material).
@@ -1,5 +1,5 @@
1
1
  import type { AuthCredentialStore } from "../auth/store.js";
2
- import { type AuthCredential, type DisabledCredentialSummary, type OAuthCredential, type StoredAuthCredential, type StoredCredentialBlock } from "../auth/types.js";
2
+ import { type AuthCredential, type DisabledCredentialSummary, type OAuthCredential, type OAuthRefreshReason, type StoredAuthCredential, type StoredCredentialBlock } from "../auth/types.js";
3
3
  import type { OAuthCredentials } from "../registry/oauth/types.js";
4
4
  import type { Provider } from "../types.js";
5
5
  import type { ClientUsageIdentity, ObservedUsageEntry, UsageReport } from "../usage.js";
@@ -128,7 +128,7 @@ export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
128
128
  * the broker-redacted credential with {@link REMOTE_REFRESH_SENTINEL} in
129
129
  * the `refresh` slot.
130
130
  */
131
- refreshOAuthCredential(_provider: Provider, credentialId: number, _credential: OAuthCredential, signal?: AbortSignal): Promise<OAuthCredentials>;
131
+ refreshOAuthCredential(_provider: Provider, credentialId: number, _credential: OAuthCredential, signal?: AbortSignal, reason?: OAuthRefreshReason): Promise<OAuthCredentials>;
132
132
  /**
133
133
  * Store-level hook consumed by `AuthStorage.usage.reports()` — proxies
134
134
  * to the broker's `/v1/usage` endpoint. Shared per-credential caches and
@@ -9,8 +9,9 @@ export declare function isDashScopeTokenLimitText(errorMessage: string): boolean
9
9
  * Classify a rate-limit error message into a reason category.
10
10
  * Priority order: explicit details in a resource-exhausted error > QUOTA
11
11
  * (Antigravity "quota will reset") > CN quota > DASHSCOPE_TOKEN_LIMIT (TPM/TPS
12
- * throttle) > CONCURRENT_LIMIT > MODEL_CAPACITY > QUOTA (account) > RATE_LIMIT >
13
- * QUOTA (generic) > SERVER_ERROR > bare resource-exhausted > UNKNOWN.
12
+ * throttle) > CONCURRENT_LIMIT > MODEL_CAPACITY > QUOTA (account) > RATE_LIMIT
13
+ * (including TPM/RPM rolling windows) > QUOTA (generic) > SERVER_ERROR > bare
14
+ * resource-exhausted > UNKNOWN.
14
15
  *
15
16
  * Bare "resource exhausted" / "resource_exhausted" maps to MODEL_CAPACITY (transient, short wait).
16
17
  * Explicit details such as "quota exceeded" retain their normal classification.
@@ -19,6 +19,13 @@ export declare const __providerInFlightForTesting: {
19
19
  setWaitObserver(observer: ((provider: string) => void) | undefined): void;
20
20
  setLockCreatedObserver(observer: ((lockDir: string) => Promise<void>) | undefined): void;
21
21
  setLockIdentifiedObserver(observer: ((lockDir: string) => Promise<void>) | undefined): void;
22
+ setLockMkdirOverride(mkdir: ((lockDir: string) => Promise<void>) | undefined): void;
23
+ setLockPlatformOverride(platform: NodeJS.Platform | undefined): void;
24
+ setLockRetryTimings(timings: {
25
+ budgetMs?: number;
26
+ initialDelayMs?: number;
27
+ maxDelayMs?: number;
28
+ } | undefined): void;
22
29
  providerDir(provider: string): string;
23
30
  lockDir(provider: string): string;
24
31
  captureStaleLockRelease(provider: string): Promise<(() => Promise<void>) | null>;
@@ -1,4 +1,4 @@
1
- import type { UsageProvider, UsageReport } from "../usage.js";
1
+ import type { CredentialRankingStrategy, UsageProvider, UsageReport } from "../usage.js";
2
2
  /**
3
3
  * Cursor's `/api/usage-summary` has shipped two personal-bucket shapes:
4
4
  * - Enterprise/team dashboards historically exposed `individualUsage.overall`
@@ -12,4 +12,6 @@ import type { UsageProvider, UsageReport } from "../usage.js";
12
12
  */
13
13
  export declare function parseCursorIndividualUsage(payload: unknown, fetchedAt?: number): UsageReport | null;
14
14
  export declare function parseCursorUsage(payload: unknown, fetchedAt?: number): UsageReport | null;
15
+ /** Routes Cursor credential ranking and reserve checks through the requested model's billing pool. */
16
+ export declare const cursorRankingStrategy: CredentialRankingStrategy;
15
17
  export declare const cursorUsageProvider: UsageProvider;
@@ -1,3 +1,5 @@
1
1
  import type { CredentialRankingStrategy, UsageProvider } from "../usage.js";
2
+ /** Fetches the account-wide quota windows for ZAI credentials. */
2
3
  export declare const zaiUsageProvider: UsageProvider;
4
+ /** Ranks ZAI credentials and identifies quota blocks a live report can heal. */
3
5
  export declare const zaiRankingStrategy: CredentialRankingStrategy;
@@ -580,6 +580,12 @@ export interface UsageFetchContext {
580
580
  fetch: FetchImpl;
581
581
  logger?: UsageLogger;
582
582
  retryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
583
+ /**
584
+ * Last report cached for this exact credential cache key, when one exists.
585
+ * Lets a fetcher keep a field it could not re-read this time (a failed
586
+ * secondary probe) instead of reporting it as absent.
587
+ */
588
+ previousReport?: UsageReport;
583
589
  }
584
590
  /** Provider implementation for fetching usage information. */
585
591
  export interface UsageProvider {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.4.0",
3
+ "version": "18.4.1",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.4.0",
159
- "@oh-my-pi/pi-catalog": "18.4.0",
160
- "@oh-my-pi/pi-natives": "18.4.0",
161
- "@oh-my-pi/pi-utils": "18.4.0",
162
- "@oh-my-pi/pi-wire": "18.4.0"
158
+ "@oh-my-pi/omptype": "18.4.1",
159
+ "@oh-my-pi/pi-catalog": "18.4.1",
160
+ "@oh-my-pi/pi-natives": "18.4.1",
161
+ "@oh-my-pi/pi-utils": "18.4.1",
162
+ "@oh-my-pi/pi-wire": "18.4.1"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -399,6 +399,7 @@ export class KeyCascade implements KeysApi {
399
399
  baseUrl,
400
400
  modelId,
401
401
  forceRefresh: true,
402
+ refreshReason: AIError.status(error) === 401 ? "auth-recovery" : undefined,
402
403
  signal,
403
404
  });
404
405
  };
package/src/auth/oauth.ts CHANGED
@@ -13,6 +13,7 @@ import type { CredentialSelector } from "./select";
13
13
  import type {
14
14
  AuthAccountPolicy,
15
15
  AuthApiKeyOptions,
16
+ AuthCredentialSnapshotEntry,
16
17
  OAuthAccess,
17
18
  OAuthAccessResolution,
18
19
  OAuthAccountIdentity,
@@ -21,9 +22,9 @@ import type {
21
22
  OAuthCredential,
22
23
  OAuthLoginController,
23
24
  OAuthLoginIdentity,
25
+ OAuthRefreshByIdOptions,
24
26
  StoredOAuthRefreshOptions,
25
27
  StoredOAuthRefreshResult,
26
- AuthCredentialSnapshotEntry,
27
28
  } from "./types";
28
29
 
29
30
  type StoredOAuthSelection = {
@@ -338,8 +339,8 @@ export class OAuthAccounts implements OAuthApi {
338
339
  }
339
340
 
340
341
  /** Force-refresh one stored credential by its durable row id. */
341
- refresh(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry> {
342
- return this.#deps.refresher.refreshById(id, signal);
342
+ refresh(id: number, signal?: AbortSignal, options?: OAuthRefreshByIdOptions): Promise<AuthCredentialSnapshotEntry> {
343
+ return this.#deps.refresher.refreshById(id, signal, options);
343
344
  }
344
345
 
345
346
  /** Refresh one stored OAuth credential through the durable ownership path. */
@@ -13,6 +13,8 @@ import {
13
13
  type AuthCredentialSnapshotEntry,
14
14
  type AuthStorageOptions,
15
15
  type OAuthCredential,
16
+ type OAuthRefreshByIdOptions,
17
+ type OAuthRefreshReason,
16
18
  type StoredOAuthRefreshOptions,
17
19
  type StoredOAuthRefreshResult,
18
20
  } from "./types";
@@ -30,6 +32,12 @@ import {
30
32
  * the rotation cadence by <4%.
31
33
  */
32
34
  export const OAUTH_REFRESH_SKEW_MS = 60_000;
35
+ /**
36
+ * How long auth recovery may reuse a token this refresher actually minted.
37
+ * Re-minting a token that a provider rejected moments ago only rotates the
38
+ * refresh token and churns broker snapshots during provider-wide outages.
39
+ */
40
+ const OAUTH_REMINT_COOLDOWN_MS = 5 * 60_000;
33
41
  const OAUTH_REFRESH_LEASE_TTL_MS = 15_000;
34
42
  const OAUTH_REFRESH_LEASE_POLL_MS = 50;
35
43
  const OAUTH_REFRESH_LEASE_RENEW_MS = 5_000;
@@ -67,11 +75,45 @@ export class OAuthRefresher {
67
75
  readonly #deps: OAuthRefresherDeps;
68
76
  #oauthRefreshInFlight: Map<number, Promise<AuthCredentialSnapshotEntry>> = new Map();
69
77
  #oauthCredentialRefreshInFlight: Map<number, Promise<OAuthCredentials>> = new Map();
78
+ /** Access token this process last minted per credential id. */
79
+ #recentMints = new Map<number, { provider: string; access: string; at: number }>();
70
80
 
71
81
  constructor(deps: OAuthRefresherDeps) {
72
82
  this.#deps = deps;
73
83
  }
74
84
 
85
+ /**
86
+ * Return the stored row while it still holds a usable token this refresher
87
+ * minted within {@link OAUTH_REMINT_COOLDOWN_MS}.
88
+ */
89
+ #recentMint(id: number): { provider: string; credential: OAuthCredential } | undefined {
90
+ const mint = this.#recentMints.get(id);
91
+ if (!mint) return undefined;
92
+ const now = Date.now();
93
+ if (now - mint.at >= OAUTH_REMINT_COOLDOWN_MS) {
94
+ this.#recentMints.delete(id);
95
+ return undefined;
96
+ }
97
+ const credential = this.#deps.pool.entries(mint.provider).find(entry => entry.id === id)?.credential;
98
+ if (
99
+ credential?.type !== "oauth" ||
100
+ credential.access !== mint.access ||
101
+ credential.expires <= now + OAUTH_REFRESH_SKEW_MS
102
+ ) {
103
+ this.#recentMints.delete(id);
104
+ return undefined;
105
+ }
106
+ return { provider: mint.provider, credential };
107
+ }
108
+
109
+ #rememberMint(provider: string, id: number, credential: OAuthCredentials): void {
110
+ const now = Date.now();
111
+ for (const [cachedId, mint] of this.#recentMints) {
112
+ if (now - mint.at >= OAUTH_REMINT_COOLDOWN_MS) this.#recentMints.delete(cachedId);
113
+ }
114
+ this.#recentMints.set(id, { provider, access: credential.access, at: now });
115
+ }
116
+
75
117
  /**
76
118
  * Refresh one stored OAuth credential under durable row ownership.
77
119
  */
@@ -278,6 +320,9 @@ export class OAuthRefresher {
278
320
  } else {
279
321
  this.#deps.store.updateAuthCredential(row.id, merged);
280
322
  }
323
+ if (this.#deps.override === undefined && this.#deps.store.refreshOAuthCredential === undefined) {
324
+ this.#rememberMint(provider, row.id, merged);
325
+ }
281
326
  this.#deps.pool.replace(
282
327
  provider,
283
328
  rows.map(entry => ({
@@ -361,6 +406,23 @@ export class OAuthRefresher {
361
406
  credential: OAuthCredential,
362
407
  credentialId: number | undefined,
363
408
  signal?: AbortSignal,
409
+ reason?: OAuthRefreshReason,
410
+ ): Promise<OAuthCredentials> {
411
+ const authRecovery = reason === "auth-recovery";
412
+ if (authRecovery && credentialId !== undefined && !this.#oauthCredentialRefreshInFlight.has(credentialId)) {
413
+ const recent = this.#recentMint(credentialId);
414
+ if (recent) return recent.credential;
415
+ }
416
+ return this.#refreshSingleFlight(provider, credential, credentialId, signal, reason);
417
+ }
418
+
419
+ /** Refresh without recent-mint reuse; still shares the per-credential in-flight request. */
420
+ async #refreshSingleFlight(
421
+ provider: Provider,
422
+ credential: OAuthCredential,
423
+ credentialId: number | undefined,
424
+ signal?: AbortSignal,
425
+ reason?: OAuthRefreshReason,
364
426
  ): Promise<OAuthCredentials> {
365
427
  credential = normalizeOAuthCredentialExpiry(provider, credential);
366
428
  if (credentialId !== undefined) {
@@ -369,11 +431,24 @@ export class OAuthRefresher {
369
431
  }
370
432
  if (Date.now() + OAUTH_REFRESH_SKEW_MS < credential.expires) return credential;
371
433
  if (credentialId === undefined) {
372
- return this.#refreshOAuthCredentialUnshared(provider, credential, undefined, signal);
434
+ return this.#refreshOAuthCredentialUnshared(provider, credential, undefined, signal, reason);
373
435
  }
374
- const promise = this.#refreshOAuthCredentialUnshared(provider, credential, credentialId).finally(() => {
375
- this.#oauthCredentialRefreshInFlight.delete(credentialId);
376
- });
436
+ const promise = this.#refreshOAuthCredentialUnshared(provider, credential, credentialId, signal, reason)
437
+ .then(refreshed => {
438
+ // A delegated refresh may have returned a broker-cached token rather
439
+ // than minting one. Only direct local requests can establish mint time.
440
+ if (
441
+ !hasRefreshLeases(this.#deps.store) &&
442
+ this.#deps.override === undefined &&
443
+ this.#deps.store.refreshOAuthCredential === undefined
444
+ ) {
445
+ this.#rememberMint(provider, credentialId, refreshed);
446
+ }
447
+ return refreshed;
448
+ })
449
+ .finally(() => {
450
+ this.#oauthCredentialRefreshInFlight.delete(credentialId);
451
+ });
377
452
  this.#oauthCredentialRefreshInFlight.set(credentialId, promise);
378
453
  return raceSignal(promise, signal, "credential refresh aborted");
379
454
  }
@@ -383,6 +458,7 @@ export class OAuthRefresher {
383
458
  credential: OAuthCredential,
384
459
  credentialId: number | undefined,
385
460
  signal?: AbortSignal,
461
+ reason?: OAuthRefreshReason,
386
462
  ): Promise<OAuthCredentials> {
387
463
  if (credentialId !== undefined && hasRefreshLeases(this.#deps.store)) {
388
464
  const forceRefresh = credential.expires === 0;
@@ -398,6 +474,7 @@ export class OAuthRefresher {
398
474
  current,
399
475
  credentialId,
400
476
  signal && refreshSignal ? AbortSignal.any([signal, refreshSignal]) : (signal ?? refreshSignal),
477
+ reason,
401
478
  ),
402
479
  isDefinitiveFailure: error => AIError.isDefinitiveOAuthFailure(String(error)),
403
480
  disabledCause: error => `oauth refresh failed: ${String(error)}`,
@@ -429,7 +506,7 @@ export class OAuthRefresher {
429
506
  provider,
430
507
  });
431
508
  }
432
- return this.#requestOAuthCredentialRefresh(provider, credential, credentialId, signal);
509
+ return this.#requestOAuthCredentialRefresh(provider, credential, credentialId, signal, reason);
433
510
  }
434
511
 
435
512
  async #requestOAuthCredentialRefresh(
@@ -437,6 +514,7 @@ export class OAuthRefresher {
437
514
  credential: OAuthCredential,
438
515
  credentialId: number | undefined,
439
516
  signal?: AbortSignal,
517
+ reason?: OAuthRefreshReason,
440
518
  ): Promise<OAuthCredentials> {
441
519
  let refreshPromise: Promise<OAuthCredentials>;
442
520
  // Caller override > store-level hook > local per-provider refresh.
@@ -445,7 +523,7 @@ export class OAuthRefresher {
445
523
  const storeRefresh = this.#deps.store.refreshOAuthCredential?.bind(this.#deps.store);
446
524
  const overrideRefresh = this.#deps.override ?? storeRefresh;
447
525
  if (overrideRefresh && credentialId !== undefined) {
448
- refreshPromise = overrideRefresh(provider, credentialId, credential, signal);
526
+ refreshPromise = overrideRefresh(provider, credentialId, credential, signal, reason);
449
527
  } else {
450
528
  const customProvider = getOAuthProvider(provider);
451
529
  if (customProvider) {
@@ -497,9 +575,15 @@ export class OAuthRefresher {
497
575
  * refresh attempt, which is required for providers that rotate refresh tokens
498
576
  * on every successful refresh.
499
577
  */
500
- async refreshById(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry> {
578
+ async refreshById(
579
+ id: number,
580
+ signal?: AbortSignal,
581
+ options?: OAuthRefreshByIdOptions,
582
+ ): Promise<AuthCredentialSnapshotEntry> {
501
583
  const existing = this.#oauthRefreshInFlight.get(id);
502
584
  if (existing) return raceSignal(existing, signal, "credential refresh aborted");
585
+ const recent = options?.reuseRecentMint ? this.#recentMint(id) : undefined;
586
+ if (recent) return snapshotEntry(id, recent.provider, recent.credential);
503
587
 
504
588
  const promise = (async () => {
505
589
  this.#deps.pool.bump("credential-refresh-start");
@@ -531,12 +615,12 @@ export class OAuthRefresher {
531
615
  // await so a definitive failure can CAS-disable the row against the
532
616
  // value we actually attempted (NOT the expires:0 clone below).
533
617
  const attempted = target.credential;
534
- // Pass a clone with expires=0 so the cached not-yet-expired short-circuit
535
- // in refresh doesn't suppress the requested refresh.
618
+ // Pass a clone with expires=0 and bypass recent-mint reuse so scheduled
619
+ // refreshes and direct force-refresh callers always mint.
536
620
  const stale: OAuthCredential = { ...attempted, expires: 0 };
537
621
  let refreshed: OAuthCredentials;
538
622
  try {
539
- refreshed = await this.refresh(provider as Provider, stale, id, signal);
623
+ refreshed = await this.#refreshSingleFlight(provider as Provider, stale, id, signal);
540
624
  } catch (error) {
541
625
  // A definitively-dead grant tears the row down here, where the
542
626
  // attempted credential is known. CAS on the persisted credential so a
@@ -563,13 +647,18 @@ export class OAuthRefresher {
563
647
  if (this.#deps.pool.replaceById(provider, id, updated) === -1) {
564
648
  throw new AIError.ValidationError(`No credential with id=${id}`);
565
649
  }
566
- return {
567
- id,
568
- provider,
569
- credential: { ...updated, refresh: REMOTE_REFRESH_SENTINEL },
570
- identityKey: resolveCredentialIdentityKey(provider, updated),
571
- };
650
+ return snapshotEntry(id, provider, updated);
572
651
  }
573
652
  throw new AIError.ValidationError(`No credential with id=${id}`);
574
653
  }
575
654
  }
655
+
656
+ /** Broker-facing snapshot entry for a refreshed row; the real refresh token never leaves the store. */
657
+ function snapshotEntry(id: number, provider: string, credential: OAuthCredential): AuthCredentialSnapshotEntry {
658
+ return {
659
+ id,
660
+ provider,
661
+ credential: { ...credential, refresh: REMOTE_REFRESH_SENTINEL },
662
+ identityKey: resolveCredentialIdentityKey(provider, credential),
663
+ };
664
+ }
@@ -200,7 +200,9 @@ export class ResetCredits implements ResetsApi {
200
200
  selected.remainingCount < pending.remainingCount
201
201
  ) {
202
202
  this.#pendingClaudeResets.delete(accountKey);
203
- this.#deps.usageCache.invalidateAfterReset(provider, options.baseUrl);
203
+ this.#deps.usageCache.invalidateAfterReset(provider, options.baseUrl, {
204
+ resetSpentCredentialId: access.credentialId,
205
+ });
204
206
  return { ...identity, ok: false, code: "already_redeemed", creditId };
205
207
  }
206
208
  if (pending.program === "juniper_tide") {
@@ -256,7 +258,9 @@ export class ResetCredits implements ResetsApi {
256
258
  result = { ...identity, ok: consumed.ok, code: consumed.code, creditId };
257
259
  }
258
260
  if (result.ok) {
259
- this.#deps.usageCache.invalidateAfterReset(provider, options.baseUrl);
261
+ this.#deps.usageCache.invalidateAfterReset(provider, options.baseUrl, {
262
+ resetSpentCredentialId: access.credentialId,
263
+ });
260
264
  if (this.#deps.store.invalidateUsageCache) {
261
265
  await this.#deps.store.invalidateUsageCache(options.signal).catch(err => {
262
266
  logger.debug("Failed to notify store of stale usage", { err });