@f5-sales-demo/pi-ai 21.21.0 → 21.21.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.
package/CHANGELOG.md CHANGED
@@ -13,6 +13,14 @@
13
13
 
14
14
  ### Fixed
15
15
 
16
+ - Coordinated stored OAuth refreshes across processes with renewable SQLite leases, canonical
17
+ re-reads, and fenced writes. This prevents stale refresh attempts from overwriting rotated tokens
18
+ or disabling a peer's winner. Adapted from the upstream Pi
19
+ [lease and compare-and-swap design](https://github.com/can1357/oh-my-pi/commit/591f1c5765fa42cfcd68e020f875dd56b93b8963),
20
+ including its later renewable-lease and fenced-write hardening, prompted by
21
+ [anthropics/claude-code#24317](https://github.com/anthropics/claude-code/issues/24317) and
22
+ [#54443](https://github.com/anthropics/claude-code/issues/54443)
23
+ ([#3796](https://github.com/f5-sales-demo/xcsh/issues/3796)).
16
24
  - Removed output-only lifecycle statuses before replaying persisted OpenAI Responses items,
17
25
  including remote compaction and compaction summaries. Semantically ported from
18
26
  [can1357/oh-my-pi#7743](https://github.com/can1357/oh-my-pi/pull/7743) and
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/pi-ai",
4
- "version": "21.21.0",
4
+ "version": "21.21.2",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -46,7 +46,7 @@
46
46
  "@anthropic-ai/sdk": "^0.115",
47
47
  "@aws-sdk/client-bedrock-runtime": "^3",
48
48
  "@bufbuild/protobuf": "^2.11",
49
- "@f5-sales-demo/pi-utils": "21.21.0",
49
+ "@f5-sales-demo/pi-utils": "21.21.2",
50
50
  "@google/genai": "^2.15",
51
51
  "@sinclair/typebox": "^0.34",
52
52
  "@smithy/node-http-handler": "^4.4",
@@ -177,6 +177,74 @@ const USAGE_CACHE_PREFIX = "usage_cache:";
177
177
  const USAGE_REPORT_TTL_MS = 30_000;
178
178
  const DEFAULT_USAGE_REQUEST_TIMEOUT_MS = 3_000;
179
179
  const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 10_000;
180
+ const DEFAULT_OAUTH_REFRESH_SKEW_MS = 0;
181
+ const GOOGLE_OAUTH_REFRESH_SKEW_MS = 60_000;
182
+ const OAUTH_REFRESH_LEASE_TTL_MS = 15_000;
183
+ const OAUTH_REFRESH_LEASE_POLL_MS = 50;
184
+ const OAUTH_REFRESH_LEASE_RENEW_MS = 5_000;
185
+
186
+ export interface CredentialRefreshLeaseFence {
187
+ owner: string;
188
+ nowMs: number;
189
+ }
190
+
191
+ export interface StoredOAuthRefreshOptions<T extends OAuthCredential = OAuthCredential> {
192
+ credentialId?: number;
193
+ observedCredential?: T;
194
+ credentialFromRow: (credential: OAuthCredential) => T | undefined;
195
+ forceRefresh?: boolean;
196
+ canRefresh?: (credential: T) => boolean;
197
+ refreshSkewMs?: number;
198
+ signal?: AbortSignal;
199
+ keepCredentialOnRefreshFailure?: boolean | ((error: unknown) => boolean);
200
+ onRefreshFailure?: (error: unknown) => void;
201
+ refreshTimeoutMs?: number;
202
+ refresh: (credential: T, signal: AbortSignal) => Promise<OAuthCredentials>;
203
+ mergeRefreshedCredential?: (credential: T, refreshed: OAuthCredentials) => T;
204
+ isDefinitiveFailure?: (error: unknown) => boolean;
205
+ disabledCause?: (error: unknown) => string;
206
+ }
207
+
208
+ export interface StoredOAuthRefreshResult<T extends OAuthCredential = OAuthCredential> {
209
+ credential: T | undefined;
210
+ refreshed: boolean;
211
+ removed: boolean;
212
+ reauthenticationRequired: boolean;
213
+ }
214
+
215
+ export function isDefinitiveOAuthFailure(error: unknown): boolean {
216
+ const message = error instanceof Error ? error.message : String(error);
217
+ return (
218
+ /invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i.test(message) ||
219
+ (/\b(401|403)\b/.test(message) && !/timeout|network|fetch failed|ECONNREFUSED/i.test(message))
220
+ );
221
+ }
222
+
223
+ function authCredentialEquals(left: AuthCredential, right: AuthCredential): boolean {
224
+ if (left.type !== right.type) return false;
225
+ if (left.type === "api_key") return right.type === "api_key" && left.key === right.key;
226
+ if (right.type !== "oauth") return false;
227
+ return (
228
+ left.access === right.access &&
229
+ left.refresh === right.refresh &&
230
+ left.expires === right.expires &&
231
+ left.enterpriseUrl === right.enterpriseUrl &&
232
+ left.projectId === right.projectId &&
233
+ left.tierId === right.tierId &&
234
+ left.email === right.email &&
235
+ left.accountId === right.accountId
236
+ );
237
+ }
238
+
239
+ function raceRefreshWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
240
+ if (!signal) return promise;
241
+ if (signal.aborted) return Promise.reject(signal.reason ?? new Error("OAuth refresh aborted"));
242
+ return new Promise<T>((resolve, reject) => {
243
+ const onAbort = (): void => reject(signal.reason ?? new Error("OAuth refresh aborted"));
244
+ signal.addEventListener("abort", onAbort, { once: true });
245
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
246
+ });
247
+ }
180
248
 
181
249
  /** Exact marker written by #3210 when valid ChatGPT credentials were regression-disabled. */
182
250
  export const OPENAI_CODEX_REGRESSION_DISABLED_CAUSE = "unsupported OpenAI Codex OAuth; remove manually with logout";
@@ -299,6 +367,7 @@ export class AuthStorage {
299
367
  #usageCache: UsageCache;
300
368
  #usageRequestInFlight: Map<string, Promise<UsageReport | null>> = new Map();
301
369
  #usageReportsInFlight: Map<string, Promise<UsageReport[]>> = new Map();
370
+ #oauthRefreshInFlight: Map<number, Promise<StoredOAuthRefreshResult<OAuthCredential>>> = new Map();
302
371
  #usageFetch: typeof fetch;
303
372
  #usageRequestTimeoutMs: number;
304
373
  #usageLogger?: UsageLogger;
@@ -623,31 +692,6 @@ export class AuthStorage {
623
692
  }
624
693
  }
625
694
 
626
- /** Updates credential at index in-place (used for OAuth token refresh) */
627
- #replaceCredentialAt(provider: string, index: number, credential: AuthCredential): void {
628
- const entries = this.#getStoredCredentials(provider);
629
- if (index < 0 || index >= entries.length) return;
630
- const target = entries[index];
631
- this.#store.updateAuthCredential(target.id, credential);
632
- const updated = [...entries];
633
- updated[index] = { id: target.id, credential };
634
- this.#setStoredCredentials(provider, updated);
635
- }
636
-
637
- /**
638
- * Disables credential at index (used when OAuth refresh fails).
639
- * The credential remains in the database but is excluded from active queries.
640
- * Cleans up provider entry if last credential disabled.
641
- */
642
- #disableCredentialAt(provider: string, index: number, disabledCause: string): void {
643
- const entries = this.#getStoredCredentials(provider);
644
- if (index < 0 || index >= entries.length) return;
645
- this.#store.deleteAuthCredential(entries[index].id, disabledCause);
646
- const updated = entries.filter((_value, idx) => idx !== index);
647
- this.#setStoredCredentials(provider, updated);
648
- this.#resetProviderAssignments(provider);
649
- }
650
-
651
695
  /**
652
696
  * Get credential for a provider (first entry if multiple).
653
697
  */
@@ -670,6 +714,233 @@ export class AuthStorage {
670
714
  this.#resetProviderAssignments(provider);
671
715
  }
672
716
 
717
+ /** List active stored credential rows, optionally scoped to one provider. */
718
+ listStoredCredentials(provider?: string): StoredAuthCredential[] {
719
+ if (provider !== undefined) {
720
+ const canonicalProvider = canonicalizeOAuthProviderId(provider);
721
+ return this.#getStoredCredentials(canonicalProvider).map(entry => ({
722
+ id: entry.id,
723
+ provider: canonicalProvider,
724
+ credential: entry.credential,
725
+ disabledCause: null,
726
+ }));
727
+ }
728
+ return [...this.#data.entries()].flatMap(([storedProvider, entries]) =>
729
+ entries.map(entry => ({
730
+ id: entry.id,
731
+ provider: storedProvider,
732
+ credential: entry.credential,
733
+ disabledCause: null,
734
+ })),
735
+ );
736
+ }
737
+
738
+ /** Refresh one SQLite-backed OAuth row under renewable, fenced ownership. */
739
+ async refreshStoredOAuthCredential<T extends OAuthCredential = OAuthCredential>(
740
+ provider: string,
741
+ options: StoredOAuthRefreshOptions<T>,
742
+ ): Promise<StoredOAuthRefreshResult<T>> {
743
+ provider = canonicalizeOAuthProviderId(provider);
744
+ if (options.credentialId !== undefined) {
745
+ const existing = this.#oauthRefreshInFlight.get(options.credentialId);
746
+ if (existing) {
747
+ const outcome = (await raceRefreshWithSignal(existing, options.signal)) as StoredOAuthRefreshResult<T>;
748
+ const unchangedObserved =
749
+ outcome.credential !== undefined &&
750
+ (options.observedCredential === undefined ||
751
+ authCredentialEquals(outcome.credential, options.observedCredential));
752
+ if (options.forceRefresh && !outcome.refreshed && unchangedObserved) {
753
+ return this.refreshStoredOAuthCredential(provider, options);
754
+ }
755
+ return outcome;
756
+ }
757
+ const promise = this.#refreshStoredOAuthCredentialUnshared(provider, options).finally(() => {
758
+ this.#oauthRefreshInFlight.delete(options.credentialId!);
759
+ }) as Promise<StoredOAuthRefreshResult<OAuthCredential>>;
760
+ this.#oauthRefreshInFlight.set(options.credentialId, promise);
761
+ return raceRefreshWithSignal(promise, options.signal) as Promise<StoredOAuthRefreshResult<T>>;
762
+ }
763
+ return this.#refreshStoredOAuthCredentialUnshared(provider, options);
764
+ }
765
+
766
+ async #refreshStoredOAuthCredentialUnshared<T extends OAuthCredential>(
767
+ provider: string,
768
+ options: StoredOAuthRefreshOptions<T>,
769
+ ): Promise<StoredOAuthRefreshResult<T>> {
770
+ const result = (
771
+ credential: T | undefined,
772
+ refreshed = false,
773
+ removed = false,
774
+ reauthenticationRequired = false,
775
+ ): StoredOAuthRefreshResult<T> => ({ credential, refreshed, removed, reauthenticationRequired });
776
+ const owner = crypto.randomUUID();
777
+ const refreshSkewMs = options.refreshSkewMs ?? DEFAULT_OAUTH_REFRESH_SKEW_MS;
778
+ let leasedCredentialId: number | undefined;
779
+
780
+ const readRow = (): { id: number; credential: T } | undefined => {
781
+ const rows = this.#store.listAuthCredentials(provider);
782
+ this.#setStoredCredentials(
783
+ provider,
784
+ rows.map(row => ({ id: row.id, credential: row.credential })),
785
+ );
786
+ const row = rows.find(
787
+ entry =>
788
+ entry.credential.type === "oauth" &&
789
+ (options.credentialId === undefined || entry.id === options.credentialId),
790
+ );
791
+ if (row?.credential.type !== "oauth") return undefined;
792
+ const credential = options.credentialFromRow(row.credential);
793
+ return credential ? { id: row.id, credential } : undefined;
794
+ };
795
+
796
+ const canAdopt = (credential: T): boolean => Date.now() + refreshSkewMs < credential.expires;
797
+ const shouldReturn = (credential: T): boolean => {
798
+ if (
799
+ options.observedCredential &&
800
+ !authCredentialEquals(credential, options.observedCredential) &&
801
+ canAdopt(credential)
802
+ ) {
803
+ return true;
804
+ }
805
+ return !options.forceRefresh && canAdopt(credential);
806
+ };
807
+
808
+ while (true) {
809
+ if (options.signal?.aborted) throw options.signal.reason ?? new Error("OAuth refresh ownership aborted");
810
+ const row = readRow();
811
+ if (!row) return result(undefined);
812
+ if (shouldReturn(row.credential) || (options.canRefresh && !options.canRefresh(row.credential))) {
813
+ return result(row.credential);
814
+ }
815
+ if (this.#store.tryAcquireCredentialRefreshLease(row.id, owner, Date.now() + OAUTH_REFRESH_LEASE_TTL_MS)) {
816
+ leasedCredentialId = row.id;
817
+ break;
818
+ }
819
+ const leaseExpiresAt = this.#store.getCredentialRefreshLeaseExpiresAt(row.id);
820
+ const waitMs =
821
+ leaseExpiresAt === undefined
822
+ ? OAUTH_REFRESH_LEASE_POLL_MS
823
+ : Math.min(Math.max(leaseExpiresAt - Date.now(), OAUTH_REFRESH_LEASE_POLL_MS), 250);
824
+ await raceRefreshWithSignal(Bun.sleep(waitMs), options.signal);
825
+ }
826
+
827
+ let stopRenewal = false;
828
+ let leaseLost = false;
829
+ let renewalTimer: ReturnType<typeof setTimeout> | undefined;
830
+ let wakeRenewal: (() => void) | undefined;
831
+ const renewal = (async () => {
832
+ while (!stopRenewal) {
833
+ await new Promise<void>(resolve => {
834
+ wakeRenewal = resolve;
835
+ renewalTimer = setTimeout(resolve, OAUTH_REFRESH_LEASE_RENEW_MS);
836
+ });
837
+ if (renewalTimer) clearTimeout(renewalTimer);
838
+ renewalTimer = undefined;
839
+ wakeRenewal = undefined;
840
+ if (stopRenewal) return;
841
+ if (
842
+ leasedCredentialId === undefined ||
843
+ !this.#store.renewCredentialRefreshLease(
844
+ leasedCredentialId,
845
+ owner,
846
+ Date.now() + OAUTH_REFRESH_LEASE_TTL_MS,
847
+ )
848
+ ) {
849
+ leaseLost = true;
850
+ return;
851
+ }
852
+ }
853
+ })();
854
+
855
+ const refreshAbort = new AbortController();
856
+ const abortFromCaller = (): void =>
857
+ refreshAbort.abort(options.signal?.reason ?? new Error("OAuth refresh aborted by caller"));
858
+ if (options.signal?.aborted) abortFromCaller();
859
+ else options.signal?.addEventListener("abort", abortFromCaller, { once: true });
860
+ const timeout = setTimeout(
861
+ () => refreshAbort.abort(new Error(`OAuth token refresh timed out for provider: ${provider}`)),
862
+ options.refreshTimeoutMs ?? DEFAULT_OAUTH_REFRESH_TIMEOUT_MS,
863
+ );
864
+
865
+ try {
866
+ const row = readRow();
867
+ if (!row || row.id !== leasedCredentialId) return result(undefined);
868
+ const current = row.credential;
869
+ if (shouldReturn(current) || (options.canRefresh && !options.canRefresh(current))) return result(current);
870
+ const serialized = serializeCredential(provider, current);
871
+ if (!serialized) return result(current);
872
+
873
+ let refreshed: OAuthCredentials;
874
+ try {
875
+ refreshed = await options.refresh(current, refreshAbort.signal);
876
+ if (refreshAbort.signal.aborted) {
877
+ throw refreshAbort.signal.reason ?? new Error("OAuth refresh aborted");
878
+ }
879
+ } catch (error) {
880
+ if (refreshAbort.signal.aborted) {
881
+ throw refreshAbort.signal.reason ?? error;
882
+ }
883
+ if (options.isDefinitiveFailure?.(error)) {
884
+ const disabledCause = options.disabledCause?.(error) ?? `oauth refresh failed: ${String(error)}`;
885
+ const disabled = this.#store.tryDisableAuthCredentialIfMatches(row.id, serialized.data, disabledCause, {
886
+ owner,
887
+ nowMs: Date.now(),
888
+ });
889
+ if (disabled) {
890
+ const latest = this.#store.listAuthCredentials(provider);
891
+ this.#setStoredCredentials(
892
+ provider,
893
+ latest.map(entry => ({ id: entry.id, credential: entry.credential })),
894
+ );
895
+ this.#resetProviderAssignments(provider);
896
+ return result(undefined, false, true, true);
897
+ }
898
+ const winner = readRow();
899
+ return result(winner?.credential);
900
+ }
901
+ options.onRefreshFailure?.(error);
902
+ const keep =
903
+ typeof options.keepCredentialOnRefreshFailure === "function"
904
+ ? options.keepCredentialOnRefreshFailure(error)
905
+ : options.keepCredentialOnRefreshFailure === true;
906
+ if (keep) return result(current);
907
+ throw error;
908
+ }
909
+
910
+ if (leaseLost) return result(readRow()?.credential);
911
+ const merged = options.mergeRefreshedCredential
912
+ ? options.mergeRefreshedCredential(current, refreshed)
913
+ : ({
914
+ ...current,
915
+ ...refreshed,
916
+ accountId: refreshed.accountId ?? current.accountId,
917
+ email: refreshed.email ?? current.email,
918
+ projectId: refreshed.projectId ?? current.projectId,
919
+ tierId: refreshed.tierId ?? current.tierId,
920
+ enterpriseUrl: refreshed.enterpriseUrl ?? current.enterpriseUrl,
921
+ } as T);
922
+ const updated = this.#store.tryUpdateAuthCredentialIfMatches(row.id, serialized.data, merged, {
923
+ owner,
924
+ nowMs: Date.now(),
925
+ });
926
+ if (!updated) return result(readRow()?.credential);
927
+ const rows = this.#store.listAuthCredentials(provider);
928
+ this.#setStoredCredentials(
929
+ provider,
930
+ rows.map(entry => ({ id: entry.id, credential: entry.credential })),
931
+ );
932
+ return result(merged, true);
933
+ } finally {
934
+ clearTimeout(timeout);
935
+ options.signal?.removeEventListener("abort", abortFromCaller);
936
+ stopRenewal = true;
937
+ if (renewalTimer) clearTimeout(renewalTimer);
938
+ wakeRenewal?.();
939
+ await renewal;
940
+ if (leasedCredentialId !== undefined) this.#store.releaseCredentialRefreshLease(leasedCredentialId, owner);
941
+ }
942
+ }
943
+
673
944
  async #upsertOAuthCredential(provider: string, credential: OAuthCredential): Promise<void> {
