@linxiraos/pi-ai 1.1.26 → 1.2.0

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 (237) hide show
  1. package/CHANGELOG.md +9 -8
  2. package/README.md +15 -0
  3. package/dist/types/auth/affinity.d.ts +33 -6
  4. package/dist/types/auth/cascade.d.ts +44 -7
  5. package/dist/types/auth/oauth.d.ts +9 -6
  6. package/dist/types/auth/policy.d.ts +1 -0
  7. package/dist/types/auth/refresh.d.ts +1 -2
  8. package/dist/types/auth/select.d.ts +6 -4
  9. package/dist/types/auth/sqlite-credential-store.d.ts +21 -1
  10. package/dist/types/auth/store.d.ts +8 -2
  11. package/dist/types/auth/types.d.ts +99 -15
  12. package/dist/types/auth-broker/client.d.ts +3 -3
  13. package/dist/types/auth-broker/discover.d.ts +1 -1
  14. package/dist/types/auth-broker/refresher.d.ts +6 -1
  15. package/dist/types/auth-broker/remote-store.d.ts +6 -4
  16. package/dist/types/auth-broker/server.d.ts +2 -0
  17. package/dist/types/auth-broker/snapshot-cache.d.ts +12 -0
  18. package/dist/types/auth-gateway/dispatch.d.ts +13 -5
  19. package/dist/types/auth-gateway/http.d.ts +0 -2
  20. package/dist/types/auth-gateway/index.d.ts +1 -0
  21. package/dist/types/auth-gateway/routes/embeddings.d.ts +2 -2
  22. package/dist/types/auth-gateway/routes/images.d.ts +3 -3
  23. package/dist/types/auth-gateway/routes/rerank.d.ts +2 -2
  24. package/dist/types/auth-gateway/routes/speech.d.ts +2 -2
  25. package/dist/types/auth-gateway/routes/systemone.d.ts +2 -2
  26. package/dist/types/auth-gateway/routes/transcriptions.d.ts +2 -2
  27. package/dist/types/auth-gateway/routes/video.d.ts +4 -4
  28. package/dist/types/auth-gateway/server.d.ts +22 -5
  29. package/dist/types/auth-gateway/session-state.d.ts +6 -0
  30. package/dist/types/auth-gateway/stdio.d.ts +12 -0
  31. package/dist/types/auth-storage.d.ts +7 -6
  32. package/dist/types/dialect/terminator-wait.d.ts +19 -0
  33. package/dist/types/env-api-key.d.ts +21 -0
  34. package/dist/types/error/auth.d.ts +10 -0
  35. package/dist/types/error/flags.d.ts +38 -0
  36. package/dist/types/images/index.d.ts +2 -3
  37. package/dist/types/images/shared.d.ts +6 -0
  38. package/dist/types/index.d.ts +2 -1
  39. package/dist/types/judgment/index.d.ts +2 -1
  40. package/dist/types/judgment/native.d.ts +80 -0
  41. package/dist/types/judgment/openai-decisions.d.ts +3 -0
  42. package/dist/types/judgment/question-file.d.ts +52 -0
  43. package/dist/types/judgment/systemone.d.ts +7 -0
  44. package/dist/types/judgment/types.d.ts +2 -2
  45. package/dist/types/judgment/wire.d.ts +36 -0
  46. package/dist/types/provider-details.d.ts +1 -1
  47. package/dist/types/provider-session-state.d.ts +2 -2
  48. package/dist/types/providers/anthropic-client.d.ts +10 -0
  49. package/dist/types/providers/anthropic-state.d.ts +2 -0
  50. package/dist/types/providers/anthropic.d.ts +38 -3
  51. package/dist/types/providers/aws-credentials.d.ts +4 -8
  52. package/dist/types/providers/connect-error-detail.d.ts +9 -0
  53. package/dist/types/providers/connect-frame.d.ts +44 -0
  54. package/dist/types/providers/cursor/interaction-query.d.ts +0 -2
  55. package/dist/types/providers/cursor.d.ts +8 -8
  56. package/dist/types/providers/google-auth.d.ts +4 -1
  57. package/dist/types/providers/google-shared.d.ts +24 -4
  58. package/dist/types/providers/inference-headers.d.ts +6 -0
  59. package/dist/types/providers/mock.d.ts +3 -1
  60. package/dist/types/providers/openai-codex/request-transformer.d.ts +2 -0
  61. package/dist/types/providers/openai-codex-responses.d.ts +1 -1
  62. package/dist/types/providers/openai-configuration-update.d.ts +20 -11
  63. package/dist/types/providers/openai-responses-wire.d.ts +1 -55
  64. package/dist/types/providers/openai-responses.d.ts +40 -3
  65. package/dist/types/providers/openai-shared.d.ts +30 -5
  66. package/dist/types/providers/pi-native-client.d.ts +1 -1
  67. package/dist/types/providers/transform-messages.d.ts +25 -0
  68. package/dist/types/providers/vision-guard.d.ts +1 -1
  69. package/dist/types/registry/oauth/cloudflare-workers-ai.d.ts +3 -0
  70. package/dist/types/registry/oauth/cursor.d.ts +10 -2
  71. package/dist/types/registry/oauth/index.d.ts +2 -0
  72. package/dist/types/registry/oauth/snowflake.d.ts +3 -0
  73. package/dist/types/registry/snowflake.d.ts +4 -0
  74. package/dist/types/stream.d.ts +0 -21
  75. package/dist/types/transcription/types.d.ts +2 -1
  76. package/dist/types/types.d.ts +116 -14
  77. package/dist/types/usage.d.ts +2 -0
  78. package/dist/types/utils/anthropic-auth.d.ts +21 -7
  79. package/dist/types/utils/aws-profile.d.ts +5 -0
  80. package/dist/types/utils/block-symbols.d.ts +14 -0
  81. package/dist/types/utils/dsml-leak.d.ts +29 -0
  82. package/dist/types/utils/glyph-codec.d.ts +1 -1
  83. package/dist/types/utils/idle-iterator.d.ts +11 -0
  84. package/dist/types/utils/leaked-thinking-stream.d.ts +1 -1
  85. package/dist/types/utils/resolve-peer.d.ts +2 -0
  86. package/dist/types/utils/retry-wait.d.ts +9 -0
  87. package/dist/types/utils/schema/normalize.d.ts +8 -3
  88. package/dist/types/utils/stream-markup-healing.d.ts +4 -4
  89. package/dist/types/utils/tool-call-arguments.d.ts +8 -0
  90. package/dist/types/utils.d.ts +6 -0
  91. package/package.json +6 -6
  92. package/src/auth/affinity.ts +130 -37
  93. package/src/auth/blocks.ts +94 -15
  94. package/src/auth/cascade.ts +112 -17
  95. package/src/auth/health.ts +3 -2
  96. package/src/auth/oauth.ts +25 -10
  97. package/src/auth/policy.ts +10 -1
  98. package/src/auth/pool.ts +0 -5
  99. package/src/auth/refresh.ts +17 -12
  100. package/src/auth/resets.ts +1 -1
  101. package/src/auth/rotation.ts +8 -2
  102. package/src/auth/select.ts +102 -30
  103. package/src/auth/sqlite-credential-store.ts +119 -49
  104. package/src/auth/store.ts +8 -2
  105. package/src/auth/types.ts +108 -17
  106. package/src/auth/usage-cache.ts +16 -5
  107. package/src/auth/usage.ts +2 -2
  108. package/src/auth-broker/client.ts +7 -4
  109. package/src/auth-broker/discover.ts +6 -5
  110. package/src/auth-broker/refresher.ts +8 -1
  111. package/src/auth-broker/remote-store.ts +200 -62
  112. package/src/auth-broker/server.ts +316 -130
  113. package/src/auth-broker/snapshot-cache.ts +142 -2
  114. package/src/auth-gateway/dispatch.ts +15 -7
  115. package/src/auth-gateway/http.ts +0 -8
  116. package/src/auth-gateway/index.ts +1 -0
  117. package/src/auth-gateway/routes/embeddings.ts +2 -2
  118. package/src/auth-gateway/routes/images.ts +4 -4
  119. package/src/auth-gateway/routes/rerank.ts +2 -2
  120. package/src/auth-gateway/routes/speech.ts +2 -2
  121. package/src/auth-gateway/routes/systemone.ts +22 -14
  122. package/src/auth-gateway/routes/transcriptions.ts +2 -2
  123. package/src/auth-gateway/routes/video.ts +7 -7
  124. package/src/auth-gateway/server.ts +175 -134
  125. package/src/auth-gateway/session-state.ts +51 -9
  126. package/src/auth-gateway/stdio.ts +132 -0
  127. package/src/auth-storage.ts +23 -9
  128. package/src/dialect/deepseek.ts +67 -8
  129. package/src/dialect/gemini.ts +8 -2
  130. package/src/dialect/hermes.ts +8 -2
  131. package/src/dialect/kimi.ts +8 -2
  132. package/src/dialect/qwen-xml.ts +8 -2
  133. package/src/dialect/qwen3.ts +8 -2
  134. package/src/dialect/terminator-wait.ts +42 -0
  135. package/src/env-api-key.ts +78 -0
  136. package/src/error/auth.ts +20 -0
  137. package/src/error/flags.ts +143 -24
  138. package/src/error/rate-limit.ts +8 -6
  139. package/src/images/google-antigravity.ts +0 -1
  140. package/src/images/index.ts +3 -17
  141. package/src/images/openai-hosted.ts +13 -4
  142. package/src/images/openai-images.ts +19 -13
  143. package/src/images/shared.ts +10 -2
  144. package/src/index.ts +2 -1
  145. package/src/judgment/index.ts +2 -1
  146. package/src/judgment/native.ts +333 -0
  147. package/src/judgment/openai-decisions-predicate.md +9 -0
  148. package/src/judgment/openai-decisions.ts +129 -0
  149. package/src/judgment/question-file.ts +90 -0
  150. package/src/judgment/systemone.ts +65 -0
  151. package/src/judgment/text.ts +9 -1
  152. package/src/judgment/types.ts +2 -2
  153. package/src/judgment/wire.ts +42 -0
  154. package/src/provider-details.ts +1 -1
  155. package/src/provider-session-state.ts +2 -2
  156. package/src/providers/amazon-bedrock.ts +19 -8
  157. package/src/providers/anthropic-client.ts +16 -1
  158. package/src/providers/anthropic-state.ts +6 -0
  159. package/src/providers/anthropic.ts +962 -238
  160. package/src/providers/apple-foundation-models.ts +10 -4
  161. package/src/providers/aws-credentials.ts +65 -5
  162. package/src/providers/azure-openai-responses.ts +1 -1
  163. package/src/providers/connect-error-detail.ts +21 -0
  164. package/src/providers/connect-frame.ts +107 -0
  165. package/src/providers/cursor/interaction-query.ts +1 -9
  166. package/src/providers/cursor.ts +590 -133
  167. package/src/providers/devin.ts +68 -58
  168. package/src/providers/factory-droid/gemini.ts +38 -51
  169. package/src/providers/factory-droid.ts +2 -0
  170. package/src/providers/gitlab-duo-workflow.ts +5 -2
  171. package/src/providers/gitlab-duo.ts +1 -0
  172. package/src/providers/google-auth.ts +10 -3
  173. package/src/providers/google-gemini-cli.ts +4 -0
  174. package/src/providers/google-shared.ts +134 -105
  175. package/src/providers/google-vertex.ts +0 -1
  176. package/src/providers/google.ts +1 -1
  177. package/src/providers/inference-headers.ts +19 -0
  178. package/src/providers/mock.ts +4 -0
  179. package/src/providers/ollama.ts +1 -1
  180. package/src/providers/openai-anthropic-shim.ts +1 -0
  181. package/src/providers/openai-codex/request-transformer.ts +16 -11
  182. package/src/providers/openai-codex-responses.ts +201 -76
  183. package/src/providers/openai-completions.ts +12 -2
  184. package/src/providers/openai-configuration-update.ts +54 -15
  185. package/src/providers/openai-responses-wire.ts +1 -55
  186. package/src/providers/openai-responses.ts +437 -33
  187. package/src/providers/openai-shared.ts +267 -72
  188. package/src/providers/pi-native-client.ts +2 -2
  189. package/src/providers/pi-native-server.ts +1 -0
  190. package/src/providers/systemone-server.ts +1 -1
  191. package/src/providers/transcriptions-server.ts +1 -1
  192. package/src/providers/transform-messages.ts +240 -84
  193. package/src/providers/vision-guard.ts +1 -1
  194. package/src/registry/aws.ts +51 -14
  195. package/src/registry/cloudflare-ai-gateway.ts +26 -3
  196. package/src/registry/hooks/api-key.ts +1 -0
  197. package/src/registry/hooks/custom.ts +2 -0
  198. package/src/registry/hooks/env.ts +2 -5
  199. package/src/registry/oauth/callback-server.ts +6 -1
  200. package/src/registry/oauth/cloudflare-ai-gateway.ts +2 -2
  201. package/src/registry/oauth/cloudflare-workers-ai.ts +32 -0
  202. package/src/registry/oauth/cursor.ts +80 -17
  203. package/src/registry/oauth/index.ts +5 -0
  204. package/src/registry/oauth/snowflake.ts +183 -0
  205. package/src/registry/registry.ts +2 -0
  206. package/src/registry/snowflake.ts +149 -0
  207. package/src/stream.ts +85 -107
  208. package/src/transcription/types.ts +2 -1
  209. package/src/types.ts +142 -15
  210. package/src/usage/claude.ts +19 -12
  211. package/src/usage/commandcode.ts +1 -1
  212. package/src/usage/cursor.ts +14 -25
  213. package/src/usage/github-copilot.ts +1 -1
  214. package/src/usage/kimi.ts +1 -1
  215. package/src/usage/minimax-code.ts +8 -1
  216. package/src/usage.ts +8 -0
  217. package/src/utils/anthropic-auth.ts +34 -10
  218. package/src/utils/aws-profile.ts +10 -4
  219. package/src/utils/block-symbols.ts +17 -0
  220. package/src/utils/dsml-leak.ts +109 -0
  221. package/src/utils/empty-completion-retry.ts +2 -3
  222. package/src/utils/glyph-codec.ts +200 -7
  223. package/src/utils/idle-iterator.ts +80 -35
  224. package/src/utils/leaked-thinking-stream.ts +47 -25
  225. package/src/utils/resolve-peer.ts +7 -0
  226. package/src/utils/retry-wait.ts +19 -0
  227. package/src/utils/schema/CONSTRAINTS.md +9 -7
  228. package/src/utils/schema/fields.ts +20 -0
  229. package/src/utils/schema/normalize.ts +65 -23
  230. package/src/utils/schema/wire.ts +3 -3
  231. package/src/utils/stream-markup-healing.ts +4 -4
  232. package/src/utils/thinking-loop.ts +36 -13
  233. package/src/utils/tool-call-arguments.ts +16 -0
  234. package/src/utils/transport-fetch.ts +1 -1
  235. package/src/utils.ts +41 -10
  236. package/dist/types/judgment/typesafe.d.ts +0 -49
  237. package/src/judgment/typesafe.ts +0 -183
