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                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    /// # Errors
252    ///
253    /// Returns an error if spawning or waiting on the child process fails.
254    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, // no models map → pass-through, accepts any model key
272            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 models map is not set, pass --model <value> through as-is
300        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    // -----------------------------------------------------------------------
460    // OpenRouter dispatch tests
461    // These tests verify that check_limit() / fetch_status() correctly route
462    // to the openrouter handler when provider == "openrouter", and that a
463    // missing management key causes an immediate error (no HTTP call made).
464    // -----------------------------------------------------------------------
465
466    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        // Given: openrouter agent with no management key configured
488        let agent = make_openrouter_agent(None);
489
490        // When: check_limit is called
491        let result = agent.check_limit().await;
492
493        // Then: error mentions the missing key — no HTTP call should be made
494        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        // Given: openrouter agent with no management key configured
502        let agent = make_openrouter_agent(None);
503
504        // When: fetch_status is called
505        let result = agent.fetch_status().await;
506
507        // Then: error mentions the missing key — no HTTP call should be made
508        let err_msg = result.err().ok_or("expected Err")?.to_string();
509        assert!(err_msg.contains("openrouter_management_key"));
510        Ok(())
511    }
512}