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 let base = self.base_fallback_message();
222 match string_field(&self.fields, "detail") {
223 Some(detail) => format!("{base}\n\nDetails: {detail}"),
224 None => base,
225 }
226 }
227
228 fn base_fallback_message(&self) -> String {
229 match self.code.as_str() {
230 codes::BUDGET_EXHAUSTED => budget_exhausted_message(&self.fields),
231 codes::BUDGET_PAUSED => budget_paused_message(&self.fields),
232 codes::SOFT_LIMIT_REACHED => string_field(&self.fields, "message")
233 .unwrap_or("Soft limit reached.")
234 .to_string(),
235 codes::MODEL_UNAVAILABLE => {
236 if let Some(model_id) = string_field(&self.fields, "model_id") {
237 format!(
238 "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.",
239 model_id
240 )
241 } else {
242 "The selected model is not available. Please select a different model."
243 .to_string()
244 }
245 }
246 codes::REQUEST_TOO_LARGE => {
247 "The conversation has become too long for the model to process. Please start a new session or reduce the context size.".to_string()
248 }
249 codes::PROVIDER_RATE_LIMITED => {
250 "Rate limited by the AI provider. Please wait a moment.".to_string()
251 }
252 codes::PROVIDER_MISCONFIGURED => {
253 "There is a misconfiguration with the AI provider. Please contact support."
254 .to_string()
255 }
256 codes::PROVIDER_QUOTA_EXHAUSTED => {
257 "The AI provider account is out of credits or quota. Add credits or raise the provider account limits to continue."
258 .to_string()
259 }
260 codes::PROVIDER_UNAVAILABLE => {
261 "The AI provider is experiencing issues. Please try again shortly.".to_string()
262 }
263 codes::DEPENDENCY_UNAVAILABLE => {
264 "Execution stopped because a required dependency is unavailable.".to_string()
265 }
266 _ => "I encountered an error while processing your request. Please try again later."
267 .to_string(),
268 }
269 }
270}
271
272pub fn classify_runtime_error_message(
273 error: &str,
274 context: &UserFacingErrorContext,
275) -> UserFacingError {
276 let normalized = trim_error_chain_prefixes(error).trim();
277 let lower = normalized.to_ascii_lowercase();
278
279 if let Some(fields) = parse_budget_exhausted_fields(normalized) {
280 return UserFacingError {
281 code: codes::BUDGET_EXHAUSTED.to_string(),
282 fields,
283 };
284 }
285
286 if normalized.starts_with("Budget exhausted.") {
287 return UserFacingError::new(codes::BUDGET_EXHAUSTED);
288 }
289
290 if normalized.starts_with("Budget exhausted (") {
291 return UserFacingError::new(codes::BUDGET_EXHAUSTED);
292 }
293
294 if let Some(fields) = parse_budget_paused_fields(normalized) {
295 return UserFacingError {
296 code: codes::BUDGET_PAUSED.to_string(),
297 fields,
298 };
299 }
300
301 if normalized.starts_with("Budget paused.") || normalized.starts_with("Budget paused with ") {
302 return UserFacingError::new(codes::BUDGET_PAUSED);
303 }
304
305 if normalized.starts_with("Budget paused (") || normalized.starts_with("Soft limit reached.") {
306 return if normalized.starts_with("Soft limit reached.") {
307 UserFacingError::new(codes::SOFT_LIMIT_REACHED).with_field("message", normalized)
308 } else {
309 UserFacingError::new(codes::BUDGET_PAUSED)
310 };
311 }
312
313 if let Some(model_id) = normalized.strip_prefix("Model not available: ") {
314 return UserFacingError::new(codes::MODEL_UNAVAILABLE).with_field("model_id", model_id);
315 }
316
317 if normalized.starts_with("Request too large:")
318 || lower.contains("context length")
319 || lower.contains("maximum context length")
320 {
321 return UserFacingError::new(codes::REQUEST_TOO_LARGE)
322 .with_optional_field("provider", context.provider.clone())
323 .with_optional_field("model_id", context.model_id.clone());
324 }
325
326 if is_provider_quota_message(normalized) {
332 return UserFacingError::new(codes::PROVIDER_QUOTA_EXHAUSTED)
333 .with_optional_field("provider", context.provider.clone())
334 .with_optional_field("model_id", context.model_id.clone());
335 }
336
337 if lower.contains("(429)")
338 || lower.contains("rate limit")
339 || lower.contains("too many requests")
340 {
341 return UserFacingError::new(codes::PROVIDER_RATE_LIMITED)
342 .with_optional_field("provider", context.provider.clone())
343 .with_optional_field("model_id", context.model_id.clone())
344 .with_optional_field("retry_after", context.retry_after);
345 }
346
347 if lower.contains("(401)") || lower.contains("(403)") {
348 return UserFacingError::new(codes::PROVIDER_MISCONFIGURED)
349 .with_optional_field("provider", context.provider.clone())
350 .with_optional_field("model_id", context.model_id.clone());
351 }
352
353 if lower.contains("api key is required")
354 || lower.contains("configure the api key")
355 || lower.contains("api key missing")
356 || lower.contains("missing api key")
357 || lower.contains("invalid api key")
358 {
359 return UserFacingError::new(codes::PROVIDER_MISCONFIGURED)
360 .with_optional_field("provider", context.provider.clone())
361 .with_optional_field("model_id", context.model_id.clone());
362 }
363
364 if ["(500)", "(502)", "(503)", "(504)", "(529)"]
365 .iter()
366 .any(|code| lower.contains(code))
367 {
368 return UserFacingError::new(codes::PROVIDER_UNAVAILABLE)
369 .with_optional_field("provider", context.provider.clone())
370 .with_optional_field("model_id", context.model_id.clone());
371 }
372
373 UserFacingError::new(codes::PROCESSING_ERROR)
374 .with_optional_field("provider", context.provider.clone())
375 .with_optional_field("model_id", context.model_id.clone())
376}
377
378pub fn trim_error_chain_prefixes(error_chain: &str) -> &str {
379 error_chain
380 .trim()
381 .trim_start_matches("InputAtom execution failed: ")
382 .trim_start_matches("ReasonAtom execution failed: ")
383 .trim_start_matches("ActAtom execution failed: ")
384}
385
386fn budget_exhausted_message(fields: &UserFacingErrorFields) -> String {
387 if let (Some(spent), Some(limit), Some(currency)) = (
388 number_field(fields, "spent"),
389 number_field(fields, "limit"),
390 string_field(fields, "currency"),
391 ) {
392 let comparison = if spent > limit { "exceeded" } else { "reached" };
393 return format!(
394 "Budget exhausted. {:.2} {} spent {} the {:.2} {} limit. Increase the budget to continue.",
395 spent, currency, comparison, limit, currency
396 );
397 }
398
399 "Budget exhausted. Increase the budget to continue.".to_string()
400}
401
402fn budget_paused_message(fields: &UserFacingErrorFields) -> String {
403 let spent = number_field(fields, "spent");
404 let currency = string_field(fields, "currency");
405 let soft_limit = number_field(fields, "soft_limit");
406
407 match (spent, currency, soft_limit) {
408 (Some(spent), Some(currency), Some(soft_limit)) => {
409 let comparison = if spent > soft_limit {
410 "exceeded"
411 } else if spent >= soft_limit {
412 "reached"
413 } else {
414 "with"
415 };
416 if comparison == "with" {
417 format!(
418 "Budget paused with {:.2} {} spent. Increase or resume the budget to continue.",
419 spent, currency
420 )
421 } else {
422 format!(
423 "Budget paused. {:.2} {} spent {} the {:.2} {} soft limit. Increase or resume the budget to continue.",
424 spent, currency, comparison, soft_limit, currency
425 )
426 }
427 }
428 (Some(spent), Some(currency), None) => format!(
429 "Budget paused with {:.2} {} spent. Increase or resume the budget to continue.",
430 spent, currency
431 ),
432 _ => "Budget paused. Increase or resume the budget to continue.".to_string(),
433 }
434}
435
436fn parse_budget_exhausted_fields(message: &str) -> Option<UserFacingErrorFields> {
437 static RE: OnceLock<Regex> = OnceLock::new();
438 let re = RE.get_or_init(|| {
439 Regex::new(
440 r"^Budget exhausted\. (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent (?:reached|exceeded) the (?P<limit>\d+(?:\.\d+)?) \S+ limit\.",
441 )
442 .expect("valid budget exhausted regex")
443 });
444 let caps = re.captures(message)?;
445 Some(
446 UserFacingErrorFields::new()
447 .with_number("spent", caps.name("spent")?.as_str())
448 .with_number("limit", caps.name("limit")?.as_str())
449 .with_string("currency", caps.name("currency")?.as_str()),
450 )
451}
452
453fn parse_budget_paused_fields(message: &str) -> Option<UserFacingErrorFields> {
454 static SOFT_LIMIT_RE: OnceLock<Regex> = OnceLock::new();
455 static SIMPLE_RE: OnceLock<Regex> = OnceLock::new();
456
457 let soft_limit_re = SOFT_LIMIT_RE.get_or_init(|| {
458 Regex::new(
459 r"^Budget paused\. (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent (?:reached|exceeded) the (?P<soft_limit>\d+(?:\.\d+)?) \S+ soft limit\.",
460 )
461 .expect("valid budget paused regex")
462 });
463 if let Some(caps) = soft_limit_re.captures(message) {
464 return Some(
465 UserFacingErrorFields::new()
466 .with_number("spent", caps.name("spent")?.as_str())
467 .with_number("soft_limit", caps.name("soft_limit")?.as_str())
468 .with_string("currency", caps.name("currency")?.as_str()),
469 );
470 }
471
472 let simple_re = SIMPLE_RE.get_or_init(|| {
473 Regex::new(r"^Budget paused with (?P<spent>\d+(?:\.\d+)?) (?P<currency>\S+) spent\.")
474 .expect("valid budget paused simple regex")
475 });
476 let caps = simple_re.captures(message)?;
477 Some(
478 UserFacingErrorFields::new()
479 .with_number("spent", caps.name("spent")?.as_str())
480 .with_string("currency", caps.name("currency")?.as_str()),
481 )
482}
483
484fn string_field<'a>(fields: &'a UserFacingErrorFields, key: &str) -> Option<&'a str> {
485 fields.get(key)?.as_str()
486}
487
488fn truncate_chars(value: &str, max_chars: usize) -> String {
489 if value.chars().count() <= max_chars {
490 return value.to_string();
491 }
492 let truncated: String = value.chars().take(max_chars).collect();
493 format!("{truncated}\u{2026}")
494}
495
496fn number_field(fields: &UserFacingErrorFields, key: &str) -> Option<f64> {
497 match fields.get(key)? {
498 Value::Number(number) => number.as_f64(),
499 Value::String(value) => value.parse().ok(),
500 _ => None,
501 }
502}
503
504trait ErrorFieldsExt {
505 fn with_string(self, key: &str, value: &str) -> Self;
506 fn with_number(self, key: &str, value: &str) -> Self;
507}
508
509impl ErrorFieldsExt for UserFacingErrorFields {
510 fn with_string(mut self, key: &str, value: &str) -> Self {
511 self.insert(key.to_string(), Value::String(value.to_string()));
512 self
513 }
514
515 fn with_number(mut self, key: &str, value: &str) -> Self {
516 if let Ok(number) = value.parse::<f64>()
517 && let Some(json_number) = serde_json::Number::from_f64(number)
518 {
519 self.insert(key.to_string(), Value::Number(json_number));
520 }
521 self
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 #[test]
530 fn classify_budget_exhausted_parses_fields() {
531 let error = classify_runtime_error_message(
532 "ReasonAtom execution failed: Budget exhausted. 12.50 usd spent exceeded the 10.00 usd limit. Increase the budget to continue.",
533 &UserFacingErrorContext::default(),
534 );
535
536 assert_eq!(error.code, codes::BUDGET_EXHAUSTED);
537 assert_eq!(number_field(&error.fields, "spent"), Some(12.5));
538 assert_eq!(number_field(&error.fields, "limit"), Some(10.0));
539 assert_eq!(string_field(&error.fields, "currency"), Some("usd"));
540 }
541
542 #[test]
543 fn classify_provider_rate_limit_keeps_context() {
544 let error = classify_runtime_error_message(
545 "OpenAI API error (429): rate limit exceeded",
546 &UserFacingErrorContext::default()
547 .with_provider("openai")
548 .with_model_id("gpt-5")
549 .with_retry_after(7),
550 );
551
552 assert_eq!(error.code, codes::PROVIDER_RATE_LIMITED);
553 assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
554 assert_eq!(string_field(&error.fields, "model_id"), Some("gpt-5"));
555 assert_eq!(number_field(&error.fields, "retry_after"), Some(7.0));
556 }
557
558 #[test]
559 fn classify_openai_insufficient_quota_as_provider_quota_exhausted() {
560 let error = classify_runtime_error_message(
564 "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\"}}",
565 &UserFacingErrorContext::default()
566 .with_provider("openai")
567 .with_model_id("gpt-4.1-mini"),
568 );
569
570 assert_eq!(error.code, codes::PROVIDER_QUOTA_EXHAUSTED);
571 assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
572 assert_eq!(
573 string_field(&error.fields, "model_id"),
574 Some("gpt-4.1-mini")
575 );
576 }
577
578 #[test]
579 fn classify_insufficient_quota_without_status_prefix() {
580 let error = classify_runtime_error_message(
584 "LLM error: insufficient_quota: You exceeded your current quota.",
585 &UserFacingErrorContext::default(),
586 );
587
588 assert_eq!(error.code, codes::PROVIDER_QUOTA_EXHAUSTED);
589 }
590
591 #[test]
592 fn classify_anthropic_low_credit_balance_as_provider_quota_exhausted() {
593 let error = classify_runtime_error_message(
594 "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.\"}}",
595 &UserFacingErrorContext::default().with_provider("anthropic"),
596 );
597
598 assert_eq!(error.code, codes::PROVIDER_QUOTA_EXHAUSTED);
599 }
600
601 #[test]
602 fn disclosure_generic_collapses_code_and_fields() {
603 let error = UserFacingError::new(codes::PROVIDER_QUOTA_EXHAUSTED)
604 .with_field("provider", "openai")
605 .with_field("model_id", "gpt-4.1-mini");
606
607 let disclosed = error.apply_disclosure(ErrorDisclosure::Generic, Some("raw detail"));
608
609 assert_eq!(disclosed.code, codes::PROCESSING_ERROR);
610 assert!(disclosed.fields.is_empty());
611 assert_eq!(
612 disclosed.fallback_message(),
613 "I encountered an error while processing your request. Please try again later."
614 );
615 }
616
617 #[test]
618 fn disclosure_standard_is_identity() {
619 let error = UserFacingError::new(codes::PROVIDER_RATE_LIMITED).with_field("retry_after", 7);
620 let disclosed = error.apply_disclosure(ErrorDisclosure::Standard, Some("raw detail"));
621 assert_eq!(disclosed, error);
622 }
623
624 #[test]
625 fn disclosure_detailed_attaches_detail_and_renders_it() {
626 let error = UserFacingError::new(codes::PROVIDER_QUOTA_EXHAUSTED);
627 let disclosed = error.apply_disclosure(
628 ErrorDisclosure::Detailed,
629 Some("OpenAI API error (429): insufficient_quota"),
630 );
631
632 assert_eq!(disclosed.code, codes::PROVIDER_QUOTA_EXHAUSTED);
633 assert_eq!(
634 string_field(&disclosed.fields, "detail"),
635 Some("OpenAI API error (429): insufficient_quota")
636 );
637 let message = disclosed.fallback_message();
638 assert!(message.contains("out of credits or quota"));
639 assert!(message.contains("Details: OpenAI API error (429): insufficient_quota"));
640 }
641
642 #[test]
643 fn disclosure_detailed_truncates_long_detail() {
644 let error = UserFacingError::new(codes::PROCESSING_ERROR);
645 let long_detail = "x".repeat(5000);
646 let disclosed = error.apply_disclosure(ErrorDisclosure::Detailed, Some(&long_detail));
647 let detail = string_field(&disclosed.fields, "detail").unwrap();
648 assert!(detail.chars().count() <= 1001); }
650
651 #[test]
652 fn disclosure_parse_and_ordering() {
653 assert_eq!(
654 ErrorDisclosure::parse("Generic"),
655 Some(ErrorDisclosure::Generic)
656 );
657 assert_eq!(
658 ErrorDisclosure::parse("detailed"),
659 Some(ErrorDisclosure::Detailed)
660 );
661 assert_eq!(ErrorDisclosure::parse("nope"), None);
662 assert!(ErrorDisclosure::Generic < ErrorDisclosure::Standard);
663 assert!(ErrorDisclosure::Standard < ErrorDisclosure::Detailed);
664 assert_eq!(ErrorDisclosure::default(), ErrorDisclosure::Standard);
665 }
666
667 #[test]
668 fn classify_missing_api_key_as_provider_misconfigured() {
669 let error = classify_runtime_error_message(
670 "LLM error: API key is required. Configure the API key in provider settings.",
671 &UserFacingErrorContext::default().with_provider("openai"),
672 );
673
674 assert_eq!(error.code, codes::PROVIDER_MISCONFIGURED);
675 assert_eq!(string_field(&error.fields, "provider"), Some("openai"));
676 }
677
678 #[test]
679 fn fallback_message_reuses_budget_fields() {
680 let error = UserFacingError::new(codes::BUDGET_PAUSED)
681 .with_field("spent", 5.0)
682 .with_field("soft_limit", 5.0)
683 .with_field("currency", "tokens");
684
685 assert_eq!(
686 error.fallback_message(),
687 "Budget paused. 5.00 tokens spent reached the 5.00 tokens soft limit. Increase or resume the budget to continue."
688 );
689 }
690}