Skip to main content

seher/agent/
mod.rs

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    /// # Errors
94    ///
95    /// Returns an error if fetching usage from the provider API fails or the domain is unknown.
96    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    /// # Errors
108    ///
109    /// Returns an error if fetching usage from the provider API fails or the domain is unknown.
110    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    /// # Errors
242    ///
243    /// Returns an error if spawning or waiting on the child process fails.
244    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, // no models map → pass-through, accepts any model key
273            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 models map is not set, pass --model <value> through as-is
301        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    // -----------------------------------------------------------------------
462    // OpenRouter dispatch tests
463    // These tests verify that check_limit() / fetch_status() correctly route
464    // to the openrouter handler when provider == "openrouter", and that a
465    // missing management key causes an immediate error (no HTTP call made).
466    // -----------------------------------------------------------------------
467
468    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        // pre_command: true (always exits 0), main: true
506        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        // pre_command: false (always exits non-0), main: true
516        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        // Given: openrouter agent with no management key configured
527        let agent = make_openrouter_agent(None);
528
529        // When: check_limit is called
530        let result = agent.check_limit().await;
531
532        // Then: error mentions the missing key — no HTTP call should be made
533        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        // Given: openrouter agent with no management key configured
541        let agent = make_openrouter_agent(None);
542
543        // When: fetch_status is called
544        let result = agent.fetch_status().await;
545
546        // Then: error mentions the missing key — no HTTP call should be made
547        let err_msg = result.err().ok_or("expected Err")?.to_string();
548        assert!(err_msg.contains("openrouter_management_key"));
549        Ok(())
550    }
551}