674
945
  const stored = this.#store.upsertAuthCredentialForProvider(provider, credential);
675
946
  this.#setStoredCredentials(
@@ -1135,32 +1406,10 @@ export class AuthStorage {
1135
1406
  };
1136
1407
  }
1137
1408
 
1138
- #persistRefreshedUsageCredential(provider: Provider, previous: UsageCredential, next: UsageCredential): void {
1139
- const entries = this.#getStoredCredentials(provider);
1140
- const index = entries.findIndex(entry => {
1141
- if (entry.credential.type !== "oauth") return false;
1142
- if (previous.refreshToken && entry.credential.refresh === previous.refreshToken) return true;
1143
- if (previous.accessToken && entry.credential.access === previous.accessToken) return true;
1144
- return (
1145
- entry.credential.accountId === previous.accountId &&
1146
- entry.credential.email === previous.email &&
1147
- entry.credential.projectId === previous.projectId
1148
- );
1149
- });
1150
- if (index === -1) return;
1151
- const existing = entries[index]!.credential;
1152
- if (existing.type !== "oauth") return;
1153
- this.#replaceCredentialAt(provider, index, {
1154
- type: "oauth",
1155
- access: next.accessToken ?? existing.access,
1156
- refresh: next.refreshToken ?? existing.refresh,
1157
- expires: next.expiresAt ?? existing.expires,
1158
- accountId: next.accountId,
1159
- projectId: next.projectId,
1160
- tierId: next.tierId ?? existing.tierId,
1161
- email: next.email,
1162
- enterpriseUrl: next.enterpriseUrl,
1163
- });
1409
+ #findStoredOAuthCredential(provider: Provider, observed: OAuthCredential): StoredCredential | undefined {
1410
+ return this.#getStoredCredentials(provider).find(
1411
+ entry => entry.credential.type === "oauth" && authCredentialEquals(entry.credential, observed),
1412
+ );
1164
1413
  }
