@oh-my-pi/pi-ai 17.4.0 → 17.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +23 -0
  3. package/THIRD-PARTY-NOTICES.txt +22909 -0
  4. package/dist/types/auth-retry.d.ts +8 -3
  5. package/dist/types/auth-storage.d.ts +9 -0
  6. package/dist/types/dialect/qwen-xml.d.ts +6 -0
  7. package/dist/types/error/auth-classify.d.ts +8 -8
  8. package/dist/types/error/oauth.d.ts +2 -1
  9. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  10. package/dist/types/providers/cursor-pi-args.d.ts +21 -0
  11. package/dist/types/providers/cursor.d.ts +23 -3
  12. package/dist/types/providers/google-types.d.ts +6 -0
  13. package/dist/types/providers/mock.d.ts +3 -1
  14. package/dist/types/providers/openai-shared.d.ts +6 -1
  15. package/dist/types/registry/oauth/google-antigravity.d.ts +2 -6
  16. package/dist/types/registry/oauth/index.d.ts +2 -3
  17. package/dist/types/registry/oauth/types.d.ts +2 -1
  18. package/dist/types/registry/oauth/xai-oauth.d.ts +2 -2
  19. package/dist/types/registry/registry.d.ts +1 -1
  20. package/dist/types/registry/types.d.ts +2 -1
  21. package/dist/types/registry/xai-oauth.d.ts +1 -1
  22. package/dist/types/types.d.ts +21 -0
  23. package/dist/types/utils/stream-markup-healing.d.ts +1 -1
  24. package/package.json +8 -6
  25. package/src/auth-retry.ts +41 -3
  26. package/src/auth-storage.ts +229 -75
  27. package/src/dialect/qwen-xml.ts +82 -0
  28. package/src/error/auth-classify.ts +10 -8
  29. package/src/error/oauth.ts +2 -1
  30. package/src/providers/amazon-bedrock.ts +12 -19
  31. package/src/providers/anthropic.ts +29 -35
  32. package/src/providers/cursor/exec-modern.ts +2 -0
  33. package/src/providers/cursor-pi-args.ts +38 -2
  34. package/src/providers/cursor.ts +357 -32
  35. package/src/providers/google-shared.ts +11 -12
  36. package/src/providers/google-types.ts +7 -0
  37. package/src/providers/mock.ts +4 -1
  38. package/src/providers/openai-codex-responses.ts +34 -8
  39. package/src/providers/openai-completions.ts +2 -2
  40. package/src/providers/openai-shared.ts +72 -14
  41. package/src/registry/oauth/google-antigravity.ts +247 -162
  42. package/src/registry/oauth/index.ts +3 -3
  43. package/src/registry/oauth/types.ts +2 -1
  44. package/src/registry/oauth/xai-oauth.ts +14 -7
  45. package/src/registry/types.ts +2 -1
  46. package/src/registry/xai-oauth.ts +2 -2
  47. package/src/stream.ts +8 -4
  48. package/src/types.ts +22 -0
  49. package/src/utils/schema/CONSTRAINTS.md +1 -0
  50. package/src/utils/schema/fields.ts +20 -4
  51. package/src/utils/stream-markup-healing.ts +9 -2
@@ -710,6 +710,7 @@ const USAGE_REPORT_CACHE_KEY_VERSION_OVERRIDES: Partial<Record<Provider, number>
710
710
  anthropic: 3,
711
711
  };
712
712
  const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 10_000;
713
+ const OAUTH_REFRESH_FAILURE_BACKOFF_MS = 5 * 60 * 1000;
713
714
  /**
714
715
  * Refresh OAuth access tokens this many ms before their stated expiry. The
715
716
  * skew exists so callers downstream of {@link AuthStorage} (stream providers,
@@ -1313,6 +1314,9 @@ export class AuthStorage {
1313
1314
  */
1314
1315
  #persistedBlockStoreDamaged = false;
1315
1316
  #usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
1317
+ /** Runtime extension providers take precedence over this configured/default resolver. */
1318
+ #runtimeUsageProviderOverrides: Map<Provider, { provider: UsageProvider; apiKey?: string }> = new Map();
1319
+ #usageReportCacheKeysByProvider: Map<Provider, Set<string>> = new Map();
1316
1320
  #rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
1317
1321
  #usageCache: UsageCache;
1318
1322
  #usageCacheEpoch = 0;
@@ -1492,6 +1496,26 @@ export class AuthStorage {
1492
1496
  this.#runtimeOverrides.delete(provider);
1493
1497
  }
1494
1498
 
