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 Some("glm") => self.check_glm_limit().await,
103 Some("zai") => self.check_zai_limit().await,
104 Some("kimi-k2") => self.check_kimik2_limit().await,
105 Some("warp") => self.check_warp_limit().await,
106 Some("kiro") => self.check_kiro_limit().await,
107 Some("opencode-go") => self.check_opencode_go_limit(),
108 None => Ok(AgentLimit::NotLimited),
109 Some(p) => Err(format!("Unknown provider: {p}").into()),
110 }
111 }
112
113 #[expect(clippy::too_many_lines)]
117 pub async fn fetch_status(&self) -> Result<AgentStatus, Box<dyn std::error::Error>> {
118 let command = self.config.command.clone();
119 let provider = self.config.resolve_provider().map(ToString::to_string);
120 let usage = match provider.as_deref() {
121 None => vec![],
122 Some("claude") => {
123 let usage = crate::claude::ClaudeClient::fetch_usage(&self.cookies).await?;
124 usage
125 .all_windows()
126 .into_iter()
127 .map(|(name, w)| UsageEntry {
128 entry_type: name.to_string(),
129 limited: w.is_limited(),
130 utilization: w.utilization.unwrap_or(0.0),
131 resets_at: w.resets_at,
132 })
133 .collect()
134 }
135 Some("codex") => {
136 let usage = crate::codex::CodexClient::fetch_usage(&self.cookies).await?;
137 [
138 ("rate_limit", &usage.rate_limit),
139 ("code_review_rate_limit", &usage.code_review_rate_limit),
140 ]
141 .into_iter()
142 .flat_map(|(prefix, limit)| codex_usage_entries(prefix, limit))
143 .collect()
144 }
145 Some("copilot") => {
146 let quota = crate::copilot::CopilotClient::fetch_quota(&self.cookies).await?;
147 vec![
148 UsageEntry {
149 entry_type: "chat_utilization".to_string(),
150 limited: quota.chat_utilization >= 100.0,
151 utilization: quota.chat_utilization,
152 resets_at: quota.reset_time,
153 },
154 UsageEntry {
155 entry_type: "premium_utilization".to_string(),
156 limited: quota.premium_utilization >= 100.0,
157 utilization: quota.premium_utilization,
158 resets_at: quota.reset_time,
159 },
160 ]
161 }
162 Some("openrouter") => {
163 let management_key = self.openrouter_management_key()?;
164 let credits =
165 crate::openrouter::OpenRouterClient::fetch_credits(management_key).await?;
166 vec![UsageEntry {
167 entry_type: "credits".to_string(),
168 limited: credits.data.is_limited(),
169 utilization: credits.data.utilization(),
170 resets_at: None,
171 }]
172 }
173 Some("glm") => {
174 let api_key = self.glm_api_key()?;
175 let quota = crate::glm::GlmClient::fetch_quota(api_key).await?;
176 match quota.data {
177 Some(data) => data
178 .limits
179 .iter()
180 .map(|l| UsageEntry {
181 entry_type: l.limit_type.clone(),
182 limited: l.percentage >= 100,
183 utilization: f64::from(l.percentage),
184 resets_at: l.next_reset_time.and_then(DateTime::from_timestamp_millis),
185 })
186 .collect(),
187 None => vec![],
188 }
189 }
190 Some("zai") => {
191 let api_key = self.resolve_env_key("Z_AI_API_KEY")?;
192 let quota_url = self.resolve_optional_env("Z_AI_QUOTA_URL");
193 let quota =
194 crate::zai::ZaiClient::fetch_quota(&api_key, quota_url.as_deref()).await?;
195 match quota.data {
196 Some(data) => data
197 .limits
198 .iter()
199 .map(|l| UsageEntry {
200 entry_type: l.limit_type.clone(),
201 limited: l.percentage >= 100,
202 utilization: f64::from(l.percentage),
203 resets_at: l.next_reset_time.and_then(DateTime::from_timestamp_millis),
204 })
205 .collect(),
206 None => vec![],
207 }
208 }
209 Some("kimi-k2") => {
210 let api_key = self.resolve_env_key("KIMI_K2_API_KEY")?;
211 let credits = crate::kimik2::KimiK2Client::fetch_credits(&api_key).await?;
212 vec![UsageEntry {
213 entry_type: "credits".to_string(),
214 limited: credits.is_limited(),
215 utilization: credits.utilization(),
216 resets_at: None,
217 }]
218 }
219 Some("warp") => {
220 let api_key = self.resolve_env_key("WARP_API_KEY")?;
221 let info = crate::warp::WarpClient::fetch_limit_info(&api_key).await?;
222 let limit_info = &info.data.get_request_limit_info;
223 vec![UsageEntry {
224 entry_type: "requests".to_string(),
225 limited: limit_info.is_limited(),
226 utilization: limit_info.utilization(),
227 resets_at: Self::reset_time_from_seconds(limit_info.reset_in_seconds),
228 }]
229 }
230 Some("kiro") => {
231 let info = crate::kiro::KiroClient::fetch_usage().await?;
232 vec![UsageEntry {
233 entry_type: "requests".to_string(),
234 limited: info.is_limited(),
235 utilization: info.utilization(),
236 resets_at: Self::reset_time_from_seconds(info.reset_in_seconds),
237 }]
238 }
239 Some("opencode-go") => self
240 .opencode_go_usage_snapshot()?
241 .windows
242 .into_iter()
243 .map(|window| UsageEntry {
244 entry_type: window.entry_type.to_string(),
245 limited: window.is_limited(),
246 utilization: window.utilization(),
247 resets_at: window.resets_at,
248 })
249 .collect(),
250 Some(p) => return Err(format!("Unknown provider: {p}").into()),
251 };
252 Ok(AgentStatus {
253 command,
254 provider,
255 usage,
256 })
257 }
258
259 async fn check_claude_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
260 let usage = crate::claude::ClaudeClient::fetch_usage(&self.cookies).await?;
261 let windows = usage.all_windows();
262
263 let (has_limited, reset_time) =
264 windows
265 .iter()
266 .fold((false, None), |(has_lim, max_t), (_, w)| {
267 if w.is_limited() {
268 (true, max_t.max(w.resets_at))
269 } else {
270 (has_lim, max_t)
271 }
272 });
273
274 if has_limited {
275 Ok(AgentLimit::Limited { reset_time })
276 } else {
277 Ok(AgentLimit::NotLimited)
278 }
279 }
280
281 async fn check_copilot_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
282 let quota = crate::copilot::CopilotClient::fetch_quota(&self.cookies).await?;
283
284 if quota.is_limited() {
285 Ok(AgentLimit::Limited {
286 reset_time: quota.reset_time,
287 })
288 } else {
289 Ok(AgentLimit::NotLimited)
290 }
291 }
292
293 fn openrouter_management_key(&self) -> Result<&str, Box<dyn std::error::Error>> {
294 self.config
295 .openrouter_management_key
296 .as_deref()
297 .ok_or_else(|| {
298 "openrouter_management_key is required for OpenRouter provider"
299 .to_string()
300 .into()
301 })
302 }
303
304 async fn check_openrouter_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
305 let management_key = self.openrouter_management_key()?;
306 let credits = crate::openrouter::OpenRouterClient::fetch_credits(management_key).await?;
307 if credits.data.is_limited() {
308 Ok(AgentLimit::Limited { reset_time: None })
309 } else {
310 Ok(AgentLimit::NotLimited)
311 }
312 }
313
314 fn glm_api_key(&self) -> Result<&str, Box<dyn std::error::Error>> {
315 self.config.glm_api_key.as_deref().ok_or_else(|| {
316 "glm_api_key is required for GLM provider"
317 .to_string()
318 .into()
319 })
320 }
321
322 async fn check_glm_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
323 let api_key = self.glm_api_key()?;
324 let quota = crate::glm::GlmClient::fetch_quota(api_key).await?;
325 match quota.data {
326 Some(data) if data.is_limited() => {
327 let reset_time = data
328 .limits
329 .iter()
330 .filter_map(|l| l.next_reset_time)
331 .filter_map(DateTime::from_timestamp_millis)
332 .max();
333 Ok(AgentLimit::Limited { reset_time })
334 }
335 _ => Ok(AgentLimit::NotLimited),
336 }
337 }
338
339 fn reset_time_from_seconds(secs: Option<i64>) -> Option<DateTime<Utc>> {
340 secs.and_then(|s| Utc::now().checked_add_signed(chrono::Duration::seconds(s)))
341 }
342
343 fn resolve_env_key(&self, key: &str) -> Result<String, Box<dyn std::error::Error>> {
344 if let Some(env) = &self.config.env
346 && let Some(val) = env.get(key)
347 {
348 return Ok(val.clone());
349 }
350 if let Ok(val) = std::env::var(key) {
352 return Ok(val);
353 }
354 Err(format!("{key} is required for this provider").into())
355 }
356
357 fn resolve_optional_env(&self, key: &str) -> Option<String> {
358 self.config
359 .env
360 .as_ref()
361 .and_then(|env| env.get(key).cloned())
362 .or_else(|| std::env::var(key).ok())
363 }
364
365 async fn check_zai_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
366 let api_key = self.resolve_env_key("Z_AI_API_KEY")?;
367 let quota_url = self.resolve_optional_env("Z_AI_QUOTA_URL");
368 let quota = crate::zai::ZaiClient::fetch_quota(&api_key, quota_url.as_deref()).await?;
369 match quota.data {
370 Some(data) if data.is_limited() => {
371 let reset_time = data
372 .limits
373 .iter()
374 .filter_map(|l| l.next_reset_time)
375 .filter_map(DateTime::from_timestamp_millis)
376 .max();
377 Ok(AgentLimit::Limited { reset_time })
378 }
379 _ => Ok(AgentLimit::NotLimited),
380 }
381 }
382
383 async fn check_kimik2_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
384 let api_key = self.resolve_env_key("KIMI_K2_API_KEY")?;
385 let credits = crate::kimik2::KimiK2Client::fetch_credits(&api_key).await?;
386 if credits.is_limited() {
387 Ok(AgentLimit::Limited { reset_time: None })
388 } else {
389 Ok(AgentLimit::NotLimited)
390 }
391 }
392
393 async fn check_warp_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
394 let api_key = self.resolve_env_key("WARP_API_KEY")?;
395 let info = crate::warp::WarpClient::fetch_limit_info(&api_key).await?;
396 let limit_info = &info.data.get_request_limit_info;
397 if limit_info.is_limited() {
398 Ok(AgentLimit::Limited {
399 reset_time: Self::reset_time_from_seconds(limit_info.reset_in_seconds),
400 })
401 } else {
402 Ok(AgentLimit::NotLimited)
403 }
404 }
405
406 async fn check_kiro_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
407 let info = crate::kiro::KiroClient::fetch_usage().await?;
408 if info.is_limited() {
409 Ok(AgentLimit::Limited {
410 reset_time: Self::reset_time_from_seconds(info.reset_in_seconds),
411 })
412 } else {
413 Ok(AgentLimit::NotLimited)
414 }
415 }
416
417 fn check_opencode_go_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
418 let snapshot = self.opencode_go_usage_snapshot()?;
419 if snapshot
420 .windows
421 .iter()
422 .any(crate::opencode_go::OpencodeGoUsageWindow::is_limited)
423 {
424 Ok(AgentLimit::Limited {
425 reset_time: snapshot.reset_time(),
426 })
427 } else {
428 Ok(AgentLimit::NotLimited)
429 }
430 }
431
432 async fn check_codex_limit(&self) -> Result<AgentLimit, Box<dyn std::error::Error>> {
433 let usage = crate::codex::CodexClient::fetch_usage(&self.cookies).await?;
434
435 if usage.rate_limit.is_limited() {
436 Ok(AgentLimit::Limited {
437 reset_time: usage.rate_limit.next_reset_time(),
438 })
439 } else {
440 Ok(AgentLimit::NotLimited)
441 }
442 }
443
444 pub fn execute(
448 &self,
449 resolved_args: &[String],
450 extra_args: &[String],
451 ) -> std::io::Result<std::process::ExitStatus> {
452 if let Some((cmd, args)) = self.config.pre_command.split_first() {
453 let mut pre_cmd = std::process::Command::new(cmd);
454 pre_cmd.args(args);
455 if let Some(env) = &self.config.env {
456 pre_cmd.envs(env);
457 }
458 let status = pre_cmd.status()?;
459 if !status.success() {
460 return Ok(status);
461 }
462 }
463 let mut cmd = std::process::Command::new(self.command());
464 cmd.args(resolved_args);
465 cmd.args(extra_args);
466 if let Some(env) = &self.config.env {
467 cmd.envs(env);
468 }
469 cmd.status()
470 }
471
472 #[must_use]
473 pub fn has_model(&self, model_key: &str) -> bool {
474 self.config.has_model(model_key)
475 }
476
477 #[must_use]
478 pub fn resolved_args(&self, model: Option<&str>) -> Vec<String> {
479 const MODEL_PLACEHOLDER: &str = "{model}";
480 let mut args: Vec<String> = self
481 .config
482 .args
483 .iter()
484 .filter_map(|arg| {
485 if arg.contains(MODEL_PLACEHOLDER) {
486 let model_key = model?;
487 let replacement = self
488 .config
489 .models
490 .as_ref()
491 .and_then(|m| m.get(model_key))
492 .map_or(model_key, |s| s.as_str());
493 Some(arg.replace(MODEL_PLACEHOLDER, replacement))
494 } else {
495 Some(arg.clone())
496 }
497 })
498 .collect();
499
500 if self.config.models.is_none()
502 && let Some(model_key) = model
503 {
504 args.push("--model".to_string());
505 args.push(model_key.to_string());
506 }
507
508 args
509 }
510
511 #[must_use]
512 pub fn mapped_args(&self, args: &[String]) -> Vec<String> {
513 args.iter()
514 .flat_map(|arg| {
515 self.config
516 .arg_maps
517 .get(arg.as_str())
518 .map_or_else(|| std::slice::from_ref(arg), Vec::as_slice)
519 })
520 .cloned()
521 .collect()
522 }
523
524 fn opencode_go_usage_snapshot(
525 &self,
526 ) -> Result<crate::opencode_go::OpencodeGoUsageSnapshot, Box<dyn std::error::Error>> {
527 let db_path = self.resolve_optional_env("SEHER_OPENCODE_DB_PATH");
528 let auth_path = self.resolve_optional_env("SEHER_OPENCODE_AUTH_PATH");
529 Ok(
530 crate::opencode_go::OpencodeGoUsageStore::fetch_usage_with_paths_at(
531 db_path.as_deref().map(std::path::Path::new),
532 auth_path.as_deref().map(std::path::Path::new),
533 Utc::now(),
534 )?,
535 )
536 }
537}
538
539#[cfg(test)]
540mod tests {
541 use std::collections::HashMap;
542
543 use super::*;
544 use crate::codex::{CodexRateLimit, CodexWindow};
545 use crate::config::AgentConfig;
546
547 fn make_agent(
548 models: Option<HashMap<String, String>>,
549 arg_maps: HashMap<String, Vec<String>>,
550 ) -> Agent {
551 Agent::new(
552 AgentConfig {
553 command: "claude".to_string(),
554 args: vec![],
555 models,
556 arg_maps,
557 env: None,
558 provider: None,
559 openrouter_management_key: None,
560 glm_api_key: None,
561 pre_command: vec![],
562 },
563 vec![],
564 )
565 }
566
567 #[test]
568 fn has_model_returns_true_when_models_is_none() {
569 let agent = make_agent(None, HashMap::new());
570 assert!(agent.has_model("high"));
571 assert!(agent.has_model("anything"));
572 }
573
574 #[test]
575 fn resolved_args_passthrough_when_models_is_none_with_model() {
576 let agent = make_agent(None, HashMap::new());
577 let args = agent.resolved_args(Some("high"));
578 assert_eq!(args, vec!["--model", "high"]);
579 }
580
581 #[test]
582 fn resolved_args_no_model_flag_when_models_is_none_without_model() {
583 let agent = make_agent(None, HashMap::new());
584 let args = agent.resolved_args(None);
585 assert!(!args.contains(&"--model".to_string()));
586 }
587
588 #[test]
589 fn mapped_args_passthrough_when_arg_maps_is_empty() {
590 let agent = make_agent(None, HashMap::new());
591 let args = vec!["--danger".to_string(), "fix bugs".to_string()];
592
593 assert_eq!(agent.mapped_args(&args), args);
594 }
595
596 #[test]
597 fn mapped_args_replaces_matching_tokens() {
598 let mut arg_maps = HashMap::new();
599 arg_maps.insert("--danger".to_string(), vec!["--yolo".to_string()]);
600 let agent = make_agent(None, arg_maps);
601
602 assert_eq!(
603 agent.mapped_args(&["--danger".to_string(), "fix bugs".to_string()]),
604 vec!["--yolo".to_string(), "fix bugs".to_string()]
605 );
606 }
607
608 #[test]
609 fn mapped_args_can_expand_to_multiple_tokens() {
610 let mut arg_maps = HashMap::new();
611 arg_maps.insert(
612 "--danger".to_string(),
613 vec![
614 "--permission-mode".to_string(),
615 "bypassPermissions".to_string(),
616 ],
617 );
618 let agent = make_agent(None, arg_maps);
619
620 assert_eq!(
621 agent.mapped_args(&["--danger".to_string(), "fix bugs".to_string()]),
622 vec![
623 "--permission-mode".to_string(),
624 "bypassPermissions".to_string(),
625 "fix bugs".to_string(),
626 ]
627 );
628 }
629
630 #[test]
631 fn codex_usage_entries_marks_blocking_window_when_only_top_level_limit_is_set() {
632 let limit = CodexRateLimit {
633 allowed: false,
634 limit_reached: false,
635 primary_window: Some(CodexWindow {
636 used_percent: 55.0,
637 limit_window_seconds: 60,
638 reset_after_seconds: 30,
639 reset_at: 100,
640 }),
641 secondary_window: Some(CodexWindow {
642 used_percent: 40.0,
643 limit_window_seconds: 120,
644 reset_after_seconds: 90,
645 reset_at: 200,
646 }),
647 };
648
649 let entries = codex_usage_entries("rate_limit", &limit);
650
651 assert_eq!(entries.len(), 2);
652 assert_eq!(entries[0].entry_type, "rate_limit_primary");
653 assert!(!entries[0].limited);
654 assert_eq!(entries[1].entry_type, "rate_limit_secondary");
655 assert!(entries[1].limited);
656 }
657
658 #[test]
659 fn codex_usage_entries_adds_summary_when_limit_has_no_windows() {
660 let limit = CodexRateLimit {
661 allowed: false,
662 limit_reached: true,
663 primary_window: None,
664 secondary_window: None,
665 };
666
667 let entries = codex_usage_entries("code_review_rate_limit", &limit);
668
669 assert_eq!(entries.len(), 1);
670 assert_eq!(entries[0].entry_type, "code_review_rate_limit");
671 assert!(entries[0].limited);
672 assert!((entries[0].utilization - 100.0).abs() < f64::EPSILON);
673 assert_eq!(entries[0].resets_at, None);
674 }
675
676 fn make_openrouter_agent(management_key: Option<&str>) -> Agent {
684 Agent::new(
685 AgentConfig {
686 command: "myai".to_string(),
687 args: vec![],
688 models: None,
689 arg_maps: HashMap::new(),
690 env: None,
691 provider: Some(crate::config::ProviderConfig::Explicit(
692 "openrouter".to_string(),
693 )),
694 openrouter_management_key: management_key.map(str::to_string),
695 glm_api_key: None,
696 pre_command: vec![],
697 },
698 vec![],
699 )
700 }
701
702 fn make_agent_with_pre_command(pre_command: Vec<String>, main_command: &str) -> Agent {
703 Agent::new(
704 AgentConfig {
705 command: main_command.to_string(),
706 args: vec![],
707 models: None,
708 arg_maps: HashMap::new(),
709 env: None,
710 provider: None,
711 openrouter_management_key: None,
712 glm_api_key: None,
713 pre_command,
714 },
715 vec![],
716 )
717 }
718
719 #[test]
720 #[cfg(unix)]
721 fn execute_runs_main_command_when_pre_command_succeeds() -> TestResult {
722 let agent = make_agent_with_pre_command(vec!["true".to_string()], "true");
724 let status = agent.execute(&[], &[])?;
725 assert!(status.success());
726 Ok(())
727 }
728
729 #[test]
730 #[cfg(unix)]
731 fn execute_skips_main_command_when_pre_command_fails() -> TestResult {
732 let agent = make_agent_with_pre_command(vec!["false".to_string()], "true");
734 let status = agent.execute(&[], &[])?;
735 assert!(!status.success());
736 Ok(())
737 }
738
739 type TestResult = Result<(), Box<dyn std::error::Error>>;
740
741 #[tokio::test(flavor = "current_thread")]
742 async fn check_limit_openrouter_returns_error_when_management_key_is_missing() -> TestResult {
743 let agent = make_openrouter_agent(None);
745
746 let result = agent.check_limit().await;
748
749 let err_msg = result.err().ok_or("expected Err")?.to_string();
751 assert!(err_msg.contains("openrouter_management_key"));
752 Ok(())
753 }
754
755 #[tokio::test(flavor = "current_thread")]
756 async fn fetch_status_openrouter_returns_error_when_management_key_is_missing() -> TestResult {
757 let agent = make_openrouter_agent(None);
759
760 let result = agent.fetch_status().await;
762
763 let err_msg = result.err().ok_or("expected Err")?.to_string();
765 assert!(err_msg.contains("openrouter_management_key"));
766 Ok(())
767 }
768
769 fn make_api_key_agent(provider: &str) -> Agent {
770 Agent::new(
771 AgentConfig {
772 command: "myai".to_string(),
773 args: vec![],
774 models: None,
775 arg_maps: HashMap::new(),
776 env: None,
777 provider: Some(crate::config::ProviderConfig::Explicit(
778 provider.to_string(),
779 )),
780 openrouter_management_key: None,
781 glm_api_key: None,
782 pre_command: vec![],
783 },
784 vec![],
785 )
786 }
787
788 #[tokio::test(flavor = "current_thread")]
791 async fn check_limit_zai_returns_error_when_api_key_is_missing() -> TestResult {
792 let agent = make_api_key_agent("zai");
793 let result = agent.check_limit().await;
794 let err_msg = result.err().ok_or("expected Err")?.to_string();
795 assert!(
796 err_msg.contains("Z_AI_API_KEY"),
797 "error should mention Z_AI_API_KEY, got: {err_msg}"
798 );
799 Ok(())
800 }
801
802 #[tokio::test(flavor = "current_thread")]
803 async fn fetch_status_zai_returns_error_when_api_key_is_missing() -> TestResult {
804 let agent = make_api_key_agent("zai");
805 let result = agent.fetch_status().await;
806 let err_msg = result.err().ok_or("expected Err")?.to_string();
807 assert!(
808 err_msg.contains("Z_AI_API_KEY"),
809 "error should mention Z_AI_API_KEY, got: {err_msg}"
810 );
811 Ok(())
812 }
813
814 #[tokio::test(flavor = "current_thread")]
817 async fn check_limit_kimik2_returns_error_when_api_key_is_missing() -> TestResult {
818 let agent = make_api_key_agent("kimi-k2");
819 let result = agent.check_limit().await;
820 let err_msg = result.err().ok_or("expected Err")?.to_string();
821 assert!(
822 err_msg.contains("KIMI_K2_API_KEY"),
823 "error should mention KIMI_K2_API_KEY, got: {err_msg}"
824 );
825 Ok(())
826 }
827
828 #[tokio::test(flavor = "current_thread")]
829 async fn fetch_status_kimik2_returns_error_when_api_key_is_missing() -> TestResult {
830 let agent = make_api_key_agent("kimi-k2");
831 let result = agent.fetch_status().await;
832 let err_msg = result.err().ok_or("expected Err")?.to_string();
833 assert!(
834 err_msg.contains("KIMI_K2_API_KEY"),
835 "error should mention KIMI_K2_API_KEY, got: {err_msg}"
836 );
837 Ok(())
838 }
839
840 #[tokio::test(flavor = "current_thread")]
843 async fn check_limit_warp_returns_error_when_api_key_is_missing() -> TestResult {
844 let agent = make_api_key_agent("warp");
845 let result = agent.check_limit().await;
846 let err_msg = result.err().ok_or("expected Err")?.to_string();
847 assert!(
848 err_msg.contains("WARP_API_KEY"),
849 "error should mention WARP_API_KEY, got: {err_msg}"
850 );
851 Ok(())
852 }
853
854 #[tokio::test(flavor = "current_thread")]
855 async fn fetch_status_warp_returns_error_when_api_key_is_missing() -> TestResult {
856 let agent = make_api_key_agent("warp");
857 let result = agent.fetch_status().await;
858 let err_msg = result.err().ok_or("expected Err")?.to_string();
859 assert!(
860 err_msg.contains("WARP_API_KEY"),
861 "error should mention WARP_API_KEY, got: {err_msg}"
862 );
863 Ok(())
864 }
865
866 #[tokio::test(flavor = "current_thread")]
869 async fn check_limit_kiro_returns_error_when_command_not_found() -> TestResult {
870 let agent = make_api_key_agent("kiro");
871 let result = agent.check_limit().await;
872 assert!(result.is_err(), "kiro without CLI should return an error");
873 Ok(())
874 }
875
876 #[tokio::test(flavor = "current_thread")]
877 async fn fetch_status_kiro_returns_error_when_command_not_found() -> TestResult {
878 let agent = make_api_key_agent("kiro");
879 let result = agent.fetch_status().await;
880 assert!(result.is_err(), "kiro without CLI should return an error");
881 Ok(())
882 }
883
884 #[tokio::test(flavor = "current_thread")]
885 async fn check_limit_opencode_go_uses_local_history() -> TestResult {
886 let tmp = tempfile::tempdir()?;
887 let db_path = tmp.path().join("opencode.db");
888 let conn = rusqlite::Connection::open(&db_path)?;
889 conn.execute("CREATE TABLE message (data TEXT NOT NULL)", [])?;
890 conn.execute(
891 "INSERT INTO message (data) VALUES (?1)",
892 [r#"{"role":"assistant","providerID":"opencode-go","cost":6.5,"time":{"completed":4102448400000}}"#],
893 )?;
894 conn.execute(
895 "INSERT INTO message (data) VALUES (?1)",
896 [r#"{"role":"assistant","providerID":"opencode-go","cost":6.0,"time":{"completed":4102461000000}}"#],
897 )?;
898 drop(conn);
899
900 let mut agent = make_api_key_agent("opencode-go");
901 agent.config.env = Some(HashMap::from([(
902 "SEHER_OPENCODE_DB_PATH".to_string(),
903 db_path.display().to_string(),
904 )]));
905 let result = agent.check_limit().await?;
906 assert!(matches!(result, AgentLimit::Limited { .. }));
907 Ok(())
908 }
909
910 #[tokio::test(flavor = "current_thread")]
911 async fn fetch_status_opencode_go_returns_usage_windows() -> TestResult {
912 let tmp = tempfile::tempdir()?;
913 let db_path = tmp.path().join("opencode.db");
914 let conn = rusqlite::Connection::open(&db_path)?;
915 conn.execute("CREATE TABLE message (data TEXT NOT NULL)", [])?;
916 conn.execute(
917 "INSERT INTO message (data) VALUES (?1)",
918 [r#"{"role":"assistant","providerID":"opencode-go","cost":2.25,"time":{"completed":4102461000000}}"#],
919 )?;
920 drop(conn);
921
922 let mut agent = make_api_key_agent("opencode-go");
923 agent.config.env = Some(HashMap::from([(
924 "SEHER_OPENCODE_DB_PATH".to_string(),
925 db_path.display().to_string(),
926 )]));
927 let status = agent.fetch_status().await?;
928 assert_eq!(status.provider.as_deref(), Some("opencode-go"));
929 assert_eq!(status.usage.len(), 3);
930 assert!(
931 status
932 .usage
933 .iter()
934 .any(|entry| entry.entry_type == "five_hour_spend")
935 );
936 assert!(
937 status
938 .usage
939 .iter()
940 .any(|entry| entry.entry_type == "weekly_spend")
941 );
942 assert!(
943 status
944 .usage
945 .iter()
946 .any(|entry| entry.entry_type == "monthly_spend")
947 );
948 Ok(())
949 }
950
951 #[tokio::test(flavor = "current_thread")]
954 async fn check_limit_unknown_provider_returns_error() -> TestResult {
955 let agent = make_api_key_agent("nonexistent-provider");
956 let result = agent.check_limit().await;
957 let err_msg = result.err().ok_or("expected Err")?.to_string();
958 assert!(err_msg.contains("Unknown provider"), "got: {err_msg}");
959 Ok(())
960 }
961}