1165
1414
 
1166
1415
  async #fetchUsageUncached(request: UsageRequestDescriptor, timeoutMs?: number): Promise<UsageReport | null> {
@@ -1183,14 +1432,30 @@ export class AuthStorage {
1183
1432
  ) {
1184
1433
  const refreshableCredential = this.#buildRefreshableOauthCredential(request.credential);
1185
1434
  if (refreshableCredential) {
1435
+ const stored = this.#findStoredOAuthCredential(request.provider, refreshableCredential);
1186
1436
  try {
1187
- const refreshed = await this.#refreshOAuthCredential(request.provider, refreshableCredential);
1188
- const refreshedCredential = this.#mergeRefreshedUsageCredential(request.credential, refreshed);
1189
- this.#persistRefreshedUsageCredential(request.provider, request.credential, refreshedCredential);
1190
- params = {
1191
- ...params,
1192
- credential: refreshedCredential,
1193
- };
1437
+ if (stored) {
1438
+ const outcome = await this.refreshStoredOAuthCredential(request.provider, {
1439
+ credentialId: stored.id,
1440
+ observedCredential: refreshableCredential,
1441
+ credentialFromRow: current => current,
1442
+ signal: timeoutSignal,
1443
+ refresh: (current, refreshSignal) =>
1444
+ this.#requestOAuthCredentialRefresh(request.provider, current, refreshSignal),
1445
+ keepCredentialOnRefreshFailure: true,
1446
+ onRefreshFailure: error =>
1447
+ this.#usageLogger?.debug("Usage credential refresh failed, using original credential", {
1448
+ provider: request.provider,
1449
+ error: String(error),
1450
+ }),
1451
+ });
1452
+ if (outcome.credential) {
1453
+ params = {
1454
+ ...params,
1455
+ credential: this.#mergeRefreshedUsageCredential(request.credential, outcome.credential),
1456
+ };
1457
+ }
1458
+ }
1194
1459
  } catch (error) {
1195
1460
  this.#usageLogger?.debug("Usage credential refresh failed, using original credential", {
1196
1461
  provider: request.provider,
@@ -1594,12 +1859,12 @@ export class AuthStorage {
1594
1859
  providerKey: string;
1595
1860
  provider: string;
1596
1861
  order: number[];
1597
- credentials: Array<{ credential: OAuthCredential; index: number }>;
1862
+ credentials: Array<{ credential: OAuthCredential; index: number; credentialId: number }>;
1598
1863
  options?: AuthApiKeyOptions;
1599
1864
  strategy: CredentialRankingStrategy;
1600
1865
  }): Promise<
1601
1866
  Array<{
1602
- selection: { credential: OAuthCredential; index: number };
1867
+ selection: { credential: OAuthCredential; index: number; credentialId: number };
1603
1868
  usage: UsageReport | null;
1604
1869
  usageChecked: boolean;
1605
1870
  }>
