1use std::collections::HashMap;
45use std::fmt;
46use std::sync::Arc;
47use std::time::Duration;
48
49use chrono::{DateTime, TimeDelta, Utc};
50use ironflow_core::account::{
51 AccountKind, AccountSession, AccountWindow, ClaudeSubscriptionKind, RateLimitRecorder,
52 WindowStatus,
53};
54use ironflow_core::account_strategy::{
55 AccountCandidate, AccountStrategy, LeastUtilized, select_account,
56};
57use ironflow_core::error::AgentError;
58use ironflow_core::provider::{
59 AgentConfig, AgentOutput, AgentProvider, InvokeFuture, LogSink, ReleaseFuture,
60};
61use ironflow_store::entities::{
62 AccountWindowStatus, NewAccountWindow, NewProviderAccountObservation, ProviderAccount,
63 ProviderAccountCandidate, ProviderAccountWindow,
64};
65use ironflow_store::store::Store;
66use tracing::{debug, info, warn};
67use uuid::Uuid;
68
69pub fn window_from_store(window: &ProviderAccountWindow) -> AccountWindow {
91 AccountWindow {
92 window: window.window.clone(),
93 utilization: window.utilization,
94 resets_at: window.resets_at,
95 status: match window.status {
96 AccountWindowStatus::Allowed => WindowStatus::Allowed,
97 AccountWindowStatus::AllowedWarning => WindowStatus::AllowedWarning,
98 AccountWindowStatus::Rejected => WindowStatus::Rejected,
99 },
100 model_scope: window.model_scope.clone(),
101 observed_at: window.observed_at,
102 }
103}
104
105pub fn window_to_store(window: AccountWindow) -> NewAccountWindow {
126 NewAccountWindow {
127 window: window.window,
128 utilization: window.utilization,
129 resets_at: window.resets_at,
130 status: match window.status {
131 WindowStatus::Allowed => AccountWindowStatus::Allowed,
132 WindowStatus::AllowedWarning => AccountWindowStatus::AllowedWarning,
133 WindowStatus::Rejected => AccountWindowStatus::Rejected,
134 },
135 model_scope: window.model_scope,
136 observed_at: window.observed_at,
137 }
138}
139
140fn to_core_candidate(candidate: &ProviderAccountCandidate) -> AccountCandidate {
141 AccountCandidate {
142 id: candidate.account.id.to_string(),
143 name: candidate.account.name.clone(),
144 priority: candidate.account.priority,
145 max_concurrency: candidate.account.max_concurrency,
146 running_steps: candidate.running_steps,
147 windows: candidate.windows.iter().map(window_from_store).collect(),
148 }
149}
150
151fn resolution_error(message: String) -> AgentError {
152 AgentError::ProcessFailed {
153 exit_code: -1,
154 stderr: message,
155 }
156}
157
158pub const DEFAULT_MAX_CAPACITY_WAIT: Duration = Duration::from_secs(6 * 3600);
171
172const CONCURRENCY_RETRY: Duration = Duration::from_secs(60);
175
176#[derive(Debug)]
178struct AccountAttempt {
179 account_id: Uuid,
180 outcome: &'static str,
181}
182
183fn to_time_delta(duration: Duration) -> TimeDelta {
184 TimeDelta::from_std(duration).unwrap_or(TimeDelta::MAX)
185}
186
187fn capacity_decision(
193 kind: &str,
194 next_reset: Option<DateTime<Utc>>,
195 concurrency_blocked: bool,
196 wait: Duration,
197 since: Option<DateTime<Utc>>,
198 now: DateTime<Utc>,
199) -> AgentError {
200 let kind = kind.to_string();
201 if wait.is_zero() {
202 return AgentError::NoCapacity { kind, next_reset };
203 }
204 let budget = to_time_delta(wait);
205 let since = since.unwrap_or(now);
206 match next_reset {
207 Some(reset) if reset - since <= budget => AgentError::CapacityWait {
208 kind,
209 wake_at: reset,
210 },
211 Some(_) => AgentError::NoCapacity { kind, next_reset },
212 None if concurrency_blocked => {
213 let wake_at = now + to_time_delta(CONCURRENCY_RETRY);
214 if wake_at - since <= budget {
215 AgentError::CapacityWait { kind, wake_at }
216 } else {
217 AgentError::NoCapacity {
218 kind,
219 next_reset: None,
220 }
221 }
222 }
223 None => AgentError::NoCapacity {
224 kind,
225 next_reset: None,
226 },
227 }
228}
229
230fn earliest_reset<'a>(
232 windows: impl Iterator<Item = &'a AccountWindow>,
233 model: &str,
234 now: DateTime<Utc>,
235) -> Option<DateTime<Utc>> {
236 windows
237 .filter(|w| w.applies_to(model) && w.is_exhausted(now))
238 .filter_map(|w| w.resets_at)
239 .min()
240}
241
242fn is_targeted(config: &AgentConfig, account: &ProviderAccount) -> bool {
244 if let Some(name) = &config.account_name {
245 return &account.name == name;
246 }
247 if let Some(pool) = &config.account_pool {
248 return account.tags.iter().any(|tag| tag == pool);
249 }
250 true
251}
252
253pub struct AccountAwareProvider {
257 inner: Arc<dyn AgentProvider>,
258 store: Arc<dyn Store>,
259 strategy: Arc<dyn AccountStrategy>,
260 kinds: HashMap<&'static str, Arc<dyn AccountKind>>,
261 max_capacity_wait: Duration,
262}
263
264impl fmt::Debug for AccountAwareProvider {
265 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266 f.debug_struct("AccountAwareProvider")
267 .field("strategy", &self.strategy.name())
268 .field("kinds", &self.kinds.keys().collect::<Vec<_>>())
269 .field("max_capacity_wait", &self.max_capacity_wait)
270 .finish_non_exhaustive()
271 }
272}
273
274impl AccountAwareProvider {
275 pub fn new(inner: Arc<dyn AgentProvider>, store: Arc<dyn Store>) -> Self {
283 let claude: Arc<dyn AccountKind> = Arc::new(ClaudeSubscriptionKind::new());
284 Self {
285 inner,
286 store,
287 strategy: Arc::new(LeastUtilized),
288 kinds: HashMap::from([(claude.id(), claude)]),
289 max_capacity_wait: DEFAULT_MAX_CAPACITY_WAIT,
290 }
291 }
292
293 pub fn with_strategy(mut self, strategy: Arc<dyn AccountStrategy>) -> Self {
299 self.strategy = strategy;
300 self
301 }
302
303 pub fn with_max_capacity_wait(mut self, wait: Duration) -> Self {
326 self.max_capacity_wait = wait;
327 self
328 }
329
330 pub fn with_kind(mut self, kind: Arc<dyn AccountKind>) -> Self {
349 self.kinds.insert(kind.id(), kind);
350 self
351 }
352
353 async fn invoke_inner(
354 &self,
355 config: &AgentConfig,
356 sink: Option<Arc<dyn LogSink>>,
357 ) -> Result<AgentOutput, AgentError> {
358 match sink {
359 Some(sink) => self.inner.invoke_with_logs(config, sink).await,
360 None => self.inner.invoke(config).await,
361 }
362 }
363
364 async fn run(
365 &self,
366 config: &AgentConfig,
367 sink: Option<Arc<dyn LogSink>>,
368 ) -> Result<AgentOutput, AgentError> {
369 let Some(kind_id) = self.inner.account_kind_for(config) else {
370 return self.invoke_inner(config, sink).await;
371 };
372 let Some(kind) = self.kinds.get(kind_id).cloned() else {
373 debug!(
374 kind = kind_id,
375 "no account kind registered, using worker environment"
376 );
377 return self.invoke_inner(config, sink).await;
378 };
379 let wait = config.max_capacity_wait.unwrap_or(self.max_capacity_wait);
380
381 let mut attempts: Vec<AccountAttempt> = Vec::new();
384 loop {
385 let candidates = self
386 .store
387 .list_provider_account_candidates(kind_id.to_string())
388 .await
389 .map_err(|e| {
390 resolution_error(format!("provider account resolution failed: {e}"))
391 })?;
392 if candidates.is_empty()
393 && config.account_name.is_none()
394 && config.account_pool.is_none()
395 {
396 debug!(
397 kind = kind_id,
398 "no provider account for kind, using worker environment"
399 );
400 return self.run_with_worker_env(kind_id, config, sink, wait).await;
401 }
402
403 let targeted: Vec<&ProviderAccountCandidate> = candidates
404 .iter()
405 .filter(|c| is_targeted(config, &c.account))
406 .collect();
407 if targeted.is_empty() {
408 if let Some(name) = &config.account_name {
409 return Err(AgentError::AccountNotFound { name: name.clone() });
410 }
411 return Err(AgentError::NoCapacity {
412 kind: kind_id.to_string(),
413 next_reset: None,
414 });
415 }
416
417 let all: Vec<_> = targeted.iter().map(|c| to_core_candidate(c)).collect();
418 let untried: Vec<AccountCandidate> = all
419 .iter()
420 .filter(|c| !attempts.iter().any(|a| a.account_id.to_string() == c.id))
421 .cloned()
422 .collect();
423 let now = Utc::now();
424 let Some(selected) =
425 select_account(self.strategy.as_ref(), &untried, &config.model, now)
426 else {
427 let next_reset = earliest_reset(
428 all.iter().flat_map(|c| c.windows.iter()),
429 &config.model,
430 now,
431 );
432 let concurrency_blocked = all.iter().any(|c| {
433 c.max_concurrency.is_some_and(|max| c.running_steps >= max)
434 && !is_rejected(&c.windows, &config.model, now)
435 });
436 let decision = capacity_decision(
437 kind_id,
438 next_reset,
439 concurrency_blocked,
440 wait,
441 config.capacity_wait_since,
442 now,
443 );
444 info!(
445 kind = kind_id,
446 attempts = ?attempts,
447 decision = %decision,
448 "no targeted provider account has capacity"
449 );
450 return Err(decision);
451 };
452 let account: &ProviderAccount = &targeted
453 .iter()
454 .find(|c| c.account.id.to_string() == selected.id)
455 .ok_or_else(|| resolution_error("selected provider account vanished".to_string()))?
456 .account;
457
458 let result = self
459 .run_with_account(kind.as_ref(), account, config, sink.clone())
460 .await?;
461 match result {
462 AccountRun::Done(result) => return result,
463 AccountRun::Rejected => {
464 let attempt = AccountAttempt {
465 account_id: account.id,
466 outcome: "rate limited",
467 };
468 warn!(
469 account = %account.name,
470 account_id = %attempt.account_id,
471 outcome = attempt.outcome,
472 "provider account rate limited mid-step, trying the next account"
473 );
474 attempts.push(attempt);
475 }
476 }
477 }
478 }
479
480 async fn run_with_account(
486 &self,
487 kind: &dyn AccountKind,
488 account: &ProviderAccount,
489 config: &AgentConfig,
490 sink: Option<Arc<dyn LogSink>>,
491 ) -> Result<AccountRun, AgentError> {
492 let missing = || {
493 resolution_error(format!(
494 "credential of provider account '{}' is missing",
495 account.name
496 ))
497 };
498 let secret = match self.store.get_secret(&account.secret_key).await {
499 Ok(Some(secret)) => secret,
500 Ok(None) => return Err(missing()),
501 Err(e) => {
502 warn!(account = %account.name, error = %e, "failed to read provider account credential");
503 return Err(missing());
504 }
505 };
506
507 info!(
508 account = %account.name,
509 strategy = self.strategy.name(),
510 model = %config.model,
511 effort = ?config.effort,
512 "selected provider account"
513 );
514
515 let recorder = RateLimitRecorder::default();
516 let account_config = config
518 .clone()
519 .verbose(true)
520 .account_session(AccountSession::new(
521 kind.credential(&secret.value),
522 recorder.clone(),
523 ));
524
525 let result = self.invoke_inner(&account_config, sink).await;
526
527 let windows = recorder.take();
528 let now = Utc::now();
529 let rejected = result.is_err() && is_rejected(&windows, &config.model, now);
530 let auth_failed = matches!(
531 result,
532 Err(AgentError::Api {
533 status: Some(401 | 403),
534 ..
535 })
536 );
537 if !windows.is_empty() || auth_failed {
538 let observation = NewProviderAccountObservation {
539 windows: windows.into_iter().map(window_to_store).collect(),
540 auth_failed,
541 };
542 if let Err(e) = self
543 .store
544 .record_provider_account_observation(account.id, observation)
545 .await
546 {
547 warn!(account = %account.name, error = %e, "failed to record provider account usage");
548 }
549 }
550
551 if rejected {
552 return Ok(AccountRun::Rejected);
553 }
554 Ok(AccountRun::Done(result.map(|mut output| {
555 output.account_id = Some(account.id.to_string());
556 output
557 })))
558 }
559
560 async fn run_with_worker_env(
566 &self,
567 kind_id: &str,
568 config: &AgentConfig,
569 sink: Option<Arc<dyn LogSink>>,
570 wait: Duration,
571 ) -> Result<AgentOutput, AgentError> {
572 let recorder = RateLimitRecorder::default();
573 let env_config = config
575 .clone()
576 .verbose(true)
577 .rate_limit_recorder(recorder.clone());
578 let result = self.invoke_inner(&env_config, sink).await;
579 if result.is_ok() {
580 return result;
581 }
582
583 let windows = recorder.take();
584 let now = Utc::now();
585 if !is_rejected(&windows, &config.model, now) {
586 return result;
587 }
588 let next_reset = earliest_reset(windows.iter(), &config.model, now);
589 let decision = capacity_decision(
590 kind_id,
591 next_reset,
592 false,
593 wait,
594 config.capacity_wait_since,
595 now,
596 );
597 info!(
598 kind = kind_id,
599 decision = %decision,
600 "worker environment credential rate limited"
601 );
602 Err(decision)
603 }
604}
605
606#[allow(clippy::large_enum_variant)]
608enum AccountRun {
609 Done(Result<AgentOutput, AgentError>),
612 Rejected,
614}
615
616fn is_rejected(windows: &[AccountWindow], model: &str, now: DateTime<Utc>) -> bool {
618 windows
619 .iter()
620 .any(|w| w.applies_to(model) && w.is_exhausted(now))
621}
622
623impl AgentProvider for AccountAwareProvider {
624 fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a> {
625 Box::pin(self.run(config, None))
626 }
627
628 fn invoke_with_logs<'a>(
629 &'a self,
630 config: &'a AgentConfig,
631 log_sink: Arc<dyn LogSink>,
632 ) -> InvokeFuture<'a> {
633 Box::pin(self.run(config, Some(log_sink)))
634 }
635
636 fn release_run<'a>(&'a self, run_id: &'a str) -> ReleaseFuture<'a> {
637 self.inner.release_run(run_id)
638 }
639
640 fn account_kind(&self) -> Option<&'static str> {
641 self.inner.account_kind()
642 }
643
644 fn account_kind_for(&self, config: &AgentConfig) -> Option<&'static str> {
645 self.inner.account_kind_for(config)
646 }
647}
648
649#[cfg(test)]
650mod tests {
651 use std::sync::Mutex;
652
653 use ironflow_core::account_strategy::Priority;
654 use ironflow_core::providers::router::{ProviderMatcher, ProviderRouter};
655 use ironflow_store::crypto::KeyRing;
656 use ironflow_store::entities::{
657 NewProviderAccount, NewRun, ProviderKind, RunStatus, RunUpdate, TriggerKind,
658 provider_account_secret_key,
659 };
660 use ironflow_store::memory::InMemoryStore;
661 use ironflow_store::provider_account_store::ProviderAccountStore;
662 use ironflow_store::secret_store::SecretStore;
663 use ironflow_store::store::RunStore;
664 use serde_json::json;
665
666 use super::*;
667
668 const TOKEN: &str = "sk-ant-oat01-test-token-abcdefghijklmnopqrstuvwxyz";
669
670 #[derive(Clone, Copy)]
672 enum Outcome {
673 Succeed,
674 FailApi(u16),
675 Reject {
678 resets_at: DateTime<Utc>,
679 },
680 RejectOnce {
682 resets_at: DateTime<Utc>,
683 },
684 }
685
686 struct RecordingProvider {
689 kind: Option<&'static str>,
690 outcome: Outcome,
691 seen: Mutex<Vec<(Option<String>, bool)>>,
692 }
693
694 impl RecordingProvider {
695 fn new(kind: Option<&'static str>, outcome: Outcome) -> Self {
696 Self {
697 kind,
698 outcome,
699 seen: Mutex::new(Vec::new()),
700 }
701 }
702
703 fn seen(&self) -> Vec<(Option<String>, bool)> {
704 self.seen.lock().unwrap().clone()
705 }
706 }
707
708 impl AgentProvider for RecordingProvider {
709 fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a> {
710 Box::pin(async move {
711 let credential = config
712 .account
713 .as_ref()
714 .map(|s| s.credential().expose().to_string());
715 let previous = {
716 let mut seen = self.seen.lock().unwrap();
717 seen.push((credential, config.verbose));
718 seen.len() - 1
719 };
720 let rejection = match self.outcome {
721 Outcome::Reject { resets_at } => Some(resets_at),
722 Outcome::RejectOnce { resets_at } if previous == 0 => Some(resets_at),
723 _ => None,
724 };
725 if let Some(resets_at) = rejection {
726 let recorder = config
727 .account
728 .as_ref()
729 .map(AccountSession::recorder)
730 .or(config.rate_limits.as_ref())
731 .expect("AccountAwareProvider always sets a recorder");
732 recorder.record(AccountWindow {
733 window: "five_hour".to_string(),
734 utilization: 1.0,
735 resets_at: Some(resets_at),
736 status: WindowStatus::Rejected,
737 model_scope: None,
738 observed_at: Utc::now(),
739 });
740 return Err(AgentError::Api {
741 status: Some(429),
742 code: None,
743 message: "rate limited".to_string(),
744 });
745 }
746 if let Some(session) = &config.account {
747 session.recorder().record(AccountWindow {
748 window: "five_hour".to_string(),
749 utilization: 0.42,
750 resets_at: Some(Utc::now() + TimeDelta::hours(2)),
751 status: WindowStatus::Allowed,
752 model_scope: None,
753 observed_at: Utc::now(),
754 });
755 }
756 match self.outcome {
757 Outcome::Succeed | Outcome::Reject { .. } | Outcome::RejectOnce { .. } => {
758 Ok(AgentOutput::new(json!("done")))
759 }
760 Outcome::FailApi(status) => Err(AgentError::Api {
761 status: Some(status),
762 code: None,
763 message: "API Error".to_string(),
764 }),
765 }
766 })
767 }
768
769 fn account_kind(&self) -> Option<&'static str> {
770 self.kind
771 }
772 }
773
774 fn store_with_key() -> InMemoryStore {
775 let mut store = InMemoryStore::new();
776 let spec = format!("1:{}", "aa".repeat(32));
777 store.set_key_ring(KeyRing::from_spec(&spec, Some(1)).unwrap());
778 store
779 }
780
781 async fn add_account(store: &InMemoryStore, name: &str, priority: i32) -> ProviderAccount {
782 add_account_with(store, name, priority, &[], None).await
783 }
784
785 async fn add_account_with(
786 store: &InMemoryStore,
787 name: &str,
788 priority: i32,
789 tags: &[&str],
790 max_concurrency: Option<u32>,
791 ) -> ProviderAccount {
792 let id = Uuid::now_v7();
793 let secret_key = provider_account_secret_key(id);
794 store
795 .set_secret(&secret_key, &format!("{TOKEN}-{name}"))
796 .await
797 .unwrap();
798 store
799 .create_provider_account(NewProviderAccount {
800 id,
801 name: name.to_string(),
802 display_name: name.to_string(),
803 kind: ClaudeSubscriptionKind::ID.to_string(),
804 secret_key,
805 enabled: true,
806 priority,
807 tags: tags.iter().map(|t| t.to_string()).collect(),
808 max_concurrency,
809 alert_threshold: 0.8,
810 expires_at: Utc::now() + TimeDelta::days(30),
811 plan: None,
812 created_by: None,
813 })
814 .await
815 .unwrap()
816 }
817
818 fn wrap(inner: Arc<RecordingProvider>, store: &Arc<InMemoryStore>) -> AccountAwareProvider {
819 let store: Arc<dyn Store> = store.clone();
820 AccountAwareProvider::new(inner, store)
821 }
822
823 #[tokio::test]
824 async fn account_aware_provider_injects_selected_account() {
825 let store = Arc::new(store_with_key());
826 add_account(&store, "busy", 10).await;
827 let busy = store
828 .find_provider_account_by_name("busy")
829 .await
830 .unwrap()
831 .unwrap();
832 store
833 .record_provider_account_observation(
834 busy.id,
835 NewProviderAccountObservation {
836 windows: vec![NewAccountWindow {
837 window: "five_hour".to_string(),
838 utilization: 0.9,
839 resets_at: Some(Utc::now() + TimeDelta::hours(1)),
840 status: AccountWindowStatus::Allowed,
841 model_scope: None,
842 observed_at: Utc::now(),
843 }],
844 auth_failed: false,
845 },
846 )
847 .await
848 .unwrap();
849 add_account(&store, "idle", 20).await;
850
851 let inner = Arc::new(RecordingProvider::new(
852 Some(ClaudeSubscriptionKind::ID),
853 Outcome::Succeed,
854 ));
855 let provider = wrap(inner.clone(), &store);
856 provider.invoke(&AgentConfig::new("hello")).await.unwrap();
857
858 let seen = inner.seen();
859 assert_eq!(seen.len(), 1);
860 assert_eq!(seen[0].0.as_deref(), Some(format!("{TOKEN}-idle").as_str()));
861 assert!(seen[0].1, "verbose must be forced for rate_limit_event");
862 }
863
864 #[tokio::test]
865 async fn account_is_injected_behind_a_router() {
866 let store = Arc::new(store_with_key());
867 let account = add_account(&store, "perso", 10).await;
868 let claude = Arc::new(RecordingProvider::new(
869 Some(ClaudeSubscriptionKind::ID),
870 Outcome::Succeed,
871 ));
872 let router = ProviderRouter::new(claude.clone());
873 let dyn_store: Arc<dyn Store> = store.clone();
874 let provider = AccountAwareProvider::new(Arc::new(router), dyn_store);
875
876 let output = provider
877 .invoke(&AgentConfig::new("p").model("sonnet"))
878 .await
879 .unwrap();
880
881 assert_eq!(output.account_id, Some(account.id.to_string()));
882 let seen = claude.seen();
883 assert_eq!(seen.len(), 1);
884 assert_eq!(
885 seen[0].0.as_deref(),
886 Some(format!("{TOKEN}-perso").as_str())
887 );
888 }
889
890 #[tokio::test]
891 async fn mixed_router_injects_an_account_only_on_claude_routes() {
892 let store = Arc::new(store_with_key());
893 let account = add_account(&store, "perso", 10).await;
894 let claude = Arc::new(RecordingProvider::new(
895 Some(ClaudeSubscriptionKind::ID),
896 Outcome::Succeed,
897 ));
898 let http = Arc::new(RecordingProvider::new(None, Outcome::Succeed));
899 let router = ProviderRouter::new(claude.clone())
900 .route(ProviderMatcher::ModelPrefix("gpt-".into()), http.clone());
901 let dyn_store: Arc<dyn Store> = store.clone();
902 let provider = AccountAwareProvider::new(Arc::new(router), dyn_store);
903
904 let claude_output = provider
905 .invoke(&AgentConfig::new("p").model("sonnet"))
906 .await
907 .unwrap();
908 assert_eq!(claude_output.account_id, Some(account.id.to_string()));
909 assert!(claude.seen()[0].0.is_some());
910
911 let http_output = provider
912 .invoke(&AgentConfig::new("p").model("gpt-5"))
913 .await
914 .unwrap();
915 assert_eq!(http_output.account_id, None);
916 assert_eq!(http.seen(), vec![(None, false)]);
917 }
918
919 #[tokio::test]
920 async fn account_aware_provider_passthrough_without_accounts() {
921 let store = Arc::new(store_with_key());
922 let inner = Arc::new(RecordingProvider::new(
923 Some(ClaudeSubscriptionKind::ID),
924 Outcome::Succeed,
925 ));
926 let provider = wrap(inner.clone(), &store);
927 let output = provider.invoke(&AgentConfig::new("hello")).await.unwrap();
928 assert_eq!(output.account_id, None);
929 assert_eq!(inner.seen(), vec![(None, true)]);
931 }
932
933 #[tokio::test]
934 async fn account_aware_provider_passthrough_for_kindless_provider() {
935 let store = Arc::new(store_with_key());
936 add_account(&store, "perso", 10).await;
937 let inner = Arc::new(RecordingProvider::new(None, Outcome::Succeed));
938 let provider = wrap(inner.clone(), &store);
939 let output = provider.invoke(&AgentConfig::new("hello")).await.unwrap();
940 assert_eq!(output.account_id, None);
941 assert_eq!(inner.seen(), vec![(None, false)]);
942 assert_eq!(provider.account_kind(), None);
943 }
944
945 #[tokio::test]
946 async fn account_aware_provider_records_windows_on_error() {
947 let store = Arc::new(store_with_key());
948 let account = add_account(&store, "perso", 10).await;
949 let inner = Arc::new(RecordingProvider::new(
950 Some(ClaudeSubscriptionKind::ID),
951 Outcome::FailApi(500),
952 ));
953 let provider = wrap(inner, &store);
954 let err = provider
955 .invoke(&AgentConfig::new("hello"))
956 .await
957 .unwrap_err();
958 assert!(matches!(
959 err,
960 AgentError::Api {
961 status: Some(500),
962 ..
963 }
964 ));
965
966 let windows = store
967 .list_provider_account_windows(vec![account.id])
968 .await
969 .unwrap();
970 assert_eq!(windows.len(), 1);
971 assert_eq!(windows[0].window, "five_hour");
972 let stored = store
973 .get_provider_account(account.id)
974 .await
975 .unwrap()
976 .unwrap();
977 assert!(stored.auth_failed_at.is_none());
978 }
979
980 #[tokio::test]
981 async fn account_aware_provider_marks_auth_failed_on_401() {
982 let store = Arc::new(store_with_key());
983 let account = add_account(&store, "perso", 10).await;
984 let inner = Arc::new(RecordingProvider::new(
985 Some(ClaudeSubscriptionKind::ID),
986 Outcome::FailApi(401),
987 ));
988 let provider = wrap(inner, &store);
989 provider
990 .invoke(&AgentConfig::new("hello"))
991 .await
992 .unwrap_err();
993
994 let stored = store
995 .get_provider_account(account.id)
996 .await
997 .unwrap()
998 .unwrap();
999 assert!(stored.auth_failed_at.is_some());
1000 let candidates = store
1001 .list_provider_account_candidates(ClaudeSubscriptionKind::ID.to_string())
1002 .await
1003 .unwrap();
1004 assert!(candidates.is_empty(), "a rejected token is not a candidate");
1005 }
1006
1007 async fn exhaust(store: &InMemoryStore, account: &ProviderAccount, resets_at: DateTime<Utc>) {
1008 store
1009 .record_provider_account_observation(
1010 account.id,
1011 NewProviderAccountObservation {
1012 windows: vec![NewAccountWindow {
1013 window: "five_hour".to_string(),
1014 utilization: 1.0,
1015 resets_at: Some(resets_at),
1016 status: AccountWindowStatus::Rejected,
1017 model_scope: None,
1018 observed_at: Utc::now(),
1019 }],
1020 auth_failed: false,
1021 },
1022 )
1023 .await
1024 .unwrap();
1025 }
1026
1027 fn succeeding() -> Arc<RecordingProvider> {
1028 Arc::new(RecordingProvider::new(
1029 Some(ClaudeSubscriptionKind::ID),
1030 Outcome::Succeed,
1031 ))
1032 }
1033
1034 #[tokio::test]
1035 async fn pool_exhausted_sleeps_until_next_reset() {
1036 let store = Arc::new(store_with_key());
1037 let soon = Utc::now() + TimeDelta::hours(1);
1038 let later = Utc::now() + TimeDelta::hours(2);
1039 let first = add_account(&store, "first", 10).await;
1040 exhaust(&store, &first, later).await;
1041 let second = add_account(&store, "second", 20).await;
1042 exhaust(&store, &second, soon).await;
1043 let inner = succeeding();
1044 let provider = wrap(inner.clone(), &store);
1045
1046 let err = provider
1047 .invoke(&AgentConfig::new("hello"))
1048 .await
1049 .unwrap_err();
1050
1051 let AgentError::CapacityWait { kind, wake_at } = err else {
1052 panic!("expected CapacityWait, got {err}");
1053 };
1054 assert_eq!(kind, ClaudeSubscriptionKind::ID);
1055 assert_eq!(wake_at, soon, "wakes at the earliest reset of the pool");
1056 assert!(inner.seen().is_empty(), "the agent must not run");
1057 }
1058
1059 #[tokio::test]
1060 async fn zero_capacity_wait_fails_fast_with_no_capacity() {
1061 let store = Arc::new(store_with_key());
1062 let reset = Utc::now() + TimeDelta::hours(1);
1063 let account = add_account(&store, "perso", 10).await;
1064 exhaust(&store, &account, reset).await;
1065 let inner = succeeding();
1066 let provider = wrap(inner.clone(), &store);
1067
1068 let err = provider
1069 .invoke(&AgentConfig::new("hello").max_capacity_wait(Duration::ZERO))
1070 .await
1071 .unwrap_err();
1072
1073 let AgentError::NoCapacity { kind, next_reset } = err else {
1074 panic!("expected NoCapacity, got {err}");
1075 };
1076 assert_eq!(kind, ClaudeSubscriptionKind::ID);
1077 assert_eq!(next_reset, Some(reset));
1078 assert!(inner.seen().is_empty(), "the agent must not run");
1079 }
1080
1081 #[tokio::test]
1082 async fn worker_max_capacity_wait_applies_when_the_step_sets_none() {
1083 let store = Arc::new(store_with_key());
1084 let reset = Utc::now() + TimeDelta::hours(1);
1085 let account = add_account(&store, "perso", 10).await;
1086 exhaust(&store, &account, reset).await;
1087 let provider = wrap(succeeding(), &store).with_max_capacity_wait(Duration::ZERO);
1088
1089 let err = provider
1090 .invoke(&AgentConfig::new("hello"))
1091 .await
1092 .unwrap_err();
1093 assert!(matches!(err, AgentError::NoCapacity { .. }), "got {err}");
1094
1095 let err = provider
1096 .invoke(&AgentConfig::new("hello").max_capacity_wait(Duration::from_secs(7200)))
1097 .await
1098 .unwrap_err();
1099 assert!(
1100 matches!(err, AgentError::CapacityWait { .. }),
1101 "the step wait overrides the worker default, got {err}"
1102 );
1103 }
1104
1105 #[tokio::test]
1106 async fn reset_beyond_capacity_wait_fails_with_no_capacity() {
1107 let store = Arc::new(store_with_key());
1108 let reset = Utc::now() + TimeDelta::hours(8);
1109 let account = add_account(&store, "perso", 10).await;
1110 exhaust(&store, &account, reset).await;
1111 let inner = succeeding();
1112 let provider = wrap(inner.clone(), &store);
1113
1114 let err = provider
1115 .invoke(&AgentConfig::new("hello"))
1116 .await
1117 .unwrap_err();
1118
1119 let AgentError::NoCapacity { next_reset, .. } = err else {
1120 panic!("expected NoCapacity, got {err}");
1121 };
1122 assert_eq!(next_reset, Some(reset));
1123 assert!(inner.seen().is_empty(), "the agent must not run");
1124 }
1125
1126 #[tokio::test]
1127 async fn rejection_mid_step_fails_over_to_next_account() {
1128 let store = Arc::new(store_with_key());
1129 let reset = Utc::now() + TimeDelta::hours(1);
1130 let first = add_account(&store, "first", 10).await;
1131 let second = add_account(&store, "second", 20).await;
1132 let inner = Arc::new(RecordingProvider::new(
1133 Some(ClaudeSubscriptionKind::ID),
1134 Outcome::RejectOnce { resets_at: reset },
1135 ));
1136 let provider = wrap(inner.clone(), &store).with_strategy(Arc::new(Priority));
1137
1138 let output = provider.invoke(&AgentConfig::new("hello")).await.unwrap();
1139
1140 assert_eq!(output.account_id, Some(second.id.to_string()));
1141 let credentials: Vec<Option<String>> = inner.seen().into_iter().map(|s| s.0).collect();
1142 assert_eq!(
1143 credentials,
1144 vec![
1145 Some(format!("{TOKEN}-first")),
1146 Some(format!("{TOKEN}-second")),
1147 ]
1148 );
1149 let windows = store
1150 .list_provider_account_windows(vec![first.id])
1151 .await
1152 .unwrap();
1153 assert_eq!(windows.len(), 1);
1154 assert_eq!(windows[0].status, AccountWindowStatus::Rejected);
1155 assert_eq!(windows[0].resets_at, Some(reset));
1156 }
1157
1158 #[tokio::test]
1159 async fn rejection_on_every_account_sleeps_until_next_reset() {
1160 let store = Arc::new(store_with_key());
1161 let reset = Utc::now() + TimeDelta::hours(1);
1162 add_account(&store, "first", 10).await;
1163 add_account(&store, "second", 20).await;
1164 let inner = Arc::new(RecordingProvider::new(
1165 Some(ClaudeSubscriptionKind::ID),
1166 Outcome::Reject { resets_at: reset },
1167 ));
1168 let provider = wrap(inner.clone(), &store);
1169
1170 let err = provider
1171 .invoke(&AgentConfig::new("hello"))
1172 .await
1173 .unwrap_err();
1174
1175 let AgentError::CapacityWait { wake_at, .. } = err else {
1176 panic!("expected CapacityWait, got {err}");
1177 };
1178 assert_eq!(wake_at, reset);
1179 assert_eq!(inner.seen().len(), 2, "each account is tried once");
1180 }
1181
1182 #[tokio::test]
1183 async fn named_account_waits_without_failover() {
1184 let store = Arc::new(store_with_key());
1185 let reset = Utc::now() + TimeDelta::hours(1);
1186 add_account(&store, "first", 10).await;
1187 add_account(&store, "second", 20).await;
1188 let inner = Arc::new(RecordingProvider::new(
1189 Some(ClaudeSubscriptionKind::ID),
1190 Outcome::Reject { resets_at: reset },
1191 ));
1192 let provider = wrap(inner.clone(), &store);
1193
1194 let err = provider
1195 .invoke(&AgentConfig::new("hello").account("second"))
1196 .await
1197 .unwrap_err();
1198
1199 let AgentError::CapacityWait { wake_at, .. } = err else {
1200 panic!("expected CapacityWait, got {err}");
1201 };
1202 assert_eq!(wake_at, reset);
1203 let credentials: Vec<Option<String>> = inner.seen().into_iter().map(|s| s.0).collect();
1204 assert_eq!(credentials, vec![Some(format!("{TOKEN}-second"))]);
1205 }
1206
1207 #[tokio::test]
1208 async fn named_account_runs_under_that_account_only() {
1209 let store = Arc::new(store_with_key());
1210 add_account(&store, "first", 10).await;
1211 let second = add_account(&store, "second", 20).await;
1212 let inner = succeeding();
1213 let provider = wrap(inner.clone(), &store);
1214
1215 let output = provider
1216 .invoke(&AgentConfig::new("hello").account("second"))
1217 .await
1218 .unwrap();
1219
1220 assert_eq!(output.account_id, Some(second.id.to_string()));
1221 }
1222
1223 #[tokio::test]
1224 async fn unknown_account_name_fails_with_account_not_found() {
1225 let store = Arc::new(store_with_key());
1226 add_account(&store, "perso", 10).await;
1227 let inner = succeeding();
1228 let provider = wrap(inner.clone(), &store);
1229
1230 let err = provider
1231 .invoke(&AgentConfig::new("hello").account("missing"))
1232 .await
1233 .unwrap_err();
1234
1235 let AgentError::AccountNotFound { name } = err else {
1236 panic!("expected AccountNotFound, got {err}");
1237 };
1238 assert_eq!(name, "missing");
1239 assert!(inner.seen().is_empty(), "the agent must not run");
1240 }
1241
1242 #[tokio::test]
1243 async fn account_name_without_any_account_never_falls_back_to_worker_env() {
1244 let store = Arc::new(store_with_key());
1245 let inner = succeeding();
1246 let provider = wrap(inner.clone(), &store);
1247
1248 let err = provider
1249 .invoke(&AgentConfig::new("hello").account("perso"))
1250 .await
1251 .unwrap_err();
1252
1253 assert!(
1254 matches!(err, AgentError::AccountNotFound { .. }),
1255 "got {err}"
1256 );
1257 assert!(inner.seen().is_empty(), "the worker token must not be used");
1258 }
1259
1260 #[tokio::test]
1261 async fn account_pool_only_selects_tagged_accounts() {
1262 let store = Arc::new(store_with_key());
1263 add_account_with(&store, "untagged", 1, &[], None).await;
1264 let tagged = add_account_with(&store, "tagged", 50, &["batch"], None).await;
1265 let inner = succeeding();
1266 let provider = wrap(inner.clone(), &store).with_strategy(Arc::new(Priority));
1267
1268 let output = provider
1269 .invoke(&AgentConfig::new("hello").account_pool("batch"))
1270 .await
1271 .unwrap();
1272
1273 assert_eq!(output.account_id, Some(tagged.id.to_string()));
1274 }
1275
1276 #[tokio::test]
1277 async fn unknown_account_pool_fails_with_no_capacity() {
1278 let store = Arc::new(store_with_key());
1279 add_account_with(&store, "tagged", 10, &["batch"], None).await;
1280 let inner = succeeding();
1281 let provider = wrap(inner.clone(), &store);
1282
1283 let err = provider
1284 .invoke(&AgentConfig::new("hello").account_pool("nightly"))
1285 .await
1286 .unwrap_err();
1287
1288 let AgentError::NoCapacity { kind, next_reset } = err else {
1289 panic!("expected NoCapacity, got {err}");
1290 };
1291 assert_eq!(kind, ClaudeSubscriptionKind::ID);
1292 assert_eq!(next_reset, None);
1293 assert!(inner.seen().is_empty(), "the agent must not run");
1294 }
1295
1296 #[tokio::test]
1297 async fn saturated_account_retries_after_a_minute() {
1298 let store = Arc::new(store_with_key());
1299 add_account_with(&store, "busy", 10, &[], Some(0)).await;
1301 let inner = succeeding();
1302 let provider = wrap(inner.clone(), &store);
1303 let before = Utc::now();
1304
1305 let err = provider
1306 .invoke(&AgentConfig::new("hello"))
1307 .await
1308 .unwrap_err();
1309
1310 let AgentError::CapacityWait { wake_at, .. } = err else {
1311 panic!("expected CapacityWait, got {err}");
1312 };
1313 assert!(wake_at >= before + TimeDelta::seconds(60));
1314 assert!(wake_at <= Utc::now() + TimeDelta::seconds(60));
1315 assert!(inner.seen().is_empty(), "the agent must not run");
1316 }
1317
1318 #[tokio::test]
1319 async fn saturated_account_stops_waiting_past_the_cumulative_bound() {
1320 let store = Arc::new(store_with_key());
1321 add_account_with(&store, "busy", 10, &[], Some(0)).await;
1322 let provider = wrap(succeeding(), &store);
1323 let config = AgentConfig::new("hello")
1324 .max_capacity_wait(Duration::from_secs(3600))
1325 .capacity_wait_since(Utc::now() - TimeDelta::minutes(59) - TimeDelta::seconds(30));
1326
1327 let err = provider.invoke(&config).await.unwrap_err();
1328
1329 let AgentError::NoCapacity { next_reset, .. } = err else {
1330 panic!("expected NoCapacity, got {err}");
1331 };
1332 assert_eq!(next_reset, None);
1333 }
1334
1335 #[tokio::test]
1336 async fn worker_env_rejection_sleeps_until_resets_at() {
1337 let store = Arc::new(store_with_key());
1338 let reset = Utc::now() + TimeDelta::hours(1);
1339 let inner = Arc::new(RecordingProvider::new(
1340 Some(ClaudeSubscriptionKind::ID),
1341 Outcome::Reject { resets_at: reset },
1342 ));
1343 let provider = wrap(inner.clone(), &store);
1344
1345 let err = provider
1346 .invoke(&AgentConfig::new("hello"))
1347 .await
1348 .unwrap_err();
1349
1350 let AgentError::CapacityWait { kind, wake_at } = err else {
1351 panic!("expected CapacityWait, got {err}");
1352 };
1353 assert_eq!(kind, ClaudeSubscriptionKind::ID);
1354 assert_eq!(wake_at, reset);
1355 assert_eq!(inner.seen(), vec![(None, true)]);
1356 }
1357
1358 #[tokio::test]
1359 async fn worker_env_failure_without_rejection_is_returned_unchanged() {
1360 let store = Arc::new(store_with_key());
1361 let inner = Arc::new(RecordingProvider::new(
1362 Some(ClaudeSubscriptionKind::ID),
1363 Outcome::FailApi(500),
1364 ));
1365 let provider = wrap(inner, &store);
1366
1367 let err = provider
1368 .invoke(&AgentConfig::new("hello"))
1369 .await
1370 .unwrap_err();
1371
1372 assert!(matches!(
1373 err,
1374 AgentError::Api {
1375 status: Some(500),
1376 ..
1377 }
1378 ));
1379 }
1380
1381 #[tokio::test]
1382 async fn adding_account_wakes_capacity_sleepers() {
1383 let store = store_with_key();
1384 let far = Utc::now() + TimeDelta::hours(3);
1385 let mut runs = Vec::new();
1386 for kind in [ClaudeSubscriptionKind::ID, "other_kind"] {
1387 let run = store
1388 .create_run(NewRun {
1389 created_by: None,
1390 workflow_name: "capacity".to_string(),
1391 trigger: TriggerKind::Manual,
1392 payload: json!({}),
1393 max_retries: 0,
1394 handler_version: None,
1395 labels: HashMap::new(),
1396 scheduled_at: None,
1397 idempotency_key: None,
1398 concurrency_key: None,
1399 priority: 0,
1400 concurrency_limits: Vec::new(),
1401 max_cost_usd: None,
1402 worker_tags: Vec::new(),
1403 })
1404 .await
1405 .unwrap()
1406 .into_run();
1407 store
1408 .update_run_status(run.id, RunStatus::Running)
1409 .await
1410 .unwrap();
1411 store
1412 .update_run(
1413 run.id,
1414 RunUpdate {
1415 status: Some(RunStatus::Sleeping),
1416 scheduled_at: Some(far),
1417 capacity_wait_kind: Some(ProviderKind::from(kind)),
1418 ..RunUpdate::default()
1419 },
1420 )
1421 .await
1422 .unwrap();
1423 runs.push(run.id);
1424 }
1425
1426 add_account(&store, "fresh", 10).await;
1427 let now = Utc::now();
1428
1429 let woken = store.get_run(runs[0]).await.unwrap().unwrap();
1430 assert_eq!(woken.status.state, RunStatus::Sleeping);
1431 assert!(woken.scheduled_at.is_some_and(|at| at <= now));
1432 let untouched = store.get_run(runs[1]).await.unwrap().unwrap();
1433 assert_eq!(untouched.scheduled_at, Some(far));
1434 assert_eq!(
1435 untouched.capacity_wait_kind,
1436 Some(ProviderKind::from("other_kind"))
1437 );
1438 }
1439
1440 #[test]
1441 fn capacity_decision_zero_wait_fails_fast() {
1442 let now = Utc::now();
1443 let reset = now + TimeDelta::minutes(5);
1444 let err = capacity_decision("claude", Some(reset), true, Duration::ZERO, None, now);
1445 assert!(matches!(
1446 err,
1447 AgentError::NoCapacity { next_reset: Some(at), .. } if at == reset
1448 ));
1449 }
1450
1451 #[test]
1452 fn capacity_decision_waits_for_a_reset_within_the_bound() {
1453 let now = Utc::now();
1454 let reset = now + TimeDelta::hours(1);
1455 let err = capacity_decision(
1456 "claude",
1457 Some(reset),
1458 false,
1459 DEFAULT_MAX_CAPACITY_WAIT,
1460 None,
1461 now,
1462 );
1463 assert!(matches!(
1464 err,
1465 AgentError::CapacityWait { wake_at, .. } if wake_at == reset
1466 ));
1467 }
1468
1469 #[test]
1470 fn capacity_decision_counts_the_time_already_waited() {
1471 let now = Utc::now();
1472 let reset = now + TimeDelta::hours(1);
1473 let since = now - TimeDelta::hours(5) - TimeDelta::minutes(30);
1474 let err = capacity_decision(
1475 "claude",
1476 Some(reset),
1477 false,
1478 DEFAULT_MAX_CAPACITY_WAIT,
1479 Some(since),
1480 now,
1481 );
1482 assert!(matches!(
1483 err,
1484 AgentError::NoCapacity { next_reset: Some(at), .. } if at == reset
1485 ));
1486 }
1487
1488 #[test]
1489 fn capacity_decision_fails_for_a_reset_beyond_the_bound() {
1490 let now = Utc::now();
1491 let reset = now + TimeDelta::hours(7);
1492 let err = capacity_decision(
1493 "claude",
1494 Some(reset),
1495 false,
1496 DEFAULT_MAX_CAPACITY_WAIT,
1497 None,
1498 now,
1499 );
1500 assert!(matches!(
1501 err,
1502 AgentError::NoCapacity { next_reset: Some(at), .. } if at == reset
1503 ));
1504 }
1505
1506 #[test]
1507 fn capacity_decision_retries_concurrency_after_a_minute() {
1508 let now = Utc::now();
1509 let err = capacity_decision("claude", None, true, DEFAULT_MAX_CAPACITY_WAIT, None, now);
1510 assert!(matches!(
1511 err,
1512 AgentError::CapacityWait { wake_at, .. } if wake_at == now + TimeDelta::seconds(60)
1513 ));
1514 }
1515
1516 #[test]
1517 fn capacity_decision_bounds_the_concurrency_wait() {
1518 let now = Utc::now();
1519 let wait = Duration::from_secs(600);
1520 let within = capacity_decision(
1521 "claude",
1522 None,
1523 true,
1524 wait,
1525 Some(now - TimeDelta::minutes(9)),
1526 now,
1527 );
1528 assert!(matches!(within, AgentError::CapacityWait { .. }));
1529
1530 let beyond = capacity_decision(
1531 "claude",
1532 None,
1533 true,
1534 wait,
1535 Some(now - TimeDelta::minutes(9) - TimeDelta::seconds(1)),
1536 now,
1537 );
1538 assert!(matches!(
1539 beyond,
1540 AgentError::NoCapacity {
1541 next_reset: None,
1542 ..
1543 }
1544 ));
1545 }
1546
1547 #[test]
1548 fn capacity_decision_without_reset_or_concurrency_fails() {
1549 let now = Utc::now();
1550 let err = capacity_decision("claude", None, false, DEFAULT_MAX_CAPACITY_WAIT, None, now);
1551 assert!(matches!(
1552 err,
1553 AgentError::NoCapacity {
1554 next_reset: None,
1555 ..
1556 }
1557 ));
1558 }
1559
1560 #[tokio::test]
1561 async fn account_aware_provider_fails_when_credential_missing() {
1562 let store = Arc::new(store_with_key());
1563 let account = add_account(&store, "perso", 10).await;
1564 store.delete_secret(&account.secret_key).await.unwrap();
1565 let inner = Arc::new(RecordingProvider::new(
1566 Some(ClaudeSubscriptionKind::ID),
1567 Outcome::Succeed,
1568 ));
1569 let provider = wrap(inner, &store);
1570 let err = provider
1571 .invoke(&AgentConfig::new("hello"))
1572 .await
1573 .unwrap_err();
1574 let message = err.to_string();
1575 assert!(message.contains("credential of provider account 'perso' is missing"));
1576 assert!(!message.contains(TOKEN));
1577 }
1578
1579 #[tokio::test]
1580 async fn account_aware_provider_sets_output_account_id() {
1581 let store = Arc::new(store_with_key());
1582 let account = add_account(&store, "perso", 10).await;
1583 let inner = Arc::new(RecordingProvider::new(
1584 Some(ClaudeSubscriptionKind::ID),
1585 Outcome::Succeed,
1586 ));
1587 let provider = wrap(inner, &store);
1588 let output = provider.invoke(&AgentConfig::new("hello")).await.unwrap();
1589 assert_eq!(output.account_id, Some(account.id.to_string()));
1590
1591 let windows = store
1592 .list_provider_account_windows(vec![account.id])
1593 .await
1594 .unwrap();
1595 assert_eq!(windows.len(), 1);
1596 assert!((windows[0].utilization - 0.42).abs() < 1e-9);
1597 }
1598}