1499
+ /**
1500
+ * Install a runtime usage provider override (not persisted to disk).
1501
+ *
1502
+ * Runtime overrides are checked before the configured resolver, including its
1503
+ * built-in fallback. Removing the override restores that resolver unchanged.
1504
+ */
1505
+ setRuntimeUsageProvider(provider: Provider, usageProvider: UsageProvider, apiKey?: string): void {
1506
+ this.#runtimeUsageProviderOverrides.set(provider, { provider: usageProvider, apiKey });
1507
+ this.#invalidateUsageReportCacheForProvider(provider);
1508
+ }
1509
+ /** Remove a runtime usage provider override and restore configured/default resolution. */
1510
+ removeRuntimeUsageProvider(provider: Provider): void {
1511
+ if (!this.#runtimeUsageProviderOverrides.has(provider)) return;
1512
+ this.#invalidateUsageReportCacheForProvider(provider);
1513
+ this.#runtimeUsageProviderOverrides.delete(provider);
1514
+ }
1515
+ #resolveUsageProvider(provider: Provider): UsageProvider | undefined {
1516
+ return this.#runtimeUsageProviderOverrides.get(provider)?.provider ?? this.#usageProviderResolver?.(provider);
1517
+ }
1518
+
1495
1519
  /**
1496
1520
  * Register a per-provider API key sourced from user configuration
1497
1521
  * (e.g. `models.yml` `providers.<name>.apiKey`). Higher priority than
@@ -3072,14 +3096,16 @@ export class AuthStorage {
3072
3096
  this.#usageCache.set(this.#usageForceRefreshCacheKey(provider), { value: null, expiresAt: 0 });
3073
3097
  }
3074
3098
  }
3075
-
3076
3099
  #buildUsageReportCacheKey(request: UsageRequestDescriptor): string {
3077
3100
  const baseUrl = this.#normalizeUsageBaseUrl(request.baseUrl) || "default";
3078
3101
  const identity = this.#buildUsageCacheIdentity(request.credential);
3079
3102
  const providerKey = this.#usageCacheProviderKey(request.provider);
3080
- return `report:${providerKey}:${baseUrl}:${identity}`;
3103
+ const cacheKey = `report:${providerKey}:${baseUrl}:${identity}`;
3104
+ const cacheKeys = this.#usageReportCacheKeysByProvider.get(request.provider) ?? new Set<string>();
3105
+ cacheKeys.add(cacheKey);
3106
+ this.#usageReportCacheKeysByProvider.set(request.provider, cacheKeys);
3107
+ return cacheKey;
3081
3108
  }
3082
-
3083
3109
  #buildUsageReportsCacheKey(requests: ReadonlyArray<UsageRequestDescriptor>): string {
3084
3110
  const snapshot = requests
3085
3111
  .map(request => {
@@ -3216,10 +3242,7 @@ export class AuthStorage {
3216
3242
  }
3217
3243
 
3218
3244
  async #fetchUsageUncached(request: UsageRequestDescriptor, timeoutMs?: number): Promise<UsageReport | null> {
3219
- const resolver = this.#usageProviderResolver;
3220
- if (!resolver) return null;
3221
-
3222
- const providerImpl = resolver(request.provider);
3245
+ const providerImpl = this.#resolveUsageProvider(request.provider);
3223
3246
  if (!providerImpl) return null;
3224
3247
 
3225
3248
  const timeoutSignal =
@@ -3324,7 +3347,6 @@ export class AuthStorage {
3324
3347
  return null;
3325
3348
  }
3326
3349
  }
3327
-
3328
3350
  async #fetchUsageCached(
3329
3351
  request: UsageRequestDescriptor,
3330
3352
  options: { timeoutMs?: number; forceRefresh?: boolean } = {},
@@ -3332,6 +3354,7 @@ export class AuthStorage {
3332
3354
  const timeoutMs = options.timeoutMs;
3333
3355
  const forceRefresh = options.forceRefresh ?? false;
3334
3356
  const cacheKey = this.#buildUsageReportCacheKey(request);
3357
+
3335
3358
  const now = Date.now();
3336
3359
  const cached = forceRefresh ? undefined : this.#usageCache.get<UsageReport | null>(cacheKey);
3337
3360
  // Fresh cache hit: return whatever's there (success or null fallback).
@@ -3363,7 +3386,7 @@ export class AuthStorage {
3363
3386
  // value through transient failures. Session-cookie providers can opt out
3364
3387
  // so an expired login does not display stale quota indefinitely.
3365
3388
  const retainLastGood =
3366
- !forceRefresh && this.#usageProviderResolver?.(request.provider)?.retainLastGoodOnFailure !== false;
3389
+ !forceRefresh && this.#resolveUsageProvider(request.provider)?.retainLastGoodOnFailure !== false;
3367
3390
  const lastGood = retainLastGood
3368
3391
  ? (this.#usageCache.getStale<UsageReport | null>(cacheKey)?.value ?? null)
3369
3392
  : null;
@@ -3378,7 +3401,6 @@ export class AuthStorage {
3378
3401
  this.#usageRequestInFlight.set(inFlightKey, promise);
3379
3402
  return promise;
3380
3403
  }
3381
-
3382
3404
  /**
3383
3405
  * Append a freshly fetched report to durable usage history (when the store
3384
3406
  * supports it). The usage cache is latest-snapshot-only — these rows are
@@ -3479,7 +3501,7 @@ export class AuthStorage {
3479
3501
  options?: { sessionId?: string; baseUrl?: string },
3480
3502
  ): boolean {
3481
3503
  if (this.#fetchUsageReportsOverride) return false;
3482
- const parseHeaders = this.#usageProviderResolver?.(provider)?.parseRateLimitHeaders;
3504
+ const parseHeaders = this.#resolveUsageProvider(provider)?.parseRateLimitHeaders;
3483
3505
  if (!parseHeaders) return false;
3484
3506
 
3485
3507
  const credential = this.#resolveActiveOAuthCredential(provider, options?.sessionId);
@@ -3559,18 +3581,16 @@ export class AuthStorage {
3559
3581
  async #collectUsageRequests(options?: {
3560
3582
  baseUrlResolver?: (provider: Provider) => string | undefined;
3561
3583
  }): Promise<UsageRequestDescriptor[]> {
3562
- const resolver = this.#usageProviderResolver;
3563
- if (!resolver) return [];
3564
-
3565
3584
  const requests: UsageRequestDescriptor[] = [];
3566
3585
  const providers = new Set<string>([
3567
3586
  ...this.#data.keys(),
3587
+ ...this.#runtimeUsageProviderOverrides.keys(),
3568
3588
  ...DEFAULT_USAGE_PROVIDERS.map(provider => provider.id),
3569
3589
  ]);
3570
3590
 
3571
3591
  for (const providerId of providers) {
3572
3592
  const provider = providerId as Provider;
3573
- const providerImpl = resolver(provider);
3593
+ const providerImpl = this.#resolveUsageProvider(provider);
3574
3594
  if (!providerImpl) continue;
3575
3595
  const baseUrl = options?.baseUrlResolver?.(provider);
3576
3596
  let entries = this.#getStoredCredentials(providerId);
@@ -3607,8 +3627,12 @@ export class AuthStorage {
3607
3627
 
3608
3628
  if (entries.length === 0) {
3609
3629
  const runtimeKey = this.#runtimeOverrides.get(providerId);
3610
- const envKey = getEnvApiKey(providerId);
3611
- const apiKey = runtimeKey ?? envKey;
3630
+ const extensionUsageKeyConfig = this.#runtimeUsageProviderOverrides.get(provider)?.apiKey,
3631
+ extensionUsageKey = extensionUsageKeyConfig
3632
+ ? await this.#configValueResolver(extensionUsageKeyConfig)
3633
+ : undefined,
3634
+ envKey = getEnvApiKey(providerId);
3635
+ const apiKey = runtimeKey ?? extensionUsageKey ?? envKey;
3612
3636
  if (!apiKey) continue;
3613
3637
  const request = this.#buildUsageRequest(provider, { type: "api_key", apiKey }, baseUrl);
3614
3638
  if (providerImpl.supports && !providerImpl.supports(request)) continue;
@@ -3864,7 +3888,7 @@ export class AuthStorage {
3864
3888
  * providers without a usage API) — the latter never warrants a usage row.
3865
3889
  */
