1use std::collections::HashMap;
8use std::time::Instant;
9use tracing::{info, warn};
10
11fn cooldown_seconds(status: u16) -> f64 {
13 match status {
14 429 => 30.0, 401 => 300.0, 402 => 300.0, 403 => 600.0, 500 => 60.0, 502 => 30.0, 503 => 60.0, _ => 60.0, }
23}
24
25#[derive(Debug)]
27struct AuthProfile {
28 key: String,
29 #[allow(dead_code)]
30 provider: String,
31 failed_at: Option<Instant>,
32 failure_status: u16,
33 cooldown_until: Option<Instant>,
34 request_count: u64,
35 failure_count: u64,
36}
37
38impl AuthProfile {
39 fn new(key: String, provider: String) -> Self {
40 Self {
41 key,
42 provider,
43 failed_at: None,
44 failure_status: 0,
45 cooldown_until: None,
46 request_count: 0,
47 failure_count: 0,
48 }
49 }
50
51 fn is_available(&self) -> bool {
52 match self.cooldown_until {
53 None => true,
54 Some(until) => Instant::now() >= until,
55 }
56 }
57
58 fn mark_success(&mut self) {
59 self.request_count += 1;
60 self.failed_at = None;
61 self.failure_status = 0;
62 self.cooldown_until = None;
63 }
64
65 fn mark_failure(&mut self, status_code: u16) {
66 self.failure_count += 1;
67 let now = Instant::now();
68 self.failed_at = Some(now);
69 self.failure_status = status_code;
70 let cooldown = cooldown_seconds(status_code);
71 self.cooldown_until = Some(now + std::time::Duration::from_secs_f64(cooldown));
72 warn!(
73 key_prefix = &self.key[..self.key.len().min(8)],
74 status_code,
75 cooldown_secs = cooldown,
76 "Auth profile failed, cooling down"
77 );
78 }
79
80 fn cooldown_remaining(&self) -> f64 {
82 match self.cooldown_until {
83 None => 0.0,
84 Some(until) => {
85 let now = Instant::now();
86 if now >= until {
87 0.0
88 } else {
89 (until - now).as_secs_f64()
90 }
91 }
92 }
93 }
94}
95
96pub struct AuthProfileManager {
101 provider: String,
102 profiles: Vec<AuthProfile>,
103 current_index: usize,
104}
105
106impl AuthProfileManager {
107 pub fn new(provider: impl Into<String>, keys: Vec<String>) -> Self {
109 let provider = provider.into();
110 let profiles: Vec<_> = keys
111 .into_iter()
112 .filter(|k| !k.is_empty())
113 .map(|k| AuthProfile::new(k, provider.clone()))
114 .collect();
115
116 if profiles.is_empty() {
117 warn!("No API keys configured for provider '{}'", provider);
118 }
119
120 Self {
121 provider,
122 profiles,
123 current_index: 0,
124 }
125 }
126
127 pub fn from_env(provider: &str) -> Self {
131 let prefix = provider.to_uppercase().replace('-', "_");
132 let mut keys = Vec::new();
133
134 if let Ok(val) = std::env::var(format!("{prefix}_API_KEY"))
136 && !val.is_empty()
137 {
138 keys.push(val);
139 }
140
141 for i in 2..10 {
143 match std::env::var(format!("{prefix}_API_KEY_{i}")) {
144 Ok(val) if !val.is_empty() => keys.push(val),
145 _ => break,
146 }
147 }
148
149 Self::new(provider, keys)
150 }
151
152 pub fn from_config(provider: &str, config: &HashMap<String, serde_json::Value>) -> Self {
156 let keys = if let Some(serde_json::Value::Array(arr)) = config.get("api_keys") {
157 arr.iter()
158 .filter_map(|v| v.as_str().map(String::from))
159 .collect()
160 } else if let Some(serde_json::Value::String(single)) = config.get("api_key") {
161 vec![single.clone()]
162 } else {
163 vec![]
164 };
165 Self::new(provider, keys)
166 }
167
168 pub fn get_active_key(&mut self) -> Option<&str> {
172 if self.profiles.is_empty() {
173 return None;
174 }
175
176 if self.profiles[self.current_index].is_available() {
178 return Some(&self.profiles[self.current_index].key);
179 }
180
181 let len = self.profiles.len();
183 for i in 1..len {
184 let idx = (self.current_index + i) % len;
185 if self.profiles[idx].is_available() {
186 self.current_index = idx;
187 let profile = &self.profiles[idx];
188 info!(
189 key_prefix = &profile.key[..profile.key.len().min(8)],
190 provider = %self.provider,
191 "Rotated to next API key"
192 );
193 return Some(&self.profiles[idx].key);
194 }
195 }
196
197 let soonest = self
199 .profiles
200 .iter()
201 .map(|p| p.cooldown_remaining())
202 .fold(f64::MAX, f64::min);
203 warn!(
204 total = self.profiles.len(),
205 provider = %self.provider,
206 soonest_available_secs = soonest,
207 "All API keys are in cooldown"
208 );
209 None
210 }
211
212 pub fn mark_success(&mut self) {
214 if !self.profiles.is_empty() {
215 self.profiles[self.current_index].mark_success();
216 }
217 }
218
219 pub fn mark_failure(&mut self, status_code: u16) {
221 if !self.profiles.is_empty() {
222 self.profiles[self.current_index].mark_failure(status_code);
223 }
224 }
225
226 pub fn profile_count(&self) -> usize {
228 self.profiles.len()
229 }
230
231 pub fn available_count(&self) -> usize {
233 self.profiles.iter().filter(|p| p.is_available()).count()
234 }
235
236 pub fn provider(&self) -> &str {
238 &self.provider
239 }
240}
241
242impl std::fmt::Debug for AuthProfileManager {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 f.debug_struct("AuthProfileManager")
245 .field("provider", &self.provider)
246 .field("profile_count", &self.profiles.len())
247 .field("current_index", &self.current_index)
248 .finish()
249 }
250}
251
252#[cfg(test)]
253#[path = "rotation_tests.rs"]
254mod tests;