Skip to main content

ironflow_engine/
budget.rs

1//! Cost guardrails: per-run cost cap and global monthly quota.
2//!
3//! `max_budget_usd` on an [`AgentConfig`](ironflow_core::provider::AgentConfig)
4//! only caps a single agent invocation. A workflow with a loop, a dynamic
5//! `ctx.parallel()`, or a chain of sub-workflows can chain dozens of agent
6//! calls with no cumulative bound. [`BudgetConfig`] closes that gap:
7//!
8//! - **Per-run cap** -- checked before every agent step. Crossing it cancels
9//!   the run *before* the step is launched, so no spend happens.
10//! - **Monthly quota** -- checked when a new run is created. Crossing it
11//!   refuses the creation; runs already in flight are untouched.
12//! - **Per-role cap** -- an `AgentRole` with a cost cap is bounded
13//!   over a calendar period across every run of every workflow: see
14//!   [`crate::role_cost`].
15//!
16//! # Examples
17//!
18//! ```
19//! use ironflow_engine::budget::BudgetConfig;
20//! use rust_decimal::Decimal;
21//!
22//! let config = BudgetConfig::new()
23//!     .default_run_max_cost_usd(Decimal::new(500, 2))
24//!     .monthly_cost_limit_usd(Decimal::new(20000, 2));
25//!
26//! assert_eq!(config.default_run_max_cost_usd, Some(Decimal::new(500, 2)));
27//! ```
28
29use std::env;
30use std::str::FromStr;
31
32use chrono::{DateTime, Utc};
33use ironflow_core::agent_role::CostPeriod;
34use rust_decimal::Decimal;
35use rust_decimal::prelude::FromPrimitive;
36use tracing::warn;
37
38/// Environment variable holding the server-wide default per-run cost cap.
39pub const DEFAULT_RUN_MAX_COST_ENV: &str = "IRONFLOW_DEFAULT_RUN_MAX_COST_USD";
40
41/// Environment variable holding the global monthly cost quota.
42pub const MONTHLY_COST_LIMIT_ENV: &str = "IRONFLOW_MONTHLY_COST_LIMIT_USD";
43
44/// Server-level cost guardrails.
45///
46/// Both fields default to `None`, which disables the corresponding check and
47/// preserves the pre-existing behaviour exactly.
48///
49/// # Examples
50///
51/// ```
52/// use ironflow_engine::budget::BudgetConfig;
53///
54/// let config = BudgetConfig::new();
55/// assert!(config.default_run_max_cost_usd.is_none());
56/// assert!(config.monthly_cost_limit_usd.is_none());
57/// ```
58#[derive(Debug, Clone, Default, PartialEq, Eq)]
59pub struct BudgetConfig {
60    /// Default cost cap applied to a run when neither the creation request nor
61    /// the handler declares one. `None` means no default cap.
62    pub default_run_max_cost_usd: Option<Decimal>,
63    /// Global quota for the current calendar month (UTC). `None` disables the
64    /// monthly check.
65    pub monthly_cost_limit_usd: Option<Decimal>,
66}
67
68impl BudgetConfig {
69    /// Create a configuration with both guardrails disabled.
70    ///
71    /// # Examples
72    ///
73    /// ```
74    /// use ironflow_engine::budget::BudgetConfig;
75    ///
76    /// assert_eq!(BudgetConfig::new(), BudgetConfig::default());
77    /// ```
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Set the server-wide default per-run cost cap.
83    ///
84    /// # Examples
85    ///
86    /// ```
87    /// use ironflow_engine::budget::BudgetConfig;
88    /// use rust_decimal::Decimal;
89    ///
90    /// let config = BudgetConfig::new().default_run_max_cost_usd(Decimal::new(150, 2));
91    /// assert_eq!(config.default_run_max_cost_usd, Some(Decimal::new(150, 2)));
92    /// ```
93    pub fn default_run_max_cost_usd(mut self, cap: Decimal) -> Self {
94        self.default_run_max_cost_usd = Some(cap);
95        self
96    }
97
98    /// Set the global monthly cost quota.
99    ///
100    /// # Examples
101    ///
102    /// ```
103    /// use ironflow_engine::budget::BudgetConfig;
104    /// use rust_decimal::Decimal;
105    ///
106    /// let config = BudgetConfig::new().monthly_cost_limit_usd(Decimal::new(10000, 2));
107    /// assert_eq!(config.monthly_cost_limit_usd, Some(Decimal::new(10000, 2)));
108    /// ```
109    pub fn monthly_cost_limit_usd(mut self, limit: Decimal) -> Self {
110        self.monthly_cost_limit_usd = Some(limit);
111        self
112    }
113
114    /// Load the configuration from the environment.
115    ///
116    /// Reads [`DEFAULT_RUN_MAX_COST_ENV`] and [`MONTHLY_COST_LIMIT_ENV`]. A
117    /// variable that is unset, empty, unparseable, or negative is ignored (the
118    /// corresponding guardrail stays disabled) and logged at `WARN`. Loading
119    /// never fails, so a malformed value can never prevent the server from
120    /// starting.
121    ///
122    /// # Examples
123    ///
124    /// ```
125    /// use ironflow_engine::budget::BudgetConfig;
126    ///
127    /// let config = BudgetConfig::from_env();
128    /// # let _ = config;
129    /// ```
130    pub fn from_env() -> Self {
131        Self {
132            default_run_max_cost_usd: read_decimal_env(DEFAULT_RUN_MAX_COST_ENV),
133            monthly_cost_limit_usd: read_decimal_env(MONTHLY_COST_LIMIT_ENV),
134        }
135    }
136
137    /// Resolve the cost cap of a run being created.
138    ///
139    /// Priority, strongest first: the value supplied at creation time, then the
140    /// handler default, then the server default. `None` at every level means
141    /// the run has no cap.
142    ///
143    /// # Examples
144    ///
145    /// ```
146    /// use ironflow_engine::budget::BudgetConfig;
147    /// use rust_decimal::Decimal;
148    ///
149    /// let server = Decimal::new(100, 2);
150    /// let handler = Decimal::new(200, 2);
151    /// let requested = Decimal::new(300, 2);
152    /// let config = BudgetConfig::new().default_run_max_cost_usd(server);
153    ///
154    /// assert_eq!(config.resolve_run_cap(Some(requested), Some(handler)), Some(requested));
155    /// assert_eq!(config.resolve_run_cap(None, Some(handler)), Some(handler));
156    /// assert_eq!(config.resolve_run_cap(None, None), Some(server));
157    /// ```
158    pub fn resolve_run_cap(
159        &self,
160        requested: Option<Decimal>,
161        handler_default: Option<Decimal>,
162    ) -> Option<Decimal> {
163        requested
164            .or(handler_default)
165            .or(self.default_run_max_cost_usd)
166    }
167}
168
169/// Read a non-negative [`Decimal`] from an environment variable.
170///
171/// Returns `None` when the variable is unset, empty, unparseable, or negative.
172fn read_decimal_env(name: &str) -> Option<Decimal> {
173    let raw = env::var(name).ok()?;
174    let trimmed = raw.trim();
175    if trimmed.is_empty() {
176        return None;
177    }
178
179    match Decimal::from_str(trimmed) {
180        Ok(value) if value >= Decimal::ZERO => Some(value),
181        Ok(value) => {
182            warn!(env = name, value = %value, "ignoring negative budget limit");
183            None
184        }
185        Err(e) => {
186            warn!(env = name, value = trimmed, error = %e, "ignoring unparseable budget limit");
187            None
188        }
189    }
190}
191
192/// Convert an agent step budget expressed as `f64` into a [`Decimal`].
193///
194/// A missing budget counts as zero: the run cap check then reduces to
195/// "has the run already spent more than its cap?". A `NaN` or infinite value
196/// also counts as zero rather than poisoning the comparison.
197///
198/// # Examples
199///
200/// ```
201/// use ironflow_engine::budget::step_budget_usd;
202/// use rust_decimal::Decimal;
203///
204/// assert_eq!(step_budget_usd(Some(0.25)), Decimal::new(25, 2));
205/// assert_eq!(step_budget_usd(None), Decimal::ZERO);
206/// assert_eq!(step_budget_usd(Some(f64::NAN)), Decimal::ZERO);
207/// ```
208pub fn step_budget_usd(max_budget_usd: Option<f64>) -> Decimal {
209    max_budget_usd
210        .and_then(Decimal::from_f64)
211        .unwrap_or(Decimal::ZERO)
212}
213
214/// Start of the current calendar month, at 00:00:00 UTC.
215///
216/// Used as the lower bound of the monthly quota window. Same window as a
217/// per-month agent role cap ([`CostPeriod::Month`]), so both reset together.
218///
219/// # Examples
220///
221/// ```
222/// use chrono::{Datelike, TimeZone, Timelike, Utc};
223/// use ironflow_engine::budget::month_start;
224///
225/// let start = month_start(Utc.with_ymd_and_hms(2026, 7, 26, 15, 30, 0).unwrap());
226/// assert_eq!(start.year(), 2026);
227/// assert_eq!(start.month(), 7);
228/// assert_eq!(start.day(), 1);
229/// assert_eq!(start.hour(), 0);
230/// ```
231///
232/// # Panics
233///
234/// Never panics for a valid [`DateTime<Utc>`]: day 1 at midnight always exists
235/// for any year/month pair reachable from an existing timestamp.
236pub fn month_start(now: DateTime<Utc>) -> DateTime<Utc> {
237    CostPeriod::Month.start(now)
238}
239
240#[cfg(test)]
241mod tests {
242    use chrono::{Datelike, TimeZone, Timelike};
243
244    use super::*;
245
246    #[test]
247    fn new_disables_both_guardrails() {
248        let config = BudgetConfig::new();
249        assert!(config.default_run_max_cost_usd.is_none());
250        assert!(config.monthly_cost_limit_usd.is_none());
251    }
252
253    #[test]
254    fn resolve_run_cap_prefers_request_then_handler_then_server() {
255        let config = BudgetConfig::new().default_run_max_cost_usd(Decimal::ONE);
256
257        assert_eq!(
258            config.resolve_run_cap(Some(Decimal::TEN), Some(Decimal::TWO)),
259            Some(Decimal::TEN)
260        );
261        assert_eq!(
262            config.resolve_run_cap(None, Some(Decimal::TWO)),
263            Some(Decimal::TWO)
264        );
265        assert_eq!(config.resolve_run_cap(None, None), Some(Decimal::ONE));
266    }
267
268    #[test]
269    fn resolve_run_cap_without_server_default_is_none() {
270        let config = BudgetConfig::new();
271        assert_eq!(config.resolve_run_cap(None, None), None);
272    }
273
274    #[test]
275    fn resolve_run_cap_accepts_explicit_zero() {
276        let config = BudgetConfig::new().default_run_max_cost_usd(Decimal::TEN);
277        assert_eq!(
278            config.resolve_run_cap(Some(Decimal::ZERO), None),
279            Some(Decimal::ZERO)
280        );
281    }
282
283    #[test]
284    fn step_budget_usd_maps_missing_and_invalid_to_zero() {
285        assert_eq!(step_budget_usd(None), Decimal::ZERO);
286        assert_eq!(step_budget_usd(Some(f64::NAN)), Decimal::ZERO);
287        assert_eq!(step_budget_usd(Some(f64::INFINITY)), Decimal::ZERO);
288        assert_eq!(step_budget_usd(Some(f64::NEG_INFINITY)), Decimal::ZERO);
289    }
290
291    #[test]
292    fn step_budget_usd_converts_finite_values() {
293        assert_eq!(step_budget_usd(Some(0.0)), Decimal::ZERO);
294        assert_eq!(step_budget_usd(Some(0.25)), Decimal::new(25, 2));
295        assert_eq!(step_budget_usd(Some(1.5)), Decimal::new(15, 1));
296    }
297
298    #[test]
299    fn month_start_truncates_to_first_day_midnight() {
300        let now = Utc.with_ymd_and_hms(2026, 2, 17, 23, 59, 59).unwrap();
301        let start = month_start(now);
302
303        assert_eq!(start.year(), 2026);
304        assert_eq!(start.month(), 2);
305        assert_eq!(start.day(), 1);
306        assert_eq!(start.hour(), 0);
307        assert_eq!(start.minute(), 0);
308        assert_eq!(start.second(), 0);
309    }
310
311    #[test]
312    fn month_start_is_idempotent() {
313        let now = Utc.with_ymd_and_hms(2026, 12, 1, 0, 0, 0).unwrap();
314        assert_eq!(month_start(month_start(now)), month_start(now));
315    }
316
317    #[test]
318    fn read_decimal_env_rejects_unset_empty_negative_and_garbage() {
319        // SAFETY: single-threaded test, variables are scoped to this test name.
320        unsafe {
321            env::remove_var("IRONFLOW_TEST_BUDGET_UNSET");
322            env::set_var("IRONFLOW_TEST_BUDGET_EMPTY", "   ");
323            env::set_var("IRONFLOW_TEST_BUDGET_NEGATIVE", "-1.5");
324            env::set_var("IRONFLOW_TEST_BUDGET_GARBAGE", "five dollars");
325            env::set_var("IRONFLOW_TEST_BUDGET_VALID", " 2.50 ");
326        }
327
328        assert_eq!(read_decimal_env("IRONFLOW_TEST_BUDGET_UNSET"), None);
329        assert_eq!(read_decimal_env("IRONFLOW_TEST_BUDGET_EMPTY"), None);
330        assert_eq!(read_decimal_env("IRONFLOW_TEST_BUDGET_NEGATIVE"), None);
331        assert_eq!(read_decimal_env("IRONFLOW_TEST_BUDGET_GARBAGE"), None);
332        assert_eq!(
333            read_decimal_env("IRONFLOW_TEST_BUDGET_VALID"),
334            Some(Decimal::new(250, 2))
335        );
336
337        // SAFETY: single-threaded test cleanup.
338        unsafe {
339            env::remove_var("IRONFLOW_TEST_BUDGET_EMPTY");
340            env::remove_var("IRONFLOW_TEST_BUDGET_NEGATIVE");
341            env::remove_var("IRONFLOW_TEST_BUDGET_GARBAGE");
342            env::remove_var("IRONFLOW_TEST_BUDGET_VALID");
343        }
344    }
345
346    #[test]
347    fn read_decimal_env_accepts_zero() {
348        // SAFETY: single-threaded test.
349        unsafe { env::set_var("IRONFLOW_TEST_BUDGET_ZERO", "0") };
350        assert_eq!(
351            read_decimal_env("IRONFLOW_TEST_BUDGET_ZERO"),
352            Some(Decimal::ZERO)
353        );
354        // SAFETY: single-threaded test cleanup.
355        unsafe { env::remove_var("IRONFLOW_TEST_BUDGET_ZERO") };
356    }
357}