3866
3890
  usageProviderFor(provider: Provider): UsageProvider | undefined {
3867
- return this.#usageProviderResolver?.(provider);
3891
+ return this.#resolveUsageProvider(provider);
3868
3892
  }
3869
3893
 
3870
3894
  /**
@@ -4115,7 +4139,7 @@ export class AuthStorage {
4115
4139
  if (shouldReconcileStoreHookReports && reports) this.#reconcileCodexUsageBlocksFromReports(reports);
4116
4140
  return reports;
4117
4141
  }
4118
- if (!this.#usageProviderResolver) return null;
4142
+ if (!this.#usageProviderResolver && this.#runtimeUsageProviderOverrides.size === 0) return null;
4119
4143
 
4120
4144
  const requests = await this.#collectUsageRequests(options);
4121
4145
  if (requests.length === 0) return [];
@@ -4206,7 +4230,7 @@ export class AuthStorage {
4206
4230
  async checkCredentials(options?: CheckCredentialsOptions): Promise<CredentialHealthResult[]> {
4207
4231
  options?.signal?.throwIfAborted();
4208
4232
  const stored = this.#store.listAuthCredentials();
4209
- const resolver = this.#usageProviderResolver;
4233
+
4210
4234
  const timeoutMs = options?.timeoutMs ?? this.#usageRequestTimeoutMs;
4211
4235
  const completionProbe = options?.completionProbe;
4212
4236
  const completionTimeoutMs = options?.completionTimeoutMs ?? timeoutMs;
@@ -4307,7 +4331,7 @@ export class AuthStorage {
4307
4331
  continue;
4308
4332
  }
4309
4333
 
4310
- const providerImpl = resolver?.(row.provider as Provider);
4334
+ const providerImpl = this.#resolveUsageProvider(row.provider as Provider);
4311
4335
  if (!providerImpl) {
4312
4336
  base.reason = `no usage probe configured for provider ${row.provider}`;
4313
4337
  } else if (providerImpl.supports && !providerImpl.supports(initialRequest)) {
@@ -4853,6 +4877,7 @@ export class AuthStorage {
4853
4877
  .map(idx => credentials[idx])
4854
4878
  .filter((selection): selection is { credential: OAuthCredential; index: number } => Boolean(selection))
4855
4879
  .map(selection => ({ selection, usage: null, usageChecked: false }));
4880
+ const preflightFailures = new Set<OAuthCandidate>();
4856
4881
 
4857
4882
  // On the warm skip path the candidate list follows the round-robin `order`, not the pin, so
4858
4883
  // hoist the pinned credential to the front to actually reuse it. When ranking ran, the pin is
@@ -4896,8 +4921,8 @@ export class AuthStorage {
4896
4921
  candidate.selection.credential = latestCredential;
4897
4922
  return;
4898
4923
  }
4924
+ const credentialId = this.#getStoredCredentials(provider)[candidate.selection.index]?.id;
4899
4925
  try {
4900
- const credentialId = this.#getStoredCredentials(provider)[candidate.selection.index]?.id;
4901
4926
  // Hand #refreshOAuthCredential a stale clone (expires:0) so its
4902
4927
  // not-yet-expired short-circuit doesn't suppress the forced
4903
4928
  // re-mint; an in-flight peer refresh is still awaited via the
@@ -4924,15 +4949,60 @@ export class AuthStorage {
4924
4949
  this.#replaceCredentialAt(provider, candidate.selection.index, updated);
4925
4950
  }
4926
4951
  } catch (error) {
4927
- // Recovery for definitive failures (incl. peer rotation) lives in
4928
- // #tryOAuthCredential; log instead of swallowing silently — a bare
4929
- // catch here hid stale-refresh-token replays from concurrent
4930
- // sessions (one-turn 401 "Invalid authentication credentials").
4952
+ // A failed preflight already exercised the provider refresh path.
4953
+ // Do not replay the same refresh token in the final candidate pass.
4954
+ const errorMsg = String(error);
4955
+ const isDefinitiveFailure = AIError.isDefinitiveOAuthFailure(errorMsg);
4931
4956
  logger.debug("OAuth preflight refresh failed", {
4932
4957
  provider,
4933
4958
  index: candidate.selection.index,
4934
- error: String(error),
4959
+ error: errorMsg,
4960
+ isDefinitiveFailure,
4935
4961
  });
4962
+ if (isDefinitiveFailure) {
4963
+ // A dead grant discovered during preflight must be disabled here too —
4964
+ // the final candidate pass below skips every `preflightFailures` entry,
4965
+ // so if this branch only blocked the row (like the transient case), the
4966
+ // definitive failure would never reach `#tryOAuthCredential`'s own
4967
+ // disable logic and the row would be retried forever instead of torn down.
4968
+ const outcome = await this.#disableDefinitiveOAuthFailure(
4969
+ provider,
4970
+ credentialId,
4971
+ candidate.selection.credential,
4972
+ candidate.selection.index,
4973
+ errorMsg,
4974
+ );
4975
+ if (
4976
+ outcome !== "disabled" &&
4977
+ credentialId !== undefined &&
4978
+ this.#syncOAuthSelectionFromStore(provider, candidate.selection, credentialId)
4979
+ ) {
4980
+ // A peer rotated this row (or won the disable CAS) between our
4981
+ // snapshot and the refresh; the helper reloaded storage and the row
4982
+ // still exists, so it now holds a valid, freshly rotated credential.
4983
+ // Re-sync the candidate onto it and leave it eligible so the final
4984
+ // pass retries with the live token instead of stranding it (mirrors
4985
+ // #tryOAuthCredential's peer-rotated re-resolve). If the peer instead
4986
+ // deleted/disabled the row, the re-sync fails and we fall through to
4987
+ // preflightFailures — leaving a stale index could rebind the candidate
4988
+ // to a sibling account with the wrong prefetched usage/plan.
4989
+ return;
4990
+ }
4991
+ } else if (credentialId !== undefined) {
4992
+ const latestIndex = this.#getStoredCredentials(provider).findIndex(
4993
+ entry => entry.id === credentialId,
4994
+ );
4995
+ if (latestIndex !== -1) {
4996
+ this.#markCredentialBlocked(
4997
+ provider,
4998
+ providerKey,
4999
+ latestIndex,
5000
+ Date.now() + OAUTH_REFRESH_FAILURE_BACKOFF_MS,
5001
+ blockScope,
5002
+ );
5003
+ }
5004
+ }
5005
+ preflightFailures.add(candidate);
4936
5006
  }
4937
5007
  }),
4938
5008
  );
@@ -4975,6 +5045,7 @@ export class AuthStorage {
4975
5045
 
4976
5046
  for (const pass of passes) {
4977
5047
  for (const candidate of candidates) {
5048
+ if (preflightFailures.has(candidate)) continue;
4978
5049
  const resolved = await this.#tryOAuthCredential(
4979
5050
  provider,
4980
5051
  candidate.selection,
@@ -5001,6 +5072,73 @@ export class AuthStorage {
5001
5072
  return undefined;
5002
5073
  }
5003
5074
 
5075
+ /**
5076
+ * Handles a definitively-dead OAuth grant discovered during refresh (`invalid_grant`,
5077
+ * `revoked`, …): checks for a peer rotation that raced the failure, then CAS-disables
5078
+ * the row and emits `credential_disabled`. Shared by the eager preflight refresh in
5079
+ * {@link AuthStorage.#resolveOAuthSelection} and the final-candidate refresh in
5080
+ * {@link AuthStorage.#tryOAuthCredential} so both actually disable the credential — not
5081
+ * just temporarily block it — on a definitive failure.
5082
+ *
5083
+ * Returns `"disabled"` once the row is torn down, `"peer-rotated"` when a concurrent
5084
+ * process refreshed the same row first (the persisted refresh token no longer matches
5085
+ * what we attempted — the caller should reload and retry with the new credential), or
5086
+ * `"cas-lost"` when the disable itself lost a race and the caller should reload before
5087
+ * continuing.
5088
+ */
5089
+ async #disableDefinitiveOAuthFailure(
5090
+ provider: string,
5091
+ credentialId: number | undefined,
5092
+ attemptedCredential: OAuthCredential,
5093
+ index: number,
5094
+ errorMsg: string,
5095
+ ): Promise<"disabled" | "peer-rotated" | "cas-lost"> {
5096
+ // The credential at this index may have been rotated by another process between
5097
+ // our in-memory snapshot and the refresh attempt: Anthropic rotates refresh
5098
+ // tokens on every use, so the peer's success leaves our stored token invalid.
5099
+ // Re-read the row from disk before marking it disabled — if the persisted
5100
+ // refresh token has changed, the peer rotation succeeded and we should pick
5101
+ // up the new credential instead of soft-deleting the row that the peer just
5102
+ // updated.
5103
+ if (credentialId !== undefined) {
5104
+ const latestRow = this.#store.listAuthCredentials(provider).find(row => row.id === credentialId);
5105
+ const latestCredential = latestRow?.credential;
5106
+ if (latestCredential?.type === "oauth" && latestCredential.refresh !== attemptedCredential.refresh) {
5107
+ logger.debug("OAuth refresh race detected; another process rotated token first", {
5108
+ provider,
5109
+ index,
5110
+ credentialId,
5111
+ });
5112
+ await this.reload();
5113
+ return "peer-rotated";
5114
+ }
5115
+ }
5116
+ // Permanently disable invalid credentials with an explicit cause for inspection/debugging.
5117
+ // Use a CAS-style disable conditioned on the row still containing the stale credential
5118
+ // we tried to refresh, so a peer rotation that lands between the pre-check above and
5119
+ // this disable doesn't soft-delete the freshly-rotated row.
5120
+ const disabled =
5121
+ credentialId !== undefined
5122
+ ? this.#disableCredentialByIdIfMatches(
5123
+ provider,
5124
+ credentialId,
5125
+ attemptedCredential,
5126
+ `oauth refresh failed: ${errorMsg}`,
5127
+ )
5128
+ : this.#tryDisableCredentialAtIfMatches(
5129
+ provider,
5130
+ index,
5131
+ attemptedCredential,
5132
+ `oauth refresh failed: ${errorMsg}`,
5133
+ );
5134
+ if (!disabled) {
5135
+ logger.debug("OAuth refresh disable lost CAS; reloading after peer rotation", { provider, index });
5136
+ await this.reload();
5137
+ return "cas-lost";
5138
+ }
5139
+ return "disabled";
5140
+ }
5141
+
5004
5142
  async #refreshOAuthCredential(
