@oh-my-pi/pi-ai 18.2.0 → 18.2.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 (75) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +2 -0
  3. package/dist/types/auth/sqlite-credential-store.d.ts +2 -1
  4. package/dist/types/auth-broker/remote-store.d.ts +17 -0
  5. package/dist/types/auth-gateway/index.d.ts +1 -0
  6. package/dist/types/auth-gateway/session-state.d.ts +118 -0
  7. package/dist/types/auth-storage.d.ts +17 -0
  8. package/dist/types/error/body-error.d.ts +15 -0
  9. package/dist/types/error/flags.d.ts +16 -0
  10. package/dist/types/error/index.d.ts +1 -0
  11. package/dist/types/index.d.ts +1 -0
  12. package/dist/types/oneshot-retry.d.ts +6 -0
  13. package/dist/types/provider-session-state.d.ts +46 -0
  14. package/dist/types/providers/amazon-bedrock.d.ts +3 -0
  15. package/dist/types/providers/aws-sigv4.d.ts +12 -0
  16. package/dist/types/providers/openai-codex/request-transformer.d.ts +27 -0
  17. package/dist/types/providers/openai-responses.d.ts +15 -0
  18. package/dist/types/providers/openai-shared.d.ts +20 -3
  19. package/dist/types/registry/oauth/perplexity.d.ts +1 -7
  20. package/dist/types/registry/oauth/types.d.ts +8 -0
  21. package/dist/types/stream.d.ts +2 -0
  22. package/dist/types/types.d.ts +3 -1
  23. package/dist/types/usage/openai-codex.d.ts +3 -1
  24. package/dist/types/usage.d.ts +11 -1
  25. package/dist/types/utils/block-symbols.d.ts +36 -0
  26. package/dist/types/utils/openai-http.d.ts +2 -0
  27. package/dist/types/utils/retry-after.d.ts +2 -0
  28. package/dist/types/utils/schema/wire.d.ts +4 -5
  29. package/dist/types/utils.d.ts +9 -0
  30. package/package.json +6 -6
  31. package/src/auth/sqlite-credential-store.ts +8 -33
  32. package/src/auth-broker/remote-store.ts +73 -8
  33. package/src/auth-broker/wire-schemas.ts +1 -0
  34. package/src/auth-gateway/index.ts +1 -0
  35. package/src/auth-gateway/server.ts +186 -74
  36. package/src/auth-gateway/session-state.ts +312 -0
  37. package/src/auth-storage.ts +146 -15
  38. package/src/error/body-error.ts +310 -0
  39. package/src/error/flags.ts +63 -13
  40. package/src/error/index.ts +1 -0
  41. package/src/error/retryable.ts +2 -0
  42. package/src/index.ts +1 -0
  43. package/src/oneshot-retry.ts +13 -3
  44. package/src/provider-session-state.ts +56 -0
  45. package/src/providers/amazon-bedrock.ts +20 -3
  46. package/src/providers/anthropic-messages-server.ts +104 -23
  47. package/src/providers/anthropic-signature.ts +5 -2
  48. package/src/providers/anthropic.ts +101 -15
  49. package/src/providers/aws-sigv4.ts +16 -5
  50. package/src/providers/cursor.ts +60 -10
  51. package/src/providers/devin.ts +82 -28
  52. package/src/providers/openai-chat-server.ts +4 -0
  53. package/src/providers/openai-codex/request-transformer.ts +36 -0
  54. package/src/providers/openai-codex-responses.ts +35 -12
  55. package/src/providers/openai-completions.ts +49 -12
  56. package/src/providers/openai-reasoning-fallback.ts +6 -6
  57. package/src/providers/openai-responses-server.ts +2 -1
  58. package/src/providers/openai-responses.ts +52 -4
  59. package/src/providers/openai-shared.ts +199 -51
  60. package/src/registry/oauth/perplexity.ts +94 -28
  61. package/src/registry/oauth/types.ts +9 -0
  62. package/src/stream.ts +23 -2
  63. package/src/types.ts +3 -0
  64. package/src/usage/claude.ts +33 -0
  65. package/src/usage/google-antigravity.ts +8 -2
  66. package/src/usage/openai-codex.ts +94 -11
  67. package/src/usage.ts +8 -1
  68. package/src/utils/block-symbols.ts +57 -0
  69. package/src/utils/http-inspector.ts +20 -0
  70. package/src/utils/openai-http.ts +39 -3
  71. package/src/utils/retry-after.ts +12 -0
  72. package/src/utils/schema/normalize.ts +3 -3
  73. package/src/utils/schema/stamps.ts +33 -45
  74. package/src/utils/schema/wire.ts +9 -7
  75. package/src/utils.ts +67 -22
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Server-owned provider session state for the auth-gateway.
3
+ *
4
+ * `SimpleStreamOptions.providerSessionState` is how a provider keeps what it
5
+ * learned about an endpoint across turns of one conversation: Anthropic's
6
+ * sticky `strictToolsDisabled` / `fastModeDisabled` /
7
+ * `replayUnsignedThinkingDisabled` flags and dropped-thinking-prefix set,
8
+ * OpenAI's strict-tools and reasoning-effort fallbacks, Codex's WebSocket and
9
+ * turn-state sessions. An in-process omp session owns that `Map` for its whole
10
+ * lifetime, so a grammar-too-large 400 or a fast-mode rejection costs one
11
+ * wasted round-trip per session rather than one per turn.
12
+ *
13
+ * The map is deliberately non-serializable — `Set`/`Map` fields, live sockets,
14
+ * a `close()` method — so `pi-native-client` strips it from the wire and
15
+ * `pi-native-server` never accepts it. Gateway clients therefore cannot bring
16
+ * their own, and without a server-side owner every containerized / robomp turn
17
+ * re-learns every lesson from a fresh upstream rejection.
18
+ *
19
+ * A plain `Map<sessionId, …>` in a long-lived server process is a leak: nothing
20
+ * ever reclaims an entry, and the entries own timers and sockets. This store is
21
+ * an LRU with a hard entry ceiling that calls `close()` on everything it drops
22
+ * and on everything it still holds at shutdown — but it only ever drops an
23
+ * entry no request is holding, because `close()` on a live entry tears down
24
+ * state an in-flight stream is still streaming through.
25
+ */
26
+
27
+ import { logger } from "@oh-my-pi/pi-utils";
28
+ import { resetAccountScopedProviderSessionState } from "../provider-session-state";
29
+ import type { Api, Context, Model, ProviderSessionState } from "../types";
30
+
31
+ /**
32
+ * Retained logical sessions. Each entry is a handful of small provider records
33
+ * plus, for Codex, a WebSocket session — cheap to keep, but not free, so the
34
+ * ceiling is what turns "one entry per session id forever" into a bounded cost.
35
+ * Eviction is least-recently-used, so the ceiling only ever drops sessions that
36
+ * have been quiet longer than the 256 most recent ones.
37
+ */
38
+ export const AUTH_GATEWAY_MAX_SESSION_STATES = 256;
39
+
40
+ /** Why an entry's provider records were closed. Logged on teardown failure. */
41
+ type SessionDisposeReason = "evict" | "shutdown";
42
+
43
+ /**
44
+ * One request's claim on a retained session.
45
+ *
46
+ * `release()` is what makes the entry evictable again, so it MUST run for every
47
+ * outcome of the request — a `finally` at the call site for the synchronous
48
+ * paths, stream completion for the streaming ones. It is idempotent, so the
49
+ * two can overlap.
50
+ */
51
+ export interface AuthGatewaySessionStateLease {
52
+ /** The map to hand to `streamSimple` as `providerSessionState`. */
53
+ readonly states: Map<string, ProviderSessionState>;
54
+ /** Reset account-scoped records if an in-request auth retry switches accounts. */
55
+ updateAccount(account: string): void;
56
+ /** Give up this request's claim. Idempotent. */
57
+ release(): void;
58
+ }
59
+
60
+ /** Everything the store needs to place one request on a retained session. */
61
+ export interface AuthGatewaySessionStateRequest {
62
+ /**
63
+ * The client's own session key (`prompt_cache_key` / `sessionId`), or
64
+ * `undefined` when it sent none — blank counts as none. A supplied key is
65
+ * authoritative: the client is telling us which conversation this is.
66
+ */
67
+ clientKey: string | undefined;
68
+ model: Model<Api>;
69
+ /**
70
+ * System prompt, tools and message history of this request. Used only when
71
+ * `clientKey` is absent, to place the request on the conversation it
72
+ * continues.
73
+ */
74
+ context: Context;
75
+ /**
76
+ * Stable identity of the account this request's credential resolved to.
77
+ * A change means the gateway switched the session to a sibling credential,
78
+ * so the account-dependent lessons in the retained map are re-probed. The
79
+ * comparison happens on acquire and whenever an in-request auth retry
80
+ * resolves a sibling credential.
81
+ */
82
+ account: string;
83
+ }
84
+
85
+ interface RetainedSession {
86
+ /** Current index key. Advances as a keyless conversation grows. */
87
+ key: string;
88
+ states: Map<string, ProviderSessionState>;
89
+ /** Account identity of the most recent request placed on this entry. */
90
+ account: string;
91
+ /** Requests currently holding this entry. Eviction never takes one of these. */
92
+ leases: number;
93
+ }
94
+
95
+ /**
96
+ * Close every provider record an evicted (or drained) session held.
97
+ *
98
+ * Anthropic's `close()` resets its sticky flags, Codex's tears down WebSockets
99
+ * and GitLab Duo's stops the server-side workflow — so dropping an entry
100
+ * without closing it leaks exactly the resources the bound exists to cap.
101
+ */
102
+ function closeSessionState(
103
+ states: Map<string, ProviderSessionState>,
104
+ sessionKey: string,
105
+ reason: SessionDisposeReason,
106
+ ): void {
107
+ for (const [providerKey, state] of states) {
108
+ try {
109
+ state.close();
110
+ } catch (error) {
111
+ // One provider's teardown must not abort the rest: a throw here
112
+ // propagates out of the eviction into whichever request happened to
113
+ // trigger it, or abandons the remainder of the shutdown drain.
114
+ logger.warn("auth-gateway provider session state close failed", {
115
+ sessionKey,
116
+ providerKey,
117
+ reason,
118
+ error: String(error),
119
+ });
120
+ }
121
+ }
122
+ states.clear();
123
+ }
124
+
125
+ /**
126
+ * Index keys this request may be placed on, most specific first.
127
+ *
128
+ * With a client key there is exactly one: the client named its conversation, so
129
+ * provider + model + that key is the identity.
130
+ *
131
+ * Without one the gateway has to infer the conversation, and the request's
132
+ * message history is the only thing that can distinguish two of them. The
133
+ * derived `sessionId` used for prefix caching and credential stickiness hashes
134
+ * the model, system prompt, tools and *first* message, which is deliberately
135
+ * prefix-shaped — two chats that open the same way share a cache bucket, which
136
+ * is a cache hit rather than a leak, and share a sticky account, which is a
137
+ * load-balancing hint. Retained provider state is neither: sharing it means one
138
+ * chat's rejection silences another chat's request, and one chat's Codex
139
+ * transport session answers another chat's turn. So provider state gets its own
140
+ * key, and only provider state: `deriveSessionId` keeps its two other jobs.
141
+ *
142
+ * The key is therefore a running hash over (provider, model, system, tools) and
143
+ * then every message, one key per message — the last of which identifies the
144
+ * exact history this request presented. Turn N+1 of a conversation extends turn
145
+ * N's history, so turn N's key is one of the earlier entries in turn N+1's
146
+ * chain: the store finds the nearest ancestor and moves that entry forward onto
147
+ * the new key. Two conversations that share an opening therefore share an entry
148
+ * only until they diverge; after that the first branch to arrive keeps the
149
+ * ancestor and the other starts clean. That is the most a stateless wire can
150
+ * tell us — before divergence the two requests are byte-identical.
151
+ *
152
+ * System prompt and tools sit in the root rather than per-message because they
153
+ * are not history: a client that re-stamps its system prompt every turn (a
154
+ * date, a cwd) starts a new lineage, exactly as it already starts a new derived
155
+ * `sessionId` today.
156
+ */
157
+ function sessionKeys(request: AuthGatewaySessionStateRequest): string[] {
158
+ const { model } = request;
159
+ const scope = `${model.provider}\u0000${model.id}`;
160
+ if (request.clientKey !== undefined) return [`c\u0000${scope}\u0000${request.clientKey}`];
161
+ const { context } = request;
162
+ // NUL separates the components so none of them can forge the boundary.
163
+ let hash = Bun.hash(
164
+ `${scope}\u0000${context.systemPrompt?.join("\n\n") ?? ""}\u0000${context.tools ? JSON.stringify(context.tools) : ""}`,
165
+ );
166
+ const keys: string[] = [];
167
+ for (const message of context.messages) {
168
+ // Role + content only: omp re-stamps `timestamp` and provider metadata on
169
+ // every parsed message, so hashing those would break the chain on turn
170
+ // two of every conversation.
171
+ hash = Bun.hash(JSON.stringify({ role: message.role, content: message.content }), hash);
172
+ keys.push(`h\u0000${scope}\u0000${hash.toString(36)}`);
173
+ }
174
+ // A request with no messages has no history to place; its root is the key.
175
+ if (keys.length === 0) return [`h\u0000${scope}\u0000${hash.toString(36)}`];
176
+ keys.reverse();
177
+ return keys;
178
+ }
179
+
180
+ /**
181
+ * Bounded per-session provider state, owned by one gateway server instance.
182
+ *
183
+ * Two gateways in the same process get separate stores, so neither can hand a
184
+ * request another gateway's learned state or close it out from under one.
185
+ *
186
+ * The recency order is this class's own (a `Map` iterates in insertion order,
187
+ * and every acquire re-inserts) rather than `LRUCache`'s, because the policy
188
+ * needs two things a general cache cannot express: an entry that a request is
189
+ * still holding must be skipped when picking a victim, and an entry must be
190
+ * able to change key — `LRUCache` disposes on every removal, which is precisely
191
+ * the `close()` we must not run here.
192
+ */
193
+ export class AuthGatewaySessionStateStore {
194
+ /** Least recently acquired first — insertion order is the LRU order. */
195
+ readonly #sessions = new Map<string, RetainedSession>();
196
+ readonly #max: number;
197
+
198
+ constructor(max: number = AUTH_GATEWAY_MAX_SESSION_STATES) {
199
+ if (!Number.isInteger(max) || max < 1) throw new TypeError("max must be a positive integer");
200
+ this.#max = max;
201
+ }
202
+
203
+ /** Retained logical sessions. */
204
+ get size(): number {
205
+ return this.#sessions.size;
206
+ }
207
+
208
+ /**
209
+ * Claim the provider-session map for one request, created on first use and
210
+ * returned by reference so provider mutations persist into the next request.
211
+ *
212
+ * Keyed by provider + model + conversation (see {@link sessionKeys}). A
213
+ * client is free to reuse one session id across models, and the coarsest
214
+ * provider entries do not separate models themselves (`openai-responses`
215
+ * keys its strict-tools / history-replay record by provider alone,
216
+ * Antigravity by a single constant), so the model belongs in the key here.
217
+ * Endpoint is deliberately absent: every provider whose learning is
218
+ * endpoint-specific already sub-keys it internally
219
+ * (`anthropic-messages:${baseUrl}\0${modelId}`,
220
+ * `openai-completions:${provider}:${baseUrl}:${modelId}`), and repeating it
221
+ * would only fragment the map. The credential is absent for the same reason
222
+ * — most of what is retained is true of the endpoint whoever calls it, and
223
+ * Codex already sub-keys its transport by account and bearer — so a
224
+ * credential switch resets the account-dependent subset instead of
225
+ * splitting the entry (see `resetAccountScopedProviderSessionState`).
226
+ *
227
+ * The returned lease MUST be released; until then the entry cannot be
228
+ * evicted.
229
+ */
230
+ acquire(request: AuthGatewaySessionStateRequest): AuthGatewaySessionStateLease {
231
+ const session = this.#claim(sessionKeys(request), request.account);
232
+ let released = false;
233
+ return {
234
+ states: session.states,
235
+ updateAccount: (account: string): void => {
236
+ if (session.account === account) return;
237
+ resetAccountScopedProviderSessionState(session.states);
238
+ session.account = account;
239
+ },
240
+ release: (): void => {
241
+ if (released) return;
242
+ released = true;
243
+ session.leases--;
244
+ // This entry may be the victim the bound has been waiting for.
245
+ if (session.leases === 0) this.#evict();
246
+ },
247
+ };
248
+ }
249
+
250
+ /** Close and drop every retained state. Called when the gateway shuts down. */
251
+ close(): void {
252
+ // The only place a leased entry is torn down: the listener is already
253
+ // down, every in-flight stream is being cancelled with it, and the
254
+ // process cannot settle while a Codex WebSocket or Duo workflow is open.
255
+ for (const session of this.#sessions.values()) closeSessionState(session.states, session.key, "shutdown");
256
+ this.#sessions.clear();
257
+ }
258
+
259
+ /**
260
+ * Resolve `keys` to an entry — reusing the nearest ancestor when a keyless
261
+ * conversation has grown — mark it most recently used, and hand the caller
262
+ * the claim. The claim is taken before the ceiling is enforced: a brand-new
263
+ * entry belongs to the request that just created it, and is not a candidate
264
+ * for making room for itself.
265
+ */
266
+ #claim(keys: readonly string[], account: string): RetainedSession {
267
+ const key = keys[0] ?? "";
268
+ for (const candidate of keys) {
269
+ const session = this.#sessions.get(candidate);
270
+ if (session === undefined) continue;
271
+ // Re-insert at the tail for recency, under this request's own key so
272
+ // the next turn of this conversation finds it as its ancestor. A
273
+ // sibling branch of the same ancestor no longer matches, which is the
274
+ // point: it gets an entry of its own.
275
+ this.#sessions.delete(candidate);
276
+ session.key = key;
277
+ this.#sessions.set(key, session);
278
+ session.leases++;
279
+ if (session.account !== account) {
280
+ resetAccountScopedProviderSessionState(session.states);
281
+ session.account = account;
282
+ }
283
+ return session;
284
+ }
285
+ const created: RetainedSession = { key, states: new Map(), account, leases: 1 };
286
+ this.#sessions.set(key, created);
287
+ this.#evict();
288
+ return created;
289
+ }
290
+
291
+ /**
292
+ * Enforce the ceiling against entries no request is holding.
293
+ *
294
+ * A long-running stream is exactly the entry LRU order would pick — it was
295
+ * acquired when the stream opened and not touched since — so blind eviction
296
+ * would `close()` the sockets and flags that stream is still using. Live
297
+ * entries are skipped instead, and the store sits above its bound until
298
+ * their requests release; the excess is therefore capped by the number of
299
+ * concurrent requests, each of which holds a client connection.
300
+ */
301
+ #evict(): void {
302
+ if (this.#sessions.size <= this.#max) return;
303
+ // Deleting during Map iteration is well-defined: the current and later
304
+ // keys stay consistent, so this walks least-recently-acquired first.
305
+ for (const session of this.#sessions.values()) {
306
+ if (this.#sessions.size <= this.#max) return;
307
+ if (session.leases > 0) continue;
308
+ this.#sessions.delete(session.key);
309
+ closeSessionState(session.states, session.key, "evict");
310
+ }
311
+ }
312
+ }
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { createHash } from "node:crypto";
11
11
  import { planRequirementFor } from "@oh-my-pi/pi-catalog/compat/behavior";
12
- import { $env, $envExact, extractRetryHint, getAgentDbPath, logger, untilAborted } from "@oh-my-pi/pi-utils";
12
+ import { $env, $envExact, getAgentDbPath, logger, untilAborted } from "@oh-my-pi/pi-utils";
13
13
  import {
14
14
  isSqliteCorruptionError,
15
15
  resolveCredentialIdentityKey,
@@ -31,6 +31,7 @@ import type {
31
31
  } from "./registry/oauth/types";
32
32
  import { AUTHENTICATED_SENTINEL } from "./registry/types";
33
33
  import { getEnvApiKey, getEnvApiKeyName } from "./stream";
34
+ import { extractProviderRetryHint } from "./utils/retry-after";
34
35
  import type { Provider } from "./types";
35
36
  import type {
36
37
  ClientUsageIdentity,
@@ -765,6 +766,11 @@ export { isDefinitiveOAuthFailure } from "./error/auth-classify";
765
766
  * the usage report reveals. Callers that wait the account out (instead of
766
767
  * rotating) must sleep until this, not the error-text hint alone.
767
768
  *
769
+ * `requestedBlockedUntilMs` (epoch ms) is this mark call's initial deadline,
770
+ * before usage-report correction and longest-wins merging. Callers use it to
771
+ * distinguish the call's replaceable heuristic from a longer merged block
772
+ * that credential selection will continue enforcing.
773
+ *
768
774
  * `priorBlockedUntilMs` (epoch ms) is the live block deadline the map already
769
775
  * stored for this credential before this call. The merged `blockedUntilMs`
770
776
  * masks a pre-existing block shorter than this call's own heuristic
@@ -790,6 +796,8 @@ export interface UsageLimitMarkResult {
790
796
  switched: boolean;
791
797
  retryAtMs?: number;
792
798
  blockedUntilMs?: number;
799
+ /** This mark call's initial deadline, before report correction and merging. */
800
+ requestedBlockedUntilMs?: number;
793
801
  priorBlockedUntilMs?: number;
794
802
  priorBlockedUntilTimed?: boolean;
795
803
  reportResetAtMs?: number;
@@ -1353,7 +1361,7 @@ export class AuthStorage {
1353
1361
  /** Tracks the last used credential per provider for a session (used for rate-limit switching). */
1354
1362
  #sessionLastCredential: Map<
1355
1363
  string,
1356
- Map<string, { type: AuthCredential["type"]; index: number; lastUsedAtMs?: number }>
1364
+ Map<string, { type: AuthCredential["type"]; index: number; credentialId?: number; lastUsedAtMs?: number }>
1357
1365
  > = new Map();
1358
1366
  /** Recent bearer fingerprints resolved for each durable OAuth row; used only for delayed usage-limit attribution. */
1359
1367
  #oauthBearerFingerprints: Map<string, Map<number, string[]>> = new Map();
@@ -1490,6 +1498,36 @@ export class AuthStorage {
1490
1498
  return true;
1491
1499
  }
1492
1500
 
1501
+ /**
1502
+ * Adopt credentials another process committed before selecting or rotating.
1503
+ *
1504
+ * The store is shared across every omp process, but the pool is an
1505
+ * in-process cache refreshed only by this process's own writes. Without
1506
+ * this a long-running session ranks a stale pool for its whole lifetime:
1507
+ * `omp auth` in another terminal is invisible, rotation reports no usable
1508
+ * sibling while a freshly added account sits unblocked in SQLite, and the
1509
+ * turn degrades to the fallback chain. The auth-broker path already polls;
1510
+ * direct-store sessions had no equivalent.
1511
+ *
1512
+ * A poll is two cheap reads (`PRAGMA data_version` plus the auth revision)
1513
+ * and re-lists credentials only when another connection committed, so it
1514
+ * runs on every resolution rather than on a timer that would make recovery
1515
+ * depend on wall-clock spacing. It sits on the paths that read the pool —
1516
+ * OAuth selection, and the two public usage-limit entry points — and is
1517
+ * idempotent, so a rotation reached through `markUsageLimitReached` costs
1518
+ * one extra `data_version` read and no second reload.
1519
+ */
1520
+ async #adoptExternalCredentialChanges(): Promise<void> {
1521
+ if (this.#closed || this.#store.pollExternalChanges === undefined) return;
1522
+ try {
1523
+ await this.pollExternalChanges();
1524
+ } catch (error) {
1525
+ // A failed poll must not fail credential resolution: the in-memory
1526
+ // pool is still serviceable, just possibly stale.
1527
+ logger.debug("External credential poll failed", { error: String(error) });
1528
+ }
1529
+ }
1530
+
1493
1531
  onGenerationChanged(listener: (generation: number) => void): () => void {
1494
1532
  this.#generationListeners.add(listener);
1495
1533
  return () => {
@@ -2084,12 +2122,12 @@ export class AuthStorage {
2084
2122
  ): void {
2085
2123
  if (!sessionId) return;
2086
2124
  const nowMs = lastUsedAtMs ?? Date.now();
2125
+ const credentialId = this.#getStoredCredentials(provider)[index]?.id;
2087
2126
  const sessionMap = this.#sessionLastCredential.get(provider) ?? new Map();
2088
- sessionMap.set(sessionId, { type, index, lastUsedAtMs: nowMs });
2127
+ sessionMap.set(sessionId, { type, index, credentialId, lastUsedAtMs: nowMs });
2089
2128
  this.#sessionLastCredential.set(provider, sessionMap);
2090
2129
 
2091
2130
  try {
2092
- const credentialId = this.#getStoredCredentials(provider)[index]?.id;
2093
2131
  if (credentialId !== undefined) {
2094
2132
  const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
2095
2133
  const cacheValue = JSON.stringify({
@@ -2111,11 +2149,24 @@ export class AuthStorage {
2111
2149
  #getSessionCredential(
2112
2150
  provider: string,
2113
2151
  sessionId: string | undefined,
2114
- ): { type: AuthCredential["type"]; index: number; lastUsedAtMs?: number } | undefined {
2152
+ ): { type: AuthCredential["type"]; index: number; credentialId?: number; lastUsedAtMs?: number } | undefined {
2115
2153
  if (!sessionId) return undefined;
2116
2154
  let sessionMap = this.#sessionLastCredential.get(provider);
2117
- if (sessionMap?.has(sessionId)) {
2118
- return sessionMap.get(sessionId);
2155
+ const live = sessionMap?.get(sessionId);
2156
+ if (live) {
2157
+ // Another process can add or drop rows mid-session and the pool is an
2158
+ // index-ordered snapshot, so re-resolve the pin through its durable row
2159
+ // id: a compacted array must not point the session at a different
2160
+ // account, and a deleted account must not hand its slot to a sibling.
2161
+ if (live.credentialId === undefined) return live;
2162
+ const stored = this.#getStoredCredentials(provider);
2163
+ const actualIndex = stored.findIndex(entry => entry.id === live.credentialId);
2164
+ if (actualIndex === -1 || stored[actualIndex]?.credential.type !== live.type) {
2165
+ sessionMap?.delete(sessionId);
2166
+ return undefined;
2167
+ }
2168
+ live.index = actualIndex;
2169
+ return live;
2119
2170
  }
2120
2171
  try {
2121
2172
  const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
@@ -2149,6 +2200,7 @@ export class AuthStorage {
2149
2200
  const sessionVal = {
2150
2201
  type: val.type,
2151
2202
  index: val.index,
2203
+ credentialId: val.credentialId,
2152
2204
  lastUsedAtMs: val.lastUsedAtMs,
2153
2205
  };
2154
2206
  sessionMap.set(sessionId, sessionVal);
@@ -3145,6 +3197,7 @@ export class AuthStorage {
3145
3197
  onProgress: ctrl.onProgress,
3146
3198
  onPrompt: ctrl.onPrompt,
3147
3199
  onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
3200
+ onBrowserSession: ctrl.onBrowserSession,
3148
3201
  signal: ctrl.signal,
3149
3202
  fetch: ctrl.fetch,
3150
3203
  });
@@ -3730,7 +3783,7 @@ export class AuthStorage {
3730
3783
  ingestUsageHeaders(
3731
3784
  provider: Provider,
3732
3785
  headers: Record<string, string>,
3733
- options?: { sessionId?: string; baseUrl?: string },
3786
+ options?: { sessionId?: string; baseUrl?: string; responseStatus?: number },
3734
3787
  ): boolean {
3735
3788
  if (this.#fetchUsageReportsOverride) return false;
3736
3789
  const parseHeaders = this.#resolveUsageProvider(provider)?.parseRateLimitHeaders;
@@ -3743,7 +3796,7 @@ export class AuthStorage {
3743
3796
  this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
3744
3797
  );
3745
3798
  const now = Date.now();
3746
- const parsedReport = parseHeaders(headers, now);
3799
+ const parsedReport = parseHeaders(headers, now, { responseStatus: options?.responseStatus });
3747
3800
  if (!parsedReport) return false;
3748
3801
  // Throttled to one ingest per interval — except when a window reads
3749
3802
  // exhausted: persist that snapshot immediately. A full-backed cache can
@@ -4204,7 +4257,14 @@ export class AuthStorage {
4204
4257
  const credentialType = entry.credential.type;
4205
4258
  const providerKey = this.#getProviderTypeKey(provider, credentialType);
4206
4259
  let blockedUntil = this.#getCredentialBlockedUntil(provider, providerKey, index, blockScopes);
4207
- if (blockedUntil !== undefined && provider !== "openai-codex") {
4260
+ // A block under a scope the strategy can vouch for must still fetch
4261
+ // a probe report, or it outlives the recovery that report would
4262
+ // prove: no report means no reconciliation, so the credential idles
4263
+ // until the clock runs out even after quota is restored.
4264
+ if (
4265
+ blockedUntil !== undefined &&
4266
+ !this.#blockedCredentialCanHeal(provider, providerKey, index, blockScopes)
4267
+ ) {
4208
4268
  return {
4209
4269
  credentialId: entry.id,
4210
4270
  credentialType,
@@ -4231,7 +4291,7 @@ export class AuthStorage {
4231
4291
  planEligibilityByCredential.set(entry.id, getOpenAICodexPlanEligibility(report, planRequirement));
4232
4292
  }
4233
4293
 
4234
- if (provider === "openai-codex") {
4294
+ if (this.#supportsUsageBlockHealing(provider)) {
4235
4295
  blockedUntil = this.#getCredentialBlockedUntil(provider, providerKey, index, blockScopes);
4236
4296
  }
4237
4297
  if (blockedUntil !== undefined) {
@@ -4795,6 +4855,7 @@ export class AuthStorage {
4795
4855
  signal?: AbortSignal;
4796
4856
  },
4797
4857
  ): Promise<UsageLimitMarkResult> {
4858
+ await this.#adoptExternalCredentialChanges();
4798
4859
  let sessionCredential = await this.#resolveCredentialTarget(provider, sessionId, {
4799
4860
  credentialId: options?.credentialId,
4800
4861
  apiKey: options?.apiKey,
@@ -4820,7 +4881,8 @@ export class AuthStorage {
4820
4881
 
4821
4882
  const routing = this.#credentialBlockRouting(provider, credentialType, options?.modelId);
4822
4883
  const now = Date.now();
4823
- let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
4884
+ const requestedBlockedUntilMs = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
4885
+ let blockedUntil = requestedBlockedUntilMs;
4824
4886
  // Heuristic/default fallbacks are guesses; provider-stated hints and
4825
4887
  // report-derived extensions are timed.
4826
4888
  let providerTimed = options?.providerTimed === true;
@@ -4871,7 +4933,11 @@ export class AuthStorage {
4871
4933
  routing,
4872
4934
  providerTimed,
4873
4935
  );
4874
- return reportResetAtMs === undefined ? rotation : { ...rotation, reportResetAtMs };
4936
+ return {
4937
+ ...rotation,
4938
+ requestedBlockedUntilMs,
4939
+ ...(reportResetAtMs === undefined ? {} : { reportResetAtMs }),
4940
+ };
4875
4941
  }
4876
4942
 
4877
4943
  #resolveWindowResetAt(window: UsageLimit["window"]): number | undefined {
@@ -5011,7 +5077,15 @@ export class AuthStorage {
5011
5077
  );
5012
5078
  let usage: UsageReport | null = null;
5013
5079
  let usageChecked = false;
5014
- if (blockedUntil !== undefined && args.provider === "openai-codex") {
5080
+ if (
5081
+ blockedUntil !== undefined &&
5082
+ this.#blockedCredentialCanHeal(
5083
+ args.provider,
5084
+ args.providerKey,
5085
+ selection.index,
5086
+ args.blockScopes ?? args.blockScope,
5087
+ )
5088
+ ) {
5015
5089
  usage = await this.#getUsageReport(args.provider, selection.credential, {
5016
5090
  ...args.options,
5017
5091
  timeoutMs: this.#usageRequestTimeoutMs,
@@ -5136,6 +5210,7 @@ export class AuthStorage {
5136
5210
  sessionId?: string,
5137
5211
  options?: AuthApiKeyOptions,
5138
5212
  ): Promise<OAuthResolutionResult | undefined> {
5213
+ await this.#adoptExternalCredentialChanges();
5139
5214
  const credentials = this.#getCredentialsForProvider(provider)
5140
5215
  .map((credential, index) => ({ credential, index }))
5141
5216
  .filter((entry): entry is { credential: OAuthCredential; index: number } => entry.credential.type === "oauth");
@@ -5889,6 +5964,8 @@ export class AuthStorage {
5889
5964
  return configKey;
5890
5965
  }
5891
5966
 
5967
+ await this.#adoptExternalCredentialChanges();
5968
+
5892
5969
  // Precedence: a deliberate OAuth/login credential wins, then an explicit env var,
5893
5970
  // then a stored static api_key (which may be a stale broker-migrated copy) as a last resort.
5894
5971
  const oauthSelection = this.#selectCredentialByType(provider, "oauth");
@@ -6164,6 +6241,32 @@ export class AuthStorage {
6164
6241
  return true;
6165
6242
  }
6166
6243
 
6244
+ /**
6245
+ * Copy every stored credential affinity from one live session to another.
6246
+ *
6247
+ * The target receives its own sticky entries, so request resolution, usage
6248
+ * blocking, credential rotation, metadata, and persisted pins all continue
6249
+ * through the target session id without retaining a live dependency on the
6250
+ * source session.
6251
+ */
6252
+ inheritSessionCredentials(sourceSessionId: string, targetSessionId: string): number {
6253
+ if (!sourceSessionId || !targetSessionId || sourceSessionId === targetSessionId) return 0;
6254
+ let inherited = 0;
6255
+ for (const provider of this.#data.keys()) {
6256
+ const credential = this.#getSessionCredential(provider, sourceSessionId);
6257
+ if (!credential) continue;
6258
+ this.#recordSessionCredential(
6259
+ provider,
6260
+ targetSessionId,
6261
+ credential.type,
6262
+ credential.index,
6263
+ credential.lastUsedAtMs,
6264
+ );
6265
+ inherited += 1;
6266
+ }
6267
+ return inherited;
6268
+ }
6269
+
6167
6270
  /**
6168
6271
  * Resolve every stored OAuth credential for `provider` independently.
6169
6272
  *
@@ -6612,6 +6715,29 @@ export class AuthStorage {
6612
6715
  );
6613
6716
  }
6614
6717
 
6718
+ /**
6719
+ * Whether a fresh report could lift what currently blocks this credential.
6720
+ *
6721
+ * A strategy that names healable scopes can only vouch for those scopes, so
6722
+ * a live unscoped block — an Opus/Sonnet usage limit, a refresh failure —
6723
+ * keeps the credential unusable whatever the report says about a tier. A
6724
+ * probe then cannot change the outcome and must not be spent; the tier scope
6725
+ * heals on a later pass, once the block that actually holds the credential
6726
+ * has lifted. Codex heals through its meter metadata rather than named
6727
+ * scopes, so its blocks always qualify.
6728
+ */
6729
+ #blockedCredentialCanHeal(
6730
+ provider: Provider,
6731
+ providerKey: string,
6732
+ credentialIndex: number,
6733
+ blockScopeOrScopes: string | readonly string[] | undefined,
6734
+ ): boolean {
6735
+ if (!this.#supportsUsageBlockHealing(provider)) return false;
6736
+ if (this.#rankingStrategyResolver?.(provider)?.healableBlockScopes === undefined) return true;
6737
+ if (this.#getCredentialBlockedUntil(provider, providerKey, credentialIndex) !== undefined) return false;
6738
+ return this.#getCredentialBlockedUntil(provider, providerKey, credentialIndex, blockScopeOrScopes) !== undefined;
6739
+ }
6740
+
6615
6741
  /**
6616
6742
  * Self-heal stale usage-limit blocks: when a fresh live usage report says a
6617
6743
  * scope is below every limit gating it, drop its persisted and in-memory
@@ -6626,6 +6752,10 @@ export class AuthStorage {
6626
6752
  if (credentialIndex < 0) return;
6627
6753
  const strategy = this.#rankingStrategyResolver?.(provider);
6628
6754
  if (provider !== "openai-codex") {
6755
+ // Only a live report proves recovery. A broker can serve its retained
6756
+ // last-good report for hours after `/usage` starts failing, and those
6757
+ // healthy limits describe the account before the 429 that blocked it.
6758
+ if (!Number.isFinite(report.fetchedAt) || Date.now() - report.fetchedAt > USAGE_REPORT_TTL_MS) return;
6629
6759
  for (const { blockScope, limits } of strategy?.healableBlockScopes?.(report) ?? []) {
6630
6760
  if (limits.length === 0 || this.#isUsageLimitReached(limits)) continue;
6631
6761
  this.#clearHealedBlockScope(provider, providerKey, credentialId, credentialIndex, blockScope);
@@ -6861,6 +6991,7 @@ export class AuthStorage {
6861
6991
  signal?: AbortSignal;
6862
6992
  },
6863
6993
  ): Promise<boolean> {
6994
+ await this.#adoptExternalCredentialChanges();
6864
6995
  const error = options?.error;
6865
6996
  const status = AIError.status(error);
6866
6997
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
@@ -6870,7 +7001,7 @@ export class AuthStorage {
6870
7001
  // Thread the provider-specified reset window (e.g. Devin "Your limit
6871
7002
  // will reset in 13 minutes") into the block duration so the credential
6872
7003
  // is not reselected and hammered while the cap remains active.
6873
- const retryAfterMs = extractRetryHint(undefined, message);
7004
+ const retryAfterMs = extractProviderRetryHint(provider, message);
6874
7005
  return (
6875
7006
  await this.markUsageLimitReached(provider, sessionId, {
6876
7007
  retryAfterMs,