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 usage
118 .all_windows()
119 .into_iter()
120 .map(|(name, w)| UsageEntry {
121 entry_type: name.to_string(),
122 limited: w.utilization >= 100.0,
123 utilization: w.utilization,
124 resets_at: w.resets_at,
125 })
126 .collect()
127 }
128 Some("codex") => {
129 let usage = crate::codex::CodexClient::fetch_usage(&self.cookies).await?;
130 [
131 ("rate_limit", &usage.rate_limit),
132 ("code_review_rate_limit", &usage.code_review_rate_limit),
133 ]
134 .into_iter()
135 .flat_map(|(prefix, limit)| codex_usage_entries(prefix, limit))
136 .collect()
137 }
138 Some("copilot") => {
139 let quota = crate::copilot::CopilotClient::fetch_quota(&self.cookies).await?;
140 vec![
141 UsageEntry {
142 entry_type: "chat_utilization".to_string(),
143 limited: quota.chat_utilization >= 100.0,
144 utilization: quota.chat_utilization,
145 resets_at: quota.reset_time,
146 },
147 UsageEntry {
148 entry_type: "premium_utilization".to_string(),
149 limited: quota.premium_utilization >= 100.0,
150 utilization: quota.premium_utilization,
151 resets_at: quota.reset_time,
152 },
153 ]
154 }
155 Some("openrouter") => {
156 let management_key = self.openrouter_management_key()?;
157 let credits =
158 crate::openrouter::OpenRouterClient::fetch_credits(management_key).await?;
159 vec![UsageEntry {
160 entry_type: "credits".to_string(),
161 limited: credits.data.is_limited(),
162 utilization: credits.data.utilization(),
163 resets_at: None,
164 }]
165 }
166 Some(p) => return Err(format!("Unknown provider: {p}").into()),
167 };
168 Ok(AgentStatus {
169 command,
170 provider,
171 usage,
172 })
173 }
174
175 async fn check_claude_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
176 let usage = crate::claude::ClaudeClient::fetch_usage(&self.cookies).await?;
177 let windows = usage.all_windows();
178
179 let reset_time = windows
180 .iter()
181 .filter(|(_, w)| w.utilization >= 100.0)
182 .filter_map(|(_, w)| w.resets_at)
183 .max();
184
185 if let Some(reset_time) = reset_time {
186 Ok(AgentLimit::Limited {
187 reset_time: Some(reset_time),
188 })
189 } else if windows.iter().any(|(_, w)| w.utilization >= 100.0) {
190 Ok(AgentLimit::Limited { reset_time: None })
191 } else {
192 Ok(AgentLimit::NotLimited)
193 }
194 }
195
196 async fn check_copilot_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
197 let quota = crate::copilot::CopilotClient::fetch_quota(&self.cookies).await?;
198
199 if quota.is_limited() {
200 Ok(AgentLimit::Limited {
201 reset_time: quota.reset_time,
202 })
203 } else {
204 Ok(AgentLimit::NotLimited)
205 }
206 }
207
208 fn openrouter_management_key(&self) -> Result<&str, Box<dyn std::error::Error>> {
209 self.config
210 .openrouter_management_key
211 .as_deref()
212 .ok_or_else(|| {
213 "openrouter_management_key is required for OpenRouter provider"
214 .to_string()
215 .into()
216 })
217 }
218
219 async fn check_openrouter_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
220 let management_key = self.openrouter_management_key()?;
221 let credits = crate::openrouter::OpenRouterClient::fetch_credits(management_key).await?;
222 if credits.data.is_limited() {
223 Ok(AgentLimit::Limited { reset_time: None })
224 } else {
225 Ok(AgentLimit::NotLimited)
226 }
227 }
228
229 async fn check_codex_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
230 let usage = crate::codex::CodexClient::fetch_usage(&self.cookies).await?;
231
232 if usage.rate_limit.is_limited() {
233 Ok(AgentLimit::Limited {
234 reset_time: usage.rate_limit.next_reset_time(),
235 })
236 } else {
237 Ok(AgentLimit::NotLimited)
238 }
239 }
240
241 pub fn execute(
245 &self,
246 resolved_args: &[String],
247 extra_args: &[String],
248 ) -> std::io::Result<std::process::ExitStatus> {
249 if let Some((cmd, args)) = self.config.pre_command.split_first() {
250 let mut pre_cmd = std::process::Command::new(cmd);
251 pre_cmd.args(args);
252 if let Some(env) = &self.config.env {
253 pre_cmd.envs(env);
254 }
255 let status = pre_cmd.status()?;
256 if !status.success() {
257 return Ok(status);
258 }
259 }
260 let mut cmd = std::process::Command::new(self.command());
261 cmd.args(resolved_args);
262 cmd.args(extra_args);
263 if let Some(env) = &self.config.env {
264 cmd.envs(env);
265 }
266 cmd.status()
267 }
268
269 #[must_use]
270 pub fn has_model(&self, model_key: &str) -> bool {
271 match &self.config.models {
272 None => true, Some(m) => m.contains_key(model_key),
274 }
275 }
276
277 #[must_use]
278 pub fn resolved_args(&self, model: Option<&str>) -> Vec<String> {
279 const MODEL_PLACEHOLDER: &str = "{model}";
280 let mut args: Vec<String> = self
281 .config
282 .args
283 .iter()
284 .filter_map(|arg| {
285 if arg.contains(MODEL_PLACEHOLDER) {
286 let model_key = model?;
287 let replacement = self
288 .config
289 .models
290 .as_ref()
291 .and_then(|m| m.get(model_key))
292 .map_or(model_key, |s| s.as_str());
293 Some(arg.replace(MODEL_PLACEHOLDER, replacement))
294 } else {
295 Some(arg.clone())
296 }
297 })
298 .collect();
299
300 if self.config.models.is_none()
302 && let Some(model_key) = model
303 {
304 args.push("--model".to_string());
305 args.push(model_key.to_string());
306 }
307
308 args
309 }
310
311 #[must_use]
312 pub fn mapped_args(&self, args: &[String]) -> Vec<String> {
313 args.iter()
314 .flat_map(|arg| {
315 self.config
316 .arg_maps
317 .get(arg.as_str())
318 .map_or_else(|| std::slice::from_ref(arg), Vec::as_slice)
319 })
320 .cloned()
321 .collect()
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use std::collections::HashMap;
328
329 use super::*;
330 use crate::codex::{CodexRateLimit, CodexWindow};
331 use crate::config::AgentConfig;
332
333 fn make_agent(
334 models: Option<HashMap<String, String>>,
335 arg_maps: HashMap<String, Vec<String>>,
336 ) -> Agent {
337 Agent::new(
338 AgentConfig {
339 command: "claude".to_string(),
340 args: vec![],
341 models,
342 arg_maps,
343 env: None,
344 provider: None,
345 openrouter_management_key: None,
346 pre_command: vec![],
347 },
348 vec![],
349 )
350 }
351
352 #[test]
353 fn has_model_returns_true_when_models_is_none() {
354 let agent = make_agent(None, HashMap::new());
355 assert!(agent.has_model("high"));
356 assert!(agent.has_model("anything"));
357 }
358
359 #[test]
360 fn resolved_args_passthrough_when_models_is_none_with_model() {
361 let agent = make_agent(None, HashMap::new());
362 let args = agent.resolved_args(Some("high"));
363 assert_eq!(args, vec!["--model", "high"]);
364 }
365
366 #[test]
367 fn resolved_args_no_model_flag_when_models_is_none_without_model() {
368 let agent = make_agent(None, HashMap::new());
369 let args = agent.resolved_args(None);
370 assert!(!args.contains(&"--model".to_string()));
371 }
372
373 #[test]
374 fn mapped_args_passthrough_when_arg_maps_is_empty() {
375 let agent = make_agent(None, HashMap::new());
376 let args = vec!["--danger".to_string(), "fix bugs".to_string()];
377
378 assert_eq!(agent.mapped_args(&args), args);
379 }
380
381 #[test]
382 fn mapped_args_replaces_matching_tokens() {
383 let mut arg_maps = HashMap::new();
384 arg_maps.insert("--danger".to_string(), vec!["--yolo".to_string()]);
385 let agent = make_agent(None, arg_maps);
386
387 assert_eq!(
388 agent.mapped_args(&["--danger".to_string(), "fix bugs".to_string()]),
389 vec!["--yolo".to_string(), "fix bugs".to_string()]
390 );
391 }
392
393 #[test]
394 fn mapped_args_can_expand_to_multiple_tokens() {
395 let mut arg_maps = HashMap::new();
396 arg_maps.insert(
397 "--danger".to_string(),
398 vec![
399 "--permission-mode".to_string(),
400 "bypassPermissions".to_string(),
401 ],
402 );
403 let agent = make_agent(None, arg_maps);
404
405 assert_eq!(
406 agent.mapped_args(&["--danger".to_string(), "fix bugs".to_string()]),
407 vec![
408 "--permission-mode".to_string(),
409 "bypassPermissions".to_string(),
410 "fix bugs".to_string(),
411 ]
412 );
413 }
414
415 #[test]
416 fn codex_usage_entries_marks_blocking_window_when_only_top_level_limit_is_set() {
417 let limit = CodexRateLimit {
418 allowed: false,
419 limit_reached: false,
420 primary_window: Some(CodexWindow {
421 used_percent: 55.0,
422 limit_window_seconds: 60,
423 reset_after_seconds: 30,
424 reset_at: 100,
425 }),
426 secondary_window: Some(CodexWindow {
427 used_percent: 40.0,
428 limit_window_seconds: 120,
429 reset_after_seconds: 90,
430 reset_at: 200,
431 }),
432 };
433
434 let entries = codex_usage_entries("rate_limit", &limit);
435
436 assert_eq!(entries.len(), 2);
437 assert_eq!(entries[0].entry_type, "rate_limit_primary");
438 assert!(!entries[0].limited);
439 assert_eq!(entries[1].entry_type, "rate_limit_secondary");
440 assert!(entries[1].limited);
441 }
442
443 #[test]
444 fn codex_usage_entries_adds_summary_when_limit_has_no_windows() {
445 let limit = CodexRateLimit {
446 allowed: false,
447 limit_reached: true,
448 primary_window: None,
449 secondary_window: None,
450 };
451
452 let entries = codex_usage_entries("code_review_rate_limit", &limit);
453
454 assert_eq!(entries.len(), 1);
455 assert_eq!(entries[0].entry_type, "code_review_rate_limit");
456 assert!(entries[0].limited);
457 assert!((entries[0].utilization - 100.0).abs() < f64::EPSILON);
458 assert_eq!(entries[0].resets_at, None);
459 }
460
461 fn make_openrouter_agent(management_key: Option<&str>) -> Agent {
469 Agent::new(
470 AgentConfig {
471 command: "myai".to_string(),
472 args: vec![],
473 models: None,
474 arg_maps: HashMap::new(),
475 env: None,
476 provider: Some(crate::config::ProviderConfig::Explicit(
477 "openrouter".to_string(),
478 )),
479 openrouter_management_key: management_key.map(str::to_string),
480 pre_command: vec![],
481 },
482 vec![],
483 )
484 }
485
486 fn make_agent_with_pre_command(pre_command: Vec<String>, main_command: &str) -> Agent {
487 Agent::new(
488 AgentConfig {
489 command: main_command.to_string(),
490 args: vec![],
491 models: None,
492 arg_maps: HashMap::new(),
493 env: None,
494 provider: None,
495 openrouter_management_key: None,
496 pre_command,
497 },
498 vec![],
499 )
500 }
501
502 #[test]
503 #[cfg(unix)]
504 fn execute_runs_main_command_when_pre_command_succeeds() -> TestResult {
505 let agent = make_agent_with_pre_command(vec!["true".to_string()], "true");
507 let status = agent.execute(&[], &[])?;
508 assert!(status.success());
509 Ok(())
510 }
511
512 #[test]
513 #[cfg(unix)]
514 fn execute_skips_main_command_when_pre_command_fails() -> TestResult {
515 let agent = make_agent_with_pre_command(vec!["false".to_string()], "true");
517 let status = agent.execute(&[], &[])?;
518 assert!(!status.success());
519 Ok(())
520 }
521
522 type TestResult = Result<(), Box<dyn std::error::Error>>;
523
524 #[tokio::test(flavor = "current_thread")]
525 async fn check_limit_openrouter_returns_error_when_management_key_is_missing() -> TestResult {
526 let agent = make_openrouter_agent(None);
528
529 let result = agent.check_limit().await;
531
532 let err_msg = result.err().ok_or("expected Err")?.to_string();
534 assert!(err_msg.contains("openrouter_management_key"));
535 Ok(())
536 }
537
538 #[tokio::test(flavor = "current_thread")]
539 async fn fetch_status_openrouter_returns_error_when_management_key_is_missing() -> TestResult {
540 let agent = make_openrouter_agent(None);
542
543 let result = agent.fetch_status().await;
545
546 let err_msg = result.err().ok_or("expected Err")?.to_string();
548 assert!(err_msg.contains("openrouter_management_key"));
549 Ok(())
550 }
551}