@oh-my-pi/pi-ai 18.6.3 → 18.8.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 (83) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/auth/sqlite-credential-store.d.ts +1 -0
  3. package/dist/types/auth/store.d.ts +6 -0
  4. package/dist/types/auth-broker/remote-store.d.ts +1 -0
  5. package/dist/types/auth-broker/snapshot-cache.d.ts +12 -0
  6. package/dist/types/auth-gateway/session-state.d.ts +6 -0
  7. package/dist/types/dialect/terminator-wait.d.ts +19 -0
  8. package/dist/types/env-api-key.d.ts +21 -0
  9. package/dist/types/images/index.d.ts +2 -3
  10. package/dist/types/images/shared.d.ts +6 -0
  11. package/dist/types/index.d.ts +1 -0
  12. package/dist/types/providers/connect-frame.d.ts +44 -0
  13. package/dist/types/providers/cursor/interaction-query.d.ts +0 -2
  14. package/dist/types/providers/google-auth.d.ts +4 -1
  15. package/dist/types/providers/mock.d.ts +3 -1
  16. package/dist/types/providers/openai-responses-wire.d.ts +0 -54
  17. package/dist/types/providers/openai-shared.d.ts +4 -1
  18. package/dist/types/registry/oauth/cursor.d.ts +9 -1
  19. package/dist/types/registry/oauth/index.d.ts +2 -0
  20. package/dist/types/stream.d.ts +0 -21
  21. package/dist/types/transcription/types.d.ts +2 -1
  22. package/dist/types/types.d.ts +32 -10
  23. package/dist/types/utils/aws-profile.d.ts +5 -0
  24. package/package.json +6 -6
  25. package/src/auth/affinity.ts +33 -24
  26. package/src/auth/blocks.ts +90 -14
  27. package/src/auth/cascade.ts +1 -1
  28. package/src/auth/select.ts +27 -2
  29. package/src/auth/sqlite-credential-store.ts +85 -42
  30. package/src/auth/store.ts +6 -0
  31. package/src/auth/usage-cache.ts +16 -5
  32. package/src/auth/usage.ts +1 -1
  33. package/src/auth-broker/discover.ts +5 -4
  34. package/src/auth-broker/remote-store.ts +117 -46
  35. package/src/auth-broker/server.ts +283 -123
  36. package/src/auth-broker/snapshot-cache.ts +142 -2
  37. package/src/auth-gateway/server.ts +19 -5
  38. package/src/auth-gateway/session-state.ts +51 -9
  39. package/src/dialect/deepseek.ts +10 -2
  40. package/src/dialect/gemini.ts +8 -2
  41. package/src/dialect/hermes.ts +8 -2
  42. package/src/dialect/kimi.ts +8 -2
  43. package/src/dialect/qwen-xml.ts +8 -2
  44. package/src/dialect/qwen3.ts +8 -2
  45. package/src/dialect/terminator-wait.ts +42 -0
  46. package/src/env-api-key.ts +78 -0
  47. package/src/images/index.ts +3 -17
  48. package/src/images/openai-hosted.ts +11 -4
  49. package/src/images/openai-images.ts +19 -13
  50. package/src/images/shared.ts +10 -2
  51. package/src/index.ts +1 -0
  52. package/src/providers/anthropic.ts +39 -13
  53. package/src/providers/apple-foundation-models.ts +10 -4
  54. package/src/providers/azure-openai-responses.ts +1 -1
  55. package/src/providers/connect-frame.ts +107 -0
  56. package/src/providers/cursor/interaction-query.ts +1 -9
  57. package/src/providers/cursor.ts +87 -47
  58. package/src/providers/devin.ts +32 -41
  59. package/src/providers/gitlab-duo-workflow.ts +5 -2
  60. package/src/providers/google-auth.ts +10 -3
  61. package/src/providers/google.ts +1 -1
  62. package/src/providers/mock.ts +4 -0
  63. package/src/providers/ollama.ts +1 -1
  64. package/src/providers/openai-codex-responses.ts +49 -45
  65. package/src/providers/openai-completions.ts +1 -1
  66. package/src/providers/openai-responses-wire.ts +0 -54
  67. package/src/providers/openai-responses.ts +5 -4
  68. package/src/providers/openai-shared.ts +73 -27
  69. package/src/providers/transcriptions-server.ts +1 -1
  70. package/src/providers/transform-messages.ts +17 -13
  71. package/src/registry/aws.ts +51 -14
  72. package/src/registry/cloudflare-ai-gateway.ts +24 -1
  73. package/src/registry/hooks/env.ts +2 -5
  74. package/src/registry/oauth/cursor.ts +57 -3
  75. package/src/registry/oauth/index.ts +5 -0
  76. package/src/stream.ts +3 -72
  77. package/src/transcription/types.ts +2 -1
  78. package/src/types.ts +52 -11
  79. package/src/usage/cursor.ts +14 -25
  80. package/src/usage/minimax-code.ts +8 -1
  81. package/src/utils/aws-profile.ts +10 -4
  82. package/src/utils/thinking-loop.ts +18 -3
  83. package/src/utils.ts +22 -6
@@ -1,5 +1,6 @@
1
1
  import { logger } from "@oh-my-pi/pi-utils";
2
- import { getEnvApiKey } from "../stream";
2
+ import { LRUCache } from "@oh-my-pi/pi-utils/lru";
3
+ import { getEnvApiKey } from "../env-api-key";
3
4
  import type { AuthCredential, OAuthCredential, SessionsApi } from "./types";
