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 AdapterAssetErrorContext {
64 debug_description: String,
65}
66
67impl AdapterAssetErrorContext {
68 #[must_use]
70 pub fn new(debug_description: impl Into<String>) -> Self {
71 Self {
72 debug_description: debug_description.into(),
73 }
74 }
75
76 #[must_use]
78 pub fn debug_description(&self) -> &str {
79 &self.debug_description
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct ToolCallError {
86 tool: ToolDefinition,
87 underlying_error: String,
88}
89
90impl ToolCallError {
91 #[must_use]
93 pub fn new(tool: ToolDefinition, underlying_error: impl Into<String>) -> Self {
94 Self {
95 tool,
96 underlying_error: underlying_error.into(),
97 }
98 }
99
100 #[must_use]
102 pub const fn tool(&self) -> &ToolDefinition {
103 &self.tool
104 }
105
106 #[must_use]
108 pub fn underlying_error(&self) -> &str {
109 &self.underlying_error
110 }
111}
112
113#[derive(Debug, Clone, PartialEq)]
115pub struct Refusal {
116 token: Option<String>,
117 transcript: Option<Transcript>,
118}
119
120impl Refusal {
121 #[must_use]
123 pub fn new(entries: impl IntoIterator<Item = Entry>) -> Self {
124 Self {
125 token: None,
126 transcript: Some(Transcript::from_entries(entries.into_iter().collect())),
127 }
128 }
129
130 pub(crate) fn from_token(token: impl Into<String>) -> Self {
131 Self {
132 token: Some(token.into()),
133 transcript: None,
134 }
135 }
136
137 #[must_use]
139 pub fn transcript(&self) -> Option<&Transcript> {
140 self.transcript.as_ref()
141 }
142
143 pub fn explanation(&self) -> Result<SessionResponse<String>, FMError> {
149 if let Some(token) = &self.token {
150 let token = CString::new(token.as_str()).map_err(|error| {
151 FMError::InvalidArgument(format!(
152 "refusal token contains an interior NUL byte: {error}"
153 ))
154 })?;
155 return session::request_text_response_with(|context, callback| unsafe {
156 ffi::fm_refusal_explanation_json(token.as_ptr(), context, callback)
157 });
158 }
159
160 let transcript = self.transcript.as_ref().ok_or_else(|| {
161 FMError::InvalidArgument("refusal does not contain any transcript state".into())
162 })?;
163 let transcript_json = CString::new(transcript.to_json_string()?).map_err(|error| {
164 FMError::InvalidArgument(format!(
165 "refusal transcript JSON contains an interior NUL byte: {error}"
166 ))
167 })?;
168 session::request_text_response_with(|context, callback| unsafe {
169 ffi::fm_refusal_explanation_from_transcript_json(
170 transcript_json.as_ptr(),
171 context,
172 callback,
173 )
174 })
175 }
176
177 pub fn explanation_stream<F>(&self, on_chunk: F) -> Result<(), FMError>
183 where
184 F: FnMut(StreamEvent<'_>) + Send + 'static,
185 {
186 if let Some(token) = &self.token {
187 let token = CString::new(token.as_str()).map_err(|error| {
188 FMError::InvalidArgument(format!(
189 "refusal token contains an interior NUL byte: {error}"
190 ))
191 })?;
192 return session::run_text_stream_with(
193 |context, callback| unsafe {
194 ffi::fm_refusal_explanation_stream(token.as_ptr(), context, callback)
195 },
196 on_chunk,
197 );
198 }
199
200 let transcript = self.transcript.as_ref().ok_or_else(|| {
201 FMError::InvalidArgument("refusal does not contain any transcript state".into())
202 })?;
203 let transcript_json = CString::new(transcript.to_json_string()?).map_err(|error| {
204 FMError::InvalidArgument(format!(
205 "refusal transcript JSON contains an interior NUL byte: {error}"
206 ))
207 })?;
208 session::run_text_stream_with(
209 |context, callback| unsafe {
210 ffi::fm_refusal_explanation_stream_from_transcript_json(
211 transcript_json.as_ptr(),
212 context,
213 callback,
214 )
215 },
216 on_chunk,
217 )
218 }
219}
220
221#[derive(Debug, Clone, Default, PartialEq)]
222struct ErrorMetadata {
223 recovery_suggestion: Option<String>,
224 failure_reason: Option<String>,
225 generation_error_context: Option<GenerationErrorContext>,
226 adapter_asset_error_context: Option<AdapterAssetErrorContext>,
227 schema_error_context: Option<SchemaErrorContext>,
228 refusal: Option<Refusal>,
229 tool_call_error: Option<ToolCallError>,
230}
231
232#[derive(Debug, Deserialize)]
233struct BridgeErrorContext {
234 #[serde(rename = "debugDescription")]
235 debug_description: String,
236}
237
238#[derive(Debug, Deserialize)]
239struct BridgeRefusal {
240 token: String,
241}
242
243#[derive(Debug, Deserialize)]
244struct BridgeToolDefinition {
245 name: String,
246 description: String,
247 #[serde(rename = "parametersJSON")]
248 parameters_json: String,
249}
250
251#[derive(Debug, Deserialize)]
252struct BridgeToolCallError {
253 tool: BridgeToolDefinition,
254 #[serde(rename = "underlyingError")]
255 underlying_error: String,
256}
257
258#[derive(Debug, Deserialize)]
259struct BridgeErrorPayload {
260 message: String,
261 #[serde(rename = "recoverySuggestion")]
262 recovery_suggestion: Option<String>,
263 #[serde(rename = "failureReason")]
264 failure_reason: Option<String>,
265 #[serde(rename = "generationErrorContext")]
266 generation_error_context: Option<BridgeErrorContext>,
267 refusal: Option<BridgeRefusal>,
268 #[serde(rename = "toolCallError")]
269 tool_call_error: Option<BridgeToolCallError>,
270 #[serde(rename = "adapterAssetErrorContext")]
271 adapter_asset_error_context: Option<BridgeErrorContext>,
272 #[serde(rename = "schemaErrorContext")]
273 schema_error_context: Option<BridgeErrorContext>,
274}
275
276impl BridgeErrorPayload {
277 fn into_metadata(self) -> ErrorMetadata {
278 ErrorMetadata {
279 recovery_suggestion: self.recovery_suggestion,
280 failure_reason: self.failure_reason,
281 generation_error_context: self
282 .generation_error_context
283 .map(|context| GenerationErrorContext::new(context.debug_description)),
284 adapter_asset_error_context: self
285 .adapter_asset_error_context
286 .map(|context| AdapterAssetErrorContext::new(context.debug_description)),
287 schema_error_context: self
288 .schema_error_context
289 .map(|context| SchemaErrorContext::new(context.debug_description)),
290 refusal: self
291 .refusal
292 .map(|refusal| Refusal::from_token(refusal.token)),
293 tool_call_error: self.tool_call_error.map(|error| {
294 ToolCallError::new(
295 ToolDefinition::new(
296 error.tool.name,
297 error.tool.description,
298 GenerationSchema::from_json_schema_unchecked(error.tool.parameters_json),
299 ),
300 error.underlying_error,
301 )
302 }),
303 }
304 }
305}
306
307fn metadata_registry() -> &'static Mutex<HashMap<usize, ErrorMetadata>> {
308 static REGISTRY: OnceLock<Mutex<HashMap<usize, ErrorMetadata>>> = OnceLock::new();
309 REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
310}
311
312fn register_metadata(message: &str, metadata: ErrorMetadata) {
313 if metadata == ErrorMetadata::default() {
314 return;
315 }
316 metadata_registry()
317 .lock()
318 .expect("error metadata registry mutex poisoned")
319 .insert(message.as_ptr() as usize, metadata);
320}
321
322fn clone_message_with_metadata(message: &str) -> String {
323 let cloned = message.to_owned();
324 let metadata = metadata_registry()
325 .lock()
326 .expect("error metadata registry mutex poisoned")
327 .get(&(message.as_ptr() as usize))
328 .cloned();
329 if let Some(metadata) = metadata {
330 register_metadata(&cloned, metadata);
331 }
332 cloned
333}
334
335#[derive(Debug, PartialEq, Eq)]
337#[non_exhaustive]
338pub enum FMError {
339 ModelUnavailable {
343 reason: Unavailability,
344 message: String,
345 },
346 GuardrailViolation(String),
349 ContextWindowExceeded(String),
351 UnsupportedLanguage(String),
353 AssetsUnavailable(String),
355 RateLimited(String),
358 DecodingFailure(String),
361 Refusal(String),
364 ConcurrentRequests(String),
366 UnsupportedGuide(String),
368 ToolCallFailed(String),
370 AdapterInvalidAsset(String),
372 AdapterInvalidName(String),
374 AdapterCompatibleNotFound(String),
376 Cancelled,
378 InvalidArgument(String),
380 Unknown { code: i32, message: String },
383}
384
385impl Clone for FMError {
386 fn clone(&self) -> Self {
387 match self {
388 Self::ModelUnavailable { reason, message } => Self::ModelUnavailable {
389 reason: *reason,
390 message: clone_message_with_metadata(message),
391 },
392 Self::GuardrailViolation(message) => {
393 Self::GuardrailViolation(clone_message_with_metadata(message))
394 }
395 Self::ContextWindowExceeded(message) => {
396 Self::ContextWindowExceeded(clone_message_with_metadata(message))
397 }
398 Self::UnsupportedLanguage(message) => {
399 Self::UnsupportedLanguage(clone_message_with_metadata(message))
400 }
401 Self::AssetsUnavailable(message) => {
402 Self::AssetsUnavailable(clone_message_with_metadata(message))
403 }
404 Self::RateLimited(message) => Self::RateLimited(clone_message_with_metadata(message)),
405 Self::DecodingFailure(message) => {
406 Self::DecodingFailure(clone_message_with_metadata(message))
407 }
408 Self::Refusal(message) => Self::Refusal(clone_message_with_metadata(message)),
409 Self::ConcurrentRequests(message) => {
410 Self::ConcurrentRequests(clone_message_with_metadata(message))
411 }
412 Self::UnsupportedGuide(message) => {
413 Self::UnsupportedGuide(clone_message_with_metadata(message))
414 }
415 Self::ToolCallFailed(message) => {
416 Self::ToolCallFailed(clone_message_with_metadata(message))
417 }
418 Self::AdapterInvalidAsset(message) => {
419 Self::AdapterInvalidAsset(clone_message_with_metadata(message))
420 }
421 Self::AdapterInvalidName(message) => {
422 Self::AdapterInvalidName(clone_message_with_metadata(message))
423 }
424 Self::AdapterCompatibleNotFound(message) => {
425 Self::AdapterCompatibleNotFound(clone_message_with_metadata(message))
426 }
427 Self::Cancelled => Self::Cancelled,
428 Self::InvalidArgument(message) => {
429 Self::InvalidArgument(clone_message_with_metadata(message))
430 }
431 Self::Unknown { code, message } => Self::Unknown {
432 code: *code,
433 message: clone_message_with_metadata(message),
434 },
435 }
436 }
437}
438
439#[derive(Debug, Clone, Copy, PartialEq, Eq)]
441#[non_exhaustive]
442pub enum Unavailability {
443 DeviceNotEligible,
445 AppleIntelligenceNotEnabled,
447 ModelNotReady,
449 OsTooOld,
451 Unknown,
454}
455
456impl FMError {
457 fn message_storage(&self) -> Option<&String> {
458 match self {
459 Self::ModelUnavailable { message, .. }
460 | Self::GuardrailViolation(message)
461 | Self::ContextWindowExceeded(message)
462 | Self::UnsupportedLanguage(message)
463 | Self::AssetsUnavailable(message)
464 | Self::RateLimited(message)
465 | Self::DecodingFailure(message)
466 | Self::Refusal(message)
467 | Self::ConcurrentRequests(message)
468 | Self::UnsupportedGuide(message)
469 | Self::ToolCallFailed(message)
470 | Self::AdapterInvalidAsset(message)
471 | Self::AdapterInvalidName(message)
472 | Self::AdapterCompatibleNotFound(message)
473 | Self::InvalidArgument(message)
474 | Self::Unknown { message, .. } => Some(message),
475 Self::Cancelled => None,
476 }
477 }
478
479 fn metadata(&self) -> Option<ErrorMetadata> {
480 let message = self.message_storage()?;
481 metadata_registry()
482 .lock()
483 .expect("error metadata registry mutex poisoned")
484 .get(&(message.as_ptr() as usize))
485 .cloned()
486 }
487
488 #[must_use]
491 pub const fn code(&self) -> i32 {
492 match self {
493 Self::ModelUnavailable { .. } => ffi::status::MODEL_UNAVAILABLE,
494 Self::GuardrailViolation(_) => ffi::status::GUARDRAIL_VIOLATION,
495 Self::ContextWindowExceeded(_) => ffi::status::CONTEXT_WINDOW_EXCEEDED,
496 Self::UnsupportedLanguage(_) => ffi::status::UNSUPPORTED_LANGUAGE,
497 Self::AssetsUnavailable(_) => ffi::status::ASSETS_UNAVAILABLE,
498 Self::RateLimited(_) => ffi::status::RATE_LIMITED,
499 Self::DecodingFailure(_) => ffi::status::DECODING_FAILURE,
500 Self::Refusal(_) => ffi::status::REFUSAL,
501 Self::ConcurrentRequests(_) => ffi::status::CONCURRENT_REQUESTS,
502 Self::UnsupportedGuide(_) => ffi::status::UNSUPPORTED_GUIDE,
503 Self::ToolCallFailed(_) => ffi::status::TOOL_CALL_FAILED,
504 Self::AdapterInvalidAsset(_) => ffi::status::ADAPTER_INVALID_ASSET,
505 Self::AdapterInvalidName(_) => ffi::status::ADAPTER_INVALID_NAME,
506 Self::AdapterCompatibleNotFound(_) => ffi::status::ADAPTER_COMPATIBLE_NOT_FOUND,
507 Self::Cancelled => ffi::status::CANCELLED,
508 Self::InvalidArgument(_) => ffi::status::INVALID_ARGUMENT,
509 Self::Unknown { code, .. } => *code,
510 }
511 }
512
513 #[must_use]
515 pub fn message(&self) -> &str {
516 match self {
517 Self::ModelUnavailable { message, .. }
518 | Self::GuardrailViolation(message)
519 | Self::ContextWindowExceeded(message)
520 | Self::UnsupportedLanguage(message)
521 | Self::AssetsUnavailable(message)
522 | Self::RateLimited(message)
523 | Self::DecodingFailure(message)
524 | Self::Refusal(message)
525 | Self::ConcurrentRequests(message)
526 | Self::UnsupportedGuide(message)
527 | Self::ToolCallFailed(message)
528 | Self::AdapterInvalidAsset(message)
529 | Self::AdapterInvalidName(message)
530 | Self::AdapterCompatibleNotFound(message)
531 | Self::InvalidArgument(message)
532 | Self::Unknown { message, .. } => message,
533 Self::Cancelled => "generation cancelled",
534 }
535 }
536
537 #[must_use]
539 pub fn generation_error_context(&self) -> Option<GenerationErrorContext> {
540 self.metadata()?.generation_error_context
541 }
542
543 #[must_use]
545 pub fn adapter_asset_error_context(&self) -> Option<AdapterAssetErrorContext> {
546 self.metadata()?.adapter_asset_error_context
547 }
548
549 #[must_use]
551 pub fn schema_error_context(&self) -> Option<SchemaErrorContext> {
552 self.metadata()?.schema_error_context
553 }
554
555 #[must_use]
557 pub fn recovery_suggestion(&self) -> Option<String> {
558 self.metadata()?.recovery_suggestion
559 }
560
561 #[must_use]
563 pub fn failure_reason(&self) -> Option<String> {
564 self.metadata()?.failure_reason
565 }
566
567 #[must_use]
569 pub fn refusal(&self) -> Option<Refusal> {
570 self.metadata()?.refusal
571 }
572
573 #[must_use]
575 pub fn tool_call_error(&self) -> Option<ToolCallError> {
576 self.metadata()?.tool_call_error
577 }
578}
579
580impl fmt::Display for FMError {
581 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
582 write!(f, "{} (code {})", self.message(), self.code())
583 }
584}
585
586impl std::error::Error for FMError {}
587
588pub(crate) fn from_swift(status: i32, error_str: *mut c_char) -> FMError {
593 let raw_message = if error_str.is_null() {
594 String::new()
595 } else {
596 let value = unsafe { CStr::from_ptr(error_str) }
597 .to_string_lossy()
598 .into_owned();
599 unsafe { ffi::fm_string_free(error_str) };
600 value
601 };
602
603 let (message, metadata) = match serde_json::from_str::<BridgeErrorPayload>(&raw_message) {
604 Ok(payload) => {
605 let message = payload.message.clone();
606 let metadata = payload.into_metadata();
607 (message, Some(metadata))
608 }
609 Err(_) => (raw_message, None),
610 };
611
612 let error = match status {
613 ffi::status::MODEL_UNAVAILABLE => FMError::ModelUnavailable {
614 reason: Unavailability::Unknown,
615 message,
616 },
617 ffi::status::GUARDRAIL_VIOLATION => FMError::GuardrailViolation(message),
618 ffi::status::CONTEXT_WINDOW_EXCEEDED => FMError::ContextWindowExceeded(message),
619 ffi::status::UNSUPPORTED_LANGUAGE => FMError::UnsupportedLanguage(message),
620 ffi::status::ASSETS_UNAVAILABLE => FMError::AssetsUnavailable(message),
621 ffi::status::RATE_LIMITED => FMError::RateLimited(message),
622 ffi::status::DECODING_FAILURE => FMError::DecodingFailure(message),
623 ffi::status::REFUSAL => FMError::Refusal(message),
624 ffi::status::CONCURRENT_REQUESTS => FMError::ConcurrentRequests(message),
625 ffi::status::UNSUPPORTED_GUIDE => FMError::UnsupportedGuide(message),
626 ffi::status::TOOL_CALL_FAILED => FMError::ToolCallFailed(message),
627 ffi::status::ADAPTER_INVALID_ASSET => FMError::AdapterInvalidAsset(message),
628 ffi::status::ADAPTER_INVALID_NAME => FMError::AdapterInvalidName(message),
629 ffi::status::ADAPTER_COMPATIBLE_NOT_FOUND => FMError::AdapterCompatibleNotFound(message),
630 ffi::status::CANCELLED => FMError::Cancelled,
631 ffi::status::INVALID_ARGUMENT => FMError::InvalidArgument(message),
632 code => FMError::Unknown { code, message },
633 };
634
635 if let (Some(message), Some(metadata)) = (error.message_storage(), metadata) {
636 register_metadata(message, metadata);
637 }
638
639 error
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645 use serde_json::json;
646
647 fn payload_ptr(value: serde_json::Value) -> *mut c_char {
648 let payload = CString::new(value.to_string()).expect("JSON payloads must not contain NUL");
649 unsafe { ffi::fm_string_dup(payload.as_ptr()) }
650 }
651
652 #[test]
653 fn generation_error_metadata_round_trips() {
654 let error = from_swift(
655 ffi::status::REFUSAL,
656 payload_ptr(json!({
657 "message": "request refused",
658 "recoverySuggestion": "Try a safer prompt",
659 "failureReason": "Safety policy",
660 "generationErrorContext": { "debugDescription": "guardrail refusal" },
661 "refusal": { "token": "refusal-token" }
662 })),
663 );
664 let cloned = error.clone();
665
666 assert_eq!(error.recovery_suggestion(), cloned.recovery_suggestion());
667 assert_eq!(cloned.message(), "request refused");
668 assert_eq!(
669 cloned.recovery_suggestion().as_deref(),
670 Some("Try a safer prompt")
671 );
672 assert_eq!(cloned.failure_reason().as_deref(), Some("Safety policy"));
673 assert_eq!(
674 cloned
675 .generation_error_context()
676 .expect("generation context")
677 .debug_description(),
678 "guardrail refusal"
679 );
680 assert_eq!(cloned.refusal(), Some(Refusal::from_token("refusal-token")));
681 }
682
683 #[test]
684 fn tool_call_error_metadata_round_trips() {
685 let error = from_swift(
686 ffi::status::TOOL_CALL_FAILED,
687 payload_ptr(json!({
688 "message": "tool failed",
689 "toolCallError": {
690 "tool": {
691 "name": "echo",
692 "description": "Echo input",
693 "parametersJSON": "{\"type\":\"object\"}"
694 },
695 "underlyingError": "callback panicked"
696 }
697 })),
698 );
699
700 let tool_call_error = error.tool_call_error().expect("tool call metadata");
701 assert_eq!(tool_call_error.tool().name, "echo");
702 assert_eq!(tool_call_error.tool().description, "Echo input");
703 assert_eq!(
704 tool_call_error.tool().parameters.json_schema(),
705 "{\"type\":\"object\"}"
706 );
707 assert_eq!(tool_call_error.underlying_error(), "callback panicked");
708 }
709
710 #[test]
711 fn schema_error_metadata_round_trips() {
712 let error = from_swift(
713 ffi::status::UNKNOWN,
714 payload_ptr(json!({
715 "message": "schema rejected",
716 "recoverySuggestion": "Rename the duplicate type",
717 "schemaErrorContext": { "debugDescription": "duplicate type Person" }
718 })),
719 );
720
721 assert_eq!(
722 error.recovery_suggestion().as_deref(),
723 Some("Rename the duplicate type")
724 );
725 assert_eq!(
726 error
727 .schema_error_context()
728 .expect("schema context")
729 .debug_description(),
730 "duplicate type Person"
731 );
732 }
733
734 #[test]
735 fn adapter_asset_error_metadata_round_trips() {
736 let error = from_swift(
737 ffi::status::ADAPTER_INVALID_NAME,
738 payload_ptr(json!({
739 "message": "adapter not found",
740 "recoverySuggestion": "Install a compatible adapter first",
741 "adapterAssetErrorContext": { "debugDescription": "missing adapter metadata" }
742 })),
743 );
744
745 assert_eq!(
746 error.recovery_suggestion().as_deref(),
747 Some("Install a compatible adapter first")
748 );
749 assert_eq!(
750 error
751 .adapter_asset_error_context()
752 .expect("adapter asset context")
753 .debug_description(),
754 "missing adapter metadata"
755 );
756 }
757}