/** * Credential storage for API keys and OAuth tokens. * Handles loading, saving, refreshing credentials, and usage tracking. * * This module defines: * - `AuthCredentialStore` interface: persistence abstraction (SQLite, remote vault, …) * - `AuthStorage` class: credential management with round-robin, usage limits, OAuth refresh * - `SqliteAuthCredentialStore`: concrete SQLite-backed implementation */ import { Database } from "bun:sqlite"; import type { ApiKeyResolver } from "./auth-retry"; import type { OAuthController, OAuthCredentials, OAuthProviderId } from "./registry/oauth/types"; import type { Provider } from "./types"; import type { CredentialRankingStrategy, UsageCostHistoryEntry, UsageCostHistoryQuery, UsageHistoryEntry, UsageHistoryQuery, UsageLogger, UsageProvider, UsageReport } from "./usage"; import { type CodexResetConsumeCode, type CodexResetCredit } from "./usage/openai-codex-reset"; export type ApiKeyCredential = { type: "api_key"; key: string; }; export type OAuthCredential = { type: "oauth"; } & OAuthCredentials; export type AuthCredential = ApiKeyCredential | OAuthCredential; export type AuthCredentialEntry = AuthCredential | AuthCredential[]; export type AuthStorageData = Record; /** * Cascade leg that supplies a provider's active credential, highest precedence * first — mirrors {@link AuthStorage.getApiKey}'s resolution order. */ export type CredentialOriginKind = "runtime" | "config" | "oauth" | "api_key" | "env" | "fallback"; /** * Structured provenance for a provider's auth, for UI that needs a machine * tag (the `/login` provider list) rather than the prose of * {@link AuthStorage.describeCredentialSource}. */ export interface CredentialOrigin { kind: CredentialOriginKind; /** Env var name when `kind === "env"` and a single named variable backs it. */ envVar?: string; } /** * Serialized representation of AuthStorage for passing to subagent workers. * Contains only the essential credential data, not runtime state. */ export interface SerializedAuthStorage { credentials: Record; }>>; runtimeOverrides?: Record; dbPath?: string; } /** * Auth credential with database row ID for updates/deletes. * Wraps AuthCredential with storage metadata. */ export interface StoredAuthCredential { id: number; provider: string; credential: AuthCredential; disabledCause: string | null; } /** * Per-credential health record returned by {@link AuthStorage.checkCredentials}. * * Use this to identify which credential in a multi-account pool is causing * auth errors. `ok` is tri-state: * * - `true` — credential authenticated against the provider's auth-verifying * probe (today: the usage endpoint). For OAuth this also exercises refresh * when the access token was expired. * - `false` — the probe rejected the credential (401/403/refresh failure/etc). * `reason` carries the upstream error string. * - `null` — no probe is configured for this provider (or the configured * probe doesn't support this credential type). The credential's auth * status is unverifiable from here. */ export interface CredentialHealthResult { /** Database row id (matches {@link StoredAuthCredential.id}). */ id: number; provider: string; type: AuthCredential["type"]; /** OAuth email if known on the stored credential or surfaced by the probe. */ email?: string; /** OAuth account id / org id if known. */ accountId?: string; /** `true` when the refresh token lives on a remote broker (sentinel was present). */ remoteRefresh?: true; ok: boolean | null; /** Failure / unverifiable reason; absent when `ok === true`. */ reason?: string; /** Probe usage report (raw payload stripped) when `ok === true`. */ report?: Omit; /** * Result of the optional end-to-end completion probe (see * {@link CheckCredentialsOptions.completionProbe}). Absent when no probe was * supplied. The completion probe exercises the provider's chat-completion * endpoint with the credential's bearer bytes, which is a stricter signal * than the usage endpoint (some providers happily 200 a `/usage` call while * the chat endpoint 401s the same bearer). */ completion?: CredentialCompletionResult; } /** * Outcome of the end-to-end completion probe. `null` means the probe was * skipped (no bearer bytes were available — e.g. OAuth refresh failed * upstream of the probe). */ export interface CredentialCompletionResult { ok: boolean | null; /** Failure / unverifiable reason; absent when `ok === true`. */ reason?: string; /** Probe model id used (carried back from the caller for display). */ modelId?: string; /** Round-trip latency in milliseconds. */ latencyMs?: number; } /** * Credential payload handed to {@link CompletionProbe}. For API-key * credentials only the bytes are exposed; for OAuth, every identity field * carried by the refreshed credential is included so the probe can compose * provider-specific apiKey shapes (e.g. GitHub Copilot / Google Gemini CLI * expect a JSON blob with `token` + `projectId`, not the raw access token). * * `refreshToken` may be {@link REMOTE_REFRESH_SENTINEL} when the credential * lives behind a broker; the chat endpoint never reads it, so the probe can * forward it verbatim into the structured shape without harm. */ export type CompletionProbeCredential = { type: "api_key"; apiKey: string; } | { type: "oauth"; accessToken: string; refreshToken?: string; expiresAt?: number; accountId?: string; projectId?: string; email?: string; enterpriseUrl?: string; apiEndpoint?: string; }; /** * Caller-supplied bearer probe. Receives the post-refresh credential for a * single row and reports whether a real chat-completion round-trip succeeds. * The check-credentials pipeline calls this AFTER any OAuth refresh so the * bytes match what a live request would send. */ export interface CompletionProbeInput { provider: Provider; credentialId: number; credential: CompletionProbeCredential; signal: AbortSignal; } export type CompletionProbe = (input: CompletionProbeInput) => Promise; export interface CheckCredentialsOptions { signal?: AbortSignal; /** Per-credential probe timeout (ms). Defaults to the configured usage request timeout. */ timeoutMs?: number; /** Provider → base URL override, same shape as {@link AuthStorage.fetchUsageReports}. */ baseUrlResolver?: (provider: Provider) => string | undefined; /** * Optional end-to-end probe. When provided, `checkCredentials` invokes it * for every credential where a usable bearer is available (API key, or * OAuth access token after refresh-on-expiry succeeded). The result lands * on {@link CredentialHealthResult.completion}. * * The probe runs INDEPENDENTLY of whether a {@link UsageProvider} is * configured: providers without a usage endpoint still benefit from the * extra signal. The probe is NOT invoked when OAuth refresh fails — the * bytes would be stale anyway and the upstream failure is already captured * on `reason`. */ completionProbe?: CompletionProbe; /** Per-credential completion probe timeout (ms). Defaults to `timeoutMs`. */ completionTimeoutMs?: number; } /** * Sentinel value placed in OAuth `refresh` fields when a credential is shared * via {@link AuthStorage.exportSnapshot}. Refresh tokens never leave the broker; * clients must call back to refresh. */ export declare const REMOTE_REFRESH_SENTINEL: "__remote__"; export type RemoteRefreshSentinel = typeof REMOTE_REFRESH_SENTINEL; /** OAuth credential with refresh token replaced by the broker sentinel. */ export type RemoteOAuthCredential = Omit & { refresh: RemoteRefreshSentinel; }; /** Discriminated credential payload as published by the broker. */ export type SnapshotCredential = ApiKeyCredential | RemoteOAuthCredential; export interface AuthCredentialSnapshotEntry { id: number; provider: string; credential: SnapshotCredential; identityKey: string | null; } /** * Wire-shaped snapshot exported by {@link AuthStorage.exportSnapshot} and * served by the auth-broker server on `GET /v1/snapshot`. */ export interface AuthCredentialSnapshot { generation: number; generatedAt: number; credentials: AuthCredentialSnapshotEntry[]; } /** * Persistence abstraction consumed by {@link AuthStorage}. * * Concrete implementations: * - {@link SqliteAuthCredentialStore} — local SQLite-backed store (default). * - `RemoteAuthCredentialStore` from `./auth-broker` — client-side snapshot of * a remote broker; mutating methods (`replace*`, `upsert*`, `delete*ForProvider`) * throw because login flows route through the broker, not the client. */ export interface AuthCredentialStore { close(): void; listAuthCredentials(provider?: string): StoredAuthCredential[]; updateAuthCredential(id: number, credential: AuthCredential): void; deleteAuthCredential(id: number, disabledCause: string): void; tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string): boolean; replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[]; upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[]; deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void; getCache(key: string, options?: { includeExpired?: boolean; }): string | null; setCache(key: string, value: string, expiresAtSec: number): void; cleanExpiredCache(): void; /** * Append usage-limit snapshots for trend history. Optional: stores without * durable storage (e.g. the broker remote store) omit it and recording is * skipped — the broker host records into its own database instead. */ recordUsageSnapshots?(entries: UsageHistoryEntry[]): void; /** Append observed request costs for providers without upstream usage APIs. */ recordUsageCosts?(entries: UsageCostHistoryEntry[]): void; /** Read observed request costs, oldest first. */ listUsageCosts?(query?: UsageCostHistoryQuery): UsageCostHistoryEntry[]; /** Read recorded usage-limit snapshots, oldest first. */ listUsageHistory?(query?: UsageHistoryQuery): UsageHistoryEntry[]; /** * Optional store-supplied OAuth refresh. When present, `AuthStorage` uses * it before the per-provider local refresh path. `RemoteAuthCredentialStore` * implements this against the broker; SQLite stores leave it undefined. * * Precedence: `AuthStorageOptions.refreshOAuthCredential` > this hook > local. * * `signal` propagates the agent's cancel (ESC, request abort, …) all the * way to the broker fetch so a hung connection can't strand the caller * for `timeoutMs * (maxRetries + 1)`. */ refreshOAuthCredential?(provider: Provider, credentialId: number, credential: OAuthCredential, signal?: AbortSignal): Promise; /** * Optional async pre-read hook invoked after AuthStorage selects a stored * credential but before it returns that credential for an outbound request. * Remote broker stores use this to wait out imminent rotations and refresh * their local snapshot before the caller sees a stale access token. */ prepareForRequest?(credentialId: number, opts?: { signal?: AbortSignal; }): Promise; /** * Optional store-supplied aggregate usage fetch. When present, `AuthStorage` * routes `fetchUsageReports()` here instead of fanning out per-credential. * `RemoteAuthCredentialStore` proxies to the broker (whose datacenter IP * isn't rate-limited like a heavy residential client). * * Precedence: `AuthStorageOptions.fetchUsageReports` > this hook > local fan-out. * * `signal` propagates the agent's cancel down to the broker fetch. */ fetchUsageReports?(signal?: AbortSignal): Promise; /** * Optional store-supplied per-credential usage report lookup. When present, * `AuthStorage` consults this before its own per-credential upstream fetch * (`#getUsageReport`). `RemoteAuthCredentialStore` implements this against * the broker's aggregate `/v1/usage` (one coalesced round-trip shared across * all callers) so multi-credential ranking on the client never hits the * upstream provider's rate-limited usage endpoint from the laptop IP. * * Returning `null` is authoritative — `AuthStorage` does NOT fall back to * the local fetch path. The store hook owns the decision, since falling * back would re-introduce the per-IP rate-limit problem the broker exists * to avoid. * * `signal` propagates the agent's cancel down to the broker fetch. */ getUsageReport?(provider: Provider, credential: OAuthCredential, signal?: AbortSignal): Promise; /** * Optional store hook to invalidate a specific credential after the upstream * provider returned 401 on a supposedly-fresh key. Remote stores force the * broker to re-issue the row; local stores can leave it undefined and let * {@link AuthStorage.invalidateCredentialMatching} fall back to `reload()`. */ markCredentialSuspect?(credentialId: number, opts?: { signal?: AbortSignal; }): Promise; /** * Optional async write hook for upserting a single credential. When present, * `AuthStorage.#upsertOAuthCredential` routes through this instead of the * sync `upsertAuthCredentialForProvider`. `RemoteAuthCredentialStore` uses * it to send the upsert to the broker via `POST /v1/credential`. * * Implementations MUST update the in-memory snapshot before returning so the * post-write read path is consistent. */ upsertAuthCredentialRemote?(provider: string, credential: AuthCredential): Promise; /** * Optional async write hook for replace-all semantics (e.g. API-key login * overwriting any previous keys for the same provider). When present, * `AuthStorage.set` routes through this instead of the sync * `replaceAuthCredentialsForProvider`. */ replaceAuthCredentialsRemote?(provider: string, credentials: AuthCredential[]): Promise; /** * Optional async write hook for disabling one stored credential. Remote stores * use it to await broker persistence before AuthStorage updates its snapshot. */ deleteAuthCredentialRemote?(id: number, disabledCause: string): Promise; /** * Optional async write hook for clearing every credential for a provider * (logout). When present, `AuthStorage.remove` routes through this instead * of the sync `deleteAuthCredentialsForProvider`. */ deleteAuthCredentialsRemote?(provider: string, disabledCause: string): Promise; } /** * Event payload describing a credential that was just soft-disabled. * * Today the only call site is OAuth refresh failures with a definitive cause * (`invalid_grant`, `401/403` not from a network blip, etc.) — the * disabled_cause string is the verbatim error captured for forensics. * * Subscribers can use this to surface a notification, banner, or auto-launch * a re-login flow instead of letting the credential silently disappear. */ export interface CredentialDisabledEvent { provider: string; disabledCause: string; } export type AuthStorageOptions = { usageProviderResolver?: (provider: Provider) => UsageProvider | undefined; rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined; usageFetch?: typeof fetch; usageRequestTimeoutMs?: number; usageLogger?: UsageLogger; /** * Resolve a config value (API key, header value, etc.) to an actual value. * - coding-agent injects its resolveConfigValue (supports "!command" syntax via pi-natives) * - Default: checks environment variable first, then treats as literal */ configValueResolver?: (config: string) => Promise; /** * Optional callback fired when AuthStorage automatically disables a * credential because something detected it as no longer usable — today * that's the OAuth refresh-failure path in `getApiKey`. NOT fired for * user-initiated `remove()` (the user already knows) or dedup of * duplicate credentials (uninteresting hygiene). */ onCredentialDisabled?: (event: CredentialDisabledEvent) => void | Promise; /** * Override OAuth refresh. When set, `AuthStorage` calls this instead of the * per-provider local refresh function. Receives the credential id so the * implementation can address remote credentials. * * Must return updated {@link OAuthCredentials} with at least `access` and * `expires`. `refresh` may be an opaque sentinel (e.g. `"__remote__"`) when * the actual refresh token never leaves the broker. */ refreshOAuthCredential?: (provider: Provider, credentialId: number, credential: OAuthCredential, signal?: AbortSignal) => Promise; /** * Human-readable description of the credential store backing this * AuthStorage instance. Surfaced through {@link AuthStorage.describeCredentialSource} * so the TUI can show where a token came from (broker URL or local SQLite path). * * Examples: * - `"local ~/.omp/agent/agent.db"` * - `"broker http://omp.internal:8765"` */ sourceLabel?: string; /** * Override `fetchUsageReports`. When set, `AuthStorage.fetchUsageReports` * calls this instead of fanning out per-credential. The primary use case is * routing through a broker that egresses from a less-throttled IP — e.g. a * residential laptop trips Anthropic's per-IP rate limit on the usage * endpoint and drops 2-of-5 credentials, while the VPS broker gets all 5. * * Implementations may return null when no usage data is available; the * AuthStorage caller surfaces that to its own consumer unchanged. */ fetchUsageReports?: (signal?: AbortSignal) => Promise; }; export { isDefinitiveOAuthFailure } from "./error/auth-classify"; /** * Outcome of {@link AuthStorage.markUsageLimitReached}. * * `switched` is `true` when an unblocked same-type sibling credential is * available right now, so the caller can retry immediately and the next * `getApiKey` will hand it out. When `false`, `retryAtMs` (epoch ms) carries * the earliest moment any same-type sibling's temporary block expires — * callers should prefer waiting until then over the provider's (often * multi-hour) retry-after when it is sooner. `retryAtMs` is `undefined` when * no sibling credentials exist at all, or when the session has no tracked * credential to rotate away from. */ export interface UsageLimitMarkResult { switched: boolean; retryAtMs?: number; } type AuthApiKeyOptions = { baseUrl?: string; modelId?: string; /** * Caller's cancel signal. Threaded into any broker-bound OAuth refresh so * `ESC` / request abort actually kills a hung broker fetch instead of * stranding the caller for `timeoutMs * (maxRetries + 1)`. */ signal?: AbortSignal; /** * Force a re-mint of the session-preferred OAuth credential's access token, * bypassing the not-yet-expired short-circuit. Powers step (b) of the * auth-retry policy ("refresh the SAME account") so a locally-cached token * that a peer/broker rotated out from under us is replaced before retrying. */ forceRefresh?: boolean; }; /** * Refreshed OAuth access plus identity metadata returned by * {@link AuthStorage.getOAuthAccess}. Callers that authenticate via a bearer * AND need the credential's identity (Codex `chatgpt-account-id`, Google * `projectId`, GitHub `enterpriseUrl`) consume this shape directly; the * refresh slot is deliberately omitted because rotating refresh tokens never * leave {@link AuthStorage}. */ export interface OAuthAccess { accessToken: string; credentialId?: number; accountId?: string; email?: string; projectId?: string; enterpriseUrl?: string; apiEndpoint?: string; } export interface OAuthAccessFailure { credentialId?: number; accountId?: string; email?: string; projectId?: string; enterpriseUrl?: string; apiEndpoint?: string; error: string; } /** * Identity of the OAuth credential a session is currently routed to. Read-only * display/metadata shape: `accountId` is the provider's account UUID, `email` * the user-facing login, `projectId` the GCP-style project for providers that * key usage on it (Gemini CLI / Antigravity). */ export interface OAuthAccountIdentity { accountId?: string; email?: string; projectId?: string; } export type OAuthAccessResolution = ({ ok: true; } & OAuthAccess) | ({ ok: false; } & OAuthAccessFailure); /** * Read-only identity of one stored OAuth account, in stable storage order. * Returned by {@link AuthStorage.listOAuthAccounts}; `position` (0-based) is the * selector accepted by {@link AuthStorage.getOAuthAccessAt}. */ export interface OAuthAccountSummary { position: number; credentialId: number; accountId?: string; email?: string; projectId?: string; enterpriseUrl?: string; } export interface InvalidateCredentialMatchingOptions { signal?: AbortSignal; sessionId?: string; } /** * Identifies which stored account to redeem a saved rate-limit reset for. * Any one field is enough; `credentialId` is the most precise. */ export interface ResetCreditTarget { credentialId?: number; accountId?: string; email?: string; } /** Outcome of {@link AuthStorage.redeemResetCredit}. */ export interface ResetCreditRedeemOutcome { /** `true` only when a reset was actually applied (`code === "reset"`). */ ok: boolean; /** * Result code. Backend codes: `reset` (success), `already_redeemed`, * `no_credit`, `nothing_to_reset`. Locally-synthesized: `no_account` * (target not found), `account_unavailable` (token refresh failed), * `http_` (unexpected HTTP). */ code: CodexResetConsumeCode; accountId?: string; email?: string; /** The credit that was spent (when one was). */ creditId?: string; } /** One stored account's live saved-reset status, from {@link AuthStorage.listResetCredits}. */ export interface ResetCreditAccountStatus { credentialId?: number; accountId?: string; email?: string; /** Resets redeemable for this account right now (live, not cached). */ availableCount: number; credits: CodexResetCredit[]; /** Whether this is the given session's active account. */ active: boolean; /** Set when the account's token refresh or list call failed. */ error?: string; } /** * Credential storage backed by an AuthCredentialStore. * Reads from storage on reload(), manages round-robin credential selection, * usage limit tracking, and OAuth token refresh. */ export declare class AuthStorage { #private; constructor(store: AuthCredentialStore, options?: AuthStorageOptions); /** * Create an AuthStorage instance backed by a AuthCredentialStore. * Convenience factory for standalone use (e.g., pi-ai CLI). * @param dbPath - Path to SQLite database */ static create(dbPath: string, options?: AuthStorageOptions): Promise; /** * Close the underlying credential store. * * After calling this, the instance must not be reused. */ close(): void; getGeneration(): number; onGenerationChanged(listener: (generation: number) => void): () => void; offGenerationChanged(listener: (generation: number) => void): void; /** * Subscribe to {@link CredentialDisabledEvent}s. Multiple subscribers are supported and * each fires for every disable event; subscribers are invoked in registration order with * exceptions and async rejections isolated per-listener so a misbehaving subscriber * cannot break the disable path or starve the rest of the chain. * * If `credential_disabled` events were emitted while no listener was subscribed, they are * replayed (in insertion order) to the listener that triggers the empty→non-empty * transition. The drain is one-shot — listeners that subscribe after that no longer see * past events. * * Returns an unsubscribe function. The function is idempotent: calling it more than once * is a no-op. After every subscriber has unsubscribed, subsequent disable events buffer * again until the next subscribe. * * @param listener Callback invoked with each disable event. May be sync or async. * @returns A function that removes this listener from the subscriber set. */ onCredentialDisabled(listener: (event: CredentialDisabledEvent) => void | Promise): () => void; /** * Set a runtime API key override (not persisted to disk). * Used for CLI --api-key flag. */ setRuntimeApiKey(provider: string, apiKey: string): void; /** * Remove a runtime API key override. */ removeRuntimeApiKey(provider: string): void; /** * Register a per-provider API key sourced from user configuration * (e.g. `models.yml` `providers..apiKey`). Higher priority than * stored credentials and OAuth tokens — when the user pins a key in * config, that key is what authenticates outbound requests, regardless * of whatever the broker happens to have loaded for that provider. * * Lower priority than {@link setRuntimeApiKey} so a CLI `--api-key` * still wins for the duration of a single invocation. */ setConfigApiKey(provider: string, apiKey: string): void; /** * Remove a single config-sourced API key override. */ removeConfigApiKey(provider: string): void; /** * Drop every config-sourced API key. Called by `ModelRegistry` before * re-parsing `models.yml` so removed entries actually disappear. */ clearConfigApiKeys(): void; /** * Set a fallback resolver for API keys not found in storage or env vars. * Used for custom provider keys from models.json. */ setFallbackResolver(resolver: (provider: string) => string | undefined): void; /** * Reload credentials from storage. */ reload(): Promise; /** * Get credential for a provider (first entry if multiple). */ get(provider: string): AuthCredential | undefined; /** * Set credential for a provider. */ set(provider: string, credential: AuthCredentialEntry): Promise; /** * List stored credential rows, optionally filtered by provider. */ listStoredCredentials(provider?: string): StoredAuthCredential[]; /** * Remove credential for a provider. */ remove(provider: string): Promise; /** * Remove one stored credential for a provider. */ removeCredential(provider: string, credentialId: number): Promise; /** * List all providers with credentials. */ list(): string[]; /** * Check if credentials exist for a provider in storage. */ has(provider: string): boolean; /** * Check if any form of auth is configured for a provider. * Unlike getApiKey(), this doesn't refresh OAuth tokens. */ hasAuth(provider: string): boolean; /** * True iff a dedicated, non-env credential source is configured for this * provider — i.e. anything in the cascade EXCEPT `getEnvApiKey(provider)`. * * Mirrors `hasAuth` minus the env-fallback leg. Useful for callers that * need to distinguish "the user explicitly configured this provider" * from "an env var happens to alias this provider via the cross-provider * fallback map" (see e.g. `xai-oauth → XAI_OAUTH_TOKEN || XAI_API_KEY` in * `stream.ts`). Without that distinction, an `XAI_API_KEY`-only setup * silently satisfies xai-oauth and routes around `providers.xai.baseUrl`. */ hasNonEnvCredential(provider: string): boolean; /** * Classify where a provider's auth comes from, following the same precedence * as {@link AuthStorage.getApiKey}: runtime override → config override → * stored OAuth → env var → stored api_key → fallback resolver. Returns * undefined when no auth is configured. * * Compact, structured counterpart to {@link describeCredentialSource}. */ getCredentialOrigin(provider: string): CredentialOrigin | undefined; /** * Check if OAuth credentials are configured for a provider. */ hasOAuth(provider: string): boolean; /** * Get OAuth credentials for a provider. */ getOAuthCredential(provider: string): OAuthCredential | undefined; /** * Get the OAuth `accountId` for a provider, preferring the credential that is * session-sticky for `sessionId` when multiple OAuth credentials are configured. * Falls back to the first OAuth credential when no session preference exists (e.g. * first call before any `getApiKey` has been issued, or single-credential setups). * Returns `undefined` when no OAuth credential carries an `accountId`. */ getOAuthAccountId(provider: string, sessionId?: string): string | undefined; /** * Get the OAuth account identity for a provider, preferring the credential that * is session-sticky for `sessionId`. This is a read-only lookup for display and * metadata paths; it does not refresh tokens, rank usage, or advance selection. */ getOAuthAccountIdentity(provider: string, sessionId?: string): OAuthAccountIdentity | undefined; /** * Get all credentials. */ getAll(): AuthStorageData; /** * Login to an OAuth provider. */ login(provider: OAuthProviderId, ctrl: OAuthController & { /** onAuth is required by auth-storage but optional in OAuthController */ onAuth: (info: { url: string; instructions?: string; }) => void; /** onPrompt is required for some providers (github-copilot, openai-codex) */ onPrompt: (prompt: { message: string; placeholder?: string; }) => Promise; }): Promise; /** * Logout from a provider. */ logout(provider: string): Promise; /** * Recorded usage-limit snapshots, oldest first. Empty when the underlying * store has no durable history (e.g. a broker-backed remote store). */ listUsageHistory(query?: UsageHistoryQuery): UsageHistoryEntry[]; /** Record one observed provider request cost for later local usage aggregation. */ recordUsageCost(provider: Provider, costUsd: number, options?: { sessionId?: string; recordedAt?: number; baseUrl?: string; }): boolean; ingestUsageHeaders(provider: Provider, headers: Record, options?: { sessionId?: string; baseUrl?: string; }): boolean; /** * The {@link UsageProvider} registered for `provider`, or undefined when the * provider has no usage endpoint at all. Lets callers tell "a credential we * could have fetched usage for but didn't" apart from "a provider with no * usage concept" (web-search keys, local/keyless servers, inference * providers without a usage API) — the latter never warrants a usage row. */ usageProviderFor(provider: Provider): UsageProvider | undefined; fetchUsageReports(options?: { baseUrlResolver?: (provider: Provider) => string | undefined; /** Caller's cancel signal; only rejects this caller, never the shared upstream fetch. */ signal?: AbortSignal; }): Promise; /** * Probe each stored credential against its provider's auth-verifying usage * endpoint and report per-credential auth health. * * Surfaces the identity of failing credentials so callers running a * multi-account pool (e.g. a broker-backed auth-gateway) can tell which * row is producing 401s. The probe mirrors the per-credential fan-out * inside {@link AuthStorage.fetchUsageReports} (OAuth refresh-on-expiry, * then `UsageProvider.fetchUsage`) but does NOT swallow errors — every * credential gets either `ok: true`, `ok: false` with `reason`, or * `ok: null` when no probe is configured for the provider. * * Iterates sequentially to avoid synchronized N-account fan-out that * upstream `/usage` rate limiters (per source IP) treat as a burst. * * Only inspects active rows from {@link AuthCredentialStore.listAuthCredentials}; * soft-disabled rows are already known-bad and don't need a network probe. * Environment-variable API keys are not enumerated — the caller's intent * here is "which of my stored credentials is broken". * * Pass {@link CheckCredentialsOptions.completionProbe} to additionally * exercise each credential against the provider's chat-completion endpoint * (strict mode). The result lands on * {@link CredentialHealthResult.completion}; the usage `ok` field is * unchanged so callers can tell the two signals apart. */ checkCredentials(options?: CheckCredentialsOptions): Promise; /** * Marks the current session's credential as temporarily blocked due to usage limits. * Uses usage reports to determine accurate reset time when available. * Returns whether a sibling credential is available now; when none is, also * reports the earliest time a blocked sibling becomes available again so * callers can wait for the sibling instead of the provider's full window. */ markUsageLimitReached(provider: string, sessionId: string | undefined, options?: { retryAfterMs?: number; baseUrl?: string; modelId?: string; signal?: AbortSignal; }): Promise; /** * Peek at API key for a provider without refreshing OAuth tokens. * Used for model discovery where we only need to know if credentials exist * and get a best-effort token. For GitHub Copilot we preserve enterprise * routing metadata so discovery can hit the correct host. */ peekApiKey(provider: string): Promise; /** * Get API key for a provider. * Priority (first match wins): * 1. Runtime override (CLI --api-key) * 2. Config override (models.yml `providers..apiKey`) * 3. OAuth token from storage (auto-refreshed) * 4. Environment variable * 5. Stored API key (e.g. a broker-migrated copy) — last resort, so an explicit env var wins * 6. Fallback resolver (models.yml custom providers, last-resort) */ getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise; /** * Resolve the OAuth credential for `provider`, refreshing through the same * pipeline as {@link AuthStorage.getApiKey} but returning the refreshed * {@link OAuthAccess} (raw access token + identity metadata) instead of * the API-key bytes. * * Use this when the caller needs to inject identity headers alongside the * bearer (Codex `chatgpt-account-id`, Google `project`, GitHub * `enterpriseUrl`). For pure "give me the bytes for `Authorization`" * scenarios, prefer {@link AuthStorage.getApiKey}. * * Returns `undefined` when no OAuth credential is available, the * credential fails to refresh, or runtime/config overrides have replaced * OAuth with an explicit API key. */ getOAuthAccess(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise; /** * Read-only list of stored OAuth accounts for `provider` in stable storage * order, WITHOUT refreshing any token. The array position (0-based) is the * selector accepted by {@link AuthStorage.getOAuthAccessAt}; a "pick the Nth * account" UI should render `position + 1`. */ listOAuthAccounts(provider: string): OAuthAccountSummary[]; /** * Resolve every stored OAuth credential for `provider` independently. * * Refreshes credentials through the same broker/local path as * {@link AuthStorage.getOAuthAccess}, but does not rank, round-robin, or * stop after the first usable account. Intended for diagnostics that must * exercise each stored account exactly once. */ getOAuthAccesses(provider: string, options?: AuthApiKeyOptions): Promise; /** * Resolve a single stored OAuth credential by its account position (0-based, * matching {@link AuthStorage.listOAuthAccounts}). Refreshes ONLY that * credential ({@link #resolveStoredOAuthAccess} runs with `allowFallback: * false`), so — unlike {@link AuthStorage.getOAuthAccesses} — a definitive * failure of the targeted account surfaces as a failed resolution rather than * silently rotating or rate-tripping a sibling. * * Returns `undefined` when `position` is out of range or runtime/config * overrides have replaced OAuth with an explicit API key. */ getOAuthAccessAt(provider: string, position: number, options?: AuthApiKeyOptions): Promise; /** * List saved rate-limit resets for every stored OAuth account of `provider` * (Codex), fetched LIVE from the dedicated `rate-limit-reset-credits` route. * * This deliberately bypasses the usage-report cache: `/wham/usage` is * IP-rate-limited and may serve stale (or pre-feature) snapshots when many * accounts are polled, which would hide redeemable credits. One entry per * account, with the session's active account flagged and unreachable * accounts carrying an `error`. */ listResetCredits(options?: { provider?: string; sessionId?: string; baseUrlResolver?: (provider: string) => string | undefined; signal?: AbortSignal; }): Promise; /** * Redeem one saved rate-limit reset (OpenAI Codex "saved resets") for a * specific stored account. * * Resolves a fresh access token for the target account, picks an available * credit (the given `creditId`, else the first redeemable one), spends it, * and invalidates the cached usage report so the next `/usage` reflects the * reset. Never throws for business outcomes — inspect the returned `code`. */ redeemResetCredit(options: { target: ResetCreditTarget; provider?: string; creditId?: string; baseUrlResolver?: (provider: string) => string | undefined; signal?: AbortSignal; }): Promise; invalidateCredentialMatching(provider: string, apiKey: string, options?: InvalidateCredentialMatchingOptions): Promise; invalidateCredentialMatching(provider: string, apiKey: string, signal?: AbortSignal): Promise; /** * Rotate away from the session's current credential after a retryable auth * error — step (c) of the auth-retry policy. Stateless: looks up the * session-sticky credential (no API-key matching needed), applies the * storage action for the error class, then clears the sticky so the next * {@link AuthStorage.getApiKey} for this session picks a sibling. * * - usage-limit / account-rate-limit error → {@link AuthStorage.markUsageLimitReached} * (temporary block via its own backoff — default plus server usage-report * reset; sticky left intact so the next resolve re-ranks around the block). * - otherwise (hard 401 / auth failure) → mark the credential suspect (or * reload when no broker hook is wired) and block it, then drop the sticky. * * Returns whether another usable credential of the same type remains. */ rotateSessionCredential(provider: string, sessionId: string | undefined, options?: { error?: unknown; modelId?: string; signal?: AbortSignal; }): Promise; /** * Build an {@link ApiKeyResolver} backed by this storage, implementing the * central a/b/c auth-retry policy: * * - initial (`error: undefined`) → resolve the session credential. * - step (b) `!lastChance` → force-refresh the SAME session-sticky credential. * - step (c) `lastChance` → rotate to a sibling credential, then re-resolve. * * Used by web-search providers and other consumers that hold an AuthStorage * directly (no ModelRegistry in scope). */ resolver(provider: string, options?: { sessionId?: string; baseUrl?: string; modelId?: string; }): ApiKeyResolver; /** * Build a redacted snapshot of all loaded credentials for the auth-broker * wire. OAuth refresh tokens are replaced with {@link REMOTE_REFRESH_SENTINEL} * so clients never see the actual refresh token. * * Callers must {@link AuthStorage.reload} first when serving a stale snapshot * (the broker server's HTTP handler does this). */ exportSnapshot(): AuthCredentialSnapshot; /** * Refresh the OAuth credential with the given id through a per-credential * single-flight. Concurrent callers for the same row await the same upstream * refresh attempt, which is required for providers that rotate refresh tokens * on every successful refresh. */ refreshCredentialById(id: number, signal?: AbortSignal): Promise; /** * Force-refresh the OAuth credential with the given id, bypassing the * not-yet-expired guard. Used by the auth-broker server to honour * `POST /v1/credential/:id/refresh`. * * Returns the redacted snapshot entry for the refreshed row. * Throws when no OAuth credential with that id is loaded. */ forceRefreshCredentialById(id: number, signal?: AbortSignal): Promise; /** * Disable the credential with the given id and emit a * {@link CredentialDisabledEvent}. Used by the auth-broker server to honour * `POST /v1/credential/:id/disable`. Returns `false` when no such row exists. */ disableCredentialById(id: number, disabledCause: string): boolean; /** * Upsert a credential into the underlying store, refresh the in-memory * snapshot, and return the redacted snapshot entries for the provider. * * Used by the auth-broker server to honour `POST /v1/credential`. The * persistence layer (`SqliteAuthCredentialStore.upsertAuthCredentialForProvider`) * does identity-key matching, so re-uploading the same email/account replaces * the existing row instead of inserting a duplicate. */ upsertCredential(provider: string, credential: AuthCredential): AuthCredentialSnapshotEntry[]; /** * Describe where the active credential for a provider came from. * * Mirrors {@link AuthStorage.getApiKey} precedence, highest first: * 1. Runtime override (`--api-key`). * 2. Config override (`models.yml` `providers..apiKey`). * 3. Stored OAuth credential. * 4. Env var — overrides a stored static api_key (e.g. a stale broker copy). * 5. Stored api_key credential. * 6. Fallback resolver. * * The string is purely informational; consumers must not parse it. */ describeCredentialSource(provider: string, sessionId?: string): string | undefined; } /** * SQLite's busy result code family — base `SQLITE_BUSY` plus the extended * variants `SQLITE_BUSY_RECOVERY` (concurrent WAL recovery), `SQLITE_BUSY_SNAPSHOT`, * and `SQLITE_BUSY_TIMEOUT`. All warrant the same backoff-and-retry treatment. */ export declare function isSqliteBusyError(err: unknown): boolean; /** * Default SQLite-backed implementation of {@link AuthCredentialStore}. * * Used by the pi-ai CLI and as the default store for `AuthStorage.create()`. * Also exposes convenience methods (`saveOAuth`, `getOAuth`, `saveApiKey`, * `getApiKey`, `listProviders`, `deleteProvider`) that callers can use directly * without going through `AuthStorage`. */ export declare class SqliteAuthCredentialStore implements AuthCredentialStore { #private; constructor(db: Database); static open(dbPath?: string): Promise; listAuthCredentials(provider?: string): StoredAuthCredential[]; replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[]; upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[]; updateAuthCredential(id: number, credential: AuthCredential): void; deleteAuthCredential(id: number, disabledCause: string): void; /** * CAS-style disable: only soft-deletes the row when its `data` column still * matches `expectedData` and the row has not already been disabled. Used by * the OAuth refresh-failure path to avoid clobbering a peer that rotated the * row between our pre-check and the disable. */ tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string): boolean; deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void; getCache(key: string, options?: { includeExpired?: boolean; }): string | null; setCache(key: string, value: string, expiresAtSec: number): void; cleanExpiredCache(): void; recordUsageSnapshots(entries: UsageHistoryEntry[]): void; listUsageHistory(query?: UsageHistoryQuery): UsageHistoryEntry[]; recordUsageCosts(entries: UsageCostHistoryEntry[]): void; listUsageCosts(query?: UsageCostHistoryQuery): UsageCostHistoryEntry[]; /** * Save OAuth credentials for a provider. * Preserves unrelated identities and replaces only the matching credential. */ saveOAuth(provider: string, credentials: OAuthCredentials): void; /** * Get OAuth credentials for a provider. */ getOAuth(provider: string): OAuthCredentials | null; /** * Save API key for a provider (replaces existing). */ saveApiKey(provider: string, apiKey: string): void; /** * Get API key for a provider. */ getApiKey(provider: string): string | null; /** * List all providers with credentials. */ listProviders(): string[]; /** * Delete all credentials for a provider. */ deleteProvider(provider: string): void; close(): void; }