4
5
  import type { AuthCredentialStore } from "./store";
5
6
  import type { CredentialPool } from "./pool";
@@ -14,6 +15,11 @@ const SESSION_STICKY_TTL_SEC = 30 * 24 * 60 * 60;
14
15
  * in-memory sticky stays exact; only the copy other processes resume from lags.
15
16
  */
16
17
  const SESSION_STICKY_PERSIST_INTERVAL_MS = 60_000;
18
+ /**
19
+ * In-memory pins kept per provider. Long-lived gateways mint a session id per
20
+ * conversation; evicted sessions resume from their persisted sticky row.
21
+ */
22
+ const SESSION_AFFINITY_MAX_SESSIONS_PER_PROVIDER = 256;
17
23
 
18
24
  /** What this process last wrote (or read) for one persisted sticky row. */
19
25
  type PersistedSticky = {
@@ -36,9 +42,9 @@ export type SessionCredential = {
36
42
  /** Session → credential affinity (pins), persisted in the store cache. */
37
43
  export class SessionAffinity implements SessionsApi {
38
44
  /** Tracks the last used credential per provider for a session (used for rate-limit switching). */
39
- #sessionLastCredential: Map<string, Map<string, SessionCredential>> = new Map();
40
- /** Persisted sticky rows keyed by cache key, so unchanged re-records skip the write. */
41
- #persistedSticky: Map<string, PersistedSticky> = new Map();
45
+ #sessionLastCredential: Map<string, LRUCache<string, SessionCredential>> = new Map();
46
+ /** Persisted sticky rows per provider, keyed by session id, so unchanged re-records skip the write. */
47
+ #persistedSticky: Map<string, LRUCache<string, PersistedSticky>> = new Map();
42
48
  #store: AuthCredentialStore;
43
49
  #pool: CredentialPool;
44
50
  #overrides: KeyOverrides;
@@ -49,13 +55,21 @@ export class SessionAffinity implements SessionsApi {
49
55
  this.#overrides = overrides;
50
56
  }
51
57
 
58
+ /** Bounded per-provider session map, created on first use. */
59
+ static #sessionsFor<V>(maps: Map<string, LRUCache<string, V>>, provider: string): LRUCache<string, V> {
60
+ let sessions = maps.get(provider);
61
+ if (!sessions) {
62
+ sessions = new LRUCache<string, V>({ max: SESSION_AFFINITY_MAX_SESSIONS_PER_PROVIDER });
63
+ maps.set(provider, sessions);
64
+ }
65
+ return sessions;
66
+ }
67
+
52
68
  /** Drop every pin for a provider, both in memory and in the persisted cache. */
53
69
  clearProvider(provider: string): void {
54
70
  this.#sessionLastCredential.delete(provider);
71
+ this.#persistedSticky.delete(provider);
55
72
  const prefix = `${SESSION_STICKY_CACHE_PREFIX}${provider}:`;
56
- for (const cacheKey of this.#persistedSticky.keys()) {
57
- if (cacheKey.startsWith(prefix)) this.#persistedSticky.delete(cacheKey);
58
- }
59
73
  try {
60
74
  this.#store.deleteCachePrefix?.(prefix);
61
75
  } catch (err) {
@@ -90,7 +104,7 @@ export class SessionAffinity implements SessionsApi {
90
104
  if (!sessionId) return;
91
105
  const nowMs = lastUsedAtMs ?? Date.now();
92
106
  const credentialId = this.#pool.entries(provider)[index]?.id;
93
- const sessionMap = this.#sessionLastCredential.get(provider) ?? new Map();
107
+ const sessionMap = SessionAffinity.#sessionsFor(this.#sessionLastCredential, provider);
94
108
  const previous = sessionMap.get(sessionId);
95
109
  const sameCredential =
96
110
  previous?.type === type &&
@@ -104,12 +118,12 @@ export class SessionAffinity implements SessionsApi {
104
118
  ...(isExplicit ? { explicit: true as const } : {}),
105
119
  };
106
120
  sessionMap.set(sessionId, sessionCredential);
107
- this.#sessionLastCredential.set(provider, sessionMap);
108
121
 
109
122
  if (credentialId === undefined) return;
110
123
  const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
111
124
  const expiresAtSec = Math.floor(nowMs / 1000) + SESSION_STICKY_TTL_SEC;
112
- const persisted = this.#persistedSticky.get(cacheKey);
125
+ const persistedSessions = SessionAffinity.#sessionsFor(this.#persistedSticky, provider);
126
+ const persisted = persistedSessions.get(sessionId);
113
127
  if (
114
128
  persisted &&
115
129
  persisted.type === type &&
@@ -121,14 +135,14 @@ export class SessionAffinity implements SessionsApi {
121
135
  }
122
136
  try {
123
137
  this.#store.setCache(cacheKey, JSON.stringify(sessionCredential), expiresAtSec);
124
- this.#persistedSticky.set(cacheKey, {
138
+ persistedSessions.set(sessionId, {
125
139
  type,
126
140
  credentialId,
127
141
  explicit: isExplicit,
128
142
  lastUsedAtMs: nowMs,
129
143
  });
130
144
  } catch (err) {
131
- this.#persistedSticky.delete(cacheKey);
145
+ persistedSessions.delete(sessionId);
132
146
  logger.debug("Failed to write session sticky credential to persistent store cache", { err });
133
147
  }
134
148
  }
@@ -136,8 +150,7 @@ export class SessionAffinity implements SessionsApi {
136
150
  /** Retrieves the last credential used by a session. */
137
151
  get(provider: string, sessionId: string | undefined): SessionCredential | undefined {
138
152
  if (!sessionId) return undefined;
139
- let sessionMap = this.#sessionLastCredential.get(provider);
140
- const live = sessionMap?.get(sessionId);
153
+ const live = this.#sessionLastCredential.get(provider)?.get(sessionId);
141
154
  if (live) {
142
155
  // Another process can add or drop rows mid-session and the pool is an
143
156
  // index-ordered snapshot, so re-resolve the pin through its durable row
@@ -147,7 +160,7 @@ export class SessionAffinity implements SessionsApi {
147
160
  const stored = this.#pool.entries(provider);
148
161
  const actualIndex = stored.findIndex(entry => entry.id === live.credentialId);
149
162
  if (actualIndex === -1 || stored[actualIndex]?.credential.type !== live.type) {
150
- sessionMap?.delete(sessionId);
163
+ this.#sessionLastCredential.get(provider)?.delete(sessionId);
151
164
  return undefined;
152
165
  }
153
166
  live.index = actualIndex;
@@ -163,22 +176,18 @@ export class SessionAffinity implements SessionsApi {
163
176
  const stored = this.#pool.entries(provider);
164
177
  const actualIndex = stored.findIndex(entry => entry.id === val.credentialId);
165
178
  if (actualIndex === -1 || stored[actualIndex]?.credential.type !== val.type) {
166
- this.#persistedSticky.delete(cacheKey);
179
+ this.#persistedSticky.get(provider)?.delete(sessionId);
167
180
  this.#store.setCache(cacheKey, "", 0);
168
181
  return undefined;
169
182
  }
170
183
  val.index = actualIndex;
171
184
  } else {
172
185
  // Fallback: drop unsafe index-only cache rows to prevent wrong-account routing
173
- this.#persistedSticky.delete(cacheKey);
186
+ this.#persistedSticky.get(provider)?.delete(sessionId);
174
187
  this.#store.setCache(cacheKey, "", 0);
175
188
  return undefined;
176
189
  }
177
190
 
178
- if (!sessionMap) {
179
- sessionMap = new Map();
180
- this.#sessionLastCredential.set(provider, sessionMap);
181
- }
182
191
  const sessionVal: SessionCredential = {
183
192
  type: val.type,
184
193
  index: val.index,
@@ -187,14 +196,14 @@ export class SessionAffinity implements SessionsApi {
187
196
  ...(val.explicit === true ? { explicit: true } : {}),
188
197
  };
189
198
  if (typeof val.lastUsedAtMs === "number") {
190
- this.#persistedSticky.set(cacheKey, {
199
+ SessionAffinity.#sessionsFor(this.#persistedSticky, provider).set(sessionId, {
191
200
  type: val.type,
192
201
  credentialId: val.credentialId,
193
202
  explicit: val.explicit === true,
194
203
  lastUsedAtMs: val.lastUsedAtMs,
195
204
  });
196
205
  }
197
- sessionMap.set(sessionId, sessionVal);
206
+ SessionAffinity.#sessionsFor(this.#sessionLastCredential, provider).set(sessionId, sessionVal);
198
207
  return sessionVal;
199
208
  }
200
209
  } catch (err) {
@@ -214,7 +223,7 @@ export class SessionAffinity implements SessionsApi {
214
223
  }
215
224
  }
216
225
  const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
217
- this.#persistedSticky.delete(cacheKey);
226
+ this.#persistedSticky.get(provider)?.delete(sessionId);
218
227
  try {
219
228
  this.#store.setCache(cacheKey, "", 0);
220
229
  } catch (err) {
@@ -64,6 +64,14 @@ export function credentialBlockScopesForRequest(
64
64
  */
65
65
  type CredentialBackoff = { until: number; timed: boolean; probeAfter: number; persisted?: boolean };
66
66
 
67
+ /**
68
+ * One read of a credential's persisted blocks under a providerKey, keyed by
69
+ * scope (`""` is unscoped). `undefined`: the store has no bulk read, so use
70
+ * per-scope point reads. `null`: unreadable (damaged store or failed read), so
71
+ * it neither contributes deadlines nor retires mirrored in-memory blocks.
72
+ */
73
+ type PersistedBlockScopes = ReadonlyMap<string, number> | null | undefined;
74
+
67
75
  /** Latch for unrecoverably corrupt persisted block stores, shared with the credential pool. */
68
76
  export class BlockStoreHealth {
69
77
  readonly #sourceLabel: string | undefined;
@@ -134,8 +142,16 @@ export class CredentialBlocks implements BlocksApi {
134
142
  }
135
143
  }
136
144
 
137
- /** Returns in-memory block expiry timestamp for a credential/key pair, cleaning up expired entries. */
138
- #getCredentialBlockedUntilForKey(backoffKey: string, credentialId: number, nowMs: number): number | undefined {
145
+ /**
146
+ * Returns in-memory block expiry timestamp for a credential/key pair, cleaning up expired entries.
147
+ * `persistedScopes` is a bulk read for the key's providerKey; omitted, the store is point-read.
148
+ */
149
+ #getCredentialBlockedUntilForKey(
150
+ backoffKey: string,
151
+ credentialId: number,
152
+ nowMs: number,
153
+ persistedScopes: PersistedBlockScopes = undefined,
154
+ ): number | undefined {
139
155
  const block = this.#credentialBackoff.get(backoffKey)?.get(credentialId);
140
156
  if (!block) return undefined;
141
157
  // Once mirrored successfully, the store is authoritative for cross-process
@@ -143,12 +159,19 @@ export class CredentialBlocks implements BlocksApi {
143
159
  // Upserts are longest-wins, so a persisted row shorter than the deadline this
144
160
  // process mirrored means the row was deleted and replaced: the old deadline
145
161
  // was superseded and must not win the next longest-wins merge.
146
- if (block.persisted && !this.#deps.health.damaged && this.#deps.store.getCredentialBlock) {
162
+ if (
163
+ block.persisted &&
164
+ !this.#deps.health.damaged &&
165
+ persistedScopes !== null &&
166
+ (persistedScopes !== undefined || this.#deps.store.getCredentialBlock)
167
+ ) {
147
168
  const separator = backoffKey.indexOf("\0");
148
169
  const providerKey = separator < 0 ? backoffKey : backoffKey.slice(0, separator);
149
170
  const scope = separator < 0 ? "" : backoffKey.slice(separator + 1);
150
171
  try {
151
- const persisted = this.#deps.store.getCredentialBlock(credentialId, providerKey, scope);
172
+ const persisted = persistedScopes
173
+ ? persistedScopes.get(scope)
174
+ : this.#deps.store.getCredentialBlock?.(credentialId, providerKey, scope);
152
175
  if (persisted === undefined || persisted < block.until) {
153
176
  this.#deleteCredentialBackoff(backoffKey, credentialId);
154
177
  return undefined;
@@ -175,7 +198,9 @@ export class CredentialBlocks implements BlocksApi {
175
198
  credentialId: number,
176
199
  providerKey: string,
177
200
  blockScope: string | undefined,
201
+ persistedScopes: PersistedBlockScopes = undefined,
178
202
  ): number | undefined {
203
+ if (persistedScopes !== undefined) return persistedScopes?.get(blockScope ?? "");
179
204
  if (this.#deps.health.damaged) return undefined;
180
205
  const getCredentialBlock = this.#deps.store.getCredentialBlock?.bind(this.#deps.store);
181
206
  if (!getCredentialBlock) return undefined;
@@ -193,6 +218,20 @@ export class CredentialBlocks implements BlocksApi {
193
218
  }
194
219
  }
195
220
 
221
+ /** Every persisted scope of one credential/providerKey in a single store read, when supported. */
222
+ #readPersistedCredentialBlockScopes(credentialId: number, providerKey: string): PersistedBlockScopes {
223
+ const store = this.#deps.store;
224
+ if (!store.getCredentialBlockScopes) return undefined;
225
+ if (this.#deps.health.damaged) return null;
226
+ try {
227
+ return store.getCredentialBlockScopes(credentialId, providerKey);
228
+ } catch (err) {
229
+ if (this.#deps.health.handle(err)) return null;
230
+ logger.debug("Failed to read credential blocks from persistent store", { err, credentialId, providerKey });
231
+ return null;
232
+ }
233
+ }
234
+
196
235
  #readPersistedCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number {
197
236
  if (this.#deps.health.damaged) return 0;
198
237
  const getCredentialBlockReconcileAfter = this.#deps.store.getCredentialBlockReconcileAfter?.bind(
@@ -215,13 +254,19 @@ export class CredentialBlocks implements BlocksApi {
215
254
  }
216
255
  }
217
256
 
218
- #exactBlockedUntil(credentialId: number, providerKey: string, scope: string | undefined): number | undefined {
257
+ #exactBlockedUntil(
258
+ credentialId: number,
259
+ providerKey: string,
260
+ scope: string | undefined,
261
+ persistedScopes: PersistedBlockScopes = this.#readPersistedCredentialBlockScopes(credentialId, providerKey),
262
+ ): number | undefined {
219
263
  const memory = this.#getCredentialBlockedUntilForKey(
220
264
  scopedBackoffKey(providerKey, scope),
221
265
  credentialId,
222
266
  Date.now(),
267
+ persistedScopes,
223
268
  );
224
- const persisted = this.#readPersistedCredentialBlock(credentialId, providerKey, scope);
269
+ const persisted = this.#readPersistedCredentialBlock(credentialId, providerKey, scope, persistedScopes);
225
270
  return memory === undefined ? persisted : persisted === undefined ? memory : Math.max(memory, persisted);
226
271
  }
227
272
 
@@ -231,6 +276,22 @@ export class CredentialBlocks implements BlocksApi {
231
276
  providerKey: string,
232
277
  credentialIndex: number,
233
278
  blockScopeOrScopes: string | readonly string[] | undefined = undefined,
279
+ ): number | undefined {
280
+ const credentialId = this.#deps.pool.entries(provider)[credentialIndex]?.id;
281
+ if (credentialId === undefined) return undefined;
282
+ return this.#blockedUntilById(
283
+ credentialId,
284
+ providerKey,
285
+ blockScopeOrScopes,
286
+ this.#readPersistedCredentialBlockScopes(credentialId, providerKey),
287
+ );
288
+ }
289
+
290
+ #blockedUntilById(
291
+ credentialId: number,
292
+ providerKey: string,
293
+ blockScopeOrScopes: string | readonly string[] | undefined,
294
+ persistedScopes: PersistedBlockScopes,
234
295
  ): number | undefined {
235
296
  const nowMs = Date.now();
236
297
  // A request honours its own scope plus any legacy catch-all scope, so a
@@ -239,21 +300,25 @@ export class CredentialBlocks implements BlocksApi {
239
300
  ...PROTECTED_BLOCK_SCOPES,
240
301
  ...(typeof blockScopeOrScopes === "string" ? [blockScopeOrScopes] : (blockScopeOrScopes ?? [])),
241
302
  ].filter(scope => scope.length > 0);
242
- const credentialId = this.#deps.pool.entries(provider)[credentialIndex]?.id;
243
- if (credentialId === undefined) return undefined;
244
- let blockedUntil = this.#getCredentialBlockedUntilForKey(providerKey, credentialId, nowMs);
303
+ let blockedUntil = this.#getCredentialBlockedUntilForKey(providerKey, credentialId, nowMs, persistedScopes);
245
304
  for (const blockScope of scopes) {
246
305
  const scopedBlockedUntil = this.#getCredentialBlockedUntilForKey(
247
306
  scopedBackoffKey(providerKey, blockScope),
248
307
  credentialId,
249
308
  nowMs,
309
+ persistedScopes,
250
310
  );
251
311
  if (scopedBlockedUntil !== undefined && (blockedUntil === undefined || scopedBlockedUntil > blockedUntil)) {
252
312
  blockedUntil = scopedBlockedUntil;
253
313
  }
254
314
  }
255
315
 
256
- const persistedGlobalBlockedUntil = this.#readPersistedCredentialBlock(credentialId, providerKey, "");
316
+ const persistedGlobalBlockedUntil = this.#readPersistedCredentialBlock(
317
+ credentialId,
318
+ providerKey,
319
+ "",
320
+ persistedScopes,
321
+ );
257
322
  if (
258
323
  persistedGlobalBlockedUntil !== undefined &&
259
324
  (blockedUntil === undefined || persistedGlobalBlockedUntil > blockedUntil)
@@ -261,7 +326,12 @@ export class CredentialBlocks implements BlocksApi {
261
326
  blockedUntil = persistedGlobalBlockedUntil;
262
327
  }
263
328
  for (const blockScope of scopes) {
264
- const persistedScopedBlockedUntil = this.#readPersistedCredentialBlock(credentialId, providerKey, blockScope);
329
+ const persistedScopedBlockedUntil = this.#readPersistedCredentialBlock(
330
+ credentialId,
331
+ providerKey,
332
+ blockScope,
333
+ persistedScopes,
334
+ );
265
335
  if (
266
336
  persistedScopedBlockedUntil !== undefined &&
267
337
  (blockedUntil === undefined || persistedScopedBlockedUntil > blockedUntil)
@@ -440,14 +510,20 @@ export class CredentialBlocks implements BlocksApi {
440
510
  if (!this.supportsHealing(provider)) return false;
441
511
  const credentialId = this.#deps.pool.entries(provider)[credentialIndex]?.id;
442
512
  if (credentialId === undefined) return false;
443
- if (PROTECTED_BLOCK_SCOPES.some(scope => this.#exactBlockedUntil(credentialId, providerKey, scope) !== undefined))
513
+ // One persisted read serves the protected-scope, global, and requested checks.
514
+ const persistedScopes = this.#readPersistedCredentialBlockScopes(credentialId, providerKey);
515
+ if (
516
+ PROTECTED_BLOCK_SCOPES.some(
517
+ scope => this.#exactBlockedUntil(credentialId, providerKey, scope, persistedScopes) !== undefined,
518
+ )
519
+ )
444
520
  return false;
445
521
  if (
446
522
  !this.#deps.strategies(provider)?.healsGlobalBlocks &&
447
- this.blockedUntil(provider, providerKey, credentialIndex) !== undefined
523
+ this.#blockedUntilById(credentialId, providerKey, undefined, persistedScopes) !== undefined
448
524
  )
449
525
  return false;
450
- return this.blockedUntil(provider, providerKey, credentialIndex, blockScopeOrScopes) !== undefined;
526
+ return this.#blockedUntilById(credentialId, providerKey, blockScopeOrScopes, persistedScopes) !== undefined;
451
527
  }
452
528
 
453
529
  /**
@@ -1,10 +1,10 @@
1
1
  import { authPolicyFor } from "@oh-my-pi/pi-catalog/compat/auth";
2
2
  import { $env, $envExact } from "@oh-my-pi/pi-utils";
3
3
  import { type ApiKeyResolver, markAfterSiblingWait, type ResolvedApiKey } from "../auth-retry";
4
+ import { getEnvApiKey, getEnvApiKeyName } from "../env-api-key";
4
5
  import * as AIError from "../error";
5
6
  import { isUsageLimitOutcome } from "../error/rate-limit";
6
7
  import { AUTHENTICATED_SENTINEL } from "../registry/types";
7
- import { getEnvApiKey, getEnvApiKeyName } from "../stream";
8
8
  import type { SessionAffinity } from "./affinity";
9
9
  import type { CredentialPool } from "./pool";
10
10
  import type { CredentialSelector } from "./select";
@@ -28,7 +28,13 @@ import {
28
28
  } from "./rank";
29
29
  import { mergeRefreshedCredential, OAUTH_REFRESH_SKEW_MS, type OAuthRefresher } from "./refresh";
30
30
  import type { AuthCredentialStore } from "./store";
31
- import type { ApiKeyCredential, AuthApiKeyOptions, AuthCredential, OAuthCredential } from "./types";
31
+ import type {
32
+ ApiKeyCredential,
33
+ AuthApiKeyOptions,
34
+ AuthCredential,
35
+ OAuthCredential,
36
+ StoredAuthCredential,
37
+ } from "./types";
32
38
  import type { UsageService } from "./usage";
33
39
  import {
34
40
  isUsageLimitReached,
@@ -690,6 +696,10 @@ export class CredentialSelector {
690
696
  const forceRefreshIndex = options?.forceRefresh
691
697
  ? (sessionPreferredIndex ?? candidates[0]?.selection.index)
692
698
  : undefined;
699
+ // Each candidate's synchronous prefix below runs back to back inside `map`,
700
+ // before any await, so one provider re-list serves every initial rebind.
701
+ // Resyncs after an await (refresh, disable) re-read the store.
702
+ let preflightRows: StoredAuthCredential[] | undefined;
693
703
  await Promise.all(
694
704
  candidates.map(async candidate => {
695
705
  const force = forceRefreshIndex !== undefined && candidate.selection.index === forceRefreshIndex;
@@ -697,7 +707,8 @@ export class CredentialSelector {
697
707
  let syncedPeerCredential = false;
698
708
  if (initialCredentialId !== undefined) {
699
709
  const beforeSync = candidate.selection.credential;
700
- if (!this.#syncOAuthSelectionFromStore(provider, candidate.selection, initialCredentialId)) return;
710
+ preflightRows ??= this.#reloadProviderRows(provider);
711
+ if (!this.#rebindOAuthSelection(preflightRows, candidate.selection, initialCredentialId)) return;
701
712
  syncedPeerCredential = !authCredentialEquals(beforeSync, candidate.selection.credential);
702
713
  }
703
714
  const hasFreshAccess = Date.now() + OAUTH_REFRESH_SKEW_MS < candidate.selection.credential.expires;
@@ -881,11 +892,25 @@ export class CredentialSelector {
881
892
  selection: { credential: OAuthCredential; index: number },
882
893
  credentialId: number,
883
894
  ): boolean {
895
+ return this.#rebindOAuthSelection(this.#reloadProviderRows(provider), selection, credentialId);
896
+ }
897
+
898
+ /** Re-list one provider's active rows and adopt them in the pool. */
899
+ #reloadProviderRows(provider: string): StoredAuthCredential[] {
884
900
  const latestRows = this.#deps.store.listAuthCredentials(provider);
885
901
  this.#deps.pool.replace(
886
902
  provider,
887
903
  latestRows.map(row => ({ id: row.id, credential: row.credential })),
888
904
  );
905
+ return latestRows;
906
+ }
907
+
908
+ /** Point `selection` at row `credentialId` in `latestRows`; false when it is gone or no longer OAuth. */
909
+ #rebindOAuthSelection(
910
+ latestRows: readonly StoredAuthCredential[],
911
+ selection: { credential: OAuthCredential; index: number },
912
+ credentialId: number,
913
+ ): boolean {
889
914
  const latestIndex = latestRows.findIndex(row => row.id === credentialId);
890
915
  if (latestIndex === -1) return false;
891
916
  const latest = latestRows[latestIndex];
@@ -110,6 +110,12 @@ const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
110
110
  const LEGACY_CODEX_BLOCK_PROVIDER_KEY = "openai-codex:oauth";
111
111
  const LEGACY_CODEX_BLOCK_SCOPE = "shared";
112
112
  const CODEX_METER_BLOCK_SCOPES = ["chat", "spark"] as const;
113
+ /**
114
+ * Minimum spacing between read-path sweeps of expired block rows. Reads already
115
+ * filter `blocked_until_ms > now`; the sweep only bounds table growth, so it
116
+ * must not take the SQLite write lock on every selection.
117
+ */
118
+ const CREDENTIAL_BLOCK_SWEEP_INTERVAL_MS = 60_000;
113
119
 
114
120
  // SQLite error classifiers live in pi-utils so the credential store and the
115
121
  // model cache share one implementation; re-exported here to preserve the
@@ -386,11 +392,14 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
386
392
  #updateIfMatchesWithLeaseStmt: Statement;
387
393
  #deleteIfMatchesWithLeaseStmt: Statement;
388
394
  #getCredentialBlockStmt: Statement;
389
- #listCredentialBlocksByCredentialStmt: Statement;
395
+ #listCredentialBlockScopesStmt: Statement;
396
+ #listCredentialBlocksStmt: Statement;
390
397
  #upsertCredentialBlockStmt: Statement;
391
398
  #deleteCredentialBlocksStmt: Statement;
392
399
  #deleteCredentialBlockStmt: Statement;
393
400
  #deleteExpiredCredentialBlocksStmt: Statement;
401
+ /** Wall-clock time of the last expired-block sweep; throttles read-path sweeps. */
402
+ #lastCredentialBlockSweepMs = Number.NEGATIVE_INFINITY;
394
403
  #acquireCredentialRefreshLeaseStmt: Statement;
395
404
  #getCredentialRefreshLeaseStmt: Statement;
396
405
  #renewCredentialRefreshLeaseStmt: Statement;
@@ -475,12 +484,18 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
475
484
  this.#getCredentialBlockStmt = this.#db.prepare(
476
485
  "SELECT blocked_until_ms, updated_at FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ? AND blocked_until_ms > ?",
477
486
  );
478
- this.#listCredentialBlocksByCredentialStmt = this.#db.prepare(
479
- `SELECT credential_id, provider_key, block_scope, blocked_until_ms, updated_at
480
- FROM auth_credential_blocks
481
- WHERE credential_id = ? AND blocked_until_ms > ?
482
- AND NOT (provider_key = ? AND block_scope = ?)
483
- ORDER BY provider_key ASC, block_scope ASC`,
487
+ this.#listCredentialBlockScopesStmt = this.#db.prepare(
488
+ "SELECT block_scope, blocked_until_ms FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND blocked_until_ms > ?",
489
+ );
490
+ // One statement for any number of ids: `json_each` keeps the caller's id
491
+ // order (`ids.key`) and the per-credential provider/scope order.
492
+ this.#listCredentialBlocksStmt = this.#db.prepare(
493
+ `SELECT b.credential_id, b.provider_key, b.block_scope, b.blocked_until_ms, b.updated_at
494
+ FROM json_each(?) AS ids
495
+ JOIN auth_credential_blocks AS b ON b.credential_id = ids.value
496
+ WHERE b.blocked_until_ms > ?
497
+ AND NOT (b.provider_key = ? AND b.block_scope = ?)
498
+ ORDER BY ids.key ASC, b.provider_key ASC, b.block_scope ASC`,
484
499
  );
485
500
  this.#upsertCredentialBlockStmt = this.#db.prepare(
486
501
  `INSERT INTO auth_credential_blocks (credential_id, provider_key, block_scope, blocked_until_ms, updated_at)
@@ -1585,27 +1600,38 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
1585
1600
  }
1586
1601
 
1587
1602
  getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined {
1588
- const nowMs = Date.now();
1589
- const isCodexBlock = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY;
1590
1603
  // Current callers use meter scopes. The physical shared row exists only
1591
1604
  // for direct SQLite readers from pre-meter releases.
1592
- if (isCodexBlock && blockScope === LEGACY_CODEX_BLOCK_SCOPE) {
1605
+ if (providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY && blockScope === LEGACY_CODEX_BLOCK_SCOPE) {
1593
1606
  return undefined;
1594
1607
  }
1595
- if (!isCodexBlock) this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
1596
- const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as
1608
+ this.#sweepExpiredCredentialBlocks();
1609
+ const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, Date.now()) as
1597
1610
  | { blocked_until_ms?: number; updated_at?: number }
1598
1611
  | undefined;
1599
1612
  return typeof row?.blocked_until_ms === "number" ? row.blocked_until_ms : undefined;
1600
1613
  }
1601
1614
 
1615
+ getCredentialBlockScopes(credentialId: number, providerKey: string): Map<string, number> {
1616
+ this.#sweepExpiredCredentialBlocks();
1617
+ const rows = this.#listCredentialBlockScopesStmt.all(credentialId, providerKey, Date.now()) as Array<
1618
+ Pick<CredentialBlockRow, "block_scope" | "blocked_until_ms">
1619
+ >;
1620
+ const hidesLegacyShared = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY;
1621
+ const scopes = new Map<string, number>();
1622
+ for (const row of rows) {
1623
+ if (hidesLegacyShared && row.block_scope === LEGACY_CODEX_BLOCK_SCOPE) continue;
1624
+ scopes.set(row.block_scope, row.blocked_until_ms);
1625
+ }
1626
+ return scopes;
1627
+ }
1628
+
1602
1629
  getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined {
1603
- const nowMs = Date.now();
1604
- const isCodexBlock = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY;
1605
- if (isCodexBlock && blockScope === LEGACY_CODEX_BLOCK_SCOPE) {
1630
+ if (providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY && blockScope === LEGACY_CODEX_BLOCK_SCOPE) {
1606
1631
  return undefined;
1607
1632
  }
1608
- if (!isCodexBlock) this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
1633
+ this.#sweepExpiredCredentialBlocks();
1634
+ const nowMs = Date.now();
1609
1635
  const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as
1610
1636
  | { blocked_until_ms?: number; updated_at?: number }
1611
1637
  | undefined;
@@ -1622,7 +1648,10 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
1622
1648
  const isLegacyCodexBlock =
1623
1649
  block.providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY && block.blockScope === LEGACY_CODEX_BLOCK_SCOPE;
1624
1650
  const blockScopes = isLegacyCodexBlock ? CODEX_METER_BLOCK_SCOPES : [block.blockScope];
1651
+ const nowMs = Date.now();
1625
1652
  const upsert = this.#db.transaction(() => {
1653
+ // A mark already holds the write lock: sweep expired rows in the same transaction.
1654
+ this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
1626
1655
  for (const blockScope of blockScopes) {
1627
1656
  this.#upsertCredentialBlockStmt.run(
1628
1657
  block.credentialId,
@@ -1633,8 +1662,10 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
1633
1662
  }
1634
1663
  });
1635
1664
  upsert.immediate();
1665
+ this.#lastCredentialBlockSweepMs = nowMs;
1666
+ this.#pruneCredentialBlockReconcileAfter(nowMs);
1636
1667
 
1637
- const reconcileAfterMs = Math.min(block.blockedUntilMs, Date.now() + USAGE_REPORT_TTL_MS);
1668
+ const reconcileAfterMs = Math.min(block.blockedUntilMs, nowMs + USAGE_REPORT_TTL_MS);
1638
1669
  for (const blockScope of blockScopes) {
1639
1670
  this.#credentialBlockReconcileAfter.set(
1640
1671
  `${block.credentialId}\0${block.providerKey}\0${blockScope}`,
@@ -1661,38 +1692,49 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
1661
1692
  }
1662
1693
 
1663
1694
  cleanExpiredCredentialBlocks(nowMs: number): void {
1695
+ // Stamp wall-clock time first: callers may pass a future `nowMs`, and a
1696
+ // failed sweep must not be retried by every following read.
1697
+ this.#lastCredentialBlockSweepMs = Date.now();
1664
1698
  this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
1699
+ this.#pruneCredentialBlockReconcileAfter(nowMs);
1700
+ }
1701
+
1702
+ #pruneCredentialBlockReconcileAfter(nowMs: number): void {
1665
1703
  for (const [key, reconcileAfterMs] of this.#credentialBlockReconcileAfter) {
1666
1704
  if (reconcileAfterMs <= nowMs) this.#credentialBlockReconcileAfter.delete(key);
1667
1705
  }
1668
1706
  }
1669
1707
 
1670
- listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] {
1671
- if (credentialIds.length === 0) return [];
1708
+ /**
1709
+ * Read-path expiry sweep, at most once per {@link CREDENTIAL_BLOCK_SWEEP_INTERVAL_MS}.
1710
+ * A busy write lock only postpones housekeeping; it never fails the read.
1711
+ */
1712
+ #sweepExpiredCredentialBlocks(): void {
1672
1713
  const nowMs = Date.now();
1673
- this.cleanExpiredCredentialBlocks(nowMs);
1674
- const seenCredentialIds = new Set<number>();
1675
- const blocks: StoredCredentialBlock[] = [];
1676
- for (const credentialId of credentialIds) {
1677
- if (seenCredentialIds.has(credentialId)) continue;
1678
- seenCredentialIds.add(credentialId);
1679
- const rows = this.#listCredentialBlocksByCredentialStmt.all(
1680
- credentialId,
1681
- nowMs,
1682
- LEGACY_CODEX_BLOCK_PROVIDER_KEY,
1683
- LEGACY_CODEX_BLOCK_SCOPE,
1684
- ) as CredentialBlockRow[];
1685
- for (const row of rows) {
1686
- blocks.push({
1687
- credentialId: row.credential_id,
1688
- providerKey: row.provider_key,
1689
- blockScope: row.block_scope,
1690
- blockedUntilMs: row.blocked_until_ms,
1691
- updatedAtMs: row.updated_at * 1000,
1692
- });
1693
- }
1714
+ if (nowMs - this.#lastCredentialBlockSweepMs < CREDENTIAL_BLOCK_SWEEP_INTERVAL_MS) return;
1715
+ try {
1716
+ this.cleanExpiredCredentialBlocks(nowMs);
1717
+ } catch (err) {
1718
+ if (!isSqliteBusyError(err)) throw err;
1694
1719
  }
1695
- return blocks;
1720
+ }
1721
+
1722
+ listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] {
1723
+ if (credentialIds.length === 0) return [];
1724
+ this.#sweepExpiredCredentialBlocks();
1725
+ const rows = this.#listCredentialBlocksStmt.all(
1726
+ JSON.stringify([...new Set(credentialIds)]),
1727
+ Date.now(),
1728
+ LEGACY_CODEX_BLOCK_PROVIDER_KEY,
1729
+ LEGACY_CODEX_BLOCK_SCOPE,
1730
+ ) as CredentialBlockRow[];
1731
+ return rows.map(row => ({
1732
+ credentialId: row.credential_id,
1733
+ providerKey: row.provider_key,
1734
+ blockScope: row.block_scope,
1735
+ blockedUntilMs: row.blocked_until_ms,
1736
+ updatedAtMs: row.updated_at * 1000,
1737
+ }));
1696
1738
  }
1697
1739
 
1698
1740
  tryAcquireCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean {
@@ -2050,7 +2092,8 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
2050
2092
  this.#upsertCacheStmt.finalize();
2051
2093
  this.#deleteExpiredCacheStmt.finalize();
2052
2094
  this.#getCredentialBlockStmt.finalize();
2053
- this.#listCredentialBlocksByCredentialStmt.finalize();
2095
+ this.#listCredentialBlockScopesStmt.finalize();
2096
+ this.#listCredentialBlocksStmt.finalize();
2054
2097
  this.#upsertCredentialBlockStmt.finalize();
2055
2098
  this.#deleteCredentialBlocksStmt.finalize();
2056
2099
  this.#deleteCredentialBlockStmt.finalize();
package/src/auth/store.ts CHANGED
@@ -98,6 +98,12 @@ export interface CredentialCacheStore {
98
98
  export interface CredentialBlockStore {
99
99
  /** Non-expired block for one (credential, providerKey, scope) key, or undefined. */
100
100
  getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined;
101
+ /**
102
+ * Non-expired blocks for one credential and providerKey, keyed by block
103
+ * scope (`""` is unscoped), in one read. Optional: selectors fall back to
104
+ * {@link CredentialBlockStore.getCredentialBlock} per scope when absent.
105
+ */
106
+ getCredentialBlockScopes?(credentialId: number, providerKey: string): ReadonlyMap<string, number>;
101
107
  /** Earliest time a shared-store block should be eligible for live-usage reconciliation. */
102
108
  getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined;
103
109
  /** Upsert with MAX semantics: keep the later blockedUntilMs on conflict. */