@oh-my-pi/pi-ai 17.2.7 → 17.2.9

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 (39) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/types/auth/sqlite-credential-store.d.ts +117 -0
  3. package/dist/types/auth-storage.d.ts +1 -108
  4. package/dist/types/error/flags.d.ts +4 -2
  5. package/dist/types/error/index.d.ts +1 -0
  6. package/dist/types/error/rate-limit.d.ts +21 -5
  7. package/dist/types/error/retryable.d.ts +1 -1
  8. package/dist/types/providers/anthropic.d.ts +2 -2
  9. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  10. package/dist/types/providers/cursor-pi-args.d.ts +6 -0
  11. package/dist/types/providers/mock.d.ts +1 -0
  12. package/dist/types/providers/openai-codex/request-transformer.d.ts +1 -1
  13. package/dist/types/providers/openai-codex-responses.d.ts +1 -1
  14. package/dist/types/providers/pi-native-server.d.ts +1 -1
  15. package/dist/types/utils/http-inspector.d.ts +5 -4
  16. package/dist/types/utils/retry.d.ts +3 -2
  17. package/package.json +5 -5
  18. package/src/auth/sqlite-credential-store.ts +2049 -0
  19. package/src/auth-retry.ts +8 -5
  20. package/src/auth-storage.ts +30 -2023
  21. package/src/error/auth-classify.ts +5 -4
  22. package/src/error/flags.ts +74 -18
  23. package/src/error/index.ts +1 -0
  24. package/src/error/rate-limit.ts +64 -5
  25. package/src/error/retryable.ts +1 -1
  26. package/src/providers/anthropic.ts +15 -3
  27. package/src/providers/cursor/exec-modern.ts +1 -0
  28. package/src/providers/cursor-pi-args.ts +30 -0
  29. package/src/providers/cursor.ts +16 -9
  30. package/src/providers/mock.ts +1 -1
  31. package/src/providers/openai-codex/request-transformer.ts +21 -27
  32. package/src/providers/openai-codex-responses.ts +2 -2
  33. package/src/providers/pi-native-client.ts +1 -1
  34. package/src/providers/pi-native-server.ts +1 -1
  35. package/src/registry/oauth/devin.ts +1 -1
  36. package/src/stream.ts +3 -3
  37. package/src/usage/openai-codex.ts +30 -19
  38. package/src/utils/http-inspector.ts +6 -5
  39. package/src/utils/retry.ts +24 -5
@@ -5,14 +5,17 @@
5
5
  * This module defines:
6
6
  * - `AuthCredentialStore` interface: persistence abstraction (SQLite, remote vault, …)
7
7
  * - `AuthStorage` class: credential management with round-robin, usage limits, OAuth refresh
8
- * - `SqliteAuthCredentialStore`: concrete SQLite-backed implementation
8
+ * - re-exported `SqliteAuthCredentialStore`: concrete SQLite-backed implementation
9
9
  */
10
- import { Database, type Statement } from "bun:sqlite";
11
10
  import { createHash } from "node:crypto";
