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}