5005
5143
  provider: Provider,
5006
5144
  credential: OAuthCredential,
@@ -5048,8 +5186,31 @@ export class AuthStorage {
5048
5186
  credentialId,
5049
5187
  signal && refreshSignal ? AbortSignal.any([signal, refreshSignal]) : (signal ?? refreshSignal),
5050
5188
  ),
5189
+ isDefinitiveFailure: error => AIError.isDefinitiveOAuthFailure(String(error)),
5190
+ disabledCause: error => `oauth refresh failed: ${String(error)}`,
5051
5191
  });
5052
- if (result.credential) return result.credential;
5192
+ if (result.credential) {
5193
+ if (result.refreshed) {
5194
+ // We performed this refresh ourselves — trust the provider's new token
5195
+ // even when its lifetime is shorter than the refresh skew (some grants
5196
+ // are legitimately short-lived); the next resolve simply treats it as
5197
+ // due for refresh again instead of rejecting a token we just minted.
5198
+ if (Date.now() < result.credential.expires) return result.credential;
5199
+ } else if (Date.now() + OAUTH_REFRESH_SKEW_MS < result.credential.expires) {
5200
+ // Reloaded (not refreshed by us) credential — match #refreshOAuthCredential's
5201
+ // freshness contract: a reload within the refresh skew still counts as
5202
+ // needing refresh, so returning it here would make the final candidate pass
5203
+ // refresh the same row again and replay the token we just failed on.
5204
+ return result.credential;
5205
+ }
5206
+ throw new AIError.OAuthError(
5207
+ `OAuth refresh did not produce a usable credential for provider: ${provider}`,
5208
+ {
5209
+ kind: "token-refresh",
5210
+ provider,
5211
+ },
5212
+ );
5213
+ }
5053
5214
  throw new AIError.OAuthError(`OAuth credential no longer exists for provider: ${provider}`, {
5054
5215
  kind: "token-refresh",
5055
5216
  provider,
@@ -5081,9 +5242,9 @@ export class AuthStorage {
5081
5242
  provider,
5082
5243
  });
5083
5244
  }
