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}