@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
@@ -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. */
@@ -6,6 +6,7 @@ import type { UsageCredential, UsageProvider, UsageReport } from "../usage";
6
6
 
7
7
  const USAGE_CACHE_PREFIX = "usage_cache:";
8
8
  const USAGE_FORCE_REFRESH_CACHE_PREFIX = "force-refresh:";
9
+ const USAGE_REPORT_KEY_PREFIX = "report:";
9
10
  /** Minimum interval between non-exhausted header snapshots; used by UsageService. */
10
11
  export const USAGE_HEADER_INGEST_INTERVAL_MS = 60_000;
11
12
  const USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000;
@@ -45,6 +46,13 @@ function parseUsageCacheEntry<T>(raw: string): UsageCacheEntry<T> | undefined {
45
46
  }
46
47
  }
47
48
 
49
+ /** Cached reports drop the provider's upstream `raw` payload; only a fresh fetch carries it. */
50
+ function withoutRawPayload(value: unknown): unknown {
51
+ if (value === null || typeof value !== "object" || !("raw" in value)) return value;
52
+ const { raw: _raw, ...rest } = value;
53
+ return rest;
54
+ }
55
+
48
56
  /** Convert a stored credential for usage providers; used by usage and health services. */
49
57
  export function buildUsageCredential(credential: AuthCredential): UsageCredential {
50
58
  if (credential.type === "api_key") {
@@ -196,12 +204,13 @@ export class UsageCache {
196
204
  }
197
205
 
198
206
  set<T>(key: string, entry: UsageCacheEntry<T>): void {
207
+ const value = withoutRawPayload(entry.value);
199
208
  const payload = JSON.stringify({
200
- value: entry.value,
209
+ value,
201
210
  expiresAt: entry.expiresAt,
202
211
  });
203
212
  const durableExpiresAt =
204
- entry.value === null ? entry.expiresAt : Math.max(entry.expiresAt, Date.now() + USAGE_LAST_GOOD_RETENTION_MS);
213
+ value === null ? entry.expiresAt : Math.max(entry.expiresAt, Date.now() + USAGE_LAST_GOOD_RETENTION_MS);
205
214
  this.#store.setCache(`${USAGE_CACHE_PREFIX}${key}`, payload, Math.floor(durableExpiresAt / 1000));
206
215
  }
207
216
 
@@ -272,7 +281,7 @@ export class UsageCache {
272
281
  const baseUrl = this.#normalizeUsageBaseUrl(request.baseUrl) || "default";
273
282
  const identity = usageCacheIdentity(request.credential);
274
283
  const providerKey = this.#usageCacheProviderKey(request.provider);
275
- const cacheKey = `report:${providerKey}:${baseUrl}:${identity}`;
284
+ const cacheKey = `${USAGE_REPORT_KEY_PREFIX}${providerKey}:${baseUrl}:${identity}`;
276
285
  const cacheKeys = this.#usageReportCacheKeysByProvider.get(request.provider) ?? new Set<string>();
277
286
  cacheKeys.add(cacheKey);
278
287
  this.#usageReportCacheKeysByProvider.set(request.provider, cacheKeys);
@@ -334,7 +343,7 @@ export class UsageCache {
334
343
  this.#epoch += 1;
335
344
  this.#bumpRefreshEpoch(provider);
336
345
  const expired = Date.now() - 1;
337
- const prefix = `report:${this.#usageCacheProviderKey(provider)}:`;
346
+ const prefix = `${USAGE_REPORT_KEY_PREFIX}${this.#usageCacheProviderKey(provider)}:`;
338
347
  if (this.deletePrefix(prefix)) return;
339
348
  const cacheKeys = new Set(this.#usageReportCacheKeysByProvider.get(provider));
340
349
  for (const entry of this.#pool.entries(provider)) {
@@ -361,7 +370,9 @@ export class UsageCache {
361
370
  ): Promise<void> {
362
371
  this.#epoch += 1;
363
372
  this.#bumpRefreshEpoch(provider);
364
- const prefix = provider ? `report:${this.#usageCacheProviderKey(provider)}:` : "report:";
373
+ const prefix = provider
374
+ ? `${USAGE_REPORT_KEY_PREFIX}${this.#usageCacheProviderKey(provider)}:`
375
+ : USAGE_REPORT_KEY_PREFIX;
365
376
  if (!this.deletePrefix(prefix)) {
366
377
  // Third-party stores may not support prefix deletion. Clear every active
367
378
  // request key instead, including API-key and environment credentials.
package/src/auth/usage.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { authPolicyFor } from "@oh-my-pi/pi-catalog/compat/auth";
2
2
  import { $pickenv, logger } from "@oh-my-pi/pi-utils";
3
+ import { getEnvApiKey } from "../env-api-key";
3
4
  import * as AIError from "../error";
4
- import { getEnvApiKey } from "../stream";
5
5
  import type { OAuthCredentials } from "../registry/oauth/types";
6
6
  import type { Provider } from "../types";
7
7
  import { resolveUsedFraction } from "../usage";
@@ -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 {
@@ -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;