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