@@ -1607,7 +1872,7 @@ export class AuthStorage {
1607
1872
  const nowMs = Date.now();
1608
1873
  const { strategy } = args;
1609
1874
  const ranked: Array<{
1610
- selection: { credential: OAuthCredential; index: number };
1875
+ selection: { credential: OAuthCredential; index: number; credentialId: number };
1611
1876
  usage: UsageReport | null;
1612
1877
  usageChecked: boolean;
1613
1878
  blocked: boolean;
@@ -1706,9 +1971,12 @@ export class AuthStorage {
1706
1971
  sessionId?: string,
1707
1972
  options?: AuthApiKeyOptions,
1708
1973
  ): Promise<string | undefined> {
1709
- const credentials = this.#getCredentialsForProvider(provider)
1710
- .map((credential, index) => ({ credential, index }))
1711
- .filter((entry): entry is { credential: OAuthCredential; index: number } => entry.credential.type === "oauth");
1974
+ const credentials = this.#getStoredCredentials(provider)
1975
+ .map((entry, index) => ({ credential: entry.credential, index, credentialId: entry.id }))
1976
+ .filter(
1977
+ (entry): entry is { credential: OAuthCredential; index: number; credentialId: number } =>
1978
+ entry.credential.type === "oauth",
1979
+ );
1712
1980
 
1713
1981
  if (credentials.length === 0) return undefined;
1714
1982
 
@@ -1730,7 +1998,9 @@ export class AuthStorage {
1730
1998
  ? await this.#rankOAuthSelections({ providerKey, provider, order, credentials, options, strategy: strategy! })
1731
1999
  : order
1732
2000
  .map(idx => credentials[idx])
1733
- .filter((selection): selection is { credential: OAuthCredential; index: number } => Boolean(selection))
2001
+ .filter((selection): selection is { credential: OAuthCredential; index: number; credentialId: number } =>
2002
+ Boolean(selection),
2003
+ )
1734
2004
  .map(selection => ({ selection, usage: null, usageChecked: false }));
1735
2005
 
1736
2006
  if (sessionPreferredIndex !== undefined && !requiresProModel) {
@@ -1747,7 +2017,9 @@ export class AuthStorage {
1747
2017
  await Promise.all(
1748
2018
  candidates.map(async candidate => {
1749
2019
  if (Date.now() < candidate.selection.credential.expires) return;
1750
- const latestCredential = this.#getCredentialsForProvider(provider)[candidate.selection.index];
2020
+ const latestCredential = this.#getStoredCredentials(provider).find(
2021
+ entry => entry.id === candidate.selection.credentialId,
2022
+ )?.credential;
1751
2023
  if (latestCredential?.type === "oauth" && Date.now() < latestCredential.expires) {
1752
2024
  candidate.selection.credential = latestCredential;
1753
2025
  return;
@@ -1756,6 +2028,7 @@ export class AuthStorage {
1756
2028
  const refreshedCredentials = await this.#refreshOAuthCredential(
1757
2029
  provider,
1758
2030
  candidate.selection.credential,
2031
+ candidate.selection.credentialId,
1759
2032
  );
1760
2033
  candidate.selection.credential = {
1761
2034
  ...candidate.selection.credential,
@@ -1790,37 +2063,51 @@ export class AuthStorage {
1790
2063
  return undefined;
1791
2064
  }
1792
2065
 
1793
- async #refreshOAuthCredential(provider: Provider, credential: OAuthCredential): Promise<OAuthCredentials> {
1794
- if (Date.now() < credential.expires) return credential;
2066
+ async #refreshOAuthCredential(
2067
+ provider: Provider,
2068
+ credential: OAuthCredential,
2069
+ credentialId: number,
2070
+ signal?: AbortSignal,
2071
+ ): Promise<OAuthCredentials> {
2072
+ const outcome = await this.refreshStoredOAuthCredential(provider, {
2073
+ credentialId,
2074
+ observedCredential: credential,
2075
+ credentialFromRow: current => current,
2076
+ signal,
2077
+ refreshSkewMs:
2078
+ provider === "google-gemini-cli" || provider === "google-antigravity"
2079
+ ? GOOGLE_OAUTH_REFRESH_SKEW_MS
2080
+ : DEFAULT_OAUTH_REFRESH_SKEW_MS,
2081
+ refresh: (current, refreshSignal) => this.#requestOAuthCredentialRefresh(provider, current, refreshSignal),
2082
+ isDefinitiveFailure: isDefinitiveOAuthFailure,
2083
+ disabledCause: error => `oauth refresh failed: ${error instanceof Error ? error.message : String(error)}`,
2084
+ });
2085
+ if (outcome.credential) return outcome.credential;
2086
+ if (outcome.reauthenticationRequired) {
2087
+ throw new Error(`OAuth reauthentication required for provider: ${provider}`);
2088
+ }
2089
+ throw new Error(`OAuth credential no longer exists for provider: ${provider}`);
2090
+ }
2091
+
2092
+ async #requestOAuthCredentialRefresh(
2093
+ provider: Provider,
2094
+ credential: OAuthCredential,
2095
+ signal: AbortSignal,
2096
+ ): Promise<OAuthCredentials> {
1795
2097
  const customProvider = getOAuthProvider(provider);
1796
- let refreshPromise: Promise<OAuthCredentials>;
1797
2098
  if (customProvider) {
1798
2099
  if (!customProvider.refreshToken) {
1799
2100
  throw new Error(`OAuth provider "${provider}" does not support token refresh`);
1800
2101
  }
1801
- refreshPromise = customProvider.refreshToken(credential);
1802
- } else {
1803
- refreshPromise = refreshOAuthToken(provider as OAuthProvider, credential);
1804
- }
1805
- // Bound the refresh so a slow/hanging token endpoint cannot stall credential selection.
1806
- let timeout: NodeJS.Timeout | undefined;
1807
- const timeoutPromise = new Promise<never>((_, reject) => {
1808
- timeout = setTimeout(
1809
- () => reject(new Error(`OAuth token refresh timed out for provider: ${provider}`)),
1810
- DEFAULT_OAUTH_REFRESH_TIMEOUT_MS,
1811
- );
1812
- });
1813
- try {
1814
- return await Promise.race([refreshPromise, timeoutPromise]);
1815
- } finally {
1816
- if (timeout) clearTimeout(timeout);
2102
+ return customProvider.refreshToken(credential, signal);
1817
2103
  }
2104
+ return refreshOAuthToken(provider as OAuthProvider, credential, signal);
1818
2105
  }
1819
2106
 
1820
2107
  /** Attempts to use a single OAuth credential, checking usage and refreshing token. */
1821
2108
  async #tryOAuthCredential(
1822
2109
  provider: Provider,
1823
- selection: { credential: OAuthCredential; index: number },
2110
+ selection: { credential: OAuthCredential; index: number; credentialId: number },
1824
2111
  providerKey: string,
1825
2112
  sessionId: string | undefined,
1826
2113
  options: AuthApiKeyOptions | undefined,
@@ -1863,18 +2150,25 @@ export class AuthStorage {
1863
2150
 
1864
2151
  try {
1865
2152
  let result: { newCredentials: OAuthCredentials; apiKey: string } | null;
2153
+ const refreshedCredentials = await this.#refreshOAuthCredential(
2154
+ provider,
2155
+ selection.credential,
2156
+ selection.credentialId,
2157
+ );
2158
+ if (Date.now() >= refreshedCredentials.expires) {
2159
+ throw new Error(`OAuth refresh coordination did not yield a usable credential for provider: ${provider}`);
2160
+ }
1866
2161
  const customProvider = getOAuthProvider(provider);
1867
2162
  if (customProvider) {
1868
- const refreshedCredentials = await this.#refreshOAuthCredential(provider, selection.credential);
1869
2163
  const apiKey = customProvider.getApiKey
1870
2164
  ? customProvider.getApiKey(refreshedCredentials)
1871
2165
  : refreshedCredentials.access;
1872
2166
  result = { newCredentials: refreshedCredentials, apiKey };
1873
2167
  } else {
1874
2168
  const oauthCreds: Record<string, OAuthCredentials> = {
1875
- [provider]: selection.credential,
2169
+ [provider]: refreshedCredentials,
1876
2170
  };
1877
- result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);
2171
+ result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds, { refreshExpired: false });
1878
2172
  }
1879
2173
  if (!result) return undefined;
1880
2174
  const updated: OAuthCredential = {
@@ -1888,7 +2182,7 @@ export class AuthStorage {
1888
2182
  tierId: result.newCredentials.tierId ?? selection.credential.tierId,
1889
2183
  enterpriseUrl: result.newCredentials.enterpriseUrl ?? selection.credential.enterpriseUrl,
1890
2184
  };
1891
- this.#replaceCredentialAt(provider, selection.index, updated);
2185
+ selection.credential = updated;
1892
2186
  if (checkUsage && !allowBlocked) {
1893
2187
  const sameAccount = selection.credential.accountId === updated.accountId;
1894
2188
  if (!usageChecked || !sameAccount) {
@@ -1913,9 +2207,7 @@ export class AuthStorage {
1913
2207
  const errorMsg = String(error);
1914
2208
  // Only remove credentials for definitive auth failures
1915
2209
  // Keep credentials for transient errors (network, 5xx) and block temporarily
1916
- const isDefinitiveFailure =
1917
- /invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i.test(errorMsg) ||
1918
- (/\b(401|403)\b/.test(errorMsg) && !/timeout|network|fetch failed|ECONNREFUSED/i.test(errorMsg));
2210
+ const isDefinitiveFailure = isDefinitiveOAuthFailure(error);
1919
2211
 
1920
2212
  logger.warn("OAuth token refresh failed", {
1921
2213
  provider,
@@ -1924,9 +2216,7 @@ export class AuthStorage {
1924
2216
  isDefinitiveFailure,
1925
2217
  });
1926
2218
 
1927
- if (isDefinitiveFailure) {
1928
- // Permanently disable invalid credentials with an explicit cause for inspection/debugging
1929
- this.#disableCredentialAt(provider, selection.index, `oauth refresh failed: ${errorMsg}`);
2219
+ if (isDefinitiveFailure || /reauthentication required/i.test(errorMsg)) {
1930
2220
  if (this.#getCredentialsForProvider(provider).some(credential => credential.type === "oauth")) {
1931
2221
  return this.getApiKey(provider, sessionId, options);
1932
2222
  }
@@ -2035,7 +2325,7 @@ type SerializedCredentialRecord = {
2035
2325
  identityKey: string | null;
2036
2326
  };
2037
2327
 
2038
- const AUTH_SCHEMA_VERSION = 4;
2328
+ const AUTH_SCHEMA_VERSION = 5;
2039
2329
  const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
2040
2330
 
2041
2331
  function normalizeStoredAccountId(accountId: string | null | undefined): string | null {
@@ -2209,12 +2499,18 @@ export class AuthCredentialStore {
2209
2499
  #listDisabledByProviderStmt: Statement;
2210
2500
  #insertStmt: Statement;
2211
2501
  #updateStmt: Statement;
2502
+ #updateIfMatchesWithLeaseStmt: Statement;
2212
2503
  #deleteStmt: Statement;
2504
+ #deleteIfMatchesWithLeaseStmt: Statement;
2213
2505
  #deleteByProviderStmt: Statement;
2214
2506
  #hardDeleteStmt: Statement;
2215
2507
  #getCacheStmt: Statement;
2216
2508
  #upsertCacheStmt: Statement;
2217
2509
  #deleteExpiredCacheStmt: Statement;
2510
+ #acquireCredentialRefreshLeaseStmt: Statement;
2511
+ #getCredentialRefreshLeaseStmt: Statement;
2512
+ #renewCredentialRefreshLeaseStmt: Statement;
2513
+ #releaseCredentialRefreshLeaseStmt: Statement;
2218
2514
  #closed = false;
2219
2515
 
2220
2516
  constructor(db: Database) {
@@ -2236,9 +2532,27 @@ export class AuthCredentialStore {
2236
2532
  this.#updateStmt = this.#db.prepare(
2237
2533
  `UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
2238
2534
  );
2535
+ this.#updateIfMatchesWithLeaseStmt = this.#db.prepare(
2536
+ `UPDATE auth_credentials
2537
+ SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH}
2538
+ WHERE id = ? AND data = ? AND disabled_cause IS NULL
2539
+ AND EXISTS (
2540
+ SELECT 1 FROM auth_credential_refresh_leases
2541
+ WHERE credential_id = ? AND owner = ? AND expires_at_ms > ?
2542
+ )`,
2543
+ );
2239
2544
  this.#deleteStmt = this.#db.prepare(
2240
2545
  `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
2241
2546
  );
2547
+ this.#deleteIfMatchesWithLeaseStmt = this.#db.prepare(
2548
+ `UPDATE auth_credentials
2549
+ SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH}
2550
+ WHERE id = ? AND data = ? AND disabled_cause IS NULL
2551
+ AND EXISTS (
2552
+ SELECT 1 FROM auth_credential_refresh_leases
2553
+ WHERE credential_id = ? AND owner = ? AND expires_at_ms > ?
2554
+ )`,
2555
+ );
2242
2556
  this.#deleteByProviderStmt = this.#db.prepare(
2243
2557
  `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`,
2244
2558
  );
@@ -2250,6 +2564,24 @@ export class AuthCredentialStore {
2250
2564
  "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
2251
2565
  );
2252
2566
  this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`);
2567
+ this.#acquireCredentialRefreshLeaseStmt = this.#db.prepare(
2568
+ `INSERT INTO auth_credential_refresh_leases (credential_id, owner, expires_at_ms, updated_at)
2569
+ VALUES (?, ?, ?, ${SQLITE_NOW_EPOCH})
2570
+ ON CONFLICT(credential_id) DO UPDATE SET
2571
+ owner = excluded.owner,
2572
+ expires_at_ms = excluded.expires_at_ms,
2573
+ updated_at = excluded.updated_at
2574
+ WHERE auth_credential_refresh_leases.expires_at_ms <= ?`,
2575
+ );
2576
+ this.#getCredentialRefreshLeaseStmt = this.#db.prepare(
2577
+ "SELECT expires_at_ms FROM auth_credential_refresh_leases WHERE credential_id = ?",
2578
+ );
2579
+ this.#renewCredentialRefreshLeaseStmt = this.#db.prepare(
2580
+ `UPDATE auth_credential_refresh_leases SET expires_at_ms = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE credential_id = ? AND owner = ?`,
2581
+ );
2582
+ this.#releaseCredentialRefreshLeaseStmt = this.#db.prepare(
2583
+ "DELETE FROM auth_credential_refresh_leases WHERE credential_id = ? AND owner = ?",
2584
+ );
2253
2585
  this.reactivateRegressionDisabledOpenAICodexCredentials();
2254
2586
  }
2255
2587
 
@@ -2288,6 +2620,14 @@ export class AuthCredentialStore {
2288
2620
  expires_at INTEGER NOT NULL
2289
2621
  );
2290
2622
  CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
2623
+ CREATE TABLE IF NOT EXISTS auth_credential_refresh_leases (
2624
+ credential_id INTEGER PRIMARY KEY,
2625
+ owner TEXT NOT NULL,
2626
+ expires_at_ms INTEGER NOT NULL,
2627
+ updated_at INTEGER NOT NULL
2628
+ );
2629
+ CREATE INDEX IF NOT EXISTS idx_auth_credential_refresh_leases_expires
2630
+ ON auth_credential_refresh_leases(expires_at_ms);
2291
2631
  `);
2292
2632
 
2293
2633
  if (!this.#authCredentialsTableExists()) {
@@ -2377,6 +2717,7 @@ export class AuthCredentialStore {
2377
2717
  if (fromVersion < 4) {
2378
2718
  this.#migrateAuthSchemaV3ToV4();
2379
2719
  }
2720
+ // Version 5 is additive; the refresh-lease table is created before migration.
2380
2721
  }
2381
2722
 
2382
2723
  #migrateAuthSchemaV0ToV1(): void {
@@ -2684,6 +3025,33 @@ export class AuthCredentialStore {
2684
3025
  }
2685
3026
  }
2686
3027
 
3028
+ tryUpdateAuthCredentialIfMatches(
3029
+ id: number,
3030
+ expectedData: string,
3031
+ credential: AuthCredential,
3032
+ lease: CredentialRefreshLeaseFence,
3033
+ ): boolean {
3034
+ const providerRow = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?").get(id) as
3035
+ | { provider?: string }
3036
+ | undefined;
3037
+ const provider = providerRow?.provider ?? "";
3038
+ const serialized = serializeCredential(provider, credential);
3039
+ if (!serialized) return false;
3040
+ const outcome = this.#updateIfMatchesWithLeaseStmt.run(
3041
+ serialized.credentialType,
3042
+ serialized.data,
3043
+ serialized.identityKey,
3044
+ id,
3045
+ expectedData,
3046
+ id,
3047
+ lease.owner,
3048
+ lease.nowMs,
3049
+ ) as { changes: number };
3050
+ if (outcome.changes === 0) return false;
3051
+ if (provider) this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider));
3052
+ return true;
3053
+ }
3054
+
2687
3055
  deleteAuthCredential(id: number, disabledCause: string): void {
2688
3056
  try {
2689
3057
  this.#deleteStmt.run(normalizeDisabledCause(disabledCause), id);
@@ -2692,6 +3060,50 @@ export class AuthCredentialStore {
2692
3060
  }
2693
3061
  }
2694
3062
 
3063
+ tryDisableAuthCredentialIfMatches(
3064
+ id: number,
3065
+ expectedData: string,
3066
+ disabledCause: string,
3067
+ lease: CredentialRefreshLeaseFence,
3068
+ ): boolean {
3069
+ const outcome = this.#deleteIfMatchesWithLeaseStmt.run(
3070
+ normalizeDisabledCause(disabledCause),
3071
+ id,
3072
+ expectedData,
3073
+ id,
3074
+ lease.owner,
3075
+ lease.nowMs,
3076
+ ) as { changes: number };
3077
+ return outcome.changes === 1;
3078
+ }
3079
+
3080
+ tryAcquireCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean {
3081
+ const outcome = this.#acquireCredentialRefreshLeaseStmt.run(credentialId, owner, expiresAtMs, Date.now()) as {
3082
+ changes: number;
3083
+ };
3084
+ return outcome.changes === 1;
3085
+ }
3086
+
3087
+ getCredentialRefreshLeaseExpiresAt(credentialId: number): number | undefined {
3088
+ const row = this.#getCredentialRefreshLeaseStmt.get(credentialId) as { expires_at_ms?: number } | undefined;
3089
+ return typeof row?.expires_at_ms === "number" && row.expires_at_ms > Date.now() ? row.expires_at_ms : undefined;
3090
+ }
3091
+
3092
+ renewCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean {
3093
+ const outcome = this.#renewCredentialRefreshLeaseStmt.run(expiresAtMs, credentialId, owner) as {
3094
+ changes: number;
3095
+ };
3096
+ return outcome.changes === 1;
3097
+ }
3098
+
3099
+ releaseCredentialRefreshLease(credentialId: number, owner: string): void {
3100
+ try {
3101
+ this.#releaseCredentialRefreshLeaseStmt.run(credentialId, owner);
3102
+ } catch {
3103
+ // Expired leases remain recoverable if best-effort release fails.
3104
+ }
3105
+ }
3106
+
2695
3107
  deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void {
2696
3108
  try {
2697
3109
  this.#deleteByProviderStmt.run(normalizeDisabledCause(disabledCause), provider);
@@ -373,6 +373,7 @@ export function shouldRefreshGeminiCliCredentials(
373
373
  async function refreshGeminiCliCredentialsIfNeeded(
374
374
  credentials: ParsedGeminiCliCredentials,
375
375
  isAntigravity: boolean,
376
+ signal: AbortSignal,
376
377
  ): Promise<ParsedGeminiCliCredentials> {
377
378
  if (!credentials.refreshToken || !shouldRefreshGeminiCliCredentials(credentials.expiresAt, isAntigravity)) {
378
379
  return credentials;
@@ -380,8 +381,8 @@ async function refreshGeminiCliCredentialsIfNeeded(
380
381
 
381
382
  try {
382
383
  const refreshed = isAntigravity
383
- ? await refreshAntigravityToken(credentials.refreshToken, credentials.projectId)
384
- : await refreshGoogleCloudToken(credentials.refreshToken, credentials.projectId);
384
+ ? await refreshAntigravityToken(credentials.refreshToken, credentials.projectId, signal)
385
+ : await refreshGoogleCloudToken(credentials.refreshToken, credentials.projectId, signal);
385
386
  return {
386
387
  accessToken: refreshed.access,
387
388
  projectId: credentials.projectId,
@@ -503,7 +504,11 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
503
504
 
504
505
  const isAntigravity = model.provider === "google-antigravity";
505
506
  const parsedCredentials = parseGeminiCliCredentials(apiKeyRaw);
506
- const activeCredentials = await refreshGeminiCliCredentialsIfNeeded(parsedCredentials, isAntigravity);
507
+ const activeCredentials = await refreshGeminiCliCredentialsIfNeeded(
508
+ parsedCredentials,
509
+ isAntigravity,
510
+ options?.signal ?? new AbortController().signal,
511
+ );
507
512
  const { accessToken, projectId } = activeCredentials;
508
513
  const vertexModelId = isAntigravity ? ANTIGRAVITY_VERTEX_MODELS[model.id] : undefined;
509
514
  const serviceName = vertexModelId ? "Vertex AI" : "Cloud Code Assist";
@@ -108,7 +108,11 @@ async function resolveAccessToken(params: UsageFetchParams, ctx: UsageFetchConte
108
108
  }
109
109
  if (!credential.refreshToken || !credential.projectId) return credential.accessToken;
110
110
  try {
111
- const refreshed = await refreshGoogleCloudToken(credential.refreshToken, credential.projectId);
111
+ const refreshed = await refreshGoogleCloudToken(
112
+ credential.refreshToken,
113
+ credential.projectId,
114
+ params.signal ?? new AbortController().signal,
115
+ );
112
116
  return refreshed.access;
113
117
  } catch (error) {
114
118
  ctx.logger?.warn("Gemini CLI token refresh failed", { error: String(error) });
@@ -108,7 +108,11 @@ async function resolveAccessToken(params: UsageFetchParams, ctx: UsageFetchConte
108
108
  }
109
109
  if (!credential.refreshToken || !credential.projectId) return undefined;
110
110
  try {
111
- const refreshed = await refreshAntigravityToken(credential.refreshToken, credential.projectId);
111
+ const refreshed = await refreshAntigravityToken(
112
+ credential.refreshToken,
113
+ credential.projectId,
114
+ params.signal ?? new AbortController().signal,
115
+ );
112
116
  return refreshed.access;
113
117
  } catch (error) {
114
118
  ctx.logger?.warn("Antigravity usage token refresh failed", { error: String(error) });
package/src/usage/kimi.ts CHANGED
@@ -234,7 +234,10 @@ export const kimiUsageProvider: UsageProvider = {
234
234
  }
235
235
  try {
236
236
  ctx.logger?.debug("Kimi usage token expired, refreshing", { provider: params.provider });
237
- const refreshed = await refreshKimiToken(credential.refreshToken);
237
+ const refreshed = await refreshKimiToken(
238
+ credential.refreshToken,
239
+ params.signal ?? new AbortController().signal,
240
+ );
238
241
  accessToken = refreshed.access;
239
242
  } catch (error) {
240
243
  ctx.logger?.warn("Kimi usage token refresh failed", { provider: params.provider, error: String(error) });
@@ -101,22 +101,61 @@ function tokenCredentials(payload: AnthropicTokenPayload, previousRefresh?: stri
101
101
  };
102
102
  }
103
103
 
104
- async function requestToken(body: Record<string, string>, previousRefresh?: string): Promise<OAuthCredentials> {
104
+ function sanitizeOAuthErrorCode(value: unknown): string | undefined {
105
+ if (typeof value !== "string") return undefined;
106
+ const bounded = value.trim().slice(0, 64);
107
+ return /^[a-z][a-z0-9_.-]*$/i.test(bounded) ? bounded : undefined;
108
+ }
109
+
110
+ function sanitizeOAuthErrorDescription(value: unknown): string | undefined {
111
+ if (typeof value !== "string") return undefined;
112
+ const bounded = value
113
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
114
+ .replace(/\bBearer\s+\S+/gi, "Bearer [redacted]")
115
+ .replace(/\b[A-Za-z0-9_-]{24,}\b/g, "[redacted]")
116
+ .trim()
117
+ .slice(0, 256);
118
+ return bounded || undefined;
119
+ }
120
+
121
+ async function formatAnthropicTokenError(response: Response): Promise<string> {
122
+ let payload: unknown;
123
+ try {
124
+ payload = await response.json();
125
+ } catch {
126
+ payload = undefined;
127
+ }
128
+ const record = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : undefined;
129
+ const code = sanitizeOAuthErrorCode((record as { error?: unknown } | undefined)?.error);
130
+ const description = sanitizeOAuthErrorDescription(
131
+ (record as { error_description?: unknown } | undefined)?.error_description,
132
+ );
133
+ const details = [code && `error=${code}`, description && `description=${description}`].filter(Boolean).join("; ");
134
+ return `Anthropic token exchange failed (HTTP ${response.status}${details ? `; ${details}` : ""})`;
135
+ }
136
+
137
+ async function requestToken(
138
+ body: Record<string, string>,
139
+ previousRefresh?: string,
140
+ signal?: AbortSignal,
141
+ ): Promise<OAuthCredentials> {
105
142
  let response: Response;
143
+ const timeoutSignal = AbortSignal.timeout(TOKEN_TIMEOUT_MS);
106
144
  try {
107
145
  response = await fetch(TOKEN_URL, {
108
146
  method: "POST",
109
147
  headers: { "Content-Type": "application/json", Accept: "application/json" },
110
148
  body: JSON.stringify(body),
111
- signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
149
+ signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal,
112
150
  });
113
- } catch (error) {
114
- if (error instanceof DOMException && error.name === "TimeoutError") {
151
+ } catch {
152
+ if (signal?.aborted) throw signal.reason ?? new Error("Anthropic token exchange aborted");
153
+ if (timeoutSignal.aborted) {
115
154
  throw new Error("Anthropic token exchange timed out after 30 seconds");
116
155
  }
117
156
  throw new Error("Anthropic token exchange failed");
118
157
  }
119
- if (!response.ok) throw new Error(`Anthropic token exchange failed (HTTP ${response.status})`);
158
+ if (!response.ok) throw new Error(await formatAnthropicTokenError(response));
120
159
  let payload: AnthropicTokenPayload;
121
160
  try {
122
161
  payload = (await response.json()) as AnthropicTokenPayload;
@@ -247,7 +286,7 @@ export async function loginAnthropic(
247
286
  return new AnthropicOAuthFlow(ctrl, options).login();
248
287
  }
249
288
 
250
- export async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials> {
289
+ export async function refreshAnthropicToken(refreshToken: string, signal: AbortSignal): Promise<OAuthCredentials> {
251
290
  return requestToken(
252
291
  {
253
292
  grant_type: "refresh_token",
@@ -255,5 +294,6 @@ export async function refreshAnthropicToken(refreshToken: string): Promise<OAuth
255
294
  refresh_token: refreshToken,
256
295
  },
257
296
  refreshToken,
297
+ signal,
258
298
  );
259
299
  }
@@ -95,7 +95,7 @@ export async function loginCursor(
95
95
  };
96
96
  }
97
97
 
98
- export async function refreshCursorToken(apiKeyOrRefreshToken: string): Promise<OAuthCredentials> {
98
+ export async function refreshCursorToken(apiKeyOrRefreshToken: string, signal: AbortSignal): Promise<OAuthCredentials> {
99
99
  const response = await fetch(CURSOR_REFRESH_URL, {
100
100
  method: "POST",
101
101
  headers: {
@@ -103,6 +103,7 @@ export async function refreshCursorToken(apiKeyOrRefreshToken: string): Promise<
103
103
  "Content-Type": "application/json",
104
104
  },
105
105
  body: "{}",
106
+ signal,
106
107
  });
107
108
 
108
109
  if (!response.ok) {
@@ -242,7 +242,11 @@ const FAR_FUTURE_MS = Date.now() + 10 * 365.25 * 24 * 60 * 60 * 1000;
242
242
  * Refresh GitHub Copilot token.
243
243
  * With the opencode OAuth flow, the GitHub token is used directly — no JWT exchange needed.
244
244
  */
245
- export function refreshGitHubCopilotToken(refreshToken: string, enterpriseDomain?: string): OAuthCredentials {
245
+ export function refreshGitHubCopilotToken(
246
+ refreshToken: string,
247
+ enterpriseDomain: string | undefined,
248
+ _signal: AbortSignal,
249
+ ): OAuthCredentials {
246
250
  return {
247
251
  refresh: refreshToken,
248
252
  access: refreshToken,
@@ -96,7 +96,10 @@ export async function loginGitLabDuo(callbacks: OAuthLoginCallbacks): Promise<OA
96
96
  return flow.login();
97
97
  }
98
98
 
99
- export async function refreshGitLabDuoToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
99
+ export async function refreshGitLabDuoToken(
100
+ credentials: OAuthCredentials,
101
+ signal: AbortSignal,
102
+ ): Promise<OAuthCredentials> {
100
103
  const response = await fetch(`${GITLAB_COM_URL}/oauth/token`, {
101
104
  method: "POST",
102
105
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -105,6 +108,7 @@ export async function refreshGitLabDuoToken(credentials: OAuthCredentials): Prom
105
108
  grant_type: "refresh_token",
106
109
  refresh_token: credentials.refresh,
107
110
  }).toString(),
111
+ signal,
108
112
  });
109
113
 
110
114
  if (!response.ok) {
@@ -550,6 +550,7 @@ export async function loginVertexWithAntigravityOAuth(ctrl: OAuthController): Pr
550
550
 
551
551
  export async function refreshVertexWithAntigravityOAuth(
552
552
  refreshToken: string,
553
+ signal: AbortSignal,
553
554
  fetchImpl: typeof fetch = fetch,
554
555
  ): Promise<OAuthCredentials> {
555
556
  const { clientId, clientSecret } = resolveVertexOAuthClient();
@@ -562,6 +563,7 @@ export async function refreshVertexWithAntigravityOAuth(
562
563
  refresh_token: refreshToken,
563
564
  grant_type: "refresh_token",
564
565
  }),
566
+ signal,
565
567
  });
566
568
  if (!response.ok) throw new Error(`Vertex token refresh failed: ${await response.text()}`);
567
569
  const data = (await response.json()) as {
@@ -579,7 +581,11 @@ export async function refreshVertexWithAntigravityOAuth(
579
581
  /**
580
582
  * Refresh Antigravity token
581
583
  */
582
- export async function refreshAntigravityToken(refreshToken: string, projectId: string): Promise<OAuthCredentials> {
584
+ export async function refreshAntigravityToken(
585
+ refreshToken: string,
586
+ projectId: string,
587
+ signal: AbortSignal,
588
+ ): Promise<OAuthCredentials> {
583
589
  const response = await fetch(TOKEN_URL, {
584
590
  method: "POST",
585
591
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -589,6 +595,7 @@ export async function refreshAntigravityToken(refreshToken: string, projectId: s
589
595
  refresh_token: refreshToken,
590
596
  grant_type: "refresh_token",
591
597
  }),
598
+ signal,
592
599
  });
593
600
 
594
601
  if (!response.ok) {
@@ -302,7 +302,11 @@ export async function loginGeminiCli(ctrl: OAuthController): Promise<OAuthCreden
302
302
  /**
303
303
  * Refresh Google Cloud Code Assist token
304
304
  */
305
- export async function refreshGoogleCloudToken(refreshToken: string, projectId: string): Promise<OAuthCredentials> {
305
+ export async function refreshGoogleCloudToken(
306
+ refreshToken: string,
307
+ projectId: string,
308
+ signal: AbortSignal,
309
+ ): Promise<OAuthCredentials> {
306
310
  const response = await fetch(TOKEN_URL, {
307
311
  method: "POST",
308
312
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -312,6 +316,7 @@ export async function refreshGoogleCloudToken(refreshToken: string, projectId: s
312
316
  refresh_token: refreshToken,
313
317
  grant_type: "refresh_token",
314
318
  }),
319
+ signal,
315
320
  });
316
321
 
317
322
  if (!response.ok) {
@@ -375,6 +375,7 @@ export function unregisterOAuthProviders(sourceId: string): void {
375
375
  export async function refreshOAuthToken(
376
376
  provider: OAuthProvider,
377
377
  credentials: OAuthCredentials,
378
+ signal: AbortSignal,
378
379
  ): Promise<OAuthCredentials> {
379
380
  if (!credentials) {
380
381
  throw new Error(`No OAuth credentials found for ${provider}`);
@@ -383,43 +384,43 @@ export async function refreshOAuthToken(
383
384
  let newCredentials: OAuthCredentials;
384
385
  switch (provider) {
385
386
  case "anthropic":
386
- newCredentials = await refreshAnthropicToken(credentials.refresh);
387
+ newCredentials = await refreshAnthropicToken(credentials.refresh, signal);
387
388
  break;
388
389
  case "github-copilot":
389
- newCredentials = await refreshGitHubCopilotToken(credentials.refresh, credentials.enterpriseUrl);
390
+ newCredentials = await refreshGitHubCopilotToken(credentials.refresh, credentials.enterpriseUrl, signal);
390
391
  break;
391
392
  case "google-gemini-cli":
392
393
  if (!credentials.projectId) {
393
394
  throw new Error("Google Cloud credentials missing projectId");
394
395
  }
395
- newCredentials = await refreshGoogleCloudToken(credentials.refresh, credentials.projectId);
396
+ newCredentials = await refreshGoogleCloudToken(credentials.refresh, credentials.projectId, signal);
396
397
  break;
397
398
  case "google-vertex":
398
- newCredentials = await refreshVertexWithAntigravityOAuth(credentials.refresh);
399
+ newCredentials = await refreshVertexWithAntigravityOAuth(credentials.refresh, signal);
399
400
  break;
400
401
  case "google-antigravity":
401
402
  if (!credentials.projectId) {
402
403
  throw new Error("Antigravity credentials missing projectId");
403
404
  }
404
- newCredentials = await refreshAntigravityToken(credentials.refresh, credentials.projectId);
405
+ newCredentials = await refreshAntigravityToken(credentials.refresh, credentials.projectId, signal);
405
406
  break;
406
407
  case "openai-codex":
407
- newCredentials = await refreshOpenAICodexToken(credentials.refresh);
408
+ newCredentials = await refreshOpenAICodexToken(credentials.refresh, signal);
408
409
  break;
409
410
  case "openai":
410
411
  newCredentials = credentials;
411
412
  break;
412
413
  case "kimi-code":
413
- newCredentials = await refreshKimiToken(credentials.refresh);
414
+ newCredentials = await refreshKimiToken(credentials.refresh, signal);
414
415
  break;
415
416
  case "kilo":
416
417
  newCredentials = credentials;
417
418
  break;
418
419
  case "gitlab-duo":
419
- newCredentials = await refreshGitLabDuoToken(credentials);
420
+ newCredentials = await refreshGitLabDuoToken(credentials, signal);
420
421
  break;
421
422
  case "cursor":
422
- newCredentials = await refreshCursorToken(credentials.refresh);
423
+ newCredentials = await refreshCursorToken(credentials.refresh, signal);
423
424
  break;
424
425
  case "perplexity":
425
426
  case "huggingface":
@@ -474,12 +475,14 @@ function getPerplexityJwtExpiryMs(token: string): number | undefined {
474
475
  *
475
476
  * For providers that need credential metadata at request time, returns JSON-encoded credentials
476
477
  * plus refresh/expiry metadata for proactive refresh support.
478
+ * @param options - Set `refreshExpired` false when a durable caller already coordinated refresh.
477
479
  * @returns API key string, or null if no credentials
478
480
  * @throws Error if refresh fails
479
481
  */
480
482
  export async function getOAuthApiKey(
481
483
  provider: OAuthProvider,
482
484
  credentials: Record<string, OAuthCredentials>,
485
+ options: { refreshExpired?: boolean; signal?: AbortSignal } = {},
483
486
  ): Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null> {
484
487
  let creds = credentials[provider];
485
488
  if (!creds) {
@@ -496,9 +499,9 @@ export async function getOAuthApiKey(
496
499
  }
497
500
  }
498
501
  // Refresh if expired
499
- if (Date.now() >= creds.expires) {
502
+ if (Date.now() >= creds.expires && options.refreshExpired !== false) {
500
503
  try {
501
- creds = await refreshOAuthToken(provider, creds);
504
+ creds = await refreshOAuthToken(provider, creds, options.signal ?? AbortSignal.timeout(10_000));
502
505
  } catch (refreshError) {
503
506
  if (provider === "perplexity") {
504
507
  const jwtExpiry = getPerplexityJwtExpiryMs(creds.access);
@@ -226,7 +226,7 @@ export async function loginKimi(options: OAuthController): Promise<OAuthCredenti
226
226
  /**
227
227
  * Refresh Kimi OAuth token.
228
228
  */
229
- export async function refreshKimiToken(refreshToken: string): Promise<OAuthCredentials> {
229
+ export async function refreshKimiToken(refreshToken: string, signal: AbortSignal): Promise<OAuthCredentials> {
230
230
  const response = await fetch(`${resolveOAuthHost()}/api/oauth/token`, {
231
231
  method: "POST",
232
232
  headers: {
@@ -238,6 +238,7 @@ export async function refreshKimiToken(refreshToken: string): Promise<OAuthCrede
238
238
  refresh_token: refreshToken,
239
239
  client_id: CLIENT_ID,
240
240
  }),
241
+ signal,
241
242
  });
242
243
 
243
244
  if (!response.ok) {
@@ -413,7 +413,7 @@ export async function loginOpenAICodexDevice(
413
413
  throw new Error("Device authorization expired after 15 minutes");
414
414
  }
415
415
 
416
- export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAuthCredentials> {
416
+ export async function refreshOpenAICodexToken(refreshToken: string, signal: AbortSignal): Promise<OAuthCredentials> {
417
417
  const response = await fetch(TOKEN_URL, {
418
418
  method: "POST",
419
419
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -422,7 +422,7 @@ export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAu
422
422
  refresh_token: refreshToken,
423
423
  client_id: CLIENT_ID,
424
424
  }),
425
- signal: AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS),
425
+ signal: AbortSignal.any([signal, AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS)]),
426
426
  });
427
427
 
428
428
  if (!response.ok) {
@@ -114,6 +114,6 @@ export interface OAuthProviderInterface {
114
114
  readonly name: string;
115
115
  readonly sourceId?: string;
116
116
  login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials | string>;
117
- refreshToken?(credentials: OAuthCredentials): Promise<OAuthCredentials>;
117
+ refreshToken?(credentials: OAuthCredentials, signal: AbortSignal): Promise<OAuthCredentials>;
118
118
  getApiKey?(credentials: OAuthCredentials): string;
119
119
  }