@oh-my-pi/pi-ai 18.7.0 → 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 (65) hide show
  1. package/CHANGELOG.md +22 -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/shared.d.ts +6 -0
  10. package/dist/types/index.d.ts +1 -0
  11. package/dist/types/providers/connect-frame.d.ts +44 -0
  12. package/dist/types/providers/cursor/interaction-query.d.ts +0 -2
  13. package/dist/types/stream.d.ts +0 -21
  14. package/dist/types/transcription/types.d.ts +2 -1
  15. package/dist/types/utils/aws-profile.d.ts +5 -0
  16. package/package.json +6 -6
  17. package/src/auth/affinity.ts +33 -24
  18. package/src/auth/blocks.ts +90 -14
  19. package/src/auth/cascade.ts +1 -1
  20. package/src/auth/select.ts +27 -2
  21. package/src/auth/sqlite-credential-store.ts +85 -42
  22. package/src/auth/store.ts +6 -0
  23. package/src/auth/usage-cache.ts +16 -5
  24. package/src/auth/usage.ts +1 -1
  25. package/src/auth-broker/discover.ts +5 -4
  26. package/src/auth-broker/remote-store.ts +117 -46
  27. package/src/auth-broker/server.ts +283 -123
  28. package/src/auth-broker/snapshot-cache.ts +142 -2
  29. package/src/auth-gateway/server.ts +19 -5
  30. package/src/auth-gateway/session-state.ts +51 -9
  31. package/src/dialect/deepseek.ts +10 -2
  32. package/src/dialect/gemini.ts +8 -2
  33. package/src/dialect/hermes.ts +8 -2
  34. package/src/dialect/kimi.ts +8 -2
  35. package/src/dialect/qwen-xml.ts +8 -2
  36. package/src/dialect/qwen3.ts +8 -2
  37. package/src/dialect/terminator-wait.ts +42 -0
  38. package/src/env-api-key.ts +78 -0
  39. package/src/images/openai-hosted.ts +11 -4
  40. package/src/images/openai-images.ts +19 -13
  41. package/src/images/shared.ts +10 -2
  42. package/src/index.ts +1 -0
  43. package/src/providers/anthropic.ts +39 -13
  44. package/src/providers/apple-foundation-models.ts +10 -4
  45. package/src/providers/azure-openai-responses.ts +1 -1
  46. package/src/providers/connect-frame.ts +107 -0
  47. package/src/providers/cursor/interaction-query.ts +1 -9
  48. package/src/providers/cursor.ts +87 -47
  49. package/src/providers/devin.ts +32 -41
  50. package/src/providers/gitlab-duo-workflow.ts +5 -2
  51. package/src/providers/google.ts +1 -1
  52. package/src/providers/ollama.ts +1 -1
  53. package/src/providers/openai-codex-responses.ts +26 -18
  54. package/src/providers/openai-completions.ts +1 -1
  55. package/src/providers/openai-responses.ts +5 -4
  56. package/src/providers/openai-shared.ts +53 -18
  57. package/src/providers/transcriptions-server.ts +1 -1
  58. package/src/providers/transform-messages.ts +17 -13
  59. package/src/registry/aws.ts +51 -14
  60. package/src/registry/cloudflare-ai-gateway.ts +24 -1
  61. package/src/stream.ts +3 -72
  62. package/src/transcription/types.ts +2 -1
  63. package/src/utils/aws-profile.ts +10 -4
  64. package/src/utils/thinking-loop.ts +18 -3
  65. package/src/utils.ts +22 -6
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.8.0] - 2026-10-07
6
+
7
+ ### Breaking Changes
8
+
9
+ - The environment API-key helpers (`getEnvApiKey`, `getEnvApiKeyName`, and `listProvidersWithEnvKey`) are no longer exported from `@oh-my-pi/pi-ai/stream`; import them from `@oh-my-pi/pi-ai` or `@oh-my-pi/pi-ai/env-api-key` instead.
10
+ - `TranscriptionRequest.audio` now accepts `Uint8Array | Blob`. Consumers must handle `Blob` values when reading transcription requests.
11
+
12
+ ### Changed
13
+
14
+ - Improved CPU and memory efficiency across streamed model responses, including Cursor, Devin, Codex, OpenAI Responses, and GitLab Duo Workflow.
15
+ - Improved request and authentication performance, including account ranking, OAuth preflight, credential rate-limit checks, credential synchronization, and auth gateway requests.
16
+ - Improved tool-call parsing performance for long calls and Apple Foundation Models requests.
17
+ - Improved Cloudflare AI Gateway request performance and AWS credential-source detection.
18
+ - Reduced memory usage when handling generated images and usage reports.
19
+
20
+ ### Fixed
21
+
22
+ - Fixed false thinking-loop detections for Gemini, DeepSeek, and Grok when responses contain repetitive code or markup such as VRML, SVG, or JSON; valid output is no longer discarded and retried.
23
+ - Fixed the Cursor provider retaining request resources after requests completed.
24
+ - Fixed session-affinity pins growing without bound in long-lived gateways; pins are now capped at 256 sessions per provider.
25
+ - Fixed Anthropic sessions failing every request with HTTP 400 ("role 'system' must precede an 'assistant' message") after a tool change coincided with compaction or an interrupted or failed reply; sessions already stuck this way recover on the next message ([#14746](https://github.com/can1357/oh-my-pi/issues/14746)).
26
+
5
27
  ## [18.7.0] - 2026-10-06
6
28
 
7
29
  ### Added
@@ -60,6 +60,7 @@ export declare class SqliteAuthCredentialStore implements AuthCredentialStore {
60
60
  deleteCachePrefix(prefix: string): void;
61
61
  cleanExpiredCache(): void;
62
62
  getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined;
63
+ getCredentialBlockScopes(credentialId: number, providerKey: string): Map<string, number>;
63
64
  getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined;
64
65
  upsertCredentialBlock(block: StoredCredentialBlock): void;
65
66
  deleteCredentialBlock(credentialId: number, providerKey: string, blockScope: string): void;
@@ -71,6 +71,12 @@ export interface CredentialCacheStore {
71
71
  export interface CredentialBlockStore {
72
72
  /** Non-expired block for one (credential, providerKey, scope) key, or undefined. */
73
73
  getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined;
74
+ /**
75
+ * Non-expired blocks for one credential and providerKey, keyed by block
76
+ * scope (`""` is unscoped), in one read. Optional: selectors fall back to
77
+ * {@link CredentialBlockStore.getCredentialBlock} per scope when absent.
78
+ */
79
+ getCredentialBlockScopes?(credentialId: number, providerKey: string): ReadonlyMap<string, number>;
74
80
  /** Earliest time a shared-store block should be eligible for live-usage reconciliation. */
75
81
  getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined;
76
82
  /** Upsert with MAX semantics: keep the later blockedUntilMs on conflict. */
@@ -73,6 +73,7 @@ export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
73
73
  /** Broker-backed disabled tombstones; empty against brokers predating the endpoint. */
74
74
  listDisabledCredentials(provider?: string, signal?: AbortSignal): Promise<DisabledCredentialSummary[]>;
75
75
  getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined;
76
+ getCredentialBlockScopes(credentialId: number, providerKey: string): Map<string, number>;
76
77
  getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined;
77
78
  listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[];
78
79
  upsertCredentialBlock(block: StoredCredentialBlock): void;
@@ -15,3 +15,15 @@ export interface WriteAuthBrokerSnapshotCacheOptions {
15
15
  }
16
16
  export declare function readAuthBrokerSnapshotCache(opts: ReadAuthBrokerSnapshotCacheOptions): Promise<SnapshotResponse | null>;
17
17
  export declare function writeAuthBrokerSnapshotCache(opts: WriteAuthBrokerSnapshotCacheOptions): Promise<void>;
18
+ /**
19
+ * Fire-and-forget cache write for snapshot callbacks. The first snapshot after
20
+ * a quiet period is written immediately; later ones within
21
+ * {@link WRITE_COALESCE_MS} collapse into a single trailing write of the
22
+ * newest. A snapshot whose generation and credential content match the last
23
+ * write is skipped (see {@link UNCHANGED_REWRITE_MS}). Pending writes are
24
+ * flushed on process shutdown and by {@link flushAuthBrokerSnapshotCacheWrites};
25
+ * failures are logged, never thrown.
26
+ */
27
+ export declare function scheduleAuthBrokerSnapshotCacheWrite(opts: WriteAuthBrokerSnapshotCacheOptions): void;
28
+ /** Write every pending scheduled snapshot now and wait for in-flight writes to settle. */
29
+ export declare function flushAuthBrokerSnapshotCacheWrites(): Promise<void>;
@@ -63,6 +63,12 @@ export interface AuthGatewaySessionStateRequest {
63
63
  * continues.
64
64
  */
65
65
  context: Context;
66
+ /**
67
+ * `JSON.stringify(context.tools)` when the caller already serialized it
68
+ * (the gateway derives its `sessionId` from the same string). Recomputed
69
+ * from `context` when absent.
70
+ */
71
+ toolsJson?: string;
66
72
  /**
67
73
  * Stable identity of the account this request's credential resolved to.
68
74
  * A change means the gateway switched the session to a sibling credential,
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Fast path for in-band scanners that sit inside a body waiting for one fixed
3
+ * terminator (a tool-call close tag) and emit nothing until it arrives.
4
+ *
5
+ * Appending each delta to the scanner's string buffer and calling `indexOf` on
6
+ * it flattens the growing rope on every delta, so a long in-band tool call cost
7
+ * O(n²) copying and scanning. While armed, deltas are set aside and only the new
8
+ * text plus the terminator-length overlap with what came before is searched;
9
+ * the buffer is rebuilt once, when the terminator shows up or the stream ends.
10
+ */
11
+ export declare class TerminatorWait {
12
+ #private;
13
+ /** Start deferring; `buffered` is the scanner's pending body, known not to contain `terminator`. */
14
+ arm(terminator: string, buffered: string): void;
15
+ /** Take `text` while the terminator is still absent; false means the caller must {@link release} and scan. */
16
+ absorb(text: string): boolean;
17
+ /** Stop deferring and return `buffered` with every absorbed delta appended. */
18
+ release(buffered: string): string;
19
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Get API key for provider from known environment variables, e.g. OPENAI_API_KEY.
3
+ *
4
+ * Will not return API keys for providers that require OAuth tokens.
5
+ * Checks Bun.env, then cwd/.env, then ~/.env.
6
+ */
7
+ export declare function getEnvApiKey(provider: string): string | undefined;
8
+ /**
9
+ * Name of the environment variable that backs `getEnvApiKey` for a provider,
10
+ * when that provider maps to a single named variable (e.g. `github-copilot` →
11
+ * `COPILOT_GITHUB_TOKEN`). Returns undefined for providers whose env fallback
12
+ * is computed (multi-var pickers, Vertex ADC / Bedrock probes, …) since no
13
+ * single variable name describes the source.
14
+ */
15
+ export declare function getEnvApiKeyName(provider: string): string | undefined;
16
+ /**
17
+ * Enumerate every provider that has an env-var fallback for `getEnvApiKey`.
18
+ * Used by `omp auth-broker migrate --include-env` to discover env-sourced keys
19
+ * that should be uploaded to the broker.
20
+ */
21
+ export declare function listProvidersWithEnvKey(): string[];
@@ -32,6 +32,12 @@ export declare function decodeImageResponse(value: unknown, fetch: FetchImpl, si
32
32
  images: GeneratedImage[];
33
33
  usage: Usage;
34
34
  }>;
35
+ /**
36
+ * Mime type of a base64 image, read from its header only. 64 base64 chars
37
+ * decode to 48 bytes, past every magic number `parseImageMetadata` checks, so
38
+ * multi-MB payloads are never fully decoded just to be labelled.
39
+ */
40
+ export declare function sniffBase64ImageMimeType(base64: string): string | undefined;
35
41
  export declare function toDataUrl(image: GeneratedImage): string;
36
42
  export declare function resolveOpenAIImageSize(aspectRatio?: string, imageSize?: string): string | undefined;
37
43
  export {};
@@ -39,6 +39,7 @@ export type * from "./providers/openai-responses.js";
39
39
  export type * from "./providers/synthetic.js";
40
40
  export * from "./registry/index.js";
41
41
  export { resolveCacheRetention } from "./utils.js";
42
+ export * from "./env-api-key.js";
42
43
  export * from "./stream.js";
43
44
  export * from "./types.js";
44
45
  export * from "./usage.js";
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Connect-protocol streaming envelope shared by the Cursor and Devin
3
+ * transports: 1 flag byte + 4-byte big-endian payload length + payload.
4
+ */
5
+ /** Flag bit: payload is compressed with the negotiated encoding (gzip). */
6
+ export declare const CONNECT_COMPRESSED_FLAG = 1;
7
+ /** Flag bit: payload is the end-of-stream JSON trailer. */
8
+ export declare const CONNECT_END_STREAM_FLAG = 2;
9
+ /** Wrap one Connect-protocol message: 1 flag byte + 4-byte big-endian length + payload. */
10
+ export declare function frameConnectMessage(data: Uint8Array, flags?: number): Buffer;
11
+ /** One decoded Connect envelope. */
12
+ export interface ConnectFrame {
13
+ flags: number;
14
+ payload: Buffer;
15
+ }
16
+ /** Options for {@link ConnectFrameDecoder}. */
17
+ export interface ConnectFrameDecoderOptions {
18
+ /**
19
+ * Payload cap: a length prefix above `maxPayloadBytes` throws `error(length)`
20
+ * before any of that payload is buffered. Uncapped when omitted.
21
+ */
22
+ limit?: {
23
+ maxPayloadBytes: number;
24
+ error: (length: number) => Error;
25
+ };
26
+ }
27
+ /**
28
+ * Incremental Connect frame decoder. Incoming chunks are kept as-is and a frame
29
+ * spanning several chunks is joined once, when the length the header announced
30
+ * has fully arrived — re-concatenating the pending bytes on every chunk made a
31
+ * multi-MB frame cost O(size × chunks).
32
+ *
33
+ * Payloads alias the chunk they came from whenever a frame fits in one chunk,
34
+ * so callers must not reuse chunk buffers they hand in.
35
+ */
36
+ export declare class ConnectFrameDecoder {
37
+ #private;
38
+ constructor(options?: ConnectFrameDecoderOptions);
39
+ /**
40
+ * Buffer `chunk` (if any) and yield every frame now complete, in wire order.
41
+ * An oversize length prefix throws after the frames that precede it.
42
+ */
43
+ decode(chunk?: Uint8Array): Generator<ConnectFrame, void, undefined>;
44
+ }
@@ -7,8 +7,6 @@ type ProtoUnknownField = {
7
7
  interface CursorInteractionWriter {
8
8
  write(frame: Uint8Array): void;
9
9
  }
10
- /** Wrap one Connect-protocol message: 1 flag byte + 4-byte big-endian length + payload. */
11
- export declare function frameConnectMessage(data: Uint8Array, flags?: number): Buffer;
12
10
  /** Well-formed protobuf-es `$unknown` entries on `message`; anything else on the bag is ignored. */
13
11
  export declare function protoUnknownFields(message: object): ProtoUnknownField[];
14
12
  /**
@@ -31,27 +31,6 @@ export declare const __providerInFlightForTesting: {
31
31
  captureStaleLockRelease(provider: string): Promise<(() => Promise<void>) | null>;
32
32
  captureLockDirRelease(provider: string): Promise<(() => Promise<void>) | null>;
33
33
  };
34
- /**
35
- * Get API key for provider from known environment variables, e.g. OPENAI_API_KEY.
36
- *
37
- * Will not return API keys for providers that require OAuth tokens.
38
- * Checks Bun.env, then cwd/.env, then ~/.env.
39
- */
40
- export declare function getEnvApiKey(provider: string): string | undefined;
41
- /**
42
- * Name of the environment variable that backs `getEnvApiKey` for a provider,
43
- * when that provider maps to a single named variable (e.g. `github-copilot` →
44
- * `COPILOT_GITHUB_TOKEN`). Returns undefined for providers whose env fallback
45
- * is computed (multi-var pickers, Vertex ADC / Bedrock probes, …) since no
46
- * single variable name describes the source.
47
- */
48
- export declare function getEnvApiKeyName(provider: string): string | undefined;
49
- /**
50
- * Enumerate every provider that has an env-var fallback for `getEnvApiKey`.
51
- * Used by `omp auth-broker migrate --include-env` to discover env-sourced keys
52
- * that should be uploaded to the broker.
53
- */
54
- export declare function listProvidersWithEnvKey(): string[];
55
34
  export declare function stream<TApi extends Api>(model: Model<TApi>, context: Context, options?: OptionsForApi<TApi>): AssistantMessageEventStream;
56
35
  export declare function complete<TApi extends Api>(model: Model<TApi>, context: Context, options?: OptionsForApi<TApi>): Promise<AssistantMessage>;
57
36
  export declare function streamSimple<TApi extends Api>(model: Model<TApi>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
@@ -2,7 +2,8 @@ import type { Usage } from "@oh-my-pi/pi-catalog/types";
2
2
  export type TranscriptionResponseFormat = "json" | "verbose_json";
3
3
  export type TranscriptionTimestampGranularity = "word" | "segment";
4
4
  export interface TranscriptionRequest {
5
- audio: Uint8Array;
5
+ /** Audio bytes. A `Blob`/`File` (e.g. a parsed multipart upload) is sent without copying. */
6
+ audio: Uint8Array | Blob;
6
7
  mimeType: string;
7
8
  fileName?: string;
8
9
  language?: string;
@@ -9,6 +9,11 @@ export declare function resolveAwsProfile(profile?: string): string;
9
9
  * the AWS SDK's `AWS_SDK_LOAD_CONFIG` opt-in.
10
10
  */
11
11
  export declare function shouldLoadAwsSharedConfig(profile?: string): boolean;
12
+ /** Shared credentials and config file locations, honoring their env overrides. */
13
+ export declare function awsSharedFilePaths(): {
14
+ credentialsPath: string;
15
+ configPath: string;
16
+ };
12
17
  export declare function resolveAwsProfileRegion(profile?: string): string | undefined;
13
18
  /** Region selected by the environment or active shared-config profile. */
14
19
  export declare function resolveAwsAmbientRegion(profile?: string): string | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.7.0",
3
+ "version": "18.8.0",
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.7.0",
159
- "@oh-my-pi/pi-catalog": "18.7.0",
160
- "@oh-my-pi/pi-natives": "18.7.0",
161
- "@oh-my-pi/pi-utils": "18.7.0",
162
- "@oh-my-pi/pi-wire": "18.7.0"
158
+ "@oh-my-pi/omptype": "18.8.0",
159
+ "@oh-my-pi/pi-catalog": "18.8.0",
160
+ "@oh-my-pi/pi-natives": "18.8.0",
161
+ "@oh-my-pi/pi-utils": "18.8.0",
162
+ "@oh-my-pi/pi-wire": "18.8.0"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -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
  /**