1use regex::Regex;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::{BTreeMap, HashMap};
5use std::sync::OnceLock;
6
7#[cfg(feature = "openapi")]
8use utoipa::ToSchema;
9
10pub mod codes {
11 pub const BUDGET_EXHAUSTED: &str = "budget_exhausted";
12 pub const BUDGET_PAUSED: &str = "budget_paused";
13 pub const MODEL_UNAVAILABLE: &str = "model_unavailable";
14 pub const REQUEST_TOO_LARGE: &str = "request_too_large";
15 pub const PROVIDER_RATE_LIMITED: &str = "provider_rate_limited";
16 pub const PROVIDER_MISCONFIGURED: &str = "provider_misconfigured";
17 pub const PROVIDER_QUOTA_EXHAUSTED: &str = "provider_quota_exhausted";
21 pub const PROVIDER_UNAVAILABLE: &str = "provider_unavailable";
22 pub const PROCESSING_ERROR: &str = "processing_error";
23 pub const DEPENDENCY_UNAVAILABLE: &str = "dependency_unavailable";
24 pub const MAX_ITERATIONS: &str = "max_iterations";
25 pub const SOFT_LIMIT_REACHED: &str = "soft_limit_reached";
26 pub const BLOCKED_BY_HOOK: &str = "blocked_by_hook";
28}
29
30pub type UserFacingErrorFields = BTreeMap<String, Value>;
31
32pub mod metadata_keys {
34 pub const ERROR_DISCLOSURE: &str = "error_disclosure";
36 pub const SOURCE_ERROR_CODE: &str = "source_error_code";
40}
41
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49#[cfg_attr(feature = "openapi", derive(ToSchema))]
50pub enum ErrorDisclosure {
51 Generic,
54 #[default]
56 Standard,
57 Detailed,
60}
61
62impl ErrorDisclosure {
63 pub fn parse(value: &str) -> Option<Self> {
64 match value.trim().to_ascii_lowercase().as_str() {
65 "generic" => Some(ErrorDisclosure::Generic),
66 "standard" => Some(ErrorDisclosure::Standard),
67 "detailed" => Some(ErrorDisclosure::Detailed),
68 _ => None,
69 }
70 }
71
72 pub fn as_str(&self) -> &'static str {
73 match self {
74 ErrorDisclosure::Generic => "generic",
75 ErrorDisclosure::Standard => "standard",
76 ErrorDisclosure::Detailed => "detailed",
77 }
78 }
79}
80
81const DETAIL_MAX_CHARS: usize = 1000;
85
86pub fn is_provider_quota_message(message: &str) -> bool {
89 let lower = message.to_ascii_lowercase();
90 lower.contains("insufficient_quota")
91 || lower.contains("insufficient quota")
92 || lower.contains("exceeded your current quota")
93 || lower.contains("credit balance is too low")
94}
95
96#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
97#[cfg_attr(feature = "openapi", derive(ToSchema))]
98pub struct UserFacingError {
99 pub code: String,
100 #[serde(default, skip_serializing_if = "UserFacingErrorFields::is_empty")]
101 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
102 pub fields: UserFacingErrorFields,
103}
104
105#[derive(Debug, Clone, Default)]
106pub struct UserFacingErrorContext {
107 pub provider: Option<String>,
108 pub model_id: Option<String>,
109 pub retry_after: Option<u64>,
110}
111
112impl UserFacingErrorContext {
113 pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
114 self.provider = Some(provider.into());
115 self
116 }
117
118 pub fn with_model_id(mut self, model_id: impl Into<String>) -> Self {
119 self.model_id = Some(model_id.into());
120 self
121 }
122
123 pub fn with_retry_after(mut self, retry_after: u64) -> Self {
124 self.retry_after = Some(retry_after);
125 self
126 }
127}
128
129impl UserFacingError {
130 pub fn new(code: impl Into<String>) -> Self {
131 Self {
132 code: code.into(),
133 fields: UserFacingErrorFields::new(),
134 }
135 }
136
137 pub fn with_field<T: Serialize>(mut self, key: impl Into<String>, value: T) -> Self {
138 let value = serde_json::to_value(value).unwrap_or(Value::Null);
139 if !value.is_null() {
140 self.fields.insert(key.into(), value);
141 }
142 self
143 }
144
145 pub fn with_optional_field<T: Serialize>(
146 self,
147 key: impl Into<String>,
148 value: Option<T>,
149 ) -> Self {
150 match value {
151 Some(value) => self.with_field(key, value),
152 None => self,
153 }
154 }
155
156 pub fn error_fields(&self) -> Option<UserFacingErrorFields> {
157 (!self.fields.is_empty()).then_some(self.fields.clone())
158 }
159
160 pub fn apply_to_event_fields(
161 &self,
162 error_code: &mut Option<String>,
163 error_fields: &mut Option<UserFacingErrorFields>,
164 ) {
165 *error_code = Some(self.code.clone());
166 *error_fields = self.error_fields();
167 }
168
169 pub fn apply_to_message_metadata(&self, metadata: &mut HashMap<String, Value>) {
170 metadata.insert("error_code".to_string(), Value::String(self.code.clone()));
171 if let Some(fields) = self.error_fields() {
172 metadata.insert(
173 "error_fields".to_string(),
174 serde_json::to_value(fields).unwrap_or(Value::Null),
175 );
176 }
177 }
178
179 pub fn apply_disclosure(&self, mode: ErrorDisclosure, detail: Option<&str>) -> UserFacingError {
188 match mode {
189 ErrorDisclosure::Generic => UserFacingError::new(codes::PROCESSING_ERROR),
190 ErrorDisclosure::Standard => self.clone(),
191 ErrorDisclosure::Detailed => {
192 let detail = detail.map(str::trim).filter(|d| !d.is_empty());
193 match detail {
194 Some(detail) => self
195 .clone()
196 .with_field("detail", truncate_chars(detail, DETAIL_MAX_CHARS)),
197 None => self.clone(),
198 }
199 }
200 }
201 }
202
203 pub fn apply_disclosure_to_message_metadata(
206 metadata: &mut HashMap<String, Value>,
207 mode: ErrorDisclosure,
208 source_code: &str,
209 ) {
210 metadata.insert(
211 metadata_keys::ERROR_DISCLOSURE.to_string(),
212 Value::String(mode.as_str().to_string()),
213 );
214 metadata.insert(
215 metadata_keys::SOURCE_ERROR_CODE.to_string(),
216 Value::String(source_code.to_string()),
217 );
218 }
219
220 pub fn fallback_message(&self) -> String {
221 self.base_fallback_message()
222 }
223
224 fn base_fallback_message(&self) -> String {
225 match self.code.as_str() {
226 codes::BUDGET_EXHAUSTED => budget_exhausted_message(&self.fields),
227 codes::BUDGET_PAUSED => budget_paused_message(&self.fields),
228 codes::SOFT_LIMIT_REACHED => string_field(&self.fields, "message")
229 .unwrap_or("Soft limit reached.")
230 .to_string(),
231 codes::MODEL_UNAVAILABLE => {
232 if let Some(model_id) = string_field(&self.fields, "model_id") {
233 format!(
234 "The model `{}` is not available. It may have been removed, renamed, or your API key may not have access to it. Please select a different model.",
235 model_id
236 )
237 } else {
238 "The selected model is not available. Please select a different model."
239 .to_string()
240 }
241 }
242 codes::REQUEST_TOO_LARGE => {
243 "The conversation has become too long for the model to process. Please start a new session or reduce the context size.".to_string()
244 }
245 codes::PROVIDER_RATE_LIMITED => {
246 "Rate limited by the AI provider. Please wait a moment.".to_string()
247 }
248 codes::PROVIDER_MISCONFIGURED => {
249 "There is a misconfiguration with the AI provider. Please contact support."
250 .to_string()
251 }
252 codes::PROVIDER_QUOTA_EXHAUSTED => {
253 "The AI provider account is out of credits or quota. Add credits or raise the provider account limits to continue."
254 .to_string()
255 }
256 codes::PROVIDER_UNAVAILABLE => {
257 "The AI provider is experiencing issues. Please try again shortly.".to_string()
258 }
259 codes::DEPENDENCY_UNAVAILABLE => {
260 "Execution stopped because a required dependency is unavailable.".to_string()
261 }
262 _ => "I encountered an error while processing your request. Please try again later."
263 .to_string(),
264 }
265 }
266}
267
268pub fn classify_runtime_error_message(
269 error: &str,
270 context: &UserFacingErrorContext,
271) -> UserFacingError {
272 let normalized = trim_error_chain_prefixes(error).trim();
273 let lower = normalized.to_ascii_lowercase();
274
275 if let Some(fields) = parse_budget_exhausted_fields(normalized) {
276 return UserFacingError {
277 code: codes::BUDGET_EXHAUSTED.to_string(),
278 fields,
279 };
280 }
281
282 if normalized.starts_with("Budget exhausted.") {
283 return UserFacingError::new(codes::BUDGET_EXHAUSTED);
284 }
285
286 if normalized.starts_with("Budget exhausted (") {
287 return UserFacingError::new(codes::BUDGET_EXHAUSTED);
288 }
289
290 if let Some(fields) = parse_budget_paused_fields(normalized) {
291 return UserFacingError {
292 code: codes::BUDGET_PAUSED.to_string(),
293 fields,
294 };
295 }
296
297 if normalized.starts_with("Budget paused.") || normalized.starts_with("Budget paused with ") {
298 return UserFacingError::new(codes::BUDGET_PAUSED);
299 }
300
301 if normalized.starts_with("Budget paused (") || normalized.starts_with("Soft limit reached.") {
302 return if normalized.starts_with("Soft limit reached.") {
303 UserFacingError::new(codes::SOFT_LIMIT_REACHED).with_field("message", normalized)
304 } else {
305 UserFacingError::new(codes::BUDGET_PAUSED)
306 };
307 }
308
309 if let Some(model_id) = normalized.strip_prefix("Model not available: ") {
310 return UserFacingError::new(codes::MODEL_UNAVAILABLE).with_field("model_id", model_id);
311 }
312
313 if normalized.starts_with("Request too large:")
314 || lower.contains("context length")
315 || lower.contains("maximum context length")
316 {
317 return UserFacingError::new(codes::REQUEST_TOO_LARGE)
318 .with_optional_field("provider", context.provider.clone())
319 .with_optional_field("model_id", context.model_id.clone());
320 }
321
322 if is_provider_quota_message(normalized) {
328 return UserFacingError::new(codes::PROVIDER_QUOTA_EXHAUSTED)
329 .with_optional_field("provider", context.provider.clone())
330 .with_optional_field("model_id", context.model_id.clone());
331 }
332
333 if lower.contains("(429)")
334 || lower.contains("rate limit")
335 || lower.contains("too many requests")
336 {
337 return UserFacingError::new(codes::PROVIDER_RATE_LIMITED)
338 .with_optional_field("provider", context.provider.clone())
339 .with_optional_field("model_id", context.model_id.clone())
340 .with_optional_field("retry_after", context.retry_after);
341 }
342
343 if lower.contains("(401)") || lower.contains("(403)") {
344 return UserFacingError::new(codes::PROVIDER_MISCONFIGURED)
345 .with_optional_field("provider", context.provider.clone())
346 .with_optional_field("model_id", context.model_id.clone());
347 }
348
349 if lower.contains("api key is required")
350 || lower.contains("configure the api key")
351 || lower.contains("api key missing")
352 || lower.contains("missing api key")
353 || lower.contains("invalid api key")
354 {
355 return UserFacingError::new(codes::PROVIDER_MISCONFIGURED)
356 .with_optional_field("provider", context.provider.clone())
357 .with_optional_field("model_id", context.model_id.clone());
358 }
359
360 if ["(500)", "(502)", "(503)", "(504)", "(529)"]
361 .iter()
362 .any(|code| lower.contains(code))
363 {
364 return UserFacingError::new(codes::PROVIDER_UNAVAILABLE)
365 .with_optional_field("provider", context.provider.clone())
366 .with_optional_field("model_id", context.model_id.clone());
367 }
368
369 UserFacingError::new(codes::PROCESSING_ERROR)
370 .with_optional_field("provider", context.provider.clone())
371 .with_optional_field("model_id", context.model_id.clone())
372}
373
374pub fn trim_error_chain_prefixes(error_chain: &str) -> &str {
375 error_chain
376 .trim()
377 .trim_start_matches("InputAtom execution failed: ")
378 .trim_start_matches("ReasonAtom execution failed: ")
379 .trim_start_matches("ActAtom execution failed: ")
380}
381
382fn budget_exhausted_message(fields: &UserFacingErrorFields) -> String {
383 if let (Some(spent), Some(limit), Some(currency)) = (
384 number_field(fields, "spent"),
385 number_field(fields, "limit"),
386 string_field(fields, "currency"),
387 ) {
388 let comparison = if spent > limit { "exceeded" } else { "reached" };
389 return format!(
390 "Budget exhausted. {:.2} {} spent {} the {:.2} {} limit. Increase the budget to continue.",
391 spent, currency, comparison, limit, currency
392 );
393 }
394
395 "Budget exhausted. Increase the budget to continue.".to_string()
396}
397
398fn budget_paused_message(fields: &UserFacingErrorFields) -> String {
399 let spent = number_field(fields, "spent");
400 let currency = string_field(fields, "currency");
401 let soft_limit = number_field(fields, "soft_limit");
402
403 match (spent, currency, soft_limit) {
404 (Some(spent), Some(currency), Some(soft_limit)) => {
405 let comparison = if spent > soft_limit {
406 "exceeded"
407 } else if spent >= soft_limit {
408 "reached"
409 } else {
410 "with"
411 };
412 if comparison == "with" {
413 format!(
414 "Budget paused with {:.2} {} spent. Increase or resume the budget to continue.",
415 spent, currency
416 )
417 } else {
418 format!(
419 "Budget paused. {:.2} {} spent {} the {:.2} {} soft limit. Increase or resume the budget to continue.",
420 spent, currency, comparison, soft_limit, currency
421 )
422 }
423 }
424 (Some(spent), Some(currency), None) => format!(
425 "Budget paused with {:.2} {} spent. Increase or resume the budget to continue.",
426 spent, currency
427 ),
428 _ => "Budget paused. Increase or resume the budget to continue.".to_string(),
429 }
430}
431
432fn parse_budget_exhausted_fields(message: &str) -> Option<UserFacingErrorFields> {
433 static RE: OnceLock<Regex> = OnceLock::new();
434 let re = RE.get_or_init(|| {
435 Regex::new(
436 r"^Budget exhausted\. (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent (?:reached|exceeded) the (?P<limit>\d+(?:\.\d+)?) \S+ limit\.",
437 )
438 .expect("valid budget exhausted regex")
439 });
440 let caps = re.captures(message)?;
441 Some(
442 UserFacingErrorFields::new()
443 .with_number("spent", caps.name("spent")?.as_str())
444 .with_number("limit", caps.name("limit")?.as_str())
445 .with_string("currency", caps.name("currency")?.as_str()),
446 )
447}
448
449fn parse_budget_paused_fields(message: &str) -> Option<UserFacingErrorFields> {
450 static SOFT_LIMIT_RE: OnceLock<Regex> = OnceLock::new();
451 static SIMPLE_RE: OnceLock<Regex> = OnceLock::new();
452
453 let soft_limit_re = SOFT_LIMIT_RE.get_or_init(|| {
454 Regex::new(
455 r"^Budget paused\. (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent (?:reached|exceeded) the (?P<soft_limit>\d+(?:\.\d+)?) \S+ soft limit\.",
456 )
457 .expect("valid budget paused regex")
458 });
459 if let Some(caps) = soft_limit_re.captures(message) {
460 return Some(
461 UserFacingErrorFields::new()
462 .with_number("spent", caps.name("spent")?.as_str())
463 .with_number("soft_limit", caps.name("soft_limit")?.as_str())
464 .with_string("currency", caps.name("currency")?.as_str()),
465 );
466 }
467
468 let simple_re = SIMPLE_RE.get_or_init(|| {
469 Regex::new(r"^Budget paused with (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent\.")
470 .expect("valid budget paused simple regex")
471 });
472 let caps = simple_re.captures(message)?;
473 Some(
474 UserFacingErrorFields::new()
475 .with_number("spent", caps.name("spent")?.as_str())
476 .with_string("currency", caps.name("currency")?.as_str()),
477 )
478}
479
480fn string_field<'a>(fields: &'a UserFacingErrorFields, key: &str) -> Option<&'a str> {
481 fields.get(key)?.as_str()
482}
483
484fn truncate_chars(value: &str, max_chars: usize) -> String {
485 if value.chars().count() <= max_chars {
486 return value.to_string();
487 }
488 let truncated: String = value.chars().take(max_chars).collect();
489 format!("{truncated}\u{2026}")
490}
491
492fn number_field(fields: &UserFacingErrorFields, key: &str) -> Option<f64> {
493 match fields.get(key)? {
494 Value::Number(number) => number.as_f64(),
495 Value::String(value) => value.parse().ok(),
496 _ => None,
497 }
498}
499
500trait ErrorFieldsExt {
501 fn with_string(self, key: &str, value: &str) -> Self;
502 fn with_number(self, key: &str, value: &str) -> Self;
503}
504
505impl ErrorFieldsExt for UserFacingErrorFields {
506 fn with_string(mut self, key: &str, value: &str) -> Self {
507 self.insert(key.to_string(), Value::String(value.to_string()));
508 self
509 }
510
511 fn with_number(mut self, key: &str, value: &str) -> Self {
512 if let Ok(number) = value.parse::<f64>()
513 && let Some(json_number) = serde_json::Number::from_f64(number)
514 {
515 self.insert(key.to_string(), Value::Number(json_number));
516 }
517 self
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 #[test]
526 fn classify_budget_exhausted_parses_fields() {
527 let error = classify_runtime_error_message(
528 "ReasonAtom execution failed: Budget exhausted. 12.50 usd spent exceeded the 10.00 usd limit. Increase the budget to continue.",
529 &UserFacingErrorContext::default(),
530 );
531
532 assert_eq!(error.code, codes::BUDGET_EXHAUSTED);
533 assert_eq!(number_field(&error.fields, "spent"), Some(12.5));
534 assert_eq!(number_field(&error.fields, "limit"), Some(10.0));
535 assert_eq!(string_field(&error.fields, "currency"), Some("usd"));
536 }
537
538 #[test]
539 fn classify_provider_rate_limit_keeps_context() {
540 let error = classify_runtime_error_message(
541 "OpenAI API error (429): rate limit exceeded",
542 &UserFacingErrorContext::default()
543 .with_provider("openai")
544 .with_model_id("gpt-5")
545 .with_retry_after(7),
546 );
547
548 assert_eq!(error.code, codes::PROVIDER_RATE_LIMITED);
549 assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
550 assert_eq!(string_field(&error.fields, "model_id"), Some("gpt-5"));
551 assert_eq!(number_field(&error.fields, "retry_after"), Some(7.0));
552 }
553
554 #[test]
555 fn classify_openai_insufficient_quota_as_provider_quota_exhausted() {
556 let error = classify_runtime_error_message(
560 "ReasonAtom execution failed: OpenAI API error (429): {\"error\":{\"message\":\"You exceeded your current quota, please check your plan and billing details.\",\"type\":\"insufficient_quota\",\"code\":\"insufficient_quota\"}}",
561 &UserFacingErrorContext::default()
562 .with_provider("openai")
563 .with_model_id("gpt-4.1-mini"),
564 );
565
566 assert_eq!(error.code, codes::PROVIDER_QUOTA_EXHAUSTED);
567 assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
568 assert_eq!(
569 string_field(&error.fields, "model_id"),
570 Some("gpt-4.1-mini")
571 );
572 }
573
574 #[test]
575 fn classify_insufficient_quota_without_status_prefix() {
576 let error = classify_runtime_error_message(
580 "LLM error: insufficient_quota: You exceeded your current quota.",
581 &UserFacingErrorContext::default(),
582 );
583
584 assert_eq!(error.code, codes::PROVIDER_QUOTA_EXHAUSTED);
585 }
586
587 #[test]
588 fn classify_anthropic_low_credit_balance_as_provider_quota_exhausted() {
589 let error = classify_runtime_error_message(
590 "Anthropic API error (400): {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits.\"}}",
591 &UserFacingErrorContext::default().with_provider("anthropic"),
592 );
593
594 assert_eq!(error.code, codes::PROVIDER_QUOTA_EXHAUSTED);
595 }
596
597 #[test]
598 fn disclosure_generic_collapses_code_and_fields() {
599 let error = UserFacingError::new(codes::PROVIDER_QUOTA_EXHAUSTED)
600 .with_field("provider", "openai")
601 .with_field("model_id", "gpt-4.1-mini");
602
603 let disclosed = error.apply_disclosure(ErrorDisclosure::Generic, Some("raw detail"));
604
605 assert_eq!(disclosed.code, codes::PROCESSING_ERROR);
606 assert!(disclosed.fields.is_empty());
607 assert_eq!(
608 disclosed.fallback_message(),
609 "I encountered an error while processing your request. Please try again later."
610 );
611 }
612
613 #[test]
614 fn disclosure_standard_is_identity() {
615 let error = UserFacingError::new(codes::PROVIDER_RATE_LIMITED).with_field("retry_after", 7);
616 let disclosed = error.apply_disclosure(ErrorDisclosure::Standard, Some("raw detail"));
617 assert_eq!(disclosed, error);
618 }
619
620 #[test]
621 fn disclosure_detailed_attaches_detail_without_rendering_it() {
622 let error = UserFacingError::new(codes::PROVIDER_QUOTA_EXHAUSTED);
623 let disclosed = error.apply_disclosure(
624 ErrorDisclosure::Detailed,
625 Some("OpenAI API error (429): insufficient_quota Authorization: Bearer sk-secret"),
626 );
627
628 assert_eq!(disclosed.code, codes::PROVIDER_QUOTA_EXHAUSTED);
629 assert_eq!(
630 string_field(&disclosed.fields, "detail"),
631 Some("OpenAI API error (429): insufficient_quota Authorization: Bearer sk-secret")
632 );
633 let message = disclosed.fallback_message();
634 assert!(message.contains("out of credits or quota"));
635 assert!(!message.contains("insufficient_quota"));
636 assert!(!message.contains("sk-secret"));
637 }
638
639 #[test]
640 fn disclosure_detailed_truncates_long_detail() {
641 let error = UserFacingError::new(codes::PROCESSING_ERROR);
642 let long_detail = "x".repeat(5000);
643 let disclosed = error.apply_disclosure(ErrorDisclosure::Detailed, Some(&long_detail));
644 let detail = string_field(&disclosed.fields, "detail").unwrap();
645 assert!(detail.chars().count() <= 1001); }
647
648 #[test]
649 fn disclosure_parse_and_ordering() {
650 assert_eq!(
651 ErrorDisclosure::parse("Generic"),
652 Some(ErrorDisclosure::Generic)
653 );
654 assert_eq!(
655 ErrorDisclosure::parse("detailed"),
656 Some(ErrorDisclosure::Detailed)
657 );
658 assert_eq!(ErrorDisclosure::parse("nope"), None);
659 assert!(ErrorDisclosure::Generic < ErrorDisclosure::Standard);
660 assert!(ErrorDisclosure::Standard < ErrorDisclosure::Detailed);
661 assert_eq!(ErrorDisclosure::default(), ErrorDisclosure::Standard);
662 }
663
664 #[test]
665 fn classify_missing_api_key_as_provider_misconfigured() {
666 let error = classify_runtime_error_message(
667 "LLM error: API key is required. Configure the API key in provider settings.",
668 &UserFacingErrorContext::default().with_provider("openai"),
669 );
670
671 assert_eq!(error.code, codes::PROVIDER_MISCONFIGURED);
672 assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
673 }
674
675 #[test]
676 fn fallback_message_reuses_budget_fields() {
677 let error = UserFacingError::new(codes::BUDGET_PAUSED)
678 .with_field("spent", 5.0)
679 .with_field("soft_limit", 5.0)
680 .with_field("currency", "tokens");
681
682 assert_eq!(
683 error.fallback_message(),
684 "Budget paused. 5.00 tokens spent reached the 5.00 tokens soft limit. Increase or resume the budget to continue."
685 );
686 }
687}