12
- import * as fs from "node:fs/promises";
13
- import * as path from "node:path";
14
- import { parseAlibabaTokenPlanCredential } from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan";
15
- import { $env, $envExact, getAgentDbPath, getDbBusyTimeoutMs, logger } from "@oh-my-pi/pi-utils";
11
+ import { $env, $envExact, extractRetryHint, getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
12
+ import {
13
+ isSqliteCorruptionError,
14
+ resolveCredentialIdentityKey,
15
+ SqliteAuthCredentialStore,
16
+ serializeCredential,
17
+ USAGE_REPORT_TTL_MS,
18
+ } from "./auth/sqlite-credential-store";
16
19
  import type { ApiKeyResolver } from "./auth-retry";
17
20
  import * as AIError from "./error";
18
21
  import { isUsageLimitOutcome } from "./error/rate-limit";
@@ -28,7 +31,6 @@ import type {
28
31
  import { getEnvApiKey, getEnvApiKeyName } from "./stream";
29
32
  import type { Provider } from "./types";
30
33
  import type {
31
- ClientProviderUsage,
32
34
  ClientUsageReport,
33
35
  ClientUsageSummary,
34
36
  CredentialRankingContext,
@@ -70,6 +72,12 @@ import { umansUsageProvider } from "./usage/umans";
70
72
  import { xaiOauthUsageProvider } from "./usage/xai-oauth";
71
73
  import { zaiRankingStrategy, zaiUsageProvider } from "./usage/zai";
72
74
 
75
+ export {
76
+ isSqliteBusyError,
77
+ isSqliteCorruptionError,
78
+ SqliteAuthCredentialStore,
79
+ } from "./auth/sqlite-credential-store";
80
+
73
81
  const USAGE_RANKING_METRIC_EPSILON = 1e-9;
74
82
  /**
75
83
  * Primary (short, e.g. 5h) window used-fraction at or above which a candidate
@@ -675,26 +683,8 @@ const DEFAULT_USAGE_PROVIDER_MAP = new Map<Provider, UsageProvider>(
675
683
  );
676
684
 
677
685
  const USAGE_CACHE_PREFIX = "usage_cache:";
678
- // 5 min stale tolerance. Anthropic / OpenAI rate-limit /usage hard at the IP
679
- // level so we can't fetch all N credentials every cycle; with a long cache
680
- // each credential's last-known value sticks visible while peers retry. UI
681
- // data (5h / 7d / monthly limits) is fine being a few minutes stale.
682
- const USAGE_REPORT_TTL_MS = 5 * 60_000;
683
686
  const USAGE_HEADER_INGEST_INTERVAL_MS = 60_000;
684
687
  const USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000;
685
- /**
686
- * Downsample usage history to at most one row per hour per account window: a
687
- * snapshot landing in the same hour bucket as the series' latest row
688
- * overwrites it in place. That bound makes further retention pruning
689
- * unnecessary — 1 row/hour is ~9k rows per account window per year.
690
- */
691
- const USAGE_HISTORY_BUCKET_MS = 60 * 60_000;
692
- /**
693
- * Merge client observed-usage flushes into at most one row per 5 minutes per
694
- * (install, provider, model): ~300 rows/day per active model per client
695
- * instead of one row per 10s flush.
696
- */
697
- const CLIENT_USAGE_BUCKET_MS = 5 * 60_000;
698
688
  /**
699
689
  * Per-credential cool-down after a usage fetch fails. While this window is
700
690
  * active we serve the last successful value to avoid dropping the credential
@@ -3708,7 +3698,7 @@ export class AuthStorage {
3708
3698
  }
3709
3699
 
3710
3700
  #isUsageLimitExhausted(limit: UsageLimit): boolean {
3711
- if (limit.status === "exhausted") return true;
3701
+ if (limit.status !== undefined && limit.status !== "unknown") return limit.status === "exhausted";
3712
3702
  const amount = limit.amount;
3713
3703
  if (amount.usedFraction !== undefined && amount.usedFraction >= 1) return true;
3714
3704
  if (amount.remainingFraction !== undefined && amount.remainingFraction <= 0) return true;
@@ -3856,13 +3846,15 @@ export class AuthStorage {
3856
3846
  : undefined;
3857
3847
 
3858
3848
  const rankingContext: CredentialRankingContext = { modelId: options.modelId };
3849
+ const planRequirement = resolveOpenAICodexPlanRequirement(provider, options.modelId);
3850
+ const planEligibilityByCredential = new Map<number, boolean | undefined>();
3859
3851
  const blockScope = strategy.blockScope?.(rankingContext);
3860
3852
  const blockScopes = strategy.blockScopes?.(rankingContext) ?? (blockScope ? [blockScope] : []);
3861
3853
  const reserveFraction = Number.isFinite(options.reserveFraction)
3862
3854
  ? Math.max(0, Math.min(1, options.reserveFraction))
3863
3855
  : 0;
3864
3856
  const nowMs = Date.now();
3865
- const accounts = await Promise.all(
3857
+ let accounts = await Promise.all(
3866
3858
  pool.map(async ({ entry, index }): Promise<ModelUsageAccountHealth> => {
3867
3859
  const credentialType = entry.credential.type;
3868
3860
  const providerKey = this.#getProviderTypeKey(provider, credentialType);
@@ -3890,6 +3882,9 @@ export class AuthStorage {
3890
3882
  if (options.signal?.aborted) throw error;
3891
3883
  report = null;
3892
3884
  }
3885
+ if (planRequirement !== "none") {
3886
+ planEligibilityByCredential.set(entry.id, getOpenAICodexPlanEligibility(report, planRequirement));
3887
+ }
3893
3888
 
3894
3889
  if (provider === "openai-codex") {
3895
3890
  blockedUntil = this.#getCredentialBlockedUntil(provider, providerKey, index, blockScopes);
@@ -3942,6 +3937,9 @@ export class AuthStorage {
3942
3937
  };
3943
3938
  }),
3944
3939
  );
3940
+ if (planRequirement !== "none") {
3941
+ accounts = accounts.filter(account => planEligibilityByCredential.get(account.credentialId) !== false);
3942
+ }
3945
3943
  if (selectedCredentialId !== undefined) {
3946
3944
  const selectedAccount = accounts.find(account => account.credentialId === selectedCredentialId);
3947
3945
  if (selectedAccount) selectedAccount.selected = true;
@@ -6095,8 +6093,13 @@ export class AuthStorage {
6095
6093
  const status = AIError.status(error);
6096
6094
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
6097
6095
  if (AIError.isUsageLimit(error) || isUsageLimitOutcome(status, message)) {
6096
+ // Thread the provider-specified reset window (e.g. Devin "Your limit
6097
+ // will reset in 13 minutes") into the block duration so the credential
6098
+ // is not reselected and hammered while the cap remains active.
6099
+ const retryAfterMs = extractRetryHint(undefined, message);
6098
6100
  return (
6099
6101
  await this.markUsageLimitReached(provider, sessionId, {
6102
+ retryAfterMs,
6100
6103
  modelId: options?.modelId,
6101
6104
  apiKey: options?.apiKey,
6102
6105
  credentialId: options?.credentialId,
@@ -6535,1999 +6538,3 @@ export class AuthStorage {
6535
6538
  return undefined;
6536
6539
  }
6537
6540
  }
6538
-
6539
- // ─────────────────────────────────────────────────────────────────────────────
6540
- // SqliteAuthCredentialStore
6541
- // ─────────────────────────────────────────────────────────────────────────────
6542
-
6543
- /** Row shape for auth_credentials table queries */
6544
- type AuthRow = {
6545
- id: number;
6546
- provider: string;
6547
- credential_type: string;
6548
- data: string;
6549
- disabled_cause: string | null;
6550
- identity_key: string | null;
6551
- };
6552
-
6553
- /** {@link AuthRow} plus `updated_at` — disabled-tombstone queries surface when the row was torn down. */
6554
- type DisabledAuthRow = AuthRow & { updated_at: number | null };
6555
-
6556
- type CredentialBlockRow = {
6557
- credential_id: number;
6558
- provider_key: string;
6559
- block_scope: string;
6560
- blocked_until_ms: number;
6561
- updated_at: number;
6562
- };
6563
-
6564
- type SerializedCredentialRecord = {
6565
- credentialType: AuthCredential["type"];
6566
- data: string;
6567
- identityKey: string | null;
6568
- };
6569
-
6570
- const AUTH_SCHEMA_VERSION = 7;
6571
- const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
6572
- const LEGACY_CODEX_BLOCK_PROVIDER_KEY = "openai-codex:oauth";
6573
- const LEGACY_CODEX_BLOCK_SCOPE = "shared";
6574
- const CODEX_METER_BLOCK_SCOPES = ["chat", "spark"] as const;
6575
-
6576
- /**
6577
- * SQLite's busy result code family — base `SQLITE_BUSY` plus the extended
6578
- * variants `SQLITE_BUSY_RECOVERY` (concurrent WAL recovery), `SQLITE_BUSY_SNAPSHOT`,
6579
- * and `SQLITE_BUSY_TIMEOUT`. All warrant the same backoff-and-retry treatment.
6580
- */
6581
- export function isSqliteBusyError(err: unknown): boolean {
6582
- if (err === null || typeof err !== "object") return false;
6583
- const code = (err as { code?: unknown }).code;
6584
- return typeof code === "string" && code.startsWith("SQLITE_BUSY");
6585
- }
6586
-
6587
- /**
6588
- * SQLite's unrecoverable-corruption result codes — the `SQLITE_CORRUPT` family
6589
- * (base plus extended variants like `SQLITE_CORRUPT_VTAB` / `SQLITE_CORRUPT_INDEX`)
6590
- * and `SQLITE_NOTADB` (the file header is not a database). Unlike
6591
- * {@link isSqliteBusyError}, these never clear by retrying: the store must be
6592
- * repaired or replaced, so callers latch and stop touching it.
6593
- */
6594
- export function isSqliteCorruptionError(err: unknown): boolean {
6595
- if (err === null || typeof err !== "object" || !("code" in err)) return false;
6596
- const code = err.code;
6597
- return typeof code === "string" && (code.startsWith("SQLITE_CORRUPT") || code === "SQLITE_NOTADB");
6598
- }
6599
-
6600
- function normalizeStoredAccountId(accountId: string | null | undefined): string | null {
6601
- const normalized = accountId?.trim();
6602
- return normalized && normalized.length > 0 ? normalized : null;
6603
- }
6604
-
6605
- function normalizeStoredEmail(email: string | null | undefined): string | null {
6606
- const normalized = email?.trim().toLowerCase();
6607
- return normalized && normalized.length > 0 ? normalized : null;
6608
- }
6609
-
6610
- function normalizeStoredIdentityKey(identityKey: string | null | undefined): string | null {
6611
- const normalized = identityKey?.trim();
6612
- return normalized && normalized.length > 0 ? normalized : null;
6613
- }
6614
-
6615
- function serializeCredential(provider: string, credential: AuthCredential): SerializedCredentialRecord | null {
6616
- if (credential.type === "api_key") {
6617
- const data = credential.source === "login" ? { key: credential.key, source: "login" } : { key: credential.key };
6618
- return {
6619
- credentialType: "api_key",
6620
- data: JSON.stringify(data),
6621
- identityKey: null,
6622
- };
6623
- }
6624
- if (credential.type === "oauth") {
6625
- const { type: _type, ...rest } = credential;
6626
- return {
6627
- credentialType: "oauth",
6628
- data: JSON.stringify(rest),
6629
- identityKey: resolveCredentialIdentityKey(provider, credential),
6630
- };
6631
- }
6632
- return null;
6633
- }
6634
-
6635
- function deserializeCredential(row: AuthRow): AuthCredential | null {
6636
- let parsed: unknown;
6637
- try {
6638
- parsed = JSON.parse(row.data);
6639
- } catch {
6640
- return null;
6641
- }
6642
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
6643
- return null;
6644
- }
6645
- if (row.credential_type === "api_key") {
6646
- const data = parsed as Record<string, unknown>;
6647
- if (typeof data.key === "string") {
6648
- const source = data.source === "login" ? "login" : undefined;
6649
- return source ? { type: "api_key", key: data.key, source } : { type: "api_key", key: data.key };
6650
- }
6651
- }
6652
- if (row.credential_type === "oauth") {
6653
- return { type: "oauth", ...(parsed as Record<string, unknown>) } as AuthCredential;
6654
- }
6655
- return null;
6656
- }
6657
-
6658
- function normalizeDisabledCause(disabledCause: string): string {
6659
- const normalized = disabledCause.trim();
6660
- return normalized.length > 0 ? normalized : "disabled";
6661
- }
6662
-
6663
- function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): StoredAuthCredential {
6664
- return { id: row.id, provider: row.provider, credential, disabledCause: row.disabled_cause };
6665
- }
6666
-
6667
- function resolveProviderCredentialIdentityKey(provider: string, identifiers: string[]): string | null {
6668
- const emailIdentifier = identifiers.find(identifier => identifier.startsWith("email:"));
6669
- if (provider === "anthropic" || provider === "openai-codex") {
6670
- // One account email can hold several organizations/workspaces (e.g. a
6671
- // Team seat plus a personal plan), each with its own org-scoped token
6672
- // and limit pools. Scope identity by org so both subscriptions can be
6673
- // stored side by side. The qualifier rides on whichever base identity
6674
- // is available, so an unqualified account/project fallback would
6675
- // still collapse two subscriptions whenever the email could not be
6676
- // recovered. Org-less credentials (rows written before org capture
6677
- // existed) keep their bare key.
6678
- const base =
6679
- emailIdentifier ??
6680
- identifiers.find(identifier => identifier.startsWith("account:")) ??
6681
- identifiers.find(identifier => identifier.startsWith("project:"));
6682
- const orgIdentifier = identifiers.find(identifier => identifier.startsWith("org:"));
6683
- if (base) return orgIdentifier ? `${base}|${orgIdentifier}` : base;
6684
- // No base identity at all: the org alone still distinguishes the row.
6685
- return orgIdentifier ?? null;
6686
- }
6687
- const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:"));
6688
- if (accountIdentifier) return accountIdentifier;
6689
- if (emailIdentifier) return emailIdentifier;
6690
- const projectIdentifier = identifiers.find(identifier => identifier.startsWith("project:"));
6691
- if (projectIdentifier) return projectIdentifier;
6692
- return null;
6693
- }
6694
-
6695
- function resolveCredentialIdentityKey(provider: string, credential: AuthCredential): string | null {
6696
- if (credential.type === "api_key") return null;
6697
- return resolveProviderCredentialIdentityKey(provider, extractOAuthCredentialIdentifiers(credential));
6698
- }
6699
-
6700
- function resolveRowCredentialIdentityKey(provider: string, row: AuthRow): string | null {
6701
- const identityKey = normalizeStoredIdentityKey(row.identity_key);
6702
- if (identityKey) return identityKey;
6703
- const credential = deserializeCredential(row);
6704
- return credential?.type === "oauth" ? resolveCredentialIdentityKey(provider, credential) : null;
6705
- }
6706
-
6707
- function matchesReplacementCredential(
6708
- provider: string,
6709
- existing: AuthCredential | null,
6710
- existingIdentityKey: string | null,
6711
- incoming: AuthCredential,
6712
- ): boolean {
6713
- if (!existing || existing.type !== incoming.type) return false;
6714
- if (incoming.type === "api_key") {
6715
- if (existing.type !== "api_key") return false;
6716
- if (existing.key === incoming.key) return true;
6717
- if (provider !== "alibaba-token-plan") return false;
6718
- const existingToken = parseAlibabaTokenPlanCredential(existing.key)?.token;
6719
- const incomingToken = parseAlibabaTokenPlanCredential(incoming.key)?.token;
6720
- return existingToken !== undefined && existingToken === incomingToken;
6721
- }
6722
- const incomingIdentifiers = extractOAuthCredentialIdentifiers(incoming);
6723
- const incomingIdentityKey = resolveProviderCredentialIdentityKey(provider, incomingIdentifiers);
6724
- if (incomingIdentityKey === null) return false;
6725
- if (incomingIdentityKey === existingIdentityKey) return true;
6726
- if (existingIdentityKey === null) return false;
6727
- // One-way upgrade, applied only when the INCOMING identity key carries the
6728
- // org qualifier (only anthropic and openai-codex keys do, so other
6729
- // providers never reach the checks below). An org-scoped login `org:<o>`
6730
- // claims (and re-keys) any existing row that denotes the same subscription:
6731
- // - `org:<o>` — org-only row stored when identity recovery failed, claimed
6732
- // once a later same-org login recovers a base identity;
6733
- // - `<b>` for any base identity `<b>` (email/account/project) the incoming
6734
- // credential carries — a pre-org legacy row, mirroring the pre-org
6735
- // replace behavior;
6736
- // - `<b>|org:<o>` for any such base — the same subscription keyed by a
6737
- // different base, e.g. an account-keyed row stored while the email could
6738
- // not be recovered, claimed once a later login recovers the email;
6739
- // - any same-org row whose STORED credential shares a base identity with
6740
- // the incoming one — a stored credential can retain identifiers its key
6741
- // does not use (an email-keyed row also carries the account UUID), so a
6742
- // later login that loses the email but keeps the account still updates
6743
- // its row instead of duplicating the subscription.
6744
- // The reverse stays a non-match: an org-less credential only ever replaces
6745
- // via exact key equality above and must never clobber an org-scoped row.
6746
- const orgIdentifier = incomingIdentifiers.find(identifier => identifier.startsWith("org:"));
6747
- if (orgIdentifier === undefined) return false;
6748
- if (incomingIdentityKey !== orgIdentifier && !incomingIdentityKey.endsWith(`|${orgIdentifier}`)) return false;
6749
- if (existingIdentityKey === orgIdentifier) return true;
6750
- const existingIdentifiers =
6751
- existing.type === "oauth" && existingIdentityKey.endsWith(`|${orgIdentifier}`)
6752
- ? extractOAuthCredentialIdentifiers(existing)
6753
- : null;
6754
- // A base identifier that merely repeats the org qualifier's id carries no
6755
- // per-user identity (openai-codex stores the ChatGPT workspace id as both
6756
- // accountId and orgId, shared by every member) — letting it act as a
6757
- // claimable base would re-key another member's same-org row.
6758
- const orgQualifierId = orgIdentifier.slice("org:".length);
6759
- for (const identifier of incomingIdentifiers) {
6760
- const isBase =
6761
- identifier.startsWith("email:") || identifier.startsWith("account:") || identifier.startsWith("project:");
6762
- if (!isBase) continue;
6763
- if (identifier.slice(identifier.indexOf(":") + 1) === orgQualifierId) continue;
6764
- if (existingIdentityKey === identifier) return true;
6765
- if (existingIdentityKey === `${identifier}|${orgIdentifier}`) return true;
6766
- if (existingIdentifiers?.includes(identifier)) return true;
6767
- }
6768
- return false;
6769
- }
6770
-
6771
- function extractOAuthCredentialIdentifiers(credential: OAuthCredential): string[] {
6772
- const identifiers = new Set<string>();
6773
- const accountId = normalizeStoredAccountId(credential.accountId);
6774
- if (accountId) identifiers.add(`account:${accountId}`);
6775
- const email = normalizeStoredEmail(credential.email);
6776
- if (email) identifiers.add(`email:${email}`);
6777
- const projectId = normalizeStoredAccountId(credential.projectId);
6778
- if (projectId) identifiers.add(`project:${projectId}`);
6779
- const orgId = normalizeStoredAccountId(credential.orgId);
6780
- if (orgId) identifiers.add(`org:${orgId}`);
6781
- const accessIdentifiers = extractOAuthTokenIdentifiers(credential.access) ?? [];
6782
- for (const identifier of accessIdentifiers) {
6783
- identifiers.add(identifier);
6784
- }
6785
- const refreshIdentifiers = extractOAuthTokenIdentifiers(credential.refresh) ?? [];
6786
- for (const identifier of refreshIdentifiers) {
6787
- identifiers.add(identifier);
6788
- }
6789
- return [...identifiers];
6790
- }
6791
-
6792
- function extractOAuthTokenIdentifiers(token: string | undefined): string[] | undefined {
6793
- if (!token) return undefined;
6794
- const parts = token.split(".");
6795
- if (parts.length !== 3) return undefined;
6796
- try {
6797
- const payload = JSON.parse(
6798
- new TextDecoder("utf-8").decode(Uint8Array.fromBase64(parts[1], { alphabet: "base64url" })),
6799
- ) as Record<string, unknown>;
6800
- const identifiers = new Set<string>();
6801
- const directEmail = normalizeStoredEmail(typeof payload.email === "string" ? payload.email : undefined);
6802
- if (directEmail) identifiers.add(`email:${directEmail}`);
6803
- const openAiProfile = payload["https://api.openai.com/profile"];
6804
- if (typeof openAiProfile === "object" && openAiProfile !== null && !Array.isArray(openAiProfile)) {
6805
- const claimEmail = normalizeStoredEmail(
6806
- (openAiProfile as Record<string, unknown>).email as string | undefined,
6807
- );
6808
- if (claimEmail) identifiers.add(`email:${claimEmail}`);
6809
- }
6810
- const openAiAuth = payload["https://api.openai.com/auth"];
6811
- const authClaims =
6812
- typeof openAiAuth === "object" && openAiAuth !== null && !Array.isArray(openAiAuth)
6813
- ? (openAiAuth as Record<string, unknown>)
6814
- : undefined;
6815
- const accountId = normalizeStoredAccountId(
6816
- typeof payload.account_id === "string"
6817
- ? payload.account_id
6818
- : typeof payload.accountId === "string"
6819
- ? payload.accountId
6820
- : typeof payload.user_id === "string"
6821
- ? payload.user_id
6822
- : typeof payload.sub === "string"
6823
- ? payload.sub
6824
- : typeof authClaims?.chatgpt_account_id === "string"
6825
- ? authClaims.chatgpt_account_id
6826
- : undefined,
6827
- );
6828
- if (accountId) identifiers.add(`account:${accountId}`);
6829
- return identifiers.size > 0 ? [...identifiers] : undefined;
6830
- } catch {
6831
- return undefined;
6832
- }
6833
- }
6834
- /**
6835
- * Default SQLite-backed implementation of {@link AuthCredentialStore}.
6836
- *
6837
- * Used by the pi-ai CLI and as the default store for `AuthStorage.create()`.
6838
- * Also exposes convenience methods (`saveOAuth`, `getOAuth`, `saveApiKey`,
6839
- * `getApiKey`, `listProviders`, `deleteProvider`) that callers can use directly
6840
- * without going through `AuthStorage`.
6841
- */
6842
- export class SqliteAuthCredentialStore implements AuthCredentialStore {
6843
- #db: Database;
6844
- #listActiveStmt: Statement;
6845
- #listActiveByProviderStmt: Statement;
6846
- #listDisabledStmt: Statement;
6847
- #listDisabledByProviderStmt: Statement;
6848
- #insertStmt: Statement;
6849
- #updateStmt: Statement;
6850
- #deleteStmt: Statement;
6851
- #deleteIfMatchesStmt: Statement;
6852
- #updateIfMatchesStmt: Statement;
6853
- #deleteByProviderStmt: Statement;
6854
- #hardDeleteStmt: Statement;
6855
- #getCacheStmt: Statement;
6856
- #getCacheIncludingExpiredStmt: Statement;
6857
- #upsertCacheStmt: Statement;
6858
- #deleteCachePrefixStmt: Statement;
6859
- #deleteExpiredCacheStmt: Statement;
6860
- #updateIfMatchesWithLeaseStmt: Statement;
6861
- #deleteIfMatchesWithLeaseStmt: Statement;
6862
- #getCredentialBlockStmt: Statement;
6863
- #listCredentialBlocksByCredentialStmt: Statement;
6864
- #upsertCredentialBlockStmt: Statement;
6865
- #deleteCredentialBlocksStmt: Statement;
6866
- #deleteCredentialBlockStmt: Statement;
6867
- #deleteExpiredCredentialBlocksStmt: Statement;
6868
- #acquireCredentialRefreshLeaseStmt: Statement;
6869
- #getCredentialRefreshLeaseStmt: Statement;
6870
- #renewCredentialRefreshLeaseStmt: Statement;
6871
- #releaseCredentialRefreshLeaseStmt: Statement;
6872
- #credentialBlockReconcileAfter: Map<string, number> = new Map();
6873
- #insertUsageHistoryStmt: Statement;
6874
- #insertUsageCostStmt: Statement;
6875
- #listUsageCostsStmt: Statement;
6876
- #lastUsageHistoryStmt: Statement;
6877
- #listUsageHistoryStmt: Statement;
6878
- #updateUsageHistoryStmt: Statement;
6879
- #dataVersion: number;
6880
- #authRevision: number;
6881
- #localAuthRevision: number;
6882
- #closed = false;
6883
-
6884
- constructor(db: Database) {
6885
- this.#db = db;
6886
- this.#initializeSchema();
6887
- this.#dataVersion = this.#readDataVersion();
6888
- this.#authRevision = this.#readAuthRevision();
6889
- this.#localAuthRevision = this.#readLocalAuthRevision();
6890
-
6891
- this.#listActiveStmt = this.#db.prepare(
6892
- "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE disabled_cause IS NULL ORDER BY id ASC",
6893
- );
6894
- this.#listActiveByProviderStmt = this.#db.prepare(
6895
- "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC",
6896
- );
6897
- this.#listDisabledStmt = this.#db.prepare(
6898
- "SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE disabled_cause IS NOT NULL ORDER BY id ASC",
6899
- );
6900
- this.#listDisabledByProviderStmt = this.#db.prepare(
6901
- "SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC",
6902
- );
6903
- this.#insertStmt = this.#db.prepare(
6904
- `INSERT INTO auth_credentials (provider, credential_type, data, identity_key, created_at, updated_at) VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}, ${SQLITE_NOW_EPOCH}) RETURNING id`,
6905
- );
6906
- this.#updateStmt = this.#db.prepare(
6907
- `UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
6908
- );
6909
- this.#updateIfMatchesStmt = this.#db.prepare(
6910
- `UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`,
6911
- );
6912
- this.#updateIfMatchesWithLeaseStmt = this.#db.prepare(
6913
- `UPDATE auth_credentials
6914
- SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH}
6915
- WHERE id = ? AND data = ? AND disabled_cause IS NULL
6916
- AND EXISTS (
6917
- SELECT 1 FROM auth_credential_refresh_leases
6918
- WHERE credential_id = ? AND owner = ? AND expires_at_ms > ?
6919
- )`,
6920
- );
6921
- this.#deleteStmt = this.#db.prepare(
6922
- `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
6923
- );
6924
- this.#deleteIfMatchesStmt = this.#db.prepare(
6925
- `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`,
6926
- );
6927
- this.#deleteIfMatchesWithLeaseStmt = this.#db.prepare(
6928
- `UPDATE auth_credentials
6929
- SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH}
6930
- WHERE id = ? AND data = ? AND disabled_cause IS NULL
6931
- AND EXISTS (
6932
- SELECT 1 FROM auth_credential_refresh_leases
6933
- WHERE credential_id = ? AND owner = ? AND expires_at_ms > ?
6934
- )`,
6935
- );
6936
- this.#deleteByProviderStmt = this.#db.prepare(
6937
- `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`,
6938
- );
6939
- this.#hardDeleteStmt = this.#db.prepare("DELETE FROM auth_credentials WHERE id = ?");
6940
- this.#getCacheStmt = this.#db.prepare(
6941
- `SELECT value FROM cache WHERE key = ? AND expires_at > ${SQLITE_NOW_EPOCH}`,
6942
- );
6943
- this.#getCacheIncludingExpiredStmt = this.#db.prepare("SELECT value FROM cache WHERE key = ?");
6944
- this.#upsertCacheStmt = this.#db.prepare(
6945
- "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
6946
- );
6947
- this.#deleteCachePrefixStmt = this.#db.prepare("DELETE FROM cache WHERE substr(key, 1, ?) = ?");
6948
- this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`);
6949
- this.#getCredentialBlockStmt = this.#db.prepare(
6950
- "SELECT blocked_until_ms, updated_at FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ? AND blocked_until_ms > ?",
6951
- );
6952
- this.#listCredentialBlocksByCredentialStmt = this.#db.prepare(
6953
- `SELECT credential_id, provider_key, block_scope, blocked_until_ms, updated_at
6954
- FROM auth_credential_blocks
6955
- WHERE credential_id = ? AND blocked_until_ms > ?
6956
- AND NOT (provider_key = ? AND block_scope = ?)
6957
- ORDER BY provider_key ASC, block_scope ASC`,
6958
- );
6959
- this.#upsertCredentialBlockStmt = this.#db.prepare(
6960
- `INSERT INTO auth_credential_blocks (credential_id, provider_key, block_scope, blocked_until_ms, updated_at)
6961
- VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH})
6962
- ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET
6963
- blocked_until_ms = MAX(blocked_until_ms, excluded.blocked_until_ms),
6964
- updated_at = excluded.updated_at`,
6965
- );
6966
- this.#deleteCredentialBlocksStmt = this.#db.prepare("DELETE FROM auth_credential_blocks WHERE credential_id = ?");
6967
- this.#deleteCredentialBlockStmt = this.#db.prepare(
6968
- "DELETE FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ?",
6969
- );
6970
- this.#deleteExpiredCredentialBlocksStmt = this.#db.prepare(
6971
- "DELETE FROM auth_credential_blocks WHERE blocked_until_ms <= ?",
6972
- );
6973
- this.#acquireCredentialRefreshLeaseStmt = this.#db.prepare(
6974
- `INSERT INTO auth_credential_refresh_leases (credential_id, owner, expires_at_ms, updated_at)
6975
- VALUES (?, ?, ?, ${SQLITE_NOW_EPOCH})
6976
- ON CONFLICT(credential_id) DO UPDATE SET
6977
- owner = excluded.owner,
6978
- expires_at_ms = excluded.expires_at_ms,
6979
- updated_at = excluded.updated_at
6980
- WHERE auth_credential_refresh_leases.expires_at_ms <= ?`,
6981
- );
6982
- this.#getCredentialRefreshLeaseStmt = this.#db.prepare(
6983
- "SELECT expires_at_ms FROM auth_credential_refresh_leases WHERE credential_id = ?",
6984
- );
6985
- this.#renewCredentialRefreshLeaseStmt = this.#db.prepare(
6986
- `UPDATE auth_credential_refresh_leases SET expires_at_ms = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE credential_id = ? AND owner = ?`,
6987
- );
6988
- this.#releaseCredentialRefreshLeaseStmt = this.#db.prepare(
6989
- "DELETE FROM auth_credential_refresh_leases WHERE credential_id = ? AND owner = ?",
6990
- );
6991
- this.#insertUsageHistoryStmt = this.#db.prepare(
6992
- "INSERT INTO usage_history (recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
6993
- );
6994
- this.#lastUsageHistoryStmt = this.#db.prepare(
6995
- "SELECT id, recorded_at FROM usage_history WHERE provider = ? AND account_key = ? AND limit_id = ? ORDER BY recorded_at DESC LIMIT 1",
6996
- );
6997
- this.#updateUsageHistoryStmt = this.#db.prepare(
6998
- "UPDATE usage_history SET recorded_at = ?, email = ?, account_id = ?, label = ?, window_label = ?, used_fraction = ?, status = ?, resets_at = ? WHERE id = ?",
6999
- );
7000
- this.#listUsageHistoryStmt = this.#db.prepare(
7001
- "SELECT recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at FROM usage_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) ORDER BY recorded_at ASC",
7002
- );
7003
- this.#insertUsageCostStmt = this.#db.prepare(
7004
- "INSERT INTO usage_cost_history (recorded_at, provider, account_key, cost_usd) VALUES (?, ?, ?, ?)",
7005
- );
7006
- this.#listUsageCostsStmt = this.#db.prepare(
7007
- "SELECT recorded_at, provider, account_key, cost_usd FROM usage_cost_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) AND (? IS NULL OR account_key = ?) ORDER BY recorded_at ASC",
7008
- );
7009
- }
7010
-
7011
- static async open(dbPath: string = getAgentDbPath()): Promise<SqliteAuthCredentialStore> {
7012
- const dir = path.dirname(dbPath);
7013
- const dirExists = await fs
7014
- .stat(dir)
7015
- .then(s => s.isDirectory())
7016
- .catch(() => false);
7017
- if (!dirExists) {
7018
- await fs.mkdir(dir, { recursive: true, mode: 0o700 });
7019
- }
7020
-
7021
- // Concurrent omp startups can race against WAL recovery and the schema
7022
- // init's first lock-taking statement. Bun's default `busy_timeout` is 0,
7023
- // so retry the open on `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY` with bounded
7024
- // exponential backoff before surfacing the failure. See issue #2421.
7025
- const maxAttempts = 4;
7026
- const baseDelayMs = 100;
7027
- let lastBusyError: Error | undefined;
7028
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
7029
- let db: Database | undefined;
7030
- try {
7031
- db = new Database(dbPath);
7032
- // Install the busy handler BEFORE the first lock-taking statement
7033
- // on this connection. The leases DDL below and the constructor's
7034
- // schema init both acquire locks during WAL recovery; without a
7035
- // non-zero `busy_timeout` they fail immediately with SQLITE_BUSY.
7036
- // See issue #2421.
7037
- SqliteAuthCredentialStore.#installBusyTimeout(db);
7038
- try {
7039
- await fs.chmod(dbPath, 0o600);
7040
- } catch {
7041
- // Ignore chmod failures (e.g., Windows)
7042
- }
7043
- SqliteAuthCredentialStore.#ensureAuthCredentialRefreshLeasesTable(db);
7044
- return new SqliteAuthCredentialStore(db);
7045
- } catch (err) {
7046
- db?.close();
7047
- if (!isSqliteBusyError(err)) {
7048
- throw err;
7049
- }
7050
- lastBusyError = err instanceof Error ? err : new Error(String(err));
7051
- if (attempt < maxAttempts - 1) {
7052
- await Bun.sleep(baseDelayMs * 2 ** attempt);
7053
- }
7054
- }
7055
- }
7056
- throw new AIError.ConfigurationError(
7057
- `Failed to open auth database at '${dbPath}' after ${maxAttempts} attempts: ${lastBusyError?.message}`,
7058
- { cause: lastBusyError },
7059
- );
7060
- }
7061
-
7062
- static #ensureAuthCredentialRefreshLeasesTable(db: Database): void {
7063
- db.run(`
7064
- CREATE TABLE IF NOT EXISTS auth_credential_refresh_leases (
7065
- credential_id INTEGER PRIMARY KEY,
7066
- owner TEXT NOT NULL,
7067
- expires_at_ms INTEGER NOT NULL,
7068
- updated_at INTEGER NOT NULL
7069
- );
7070
- CREATE INDEX IF NOT EXISTS idx_auth_credential_refresh_leases_expires ON auth_credential_refresh_leases(expires_at_ms);
7071
- `);
7072
- }
7073
-
7074
- /**
7075
- * Install the per-connection busy handler so lock-taking statements wait for
7076
- * a contended writer instead of failing immediately (Bun defaults
7077
- * `busy_timeout` to 0). MUST run before the first lock-taking statement on
7078
- * the connection: concurrent omp startups race WAL recovery and the leases
7079
- * DDL. Uses the centralized timeout so headless hosts keep their bounded
7080
- * busy wait instead of the interactive 5s value. See issues #2421, #7298.
7081
- */
7082
- static #installBusyTimeout(db: Database): void {
7083
- db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`);
7084
- }
7085
-
7086
- #initializeSchema(): void {
7087
- // Install the busy handler BEFORE any lock-taking statement (incl.
7088
- // `PRAGMA journal_mode=WAL`, which acquires an exclusive lock during WAL
7089
- // recovery). Without this, concurrent omp startups can crash here with
7090
- // `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY`. Re-setting when opened via
7091
- // `open()` (which already installed it) is idempotent. See issue #2421.
7092
- SqliteAuthCredentialStore.#installBusyTimeout(this.#db);
7093
- this.#db.run(`
7094
- PRAGMA journal_mode=WAL;
7095
- PRAGMA synchronous=NORMAL;
7096
- CREATE TABLE IF NOT EXISTS auth_schema_version (
7097
- id INTEGER PRIMARY KEY CHECK (id = 1),
7098
- version INTEGER NOT NULL
7099
- );
7100
- CREATE TABLE IF NOT EXISTS cache (
7101
- key TEXT PRIMARY KEY,
7102
- value TEXT NOT NULL,
7103
- expires_at INTEGER NOT NULL
7104
- );
7105
- CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
7106
- CREATE TABLE IF NOT EXISTS usage_history (
7107
- id INTEGER PRIMARY KEY AUTOINCREMENT,
7108
- recorded_at INTEGER NOT NULL,
7109
- provider TEXT NOT NULL,
7110
- account_key TEXT NOT NULL,
7111
- email TEXT,
7112
- account_id TEXT,
7113
- limit_id TEXT NOT NULL,
7114
- label TEXT NOT NULL,
7115
- window_label TEXT,
7116
- used_fraction REAL,
7117
- status TEXT,
7118
- resets_at INTEGER
7119
- );
7120
- CREATE INDEX IF NOT EXISTS idx_usage_history_series ON usage_history(provider, account_key, limit_id, recorded_at);
7121
- CREATE TABLE IF NOT EXISTS usage_cost_history (
7122
- id INTEGER PRIMARY KEY AUTOINCREMENT,
7123
- recorded_at INTEGER NOT NULL,
7124
- provider TEXT NOT NULL,
7125
- account_key TEXT NOT NULL,
7126
- cost_usd REAL NOT NULL
7127
- );
7128
- CREATE INDEX IF NOT EXISTS idx_usage_cost_history_lookup ON usage_cost_history(provider, account_key, recorded_at);
7129
- CREATE INDEX IF NOT EXISTS idx_usage_history_recorded ON usage_history(recorded_at);
7130
- CREATE TABLE IF NOT EXISTS clients (
7131
- install_id TEXT PRIMARY KEY,
7132
- hostname TEXT,
7133
- first_seen INTEGER NOT NULL,
7134
- last_seen INTEGER NOT NULL
7135
- );
7136
- CREATE TABLE IF NOT EXISTS client_usage (
7137
- id INTEGER PRIMARY KEY AUTOINCREMENT,
7138
- recorded_at INTEGER NOT NULL,
7139
- install_id TEXT NOT NULL,
7140
- provider TEXT NOT NULL,
7141
- model TEXT NOT NULL,
7142
- requests INTEGER NOT NULL,
7143
- input_tokens INTEGER NOT NULL,
7144
- output_tokens INTEGER NOT NULL,
7145
- cache_read_tokens INTEGER NOT NULL,
7146
- cache_write_tokens INTEGER NOT NULL,
7147
- cost_usd REAL NOT NULL DEFAULT 0
7148
- );
7149
- CREATE INDEX IF NOT EXISTS idx_client_usage_series ON client_usage(install_id, provider, model, recorded_at);
7150
- CREATE INDEX IF NOT EXISTS idx_client_usage_recorded ON client_usage(recorded_at);
7151
- `);
7152
-
7153
- if (!this.#authCredentialsTableExists()) {
7154
- this.#createAuthCredentialsTable();
7155
- this.#createAuthCredentialBlocksTable();
7156
- this.#createAuthCredentialRefreshLeasesTable();
7157
- this.#createAuthCredentialBlockCompatibilityObjects();
7158
- this.#createAuthChangeTrackingObjects();
7159
- this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION);
7160
- return;
7161
- }
7162
-
7163
- const recordedVersion = this.#readAuthSchemaVersion();
7164
- const schemaVersion = recordedVersion ?? this.#inferAuthSchemaVersion();
7165
- if (schemaVersion > AUTH_SCHEMA_VERSION) {
7166
- logger.warn("SqliteAuthCredentialStore schema version mismatch", {
7167
- current: schemaVersion,
7168
- expected: AUTH_SCHEMA_VERSION,
7169
- });
7170
- } else if (schemaVersion < AUTH_SCHEMA_VERSION) {
7171
- this.#migrateAuthSchema(schemaVersion);
7172
- }
7173
-
7174
- this.#createAuthCredentialIndexes();
7175
- this.#createAuthCredentialBlocksTable();
7176
- this.#createAuthCredentialRefreshLeasesTable();
7177
- if (schemaVersion <= AUTH_SCHEMA_VERSION) {
7178
- this.#createAuthCredentialBlockCompatibilityObjects();
7179
- }
7180
- this.#createAuthChangeTrackingObjects();
7181
- this.#backfillCredentialIdentityKeys();
7182
- // Rewriting an already-current version row is a no-op write transaction
7183
- // on every boot; only persist when the recorded version actually changes.
7184
- if (recordedVersion !== AUTH_SCHEMA_VERSION && schemaVersion <= AUTH_SCHEMA_VERSION) {
7185
- this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION);
7186
- }
7187
- }
7188
-
7189
- #authCredentialsTableExists(): boolean {
7190
- const stmt = this.#db.prepare(
7191
- "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'auth_credentials'",
7192
- );
7193
- try {
7194
- const row = stmt.get() as { present?: number } | undefined;
7195
- return row?.present === 1;
7196
- } finally {
7197
- stmt.finalize();
7198
- }
7199
- }
7200
-
7201
- #readAuthSchemaVersion(): number | null {
7202
- const stmt = this.#db.prepare("SELECT version FROM auth_schema_version WHERE id = 1");
7203
- try {
7204
- const row = stmt.get() as { version?: number } | undefined;
7205
- return typeof row?.version === "number" ? row.version : null;
7206
- } finally {
7207
- stmt.finalize();
7208
- }
7209
- }
7210
-
7211
- #writeAuthSchemaVersion(version: number): void {
7212
- const stmt = this.#db.prepare("INSERT OR REPLACE INTO auth_schema_version(id, version) VALUES (1, ?)");
7213
- try {
7214
- stmt.run(version);
7215
- } finally {
7216
- stmt.finalize();
7217
- }
7218
- }
7219
-
7220
- #inferAuthSchemaVersion(): number {
7221
- const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)");
7222
- try {
7223
- const cols = stmt.all() as Array<{ name?: string }>;
7224
- return this.#inferAuthSchemaVersionFromColumns(cols);
7225
- } finally {
7226
- stmt.finalize();
7227
- }
7228
- }
7229
-
7230
- #inferAuthSchemaVersionFromColumns(cols: Array<{ name?: string }>): number {
7231
- const hasDisabledCause = cols.some(column => column.name === "disabled_cause");
7232
- const hasIdentityKey = cols.some(column => column.name === "identity_key");
7233
- const hasAccountId = cols.some(column => column.name === "account_id");
7234
- const hasEmail = cols.some(column => column.name === "email");
7235
- if (hasIdentityKey) return 3;
7236
- if (hasAccountId || hasEmail) return 2;
7237
- if (hasDisabledCause) return 1;
7238
- return 0;
7239
- }
7240
-
7241
- #createAuthCredentialsTable(): void {
7242
- this.#db.run(`
7243
- CREATE TABLE IF NOT EXISTS auth_credentials (
7244
- id INTEGER PRIMARY KEY AUTOINCREMENT,
7245
- provider TEXT NOT NULL,
7246
- credential_type TEXT NOT NULL,
7247
- data TEXT NOT NULL,
7248
- disabled_cause TEXT DEFAULT NULL,
7249
- identity_key TEXT DEFAULT NULL,
7250
- created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}),
7251
- updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH})
7252
- );
7253
- `);
7254
- this.#createAuthCredentialIndexes();
7255
- }
7256
-
7257
- #createAuthCredentialIndexes(): void {
7258
- this.#db.run(`
7259
- CREATE INDEX IF NOT EXISTS idx_auth_provider ON auth_credentials(provider);
7260
- CREATE INDEX IF NOT EXISTS idx_auth_provider_identity ON auth_credentials(provider, identity_key) WHERE identity_key IS NOT NULL;
7261
- `);
7262
- }
7263
-
7264
- #createAuthCredentialBlocksTable(): void {
7265
- this.#db.run(`
7266
- CREATE TABLE IF NOT EXISTS auth_credential_blocks (
7267
- credential_id INTEGER NOT NULL,
7268
- provider_key TEXT NOT NULL,
7269
- block_scope TEXT NOT NULL DEFAULT '',
7270
- blocked_until_ms INTEGER NOT NULL,
7271
- updated_at INTEGER NOT NULL,
7272
- PRIMARY KEY (credential_id, provider_key, block_scope)
7273
- );
7274
- CREATE INDEX IF NOT EXISTS idx_auth_credential_blocks_expires ON auth_credential_blocks(blocked_until_ms);
7275
- `);
7276
- }
7277
-
7278
- #createAuthChangeTrackingObjects(): void {
7279
- this.#db.run(`
7280
- CREATE TABLE IF NOT EXISTS auth_change_revision (
7281
- id INTEGER PRIMARY KEY CHECK (id = 1),
7282
- revision INTEGER NOT NULL
7283
- );
7284
- INSERT OR IGNORE INTO auth_change_revision (id, revision) VALUES (1, 0);
7285
- CREATE TEMP TABLE IF NOT EXISTS auth_local_change_revision (
7286
- id INTEGER PRIMARY KEY CHECK (id = 1),
7287
- revision INTEGER NOT NULL
7288
- );
7289
- INSERT OR IGNORE INTO auth_local_change_revision (id, revision) VALUES (1, 0);
7290
- `);
7291
- for (const table of ["auth_credentials", "auth_credential_blocks"] as const) {
7292
- for (const event of ["INSERT", "UPDATE", "DELETE"] as const) {
7293
- this.#db.run(`
7294
- CREATE TRIGGER IF NOT EXISTS auth_change_revision_${table}_${event.toLowerCase()}
7295
- AFTER ${event} ON ${table}
7296
- BEGIN
7297
- UPDATE auth_change_revision SET revision = revision + 1 WHERE id = 1;
7298
- END;
7299
- `);
7300
- this.#db.run(`
7301
- CREATE TEMP TRIGGER IF NOT EXISTS auth_local_change_revision_${table}_${event.toLowerCase()}
7302
- AFTER ${event} ON main.${table}
7303
- BEGIN
7304
- UPDATE auth_local_change_revision SET revision = revision + 1 WHERE id = 1;
7305
- END;
7306
- `);
7307
- }
7308
- }
7309
- }
7310
-
7311
- #createAuthCredentialBlockMirrorGuardTable(): void {
7312
- this.#db.run(`
7313
- CREATE TABLE IF NOT EXISTS auth_credential_block_mirror_guard (
7314
- credential_id INTEGER PRIMARY KEY
7315
- ) WITHOUT ROWID;
7316
- `);
7317
- }
7318
-
7319
- /**
7320
- * Keep a physical Codex `shared` row for pre-meter binaries that read this
7321
- * database directly. Meter rows are canonical for current code. The guard
7322
- * suppresses feedback while triggers update the compatibility projection.
7323
- */
7324
- #createAuthCredentialBlockCompatibilityTriggers(): void {
7325
- for (const event of ["INSERT", "UPDATE"] as const) {
7326
- const eventName = event.toLowerCase();
7327
- this.#db.run(`
7328
- CREATE TRIGGER IF NOT EXISTS auth_codex_shared_${eventName}_to_meters
7329
- AFTER ${event} ON auth_credential_blocks
7330
- WHEN NEW.provider_key = 'openai-codex:oauth'
7331
- AND NEW.block_scope = 'shared'
7332
- AND NOT EXISTS (
7333
- SELECT 1 FROM auth_credential_block_mirror_guard
7334
- WHERE credential_id = NEW.credential_id
7335
- )
7336
- BEGIN
7337
- INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id)
7338
- VALUES (NEW.credential_id);
7339
- INSERT INTO auth_credential_blocks (
7340
- credential_id,
7341
- provider_key,
7342
- block_scope,
7343
- blocked_until_ms,
7344
- updated_at
7345
- )
7346
- VALUES (
7347
- NEW.credential_id,
7348
- NEW.provider_key,
7349
- 'chat',
7350
- NEW.blocked_until_ms,
7351
- NEW.updated_at
7352
- )
7353
- ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET
7354
- blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms),
7355
- updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at);
7356
- INSERT INTO auth_credential_blocks (
7357
- credential_id,
7358
- provider_key,
7359
- block_scope,
7360
- blocked_until_ms,
7361
- updated_at
7362
- )
7363
- VALUES (
7364
- NEW.credential_id,
7365
- NEW.provider_key,
7366
- 'spark',
7367
- NEW.blocked_until_ms,
7368
- NEW.updated_at
7369
- )
7370
- ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET
7371
- blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms),
7372
- updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at);
7373
- DELETE FROM auth_credential_block_mirror_guard
7374
- WHERE credential_id = NEW.credential_id;
7375
- END;
7376
-
7377
- CREATE TRIGGER IF NOT EXISTS auth_codex_meter_${eventName}_to_shared
7378
- AFTER ${event} ON auth_credential_blocks
7379
- WHEN NEW.provider_key = 'openai-codex:oauth'
7380
- AND NEW.block_scope IN ('chat', 'spark')
7381
- AND NOT EXISTS (
7382
- SELECT 1 FROM auth_credential_block_mirror_guard
7383
- WHERE credential_id = NEW.credential_id
7384
- )
7385
- BEGIN
7386
- INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id)
7387
- VALUES (NEW.credential_id);
7388
- DELETE FROM auth_credential_blocks
7389
- WHERE credential_id = NEW.credential_id
7390
- AND provider_key = NEW.provider_key
7391
- AND block_scope = 'shared';
7392
- INSERT INTO auth_credential_blocks (
7393
- credential_id,
7394
- provider_key,
7395
- block_scope,
7396
- blocked_until_ms,
7397
- updated_at
7398
- )
7399
- SELECT
7400
- NEW.credential_id,
7401
- NEW.provider_key,
7402
- 'shared',
7403
- MAX(blocked_until_ms),
7404
- MAX(updated_at)
7405
- FROM auth_credential_blocks
7406
- WHERE credential_id = NEW.credential_id
7407
- AND provider_key = NEW.provider_key
7408
- AND block_scope IN ('chat', 'spark')
7409
- GROUP BY credential_id, provider_key;
7410
- DELETE FROM auth_credential_block_mirror_guard
7411
- WHERE credential_id = NEW.credential_id;
7412
- END;
7413
- `);
7414
- }
7415
-
7416
- this.#db.run(`
7417
- CREATE TRIGGER IF NOT EXISTS auth_codex_shared_delete_to_meters
7418
- AFTER DELETE ON auth_credential_blocks
7419
- WHEN OLD.provider_key = 'openai-codex:oauth'
7420
- AND OLD.block_scope = 'shared'
7421
- AND NOT EXISTS (
7422
- SELECT 1 FROM auth_credential_block_mirror_guard
7423
- WHERE credential_id = OLD.credential_id
7424
- )
7425
- BEGIN
7426
- INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id)
7427
- VALUES (OLD.credential_id);
7428
- DELETE FROM auth_credential_blocks
7429
- WHERE credential_id = OLD.credential_id
7430
- AND provider_key = OLD.provider_key
7431
- AND block_scope IN ('chat', 'spark');
7432
- DELETE FROM auth_credential_block_mirror_guard
7433
- WHERE credential_id = OLD.credential_id;
7434
- END;
7435
-
7436
- CREATE TRIGGER IF NOT EXISTS auth_codex_meter_delete_to_shared
7437
- AFTER DELETE ON auth_credential_blocks
7438
- WHEN OLD.provider_key = 'openai-codex:oauth'
7439
- AND OLD.block_scope IN ('chat', 'spark')
7440
- AND NOT EXISTS (
7441
- SELECT 1 FROM auth_credential_block_mirror_guard
7442
- WHERE credential_id = OLD.credential_id
7443
- )
7444
- BEGIN
7445
- INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id)
7446
- VALUES (OLD.credential_id);
7447
- DELETE FROM auth_credential_blocks
7448
- WHERE credential_id = OLD.credential_id
7449
- AND provider_key = OLD.provider_key
7450
- AND block_scope = 'shared';
7451
- INSERT INTO auth_credential_blocks (
7452
- credential_id,
7453
- provider_key,
7454
- block_scope,
7455
- blocked_until_ms,
7456
- updated_at
7457
- )
7458
- SELECT
7459
- OLD.credential_id,
7460
- OLD.provider_key,
7461
- 'shared',
7462
- MAX(blocked_until_ms),
7463
- MAX(updated_at)
7464
- FROM auth_credential_blocks
7465
- WHERE credential_id = OLD.credential_id
7466
- AND provider_key = OLD.provider_key
7467
- AND block_scope IN ('chat', 'spark')
7468
- GROUP BY credential_id, provider_key;
7469
- DELETE FROM auth_credential_block_mirror_guard
7470
- WHERE credential_id = OLD.credential_id;
7471
- END;
7472
- `);
7473
- }
7474
-
7475
- #createAuthCredentialBlockCompatibilityObjects(): void {
7476
- this.#createAuthCredentialBlockMirrorGuardTable();
7477
- this.#createAuthCredentialBlockCompatibilityTriggers();
7478
- }
7479
-
7480
- #createAuthCredentialRefreshLeasesTable(): void {
7481
- SqliteAuthCredentialStore.#ensureAuthCredentialRefreshLeasesTable(this.#db);
7482
- }
7483
-
7484
- #migrateAuthSchema(fromVersion: number): void {
7485
- if (fromVersion < 1) {
7486
- this.#migrateAuthSchemaV0ToV1();
7487
- }
7488
- if (fromVersion < 3) {
7489
- this.#migrateAuthSchemaV1OrV2ToV3();
7490
- }
7491
- if (fromVersion < 4) {
7492
- this.#migrateAuthSchemaV3ToV4();
7493
- }
7494
- if (fromVersion < 5) {
7495
- this.#migrateAuthSchemaV4ToV5();
7496
- }
7497
- if (fromVersion < 6) {
7498
- this.#migrateAuthSchemaV5ToV6();
7499
- }
7500
- if (fromVersion < 7) {
7501
- this.#migrateAuthSchemaV6ToV7();
7502
- }
7503
- }
7504
-
7505
- #migrateAuthSchemaV0ToV1(): void {
7506
- const migrate = this.#db.transaction(() => {
7507
- const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)");
7508
- let hasDisabled = false;
7509
- try {
7510
- const v0Cols = stmt.all() as Array<{ name?: string }>;
7511
- hasDisabled = v0Cols.some(col => col.name === "disabled");
7512
- } finally {
7513
- stmt.finalize();
7514
- }
7515
-
7516
- this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v0");
7517
- this.#db.run(`
7518
- CREATE TABLE auth_credentials (
7519
- id INTEGER PRIMARY KEY AUTOINCREMENT,
7520
- provider TEXT NOT NULL,
7521
- credential_type TEXT NOT NULL,
7522
- data TEXT NOT NULL,
7523
- disabled_cause TEXT DEFAULT NULL,
7524
- created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}),
7525
- updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH})
7526
- );
7527
- `);
7528
- this.#db.run(`
7529
- INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, created_at, updated_at)
7530
- SELECT
7531
- id,
7532
- provider,
7533
- credential_type,
7534
- data,
7535
- ${hasDisabled ? "CASE WHEN disabled = 1 THEN 'disabled' ELSE NULL END" : "NULL"},
7536
- created_at,
7537
- updated_at
7538
- FROM auth_credentials_v0
7539
- `);
7540
- this.#db.run("DROP TABLE auth_credentials_v0");
7541
- });
7542
- migrate();
7543
- }
7544
-
7545
- #migrateAuthSchemaV1OrV2ToV3(): void {
7546
- const migrate = this.#db.transaction(() => {
7547
- this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_legacy");
7548
- this.#createAuthCredentialsTable();
7549
- this.#db.run(`
7550
- INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at)
7551
- SELECT
7552
- id,
7553
- provider,
7554
- credential_type,
7555
- data,
7556
- disabled_cause,
7557
- NULL,
7558
- created_at,
7559
- updated_at
7560
- FROM auth_credentials_legacy
7561
- `);
7562
- this.#db.run("DROP TABLE auth_credentials_legacy");
7563
- });
7564
- migrate();
7565
- }
7566
-
7567
- #migrateAuthSchemaV3ToV4(): void {
7568
- const migrate = this.#db.transaction(() => {
7569
- this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v3");
7570
- this.#createAuthCredentialsTable();
7571
- this.#db.run(`
7572
- INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at)
7573
- SELECT
7574
- id,
7575
- provider,
7576
- credential_type,
7577
- data,
7578
- disabled_cause,
7579
- identity_key,
7580
- created_at,
7581
- updated_at
7582
- FROM auth_credentials_v3
7583
- `);
7584
- this.#db.run("DROP TABLE auth_credentials_v3");
7585
- });
7586
- migrate();
7587
- }
7588
-
7589
- #migrateAuthSchemaV4ToV5(): void {
7590
- const migrate = this.#db.transaction(() => {
7591
- this.#createAuthCredentialBlocksTable();
7592
- });
7593
- migrate();
7594
- }
7595
-
7596
- #migrateAuthSchemaV5ToV6(): void {
7597
- const migrate = this.#db.transaction(() => {
7598
- this.#createAuthCredentialRefreshLeasesTable();
7599
- });
7600
- migrate();
7601
- }
7602
-
7603
- #migrateAuthSchemaV6ToV7(): void {
7604
- const migrate = this.#db.transaction(() => {
7605
- this.#createAuthCredentialBlocksTable();
7606
- this.#createAuthCredentialBlockMirrorGuardTable();
7607
- this.#db.run(`
7608
- DELETE FROM auth_credential_block_mirror_guard;
7609
- INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id)
7610
- SELECT DISTINCT credential_id
7611
- FROM auth_credential_blocks
7612
- WHERE provider_key = 'openai-codex:oauth'
7613
- AND block_scope IN ('chat', 'spark', 'shared');
7614
-
7615
- INSERT INTO auth_credential_blocks (
7616
- credential_id,
7617
- provider_key,
7618
- block_scope,
7619
- blocked_until_ms,
7620
- updated_at
7621
- )
7622
- SELECT credential_id, provider_key, 'chat', blocked_until_ms, updated_at
7623
- FROM auth_credential_blocks
7624
- WHERE provider_key = 'openai-codex:oauth'
7625
- AND block_scope = 'shared'
7626
- ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET
7627
- blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms),
7628
- updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at);
7629
-
7630
- INSERT INTO auth_credential_blocks (
7631
- credential_id,
7632
- provider_key,
7633
- block_scope,
7634
- blocked_until_ms,
7635
- updated_at
7636
- )
7637
- SELECT credential_id, provider_key, 'spark', blocked_until_ms, updated_at
7638
- FROM auth_credential_blocks
7639
- WHERE provider_key = 'openai-codex:oauth'
7640
- AND block_scope = 'shared'
7641
- ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET
7642
- blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms),
7643
- updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at);
7644
-
7645
- INSERT INTO auth_credential_blocks (
7646
- credential_id,
7647
- provider_key,
7648
- block_scope,
7649
- blocked_until_ms,
7650
- updated_at
7651
- )
7652
- SELECT
7653
- credential_id,
7654
- provider_key,
7655
- 'shared',
7656
- MAX(blocked_until_ms),
7657
- MAX(updated_at)
7658
- FROM auth_credential_blocks
7659
- WHERE provider_key = 'openai-codex:oauth'
7660
- AND block_scope IN ('chat', 'spark')
7661
- GROUP BY credential_id, provider_key
7662
- ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET
7663
- blocked_until_ms = excluded.blocked_until_ms,
7664
- updated_at = excluded.updated_at;
7665
-
7666
- DELETE FROM auth_credential_block_mirror_guard;
7667
- `);
7668
- this.#createAuthCredentialBlockCompatibilityTriggers();
7669
- this.#writeAuthSchemaVersion(7);
7670
- });
7671
- migrate.immediate();
7672
- }
7673
-
7674
- #backfillCredentialIdentityKeys(): void {
7675
- const selectRowsStmt = this.#db.prepare(
7676
- "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
7677
- );
7678
- let rows: AuthRow[];
7679
- try {
7680
- rows = selectRowsStmt.all() as AuthRow[];
7681
- } finally {
7682
- selectRowsStmt.finalize();
7683
- }
7684
- if (rows.length === 0) return;
7685
-
7686
- let updateIdentity: Statement | null = null;
7687
- try {
7688
- for (const row of rows) {
7689
- const identityKey = resolveRowCredentialIdentityKey(row.provider, row);
7690
- // Rows whose identity cannot be derived stay NULL; writing NULL over
7691
- // NULL would just burn a write transaction on every boot.
7692
- if (identityKey === null) continue;
7693
- updateIdentity ??= this.#db.prepare("UPDATE auth_credentials SET identity_key = ? WHERE id = ?");
7694
- updateIdentity.run(identityKey, row.id);
7695
- }
7696
- } finally {
7697
- updateIdentity?.finalize();
7698
- }
7699
- }
7700
-
7701
- // ─── AuthCredentialStore interface ──────────────────────────────────────
7702
-
7703
- listAuthCredentials(provider?: string): StoredAuthCredential[] {
7704
- const rows =
7705
- (provider
7706
- ? (this.#listActiveByProviderStmt.all(provider) as AuthRow[])
7707
- : (this.#listActiveStmt.all() as AuthRow[])) ?? [];
7708
-
7709
- const results: StoredAuthCredential[] = [];
7710
- for (const row of rows) {
7711
- const credential = deserializeCredential(row);
7712
- if (!credential) continue;
7713
- results.push(toStoredAuthCredential(row, credential));
7714
- }
7715
- return results;
7716
- }
7717
-
7718
- async listDisabledCredentials(provider?: string): Promise<DisabledCredentialSummary[]> {
7719
- const rows =
7720
- (provider
7721
- ? (this.#listDisabledByProviderStmt.all(provider) as DisabledAuthRow[])
7722
- : (this.#listDisabledStmt.all() as DisabledAuthRow[])) ?? [];
7723
- const results: DisabledCredentialSummary[] = [];
7724
- for (const row of rows) {
7725
- const credential = deserializeCredential(row);
7726
- const summary: DisabledCredentialSummary = {
7727
- id: row.id,
7728
- provider: row.provider,
7729
- type: row.credential_type === "api_key" ? "api_key" : "oauth",
7730
- cause: row.disabled_cause ?? "disabled",
7731
- };
7732
- if (credential?.type === "oauth") {
7733
- if (credential.email) summary.email = credential.email;
7734
- if (credential.accountId) summary.accountId = credential.accountId;
7735
- if (credential.orgId) summary.orgId = credential.orgId;
7736
- if (credential.orgName) summary.orgName = credential.orgName;
7737
- }
7738
- if (typeof row.updated_at === "number" && Number.isFinite(row.updated_at)) {
7739
- summary.disabledAtMs = row.updated_at * 1000;
7740
- }
7741
- results.push(summary);
7742
- }
7743
- return results;
7744
- }
7745
-
7746
- replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] {
7747
- const replace = this.#db.transaction((providerName: string, items: AuthCredential[]) => {
7748
- const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
7749
- const existing = existingRows.map(row => ({
7750
- id: row.id,
7751
- credential: deserializeCredential(row),
7752
- identityKey: resolveRowCredentialIdentityKey(providerName, row),
7753
- }));
7754
-
7755
- const result: StoredAuthCredential[] = [];
7756
- const matchedExistingIds = new Set<number>();
7757
-
7758
- for (const credential of items) {
7759
- const serialized = serializeCredential(providerName, credential);
7760
- if (!serialized) continue;
7761
- const match = existing.find(
7762
- entry =>
7763
- !matchedExistingIds.has(entry.id) &&
7764
- matchesReplacementCredential(providerName, entry.credential, entry.identityKey, credential),
7765
- );
7766
- if (match) {
7767
- matchedExistingIds.add(match.id);
7768
- this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, match.id);
7769
- result.push({ id: match.id, provider: providerName, credential, disabledCause: null });
7770
- } else {
7771
- const row = this.#insertStmt.get(
7772
- providerName,
7773
- serialized.credentialType,
7774
- serialized.data,
7775
- serialized.identityKey,
7776
- ) as { id?: number } | undefined;
7777
- if (row?.id) {
7778
- result.push({ id: row.id, provider: providerName, credential, disabledCause: null });
7779
- }
7780
- }
7781
- }
7782
-
7783
- for (const row of existing) {
7784
- if (!matchedExistingIds.has(row.id)) {
7785
- this.#deleteStmt.run("replaced by newer credential", row.id);
7786
- }
7787
- }
7788
-
7789
- return result;
7790
- });
7791
-
7792
- const result = replace(provider, credentials);
7793
- this.#purgeSupersededDisabledRows(provider, result);
7794
- return result;
7795
- }
7796
-
7797
- upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[] {
7798
- const upsert = this.#db.transaction((providerName: string, item: AuthCredential) => {
7799
- const serialized = serializeCredential(providerName, item);
7800
- if (!serialized) return this.listAuthCredentials(providerName);
7801
- const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
7802
- const existing = existingRows.map(row => ({
7803
- id: row.id,
7804
- credential: deserializeCredential(row),
7805
- identityKey: resolveRowCredentialIdentityKey(providerName, row),
7806
- }));
7807
-
7808
- if (item.type === "oauth") {
7809
- for (const row of existing) {
7810
- if (row.credential && row.credential.type === "api_key") {
7811
- this.#deleteStmt.run("replaced by oauth login", row.id);
7812
- }
7813
- }
7814
- }
7815
-
7816
- let targetId: number | null = null;
7817
- for (const row of existing) {
7818
- if (!matchesReplacementCredential(providerName, row.credential, row.identityKey, item)) continue;
7819
- if (targetId === null) {
7820
- targetId = row.id;
7821
- this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, row.id);
7822
- continue;
7823
- }
7824
- this.#deleteStmt.run("replaced by newer credential", row.id);
7825
- }
7826
-
7827
- if (targetId === null) {
7828
- const row = this.#insertStmt.get(
7829
- providerName,
7830
- serialized.credentialType,
7831
- serialized.data,
7832
- serialized.identityKey,
7833
- ) as { id?: number } | undefined;
7834
- targetId = row?.id ?? null;
7835
- }
7836
-
7837
- const activeRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
7838
- const result: StoredAuthCredential[] = [];
7839
- for (const row of activeRows) {
7840
- const activeCredential = deserializeCredential(row);
7841
- if (!activeCredential) continue;
7842
- result.push(toStoredAuthCredential(row, activeCredential));
7843
- }
7844
- return result;
7845
- });
7846
-
7847
- const result = upsert(provider, credential);
7848
- this.#purgeSupersededDisabledRows(provider, result);
7849
- return result;
7850
- }
7851
-
7852
- /**
7853
- * Hard-deletes disabled rows for a provider when an active replacement exists.
7854
- * OAuth credentials match by identity key; API keys match by provider and type.
7855
- * Disabled rows without an active same-type replacement remain recoverable.
7856
- */
7857
- #purgeSupersededDisabledRows(provider: string, activeRows: StoredAuthCredential[]): void {
7858
- try {
7859
- let hasActiveApiKey = false;
7860
- const activeIdentityKeys = new Set<string>();
7861
- for (const row of activeRows) {
7862
- if (row.credential.type === "api_key") {
7863
- hasActiveApiKey = true;
7864
- continue;
7865
- }
7866
- const identityKey = resolveCredentialIdentityKey(provider, row.credential);
7867
- if (identityKey) activeIdentityKeys.add(identityKey);
7868
- }
7869
- if (!hasActiveApiKey && activeIdentityKeys.size === 0) return;
7870
-
7871
- const disabledRows = this.#listDisabledByProviderStmt.all(provider) as AuthRow[];
7872
- for (const row of disabledRows) {
7873
- if (hasActiveApiKey && row.credential_type === "api_key") {
7874
- this.#hardDeleteStmt.run(row.id);
7875
- continue;
7876
- }
7877
- const identityKey = resolveRowCredentialIdentityKey(provider, row);
7878
- if (identityKey && activeIdentityKeys.has(identityKey)) {
7879
- this.#hardDeleteStmt.run(row.id);
7880
- }
7881
- }
7882
- } catch {
7883
- // Best-effort cleanup; don't let it break the main operation
7884
- }
7885
- }
7886
-
7887
- updateAuthCredential(id: number, credential: AuthCredential): void {
7888
- try {
7889
- const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?");
7890
- let providerRow: { provider?: string } | undefined;
7891
- try {
7892
- providerRow = providerStmt.get(id) as { provider?: string } | undefined;
7893
- } finally {
7894
- providerStmt.finalize();
7895
- }
7896
- const provider = providerRow?.provider ?? "";
7897
- const serialized = serializeCredential(provider, credential);
7898
- if (!serialized) return;
7899
- this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, id);
7900
- if (provider) {
7901
- this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider));
7902
- }
7903
- } catch {
7904
- // Ignore update failures
7905
- }
7906
- }
7907
-
7908
- tryUpdateAuthCredentialIfMatches(
7909
- id: number,
7910
- expectedData: string,
7911
- credential: AuthCredential,
7912
- lease?: CredentialRefreshLeaseFence,
7913
- ): boolean {
7914
- const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?");
7915
- let providerRow: { provider?: string } | undefined;
7916
- try {
7917
- providerRow = providerStmt.get(id) as { provider?: string } | undefined;
7918
- } finally {
7919
- providerStmt.finalize();
7920
- }
7921
- const provider = providerRow?.provider ?? "";
7922
- const serialized = serializeCredential(provider, credential);
7923
- if (!serialized) return false;
7924
- const result = lease
7925
- ? (this.#updateIfMatchesWithLeaseStmt.run(
7926
- serialized.credentialType,
7927
- serialized.data,
7928
- serialized.identityKey,
7929
- id,
7930
- expectedData,
7931
- id,
7932
- lease.owner,
7933
- lease.nowMs,
7934
- ) as { changes: number })
7935
- : (this.#updateIfMatchesStmt.run(
7936
- serialized.credentialType,
7937
- serialized.data,
7938
- serialized.identityKey,
7939
- id,
7940
- expectedData,
7941
- ) as { changes: number });
7942
- if (result.changes === 0) return false;
7943
- if (provider) {
7944
- this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider));
7945
- }
7946
- return true;
7947
- }
7948
-
7949
- deleteAuthCredential(id: number, disabledCause: string): void {
7950
- try {
7951
- this.#deleteStmt.run(normalizeDisabledCause(disabledCause), id);
7952
- } catch {
7953
- // Ignore delete failures
7954
- }
7955
- }
7956
-
7957
- /**
7958
- * CAS-style disable: only soft-deletes the row when its `data` column still
7959
- * matches `expectedData` and the row has not already been disabled. Used by
7960
- * the OAuth refresh-failure path to avoid clobbering a peer that rotated the
7961
- * row between our pre-check and the disable.
7962
- */
7963
- tryDisableAuthCredentialIfMatches(
7964
- id: number,
7965
- expectedData: string,
7966
- disabledCause: string,
7967
- lease?: CredentialRefreshLeaseFence,
7968
- ): boolean {
7969
- const result = lease
7970
- ? (this.#deleteIfMatchesWithLeaseStmt.run(
7971
- normalizeDisabledCause(disabledCause),
7972
- id,
7973
- expectedData,
7974
- id,
7975
- lease.owner,
7976
- lease.nowMs,
7977
- ) as { changes: number })
7978
- : (this.#deleteIfMatchesStmt.run(normalizeDisabledCause(disabledCause), id, expectedData) as {
7979
- changes: number;
7980
- });
7981
- return result.changes > 0;
7982
- }
7983
- deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void {
7984
- try {
7985
- this.#deleteByProviderStmt.run(normalizeDisabledCause(disabledCause), provider);
7986
- } catch {
7987
- // Ignore delete failures
7988
- }
7989
- }
7990
-
7991
- getCache(key: string, options?: { includeExpired?: boolean }): string | null {
7992
- try {
7993
- const stmt = options?.includeExpired === true ? this.#getCacheIncludingExpiredStmt : this.#getCacheStmt;
7994
- const row = stmt.get(key) as { value?: string } | undefined;
7995
- return row?.value ?? null;
7996
- } catch {
7997
- return null;
7998
- }
7999
- }
8000
-
8001
- setCache(key: string, value: string, expiresAtSec: number): void {
8002
- try {
8003
- this.#upsertCacheStmt.run(key, value, expiresAtSec);
8004
- } catch {
8005
- // Ignore cache set failures
8006
- }
8007
- }
8008
-
8009
- /** Drop all cache rows whose keys start with the supplied prefix. */
8010
- deleteCachePrefix(prefix: string): void {
8011
- try {
8012
- this.#deleteCachePrefixStmt.run(prefix.length, prefix);
8013
- } catch {
8014
- // Ignore cache delete failures
8015
- }
8016
- }
8017
-
8018
- cleanExpiredCache(): void {
8019
- try {
8020
- this.#deleteExpiredCacheStmt.run();
8021
- } catch {
8022
- // Ignore cleanup errors
8023
- }
8024
- }
8025
-
8026
- getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined {
8027
- const nowMs = Date.now();
8028
- const isCodexBlock = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY;
8029
- // Current callers use meter scopes. The physical shared row exists only
8030
- // for direct SQLite readers from pre-meter releases.
8031
- if (isCodexBlock && blockScope === LEGACY_CODEX_BLOCK_SCOPE) {
8032
- return undefined;
8033
- }
8034
- if (!isCodexBlock) this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
8035
- const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as
8036
- | { blocked_until_ms?: number; updated_at?: number }
8037
- | undefined;
8038
- return typeof row?.blocked_until_ms === "number" ? row.blocked_until_ms : undefined;
8039
- }
8040
-
8041
- getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined {
8042
- const nowMs = Date.now();
8043
- const isCodexBlock = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY;
8044
- if (isCodexBlock && blockScope === LEGACY_CODEX_BLOCK_SCOPE) {
8045
- return undefined;
8046
- }
8047
- if (!isCodexBlock) this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
8048
- const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as
8049
- | { blocked_until_ms?: number; updated_at?: number }
8050
- | undefined;
8051
- if (typeof row?.blocked_until_ms !== "number") return undefined;
8052
- const memoryReconcileAfter =
8053
- this.#credentialBlockReconcileAfter.get(`${credentialId}\0${providerKey}\0${blockScope}`) ?? 0;
8054
- const persistedReconcileAfter =
8055
- typeof row.updated_at === "number" ? row.updated_at * 1000 + USAGE_REPORT_TTL_MS : 0;
8056
- const reconcileAfter = Math.max(memoryReconcileAfter, persistedReconcileAfter);
8057
- return reconcileAfter > nowMs ? Math.min(row.blocked_until_ms, reconcileAfter) : undefined;
8058
- }
8059
-
8060
- upsertCredentialBlock(block: StoredCredentialBlock): void {
8061
- const isLegacyCodexBlock =
8062
- block.providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY && block.blockScope === LEGACY_CODEX_BLOCK_SCOPE;
8063
- const blockScopes = isLegacyCodexBlock ? CODEX_METER_BLOCK_SCOPES : [block.blockScope];
8064
- const upsert = this.#db.transaction(() => {
8065
- for (const blockScope of blockScopes) {
8066
- this.#upsertCredentialBlockStmt.run(
8067
- block.credentialId,
8068
- block.providerKey,
8069
- blockScope,
8070
- block.blockedUntilMs,
8071
- );
8072
- }
8073
- });
8074
- upsert.immediate();
8075
-
8076
- const reconcileAfterMs = Math.min(block.blockedUntilMs, Date.now() + USAGE_REPORT_TTL_MS);
8077
- for (const blockScope of blockScopes) {
8078
- this.#credentialBlockReconcileAfter.set(
8079
- `${block.credentialId}\0${block.providerKey}\0${blockScope}`,
8080
- reconcileAfterMs,
8081
- );
8082
- }
8083
- if (isLegacyCodexBlock) {
8084
- this.#credentialBlockReconcileAfter.delete(
8085
- `${block.credentialId}\0${block.providerKey}\0${LEGACY_CODEX_BLOCK_SCOPE}`,
8086
- );
8087
- }
8088
- }
8089
-
8090
- deleteCredentialBlock(credentialId: number, providerKey: string, blockScope: string): void {
8091
- this.#deleteCredentialBlockStmt.run(credentialId, providerKey, blockScope);
8092
- this.#credentialBlockReconcileAfter.delete(`${credentialId}\0${providerKey}\0${blockScope}`);
8093
- }
8094
-
8095
- deleteCredentialBlocks(credentialId: number): void {
8096
- this.#deleteCredentialBlocksStmt.run(credentialId);
8097
- for (const key of this.#credentialBlockReconcileAfter.keys()) {
8098
- if (key.startsWith(`${credentialId}\0`)) this.#credentialBlockReconcileAfter.delete(key);
8099
- }
8100
- }
8101
-
8102
- cleanExpiredCredentialBlocks(nowMs: number): void {
8103
- this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
8104
- for (const [key, reconcileAfterMs] of this.#credentialBlockReconcileAfter) {
8105
- if (reconcileAfterMs <= nowMs) this.#credentialBlockReconcileAfter.delete(key);
8106
- }
8107
- }
8108
-
8109
- listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] {
8110
- if (credentialIds.length === 0) return [];
8111
- const nowMs = Date.now();
8112
- this.cleanExpiredCredentialBlocks(nowMs);
8113
- const seenCredentialIds = new Set<number>();
8114
- const blocks: StoredCredentialBlock[] = [];
8115
- for (const credentialId of credentialIds) {
8116
- if (seenCredentialIds.has(credentialId)) continue;
8117
- seenCredentialIds.add(credentialId);
8118
- const rows = this.#listCredentialBlocksByCredentialStmt.all(
8119
- credentialId,
8120
- nowMs,
8121
- LEGACY_CODEX_BLOCK_PROVIDER_KEY,
8122
- LEGACY_CODEX_BLOCK_SCOPE,
8123
- ) as CredentialBlockRow[];
8124
- for (const row of rows) {
8125
- blocks.push({
8126
- credentialId: row.credential_id,
8127
- providerKey: row.provider_key,
8128
- blockScope: row.block_scope,
8129
- blockedUntilMs: row.blocked_until_ms,
8130
- updatedAtMs: row.updated_at * 1000,
8131
- });
8132
- }
8133
- }
8134
- return blocks;
8135
- }
8136
-
8137
- tryAcquireCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean {
8138
- const result = this.#acquireCredentialRefreshLeaseStmt.run(credentialId, owner, expiresAtMs, Date.now()) as {
8139
- changes: number;
8140
- };
8141
- return result.changes === 1;
8142
- }
8143
-
8144
- getCredentialRefreshLeaseExpiresAt(credentialId: number): number | undefined {
8145
- const row = this.#getCredentialRefreshLeaseStmt.get(credentialId) as { expires_at_ms?: number } | undefined;
8146
- if (typeof row?.expires_at_ms !== "number") return undefined;
8147
- if (row.expires_at_ms <= Date.now()) return undefined;
8148
- return row.expires_at_ms;
8149
- }
8150
-
8151
- renewCredentialRefreshLease(credentialId: number, owner: string, expiresAtMs: number): boolean {
8152
- const result = this.#renewCredentialRefreshLeaseStmt.run(expiresAtMs, credentialId, owner) as {
8153
- changes: number;
8154
- };
8155
- return result.changes === 1;
8156
- }
8157
-
8158
- releaseCredentialRefreshLease(credentialId: number, owner: string): void {
8159
- try {
8160
- this.#releaseCredentialRefreshLeaseStmt.run(credentialId, owner);
8161
- } catch {
8162
- // Ignore lease release failures; expired leases are stealable.
8163
- }
8164
- }
8165
-
8166
- recordUsageSnapshots(entries: UsageHistoryEntry[]): void {
8167
- try {
8168
- for (const entry of entries) {
8169
- const bucket = Math.floor(entry.recordedAt / USAGE_HISTORY_BUCKET_MS);
8170
- const last = this.#lastUsageHistoryStmt.get(entry.provider, entry.accountKey, entry.limitId) as
8171
- | { id: number; recorded_at: number }
8172
- | undefined;
8173
- if (last && Math.floor(last.recorded_at / USAGE_HISTORY_BUCKET_MS) === bucket) {
8174
- this.#updateUsageHistoryStmt.run(
8175
- entry.recordedAt,
8176
- entry.email ?? null,
8177
- entry.accountId ?? null,
8178
- entry.label,
8179
- entry.windowLabel ?? null,
8180
- entry.usedFraction ?? null,
8181
- entry.status ?? null,
8182
- entry.resetsAt ?? null,
8183
- last.id,
8184
- );
8185
- continue;
8186
- }
8187
- this.#insertUsageHistoryStmt.run(
8188
- entry.recordedAt,
8189
- entry.provider,
8190
- entry.accountKey,
8191
- entry.email ?? null,
8192
- entry.accountId ?? null,
8193
- entry.limitId,
8194
- entry.label,
8195
- entry.windowLabel ?? null,
8196
- entry.usedFraction ?? null,
8197
- entry.status ?? null,
8198
- entry.resetsAt ?? null,
8199
- );
8200
- }
8201
- } catch {
8202
- // History is best-effort; never break the usage fetch path.
8203
- }
8204
- }
8205
-
8206
- listUsageHistory(query?: UsageHistoryQuery): UsageHistoryEntry[] {
8207
- try {
8208
- const provider = query?.provider ?? null;
8209
- const rows = this.#listUsageHistoryStmt.all(query?.sinceMs ?? 0, provider, provider) as Array<{
8210
- recorded_at: number;
8211
- provider: string;
8212
- account_key: string;
8213
- email: string | null;
8214
- account_id: string | null;
8215
- limit_id: string;
8216
- label: string;
8217
- window_label: string | null;
8218
- used_fraction: number | null;
8219
- status: string | null;
8220
- resets_at: number | null;
8221
- }>;
8222
- return rows.map(row => ({
8223
- recordedAt: row.recorded_at,
8224
- provider: row.provider as Provider,
8225
- accountKey: row.account_key,
8226
- email: row.email ?? undefined,
8227
- accountId: row.account_id ?? undefined,
8228
- limitId: row.limit_id,
8229
- label: row.label,
8230
- windowLabel: row.window_label ?? undefined,
8231
- usedFraction: row.used_fraction ?? undefined,
8232
- status: (row.status ?? undefined) as UsageHistoryEntry["status"],
8233
- resetsAt: row.resets_at ?? undefined,
8234
- }));
8235
- } catch {
8236
- return [];
8237
- }
8238
- }
8239
- recordUsageCosts(entries: UsageCostHistoryEntry[]): void {
8240
- try {
8241
- for (const entry of entries) {
8242
- this.#insertUsageCostStmt.run(entry.recordedAt, entry.provider, entry.accountKey, entry.costUsd);
8243
- }
8244
- } catch {
8245
- // Cost history is best-effort; never break request persistence.
8246
- }
8247
- }
8248
-
8249
- listUsageCosts(query?: UsageCostHistoryQuery): UsageCostHistoryEntry[] {
8250
- try {
8251
- const provider = query?.provider ?? null;
8252
- const accountKey = query?.accountKey ?? null;
8253
- const rows = this.#listUsageCostsStmt.all(
8254
- query?.sinceMs ?? 0,
8255
- provider,
8256
- provider,
8257
- accountKey,
8258
- accountKey,
8259
- ) as Array<{
8260
- recorded_at: number;
8261
- provider: string;
8262
- account_key: string;
8263
- cost_usd: number;
8264
- }>;
8265
- return rows.map(row => ({
8266
- recordedAt: row.recorded_at,
8267
- provider: row.provider as Provider,
8268
- accountKey: row.account_key,
8269
- costUsd: row.cost_usd,
8270
- }));
8271
- } catch {
8272
- return [];
8273
- }
8274
- }
8275
-
8276
- recordClientUsage(report: ClientUsageReport): void {
8277
- const now = Date.now();
8278
- this.#db
8279
- .query(
8280
- `INSERT INTO clients (install_id, hostname, first_seen, last_seen) VALUES (?, ?, ?, ?)
8281
- ON CONFLICT(install_id) DO UPDATE SET hostname = COALESCE(excluded.hostname, hostname), last_seen = excluded.last_seen`,
8282
- )
8283
- .run(report.installId, report.hostname ?? null, now, now);
8284
- const findBucket = this.#db.query(
8285
- `SELECT id FROM client_usage
8286
- WHERE install_id = ? AND provider = ? AND model = ? AND recorded_at >= ?
8287
- ORDER BY recorded_at DESC LIMIT 1`,
8288
- );
8289
- const merge = this.#db.query(
8290
- `UPDATE client_usage SET recorded_at = ?, requests = requests + ?, input_tokens = input_tokens + ?,
8291
- output_tokens = output_tokens + ?, cache_read_tokens = cache_read_tokens + ?,
8292
- cache_write_tokens = cache_write_tokens + ?, cost_usd = cost_usd + ? WHERE id = ?`,
8293
- );
8294
- const insert = this.#db.query(
8295
- `INSERT INTO client_usage (recorded_at, install_id, provider, model, requests, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost_usd)
8296
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
8297
- );
8298
- for (const entry of report.entries) {
8299
- // Merge into the newest row of the same (install, provider, model)
8300
- // bucket so 10s client flushes don't accrete one row apiece forever.
8301
- const bucketFloor = entry.at - CLIENT_USAGE_BUCKET_MS;
8302
- const existing = findBucket.get(report.installId, entry.provider, entry.model, bucketFloor) as {
8303
- id: number;
8304
- } | null;
8305
- if (existing) {
8306
- merge.run(
8307
- entry.at,
8308
- entry.requests,
8309
- entry.inputTokens,
8310
- entry.outputTokens,
8311
- entry.cacheReadTokens,
8312
- entry.cacheWriteTokens,
8313
- entry.costUsd,
8314
- existing.id,
8315
- );
8316
- continue;
8317
- }
8318
- insert.run(
8319
- entry.at,
8320
- report.installId,
8321
- entry.provider,
8322
- entry.model,
8323
- entry.requests,
8324
- entry.inputTokens,
8325
- entry.outputTokens,
8326
- entry.cacheReadTokens,
8327
- entry.cacheWriteTokens,
8328
- entry.costUsd,
8329
- );
8330
- }
8331
- }
8332
-
8333
- getClientUsageSummary(sinceMs: number): ClientUsageSummary {
8334
- const clients = this.#db
8335
- .query("SELECT install_id, hostname, first_seen, last_seen FROM clients ORDER BY last_seen DESC")
8336
- .all() as Array<{ install_id: string; hostname: string | null; first_seen: number; last_seen: number }>;
8337
- const aggregates = this.#db
8338
- .query(
8339
- `SELECT install_id, provider, SUM(requests) requests, SUM(input_tokens) input_tokens,
8340
- SUM(output_tokens) output_tokens, SUM(cache_read_tokens) cache_read_tokens,
8341
- SUM(cache_write_tokens) cache_write_tokens, SUM(cost_usd) cost_usd
8342
- FROM client_usage WHERE recorded_at >= ? GROUP BY install_id, provider
8343
- ORDER BY install_id, SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) DESC`,
8344
- )
8345
- .all(sinceMs) as Array<{
8346
- install_id: string;
8347
- provider: string;
8348
- requests: number;
8349
- input_tokens: number;
8350
- output_tokens: number;
8351
- cache_read_tokens: number;
8352
- cache_write_tokens: number;
8353
- cost_usd: number;
8354
- }>;
8355
- const providersByInstall = new Map<string, ClientProviderUsage[]>();
8356
- for (const row of aggregates) {
8357
- let list = providersByInstall.get(row.install_id);
8358
- if (!list) {
8359
- list = [];
8360
- providersByInstall.set(row.install_id, list);
8361
- }
8362
- list.push({
8363
- provider: row.provider,
8364
- requests: row.requests,
8365
- inputTokens: row.input_tokens,
8366
- outputTokens: row.output_tokens,
8367
- cacheReadTokens: row.cache_read_tokens,
8368
- cacheWriteTokens: row.cache_write_tokens,
8369
- costUsd: row.cost_usd,
8370
- });
8371
- }
8372
- return {
8373
- clients: clients.map(client => ({
8374
- installId: client.install_id,
8375
- hostname: client.hostname ?? undefined,
8376
- firstSeen: client.first_seen,
8377
- lastSeen: client.last_seen,
8378
- providers: providersByInstall.get(client.install_id) ?? [],
8379
- })),
8380
- };
8381
- }
8382
-
8383
- // ─── Convenience methods for CLI ────────────────────────────────────────
8384
-
8385
- /**
8386
- * Save OAuth credentials for a provider.
8387
- * Preserves unrelated identities and replaces only the matching credential.
8388
- */
8389
- saveOAuth(provider: string, credentials: OAuthCredentials): void {
8390
- const credential: AuthCredential = { type: "oauth", ...credentials };
8391
- this.upsertAuthCredentialForProvider(provider, credential);
8392
- }
8393
-
8394
- /**
8395
- * Get OAuth credentials for a provider.
8396
- */
8397
- getOAuth(provider: string): OAuthCredentials | null {
8398
- const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[];
8399
- for (const row of rows) {
8400
- const credential = deserializeCredential(row);
8401
- if (credential && credential.type === "oauth") {
8402
- const { type: _type, ...oauth } = credential;
8403
- return oauth as OAuthCredentials;
8404
- }
8405
- }
8406
- return null;
8407
- }
8408
-
8409
- /**
8410
- * Save API key for a provider (replaces existing).
8411
- */
8412
- saveApiKey(provider: string, apiKey: string): void {
8413
- const credential: AuthCredential = { type: "api_key", key: apiKey };
8414
- this.replaceAuthCredentialsForProvider(provider, [credential]);
8415
- }
8416
-
8417
- /**
8418
- * Get API key for a provider.
8419
- */
8420
- getApiKey(provider: string): string | null {
8421
- const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[];
8422
- for (const row of rows) {
8423
- const credential = deserializeCredential(row);
8424
- if (credential && credential.type === "api_key") {
8425
- return credential.key;
8426
- }
8427
- }
8428
- return null;
8429
- }
8430
-
8431
- /**
8432
- * List all providers with credentials.
8433
- */
8434
- listProviders(): string[] {
8435
- const rows = this.#listActiveStmt.all() as AuthRow[];
8436
- const providers = new Set<string>();
8437
- for (const row of rows) {
8438
- providers.add(row.provider);
8439
- }
8440
- return Array.from(providers);
8441
- }
8442
-
8443
- /**
8444
- * Delete all credentials for a provider.
8445
- */
8446
- deleteProvider(provider: string): void {
8447
- this.deleteAuthCredentialsForProvider(provider, "deleted by user");
8448
- }
8449
-
8450
- /**
8451
- * SQLite increments `data_version` when another connection commits. Own
8452
- * writes leave it unchanged and already notify AuthStorage directly.
8453
- */
8454
- pollExternalChanges(): boolean {
8455
- this.#acknowledgeLocalAuthChanges();
8456
- const dataVersion = this.#readDataVersion();
8457
- if (dataVersion === this.#dataVersion) return false;
8458
- this.#dataVersion = dataVersion;
8459
- const authRevision = this.#readAuthRevision();
8460
- if (authRevision === this.#authRevision) return false;
8461
- this.#authRevision = authRevision;
8462
- return true;
8463
- }
8464
-
8465
- acknowledgeLocalChanges(): void {
8466
- this.#acknowledgeLocalAuthChanges();
8467
- }
8468
-
8469
- #acknowledgeLocalAuthChanges(): void {
8470
- const localAuthRevision = this.#readLocalAuthRevision();
8471
- this.#authRevision += localAuthRevision - this.#localAuthRevision;
8472
- this.#localAuthRevision = localAuthRevision;
8473
- }
8474
-
8475
- #readDataVersion(): number {
8476
- const row = this.#db.query("PRAGMA data_version").get() as { data_version?: number } | null;
8477
- return row?.data_version ?? 0;
8478
- }
8479
-
8480
- #readAuthRevision(): number {
8481
- const row = this.#db.query("SELECT revision FROM auth_change_revision WHERE id = 1").get() as {
8482
- revision?: number;
8483
- } | null;
8484
- return row?.revision ?? 0;
8485
- }
8486
-
8487
- #readLocalAuthRevision(): number {
8488
- const row = this.#db.query("SELECT revision FROM auth_local_change_revision WHERE id = 1").get() as {
8489
- revision?: number;
8490
- } | null;
8491
- return row?.revision ?? 0;
8492
- }
8493
-
8494
- close(): void {
8495
- if (this.#closed) return;
8496
- this.#closed = true;
8497
- this.#listActiveStmt.finalize();
8498
- this.#listActiveByProviderStmt.finalize();
8499
- this.#listDisabledStmt.finalize();
8500
- this.#listDisabledByProviderStmt.finalize();
8501
- this.#insertStmt.finalize();
8502
- this.#updateStmt.finalize();
8503
- this.#deleteStmt.finalize();
8504
- this.#deleteIfMatchesStmt.finalize();
8505
- this.#deleteByProviderStmt.finalize();
8506
- this.#hardDeleteStmt.finalize();
8507
- this.#getCacheStmt.finalize();
8508
- this.#getCacheIncludingExpiredStmt.finalize();
8509
- this.#upsertCacheStmt.finalize();
8510
- this.#deleteExpiredCacheStmt.finalize();
8511
- this.#getCredentialBlockStmt.finalize();
8512
- this.#listCredentialBlocksByCredentialStmt.finalize();
8513
- this.#upsertCredentialBlockStmt.finalize();
8514
- this.#deleteCredentialBlocksStmt.finalize();
8515
- this.#deleteCredentialBlockStmt.finalize();
8516
- this.#deleteExpiredCredentialBlocksStmt.finalize();
8517
- this.#insertUsageHistoryStmt.finalize();
8518
- this.#lastUsageHistoryStmt.finalize();
8519
- this.#listUsageHistoryStmt.finalize();
8520
- this.#updateUsageHistoryStmt.finalize();
8521
- this.#insertUsageCostStmt.finalize();
8522
- this.#listUsageCostsStmt.finalize();
8523
- this.#updateIfMatchesStmt.finalize();
8524
- this.#updateIfMatchesWithLeaseStmt.finalize();
8525
- this.#deleteIfMatchesWithLeaseStmt.finalize();
8526
- this.#deleteCachePrefixStmt.finalize();
8527
- this.#acquireCredentialRefreshLeaseStmt.finalize();
8528
- this.#getCredentialRefreshLeaseStmt.finalize();
8529
- this.#renewCredentialRefreshLeaseStmt.finalize();
8530
- this.#releaseCredentialRefreshLeaseStmt.finalize();
8531
- this.#db.close();
8532
- }
8533
- }