@linxiraos/pi-ai 1.1.26 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/CHANGELOG.md +9 -8
  2. package/README.md +15 -0
  3. package/dist/types/auth/affinity.d.ts +33 -6
  4. package/dist/types/auth/cascade.d.ts +44 -7
  5. package/dist/types/auth/oauth.d.ts +9 -6
  6. package/dist/types/auth/policy.d.ts +1 -0
  7. package/dist/types/auth/refresh.d.ts +1 -2
  8. package/dist/types/auth/select.d.ts +6 -4
  9. package/dist/types/auth/sqlite-credential-store.d.ts +21 -1
  10. package/dist/types/auth/store.d.ts +8 -2
  11. package/dist/types/auth/types.d.ts +99 -15
  12. package/dist/types/auth-broker/client.d.ts +3 -3
  13. package/dist/types/auth-broker/discover.d.ts +1 -1
  14. package/dist/types/auth-broker/refresher.d.ts +6 -1
  15. package/dist/types/auth-broker/remote-store.d.ts +6 -4
  16. package/dist/types/auth-broker/server.d.ts +2 -0
  17. package/dist/types/auth-broker/snapshot-cache.d.ts +12 -0
  18. package/dist/types/auth-gateway/dispatch.d.ts +13 -5
  19. package/dist/types/auth-gateway/http.d.ts +0 -2
  20. package/dist/types/auth-gateway/index.d.ts +1 -0
  21. package/dist/types/auth-gateway/routes/embeddings.d.ts +2 -2
  22. package/dist/types/auth-gateway/routes/images.d.ts +3 -3
  23. package/dist/types/auth-gateway/routes/rerank.d.ts +2 -2
  24. package/dist/types/auth-gateway/routes/speech.d.ts +2 -2
  25. package/dist/types/auth-gateway/routes/systemone.d.ts +2 -2
  26. package/dist/types/auth-gateway/routes/transcriptions.d.ts +2 -2
  27. package/dist/types/auth-gateway/routes/video.d.ts +4 -4
  28. package/dist/types/auth-gateway/server.d.ts +22 -5
  29. package/dist/types/auth-gateway/session-state.d.ts +6 -0
  30. package/dist/types/auth-gateway/stdio.d.ts +12 -0
  31. package/dist/types/auth-storage.d.ts +7 -6
  32. package/dist/types/dialect/terminator-wait.d.ts +19 -0
  33. package/dist/types/env-api-key.d.ts +21 -0
  34. package/dist/types/error/auth.d.ts +10 -0
  35. package/dist/types/error/flags.d.ts +38 -0
  36. package/dist/types/images/index.d.ts +2 -3
  37. package/dist/types/images/shared.d.ts +6 -0
  38. package/dist/types/index.d.ts +2 -1
  39. package/dist/types/judgment/index.d.ts +2 -1
  40. package/dist/types/judgment/native.d.ts +80 -0
  41. package/dist/types/judgment/openai-decisions.d.ts +3 -0
  42. package/dist/types/judgment/question-file.d.ts +52 -0
  43. package/dist/types/judgment/systemone.d.ts +7 -0
  44. package/dist/types/judgment/types.d.ts +2 -2
  45. package/dist/types/judgment/wire.d.ts +36 -0
  46. package/dist/types/provider-details.d.ts +1 -1
  47. package/dist/types/provider-session-state.d.ts +2 -2
  48. package/dist/types/providers/anthropic-client.d.ts +10 -0
  49. package/dist/types/providers/anthropic-state.d.ts +2 -0
  50. package/dist/types/providers/anthropic.d.ts +38 -3
  51. package/dist/types/providers/aws-credentials.d.ts +4 -8
  52. package/dist/types/providers/connect-error-detail.d.ts +9 -0
  53. package/dist/types/providers/connect-frame.d.ts +44 -0
  54. package/dist/types/providers/cursor/interaction-query.d.ts +0 -2
  55. package/dist/types/providers/cursor.d.ts +8 -8
  56. package/dist/types/providers/google-auth.d.ts +4 -1
  57. package/dist/types/providers/google-shared.d.ts +24 -4
  58. package/dist/types/providers/inference-headers.d.ts +6 -0
  59. package/dist/types/providers/mock.d.ts +3 -1
  60. package/dist/types/providers/openai-codex/request-transformer.d.ts +2 -0
  61. package/dist/types/providers/openai-codex-responses.d.ts +1 -1
  62. package/dist/types/providers/openai-configuration-update.d.ts +20 -11
  63. package/dist/types/providers/openai-responses-wire.d.ts +1 -55
  64. package/dist/types/providers/openai-responses.d.ts +40 -3
  65. package/dist/types/providers/openai-shared.d.ts +30 -5
  66. package/dist/types/providers/pi-native-client.d.ts +1 -1
  67. package/dist/types/providers/transform-messages.d.ts +25 -0
  68. package/dist/types/providers/vision-guard.d.ts +1 -1
  69. package/dist/types/registry/oauth/cloudflare-workers-ai.d.ts +3 -0
  70. package/dist/types/registry/oauth/cursor.d.ts +10 -2
  71. package/dist/types/registry/oauth/index.d.ts +2 -0
  72. package/dist/types/registry/oauth/snowflake.d.ts +3 -0
  73. package/dist/types/registry/snowflake.d.ts +4 -0
  74. package/dist/types/stream.d.ts +0 -21
  75. package/dist/types/transcription/types.d.ts +2 -1
  76. package/dist/types/types.d.ts +116 -14
  77. package/dist/types/usage.d.ts +2 -0
  78. package/dist/types/utils/anthropic-auth.d.ts +21 -7
  79. package/dist/types/utils/aws-profile.d.ts +5 -0
  80. package/dist/types/utils/block-symbols.d.ts +14 -0
  81. package/dist/types/utils/dsml-leak.d.ts +29 -0
  82. package/dist/types/utils/glyph-codec.d.ts +1 -1
  83. package/dist/types/utils/idle-iterator.d.ts +11 -0
  84. package/dist/types/utils/leaked-thinking-stream.d.ts +1 -1
  85. package/dist/types/utils/resolve-peer.d.ts +2 -0
  86. package/dist/types/utils/retry-wait.d.ts +9 -0
  87. package/dist/types/utils/schema/normalize.d.ts +8 -3
  88. package/dist/types/utils/stream-markup-healing.d.ts +4 -4
  89. package/dist/types/utils/tool-call-arguments.d.ts +8 -0
  90. package/dist/types/utils.d.ts +6 -0
  91. package/package.json +6 -6
  92. package/src/auth/affinity.ts +130 -37
  93. package/src/auth/blocks.ts +94 -15
  94. package/src/auth/cascade.ts +112 -17
  95. package/src/auth/health.ts +3 -2
  96. package/src/auth/oauth.ts +25 -10
  97. package/src/auth/policy.ts +10 -1
  98. package/src/auth/pool.ts +0 -5
  99. package/src/auth/refresh.ts +17 -12
  100. package/src/auth/resets.ts +1 -1
  101. package/src/auth/rotation.ts +8 -2
  102. package/src/auth/select.ts +102 -30
  103. package/src/auth/sqlite-credential-store.ts +119 -49
  104. package/src/auth/store.ts +8 -2
  105. package/src/auth/types.ts +108 -17
  106. package/src/auth/usage-cache.ts +16 -5
  107. package/src/auth/usage.ts +2 -2
  108. package/src/auth-broker/client.ts +7 -4
  109. package/src/auth-broker/discover.ts +6 -5
  110. package/src/auth-broker/refresher.ts +8 -1
  111. package/src/auth-broker/remote-store.ts +200 -62
  112. package/src/auth-broker/server.ts +316 -130
  113. package/src/auth-broker/snapshot-cache.ts +142 -2
  114. package/src/auth-gateway/dispatch.ts +15 -7
  115. package/src/auth-gateway/http.ts +0 -8
  116. package/src/auth-gateway/index.ts +1 -0
  117. package/src/auth-gateway/routes/embeddings.ts +2 -2
  118. package/src/auth-gateway/routes/images.ts +4 -4
  119. package/src/auth-gateway/routes/rerank.ts +2 -2
  120. package/src/auth-gateway/routes/speech.ts +2 -2
  121. package/src/auth-gateway/routes/systemone.ts +22 -14
  122. package/src/auth-gateway/routes/transcriptions.ts +2 -2
  123. package/src/auth-gateway/routes/video.ts +7 -7
  124. package/src/auth-gateway/server.ts +175 -134
  125. package/src/auth-gateway/session-state.ts +51 -9
  126. package/src/auth-gateway/stdio.ts +132 -0
  127. package/src/auth-storage.ts +23 -9
  128. package/src/dialect/deepseek.ts +67 -8
  129. package/src/dialect/gemini.ts +8 -2
  130. package/src/dialect/hermes.ts +8 -2
  131. package/src/dialect/kimi.ts +8 -2
  132. package/src/dialect/qwen-xml.ts +8 -2
  133. package/src/dialect/qwen3.ts +8 -2
  134. package/src/dialect/terminator-wait.ts +42 -0
  135. package/src/env-api-key.ts +78 -0
  136. package/src/error/auth.ts +20 -0
  137. package/src/error/flags.ts +143 -24
  138. package/src/error/rate-limit.ts +8 -6
  139. package/src/images/google-antigravity.ts +0 -1
  140. package/src/images/index.ts +3 -17
  141. package/src/images/openai-hosted.ts +13 -4
  142. package/src/images/openai-images.ts +19 -13
  143. package/src/images/shared.ts +10 -2
  144. package/src/index.ts +2 -1
  145. package/src/judgment/index.ts +2 -1
  146. package/src/judgment/native.ts +333 -0
  147. package/src/judgment/openai-decisions-predicate.md +9 -0
  148. package/src/judgment/openai-decisions.ts +129 -0
  149. package/src/judgment/question-file.ts +90 -0
  150. package/src/judgment/systemone.ts +65 -0
  151. package/src/judgment/text.ts +9 -1
  152. package/src/judgment/types.ts +2 -2
  153. package/src/judgment/wire.ts +42 -0
  154. package/src/provider-details.ts +1 -1
  155. package/src/provider-session-state.ts +2 -2
  156. package/src/providers/amazon-bedrock.ts +19 -8
  157. package/src/providers/anthropic-client.ts +16 -1
  158. package/src/providers/anthropic-state.ts +6 -0
  159. package/src/providers/anthropic.ts +962 -238
  160. package/src/providers/apple-foundation-models.ts +10 -4
  161. package/src/providers/aws-credentials.ts +65 -5
  162. package/src/providers/azure-openai-responses.ts +1 -1
  163. package/src/providers/connect-error-detail.ts +21 -0
  164. package/src/providers/connect-frame.ts +107 -0
  165. package/src/providers/cursor/interaction-query.ts +1 -9
  166. package/src/providers/cursor.ts +590 -133
  167. package/src/providers/devin.ts +68 -58
  168. package/src/providers/factory-droid/gemini.ts +38 -51
  169. package/src/providers/factory-droid.ts +2 -0
  170. package/src/providers/gitlab-duo-workflow.ts +5 -2
  171. package/src/providers/gitlab-duo.ts +1 -0
  172. package/src/providers/google-auth.ts +10 -3
  173. package/src/providers/google-gemini-cli.ts +4 -0
  174. package/src/providers/google-shared.ts +134 -105
  175. package/src/providers/google-vertex.ts +0 -1
  176. package/src/providers/google.ts +1 -1
  177. package/src/providers/inference-headers.ts +19 -0
  178. package/src/providers/mock.ts +4 -0
  179. package/src/providers/ollama.ts +1 -1
  180. package/src/providers/openai-anthropic-shim.ts +1 -0
  181. package/src/providers/openai-codex/request-transformer.ts +16 -11
  182. package/src/providers/openai-codex-responses.ts +201 -76
  183. package/src/providers/openai-completions.ts +12 -2
  184. package/src/providers/openai-configuration-update.ts +54 -15
  185. package/src/providers/openai-responses-wire.ts +1 -55
  186. package/src/providers/openai-responses.ts +437 -33
  187. package/src/providers/openai-shared.ts +267 -72
  188. package/src/providers/pi-native-client.ts +2 -2
  189. package/src/providers/pi-native-server.ts +1 -0
  190. package/src/providers/systemone-server.ts +1 -1
  191. package/src/providers/transcriptions-server.ts +1 -1
  192. package/src/providers/transform-messages.ts +240 -84
  193. package/src/providers/vision-guard.ts +1 -1
  194. package/src/registry/aws.ts +51 -14
  195. package/src/registry/cloudflare-ai-gateway.ts +26 -3
  196. package/src/registry/hooks/api-key.ts +1 -0
  197. package/src/registry/hooks/custom.ts +2 -0
  198. package/src/registry/hooks/env.ts +2 -5
  199. package/src/registry/oauth/callback-server.ts +6 -1
  200. package/src/registry/oauth/cloudflare-ai-gateway.ts +2 -2
  201. package/src/registry/oauth/cloudflare-workers-ai.ts +32 -0
  202. package/src/registry/oauth/cursor.ts +80 -17
  203. package/src/registry/oauth/index.ts +5 -0
  204. package/src/registry/oauth/snowflake.ts +183 -0
  205. package/src/registry/registry.ts +2 -0
  206. package/src/registry/snowflake.ts +149 -0
  207. package/src/stream.ts +85 -107
  208. package/src/transcription/types.ts +2 -1
  209. package/src/types.ts +142 -15
  210. package/src/usage/claude.ts +19 -12
  211. package/src/usage/commandcode.ts +1 -1
  212. package/src/usage/cursor.ts +14 -25
  213. package/src/usage/github-copilot.ts +1 -1
  214. package/src/usage/kimi.ts +1 -1
  215. package/src/usage/minimax-code.ts +8 -1
  216. package/src/usage.ts +8 -0
  217. package/src/utils/anthropic-auth.ts +34 -10
  218. package/src/utils/aws-profile.ts +10 -4
  219. package/src/utils/block-symbols.ts +17 -0
  220. package/src/utils/dsml-leak.ts +109 -0
  221. package/src/utils/empty-completion-retry.ts +2 -3
  222. package/src/utils/glyph-codec.ts +200 -7
  223. package/src/utils/idle-iterator.ts +80 -35
  224. package/src/utils/leaked-thinking-stream.ts +47 -25
  225. package/src/utils/resolve-peer.ts +7 -0
  226. package/src/utils/retry-wait.ts +19 -0
  227. package/src/utils/schema/CONSTRAINTS.md +9 -7
  228. package/src/utils/schema/fields.ts +20 -0
  229. package/src/utils/schema/normalize.ts +65 -23
  230. package/src/utils/schema/wire.ts +3 -3
  231. package/src/utils/stream-markup-healing.ts +4 -4
  232. package/src/utils/thinking-loop.ts +36 -13
  233. package/src/utils/tool-call-arguments.ts +16 -0
  234. package/src/utils/transport-fetch.ts +1 -1
  235. package/src/utils.ts +41 -10
  236. package/dist/types/judgment/typesafe.d.ts +0 -49
  237. package/src/judgment/typesafe.ts +0 -183