5084
- refreshPromise = customProvider.refreshToken(credential);
5245
+ refreshPromise = customProvider.refreshToken(credential, signal);
5085
5246
  } else {
5086
- refreshPromise = refreshOAuthToken(provider as OAuthProvider, credential);
5247
+ refreshPromise = refreshOAuthToken(provider as OAuthProvider, credential, signal);
5087
5248
  }
5088
5249
  }
5089
5250
  // Bound the refresh so a slow/hanging token endpoint cannot stall credential selection.
@@ -5335,58 +5496,29 @@ export class AuthStorage {
5335
5496
  });
5336
5497
 
5337
5498
  if (isDefinitiveFailure) {
5338
- // The credential at this index may have been rotated by another process between
5339
- // our in-memory snapshot and the refresh attempt: Anthropic rotates refresh
5340
- // tokens on every use, so the peer's success leaves our stored token invalid.
5341
- // Re-read the row from disk before marking it disabled — if the persisted
5342
- // refresh token has changed, the peer rotation succeeded and we should pick
5343
- // up the new credential instead of soft-deleting the row that the peer just
5344
- // updated.
5345
- if (credentialId !== undefined) {
5346
- const latestRow = this.#store.listAuthCredentials(provider).find(row => row.id === credentialId);
5347
- const latestCredential = latestRow?.credential;
5348
- if (latestCredential?.type === "oauth" && latestCredential.refresh !== selection.credential.refresh) {
5349
- logger.debug("OAuth refresh race detected; another process rotated token first", {
5350
- provider,
5351
- index: selection.index,
5352
- credentialId,
5353
- });
5354
- await this.reload();
5355
- if (allowFallback) return this.#resolveOAuthSelection(provider, sessionId, options);
5356
- }
5357
- }
5358
- // Permanently disable invalid credentials with an explicit cause for inspection/debugging.
5359
- // Use a CAS-style disable conditioned on the row still containing the stale credential
5360
- // we tried to refresh, so a peer rotation that lands between the pre-check above and
5361
- // this disable doesn't soft-delete the freshly-rotated row.
5362
- const disabled =
5363
- credentialId !== undefined
5364
- ? this.#disableCredentialByIdIfMatches(
5365
- provider,
5366
- credentialId,
5367
- selection.credential,
5368
- `oauth refresh failed: ${errorMsg}`,
5369
- )
5370
- : this.#tryDisableCredentialAtIfMatches(
5371
- provider,
5372
- selection.index,
5373
- selection.credential,
5374
- `oauth refresh failed: ${errorMsg}`,
5375
- );
5376
- if (!disabled) {
5377
- logger.debug("OAuth refresh disable lost CAS; reloading after peer rotation", {
5378
- provider,
5379
- index: selection.index,
5380
- });
5381
- await this.reload();
5499
+ const outcome = await this.#disableDefinitiveOAuthFailure(
5500
+ provider,
5501
+ credentialId,
5502
+ selection.credential,
5503
+ selection.index,
5504
+ errorMsg,
5505
+ );
5506
+ if (outcome === "peer-rotated") {
5382
5507
  if (allowFallback) return this.#resolveOAuthSelection(provider, sessionId, options);
5508
+ return undefined;
5383
5509
  }
