1use core::ffi::c_char;
4use core::fmt;
5use std::collections::HashMap;
6use std::ffi::{CStr, CString};
7use std::sync::{Mutex, OnceLock};
8
9use serde::Deserialize;
10
11use crate::ffi;
12use crate::prompt::ToolDefinition;
13use crate::schema::GenerationSchema;
14use crate::session::{self, SessionResponse, StreamEvent};
15use crate::transcript::{Entry, Transcript};
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct GenerationErrorContext {
20 debug_description: String,
21}
22
23impl GenerationErrorContext {
24 #[must_use]
26 pub fn new(debug_description: impl Into<String>) -> Self {
27 Self {
28 debug_description: debug_description.into(),
29 }
30 }
31
32 #[must_use]
34 pub fn debug_description(&self) -> &str {
35 &self.debug_description
36 }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct SchemaErrorContext {
42 debug_description: String,
43}
44
45impl SchemaErrorContext {
46 #[must_use]
48 pub fn new(debug_description: impl Into<String>) -> Self {
49 Self {
50 debug_description: debug_description.into(),
51 }
52 }
53
54 #[must_use]
56 pub fn debug_description(&self) -> &str {
57 &self.debug_description
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ToolCallError {
64 tool: ToolDefinition,
65 underlying_error: String,
66}
67
68impl ToolCallError {
69 #[must_use]
71 pub fn new(tool: ToolDefinition, underlying_error: impl Into<String>) -> Self {
72 Self {
73 tool,
74 underlying_error: underlying_error.into(),
75 }
76 }
77
78 #[must_use]
80 pub const fn tool(&self) -> &ToolDefinition {
81 &self.tool
82 }
83
84 #[must_use]
86 pub fn underlying_error(&self) -> &str {
87 &self.underlying_error
88 }
89}
90
91#[derive(Debug, Clone, PartialEq)]
93pub struct Refusal {
94 token: Option<String>,
95 transcript: Option<Transcript>,
96}
97
98impl Refusal {
99 #[must_use]
101 pub fn new(entries: impl IntoIterator<Item = Entry>) -> Self {
102 Self {
103 token: None,
104 transcript: Some(Transcript::from_entries(entries.into_iter().collect())),
105 }
106 }
107
108 pub(crate) fn from_token(token: impl Into<String>) -> Self {
109 Self {
110 token: Some(token.into()),
111 transcript: None,
112 }
113 }
114
115 #[must_use]
117 pub fn transcript(&self) -> Option<&Transcript> {
118 self.transcript.as_ref()
119 }
120
121 pub fn explanation(&self) -> Result<SessionResponse<String>, FMError> {
127 if let Some(token) = &self.token {
128 let token = CString::new(token.as_str()).map_err(|error| {
129 FMError::InvalidArgument(format!(
130 "refusal token contains an interior NUL byte: {error}"
131 ))
132 })?;
133 return session::request_text_response_with(|context, callback| unsafe {
134 ffi::fm_refusal_explanation_json(token.as_ptr(), context, callback)
135 });
136 }
137
138 let transcript = self.transcript.as_ref().ok_or_else(|| {
139 FMError::InvalidArgument("refusal does not contain any transcript state".into())
140 })?;
141 let transcript_json = CString::new(transcript.to_json_string()?).map_err(|error| {
142 FMError::InvalidArgument(format!(
143 "refusal transcript JSON contains an interior NUL byte: {error}"
144 ))
145 })?;
146 session::request_text_response_with(|context, callback| unsafe {
147 ffi::fm_refusal_explanation_from_transcript_json(
148 transcript_json.as_ptr(),
149 context,
150 callback,
151 )
152 })
153 }
154
155 pub fn explanation_stream<F>(&self, on_chunk: F) -> Result<(), FMError>
161 where
162 F: FnMut(StreamEvent<'_>) + Send + 'static,
163 {
164 if let Some(token) = &self.token {
165 let token = CString::new(token.as_str()).map_err(|error| {
166 FMError::InvalidArgument(format!(
167 "refusal token contains an interior NUL byte: {error}"
168 ))
169 })?;
170 return session::run_text_stream_with(
171 |context, callback| unsafe {
172 ffi::fm_refusal_explanation_stream(token.as_ptr(), context, callback)
173 },
174 on_chunk,
175 );
176 }
177
178 let transcript = self.transcript.as_ref().ok_or_else(|| {
179 FMError::InvalidArgument("refusal does not contain any transcript state".into())
180 })?;
181 let transcript_json = CString::new(transcript.to_json_string()?).map_err(|error| {
182 FMError::InvalidArgument(format!(
183 "refusal transcript JSON contains an interior NUL byte: {error}"
184 ))
185 })?;
186 session::run_text_stream_with(
187 |context, callback| unsafe {
188 ffi::fm_refusal_explanation_stream_from_transcript_json(
189 transcript_json.as_ptr(),
190 context,
191 callback,
192 )
193 },
194 on_chunk,
195 )
196 }
197}
198
199#[derive(Debug, Clone, Default, PartialEq)]
200struct ErrorMetadata {
201 recovery_suggestion: Option<String>,
202 failure_reason: Option<String>,
203 generation_error_context: Option<GenerationErrorContext>,
204 schema_error_context: Option<SchemaErrorContext>,
205 refusal: Option<Refusal>,
206 tool_call_error: Option<ToolCallError>,
207}
208
209#[derive(Debug, Deserialize)]
210struct BridgeErrorContext {
211 #[serde(rename = "debugDescription")]
212 debug_description: String,
213}
214
215#[derive(Debug, Deserialize)]
216struct BridgeRefusal {
217 token: String,
218}
219
220#[derive(Debug, Deserialize)]
221struct BridgeToolDefinition {
222 name: String,
223 description: String,
224 #[serde(rename = "parametersJSON")]
225 parameters_json: String,
226}
227
228#[derive(Debug, Deserialize)]
229struct BridgeToolCallError {
230 tool: BridgeToolDefinition,
231 #[serde(rename = "underlyingError")]
232 underlying_error: String,
233}
234
235#[derive(Debug, Deserialize)]
236struct BridgeErrorPayload {
237 message: String,
238 #[serde(rename = "recoverySuggestion")]
239 recovery_suggestion: Option<String>,
240 #[serde(rename = "failureReason")]
241 failure_reason: Option<String>,
242 #[serde(rename = "generationErrorContext")]
243 generation_error_context: Option<BridgeErrorContext>,
244 refusal: Option<BridgeRefusal>,
245 #[serde(rename = "toolCallError")]
246 tool_call_error: Option<BridgeToolCallError>,
247 #[serde(rename = "schemaErrorContext")]
248 schema_error_context: Option<BridgeErrorContext>,
249}
250
251impl BridgeErrorPayload {
252 fn into_metadata(self) -> ErrorMetadata {
253 ErrorMetadata {
254 recovery_suggestion: self.recovery_suggestion,
255 failure_reason: self.failure_reason,
256 generation_error_context: self
257 .generation_error_context
258 .map(|context| GenerationErrorContext::new(context.debug_description)),
259 schema_error_context: self
260 .schema_error_context
261 .map(|context| SchemaErrorContext::new(context.debug_description)),
262 refusal: self
263 .refusal
264 .map(|refusal| Refusal::from_token(refusal.token)),
265 tool_call_error: self.tool_call_error.map(|error| {
266 ToolCallError::new(
267 ToolDefinition::new(
268 error.tool.name,
269 error.tool.description,
270 GenerationSchema::from_json_schema_unchecked(error.tool.parameters_json),
271 ),
272 error.underlying_error,
273 )
274 }),
275 }
276 }
277}
278
279fn metadata_registry() -> &'static Mutex<HashMap<usize, ErrorMetadata>> {
280 static REGISTRY: OnceLock<Mutex<HashMap<usize, ErrorMetadata>>> = OnceLock::new();
281 REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
282}
283
284fn register_metadata(message: &str, metadata: ErrorMetadata) {
285 if metadata == ErrorMetadata::default() {
286 return;
287 }
288 metadata_registry()
289 .lock()
290 .expect("error metadata registry mutex poisoned")
291 .insert(message.as_ptr() as usize, metadata);
292}
293
294fn clone_message_with_metadata(message: &str) -> String {
295 let cloned = message.to_owned();
296 let metadata = metadata_registry()
297 .lock()
298 .expect("error metadata registry mutex poisoned")
299 .get(&(message.as_ptr() as usize))
300 .cloned();
301 if let Some(metadata) = metadata {
302 register_metadata(&cloned, metadata);
303 }
304 cloned
305}
306
307#[derive(Debug, PartialEq, Eq)]
309#[non_exhaustive]
310pub enum FMError {
311 ModelUnavailable {
315 reason: Unavailability,
316 message: String,
317 },
318 GuardrailViolation(String),
321 ContextWindowExceeded(String),
323 UnsupportedLanguage(String),
325 AssetsUnavailable(String),
327 RateLimited(String),
330 DecodingFailure(String),
333 Refusal(String),
336 ConcurrentRequests(String),
338 UnsupportedGuide(String),
340 ToolCallFailed(String),
342 AdapterInvalidAsset(String),
344 AdapterInvalidName(String),
346 AdapterCompatibleNotFound(String),
348 Cancelled,
350 InvalidArgument(String),
352 Unknown { code: i32, message: String },
355}
356
357impl Clone for FMError {
358 fn clone(&self) -> Self {
359 match self {
360 Self::ModelUnavailable { reason, message } => Self::ModelUnavailable {
361 reason: *reason,
362 message: clone_message_with_metadata(message),
363 },
364 Self::GuardrailViolation(message) => {
365 Self::GuardrailViolation(clone_message_with_metadata(message))
366 }
367 Self::ContextWindowExceeded(message) => {
368 Self::ContextWindowExceeded(clone_message_with_metadata(message))
369 }
370 Self::UnsupportedLanguage(message) => {
371 Self::UnsupportedLanguage(clone_message_with_metadata(message))
372 }
373 Self::AssetsUnavailable(message) => {
374 Self::AssetsUnavailable(clone_message_with_metadata(message))
375 }
376 Self::RateLimited(message) => Self::RateLimited(clone_message_with_metadata(message)),
377 Self::DecodingFailure(message) => {
378 Self::DecodingFailure(clone_message_with_metadata(message))
379 }
380 Self::Refusal(message) => Self::Refusal(clone_message_with_metadata(message)),
381 Self::ConcurrentRequests(message) => {
382 Self::ConcurrentRequests(clone_message_with_metadata(message))
383 }
384 Self::UnsupportedGuide(message) => {
385 Self::UnsupportedGuide(clone_message_with_metadata(message))
386 }
387 Self::ToolCallFailed(message) => {
388 Self::ToolCallFailed(clone_message_with_metadata(message))
389 }
390 Self::AdapterInvalidAsset(message) => {
391 Self::AdapterInvalidAsset(clone_message_with_metadata(message))
392 }
393 Self::AdapterInvalidName(message) => {
394 Self::AdapterInvalidName(clone_message_with_metadata(message))
395 }
396 Self::AdapterCompatibleNotFound(message) => {
397 Self::AdapterCompatibleNotFound(clone_message_with_metadata(message))
398 }
399 Self::Cancelled => Self::Cancelled,
400 Self::InvalidArgument(message) => {
401 Self::InvalidArgument(clone_message_with_metadata(message))
402 }
403 Self::Unknown { code, message } => Self::Unknown {
404 code: *code,
405 message: clone_message_with_metadata(message),
406 },
407 }
408 }
409}
410
411#[derive(Debug, Clone, Copy, PartialEq, Eq)]
413#[non_exhaustive]
414pub enum Unavailability {
415 DeviceNotEligible,
417 AppleIntelligenceNotEnabled,
419 ModelNotReady,
421 OsTooOld,
423 Unknown,
426}
427
428impl FMError {
429 fn message_storage(&self) -> Option<&String> {
430 match self {
431 Self::ModelUnavailable { message, .. }
432 | Self::GuardrailViolation(message)
433 | Self::ContextWindowExceeded(message)
434 | Self::UnsupportedLanguage(message)
435 | Self::AssetsUnavailable(message)
436 | Self::RateLimited(message)
437 | Self::DecodingFailure(message)
438 | Self::Refusal(message)
439 | Self::ConcurrentRequests(message)
440 | Self::UnsupportedGuide(message)
441 | Self::ToolCallFailed(message)
442 | Self::AdapterInvalidAsset(message)
443 | Self::AdapterInvalidName(message)
444 | Self::AdapterCompatibleNotFound(message)
445 | Self::InvalidArgument(message)
446 | Self::Unknown { message, .. } => Some(message),
447 Self::Cancelled => None,
448 }
449 }
450
451 fn metadata(&self) -> Option<ErrorMetadata> {
452 let message = self.message_storage()?;
453 metadata_registry()
454 .lock()
455 .expect("error metadata registry mutex poisoned")
456 .get(&(message.as_ptr() as usize))
457 .cloned()
458 }
459
460 #[must_use]
463 pub const fn code(&self) -> i32 {
464 match self {
465 Self::ModelUnavailable { .. } => ffi::status::MODEL_UNAVAILABLE,
466 Self::GuardrailViolation(_) => ffi::status::GUARDRAIL_VIOLATION,
467 Self::ContextWindowExceeded(_) => ffi::status::CONTEXT_WINDOW_EXCEEDED,
468 Self::UnsupportedLanguage(_) => ffi::status::UNSUPPORTED_LANGUAGE,
469 Self::AssetsUnavailable(_) => ffi::status::ASSETS_UNAVAILABLE,
470 Self::RateLimited(_) => ffi::status::RATE_LIMITED,
471 Self::DecodingFailure(_) => ffi::status::DECODING_FAILURE,
472 Self::Refusal(_) => ffi::status::REFUSAL,
473 Self::ConcurrentRequests(_) => ffi::status::CONCURRENT_REQUESTS,
474 Self::UnsupportedGuide(_) => ffi::status::UNSUPPORTED_GUIDE,
475 Self::ToolCallFailed(_) => ffi::status::TOOL_CALL_FAILED,
476 Self::AdapterInvalidAsset(_) => ffi::status::ADAPTER_INVALID_ASSET,
477 Self::AdapterInvalidName(_) => ffi::status::ADAPTER_INVALID_NAME,
478 Self::AdapterCompatibleNotFound(_) => ffi::status::ADAPTER_COMPATIBLE_NOT_FOUND,
479 Self::Cancelled => ffi::status::CANCELLED,
480 Self::InvalidArgument(_) => ffi::status::INVALID_ARGUMENT,
481 Self::Unknown { code, .. } => *code,
482 }
483 }
484
485 #[must_use]
487 pub fn message(&self) -> &str {
488 match self {
489 Self::ModelUnavailable { message, .. }
490 | Self::GuardrailViolation(message)
491 | Self::ContextWindowExceeded(message)
492 | Self::UnsupportedLanguage(message)
493 | Self::AssetsUnavailable(message)
494 | Self::RateLimited(message)
495 | Self::DecodingFailure(message)
496 | Self::Refusal(message)
497 | Self::ConcurrentRequests(message)
498 | Self::UnsupportedGuide(message)
499 | Self::ToolCallFailed(message)
500 | Self::AdapterInvalidAsset(message)
501 | Self::AdapterInvalidName(message)
502 | Self::AdapterCompatibleNotFound(message)
503 | Self::InvalidArgument(message)
504 | Self::Unknown { message, .. } => message,
505 Self::Cancelled => "generation cancelled",
506 }
507 }
508
509 #[must_use]
511 pub fn generation_error_context(&self) -> Option<GenerationErrorContext> {
512 self.metadata()?.generation_error_context
513 }
514
515 #[must_use]
517 pub fn schema_error_context(&self) -> Option<SchemaErrorContext> {
518 self.metadata()?.schema_error_context
519 }
520
521 #[must_use]
523 pub fn recovery_suggestion(&self) -> Option<String> {
524 self.metadata()?.recovery_suggestion
525 }
526
527 #[must_use]
529 pub fn failure_reason(&self) -> Option<String> {
530 self.metadata()?.failure_reason
531 }
532
533 #[must_use]
535 pub fn refusal(&self) -> Option<Refusal> {
536 self.metadata()?.refusal
537 }
538
539 #[must_use]
541 pub fn tool_call_error(&self) -> Option<ToolCallError> {
542 self.metadata()?.tool_call_error
543 }
544}
545
546impl fmt::Display for FMError {
547 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
548 write!(f, "{} (code {})", self.message(), self.code())
549 }
550}
551
552impl std::error::Error for FMError {}
553
554pub(crate) fn from_swift(status: i32, error_str: *mut c_char) -> FMError {
559 let raw_message = if error_str.is_null() {
560 String::new()
561 } else {
562 let value = unsafe { CStr::from_ptr(error_str) }
563 .to_string_lossy()
564 .into_owned();
565 unsafe { ffi::fm_string_free(error_str) };
566 value
567 };
568
569 let (message, metadata) = match serde_json::from_str::<BridgeErrorPayload>(&raw_message) {
570 Ok(payload) => {
571 let message = payload.message.clone();
572 let metadata = payload.into_metadata();
573 (message, Some(metadata))
574 }
575 Err(_) => (raw_message, None),
576 };
577
578 let error = match status {
579 ffi::status::MODEL_UNAVAILABLE => FMError::ModelUnavailable {
580 reason: Unavailability::Unknown,
581 message,
582 },
583 ffi::status::GUARDRAIL_VIOLATION => FMError::GuardrailViolation(message),
584 ffi::status::CONTEXT_WINDOW_EXCEEDED => FMError::ContextWindowExceeded(message),
585 ffi::status::UNSUPPORTED_LANGUAGE => FMError::UnsupportedLanguage(message),
586 ffi::status::ASSETS_UNAVAILABLE => FMError::AssetsUnavailable(message),
587 ffi::status::RATE_LIMITED => FMError::RateLimited(message),
588 ffi::status::DECODING_FAILURE => FMError::DecodingFailure(message),
589 ffi::status::REFUSAL => FMError::Refusal(message),
590 ffi::status::CONCURRENT_REQUESTS => FMError::ConcurrentRequests(message),
591 ffi::status::UNSUPPORTED_GUIDE => FMError::UnsupportedGuide(message),
592 ffi::status::TOOL_CALL_FAILED => FMError::ToolCallFailed(message),
593 ffi::status::ADAPTER_INVALID_ASSET => FMError::AdapterInvalidAsset(message),
594 ffi::status::ADAPTER_INVALID_NAME => FMError::AdapterInvalidName(message),
595 ffi::status::ADAPTER_COMPATIBLE_NOT_FOUND => FMError::AdapterCompatibleNotFound(message),
596 ffi::status::CANCELLED => FMError::Cancelled,
597 ffi::status::INVALID_ARGUMENT => FMError::InvalidArgument(message),
598 code => FMError::Unknown { code, message },
599 };
600
601 if let (Some(message), Some(metadata)) = (error.message_storage(), metadata) {
602 register_metadata(message, metadata);
603 }
604
605 error
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 use serde_json::json;
612
613 fn payload_ptr(value: serde_json::Value) -> *mut c_char {
614 let payload = CString::new(value.to_string()).expect("JSON payloads must not contain NUL");
615 unsafe { ffi::fm_string_dup(payload.as_ptr()) }
616 }
617
618 #[test]
619 fn generation_error_metadata_round_trips() {
620 let error = from_swift(
621 ffi::status::REFUSAL,
622 payload_ptr(json!({
623 "message": "request refused",
624 "recoverySuggestion": "Try a safer prompt",
625 "failureReason": "Safety policy",
626 "generationErrorContext": { "debugDescription": "guardrail refusal" },
627 "refusal": { "token": "refusal-token" }
628 })),
629 );
630 let cloned = error.clone();
631
632 assert_eq!(error.recovery_suggestion(), cloned.recovery_suggestion());
633 assert_eq!(cloned.message(), "request refused");
634 assert_eq!(
635 cloned.recovery_suggestion().as_deref(),
636 Some("Try a safer prompt")
637 );
638 assert_eq!(cloned.failure_reason().as_deref(), Some("Safety policy"));
639 assert_eq!(
640 cloned
641 .generation_error_context()
642 .expect("generation context")
643 .debug_description(),
644 "guardrail refusal"
645 );
646 assert_eq!(cloned.refusal(), Some(Refusal::from_token("refusal-token")));
647 }
648
649 #[test]
650 fn tool_call_error_metadata_round_trips() {
651 let error = from_swift(
652 ffi::status::TOOL_CALL_FAILED,
653 payload_ptr(json!({
654 "message": "tool failed",
655 "toolCallError": {
656 "tool": {
657 "name": "echo",
658 "description": "Echo input",
659 "parametersJSON": "{\"type\":\"object\"}"
660 },
661 "underlyingError": "callback panicked"
662 }
663 })),
664 );
665
666 let tool_call_error = error.tool_call_error().expect("tool call metadata");
667 assert_eq!(tool_call_error.tool().name, "echo");
668 assert_eq!(tool_call_error.tool().description, "Echo input");
669 assert_eq!(
670 tool_call_error.tool().parameters.json_schema(),
671 "{\"type\":\"object\"}"
672 );
673 assert_eq!(tool_call_error.underlying_error(), "callback panicked");
674 }
675
676 #[test]
677 fn schema_error_metadata_round_trips() {
678 let error = from_swift(
679 ffi::status::UNKNOWN,
680 payload_ptr(json!({
681 "message": "schema rejected",
682 "recoverySuggestion": "Rename the duplicate type",
683 "schemaErrorContext": { "debugDescription": "duplicate type Person" }
684 })),
685 );
686
687 assert_eq!(
688 error.recovery_suggestion().as_deref(),
689 Some("Rename the duplicate type")
690 );
691 assert_eq!(
692 error
693 .schema_error_context()
694 .expect("schema context")
695 .debug_description(),
696 "duplicate type Person"
697 );
698 }
699}