@oh-my-pi/pi-ai 18.3.0 → 18.3.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 (45) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/THIRD-PARTY-NOTICES.txt +2 -2
  3. package/dist/types/auth/policy.d.ts +8 -1
  4. package/dist/types/auth/pool.d.ts +8 -0
  5. package/dist/types/auth/types.d.ts +17 -7
  6. package/dist/types/auth/usage.d.ts +2 -0
  7. package/dist/types/auth-broker/discover.d.ts +22 -1
  8. package/dist/types/auth-storage.d.ts +35 -11
  9. package/dist/types/error/rate-limit.d.ts +3 -2
  10. package/dist/types/index.d.ts +1 -0
  11. package/dist/types/providers/anthropic-slow-mode.d.ts +109 -0
  12. package/dist/types/providers/anthropic-wire.d.ts +4 -0
  13. package/dist/types/providers/mock.d.ts +3 -1
  14. package/dist/types/providers/openai-codex/live-steering.d.ts +77 -0
  15. package/dist/types/providers/transform-messages.d.ts +9 -1
  16. package/dist/types/types.d.ts +56 -0
  17. package/dist/types/usage/xai-oauth.d.ts +6 -1
  18. package/dist/types/utils/http-inspector.d.ts +6 -0
  19. package/package.json +6 -6
  20. package/src/auth/policy.ts +27 -6
  21. package/src/auth/pool.ts +35 -2
  22. package/src/auth/refresh.ts +2 -2
  23. package/src/auth/types.ts +17 -7
  24. package/src/auth/usage.ts +5 -0
  25. package/src/auth-broker/discover.ts +57 -27
  26. package/src/auth-storage.ts +121 -35
  27. package/src/error/flags.ts +2 -1
  28. package/src/error/rate-limit.ts +8 -4
  29. package/src/index.ts +1 -0
  30. package/src/providers/anthropic-slow-mode.ts +232 -0
  31. package/src/providers/anthropic-wire.ts +4 -0
  32. package/src/providers/anthropic.ts +322 -22
  33. package/src/providers/cowork-fetch.ts +11 -4
  34. package/src/providers/google-shared.ts +30 -7
  35. package/src/providers/inference-headers.ts +7 -1
  36. package/src/providers/mock.ts +4 -0
  37. package/src/providers/openai-codex/live-steering.ts +237 -0
  38. package/src/providers/openai-codex-responses.ts +372 -73
  39. package/src/providers/transform-messages.ts +27 -7
  40. package/src/stream.ts +32 -2
  41. package/src/types.ts +59 -0
  42. package/src/usage/registry.ts +2 -1
  43. package/src/usage/xai-oauth.ts +31 -1
  44. package/src/utils/http-inspector.ts +21 -2
  45. package/src/utils/openrouter-headers.ts +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.3.0",