@@ -5,7 +5,6 @@ import type { OAuthCredentials, OAuthProvider } from "../registry/oauth/types";
5
5
  import type { Provider } from "../types";
6
6
  import { raceSignal } from "./abort";
7
7
  import { authCredentialEquals, type CredentialPool, credentialDisabledEvent } from "./pool";
8
- import type { AccountPolicies } from "./policy";
9
8
  import { resolveCredentialIdentityKey, serializeCredential } from "./sqlite-credential-store";
10
9
  import { hasRefreshLeases, type AuthCredentialStore } from "./store";
11
10
  import {
@@ -91,8 +90,8 @@ export function mergeRefreshedCredential<T extends OAuthCredential>(current: T,
91
90
  export interface OAuthRefresherDeps {
92
91
  store: AuthCredentialStore;
93
92
  pool: CredentialPool;
94
- policies: AccountPolicies;
95
93
  override?: AuthStorageOptions["refreshOAuthCredential"];
94
+ overrideMints?: AuthStorageOptions["refreshOAuthCredentialMints"];
96
95
  }
97
96
 
98
97
  /** Single-flighted, lease-guarded OAuth refresh with compare-and-set persistence. */
@@ -139,6 +138,13 @@ export class OAuthRefresher {
139
138
  this.#recentMints.set(id, { provider, access: credential.access, at: now });
140
139
  }
141
140
 
141
+ /** Whether refreshes run the provider token exchange in this process rather than delegating it. */
142
+ #mintsLocally(): boolean {
143
+ return this.#deps.override
144
+ ? this.#deps.overrideMints === true
145
+ : this.#deps.store.refreshOAuthCredential === undefined;
146
+ }
147
+
142
148
  /**
143
149
  * Refresh one stored OAuth credential under durable row ownership.
144
150
  */
@@ -345,7 +351,7 @@ export class OAuthRefresher {
345
351
  } else {
346
352
  this.#deps.store.updateAuthCredential(row.id, merged);
347
353
  }
348
- if (this.#deps.override === undefined && this.#deps.store.refreshOAuthCredential === undefined) {
354
+ if (this.#mintsLocally()) {
349
355
  this.#rememberMint(provider, row.id, merged);
350
356
  }
351
357
  this.#deps.pool.replace(
@@ -422,7 +428,6 @@ export class OAuthRefresher {
422
428
  await this.#deps.pool.reload();
423
429
  return "cas-lost";
424
430
  }
425
- this.#deps.policies.validateFor(provider, this.#deps.pool.credentials(provider));
426
431
  return "disabled";
427
432
  }
428
433
 
@@ -462,11 +467,7 @@ export class OAuthRefresher {
462
467
  .then(refreshed => {
463
468
  // A delegated refresh may have returned a broker-cached token rather
464
469
  // than minting one. Only direct local requests can establish mint time.
465
- if (
466
- !hasRefreshLeases(this.#deps.store) &&
467
- this.#deps.override === undefined &&
468
- this.#deps.store.refreshOAuthCredential === undefined
469
- ) {
470
+ if (!hasRefreshLeases(this.#deps.store) && this.#mintsLocally()) {
470
471
  this.#rememberMint(provider, credentialId, refreshed);
471
472
  }
472
473
  return refreshed;
@@ -613,7 +614,7 @@ export class OAuthRefresher {
613
614
  const promise = (async () => {
614
615
  this.#deps.pool.bump("credential-refresh-start");
615
616
  try {
616
- return await this.#forceRefreshCredentialByIdUnshared(id, signal);
617
+ return await this.#forceRefreshCredentialByIdUnshared(id, signal, options?.reason);
617
618
  } catch (error) {
618
619
  this.#deps.pool.bump("credential-refresh-failure");
619
620
  throw error;
@@ -625,7 +626,11 @@ export class OAuthRefresher {
625
626
  return raceSignal(promise, signal, "credential refresh aborted");
626
627
  }
627
628
 
628
- async #forceRefreshCredentialByIdUnshared(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry> {
629
+ async #forceRefreshCredentialByIdUnshared(
630
+ id: number,
631
+ signal?: AbortSignal,
632
+ reason?: OAuthRefreshReason,
633
+ ): Promise<AuthCredentialSnapshotEntry> {
629
634
  for (const provider of this.#deps.pool.providers()) {
630
635
  const entries = this.#deps.pool.entries(provider);
631
636
  const index = entries.findIndex(entry => entry.id === id);
@@ -645,7 +650,7 @@ export class OAuthRefresher {
645
650
  const stale: OAuthCredential = { ...attempted, expires: 0 };
646
651
  let refreshed: OAuthCredentials;
647
652
  try {
648
- refreshed = await this.#refreshSingleFlight(provider as Provider, stale, id, signal);
653
+ refreshed = await this.#refreshSingleFlight(provider as Provider, stale, id, signal, reason);
649
654
  } catch (error) {
650
655
  // A definitively-dead grant tears the row down here, where the
651
656
  // attempted credential is known. CAS on the persisted credential so a
@@ -262,7 +262,7 @@ export class ResetCredits implements ResetsApi {
262
262
  resetSpentCredentialId: access.credentialId,
263
263
  });
264
264
  if (this.#deps.store.invalidateUsageCache) {
265
- await this.#deps.store.invalidateUsageCache(options.signal).catch(err => {
265
+ await this.#deps.store.invalidateUsageCache(provider, options.signal).catch(err => {
266
266
  logger.debug("Failed to notify store of stale usage", { err });
267
267
  });
268
268
  }
@@ -141,6 +141,7 @@ export class RateLimits implements LimitsApi {
141
141
  }
142
142
  #blockCredentialForRotation(
143
143
  provider: string,
144
+ sessionId: string | undefined,
144
145
  credentialType: AuthCredential["type"],
145
146
  targetIndex: number,
146
147
  blockedUntil: number,
@@ -188,7 +189,9 @@ export class RateLimits implements LimitsApi {
188
189
  .map((credential, index) => ({ credential, index }))
189
190
  .filter(
190
191
  (entry): entry is { credential: AuthCredential; index: number } =>
191
- entry.credential.type === credentialType && entry.index !== targetIndex,
192
+ entry.credential.type === credentialType &&
193
+ entry.index !== targetIndex &&
194
+ this.#deps.affinity.allows(provider, sessionId, entry.credential),
192
195
  );
193
196
 
194
197
  let retryAtMs: number | undefined;
@@ -292,6 +295,7 @@ export class RateLimits implements LimitsApi {
292
295
  .findIndex(entry => entry.id === targetCredentialId && entry.credential.type === credentialType);
293
296
  const rotation = this.#blockCredentialForRotation(
294
297
  provider,
298
+ sessionId,
295
299
  credentialType,
296
300
  targetIndex,
297
301
  blockedUntil,
@@ -456,6 +460,7 @@ export class RateLimits implements LimitsApi {
456
460
  }
457
461
  const mark = this.#blockCredentialForRotation(
458
462
  provider,
463
+ sessionId,
459
464
  sessionCredential.type,
460
465
  sessionCredential.index,
461
466
  Date.now() + DEFAULT_BLOCK_MS,
@@ -474,7 +479,8 @@ export class RateLimits implements LimitsApi {
474
479
  (credential, index) =>
475
480
  credential.type === sessionCredential.type &&
476
481
  index !== sessionCredential.index &&
477
- !this.#deps.blocks.isBlocked(provider, providerKey, index),
482
+ !this.#deps.blocks.isBlocked(provider, providerKey, index) &&
483
+ this.#deps.affinity.allows(provider, sessionId, credential),
478
484
  );
479
485
  const target = this.#deps.pool.entries(provider)[sessionCredential.index];
480
486
  const sticky = this.#deps.affinity.get(provider, sessionId);
@@ -4,6 +4,7 @@ import { getOAuthApiKey, getOAuthProvider } from "../registry/oauth";
4
4
  import type { OAuthCredentials, OAuthProvider } from "../registry/oauth/types";
5
5
  import type { Provider } from "../types";
6
6
  import type { CredentialRankingContext, CredentialRankingStrategy, PlanGate, UsageReport } from "../usage";
7
+ import { isWithinUsageReserve } from "../usage";
7
8
  import type { RankingStrategyResolver } from "../usage/registry";
8
9
  import type { SessionAffinity } from "./affinity";
9
10
  import {
@@ -28,7 +29,14 @@ import {
28
29
  } from "./rank";
29
30
  import { mergeRefreshedCredential, OAUTH_REFRESH_SKEW_MS, type OAuthRefresher } from "./refresh";
30
31
  import type { AuthCredentialStore } from "./store";
31
- import type { ApiKeyCredential, AuthApiKeyOptions, AuthCredential, OAuthCredential } from "./types";
32
+ import {
33
+ type ApiKeyCredential,
34
+ type AuthApiKeyOptions,
35
+ type AuthCredential,
36
+ type OAuthCredential,
37
+ oauthAccountKey,
38
+ type StoredAuthCredential,
39
+ } from "./types";
32
40
  import type { UsageService } from "./usage";
33
41
  import {
34
42
  isUsageLimitReached,
@@ -59,6 +67,8 @@ export type TryOAuthOptions = {
59
67
  blockScopes?: readonly string[];
60
68
  /** When false, a definitive failure of THIS credential returns undefined instead of falling back to the ranked/round-robin selector (target-only resolution). */
61
69
  allowFallback?: boolean;
70
+ /** Receives a non-definitive refresh failure that left this credential unusable; the caller filters for retryable ones. */
71
+ onTransientRefreshFailure?: (error: unknown) => void;
62
72
  };
63
73
 
64
74
  /** Services consulted by CredentialSelector for policy, usage, blocks, refresh, and session affinity. */
@@ -467,7 +477,9 @@ export class CredentialSelector {
467
477
  blocked,
468
478
  blockedUntil,
469
479
  inReserve:
470
- reserveFraction !== undefined && remainingFraction !== undefined && remainingFraction <= reserveFraction,
480
+ reserveFraction !== undefined &&
481
+ remainingFraction !== undefined &&
482
+ isWithinUsageReserve(remainingFraction, reserveFraction),
471
483
  reserveMeasured: reserveFraction !== undefined && remainingFraction !== undefined,
472
484
  accountPriority: policy?.priority === undefined || !Number.isFinite(policy.priority) ? 0 : policy.priority,
473
485
  allowanceSpent: remainingFraction === 0,
@@ -493,9 +505,9 @@ export class CredentialSelector {
493
505
  * 1. strict: unblocked credentials only, usage limits respected, plan
494
506
  * filter enforced (when any account is confirmed eligible);
495
507
  * 2. plan-fitting last resort: same plan filter, but blocked/exhausted
496
- * accounts are allowed (blocked candidates rank earliest-unblocking
497
- * first) so the caller gets real usage-limit semantics from the wire
498
- * instead of a missing key;
508
+ * accounts are allowed (an explicit session pin first, then blocked
509
+ * candidates earliest-unblocking first) so the caller gets real
510
+ * usage-limit semantics from the wire instead of a missing key;
499
511
  * 3. unfiltered last resort: the plan filter matched nothing usable —
500
512
  * skip it and try every account once; the server is the final arbiter
501
513
  * of model access.
@@ -510,16 +522,22 @@ export class CredentialSelector {
510
522
  options?: AuthApiKeyOptions,
511
523
  ): Promise<OAuthResolutionResult | undefined> {
512
524
  await this.#deps.pool.adoptExternalChanges();
513
- const credentials = this.#deps.pool
525
+ const stored = this.#deps.pool
514
526
  .credentials(provider)
515
527
  .map((credential, index) => ({ credential, index }))
516
528
  .filter((entry): entry is { credential: OAuthCredential; index: number } => entry.credential.type === "oauth");
517
529
  this.#deps.policies.validateFor(
518
530
  provider,
519
- credentials.map(entry => entry.credential),
531
+ stored.map(entry => entry.credential),
520
532
  );
533
+ // A session restriction drops every other account before ranking, pins,
534
+ // and the fallback passes below, so none of them can route back to it.
535
+ const credentials = stored.filter(entry => this.#deps.affinity.allows(provider, sessionId, entry.credential));
521
536
 
522
537
  if (credentials.length === 0) return undefined;
538
+ // Ranking and preflight both await provider work; preserve the row each
539
+ // selection started with even if a peer reorders the live pool.
540
+ const credentialIds = this.#deps.pool.entries(provider).map(entry => entry.id);
523
541
  this.#deps.policies.validateUsageCapability(provider, this.#deps.usage.canFetchOAuthUsage(provider));
524
542
 
525
543
  const providerKey = providerTypeKey(provider, "oauth");
@@ -536,9 +554,10 @@ export class CredentialSelector {
536
554
  const accountIds = options?.accountIds?.length ? new Set(options.accountIds) : undefined;
537
555
  const enforceAccounts =
538
556
  accountIds !== undefined &&
539
- credentials.some(
540
- ({ credential }) => credential.accountId !== undefined && accountIds.has(credential.accountId),
541
- );
557
+ credentials.some(({ credential }) => {
558
+ const accountKey = oauthAccountKey(credential);
559
+ return accountKey !== undefined && accountIds.has(accountKey);
560
+ });
542
561
  const hasAccountPolicy = credentials.some(
543
562
  ({ credential }) => this.#deps.policies.forCredential(provider, credential) !== undefined,
544
563
  );
@@ -643,7 +662,20 @@ export class CredentialSelector {
643
662
  ? { selection, usage: sessionPreferredUsage, usageChecked: true }
644
663
  : { selection, usage: null, usageChecked: false },
645
664
  );
665
+ // Keep the candidate object: preflight may rebind its positional index
666
+ // after a peer changes the credential pool.
667
+ const explicitPin = sessionPinIsExplicit
668
+ ? candidates.find(candidate => candidate.selection.index === sessionPreferredIndex)
669
+ : undefined;
646
670
  const preflightFailures = new Set<OAuthCandidate>();
671
+ // The last retryable refresh error (network, timeout, 5xx) that removed a candidate.
672
+ // When no candidate resolves, it is rethrown so callers retry instead of reporting
673
+ // a missing key. Refresher outcomes after a dead grant (row disabled, CAS lost) are
674
+ // classified auth failures and keep resolving to undefined.
675
+ let transientRefreshFailure: unknown;
676
+ const recordTransientRefreshFailure = (error: unknown): void => {
677
+ if (AIError.retriable(AIError.classify(error))) transientRefreshFailure = error;
678
+ };
647
679
 
648
680
  const sessionPreferredCandidate = candidates.findIndex(
649
681
  candidate =>
@@ -690,16 +722,24 @@ export class CredentialSelector {
690
722
  const forceRefreshIndex = options?.forceRefresh
691
723
  ? (sessionPreferredIndex ?? candidates[0]?.selection.index)
692
724
  : undefined;
725
+ // Candidate IDs were captured before ranking. Their synchronous rebinds
726
+ // share one provider re-list; resyncs after refresh await re-read the store.
727
+ let preflightRows: StoredAuthCredential[] | undefined;
693
728
  await Promise.all(
694
729
  candidates.map(async candidate => {
695
730
  const force = forceRefreshIndex !== undefined && candidate.selection.index === forceRefreshIndex;
696
- const initialCredentialId = this.#deps.pool.entries(provider)[candidate.selection.index]?.id;
697
- let syncedPeerCredential = false;
698
- if (initialCredentialId !== undefined) {
699
- const beforeSync = candidate.selection.credential;
700
- if (!this.#syncOAuthSelectionFromStore(provider, candidate.selection, initialCredentialId)) return;
701
- syncedPeerCredential = !authCredentialEquals(beforeSync, candidate.selection.credential);
731
+ const initialCredentialId = credentialIds[candidate.selection.index];
732
+ if (initialCredentialId === undefined) {
733
+ preflightFailures.add(candidate);
734
+ return;
735
+ }
736
+ const beforeSync = candidate.selection.credential;
737
+ preflightRows ??= this.#reloadProviderRows(provider);
738
+ if (!this.#rebindOAuthSelection(preflightRows, candidate.selection, initialCredentialId)) {
739
+ preflightFailures.add(candidate);
740
+ return;
702
741
  }
742
+ const syncedPeerCredential = !authCredentialEquals(beforeSync, candidate.selection.credential);
703
743
  const hasFreshAccess = Date.now() + OAUTH_REFRESH_SKEW_MS < candidate.selection.credential.expires;
704
744
  if ((!force || syncedPeerCredential) && hasFreshAccess) return;
705
745
  const latestCredential = this.#deps.pool.credentials(provider)[candidate.selection.index];
@@ -785,16 +825,21 @@ export class CredentialSelector {
785
825
  // to a sibling account with the wrong prefetched usage/plan.
786
826
  return;
787
827
  }
788
- } else if (credentialId !== undefined) {
789
- const latestIndex = this.#deps.pool.entries(provider).findIndex(entry => entry.id === credentialId);
790
- if (latestIndex !== -1) {
791
- this.#deps.blocks.mark(
792
- provider,
793
- providerKey,
794
- latestIndex,
795
- Date.now() + OAUTH_REFRESH_FAILURE_BACKOFF_MS,
796
- AUTH_BLOCK_SCOPE,
797
- );
828
+ } else {
829
+ recordTransientRefreshFailure(error);
830
+ if (credentialId !== undefined) {
831
+ const latestIndex = this.#deps.pool
832
+ .entries(provider)
833
+ .findIndex(entry => entry.id === credentialId);
834
+ if (latestIndex !== -1) {
835
+ this.#deps.blocks.mark(
836
+ provider,
837
+ providerKey,
838
+ latestIndex,
839
+ Date.now() + OAUTH_REFRESH_FAILURE_BACKOFF_MS,
840
+ AUTH_BLOCK_SCOPE,
841
+ );
842
+ }
798
843
  }
799
844
  }
800
845
  preflightFailures.add(candidate);
@@ -850,12 +895,18 @@ export class CredentialSelector {
850
895
  ];
851
896
  if (enforcePlanRequirement) passes.push({ allowBlocked: true, enforcePlanRequirement: false, enforceAccounts });
852
897
  if (enforceAccounts) passes.push({ allowBlocked: true, enforcePlanRequirement: false, enforceAccounts: false });
898
+ // Blocked candidates rank earliest-unblock first, which would route a
899
+ // blocked explicit pin to an equally blocked sibling. Once the strict
900
+ // pass finds no unblocked account, the user's pin goes first.
901
+ const lastResortCandidates = explicitPin
902
+ ? [explicitPin, ...candidates.filter(candidate => candidate !== explicitPin)]
903
+ : candidates;
853
904
 
854
905
  for (const pass of passes) {
855
- for (const candidate of candidates) {
906
+ for (const candidate of pass.allowBlocked ? lastResortCandidates : candidates) {
856
907
  if (preflightFailures.has(candidate)) continue;
857
- const candidateAccountId = candidate.selection.credential.accountId;
858
- if (pass.enforceAccounts && (candidateAccountId === undefined || !accountIds?.has(candidateAccountId)))
908
+ const candidateAccountKey = oauthAccountKey(candidate.selection.credential);
909
+ if (pass.enforceAccounts && (candidateAccountKey === undefined || !accountIds?.has(candidateAccountKey)))
859
910
  continue;
860
911
  const resolved = await this.tryOAuth(provider, candidate.selection, providerKey, sessionId, options, {
861
912
  checkUsage,
@@ -868,11 +919,15 @@ export class CredentialSelector {
868
919
  rankingContext,
869
920
  blockScope,
870
921
  blockScopes,
922
+ onTransientRefreshFailure: recordTransientRefreshFailure,
871
923
  });
872
924
  if (resolved) return resolved;
873
925
  }
874
926
  }
875
927
 
928
+ if (transientRefreshFailure !== undefined) {
929
+ throw new AIError.OAuthRefreshUnavailableError(provider, transientRefreshFailure);
930
+ }
876
931
  return undefined;
877
932
  }
878
933
 
@@ -881,11 +936,25 @@ export class CredentialSelector {
881
936
  selection: { credential: OAuthCredential; index: number },
882
937
  credentialId: number,
883
938
  ): boolean {
939
+ return this.#rebindOAuthSelection(this.#reloadProviderRows(provider), selection, credentialId);
940
+ }
941
+
942
+ /** Re-list one provider's active rows and adopt them in the pool. */
943
+ #reloadProviderRows(provider: string): StoredAuthCredential[] {
884
944
  const latestRows = this.#deps.store.listAuthCredentials(provider);
885
945
  this.#deps.pool.replace(
886
946
  provider,
887
947
  latestRows.map(row => ({ id: row.id, credential: row.credential })),
888
948
  );
949
+ return latestRows;
950
+ }
951
+
952
+ /** Point `selection` at row `credentialId` in `latestRows`; false when it is gone or no longer OAuth. */
953
+ #rebindOAuthSelection(
954
+ latestRows: readonly StoredAuthCredential[],
955
+ selection: { credential: OAuthCredential; index: number },
956
+ credentialId: number,
957
+ ): boolean {
889
958
  const latestIndex = latestRows.findIndex(row => row.id === credentialId);
890
959
  if (latestIndex === -1) return false;
891
960
  const latest = latestRows[latestIndex];
@@ -1055,7 +1124,9 @@ export class CredentialSelector {
1055
1124
  }
1056
1125
  }
1057
1126
  this.#deps.pool.noteBearer(provider, result.apiKey, credentialId);
1058
- this.#deps.affinity.record(provider, sessionId, "oauth", selection.index);
1127
+ if (options?.recordAffinity !== false) {
1128
+ this.#deps.affinity.record(provider, sessionId, "oauth", selection.index);
1129
+ }
1059
1130
  return { apiKey: result.apiKey, credential: updated, credentialId };
1060
1131
  } catch (error) {
1061
1132
  const errorMsg = String(error);
@@ -1087,6 +1158,7 @@ export class CredentialSelector {
1087
1158
  if (allowFallback) return this.resolveOAuth(provider, sessionId, options);
1088
1159
  }
1089
1160
  } else {
1161
+ usageOptions.onTransientRefreshFailure?.(error);
1090
1162
  // Block temporarily for transient failures (5 minutes)
1091
1163
  this.#deps.blocks.mark(
1092
1164
  provider,