1use crate::Cookie;
2use crate::config::AgentConfig;
3use chrono::{DateTime, Utc};
4use serde::Serialize;
5
6pub struct Agent {
7 pub config: AgentConfig,
8 pub cookies: Vec<Cookie>,
9}
10
11#[derive(Debug)]
12pub enum AgentLimit {
13 NotLimited,
14 Limited { reset_time: Option<DateTime<Utc>> },
15}
16
17#[derive(Debug, Serialize)]
18pub struct UsageEntry {
19 #[serde(rename = "type")]
20 pub entry_type: String,
21 pub limited: bool,
22 pub utilization: f64,
23 pub resets_at: Option<DateTime<Utc>>,
24}
25
26#[derive(Debug, Serialize)]
27pub struct AgentStatus {
28 pub command: String,
29 pub provider: Option<String>,
30 pub usage: Vec<UsageEntry>,
31}
32
33fn codex_usage_entries(prefix: &str, limit: &crate::codex::CodexRateLimit) -> Vec<UsageEntry> {
34 let has_limited_window = [
35 limit.primary_window.as_ref(),
36 limit.secondary_window.as_ref(),
37 ]
38 .into_iter()
39 .flatten()
40 .any(crate::codex::types::CodexWindow::is_limited);
41 let fallback_reset = if limit.is_limited() && !has_limited_window {
42 limit.next_reset_time()
43 } else {
44 None
45 };
46
47 let mut entries = Vec::new();
48
49 for (suffix, window) in [
50 ("primary", limit.primary_window.as_ref()),
51 ("secondary", limit.secondary_window.as_ref()),
52 ] {
53 if let Some(window) = window {
54 let resets_at = window.reset_at_datetime();
55 entries.push(UsageEntry {
56 entry_type: format!("{prefix}_{suffix}"),
57 limited: window.is_limited()
58 || (fallback_reset.is_some() && resets_at == fallback_reset),
59 utilization: window.used_percent,
60 resets_at,
61 });
62 }
63 }
64
65 if entries.is_empty() && limit.is_limited() {
66 entries.push(UsageEntry {
67 entry_type: prefix.to_string(),
68 limited: true,
69 utilization: 100.0,
70 resets_at: limit.next_reset_time(),
71 });
72 }
73
74 entries
75}
76
77impl Agent {
78 #[must_use]
79 pub fn new(config: AgentConfig, cookies: Vec<Cookie>) -> Self {
80 Self { config, cookies }
81 }
82
83 #[must_use]
84 pub fn command(&self) -> &str {
85 &self.config.command
86 }
87
88 #[must_use]
89 pub fn args(&self) -> &[String] {
90 &self.config.args
91 }
92
93 pub async fn check_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
97 match self.config.resolve_provider() {
98 Some("claude") => self.check_claude_limit().await,
99 Some("codex") => self.check_codex_limit().await,
100 Some("copilot") => self.check_copilot_limit().await,
101 Some("openrouter") => self.check_openrouter_limit().await,
102 None => Ok(AgentLimit::NotLimited),
103 Some(p) => Err(format!("Unknown provider: {p}").into()),
104 }
105 }
106
107 pub async fn fetch_status(&self) -> Result<AgentStatus, Box<dyn std::error::Error>> {
111 let command = self.config.command.clone();
112 let provider = self.config.resolve_provider().map(ToString::to_string);
113 let usage = match provider.as_deref() {
114 None => vec![],
115 Some("claude") => {
116 let usage = crate::claude::ClaudeClient::fetch_usage(&self.cookies).await?;
117 let windows = [
118 ("five_hour", &usage.five_hour),
119 ("seven_day", &usage.seven_day),
120 ("seven_day_sonnet", &usage.seven_day_sonnet),
121 ];
122 windows
123 .into_iter()
124 .filter_map(|(name, w)| {
125 w.as_ref().map(|w| UsageEntry {
126 entry_type: name.to_string(),
127 limited: w.utilization >= 100.0,
128 utilization: w.utilization,
129 resets_at: w.resets_at,
130 })
131 })
132 .collect()
133 }
134 Some("codex") => {
135 let usage = crate::codex::CodexClient::fetch_usage(&self.cookies).await?;
136 [
137 ("rate_limit", &usage.rate_limit),
138 ("code_review_rate_limit", &usage.code_review_rate_limit),
139 ]
140 .into_iter()
141 .flat_map(|(prefix, limit)| codex_usage_entries(prefix, limit))
142 .collect()
143 }
144 Some("copilot") => {
145 let quota = crate::copilot::CopilotClient::fetch_quota(&self.cookies).await?;
146 vec![
147 UsageEntry {
148 entry_type: "chat_utilization".to_string(),
149 limited: quota.chat_utilization >= 100.0,
150 utilization: quota.chat_utilization,
151 resets_at: quota.reset_time,
152 },
153 UsageEntry {
154 entry_type: "premium_utilization".to_string(),
155 limited: quota.premium_utilization >= 100.0,
156 utilization: quota.premium_utilization,
157 resets_at: quota.reset_time,
158 },
159 ]
160 }
161 Some("openrouter") => {
162 let management_key = self.openrouter_management_key()?;
163 let credits =
164 crate::openrouter::OpenRouterClient::fetch_credits(management_key).await?;
165 vec![UsageEntry {
166 entry_type: "credits".to_string(),
167 limited: credits.data.is_limited(),
168 utilization: credits.data.utilization(),
169 resets_at: None,
170 }]
171 }
172 Some(p) => return Err(format!("Unknown provider: {p}").into()),
173 };
174 Ok(AgentStatus {
175 command,
176 provider,
177 usage,
178 })
179 }
180
181 async fn check_claude_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
182 let usage = crate::claude::ClaudeClient::fetch_usage(&self.cookies).await?;
183
184 if let Some(reset_time) = usage.next_reset_time() {
185 Ok(AgentLimit::Limited {
186 reset_time: Some(reset_time),
187 })
188 } else {
189 let is_limited = [
190 usage.five_hour.as_ref(),
191 usage.seven_day.as_ref(),
192 usage.seven_day_sonnet.as_ref(),
193 ]
194 .into_iter()
195 .flatten()
196 .any(|w| w.utilization >= 100.0);
197
198 if is_limited {
199 Ok(AgentLimit::Limited { reset_time: None })
200 } else {
201 Ok(AgentLimit::NotLimited)
202 }
203 }
204 }
205
206 async fn check_copilot_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
207 let quota = crate::copilot::CopilotClient::fetch_quota(&self.cookies).await?;
208
209 if quota.is_limited() {
210 Ok(AgentLimit::Limited {
211 reset_time: quota.reset_time,
212 })
213 } else {
214 Ok(AgentLimit::NotLimited)
215 }
216 }
217
218 fn openrouter_management_key(&self) -> Result<&str, Box<dyn std::error::Error>> {
219 self.config
220 .openrouter_management_key
221 .as_deref()
222 .ok_or_else(|| {
223 "openrouter_management_key is required for OpenRouter provider"
224 .to_string()
225 .into()
226 })
227 }
228
229 async fn check_openrouter_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
230 let management_key = self.openrouter_management_key()?;
231 let credits = crate::openrouter::OpenRouterClient::fetch_credits(management_key).await?;
232 if credits.data.is_limited() {
233 Ok(AgentLimit::Limited { reset_time: None })
234 } else {
235 Ok(AgentLimit::NotLimited)
236 }
237 }
238
239 async fn check_codex_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
240 let usage = crate::codex::CodexClient::fetch_usage(&self.cookies).await?;
241
242 if usage.rate_limit.is_limited() {
243 Ok(AgentLimit::Limited {
244 reset_time: usage.rate_limit.next_reset_time(),
245 })
246 } else {
247 Ok(AgentLimit::NotLimited)
248 }
249 }
250
251 pub fn execute(
255 &self,
256 resolved_args: &[String],
257 extra_args: &[String],
258 ) -> std::io::Result<std::process::ExitStatus> {
259 let mut cmd = std::process::Command::new(self.command());
260 cmd.args(resolved_args);
261 cmd.args(extra_args);
262 if let Some(env) = &self.config.env {
263 cmd.envs(env);
264 }
265 cmd.status()
266 }
267
268 #[must_use]
269 pub fn has_model(&self, model_key: &str) -> bool {
270 match &self.config.models {
271 None => true, Some(m) => m.contains_key(model_key),
273 }
274 }
275
276 #[must_use]
277 pub fn resolved_args(&self, model: Option<&str>) -> Vec<String> {
278 const MODEL_PLACEHOLDER: &str = "{model}";
279 let mut args: Vec<String> = self
280 .config
281 .args
282 .iter()
283 .filter_map(|arg| {
284 if arg.contains(MODEL_PLACEHOLDER) {
285 let model_key = model?;
286 let replacement = self
287 .config
288 .models
289 .as_ref()
290 .and_then(|m| m.get(model_key))
291 .map_or(model_key, |s| s.as_str());
292 Some(arg.replace(MODEL_PLACEHOLDER, replacement))
293 } else {
294 Some(arg.clone())
295 }
296 })
297 .collect();
298
299 if self.config.models.is_none()
301 && let Some(model_key) = model
302 {
303 args.push("--model".to_string());
304 args.push(model_key.to_string());
305 }
306
307 args
308 }
309
310 #[must_use]
311 pub fn mapped_args(&self, args: &[String]) -> Vec<String> {
312 args.iter()
313 .flat_map(|arg| {
314 self.config
315 .arg_maps
316 .get(arg.as_str())
317 .map_or_else(|| std::slice::from_ref(arg), Vec::as_slice)
318 })
319 .cloned()
320 .collect()
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use std::collections::HashMap;
327
328 use super::*;
329 use crate::codex::{CodexRateLimit, CodexWindow};
330 use crate::config::AgentConfig;
331
332 fn make_agent(
333 models: Option<HashMap<String, String>>,
334 arg_maps: HashMap<String, Vec<String>>,
335 ) -> Agent {
336 Agent::new(
337 AgentConfig {
338 command: "claude".to_string(),
339 args: vec![],
340 models,
341 arg_maps,
342 env: None,
343 provider: None,
344 openrouter_management_key: None,
345 },
346 vec![],
347 )
348 }
349
350 #[test]
351 fn has_model_returns_true_when_models_is_none() {
352 let agent = make_agent(None, HashMap::new());
353 assert!(agent.has_model("high"));
354 assert!(agent.has_model("anything"));
355 }
356
357 #[test]
358 fn resolved_args_passthrough_when_models_is_none_with_model() {
359 let agent = make_agent(None, HashMap::new());
360 let args = agent.resolved_args(Some("high"));
361 assert_eq!(args, vec!["--model", "high"]);
362 }
363
364 #[test]
365 fn resolved_args_no_model_flag_when_models_is_none_without_model() {
366 let agent = make_agent(None, HashMap::new());
367 let args = agent.resolved_args(None);
368 assert!(!args.contains(&"--model".to_string()));
369 }
370
371 #[test]
372 fn mapped_args_passthrough_when_arg_maps_is_empty() {
373 let agent = make_agent(None, HashMap::new());
374 let args = vec!["--danger".to_string(), "fix bugs".to_string()];
375
376 assert_eq!(agent.mapped_args(&args), args);
377 }
378
379 #[test]
380 fn mapped_args_replaces_matching_tokens() {
381 let mut arg_maps = HashMap::new();
382 arg_maps.insert("--danger".to_string(), vec!["--yolo".to_string()]);
383 let agent = make_agent(None, arg_maps);
384
385 assert_eq!(
386 agent.mapped_args(&["--danger".to_string(), "fix bugs".to_string()]),
387 vec!["--yolo".to_string(), "fix bugs".to_string()]
388 );
389 }
390
391 #[test]
392 fn mapped_args_can_expand_to_multiple_tokens() {
393 let mut arg_maps = HashMap::new();
394 arg_maps.insert(
395 "--danger".to_string(),
396 vec![
397 "--permission-mode".to_string(),
398 "bypassPermissions".to_string(),
399 ],
400 );
401 let agent = make_agent(None, arg_maps);
402
403 assert_eq!(
404 agent.mapped_args(&["--danger".to_string(), "fix bugs".to_string()]),
405 vec![
406 "--permission-mode".to_string(),
407 "bypassPermissions".to_string(),
408 "fix bugs".to_string(),
409 ]
410 );
411 }
412
413 #[test]
414 fn codex_usage_entries_marks_blocking_window_when_only_top_level_limit_is_set() {
415 let limit = CodexRateLimit {
416 allowed: false,
417 limit_reached: false,
418 primary_window: Some(CodexWindow {
419 used_percent: 55.0,
420 limit_window_seconds: 60,
421 reset_after_seconds: 30,
422 reset_at: 100,
423 }),
424 secondary_window: Some(CodexWindow {
425 used_percent: 40.0,
426 limit_window_seconds: 120,
427 reset_after_seconds: 90,
428 reset_at: 200,
429 }),
430 };
431
432 let entries = codex_usage_entries("rate_limit", &limit);
433
434 assert_eq!(entries.len(), 2);
435 assert_eq!(entries[0].entry_type, "rate_limit_primary");
436 assert!(!entries[0].limited);
437 assert_eq!(entries[1].entry_type, "rate_limit_secondary");
438 assert!(entries[1].limited);
439 }
440
441 #[test]
442 fn codex_usage_entries_adds_summary_when_limit_has_no_windows() {
443 let limit = CodexRateLimit {
444 allowed: false,
445 limit_reached: true,
446 primary_window: None,
447 secondary_window: None,
448 };
449
450 let entries = codex_usage_entries("code_review_rate_limit", &limit);
451
452 assert_eq!(entries.len(), 1);
453 assert_eq!(entries[0].entry_type, "code_review_rate_limit");
454 assert!(entries[0].limited);
455 assert!((entries[0].utilization - 100.0).abs() < f64::EPSILON);
456 assert_eq!(entries[0].resets_at, None);
457 }
458
459 fn make_openrouter_agent(management_key: Option<&str>) -> Agent {
467 Agent::new(
468 AgentConfig {
469 command: "myai".to_string(),
470 args: vec![],
471 models: None,
472 arg_maps: HashMap::new(),
473 env: None,
474 provider: Some(crate::config::ProviderConfig::Explicit(
475 "openrouter".to_string(),
476 )),
477 openrouter_management_key: management_key.map(str::to_string),
478 },
479 vec![],
480 )
481 }
482
483 type TestResult = Result<(), Box<dyn std::error::Error>>;
484
485 #[tokio::test(flavor = "current_thread")]
486 async fn check_limit_openrouter_returns_error_when_management_key_is_missing() -> TestResult {
487 let agent = make_openrouter_agent(None);
489
490 let result = agent.check_limit().await;
492
493 let err_msg = result.err().ok_or("expected Err")?.to_string();
495 assert!(err_msg.contains("openrouter_management_key"));
496 Ok(())
497 }
498
499 #[tokio::test(flavor = "current_thread")]
500 async fn fetch_status_openrouter_returns_error_when_management_key_is_missing() -> TestResult {
501 let agent = make_openrouter_agent(None);
503
504 let result = agent.fetch_status().await;
506
507 let err_msg = result.err().ok_or("expected Err")?.to_string();
509 assert!(err_msg.contains("openrouter_management_key"));
510 Ok(())
511 }
512}