3
+ "version": "18.3.2",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.3.0",
159
- "@oh-my-pi/pi-catalog": "18.3.0",
160
- "@oh-my-pi/pi-natives": "18.3.0",
161
- "@oh-my-pi/pi-utils": "18.3.0",
162
- "@oh-my-pi/pi-wire": "18.3.0"
158
+ "@oh-my-pi/omptype": "18.3.2",
159
+ "@oh-my-pi/pi-catalog": "18.3.2",
160
+ "@oh-my-pi/pi-natives": "18.3.2",
161
+ "@oh-my-pi/pi-utils": "18.3.2",
162
+ "@oh-my-pi/pi-wire": "18.3.2"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -22,20 +22,41 @@ export function matchesAuthAccountSelector(selector: AuthAccountSelector, identi
22
22
  /** Validated per-account routing policies (priority/reserve) plus the global reserve fallback. */
23
23
  export class AccountPolicies {
24
24
  #accountPolicies: AuthAccountPolicies;
25
- readonly defaultReservePct: number;
25
+ #defaultReservePct: number;
26
26
 
27
27
  constructor(policies: AuthAccountPolicies, defaultReservePct: number | undefined) {
28
+ AccountPolicies.#validateAccountPolicyConfiguration(policies);
28
29
  this.#accountPolicies = policies;
29
- this.defaultReservePct =
30
+ this.#defaultReservePct =
30
31
  typeof defaultReservePct === "number" && Number.isFinite(defaultReservePct)
31
32
  ? Math.max(0, Math.min(100, defaultReservePct))
32
33
  : DEFAULT_USAGE_RESERVE_PCT;
33
- this.#validateAccountPolicyConfiguration();
34
34
  }
35
35
 
36
- #validateAccountPolicyConfiguration(): void {
37
- for (let index = 0; index < this.#accountPolicies.length; index += 1) {
38
- const policy = this.#accountPolicies[index]!;
36
+ /** Global usage reserve (0–100) for accounts without a per-account `reservePct`. */
37
+ get defaultReservePct(): number {
38
+ return this.#defaultReservePct;
39
+ }
40
+
41
+ /**
42
+ * Replace the policy set and global reserve in place (live settings change).
43
+ * Validates the configuration and every provider in `storedCredentials` before
44
+ * committing; on error the previous policies stay active.
45
+ */
46
+ replace(
47
+ policies: AuthAccountPolicies,
48
+ defaultReservePct: number | undefined,
49
+ storedCredentials: ReadonlyMap<string, readonly AuthCredential[]> = new Map(),
50
+ ): void {
51
+ const next = new AccountPolicies(policies, defaultReservePct);
52
+ for (const [provider, credentials] of storedCredentials) next.validateFor(provider, credentials);
53
+ this.#accountPolicies = next.#accountPolicies;
54
+ this.#defaultReservePct = next.#defaultReservePct;
55
+ }
56
+
57
+ static #validateAccountPolicyConfiguration(accountPolicies: AuthAccountPolicies): void {
58
+ for (let index = 0; index < accountPolicies.length; index += 1) {
59
+ const policy = accountPolicies[index]!;
39
60
  const path = `auth.accountPolicies[${index}]`;
40
61
  if (
41
62
  typeof policy.provider !== "string" ||
package/src/auth/pool.ts CHANGED
@@ -31,6 +31,23 @@ function fingerprintOAuthBearer(bearer: string): string {
31
31
  /** One stored credential row as cached in memory. */
32
32
  export type StoredCredential = { id: number; credential: AuthCredential };
33
33
 
34
+ /** {@link CredentialDisabledEvent} for a torn-down row, carrying the account identity it was signed in as. */
35
+ export function credentialDisabledEvent(
36
+ provider: string,
37
+ row: StoredCredential,
38
+ disabledCause: string,
39
+ ): CredentialDisabledEvent {
40
+ const event: CredentialDisabledEvent = { provider, disabledCause, credentialId: row.id };
41
+ const { credential } = row;
42
+ if (credential.type === "oauth") {
43
+ if (credential.email) event.email = credential.email;
44
+ if (credential.accountId) event.accountId = credential.accountId;
45
+ if (credential.orgId) event.orgId = credential.orgId;
46
+ if (credential.orgName) event.orgName = credential.orgName;
47
+ }
48
+ return event;
49
+ }
50
+
34
51
  /** Credential equality used for snapshot change detection. */
35
52
  export function authCredentialEquals(left: AuthCredential, right: AuthCredential): boolean {
36
53
  if (left.type !== right.type) return false;
@@ -189,6 +206,19 @@ export class CredentialPool implements CredentialsApi {
189
206
  }
190
207
  }
191
208
 
209
+ /**
210
+ * Take over the subscribers, buffered disable events, and generation counter of
211
+ * the pool this one replaces (store swap). Listener sets are shared, so
212
+ * unsubscribe functions handed out by `previous` keep working.
213
+ */
214
+ adoptSubscribers(previous: CredentialPool): void {
215
+ this.#credentialDisabledListeners = previous.#credentialDisabledListeners;
216
+ this.#generationListeners = previous.#generationListeners;
217
+ this.#pendingDisabledEvents = previous.#pendingDisabledEvents;
218
+ previous.#pendingDisabledEvents = [];
219
+ this.#generation = previous.#generation;
220
+ }
221
+
192
222
  onGeneration(listener: (generation: number) => void): () => void {
193
223
  this.#generationListeners.add(listener);
194
224
  return () => {
@@ -430,7 +460,7 @@ export class CredentialPool implements CredentialsApi {
430
460
  const updated = entries.filter((_value, idx) => idx !== index);
431
461
  this.replace(provider, updated);
432
462
  this.reset(provider);
433
- this.emitDisabled({ provider, disabledCause });
463
+ this.emitDisabled(credentialDisabledEvent(provider, target, disabledCause));
434
464
  return true;
435
465
  }
436
466
 
@@ -481,6 +511,9 @@ export class CredentialPool implements CredentialsApi {
481
511
  }
482
512
 
483
513
  emitDisabled(event: CredentialDisabledEvent): void {
514
+ // Every automatic disable leaves a log line, with or without subscribers: the event
515
+ // alone left nothing on disk once a session had moved on to a sibling account.
516
+ logger.warn("Auth credential disabled", { ...event });
484
517
  if (this.#credentialDisabledListeners.size === 0) {
485
518
  // No subscribers — buffer for later replay. Cap the backlog so a process that runs
486
519
  // without subscribers for a long time can't grow memory unboundedly; drop oldest
@@ -721,7 +754,7 @@ export class CredentialPool implements CredentialsApi {
721
754
  const next = entries.filter((_value, idx) => idx !== index);
722
755
  this.replace(provider, next);
723
756
  this.reset(provider);
724
- this.emitDisabled({ provider, disabledCause });
757
+ this.emitDisabled(credentialDisabledEvent(provider, entries[index]!, disabledCause));
725
758
  return true;
726
759
  }
727
760
  return false;
@@ -4,7 +4,7 @@ import { getOAuthProvider, normalizeOAuthCredentialExpiry, refreshOAuthToken } f
4
4
  import type { OAuthCredentials, OAuthProvider } from "../registry/oauth/types";
5
5
  import type { Provider } from "../types";
6
6
  import { raceSignal } from "./abort";
7
- import { authCredentialEquals, type CredentialPool } from "./pool";
7
+ import { authCredentialEquals, type CredentialPool, credentialDisabledEvent } from "./pool";
8
8
  import type { AccountPolicies } from "./policy";
9
9
  import { resolveCredentialIdentityKey, serializeCredential } from "./sqlite-credential-store";
10
10
  import { hasRefreshLeases, type AuthCredentialStore } from "./store";
@@ -226,7 +226,7 @@ export class OAuthRefresher {
226
226
  })),
227
227
  );
228
228
  this.#deps.pool.reset(provider);
229
- this.#deps.pool.emitDisabled({ provider, disabledCause });
229
+ this.#deps.pool.emitDisabled(credentialDisabledEvent(provider, row, disabledCause));
230
230
  return { credential: undefined, refreshed: false, removed: true };
231
231
  }
232
232
  await this.#deps.pool.reload();
package/src/auth/types.ts CHANGED
@@ -300,9 +300,11 @@ export interface AuthCredentialSnapshot {
300
300
  /**
301
301
  * Event payload describing a credential that was just soft-disabled.
302
302
  *
303
- * Today the only call site is OAuth refresh failures with a definitive cause
304
- * (`invalid_grant`, `401/403` not from a network blip, etc.) — the
305
- * disabled_cause string is the verbatim error captured for forensics.
303
+ * Fired for automatic disables: a definitive OAuth refresh failure
304
+ * (`invalid_grant`, `401/403` not from a network blip, etc.), an upstream
305
+ * token invalidation, and an auth-broker disable. The disabled_cause string is
306
+ * the verbatim error captured for forensics. Every emission is also logged as
307
+ * a warning.
306
308
  *
307
309
  * Subscribers can use this to surface a notification, banner, or auto-launch
308
310
  * a re-login flow instead of letting the credential silently disappear.
@@ -310,6 +312,14 @@ export interface AuthCredentialSnapshot {
310
312
  export interface CredentialDisabledEvent {
311
313
  provider: string;
312
314
  disabledCause: string;
315
+ /** Database row id of the disabled credential (matches {@link StoredAuthCredential.id}). */
316
+ credentialId?: number;
317
+ /** Account identity recorded on the disabled OAuth credential, when the provider supplied one. */
318
+ email?: string;
319
+ accountId?: string;
320
+ /** Organization/workspace the credential was scoped to (Anthropic/ChatGPT multi-subscription). */
321
+ orgId?: string;
322
+ orgName?: string;
313
323
  }
314
324
 
315
325
  /** Configuration supplied when constructing credential storage. */
@@ -330,10 +340,10 @@ export type AuthStorageOptions = {
330
340
  configValueResolver?: (config: string) => Promise<string | undefined>;
331
341
  /**
332
342
  * Optional callback fired when AuthStorage automatically disables a
333
- * credential because something detected it as no longer usable — today
334
- * that's the OAuth refresh-failure path in `getApiKey`. NOT fired for
335
- * user-initiated `remove()` (the user already knows) or dedup of
336
- * duplicate credentials (uninteresting hygiene).
343
+ * credential because something detected it as no longer usable (see
344
+ * {@link CredentialDisabledEvent}). NOT fired for user-initiated `remove()`
345
+ * (the user already knows) or dedup of duplicate credentials
346
+ * (uninteresting hygiene).
337
347
  */
338
348
  onCredentialDisabled?: (event: CredentialDisabledEvent) => void | Promise<void>;
339
349
  /**
package/src/auth/usage.ts CHANGED
@@ -151,6 +151,11 @@ export class UsageService implements UsageApi {
151
151
  });
152
152
  this.#deps.cache.invalidateForProvider(provider);
153
153
  }
154
+ /** Carry runtime usage provider overrides over from the service this one replaces (store swap). */
155
+ adoptRuntimeProviders(previous: UsageService): void {
156
+ this.#runtimeUsageProviderOverrides = previous.#runtimeUsageProviderOverrides;
157
+ }
158
+
154
159
  /** Remove a runtime usage provider override and restore configured/default resolution. */
155
160
  removeProvider(provider: Provider): void {
156
161
  if (!this.#runtimeUsageProviderOverrides.has(provider)) return;
@@ -19,9 +19,11 @@ import {
19
19
  import { YAML } from "bun";
20
20
  import {
21
21
  type AuthAccountPolicies,
22
+ type AuthCredentialStore,
22
23
  AuthStorage,
23
24
  type AuthStorageOptions,
24
25
  DEFAULT_USAGE_RESERVE_PCT,
26
+ SqliteAuthCredentialStore,
25
27
  } from "../auth-storage";
26
28
  import * as AIError from "../error";
27
29
  import { AuthBrokerClient, AuthBrokerError } from "./client";
@@ -363,23 +365,32 @@ export async function resolveAuthBrokerConfig(
363
365
  return { url, token };
364
366
  }
365
367
 
368
+ export interface OpenAuthCredentialStoreOptions {
369
+ /** Broker to connect to; `null` opens the local SQLite store under `agentDir`. */
370
+ brokerConfig: AuthBrokerClientConfig | null;
371
+ agentDir?: string;
372
+ cachePath?: string;
373
+ sourceLabel?: string;
374
+ /** Programmatic pool for SDK hosts. Takes precedence over the environment file. */
375
+ accountPool?: AuthBrokerAccountPool;
376
+ }
377
+
378
+ /** Credential store opened by {@link openAuthCredentialStore} plus its diagnostics label. */
379
+ export interface OpenedAuthCredentialStore {
380
+ store: AuthCredentialStore;
381
+ sourceLabel: string;
382
+ }
383
+
366
384
  /**
367
- * Create an AuthStorage instance, using the broker when configured and falling
368
- * back to the local SQLite store otherwise. This is the single source of truth
369
- * for the TUI and the catalog generator.
385
+ * Open the credential store {@link discoverAuthStorage} would use for
386
+ * `brokerConfig`: the remote broker store (fails fast when the broker has no
387
+ * usable snapshot) or the local SQLite store. Also feeds
388
+ * {@link AuthStorage.replaceStore} when broker settings change at runtime.
370
389
  */
371
- export async function discoverAuthStorage(options: DiscoverAuthStorageOptions = {}): Promise<AuthStorage> {
372
- const agentDir = options.agentDir ?? getAgentDir();
373
- const brokerConfig = await resolveAuthBrokerConfig({
374
- agentDir,
375
- configValueResolver: options.configValueResolver,
376
- });
377
- const { accountPolicies, defaultReservePct } = await loadAuthAccountPolicyConfig({
378
- agentDir,
379
- accountPolicies: options.accountPolicies,
380
- usageReservePct: options.authStorageOptions?.defaultReservePct,
381
- });
382
-
390
+ export async function openAuthCredentialStore(
391
+ options: OpenAuthCredentialStoreOptions,
392
+ ): Promise<OpenedAuthCredentialStore> {
393
+ const brokerConfig = options.brokerConfig;
383
394
  if (brokerConfig) {
384
395
  const accountPool = options.accountPool ?? (await loadAuthBrokerAccountPool());
385
396
  const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token });
@@ -437,22 +448,41 @@ export async function discoverAuthStorage(options: DiscoverAuthStorageOptions =
437
448
  onSnapshot: persist,
438
449
  accountPool,
439
450
  });
440
- const storage = new AuthStorage(store, {
441
- ...options.authStorageOptions,
442
- configValueResolver: options.configValueResolver,
443
- sourceLabel: options.sourceLabel ?? `broker ${brokerConfig.url}`,
444
- accountPolicies,
445
- defaultReservePct,
446
- });
447
- await storage.credentials.reload();
448
- return storage;
451
+ return { store, sourceLabel: options.sourceLabel ?? `broker ${brokerConfig.url}` };
449
452
  }
450
453
 
451
- const dbPath = getAgentDbPath(agentDir);
452
- const storage = await AuthStorage.create(dbPath, {
454
+ const dbPath = getAgentDbPath(options.agentDir ?? getAgentDir());
455
+ const store = await SqliteAuthCredentialStore.open(dbPath);
456
+ return { store, sourceLabel: options.sourceLabel ?? `local ${dbPath}` };
457
+ }
458
+
459
+ /**
460
+ * Create an AuthStorage instance, using the broker when configured and falling
461
+ * back to the local SQLite store otherwise. This is the single source of truth
462
+ * for the TUI and the catalog generator.
463
+ */
464
+ export async function discoverAuthStorage(options: DiscoverAuthStorageOptions = {}): Promise<AuthStorage> {
465
+ const agentDir = options.agentDir ?? getAgentDir();
466
+ const brokerConfig = await resolveAuthBrokerConfig({
467
+ agentDir,
468
+ configValueResolver: options.configValueResolver,
469
+ });
470
+ const { accountPolicies, defaultReservePct } = await loadAuthAccountPolicyConfig({
471
+ agentDir,
472
+ accountPolicies: options.accountPolicies,
473
+ usageReservePct: options.authStorageOptions?.defaultReservePct,
474
+ });
475
+ const { store, sourceLabel } = await openAuthCredentialStore({
476
+ brokerConfig,
477
+ agentDir,
478
+ cachePath: options.cachePath,
479
+ sourceLabel: options.sourceLabel,
480
+ accountPool: options.accountPool,
481
+ });
482
+ const storage = new AuthStorage(store, {
453
483
  ...options.authStorageOptions,
454
484
  configValueResolver: options.configValueResolver,
455
- sourceLabel: options.sourceLabel ?? `local ${dbPath}`,
485
+ sourceLabel,
456
486
  accountPolicies,
457
487
  defaultReservePct,
458
488
  });
@@ -33,7 +33,9 @@ import { CredentialSelector } from "./auth/select";
33
33
  import { SqliteAuthCredentialStore } from "./auth/sqlite-credential-store";
34
34
  import type { AuthCredentialStore } from "./auth/store";
35
35
  import type {
36
+ AuthAccountPolicies,
36
37
  AuthApiKeyOptions,
38
+ AuthCredential,
37
39
  AuthStorageOptions,
38
40
  BlocksApi,
39
41
  CredentialsApi,
@@ -54,36 +56,120 @@ export { isSqliteBusyError, isSqliteCorruptionError, SqliteAuthCredentialStore }
54
56
  export * from "./auth/store";
55
57
  export * from "./auth/types";
56
58
 
59
+ /** Store-bound credential modules; rebuilt as a unit by {@link AuthStorage.replaceStore}. */
60
+ interface AuthStorageModules {
61
+ pool: CredentialPool;
62
+ keys: KeyCascade;
63
+ oauth: OAuthAccounts;
64
+ sessions: SessionAffinity;
65
+ usage: UsageService;
66
+ health: CredentialHealth;
67
+ limits: RateLimits;
68
+ resets: ResetCredits;
69
+ blocks: CredentialBlocks;
70
+ }
71
+
57
72
  /**
58
73
  * Credential management over an {@link AuthCredentialStore}: multi-account
59
74
  * selection with usage-aware ranking, rate-limit blocks, OAuth refresh, and
60
75
  * usage reporting. See the module doc for the namespace layout.
76
+ *
77
+ * Namespaces resolve against the current store on every access, so holders of
78
+ * this instance follow {@link AuthStorage.replaceStore} without re-wiring.
61
79
  */
62
80
  export class AuthStorage {
81
+ readonly #options: AuthStorageOptions;
82
+ readonly #overrides: KeyOverrides;
83
+ readonly #policies: AccountPolicies;
84
+ #modules: AuthStorageModules;
85
+
86
+ constructor(store: AuthCredentialStore, options: AuthStorageOptions = {}) {
87
+ this.#options = options;
88
+ this.#overrides = new KeyOverrides(options.configValueResolver);
89
+ this.#policies = new AccountPolicies(options.accountPolicies ?? [], options.defaultReservePct);
90
+ this.#modules = this.#compose(store, options.sourceLabel);
91
+ if (options.onCredentialDisabled) this.#modules.pool.onDisabled(options.onCredentialDisabled);
92
+ }
93
+
63
94
  /** Stored credential rows, change/disable events, broker snapshot. */
64
- readonly credentials: CredentialsApi;
95
+ get credentials(): CredentialsApi {
96
+ return this.#modules.pool;
97
+ }
65
98
  /** Provider auth cascade and key overrides. */
66
- readonly keys: KeysApi;
99
+ get keys(): KeysApi {
100
+ return this.#modules.keys;
101
+ }
67
102
  /** OAuth login, account access, listings, refresh. */
68
- readonly oauth: OAuthApi;
103
+ get oauth(): OAuthApi {
104
+ return this.#modules.oauth;
105
+ }
69
106
  /** Session → account pins. */
70
- readonly sessions: SessionsApi;
107
+ get sessions(): SessionsApi {
108
+ return this.#modules.sessions;
109
+ }
71
110
  /** Usage reports, header ingestion, history. */
72
- readonly usage: UsageApi;
111
+ get usage(): UsageApi {
112
+ return this.#modules.usage;
113
+ }
73
114
  /** Model pool health and per-credential probes. */
74
- readonly health: HealthApi;
115
+ get health(): HealthApi {
116
+ return this.#modules.health;
117
+ }
75
118
  /** Usage-limit marking and credential rotation. */
76
- readonly limits: LimitsApi;
119
+ get limits(): LimitsApi {
120
+ return this.#modules.limits;
121
+ }
77
122
  /** Saved rate-limit resets. */
78
- readonly resets: ResetsApi;
123
+ get resets(): ResetsApi {
124
+ return this.#modules.resets;
125
+ }
79
126
  /** Persisted rate-limit blocks (auth-broker server seam). */
80
- readonly blocks: BlocksApi;
81
- #pool: CredentialPool;
127
+ get blocks(): BlocksApi {
128
+ return this.#modules.blocks;
129
+ }
82
130
 
83
- constructor(store: AuthCredentialStore, options: AuthStorageOptions = {}) {
84
- const overrides = new KeyOverrides(options.configValueResolver);
85
- const policies = new AccountPolicies(options.accountPolicies ?? [], options.defaultReservePct);
86
- const blockHealth = new BlockStoreHealth(options.sourceLabel);
131
+ /**
132
+ * Apply new account routing policy (live `auth.accountPolicies` /
133
+ * `retry.usageReservePct` change). Throws a configuration error, leaving the
134
+ * active policy untouched, when the policy is malformed or does not match the
135
+ * stored OAuth accounts.
136
+ */
137
+ setAccountPolicies(config: { accountPolicies: AuthAccountPolicies; defaultReservePct: number }): void {
138
+ const pool = this.#modules.pool;
139
+ const stored = new Map<string, AuthCredential[]>();
140
+ for (const provider of pool.providers()) stored.set(provider, pool.credentials(provider));
141
+ this.#policies.replace(config.accountPolicies, config.defaultReservePct, stored);
142
+ }
143
+
144
+ /**
145
+ * Swap the backing credential store in place (live `auth.broker.url` change).
146
+ * Loads `store` into fresh store-bound state — pins, blocks, and usage caches are
147
+ * keyed by the old store's row ids — then closes the previous store. Runtime key
148
+ * overrides, account policies, usage-provider overrides, and credential event
149
+ * subscribers carry over. On a load failure `store` is closed and the current
150
+ * store stays active.
151
+ */
152
+ async replaceStore(store: AuthCredentialStore, options: { sourceLabel?: string } = {}): Promise<void> {
153
+ const next = this.#compose(store, options.sourceLabel ?? this.#options.sourceLabel);
154
+ try {
155
+ await next.pool.reload();
156
+ } catch (error) {
157
+ next.pool.close();
158
+ throw error;
159
+ }
160
+ const previous = this.#modules;
161
+ next.pool.adoptSubscribers(previous.pool);
162
+ next.usage.adoptRuntimeProviders(previous.usage);
163
+ this.#modules = next;
164
+ previous.pool.close();
165
+ next.pool.bump("store-replaced");
166
+ }
167
+
168
+ #compose(store: AuthCredentialStore, sourceLabel: string | undefined): AuthStorageModules {
169
+ const options = this.#options;
170
+ const overrides = this.#overrides;
171
+ const policies = this.#policies;
172
+ const blockHealth = new BlockStoreHealth(sourceLabel);
87
173
  const strategies = options.rankingStrategyResolver ?? defaultRankingStrategy;
88
174
  const pool = new CredentialPool(store, {
89
175
  policies,
@@ -134,32 +220,32 @@ export class AuthStorage {
134
220
  selector,
135
221
  affinity,
136
222
  rotate: (provider, sessionId, rotateOptions) => limits.rotate(provider, sessionId, rotateOptions),
137
- sourceLabel: options.sourceLabel,
223
+ sourceLabel,
138
224
  });
139
225
  const oauth = new OAuthAccounts({ pool, overrides, policies, selector, affinity, refresher });
140
226
 
141
- this.#pool = pool;
142
- this.credentials = pool;
143
- this.keys = keys;
144
- this.oauth = oauth;
145
- this.sessions = affinity;
146
- this.usage = usage;
147
- this.health = new CredentialHealth({
148
- store,
227
+ return {
149
228
  pool,
150
229
  keys,
151
- policies,
152
- blocks,
153
- affinity,
230
+ oauth,
231
+ sessions: affinity,
154
232
  usage,
155
- refresher,
156
- overrides,
157
- strategies,
158
- });
159
- this.limits = limits;
160
- this.resets = new ResetCredits({ store, pool, oauth, usage, usageCache, blocks });
161
- this.blocks = blocks;
162
- if (options.onCredentialDisabled) pool.onDisabled(options.onCredentialDisabled);
233
+ health: new CredentialHealth({
234
+ store,
235
+ pool,
236
+ keys,
237
+ policies,
238
+ blocks,
239
+ affinity,
240
+ usage,
241
+ refresher,
242
+ overrides,
243
+ strategies,
244
+ }),
245
+ limits,
246
+ resets: new ResetCredits({ store, pool, oauth, usage, usageCache, blocks }),
247
+ blocks,
248
+ };
163
249
  }
164
250
 
165
251
  /** Open the SQLite store at `dbPath` and wrap it (standalone use, e.g. the pi-ai CLI). */
@@ -170,7 +256,7 @@ export class AuthStorage {
170
256
 
171
257
  /** Close the underlying credential store; the instance must not be reused. */
172
258
  close(): void {
173
- this.#pool.close();
259
+ this.#modules.pool.close();
174
260
  }
175
261
 
176
262
  /**
@@ -659,7 +659,8 @@ export function classify(error: unknown, api?: Api): number {
659
659
  code === "usage_limit_reached" ||
660
660
  (code === "insufficient_quota" && !isDashScopeTokenLimitText(link.message)) ||
661
661
  (codeStatus === 402 &&
662
- (code === "payment_required" || code === "deactivated_workspace" || is402BillingCapBody(link.message)))
662
+ (is402BillingCapBody(link.message) ||
663
+ (code !== undefined && !isOpaqueStatusBody(code) && is402BillingCapBody(code))))
663
664
  ) {
664
665
  linkKinds |= Flag.UsageLimit;
665
666
  }
@@ -37,7 +37,10 @@ const CREDITS_EXHAUSTED_PATTERN =
37
37
  // in unrelated diagnostics ("Failed to fetch usage credits from billing
38
38
  // service"), which must not rotate a healthy credential.
39
39
  const ANTHROPIC_CREDITS_REQUIRED_PATTERN = /\busage credits are required\b|\bcredits_required\b/i;
40
- const SPEND_LIMIT_PATTERN = /spend.?limit/i;
40
+ // Account billing ceilings: Anthropic "monthly spend limit" (#4787) and Google
41
+ // "Your project has exceeded its monthly spending cap" (#13090). The `\b` after
42
+ // `cap` keeps "spending capacity" — a throttle, not a billing ceiling — out.
43
+ const SPEND_LIMIT_PATTERN = /spend(?:ing)?[\s_-]?(?:limit|cap)\b/i;
41
44
  const SUBSCRIPTION_CAP_PATTERN =
42
45
  /\b(?:subscription|plan|membership)\b[^\n]{0,80}\b(?:rate.?limits?|quota|cap)\b|\b(?:rate.?limits?|quota|cap)\b[^\n]{0,80}\b(?:subscription|plan|membership)\b/i;
43
46
  const TRANSIENT_INTERVAL_RATE_LIMIT_PATTERN = /\bper\s+(?:second|minute)\b/i;
@@ -313,8 +316,9 @@ const USAGE_LIMIT_PATTERN =
313
316
  * account-local usage cap rather than a bad credential or a transient blip.
314
317
  * HTTP 402 Payment Required represents an account-billing cap (xAI
315
318
  * Grok Build "usage balance exhausted", DeepSeek "Insufficient Balance",
316
- * OpenRouter credit exhaustion) when opaque, payment/deactivation/balance-worded,
317
- * or QUOTA_EXHAUSTED/CONCURRENT_LIMIT, while informative non-quota 402s (e.g.
319
+ * OpenCode Go "Insufficient account funds", OpenRouter credit exhaustion)
320
+ * when opaque, payment/deactivation/balance/funds-worded, or
321
+ * QUOTA_EXHAUSTED/CONCURRENT_LIMIT. Informative non-quota 402s (e.g.
318
322
  * endpoint subscription requirements) remain non-usage-limits. Always combine
319
323
  * with {@link isUsageLimitOutcome} when a message is available.
320
324
  */
@@ -322,7 +326,7 @@ export function isUsageLimitStatus(status: number | undefined): boolean {
322
326
  return status === 429 || status === 402;
323
327
  }
324
328
  const STATUS_402_QUOTA_PATTERN =
325
- /\b(?:payment(?:\s+is)?[-_.\s]*required|deactivated_workspace|insufficient.?balance)\b/i;
329
+ /\b(?:payment(?:\s+is)?[-_.\s]*required|deactivated_workspace|insufficient.?(?:balance|account.?funds))\b/i;
326
330
 
327
331
  export function is402BillingCapBody(message: string | undefined): boolean {
328
332
  if (message === undefined || isOpaqueStatusBody(message)) return true;
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ export * from "./providers/anthropic-identity";
22
22
  export * from "./providers/anthropic-state";
23
23
  export type * from "./providers/anthropic-client";
24
24
  export * from "./providers/anthropic-user-profiles";
25
+ export * from "./providers/anthropic-slow-mode";
25
26
  export type * from "./providers/apple-foundation-models";
26
27
  export type * from "./providers/azure-openai-responses";
27
28
  export type * from "./providers/cursor";