dwara_core/ai/
credentials.rs

1//! Provider credential pools (DW-080, Ent).
2//!
3//! A credential pool holds N resolved API keys for a single provider.
4//! At request time the pool picks one credential (round-robin or
5//! weighted); when a key receives a 429 from the provider, it is
6//! quarantined for a configurable window and subsequent requests
7//! rotate to the next available key. Pool exhaustion (all keys
8//! quarantined) degrades gracefully — the request fails with a 429 +
9//! Retry-After, not a panic.
10//!
11//! The pool is compiled from the `ai.providers[].credential_pool`
12//! config block at `AiRuntime::compile` time (each entry's secret is
13//! resolved via the same `resolve_configured_secret` path as singular
14//! auth). The pool lives inside `CompiledProvider` and is accessed
15//! per-request by the AI proxy. Quarantine state is mutable and
16//! thread-safe (behind `Mutex`); the rest of `CompiledProvider` is
17//! immutable.
18//!
19//! Ent-only: the config schema accepts `credential_pool` regardless of
20//! the `ent` feature, but snapshot validation rejects it when `ent` is
21//! off (the runtime module is not compiled and the pool machinery does
22//! not exist). This keeps the config schema uniform across editions
23//! while the behavior is edition-gated.
24
25use std::collections::HashMap;
26use std::sync::Mutex;
27use std::time::{Duration, Instant};
28
29use crate::config::ai::{AiCredentialPool, AiPoolStrategy};
30
31/// A single resolved credential in the pool. The header name + value
32/// are injected into the outbound request exactly like singular auth.
33#[derive(Debug, Clone)]
34pub struct PoolEntry {
35    /// Header name (e.g. `Authorization`, `x-api-key`).
36    pub header: String,
37    /// Resolved header value (secret-bearing; never logged).
38    pub value: String,
39    /// Index into the pool (for attribution + admin API visibility).
40    pub index: usize,
41}
42
43/// Per-key quarantine state. A key is quarantined until `until` passes;
44/// after that it re-enters rotation.
45#[derive(Debug, Clone)]
46struct QuarantineState {
47    /// When the quarantine window expires and the key re-enters rotation.
48    until: Instant,
49    /// How many times this key has been quarantined (cumulative, for
50    /// admin API visibility).
51    count: u64,
52}
53
54/// AI-09 (#198): Per-key health state derived from provider rate-limit
55/// headers. Updated on every provider response (200 or error). The
56/// pool uses `remaining` to proactively cool keys before a 429 occurs:
57/// when `remaining` is 0 (or below a threshold), the key is
58/// quarantined for the provider's reset window.
59#[derive(Debug, Clone, Default)]
60pub struct KeyHealth {
61    /// Remaining requests in the current window (from
62    /// `x-ratelimit-remaining-requests` or equivalent). None when the
63    /// provider does not report it.
64    pub remaining_requests: Option<u64>,
65    /// Remaining tokens in the current window (from
66    /// `x-ratelimit-remaining-tokens` or equivalent). None when the
67    /// provider does not report it.
68    pub remaining_tokens: Option<u64>,
69    /// When the current rate-limit window resets (from
70    /// `x-ratelimit-reset-requests` / `x-ratelimit-reset-tokens` or
71    /// equivalent), as an `Instant` relative to the process clock.
72    pub reset_at: Option<Instant>,
73    /// The HTTP status of the last provider response observed for
74    /// this key.
75    pub last_status: Option<u16>,
76    /// When the health was last updated (process clock).
77    pub updated_at: Option<Instant>,
78}
79
80/// The compiled credential pool (DW-080). Lives inside
81/// `CompiledProvider` behind an `Arc` so the quarantine state can be
82/// shared across request tasks without cloning the whole provider.
83#[derive(Debug)]
84pub struct CredentialPool {
85    /// Resolved credential entries (header + value per key).
86    entries: Vec<PoolEntry>,
87    /// Rotation strategy.
88    strategy: AiPoolStrategy,
89    /// Default quarantine window.
90    quarantine_secs: u64,
91    /// Mutable quarantine state: key index -> quarantine expiry.
92    /// Behind a Mutex for thread-safe per-request access.
93    quarantine: Mutex<HashMap<usize, QuarantineState>>,
94    /// AI-09 (#198): Per-key health state from provider headers.
95    health: Mutex<HashMap<usize, KeyHealth>>,
96    /// Round-robin counter (behind a Mutex for atomicity).
97    rr_counter: Mutex<usize>,
98}
99
100impl CredentialPool {
101    /// Build a pool from the config block + resolved credentials.
102    /// `entries` must be non-empty and in the same order as the config
103    /// `credentials` list (the index is used for quarantine tracking).
104    pub fn new(entries: Vec<PoolEntry>, cfg: &AiCredentialPool) -> Self {
105        Self {
106            entries,
107            strategy: cfg.strategy,
108            quarantine_secs: cfg.quarantine_secs,
109            quarantine: Mutex::new(HashMap::new()),
110            health: Mutex::new(HashMap::new()),
111            rr_counter: Mutex::new(0),
112        }
113    }
114
115    /// Number of credentials in the pool.
116    pub fn len(&self) -> usize {
117        self.entries.len()
118    }
119
120    /// Whether the pool is empty.
121    pub fn is_empty(&self) -> bool {
122        self.entries.is_empty()
123    }
124
125    /// Pick a credential for the next request. Skips quarantined keys;
126    /// if all keys are quarantined, returns None (pool exhaustion — the
127    /// caller should fail with a 429 + Retry-After).
128    ///
129    /// For `RoundRobin`: cycles through non-quarantined keys in order.
130    /// For `Weighted`: uses a deterministic hash of `request_id` to
131    /// pick a key (same hash as canary splits), skipping quarantined
132    /// keys.
133    pub fn pick(&self, request_id: &str) -> Option<&PoolEntry> {
134        let available = self.available_indices();
135        if available.is_empty() {
136            return None;
137        }
138        let idx = match self.strategy {
139            AiPoolStrategy::RoundRobin => {
140                let mut counter = self.rr_counter.lock().unwrap();
141                let pick = available[*counter % available.len()];
142                *counter = (*counter + 1) % available.len().max(1);
143                pick
144            }
145            AiPoolStrategy::Weighted => {
146                // Deterministic pick: hash the request id and pick
147                // from the available set. Same approach as canary
148                // weighted_pick (ai/routing.rs).
149                let hash = weighted_hash(request_id);
150                available[hash % available.len()]
151            }
152        };
153        self.entries.get(idx)
154    }
155
156    /// Quarantine a credential after a 429 from the provider. The key
157    /// is skipped for `quarantine_secs` (or the provider's Retry-After,
158    /// capped at 600 seconds, if the caller passes it). Idempotent —
159    /// quarantining an already-quarantined key extends the window.
160    pub fn quarantine(&self, index: usize, retry_after_secs: Option<u64>) {
161        let secs = retry_after_secs.unwrap_or(self.quarantine_secs).min(600);
162        let until = Instant::now() + Duration::from_secs(secs);
163        let mut q = self.quarantine.lock().unwrap();
164        let entry = q
165            .entry(index)
166            .or_insert(QuarantineState { until, count: 0 });
167        entry.until = until;
168        entry.count += 1;
169    }
170
171    /// AI-09 (#198): Record per-key health from provider response
172    /// headers. Called on every provider response (200 or error).
173    /// When `remaining_requests` is 0 (or `remaining_tokens` is 0),
174    /// the key is proactively quarantined until `reset_at` to prevent
175    /// a 429 on the next request.
176    pub fn record_health(&self, index: usize, health: KeyHealth) {
177        // Proactive cooldown: if remaining is 0, quarantine until reset.
178        let should_cooldown =
179            health.remaining_requests == Some(0) || health.remaining_tokens == Some(0);
180        if should_cooldown {
181            let until = health
182                .reset_at
183                .unwrap_or_else(|| Instant::now() + Duration::from_secs(self.quarantine_secs));
184            let now = Instant::now();
185            if until > now {
186                let secs = (until - now).as_secs().min(600);
187                self.quarantine(index, Some(secs));
188                tracing::info!(
189                    code = "ai_credential_pool_proactive_cooldown",
190                    key_index = index,
191                    remaining_requests = ?health.remaining_requests,
192                    remaining_tokens = ?health.remaining_tokens,
193                    "credential pool key proactively cooled down (rate-limit exhausted)"
194                );
195            }
196        }
197        let mut h = self.health.lock().unwrap();
198        h.insert(index, health);
199    }
200
201    /// Returns true if all keys are currently quarantined (pool
202    /// exhaustion). The caller should fail with a 429 + Retry-After.
203    pub fn is_exhausted(&self) -> bool {
204        self.available_indices().is_empty()
205    }
206
207    /// The earliest quarantine expiry among all keys (for the
208    /// Retry-After header on pool-exhaustion 429s). Returns None if
209    /// no keys are quarantined.
210    pub fn earliest_quarantine_expiry(&self) -> Option<Instant> {
211        let q = self.quarantine.lock().unwrap();
212        let now = Instant::now();
213        q.values()
214            .filter(|state| state.until > now)
215            .map(|state| state.until)
216            .min()
217    }
218
219    /// Snapshot of the pool state for the admin API (per-key usage +
220    /// quarantine status + health). Never includes secret values.
221    pub fn status(&self) -> Vec<CredentialStatus> {
222        let q = self.quarantine.lock().unwrap();
223        let h = self.health.lock().unwrap();
224        let now = Instant::now();
225        self.entries
226            .iter()
227            .map(|e| {
228                let state = q.get(&e.index);
229                let health = h.get(&e.index);
230                CredentialStatus {
231                    index: e.index,
232                    header: e.header.clone(),
233                    quarantined: state.is_some_and(|s| s.until > now),
234                    quarantine_count: state.map(|s| s.count).unwrap_or(0),
235                    remaining_requests: health.and_then(|h| h.remaining_requests),
236                    remaining_tokens: health.and_then(|h| h.remaining_tokens),
237                    last_status: health.and_then(|h| h.last_status),
238                }
239            })
240            .collect()
241    }
242
243    /// Indices of non-quarantined keys (available for rotation).
244    fn available_indices(&self) -> Vec<usize> {
245        let now = Instant::now();
246        let q = self.quarantine.lock().unwrap();
247        (0..self.entries.len())
248            .filter(|&i| q.get(&i).is_none_or(|s| s.until <= now))
249            .collect()
250    }
251}
252
253/// Admin-facing per-key status (no secret values).
254#[derive(Debug, Clone, serde::Serialize)]
255pub struct CredentialStatus {
256    /// Pool index (0-based).
257    pub index: usize,
258    /// Header name (e.g. `Authorization`).
259    pub header: String,
260    /// Whether the key is currently quarantined (429 from provider
261    /// or proactive cooldown from rate-limit headers).
262    pub quarantined: bool,
263    /// Cumulative number of times this key has been quarantined.
264    pub quarantine_count: u64,
265    /// AI-09 (#198): Remaining requests in the current rate-limit
266    /// window (from provider headers). None when not reported.
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub remaining_requests: Option<u64>,
269    /// AI-09 (#198): Remaining tokens in the current rate-limit
270    /// window (from provider headers). None when not reported.
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub remaining_tokens: Option<u64>,
273    /// AI-09 (#198): HTTP status of the last provider response.
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub last_status: Option<u16>,
276}
277
278/// Deterministic hash for weighted picks (same approach as
279/// `ai::routing::weighted_pick`). Uses FNV-1a on the request id.
280fn weighted_hash(s: &str) -> usize {
281    let mut hash: u64 = 0xcbf29ce484222325;
282    for b in s.as_bytes() {
283        hash ^= *b as u64;
284        hash = hash.wrapping_mul(0x100000001b3);
285    }
286    hash as usize
287}