5510
+ if (outcome === "cas-lost") return undefined;
5384
5511
  if (this.#getCredentialsForProvider(provider).some(credential => credential.type === "oauth")) {
5385
5512
  if (allowFallback) return this.#resolveOAuthSelection(provider, sessionId, options);
5386
5513
  }
5387
5514
  } else {
5388
5515
  // Block temporarily for transient failures (5 minutes)
5389
- this.#markCredentialBlocked(provider, providerKey, selection.index, Date.now() + 5 * 60 * 1000);
5516
+ this.#markCredentialBlocked(
5517
+ provider,
5518
+ providerKey,
5519
+ selection.index,
5520
+ Date.now() + OAUTH_REFRESH_FAILURE_BACKOFF_MS,
5521
+ );
5390
5522
  }
5391
5523
  }
5392
5524
 
@@ -5891,6 +6023,28 @@ export class AuthStorage {
5891
6023
  }
5892
6024
  }
5893
6025
 
6026
+ /**
6027
+ * Expire cached reports for a provider after its runtime usage implementation changes.
6028
+ * This keeps a newly installed extension provider from serving a built-in snapshot.
6029
+ */
6030
+ #invalidateUsageReportCacheForProvider(provider: Provider): void {
6031
+ this.#usageCacheEpoch += 1;
6032
+ const expired = Date.now() - 1;
6033
+ const prefix = `report:${this.#usageCacheProviderKey(provider)}:`;
6034
+ if (this.#usageCache.deletePrefix(prefix)) return;
6035
+ const cacheKeys = new Set(this.#usageReportCacheKeysByProvider.get(provider));
6036
+ for (const entry of this.#getStoredCredentials(provider)) {
6037
+ cacheKeys.add(
6038
+ this.#buildUsageReportCacheKey({
6039
+ provider,
6040
+ credential: this.#buildUsageCredential(entry.credential),
6041
+ }),
6042
+ );
6043
+ }
6044
+ for (const cacheKey of cacheKeys) {
6045
+ this.#usageCache.set(cacheKey, { value: null, expiresAt: expired });
6046
+ }
6047
+ }
5894
6048
  /**
5895
6049
  * Drop report snapshots for a user-requested refresh so a failed probe
5896
6050
  * cannot replay the pre-invalidation last-good value. The persisted marker
@@ -0,0 +1,82 @@
1
+ import type { InbandScanEvent, InbandScanner } from "./types";
2
+
3
+ const SECTION_OPEN = "<tool_calls>";
4
+ const SECTION_CLOSE = "</tool_calls>";
5
+ const TOOL_ELEMENT = /^<([A-Za-z_][\w.-]*)(\s[^<>]*?)?\s*\/>$/s;
6
+ const ATTRIBUTE = /([A-Za-z_][\w.-]*)\s*=\s*("([^"]*)"|'([^']*)')/g;
7
+
8
+ export class QwenXmlInbandScanner implements InbandScanner {
9
+ #buffer = "";
10
+ #insideSection = false;
11
+
12
+ feed(chunk: string): InbandScanEvent[] {
13
+ this.#buffer += chunk;
14
+ return this.#drain(false);
15
+ }
16
+
17
+ flush(): InbandScanEvent[] {
18
+ return this.#drain(true);
19
+ }
20
+
21
+ #drain(flush: boolean): InbandScanEvent[] {
22
+ const events: InbandScanEvent[] = [];
23
+ while (this.#buffer.length > 0) {
24
+ if (!this.#insideSection) {
25
+ const open = this.#buffer.indexOf(SECTION_OPEN);
26
+ if (open >= 0) {
27
+ if (open > 0) events.push({ type: "text", text: this.#buffer.slice(0, open) });
28
+ this.#buffer = this.#buffer.slice(open + SECTION_OPEN.length);
29
+ this.#insideSection = true;
30
+ continue;
31
+ }
32
+ if (flush) {
33
+ events.push({ type: "text", text: this.#buffer });
34
+ this.#buffer = "";
35
+ break;
36
+ }
37
+ const held = longestSuffixPrefix(this.#buffer, SECTION_OPEN);
38
+ const visibleLength = this.#buffer.length - held;
39
+ if (visibleLength > 0) {
40
+ events.push({ type: "text", text: this.#buffer.slice(0, visibleLength) });
41
+ this.#buffer = this.#buffer.slice(visibleLength);
42
+ }
43
+ break;
44
+ }
45
+
46
+ const close = this.#buffer.indexOf(SECTION_CLOSE);
47
+ if (close < 0) {
48
+ if (flush) this.#buffer = "";
49
+ break;
50
+ }
51
+ const section = this.#buffer.slice(0, close);
52
+ for (const element of section.match(/<[^<>]+\/>/gs) ?? []) {
53
+ const call = parseToolElement(element);
54
+ if (!call) continue;
55
+ const id = `call_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`;
56
+ events.push({ type: "toolStart", id, name: call.name });
57
+ events.push({ type: "toolEnd", id, name: call.name, arguments: call.arguments, rawBlock: element });
58
+ }
59
+ this.#buffer = this.#buffer.slice(close + SECTION_CLOSE.length);
60
+ this.#insideSection = false;
61
+ }
62
+ return events;
63
+ }
64
+ }
65
+
66
+ function parseToolElement(element: string): { name: string; arguments: Record<string, unknown> } | undefined {
67
+ const match = TOOL_ELEMENT.exec(element);
68
+ if (!match) return undefined;
69
+ const args: Record<string, unknown> = {};
70
+ for (const attribute of match[2]?.matchAll(ATTRIBUTE) ?? []) {
71
+ args[attribute[1]!] = attribute[3] ?? attribute[4] ?? "";
72
+ }
73
+ return { name: match[1]!, arguments: args };
74
+ }
75
+
76
+ function longestSuffixPrefix(text: string, target: string): number {
77
+ const max = Math.min(text.length, target.length - 1);
78
+ for (let length = max; length > 0; length--) {
79
+ if (text.endsWith(target.slice(0, length))) return length;
80
+ }
81
+ return 0;
82
+ }
@@ -1,5 +1,6 @@
1
1
  import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
2
2
  import { isAccountPolicyError, isOAuthExpiry, isUsageLimit } from "./flags";
3
+ import { OAuthError } from "./oauth";
3
4
  import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./rate-limit";
4
5
 
5
6
  /**
@@ -25,16 +26,17 @@ export function isInvalidatedOAuthTokenError(error: unknown): boolean {
25
26
  }
26
27
 
27
28
  /**
28
- * Whether an upstream failure should rotate to a sibling credential: a hard
29
- * `401`, a `403` (token valid but access denied — plan, model policy, or org
30
- * restriction a sibling account may not share), an account-scoped policy
31
- * denial such as Codex `cyber_policy`, a body-classified usage limit (Codex
32
- * `usage_limit_reached`, Anthropic account rate-limit, Google
33
- * `resource_exhausted`, OpenAI `insufficient_quota`, …), or a bare `429`
34
- * whose payload did not preserve a richer quota code. Transient 429s
35
- * (`Too many requests`, per-minute caps) stay in the upstream-backoff lane.
29
+ * Whether an upstream failure should retry through the credential resolver: a
30
+ * typed token-refresh request, a hard `401`, a `403` (token valid but access
31
+ * denied — plan, model policy, or org restriction a sibling account may not
32
+ * share), an account-scoped policy denial such as Codex `cyber_policy`, a
33
+ * body-classified usage limit (Codex `usage_limit_reached`, Anthropic account
34
+ * rate-limit, Google `resource_exhausted`, OpenAI `insufficient_quota`, …), or
35
+ * a bare `429` whose payload did not preserve a richer quota code. Transient
36
+ * 429s (`Too many requests`, per-minute caps) stay in the upstream-backoff lane.
36
37
  */
