Skip to main content

ironflow_engine/
accounts.rs

1//! Provider Account selection and injection for agent steps.
2//!
3//! [`AccountAwareProvider`] wraps the worker's [`AgentProvider`]. For each
4//! agent invocation on a provider that can inject a Provider Account
5//! credential for the invocation ([`AgentProvider::account_kind_for`]), it:
6//!
7//! 1. lists the candidate accounts of that kind from the store, keeping the
8//!    one named by [`AgentConfig::account_name`] or those tagged with
9//!    [`AgentConfig::account_pool`],
10//! 2. picks one with an [`AccountStrategy`],
11//! 3. reads its credential and runs the invocation under it,
12//! 4. records the usage windows observed during the invocation and, when a
13//!    rate-limited window rejected it, tries the next account (never with
14//!    `account_name`).
15//!
16//! When no targeted account can take the step, it decides between
17//! [`AgentError::CapacityWait`] (the engine puts the run to sleep until the
18//! earliest reset, or 60 seconds when only `max_concurrency` blocks) and
19//! [`AgentError::NoCapacity`] (the wait would exceed the step's
20//! [`AgentConfig::max_capacity_wait`], [`DEFAULT_MAX_CAPACITY_WAIT`] by
21//! default). It never runs the agent in that case.
22//!
23//! With no account of the kind in the store, the invocation runs with the
24//! worker environment; a rate-limit rejection of that token is handled the
25//! same way. A provider that cannot inject an account runs unchanged.
26//!
27//! # Examples
28//!
29//! ```no_run
30//! use std::sync::Arc;
31//! use ironflow_core::account_strategy::Priority;
32//! use ironflow_core::providers::claude::ClaudeCodeProvider;
33//! use ironflow_engine::accounts::AccountAwareProvider;
34//! use ironflow_store::memory::InMemoryStore;
35//!
36//! let provider = AccountAwareProvider::new(
37//!     Arc::new(ClaudeCodeProvider::new()),
38//!     Arc::new(InMemoryStore::new()),
39//! )
40//! .with_strategy(Arc::new(Priority));
41//! # let _ = provider;
42//! ```
43
44use 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
69/// Convert a stored window into the core representation.
70///
71/// # Examples
72///
73/// ```
74/// use chrono::Utc;
75/// use ironflow_engine::accounts::window_from_store;
76/// use ironflow_store::entities::{AccountWindowStatus, ProviderAccountWindow};
77/// use uuid::Uuid;
78///
79/// let window = window_from_store(&ProviderAccountWindow {
80///     account_id: Uuid::now_v7(),
81///     window: "five_hour".to_string(),
82///     utilization: 0.4,
83///     resets_at: None,
84///     status: AccountWindowStatus::Allowed,
85///     model_scope: None,
86///     observed_at: Utc::now(),
87/// });
88/// assert_eq!(window.window, "five_hour");
89/// ```
90pub 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
105/// Convert an observed core window into the store representation.
106///
107/// # Examples
108///
109/// ```
110/// use chrono::Utc;
111/// use ironflow_core::account::{AccountWindow, WindowStatus};
112/// use ironflow_engine::accounts::window_to_store;
113/// use ironflow_store::entities::AccountWindowStatus;
114///
115/// let window = window_to_store(AccountWindow {
116///     window: "seven_day".to_string(),
117///     utilization: 1.0,
118///     resets_at: None,
119///     status: WindowStatus::Rejected,
120///     model_scope: Some("opus".to_string()),
121///     observed_at: Utc::now(),
122/// });
123/// assert_eq!(window.status, AccountWindowStatus::Rejected);
124/// ```
125pub 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
158/// Longest time an agent step waits for provider capacity when neither the
159/// step ([`AgentConfig::max_capacity_wait`]) nor the worker
160/// ([`AccountAwareProvider::with_max_capacity_wait`]) sets one: 6 hours.
161///
162/// # Examples
163///
164/// ```
165/// use std::time::Duration;
166/// use ironflow_engine::accounts::DEFAULT_MAX_CAPACITY_WAIT;
167///
168/// assert_eq!(DEFAULT_MAX_CAPACITY_WAIT, Duration::from_secs(6 * 3600));
169/// ```
170pub const DEFAULT_MAX_CAPACITY_WAIT: Duration = Duration::from_secs(6 * 3600);
171
172/// How long a step waits before trying again when every targeted account is
173/// only blocked by its `max_concurrency`: running steps free no reset time.
174const CONCURRENCY_RETRY: Duration = Duration::from_secs(60);
175
176/// One account tried by a step, logged when the step fails over.
177#[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
187/// Decide between waiting and failing once no targeted account can take the
188/// step.
189///
190/// `since` is when the step first parked on a capacity wait: the whole wait,
191/// across wake-ups, stays within `wait`.
192fn 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
230/// Earliest reset of a rejected window constraining `model`.
231fn 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
242/// Whether the step targets `account`: by name, by pool tag, or any.
243fn 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
253/// An [`AgentProvider`] that runs each invocation under a Provider Account.
254///
255/// See the [module documentation](self).
256pub 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    /// Wrap `inner`, reading accounts from `store`, with the
276    /// [`LeastUtilized`] strategy, the [`ClaudeSubscriptionKind`] kind and a
277    /// capacity wait of [`DEFAULT_MAX_CAPACITY_WAIT`].
278    ///
279    /// # Examples
280    ///
281    /// See the [module documentation](self).
282    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    /// Replace the account selection strategy.
294    ///
295    /// # Examples
296    ///
297    /// See the [module documentation](self).
298    pub fn with_strategy(mut self, strategy: Arc<dyn AccountStrategy>) -> Self {
299        self.strategy = strategy;
300        self
301    }
302
303    /// Longest time a step waits for provider capacity when it sets no
304    /// [`AgentConfig::max_capacity_wait`] of its own.
305    ///
306    /// `Duration::ZERO` fails such steps at once with
307    /// [`AgentError::NoCapacity`].
308    ///
309    /// # Examples
310    ///
311    /// ```no_run
312    /// use std::sync::Arc;
313    /// use std::time::Duration;
314    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
315    /// use ironflow_engine::accounts::AccountAwareProvider;
316    /// use ironflow_store::memory::InMemoryStore;
317    ///
318    /// let provider = AccountAwareProvider::new(
319    ///     Arc::new(ClaudeCodeProvider::new()),
320    ///     Arc::new(InMemoryStore::new()),
321    /// )
322    /// .with_max_capacity_wait(Duration::from_secs(3600));
323    /// # let _ = provider;
324    /// ```
325    pub fn with_max_capacity_wait(mut self, wait: Duration) -> Self {
326        self.max_capacity_wait = wait;
327        self
328    }
329
330    /// Register an additional account kind (or replace one with the same id).
331    ///
332    /// # Examples
333    ///
334    /// ```no_run
335    /// use std::sync::Arc;
336    /// use ironflow_core::account::ClaudeSubscriptionKind;
337    /// use ironflow_core::providers::claude::ClaudeCodeProvider;
338    /// use ironflow_engine::accounts::AccountAwareProvider;
339    /// use ironflow_store::memory::InMemoryStore;
340    ///
341    /// let provider = AccountAwareProvider::new(
342    ///     Arc::new(ClaudeCodeProvider::new()),
343    ///     Arc::new(InMemoryStore::new()),
344    /// )
345    /// .with_kind(Arc::new(ClaudeSubscriptionKind::with_api_base("http://proxy:8080")));
346    /// # let _ = provider;
347    /// ```
348    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        // Accounts already tried by this invocation: each one is tried at most
382        // once, so the failover ends after the last targeted account.
383        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    /// Run the invocation under `account` and record what it observed.
481    ///
482    /// The outer `Err` is a resolution failure (missing credential); a
483    /// rejection by a rate-limited window comes back as
484    /// [`AccountRun::Rejected`].
485    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        // Rate-limit events only appear in stream-json, hence verbose.
517        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    /// Run the invocation with the worker environment credential.
561    ///
562    /// A failure along with a rejected window for the model means the worker
563    /// token is rate limited: the step waits for its reset like a pool whose
564    /// accounts are all limited.
565    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        // Rate-limit events only appear in stream-json, hence verbose.
574        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/// What a run under one account ended with.
607#[allow(clippy::large_enum_variant)]
608enum AccountRun {
609    /// The invocation finished, successfully or not, without a rate-limit
610    /// rejection.
611    Done(Result<AgentOutput, AgentError>),
612    /// A window constraining the model rejected the invocation.
613    Rejected,
614}
615
616/// Whether one of `windows` rejects requests for `model` at `now`.
617fn 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    /// What the test provider does once invoked.
671    #[derive(Clone, Copy)]
672    enum Outcome {
673        Succeed,
674        FailApi(u16),
675        /// Report a rejected window resetting at `resets_at`, then fail with
676        /// a 429, like a rate-limited Claude transport.
677        Reject {
678            resets_at: DateTime<Utc>,
679        },
680        /// Reject the first invocation, succeed afterwards.
681        RejectOnce {
682            resets_at: DateTime<Utc>,
683        },
684    }
685
686    /// A provider that behaves like the Claude transports: it reads the
687    /// injected credential and reports a rate-limit window to the recorder.
688    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        // Verbose is forced so a rate-limit rejection of the worker token is seen.
930        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        // `max_concurrency: 0` is saturated with no running step.
1300        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}