/** * CredentialPool — rotation + cooldown manager for the CLEO LLM credential * layer (T-LLM-CRED-CENTRALIZATION Phase 3 / T9265). * * Ports the Hermes `credential_pool.py` rotation strategies and cooldown * clocks to TypeScript, re-using the existing `credentials-store.ts` I/O * primitives (addCredential, listCredentials, getCredentialByLabel) for all * persistence so no new file-locking or write paths are introduced. * * ## Rotation strategies * * - `fill_first` (default): Iterate entries sorted by `priority` descending * (higher = preferred); return the first that is not in active cooldown. * - `round_robin`: Advance a per-provider in-memory cursor; skip cooled-down * entries until a healthy one is found. * - `least_used`: Pick the entry with the lowest `requestCount` that is not * in active cooldown; tie-break by `priority` descending. * * ## Cooldown semantics (mirrored from Hermes) * * | HTTP code | Cooldown | * |-----------|-----------| * | 401 | 5 minutes | * | 402 | 5 minutes | * | 429 | 1 hour | * | 5xx | 60 seconds| * | other | 60 seconds| * * Reference: `hermes-agent/agent/credential_pool.py:92-1095` * * @module llm/credential-pool * @task T9265 * @epic T-LLM-CRED-CENTRALIZATION */ import { type CredentialSeeder } from './credential-seeders/index.js'; import type { CredentialsStoreStrategy, StoredCredential } from './credentials-store.js'; import type { ModelTransport } from './types-config.js'; /** * Credential rotation strategy for `CredentialPool.pick()`. * * - `fill_first` — highest priority entry first; falls back only on cooldown. * - `round_robin` — advance a per-provider cursor across healthy entries. * - `least_used` — fewest `requestCount` that is not in cooldown. * * @task T9265 */ export type PoolStrategy = 'fill_first' | 'round_robin' | 'least_used'; /** * Options accepted by `CredentialPool.pick()`. * * @task T9265 */ export interface PoolPickOptions { /** Rotation strategy to apply. Defaults to `'fill_first'`. */ strategy?: PoolStrategy; } /** * Result returned by `CredentialPool.pick()`. * * @task T9265 */ export interface PoolPickResult { /** The selected credential entry. */ credential: StoredCredential; /** Total pool size at pick time (for telemetry / logging). */ poolSize: number; } /** * Thrown by `CredentialPool.pick()` when every entry for the provider is * currently in active cooldown (i.e., the pool is fully exhausted). * * Callers should surface this as a retryable, time-bounded failure. The * earliest retry time can be derived from `minResetAt` (epoch ms). * * @task T9265 */ export declare class PoolExhaustedError extends Error { readonly provider: ModelTransport; readonly poolSize: number; readonly minResetAt: number; /** Stable LAFS error code. */ readonly code = "E_LLM_POOL_EXHAUSTED"; /** * @param provider - The provider whose pool is exhausted. * @param poolSize - Total number of entries in the pool. * @param minResetAt - Epoch ms of the earliest cooldown expiry (may be 0 if * all entries lack a reset timestamp). */ constructor(provider: ModelTransport, poolSize: number, minResetAt: number); } /** Internal: reset refresh state for testing. */ export declare function _resetRefreshStateForTests(): void; /** * Outcome of {@link refreshExpiredOAuthForProvider}. * * @task T11986 */ export interface RefreshOAuthForProviderResult { /** Number of credentials for which refresh was attempted. */ attempted: number; /** Number of credentials refreshed successfully. */ refreshed: number; /** * Error from the last failed refresh, or `null` when all succeeded. * * When every entry either succeeded or was skipped (no refresh token / * non-OAuth / still valid), this is `null`. */ lastError: Error | null; /** * Human-readable hint surfaced when all eligible entries failed to refresh. * * `null` when not all entries failed, or when no refresh was attempted. * Callers may surface this to the operator. */ actionableHint: string | null; } /** * Refresh every expired-but-refreshable OAuth credential for `provider`, * with single-flight coalescing and a negative-cache on failure. * * ## Design (T11986 · DHQ-087) * * Called by `cross-provider-selector.ts` **before** the provisioning probe so * that an expired OAuth credential is renewed in-place and the selector sees * a valid token instead of filtering the provider as "not-provisioned". * * Single-flight: concurrent callers sharing the same `(provider, label)` key * coalesce on one in-flight Promise — the token endpoint is called at most * once per concurrent burst. After the Promise settles the key is deleted so * the next caller starts fresh. * * Negative-cache: a failed refresh stamps a 30-second suppression window. * During that window subsequent calls skip the refresh attempt rather than * hammering the endpoint with doomed requests. * * On total failure (every eligible entry failed): returns an `actionableHint` * with the exact re-login command (`cleo login ${provider}`). * * Note: `CredentialPool._refreshOAuthCredential` swallows errors silently by * design (its caller's retry path handles 401s). Here we need to surface * failures so callers can show actionable hints. We therefore attempt the * refresh via a fresh `CredentialPool` instance and check the credential store * for an updated `expiresAt` after the attempt to distinguish success from * failure. * * @param provider - The LLM transport provider to refresh credentials for. * @returns Refresh outcome with counts, last error, and actionable hint. * * @task T11986 */ export declare function refreshExpiredOAuthForProvider(provider: ModelTransport): Promise; /** * Pool manager that wraps the credential store for a single provider. * * Responsibilities: * - Pick a non-cooldown credential using one of three rotation strategies. * - Persist `lastStatus`, `lastErrorCode`, `lastErrorResetAt`, and * `requestCount` back to the store via `addCredential` upsert on every * state change. * - Maintain an in-memory round-robin cursor (per instance) for `round_robin` * strategy calls. * * Instantiate one `CredentialPool` per provider. The pool is stateless beyond * the RR cursor — all durable state lives in the credential store file. * * @example * ```ts * const pool = new CredentialPool('anthropic'); * const { credential } = await pool.pick({ strategy: 'round_robin' }); * try { * await callApi(credential.accessToken); * await pool.markOk(credential.label); * } catch (err) { * await pool.markExhausted(credential.label, err.status ?? 500); * } * ``` * * @task T9265 */ export declare class CredentialPool { private readonly provider; /** * In-memory round-robin cursor keyed on provider (one entry per instance * since each instance is scoped to one provider). Reset to 0 on construction. */ private rrCursor; /** * @param provider - The LLM transport this pool manages. */ constructor(provider: ModelTransport); /** * Pick a non-cooldown credential for the provider using the requested * rotation strategy. * * Side-effect: increments `requestCount` on the picked entry and persists * the change via `addCredential` upsert. * * @param opts - Optional pick options (strategy). Defaults to `fill_first`. * @returns The selected credential plus pool metadata. * @throws {PoolExhaustedError} When all entries are in active cooldown. * * @task T9265 */ pick(opts?: PoolPickOptions): Promise; /** * Mark a credential as exhausted, applying a cooldown based on the HTTP * error code. Persists `lastStatus`, `lastErrorCode`, and `lastErrorResetAt` * to the store. * * Cooldown durations: * - 401 (auth) → 5 minutes * - 402 (billing) → 5 minutes * - 429 (rate-limit) → 1 hour * - 5xx / other → 60 seconds * * @param label - Credential label (unique within provider). * @param errorCode - HTTP status code that triggered the exhaustion. * * @task T9265 */ markExhausted(label: string, errorCode: number): Promise; /** * Mark a credential as healthy. Clears `lastStatus`, `lastErrorCode`, and * `lastErrorResetAt` so the entry re-enters the eligible pool immediately. * * @param label - Credential label (unique within provider). * * @task T9265 */ markOk(label: string): Promise; /** * Proactively refresh an OAuth credential before it expires. * * Refresh is triggered when the remaining lifetime is less than * `max(expiresIn * 0.5, 300_000ms)`. For a credential without a known * `expiresIn`, only the 300s floor applies (requires `expiresAt`). * * For `api_key` and `aws_sdk` credentials this is a no-op. * * Supported refresh providers: * - `kimi-code` — posts to `auth.kimi.com/api/oauth/token` with * `grant_type=refresh_token` and the stored `refreshToken`. * * On success, the new access token (and optional refreshed refresh token) * are persisted via `addCredential` upsert. * * @param label - Credential label (unique within provider). * @returns `true` when a refresh was attempted (regardless of success), * `false` when no refresh was needed or the credential is not OAuth. * @task T9323 */ proactiveRefresh(label: string): Promise; /** * Refresh every expired-but-refreshable OAuth credential for this provider * before a selection pass, so the role resolver renews a stale token instead * of silently filtering it out (which previously demoted resolution to a * lower-priority — or fake — credential). * * For each stored OAuth entry that is expired (or within the proactive-refresh * floor) AND carries a `refreshToken`, attempts a refresh via * {@link proactiveRefresh}. Entries without a refresh token, non-OAuth * entries, and still-valid tokens are left untouched. Errors are swallowed * per-entry — a failed refresh leaves the entry expired and the normal * eligible-filter still drops it. * * @returns The number of entries for which a refresh was attempted. * @task T11617 */ refreshExpiredOAuth(): Promise; /** * List all entries for the provider sorted by priority descending * (highest priority = index 0). * * Includes both healthy and cooled-down entries — callers can inspect * `lastErrorResetAt` to determine cooldown state. * * @returns Immutable sorted array of all stored credentials for this provider. * * @task T9265 */ listEntries(): Promise; /** * Perform the actual token refresh for a known OAuth credential. * * Dispatches based on `profile.oauth.mode`: * - `pkce` — uses `refreshPkceToken` (RFC 6749 §6 refresh via PKCE endpoint). * - `device-code` — uses the device-code token URL + clientId. * - No profile / unknown mode → no-op (caller's 401-retry handles it). * * Errors are silently swallowed — the caller's retry path will encounter a * 401 and trigger credential rotation if the token has actually expired. * * @param existing - The credential entry to refresh. * @task T9302 (generic PKCE dispatch, replaces anthropic-specific branch) * @task T9323 (device-code path) */ private _refreshOAuthCredential; /** * Refresh via RFC 7636 PKCE `refresh_token` grant using `refreshPkceToken`. * * On success, upserts the new access token (and optional refresh token) * into the credential store. Errors are silently swallowed. * * @param existing - Credential entry to refresh. * @param tokenEndpoint - Provider token endpoint URL. * @param clientId - OAuth client ID. * @param bodyFormat - Token request body encoding (`'json'` for Anthropic). * @task T9302 * @task T11958 */ private _refreshViaPkce; /** * Shared OAuth `refresh_token` grant implementation. * * POSTs `grant_type=refresh_token` to `tokenUrl` with the credential's * stored refresh token. On success, upserts the new access token (and * updated refresh token if provided) into the credential store. * * @param existing - Credential entry to refresh. * @param tokenUrl - Provider's token endpoint. * @param clientId - OAuth client ID. * @param extraHeaders - Optional provider-specific headers (e.g. Anthropic beta flags). * @task T9323 */ private _refreshTokenViaEndpoint; /** * Fill-first: return the highest-priority entry not in cooldown. * * Entries are sorted descending by `priority` (store convention: higher * numeric priority = more preferred in pool). The first healthy entry wins. * * @param eligible - Non-disabled entries for the provider. * @returns The selected entry, or `undefined` if all are in cooldown. * * @task T9265 */ private _pickFillFirst; /** * Round-robin: advance the cursor across the priority-sorted list, skipping * cooled-down entries. * * The cursor is maintained in-memory per `CredentialPool` instance. It * advances even when an entry is skipped so a single cooled-down entry does * not permanently displace the rotation. * * @param eligible - Non-disabled entries for the provider. * @returns The selected entry, or `undefined` if all are in cooldown. * * @task T9265 */ private _pickRoundRobin; /** * Least-used: pick the entry with the lowest `requestCount` that is not in * cooldown. Ties are broken by `priority` descending (higher priority wins). * * @param eligible - Non-disabled entries for the provider. * @returns The selected entry, or `undefined` if all are in cooldown. * * @task T9265 */ private _pickLeastUsed; } /** * Seed-attempt cache TTL — 60 seconds per E2a §5.2 T-E2-5 acceptance criterion. * * After a successful (non-`force`) seed pass, repeat calls to `seed()` are * short-circuited until this TTL elapses. `force: true` always bypasses the * cache. `lazy-seed` from `pick()` also honours the cache. * * @task T9412 */ export declare const POOL_SEED_CACHE_TTL_MS: number; /** * Outcome of a single seeder's most-recent invocation. * * Returned in {@link UnifiedCredentialPool.getSeederStatus} for diagnostic * surfaces (`cleo status`, `cleo auth list --verbose`). * * @task T9412 */ export interface SeederStatus { /** Source id (e.g. `'env'`, `'claude-code'`). */ sourceId: string; /** Provider this seeder produces credentials for. */ provider: string; /** Epoch ms of the most recent invocation; `undefined` if never invoked. */ lastSeededAt?: number; /** Outcome of the most recent invocation. */ lastResult: 'ok' | 'failed' | 'skipped-consent' | 'skipped-suppressed'; /** Number of entries the seeder produced on `lastSeededAt`. */ entriesProduced?: number; /** Error message when `lastResult === 'failed'`. */ error?: string; } /** * Aggregate counts returned by {@link UnifiedCredentialPool.seed}. * * @task T9412 */ export interface PoolSeedResult { /** Number of seeders whose entries were successfully upserted. */ added: number; /** Number of seeders that threw or whose upsert failed. */ failed: number; /** Number of seeders skipped due to consent / suppression / cache. */ skipped: number; } /** * Options accepted by {@link UnifiedCredentialPool.pick}. * * Forwarded to `pickCredentialForProviderSync` so callers can request a * specific strategy or label; the pool itself only handles the lazy-seed * hook before delegating. * * @task T9412 */ export interface UnifiedPoolPickOptions { /** Override the store's default strategy. */ strategy?: CredentialsStoreStrategy; /** Prefer a specific label (collapses to that entry if present). */ preferLabel?: string; /** Skip the lazy-seed step (e.g. for diagnostic reads). Default `false`. */ noSeed?: boolean; } /** * Unified credential pool — drives every registered seeder, upserts the * discovered entries, and exposes `pick`/`list` over the populated store. * * Lifecycle: * * 1. First `pick()` (or explicit `seed()`) walks `BUILTIN_SEEDERS`. * 2. For each seeder: consent gate → suppression gate → `seed()` → * upsert each returned entry via `addCredential` (preserves the * seeder's `priority` hint when provided, otherwise the store's * `max + 10` rule applies). * 3. A successful sweep stamps the cache; subsequent calls within * {@link POOL_SEED_CACHE_TTL_MS} short-circuit unless `force: true`. * 4. `list()` reads the store directly — never triggers seeding so * diagnostic surfaces (`cleo auth list`) are pure-read. * * Error isolation: a single seeder that throws does NOT short-circuit the * sweep. The failure is counted, the error stashed in `getSeederStatus`, * and the next seeder runs. * * @example * ```ts * const pool = getCredentialPool(); * const entry = await pool.pick('anthropic'); * if (entry) { * // use entry.accessToken * } * ``` * * @task T9412 */ export declare class UnifiedCredentialPool { private readonly registryGetter; /** Epoch ms of last successful seed pass (`0` = never seeded). */ private lastSeededAt; /** Per-seeder diagnostics keyed on `${sourceId}::${provider}`. */ private readonly seederStatus; /** * Construct a pool wired to a specific seeder registry. * * Production code uses the {@link getCredentialPool} singleton which wires * to `BUILTIN_SEEDERS`. Tests pass a fresh `SeederRegistry` to isolate * registration semantics from the process-wide singleton. * * @param registryGetter - Getter that returns the active list of seeders. * Defaults to `BUILTIN_SEEDERS.getAll()`. A getter (not the array * directly) is used so the pool stays in sync with seeders registered * after construction. */ constructor(registryGetter?: () => readonly CredentialSeeder[]); /** * Walk every registered seeder, gate on consent + suppression, and upsert * the returned entries into the store. * * Cache rules: * - If `force !== true` and the last successful seed pass was less than * {@link POOL_SEED_CACHE_TTL_MS} ago, the sweep is skipped entirely * and `{ added: 0, failed: 0, skipped: }` is returned. * - `force: true` always re-runs every seeder. * * Per-seeder rules: * - `isConsentEstablished?` returning `false` → skip (status: * `'skipped-consent'`). * - `isSuppressed(provider, sourceId)` returning `true` → skip (status: * `'skipped-suppressed'`). * - `seed()` throwing → counted as `failed`; the error is logged * and stashed in `getSeederStatus`; the next seeder still runs. * - Each returned entry is upserted via `addCredential`; an upsert * failure counts the seeder as `failed`. * * @param options - `{ force }` — bypass the 60s cache when `true`. * @returns Aggregate counts across every seeder. * @task T9412 */ seed(options?: { force?: boolean; }): Promise; /** * Pick a credential for the given provider, lazy-seeding on first call. * * Behaviour: * - First call (or after `resetForTests()`) triggers a `seed()` pass. * - Subsequent calls within {@link POOL_SEED_CACHE_TTL_MS} skip seeding. * - The actual pick delegates to `pickCredentialForProviderSync` so the * strategy + label-preference semantics match the rest of the store. * * @param provider - LLM transport to pick for. * @param options - Optional strategy / label / no-seed flag. * @returns The selected `StoredCredential`, or `null` when the pool is * empty (or every entry has expired / been disabled). * @task T9412 */ pick(provider: ModelTransport, options?: UnifiedPoolPickOptions): Promise; /** * List every entry currently in the credential store. * * Pure read — never triggers seeding. Intended for diagnostic surfaces * (`cleo auth list`, `cleo status`) where calling `seed()` would be * surprising side-effect. * * @returns Read-only snapshot sorted by file order (insertion). * @task T9412 */ list(): Promise; /** * Snapshot of the last-known outcome of every registered seeder. * * Seeders that have never been invoked do not appear in the snapshot. * * @returns Read-only array of {@link SeederStatus} entries. * @task T9412 */ getSeederStatus(): readonly SeederStatus[]; /** * Test-only: invalidate the seed cache and clear status snapshots. * * Production code MUST NOT call this — it bypasses the 60s rate-limit * that exists specifically to prevent runaway seeding under tight retry * loops. The export is prefixed `_` to match the convention used by * `credentials-store.ts` for analogous helpers (`_resetRoundRobinForTests`). * * @internal */ _resetForTests(): void; } /** * Return the process-wide {@link UnifiedCredentialPool} singleton. * * Re-imports of this module yield the same instance under Node ESM's module * cache. Tests that need an isolated pool MUST construct one directly * (`new UnifiedCredentialPool(...)`) rather than mutating this singleton. * * @returns The shared pool instance. * @task T9412 */ export declare function getCredentialPool(): UnifiedCredentialPool; /** * Test-only: drop the singleton so the next {@link getCredentialPool} call * constructs a fresh instance. * * Production callers MUST NOT use this — recreating the pool drops the seed * cache and forces a re-sweep on the next `pick()`. * * @internal */ export declare function _resetCredentialPoolSingletonForTests(): void; //# sourceMappingURL=credential-pool.d.ts.map