37
38
  export function isAuthRetryableError(error: unknown): boolean {
39
+ if (error instanceof OAuthError && error.kind === "token-refresh") return true;
38
40
  if (isUsageLimit(error)) return true;
39
41
  if (isAccountPolicyError(error)) return true;
40
42
  if (isInvalidatedOAuthTokenError(error)) return true;
@@ -37,7 +37,8 @@ export interface OAuthErrorOptions {
37
37
  /**
38
38
  * A failure inside an interactive OAuth / device-code login flow. The `kind`
39
39
  * pinpoints the stage. Timeout/polling are classified transient; everything
40
- * else is a hard auth failure so the credential layer does not silently retry.
40
+ * else is a hard auth failure. The auth-retry classifier separately treats
41
+ * `token-refresh` as an explicit request to refresh and replay once.
41
42
  */
42
43
  export class OAuthError extends Error {
43
44
  readonly kind: OAuthErrorKind;
@@ -779,17 +779,6 @@ function takeCachePoint(policy: BedrockPromptCachePolicy): CachePoint | undefine
779
779
  return { cachePoint: { type: "default", ...(policy.ttl ? { ttl: policy.ttl } : {}) } };
780
780
  }
781
781
 
782
- /**
783
- * Check if the model supports thinking signatures in reasoningContent.
784
- * Only Anthropic Claude models support the signature field.
785
- * Other models (Nova, Titan, Mistral, Llama, etc.) reject it with:
786
- * "This model doesn't support the reasoningContent.reasoningText.signature field"
787
- */
788
- function supportsThinkingSignature(model: Model<"bedrock-converse-stream">): boolean {
789
- const id = model.id.toLowerCase();
790
- return id.includes("anthropic.claude") || id.includes("anthropic/claude");
791
- }
792
-
793
782
  function buildSystemPrompt(
794
783
  systemPrompt: readonly string[] | string | undefined,
795
784
  promptCachePolicy: BedrockPromptCachePolicy,
@@ -869,21 +858,25 @@ function convertMessages(
869
858
  case "thinking":
870
859
  // Skip empty thinking blocks
871
860
  if (c.thinking.trim().length === 0) continue;
872
- // A captured signature is authoritative even when the model id is an opaque ARN.
873
- // Without one, known non-Claude families use unsigned reasoning; known Claude ids demote to text.
861
+ // A captured signature is authoritative even when the model id is an opaque ARN:
862
+ // only a model that itself streamed a signature (Claude) can have one, so replay
863
+ // it as signed reasoningContent regardless of how the id is spelled.
874
864
  if (c.thinkingSignature) {
875
865
  contentBlocks.push({
876
866
  reasoningContent: {
877
867
  reasoningText: { text: c.thinking.toWellFormed(), signature: c.thinkingSignature },
878
868
  },
879
869
  });
880
- } else if (!supportsThinkingSignature(model)) {
881
- // Model doesn't support signatures at all — send as unsigned reasoning
882
- contentBlocks.push({
883
- reasoningContent: { reasoningText: { text: c.thinking.toWellFormed() } },
884
- });
885
870
  } else {
886
- // Model requires signature but we don't have one — demote to text
871
+ // No signature was captured. Do NOT fall back to unsigned reasoningContent here:
872
+ // a model streaming reasoningContent does not imply it accepts reasoningContent
873
+ // echoed back in a request. Amazon Nova streams unsigned reasoning just fine but
874
+ // rejects it on replay with HTTP 400 "User messages cannot contain reasoning
875
+ // content. Please remove the reasoning content and try again.", which wedges the
876
+ // agent loop on every turn after the first. Demote to plain text instead — the
877
+ // content survives, just no longer typed as a reasoning block. This matches how
878
+ // every other provider (Anthropic, Google, OpenAI-completions) handles thinking
879
+ // blocks it can't safely replay.
887
880
  contentBlocks.push({ text: renderDemotedThinking(model.id, c.thinking) });
888
881
  }
889
882
  break;