@@ -28,7 +28,7 @@ import {
28
28
  import * as AIError from "../error";
29
29
  import { AuthBrokerClient, AuthBrokerError } from "./client";
30
30
  import { type AuthBrokerAccountPool, RemoteAuthCredentialStore } from "./remote-store";
31
- import { readAuthBrokerSnapshotCache, writeAuthBrokerSnapshotCache } from "./snapshot-cache";
31
+ import { readAuthBrokerSnapshotCache, scheduleAuthBrokerSnapshotCacheWrite } from "./snapshot-cache";
32
32
  import { DEFAULT_SNAPSHOT_CACHE_TTL_MS, type SnapshotResponse } from "./types";
33
33
 
34
34
  export interface AuthBrokerClientConfig {
@@ -52,7 +52,7 @@ export interface DiscoverAuthStorageOptions {
52
52
  authStorageOptions?: Omit<AuthStorageOptions, "accountPolicies" | "configValueResolver" | "sourceLabel">;
53
53
  }
54
54
 
55
- /** Path to the local bearer token file. Created by `omp auth-broker token`. */
55
+ /** Path to the local bearer token file. Created by `zetacode auth-broker token`. */
56
56
  export function getAuthBrokerTokenFilePath(): string {
57
57
  return path.join(getConfigRootDir(), "auth-broker.token");
58
58
  }
@@ -396,16 +396,17 @@ export async function openAuthCredentialStore(
396
396
  const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token });
397
397
  const cachePath = options.cachePath ?? getAuthBrokerSnapshotCachePath();
398
398
  const ttlMs = resolveSnapshotTtlMs();
399
+ // Coalesced fire-and-forget: the first snapshot lands immediately (so a
400
+ // fresh boot seeds the cache), bursts collapse into one trailing write,
401
+ // and anything pending is flushed on shutdown.
399
402
  const persist =
400
403
  ttlMs > 0
401
404
  ? (snapshot: SnapshotResponse): void => {
402
- void writeAuthBrokerSnapshotCache({
405
+ scheduleAuthBrokerSnapshotCacheWrite({
403
406
  path: cachePath,
404
407
  token: brokerConfig.token,
405
408
  url: brokerConfig.url,
406
409
  snapshot,
407
- }).catch(error => {
408
- logger.debug("auth-broker snapshot cache write failed", { error: String(error) });
409
410
  });
410
411
  }
411
412
  : undefined;
@@ -75,7 +75,12 @@ export class AuthBrokerRefresher {
75
75
  };
76
76
  }
77
77
 
78
- /** Run one sweep. Exposed for tests. */
78
+ /**
79
+ * Run one sweep. Exposed for tests.
80
+ *
81
+ * Never rejects: callers fire it unawaited from a timer, so a failed sweep
82
+ * (e.g. an unreadable credential store) is logged and retried next interval.
83
+ */
79
84
  async tick(): Promise<void> {
80
85
  if (this.#running) return;
81
86
  this.#running = true;
@@ -94,6 +99,8 @@ export class AuthBrokerRefresher {
94
99
  targets.push(entry.id);
95
100
  }
96
101
  await Promise.all(targets.map(id => this.#refreshOne(id)));
102
+ } catch (error) {
103
+ logger.warn("auth-broker refresh sweep failed", { error: String(error) });
97
104
  } finally {
98
105
  this.#running = false;
99
106
  this.#nextSweepAt = this.#now() + this.#refreshIntervalMs;
@@ -8,7 +8,7 @@
8
8
  * runs isn't required.
9
9
  */
10
10
  import * as os from "node:os";
11
- import { getAppName, getInstallId, logger } from "@linxiraos/pi-utils";
11
+ import { getAppName, getInstallId, logger, postmortem } from "@linxiraos/pi-utils";
12
12
  import type { AuthCredentialStore } from "../auth/store";
13
13
  import {
14
14
  type AuthCredential,
@@ -69,6 +69,8 @@ const BACKGROUND_BACKOFF_INITIAL_MS = 500;
69
69
  const BACKGROUND_BACKOFF_MAX_MS = 30_000;
70
70
  /** Idle window after the last foreground store use before background sync parks. */
71
71
  const BACKGROUND_IDLE_MS = 20_000;
72
+ /** Longest a process exit waits for the final observed-usage report to reach the broker. */
73
+ const OBSERVED_USAGE_EXIT_FLUSH_MS = 2_000;
72
74
 
73
75
  function toCredentialBlockSnapshot(block: StoredCredentialBlock): CredentialBlockSnapshot {
74
76
  return {
@@ -124,6 +126,14 @@ function credentialEntryWithBlocks(
124
126
  return incoming;
125
127
  }
126
128
 
129
+ /**
130
+ * Change-detection hash of one row's routable credential material (provider +
131
+ * credential; the id is the map key). Used only for equality, never persisted.
132
+ */
133
+ function credentialContentHash(entry: SnapshotEntry): number | bigint {
134
+ return Bun.hash(`${entry.provider}\u0000${JSON.stringify(entry.credential)}`);
135
+ }
136
+
127
137
  function emptySnapshot(): SnapshotResponse {
128
138
  return {
129
139
  generation: 0,
@@ -255,14 +265,19 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
255
265
  #snapshotReceivedAt = Date.now();
256
266
  #generation = 0;
257
267
  /**
258
- * Content fingerprint of the credential set in {@link #snapshot} (id +
259
- * provider + credential material), recomputed after every snapshot mutation.
260
- * Drives {@link #credentialRevision} independently of the broker's numeric
268
+ * Content hash of each row's routable credential material, keyed by id —
269
+ * exactly what {@link listAuthCredentials} exposes (id, provider,
270
+ * credential); blocks and usage overlays are excluded. Drives
271
+ * {@link #credentialRevision} independently of the broker's numeric
261
272
  * generation, which is an in-memory counter that resets when the broker
262
- * process restarts and so cannot be trusted for change detection.
273
+ * process restarts and so cannot be trusted for change detection. Full
274
+ * snapshots rehash every row; deltas and local writes rehash only the ids
275
+ * they touched (see {@link #dirtyCredentialIds}).
263
276
  */
264
- #credentialFingerprint = "";
265
- /** Monotonic local counter bumped whenever {@link #credentialFingerprint} changes. */
277
+ #credentialHashes = new Map<number, number | bigint>();
278
+ /** Ids whose row changed since {@link #credentialHashes} was last reconciled. */
279
+ #dirtyCredentialIds = new Set<number>();
280
+ /** Monotonic local counter bumped whenever {@link #credentialHashes} changes. */
266
281
  #credentialRevision = 0;
267
282
  /** Revision last reported as "seen" by {@link pollExternalChanges}; seeded from the initial snapshot. */
268
283
  #acknowledgedRevision = 0;
@@ -305,6 +320,10 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
305
320
  readonly #observedUsageFlushMs: number;
306
321
  /** Latched once the broker answered 404 — old broker, never report again. */
307
322
  #observedUsageUnsupported = false;
323
+ /** Tail of the serialized observed-usage sends; each flush waits for the one before it. */
324
+ #observedUsageFlush: Promise<void> = Promise.resolve();
325
+ /** Cancels the exit flush registered with the first buffered usage. */
326
+ #cancelObservedUsageExitFlush: (() => void) | undefined;
308
327
 
309
328
  constructor(opts: RemoteAuthCredentialStoreOptions) {
310
329
  this.#client = opts.client;
@@ -331,6 +350,9 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
331
350
 
332
351
  #applySnapshot(snapshot: SnapshotResponse, generation: number, protectNewBlocks = true): void {
333
352
  const nowMs = Date.now();
353
+ // Reads check block expiry inline; expired rows and bookkeeping are pruned
354
+ // here so the block diff below compares only live rows.
355
+ this.cleanExpiredCredentialBlocks(nowMs);
334
356
  this.#replaceBrokerUsageAccounts(snapshot.credentials);
335
357
  const previousCredentials = this.#snapshot.credentials;
336
358
  const credentials = snapshot.credentials
@@ -341,7 +363,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
341
363
  this.#snapshot = { ...snapshot, credentials };
342
364
  this.#generation = generation;
343
365
  this.#snapshotReceivedAt = nowMs;
344
- this.#refreshCredentialRevision();
366
+ this.#rebuildCredentialHashes();
345
367
  const onSnapshot = this.#onSnapshot;
346
368
  if (!onSnapshot) return;
347
369
  try {
@@ -352,32 +374,52 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
352
374
  }
353
375
 
354
376
  /**
355
- * Recompute the credential-content fingerprint and bump
356
- * {@link #credentialRevision} when it changes. Called after every snapshot
357
- * mutation so {@link pollExternalChanges} detects add/remove/replace even
358
- * when the broker's numeric generation repeats (e.g. after a broker
359
- * restart resets its in-memory counter).
377
+ * Rehash every row after a full snapshot apply and bump
378
+ * {@link #credentialRevision} when the routable credential set changed, so
379
+ * {@link pollExternalChanges} detects add/remove/replace even when the
380
+ * broker's numeric generation repeats (e.g. after a broker restart resets
381
+ * its in-memory counter).
360
382
  */
361
- #refreshCredentialRevision(): void {
362
- const fingerprint = this.#computeCredentialFingerprint();
363
- if (fingerprint === this.#credentialFingerprint) return;
364
- this.#credentialFingerprint = fingerprint;
365
- this.#credentialRevision += 1;
383
+ #rebuildCredentialHashes(): void {
384
+ const previous = this.#credentialHashes;
385
+ const next = new Map<number, number | bigint>();
386
+ let changed = false;
387
+ for (const entry of this.#snapshot.credentials) {
388
+ const hash = credentialContentHash(entry);
389
+ next.set(entry.id, hash);
390
+ if (previous.get(entry.id) !== hash) changed = true;
391
+ }
392
+ this.#credentialHashes = next;
393
+ this.#dirtyCredentialIds.clear();
394
+ if (changed || next.size !== previous.size) this.#credentialRevision += 1;
366
395
  }
367
396
 
368
397
  /**
369
- * Order-independent digest of the routable credential material — exactly the
370
- * fields {@link listAuthCredentials} exposes (id, provider, credential). A
371
- * token rotation or an add/remove changes it; credential blocks and usage
372
- * overlays do not.
398
+ * Rehash only the rows marked in {@link #dirtyCredentialIds} (stream deltas
399
+ * and local writes) and bump {@link #credentialRevision} when any of them
400
+ * was added, removed, or changed since the last reconcile.
373
401
  */
374
- #computeCredentialFingerprint(): string {
375
- const parts = this.#snapshot.credentials.map(
376
- entry => `${entry.id}\u0000${entry.provider}\u0000${JSON.stringify(entry.credential)}`,
377
- );
378
- parts.sort();
379
- return parts.join("\u0001");
402
+ #refreshCredentialRevision(): void {
403
+ const dirty = this.#dirtyCredentialIds;
404
+ if (dirty.size === 0) return;
405
+ let changed = false;
406
+ for (const entry of this.#snapshot.credentials) {
407
+ if (!dirty.delete(entry.id)) continue;
408
+ const hash = credentialContentHash(entry);
409
+ if (this.#credentialHashes.get(entry.id) !== hash) {
410
+ this.#credentialHashes.set(entry.id, hash);
411
+ changed = true;
412
+ }
413
+ if (dirty.size === 0) break;
414
+ }
415
+ // Dirty ids no longer in the snapshot were removed.
416
+ for (const id of dirty) {
417
+ if (this.#credentialHashes.delete(id)) changed = true;
418
+ }
419
+ dirty.clear();
420
+ if (changed) this.#credentialRevision += 1;
380
421
  }
422
+
381
423
  #protectNewSnapshotBlocks(previous: readonly SnapshotEntry[], next: readonly SnapshotEntry[], nowMs: number): void {
382
424
  const previousBlocksByKey = new Map<string, string>();
383
425
  for (const entry of previous) {
@@ -575,19 +617,25 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
575
617
  this.#removeStreamCredential(entry.id, refresher, generation, serverNowMs, { retainBrokerUsageAccount: true });
576
618
  return;
577
619
  }
578
- const incoming = this.#normalizeSnapshotEntryBlocks(entry, Date.now());
620
+ const nowMs = Date.now();
621
+ const incoming = this.#normalizeSnapshotEntryBlocks(entry, nowMs);
579
622
  const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === incoming.id);
580
- const previousBlocks = index === -1 ? undefined : this.#snapshot.credentials[index]?.blocks;
623
+ // Expired rows linger until the next prune; compare only live ones.
624
+ const previousBlocks =
625
+ index === -1
626
+ ? undefined
627
+ : this.#snapshot.credentials[index]?.blocks?.filter(block => block.blockedUntilMs > nowMs);
581
628
  const blocksChanged = !credentialBlockSnapshotsEqual(previousBlocks, incoming.blocks);
582
629
  if (blocksChanged) this.#invalidateUsageCache();
583
630
  const credentials =
584
631
  index === -1
585
632
  ? [...this.#snapshot.credentials, incoming]
586
633
  : this.#snapshot.credentials.map((candidate, i) => (i === index ? incoming : candidate));
587
- if (blocksChanged) this.#protectNewSnapshotBlocks(this.#snapshot.credentials, credentials, Date.now());
634
+ if (blocksChanged) this.#protectNewSnapshotBlocks(this.#snapshot.credentials, credentials, nowMs);
588
635
  this.#snapshot = { ...this.#snapshot, generation, serverNowMs, refresher, credentials };
589
636
  this.#generation = generation;
590
- this.#snapshotReceivedAt = Date.now();
637
+ this.#snapshotReceivedAt = nowMs;
638
+ this.#dirtyCredentialIds.add(incoming.id);
591
639
  this.#refreshCredentialRevision();
592
640
  }
593
641
 
@@ -599,12 +647,14 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
599
647
  options?: { retainBrokerUsageAccount?: boolean },
600
648
  ): void {
601
649
  if (!options?.retainBrokerUsageAccount) this.#removeBrokerUsageAccount(id);
650
+ const nowMs = Date.now();
602
651
  const removed = this.#snapshot.credentials.find(entry => entry.id === id);
603
- if (removed?.blocks && removed.blocks.length > 0) this.#invalidateUsageCache();
652
+ if (removed?.blocks?.some(block => block.blockedUntilMs > nowMs)) this.#invalidateUsageCache();
604
653
  const credentials = this.#snapshot.credentials.filter(entry => entry.id !== id);
605
654
  this.#snapshot = { ...this.#snapshot, generation, serverNowMs, refresher, credentials };
606
655
  this.#generation = generation;
607
- this.#snapshotReceivedAt = Date.now();
656
+ this.#snapshotReceivedAt = nowMs;
657
+ this.#dirtyCredentialIds.add(id);
608
658
  this.#refreshCredentialRevision();
609
659
  }
610
660
 
@@ -662,26 +712,38 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
662
712
 
663
713
  getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined {
664
714
  this.#noteActivity();
665
- const nowMs = Date.now();
666
- this.cleanExpiredCredentialBlocks(nowMs);
715
+ // Expired rows are pruned on snapshot apply, not per read; filter inline.
667
716
  const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
668
717
  if (!entry?.blocks) return undefined;
669
718
  const block = entry.blocks.find(
670
719
  candidate => candidate.providerKey === providerKey && candidate.blockScope === blockScope,
671
720
  );
672
- if (!block || block.blockedUntilMs <= nowMs) return undefined;
721
+ if (!block || block.blockedUntilMs <= Date.now()) return undefined;
673
722
  return block.blockedUntilMs;
674
723
  }
675
724
 
725
+ getCredentialBlockScopes(credentialId: number, providerKey: string): Map<string, number> {
726
+ this.#noteActivity();
727
+ const nowMs = Date.now();
728
+ const scopes = new Map<string, number>();
729
+ const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
730
+ for (const block of entry?.blocks ?? []) {
731
+ if (block.providerKey !== providerKey || block.blockedUntilMs <= nowMs) continue;
732
+ scopes.set(block.blockScope, block.blockedUntilMs);
733
+ }
734
+ return scopes;
735
+ }
736
+
676
737
  getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined {
677
738
  if (this.getCredentialBlock(credentialId, providerKey, blockScope) === undefined) return undefined;
678
- return this.#credentialBlockReconcileAfter.get(blockKey(credentialId, { providerKey, blockScope }));
739
+ const key = blockKey(credentialId, { providerKey, blockScope });
740
+ const reconcileAfterMs = this.#credentialBlockReconcileAfter.get(key);
741
+ return reconcileAfterMs !== undefined && reconcileAfterMs > Date.now() ? reconcileAfterMs : undefined;
679
742
  }
680
743
 
681
744
  listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] {
682
745
  this.#noteActivity();
683
746
  const nowMs = Date.now();
684
- this.cleanExpiredCredentialBlocks(nowMs);
685
747
  const ids = new Set(credentialIds);
686
748
  const blocks: StoredCredentialBlock[] = [];
687
749
  for (const entry of this.#snapshot.credentials) {
@@ -802,6 +864,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
802
864
  for (const entry of this.#snapshot.credentials) {
803
865
  if (entry.id !== id) continue;
804
866
  entry.credential = credential as typeof entry.credential;
867
+ this.#dirtyCredentialIds.add(id);
805
868
  return;
806
869
  }
807
870
  }
@@ -924,15 +987,20 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
924
987
  // `entries` is the broker's authoritative post-upsert list of rows for
925
988
  // `provider`. Drop our existing rows for the same provider and splice in
926
989
  // the fresh set — preserving every other provider's rows in place.
927
- const existingBlocks = new Map(
928
- this.#snapshot.credentials
929
- .filter(entry => entry.provider === provider && entry.blocks !== undefined)
930
- .map(entry => [entry.id, entry.blocks] as const),
931
- );
932
- const others = this.#snapshot.credentials.filter(entry => entry.provider !== provider);
990
+ const existingBlocks = new Map<number, readonly CredentialBlockSnapshot[] | undefined>();
991
+ const others: SnapshotEntry[] = [];
992
+ for (const entry of this.#snapshot.credentials) {
993
+ if (entry.provider !== provider) {
994
+ others.push(entry);
995
+ continue;
996
+ }
997
+ this.#dirtyCredentialIds.add(entry.id);
998
+ if (entry.blocks !== undefined) existingBlocks.set(entry.id, entry.blocks);
999
+ }
933
1000
  const incoming = entries
934
1001
  .filter(entry => isCredentialInAccountPool(entry, this.#accountPool))
935
1002
  .map(entry => credentialEntryWithBlocks(entry, existingBlocks.get(entry.id)));
1003
+ for (const entry of incoming) this.#dirtyCredentialIds.add(entry.id);
936
1004
  this.#snapshot = { ...this.#snapshot, credentials: [...others, ...incoming] };
937
1005
  }
938
1006
  #applyCredentialEntry(entry: AuthCredentialSnapshotEntry): boolean {
@@ -943,6 +1011,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
943
1011
  const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === entry.id);
944
1012
  const existingBlocks = index === -1 ? undefined : this.#snapshot.credentials[index]?.blocks;
945
1013
  const incoming = credentialEntryWithBlocks(entry, existingBlocks);
1014
+ this.#dirtyCredentialIds.add(entry.id);
946
1015
  if (index === -1) {
947
1016
  this.#snapshot = { ...this.#snapshot, credentials: [...this.#snapshot.credentials, incoming] };
948
1017
  return true;
@@ -954,13 +1023,18 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
954
1023
  }
955
1024
 
956
1025
  #removeProviderEntries(provider: string): void {
957
- const next = this.#snapshot.credentials.filter(entry => entry.provider !== provider);
1026
+ const next: SnapshotEntry[] = [];
1027
+ for (const entry of this.#snapshot.credentials) {
1028
+ if (entry.provider === provider) this.#dirtyCredentialIds.add(entry.id);
1029
+ else next.push(entry);
1030
+ }
958
1031
  this.#snapshot = { ...this.#snapshot, credentials: next };
959
1032
  }
960
1033
 
961
1034
  #removeCredentialById(id: number): void {
962
1035
  const next = this.#snapshot.credentials.filter(entry => entry.id !== id);
963
1036
  this.#snapshot = { ...this.#snapshot, credentials: next };
1037
+ this.#dirtyCredentialIds.add(id);
964
1038
  }
965
1039
 
966
1040
  #normalizeSnapshotEntryBlocks(entry: SnapshotEntry, nowMs: number): SnapshotEntry {
@@ -1030,7 +1104,10 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1030
1104
  if (index === -1) return;
1031
1105
  const entry = this.#snapshot.credentials[index]!;
1032
1106
  const incoming = toCredentialBlockSnapshot(block);
1033
- const blocks = entry.blocks ? [...entry.blocks] : [];
1107
+ // Expired rows linger until the next prune; drop this entry's now so an
1108
+ // expired row in the same scope cannot lend the new block its stale fields.
1109
+ const nowMs = Date.now();
1110
+ const blocks = (entry.blocks ?? []).filter(candidate => candidate.blockedUntilMs > nowMs);
1034
1111
  const blockIndex = blocks.findIndex(
1035
1112
  candidate => candidate.providerKey === incoming.providerKey && candidate.blockScope === incoming.blockScope,
1036
1113
  );
@@ -1125,11 +1202,11 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1125
1202
  }
1126
1203
  }
1127
1204
 
1128
- async invalidateUsageCache(signal?: AbortSignal): Promise<void> {
1205
+ async invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise<void> {
1129
1206
  this.#noteActivity();
1130
1207
  this.#invalidateUsageCache();
1131
1208
  try {
1132
- await this.#client.notifyUsageStale(signal);
1209
+ await this.#client.notifyUsageStale(provider, signal);
1133
1210
  } catch (err) {
1134
1211
  logger.warn("auth-broker notification of stale usage failed", { error: String(err) });
1135
1212
  } finally {
@@ -1385,9 +1462,10 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1385
1462
 
1386
1463
  /**
1387
1464
  * Fold locally observed request usage into the pending report and schedule
1388
- * a flush. One `POST /v1/usage/observed` at most per flush interval; on
1389
- * failure the batch is retained and retried with the next flush. A 404
1390
- * (pre-endpoint broker) disables reporting for the life of this store.
1465
+ * a flush. One `POST /v1/usage/observed` at most per flush interval, plus a
1466
+ * final one when the process exits; on failure the batch is retained and
1467
+ * retried with the next flush. A 404 (pre-endpoint broker) disables
1468
+ * reporting for the life of this store.
1391
1469
  *
1392
1470
  * `client` overrides the reporting identity — the auth-gateway attributes
1393
1471
  * each request to the originating install/app instead of the gateway host.
@@ -1417,9 +1495,31 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1417
1495
  }, this.#observedUsageFlushMs);
1418
1496
  this.#observedUsageTimer.unref?.();
1419
1497
  }
1498
+ // The timer never holds the process open, so a process that exits first (a
1499
+ // one-shot run, a signal) sends what is still buffered on the way out. The
1500
+ // wait is bounded: an unreachable broker must not stall the exit.
1501
+ this.#cancelObservedUsageExitFlush ??= postmortem.register("auth-broker-observed-usage", () =>
1502
+ raceSignal(
1503
+ this.#flushObservedUsage(),
1504
+ AbortSignal.timeout(OBSERVED_USAGE_EXIT_FLUSH_MS),
1505
+ "observed usage exit flush timed out",
1506
+ ).catch(error => {
1507
+ logger.debug("auth-broker observed usage dropped at exit", { error: String(error) });
1508
+ }),
1509
+ );
1510
+ }
1511
+
1512
+ #flushObservedUsage(): Promise<void> {
1513
+ // The tail never rejects, so one failed send cannot stall every later flush.
1514
+ this.#observedUsageFlush = this.#observedUsageFlush
1515
+ .then(() => this.#sendObservedUsage())
1516
+ .catch(error => {
1517
+ logger.debug("auth-broker observed usage flush failed", { error: String(error) });
1518
+ });
1519
+ return this.#observedUsageFlush;
1420
1520
  }
1421
1521
 
1422
- async #flushObservedUsage(): Promise<void> {
1522
+ async #sendObservedUsage(): Promise<void> {
1423
1523
  if (this.#observedUsage.size === 0 || this.#observedUsageUnsupported) return;
1424
1524
  const batch = [...this.#observedUsage.values()];
1425
1525
  this.#observedUsage.clear();
@@ -1468,8 +1568,9 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1468
1568
  clearTimeout(this.#observedUsageTimer);
1469
1569
  this.#observedUsageTimer = undefined;
1470
1570
  }
1471
- // Best-effort final flush; failures are dropped (the process is exiting).
1472
- if (this.#observedUsage.size > 0) void this.#flushObservedUsage();
1571
+ // Final flush. The exit registration outlives close() until it settles, so a
1572
+ // process exiting right after close() still sends it; failures are dropped.
1573
+ void this.#flushObservedUsage().finally(() => this.#cancelObservedUsageExitFlush?.());
1473
1574
  this.#cache.clear();
1474
1575
  this.#usageOverlays.clear();
1475
1576
  }
@@ -1481,9 +1582,10 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1481
1582
  * pick the one whose identity (accountId / email / projectId) lines up with
1482
1583
  * the credential the caller is asking about.
1483
1584
  *
1484
- * Falls back to the lone candidate when only one matches the provider; falls
1485
- * through to `null` when nothing matches, which `AuthStorage` treats as "no
1486
- * usage data" (ranking proceeds without a usage signal for this credential).
1585
+ * Falls back to the provider's lone report when it shares no identity field
1586
+ * with the credential; falls through to `null` when nothing matches, which
1587
+ * `AuthStorage` treats as "no usage data" (ranking proceeds without a usage
1588
+ * signal for this credential).
1487
1589
  */
1488
1590
  function matchUsageReport(reports: UsageReport[], provider: Provider, credential: OAuthCredential): UsageReport | null {
1489
1591
  const all = reports.filter(report => report.provider === provider);
@@ -1534,7 +1636,17 @@ function matchUsageReport(reports: UsageReport[], provider: Provider, credential
1534
1636
  report => !readMetadataString((report.metadata ?? {}) as Record<string, unknown>, "orgId"),
1535
1637
  );
1536
1638
  if (candidates.length === 0) return null;
1537
- if (all.length === 1 && candidates.length === 1) return candidates[0];
1639
+ // The sole report stands in for a credential it shares no identity field
1640
+ // with; otherwise identity decides, because a report naming another email,
1641
+ // account or project is a sibling's pool whose fetch succeeded where this
1642
+ // credential's failed.
1643
+ if (
1644
+ all.length === 1 &&
1645
+ candidates.length === 1 &&
1646
+ !reportHasComparableIdentity(candidates[0], accountId, email, projectId)
1647
+ ) {
1648
+ return candidates[0];
1649
+ }
1538
1650
  for (const report of candidates) {
1539
1651
  if (reportMatchesIdentity(report, accountId, email, projectId)) return report;
1540
1652
  }
@@ -1598,7 +1710,13 @@ function findMatchingReportIndex(reports: UsageReport[], overlay: UsageReport):
1598
1710
  candidate => !readMetadataString((candidate.report.metadata ?? {}) as Record<string, unknown>, "orgId"),
1599
1711
  );
1600
1712
  if (candidates.length === 0) return -1;
1601
- if (all.length === 1 && candidates.length === 1) return candidates[0]!.index;
1713
+ if (
1714
+ all.length === 1 &&
1715
+ candidates.length === 1 &&
1716
+ !reportHasComparableIdentity(candidates[0]!.report, accountId, email, projectId)
1717
+ ) {
1718
+ return candidates[0]!.index;
1719
+ }
1602
1720
  for (const candidate of candidates) {
1603
1721
  if (reportMatchesIdentity(candidate.report, accountId, email, projectId)) return candidate.index;
1604
1722
  }
@@ -1620,19 +1738,39 @@ function reportMatchesIdentity(
1620
1738
  const metaAccount = readMetadataString(metadata, "accountId") ?? readMetadataString(metadata, "account_id");
1621
1739
  if (metaAccount && metaAccount.toLowerCase() === accountId) return true;
1622
1740
  for (const limit of report.limits) {
1623
- if (limit.scope.accountId?.toLowerCase() === accountId) return true;
1741
+ if (limit.scope.accountId?.trim().toLowerCase() === accountId) return true;
1624
1742
  }
1625
1743
  }
1626
1744
  if (projectId) {
1627
1745
  const metaProject = readMetadataString(metadata, "projectId") ?? readMetadataString(metadata, "project_id");
1628
1746
  if (metaProject && metaProject.toLowerCase() === projectId) return true;
1629
1747
  for (const limit of report.limits) {
1630
- if (limit.scope.projectId?.toLowerCase() === projectId) return true;
1748
+ if (limit.scope.projectId?.trim().toLowerCase() === projectId) return true;
1631
1749
  }
1632
1750
  }
1633
1751
  return false;
1634
1752
  }
1635
1753
 
1754
+ /** Whether the report names an email, account or project the caller can compare against. */
1755
+ function reportHasComparableIdentity(
1756
+ report: UsageReport,
1757
+ accountId: string | undefined,
1758
+ email: string | undefined,
1759
+ projectId: string | undefined,
1760
+ ): boolean {
1761
+ const metadata = report.metadata ?? {};
1762
+ if (email && readMetadataString(metadata, "email")) return true;
1763
+ if (accountId) {
1764
+ if (readMetadataString(metadata, "accountId") ?? readMetadataString(metadata, "account_id")) return true;
1765
+ if (report.limits.some(limit => limit.scope.accountId?.trim())) return true;
1766
+ }
1767
+ if (projectId) {
1768
+ if (readMetadataString(metadata, "projectId") ?? readMetadataString(metadata, "project_id")) return true;
1769
+ if (report.limits.some(limit => limit.scope.projectId?.trim())) return true;
1770
+ }
1771
+ return false;
1772
+ }
1773
+
1636
1774
  function readMetadataString(metadata: Record<string, unknown>, key: string): string | undefined {
1637
1775
  const value = metadata[key];
1638
1776
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;