Skip to main content

foundation_models/session/
mod.rs

1//! [`LanguageModelSession`] — a stateful conversation with the on-device model.
2
3use core::ffi::{c_char, c_void};
4use core::ptr;
5use std::ffi::CString;
6use std::sync::mpsc;
7use std::sync::{Arc, Mutex};
8
9use serde::Deserialize;
10use serde_json::json;
11
12use crate::content::{BridgeGeneratedContent, GeneratedContent};
13use crate::error::FMError;
14use crate::ffi;
15use crate::generation::{GenerationOptions, SamplingMode};
16use crate::model::ConfiguredSystemLanguageModel;
17use crate::prompt::{Instructions, Prompt, ToInstructions, ToPrompt};
18use crate::schema::GenerationSchema;
19use crate::tool::{tool_callback_trampoline, Tool, ToolRegistry};
20use crate::transcript::Transcript;
21
22/// A stateful conversation with the on-device language model.
23///
24/// Sessions retain their conversation history; subsequent calls to
25/// [`respond`](Self::respond) build on the previous turns.
26///
27/// # Examples
28///
29/// ```rust,no_run
30/// use foundation_models::LanguageModelSession;
31///
32/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
33/// let session = LanguageModelSession::new();
34/// let answer = session.respond("Name three Norse gods.")?;
35/// println!("{answer}");
36/// # Ok(())
37/// # }
38/// ```
39pub struct LanguageModelSession {
40    ptr: *mut c_void,
41    _tool_registry: Option<Arc<ToolRegistry>>,
42}
43
44// SAFETY: The underlying Swift LanguageModelSession is reference-counted via
45// Unmanaged.passRetained on the Swift side; sending the opaque pointer between
46// threads is safe as long as we don't dereference it from Rust (we never do —
47// it only travels through extern "C" calls that internally hop to the
48// Swift concurrency executor).
49unsafe impl Send for LanguageModelSession {}
50unsafe impl Sync for LanguageModelSession {}
51
52impl LanguageModelSession {
53    /// Create a session with the model's default behaviour.
54    ///
55    /// # Panics
56    ///
57    /// Panics if `FoundationModels` is not available on this OS. Check
58    /// [`crate::SystemLanguageModel::is_available`] first if you need to
59    /// handle that gracefully.
60    #[must_use]
61    pub fn new() -> Self {
62        Self::try_new(None).expect("FoundationModels is not available on this OS")
63    }
64
65    /// Create a session with custom system instructions ("system prompt").
66    ///
67    /// # Panics
68    ///
69    /// Panics if `FoundationModels` is not available, or if `instructions`
70    /// contains an interior NUL byte.
71    #[must_use]
72    pub fn with_instructions(instructions: &str) -> Self {
73        Self::try_new(Some(instructions)).expect("FoundationModels is not available on this OS")
74    }
75
76    /// Fallible constructor. Returns `None` when `FoundationModels` is not
77    /// available (OS too old, model not enabled, etc.) or when `instructions`
78    /// contains an interior NUL byte.
79    #[must_use]
80    pub fn try_new(instructions: Option<&str>) -> Option<Self> {
81        let cstring = match instructions {
82            Some(s) => Some(CString::new(s).ok()?),
83            None => None,
84        };
85        let ptr =
86            unsafe { ffi::fm_session_create(cstring.as_ref().map_or(ptr::null(), |s| s.as_ptr())) };
87        if ptr.is_null() {
88            return None;
89        }
90        Some(Self {
91            ptr,
92            _tool_registry: None,
93        })
94    }
95
96    /// Send a prompt and block until the full response is available.
97    ///
98    /// # Errors
99    ///
100    /// Returns an [`FMError`] if the model rejects the prompt, the context
101    /// window is exceeded, the session is cancelled, or the prompt contains
102    /// an interior NUL byte.
103    pub fn respond(&self, prompt: &str) -> Result<String, FMError> {
104        self.respond_with(prompt, GenerationOptions::new())
105    }
106
107    /// Pre-warm the model. Apple loads the weights + initialises the
108    /// inference engine so the next `respond` call is faster. Returns
109    /// immediately; the warm-up runs in the background.
110    pub fn prewarm(&self) {
111        unsafe { ffi::fm_session_prewarm(self.ptr) };
112    }
113
114    /// True if this session is currently producing a response (i.e. an
115    /// earlier `respond` / `stream` is still in flight on Apple's queue).
116    #[must_use]
117    pub fn is_responding(&self) -> bool {
118        unsafe { ffi::fm_session_is_responding(self.ptr) }
119    }
120
121    /// Return a best-effort JSON serialisation of the session's
122    /// `Transcript` — the full history of user prompts and model
123    /// responses. Useful for persisting a chat session across
124    /// process boundaries.
125    #[must_use]
126    pub fn transcript_json(&self) -> String {
127        let p = unsafe { ffi::fm_session_transcript_json(self.ptr) };
128        if p.is_null() {
129            return String::from("{}");
130        }
131        let s = unsafe { core::ffi::CStr::from_ptr(p) }
132            .to_string_lossy()
133            .into_owned();
134        unsafe { ffi::fm_string_free(p) };
135        s
136    }
137
138    /// Log feedback on the most recent response for diagnostic /
139    /// fine-tuning purposes. `sentiment`:
140    /// `1` positive, `0` neutral, `-1` negative.
141    pub fn log_feedback(&self, sentiment: i32, description: Option<&str>) {
142        let cstr = description.and_then(|s| CString::new(s).ok());
143        let p = cstr.as_ref().map_or(core::ptr::null(), |c| c.as_ptr());
144        unsafe { ffi::fm_session_log_feedback(self.ptr, sentiment, p) };
145    }
146
147    /// Prompt-engineered JSON-shape response.
148    ///
149    /// Wraps the prompt with a "respond with valid JSON matching this schema"
150    /// instruction and parses the response. The schema is a
151    /// `serde_json::Value`-style JSON string (passed as text).
152    ///
153    /// Useful for getting structured data out of the model without the
154    /// full Generable macro machinery. The model still returns plain
155    /// text — the caller must parse with `serde_json` / `serde` after.
156    ///
157    /// # Errors
158    ///
159    /// See [`respond`](Self::respond).
160    pub fn respond_with_json_schema(
161        &self,
162        prompt: &str,
163        schema_description: &str,
164    ) -> Result<String, FMError> {
165        let wrapped = format!(
166            "{prompt}\n\n\
167             IMPORTANT: respond with VALID JSON ONLY (no prose, no markdown \
168             fences) that matches this schema:\n\n{schema_description}\n\n\
169             Your entire response must be parseable by JSON.parse()."
170        );
171        self.respond(&wrapped)
172    }
173
174    /// Like [`respond`](Self::respond), but with explicit generation options.
175    ///
176    /// # Errors
177    ///
178    /// See [`respond`](Self::respond).
179    pub fn respond_with(
180        &self,
181        prompt: &str,
182        options: GenerationOptions,
183    ) -> Result<String, FMError> {
184        self.respond_prompt_with(prompt, options)
185    }
186
187    /// Schema-driven structured response.
188    ///
189    /// Builds a `DynamicGenerationSchema` from the provided JSON
190    /// schema, runs `LanguageModelSession.respond(schema:prompt:)`,
191    /// and returns the model's `GeneratedContent.jsonString` — a
192    /// well-formed JSON string matching the requested shape.
193    ///
194    /// Supported `schema` shape (strict subset of JSON Schema):
195    ///
196    /// ```json
197    /// {
198    ///   "type": "object",
199    ///   "name": "Movie",
200    ///   "properties": {
201    ///     "title":  { "type": "string", "description": "Movie title" },
202    ///     "year":   { "type": "integer" },
203    ///     "rating": { "type": "number", "optional": true },
204    ///     "tags":   { "type": "array", "items": { "type": "string" }, "min": 1, "max": 5 }
205    ///   }
206    /// }
207    /// ```
208    ///
209    /// Primitive types: `"string"`, `"integer"`, `"number"`,
210    /// `"boolean"`, `"array"`, `"object"`. Each property may set
211    /// `"description"` and `"optional"`. Array schemas accept
212    /// `"items"` plus optional `"min"` / `"max"` element counts.
213    ///
214    /// # Errors
215    ///
216    /// See [`respond`](Self::respond) for general errors, plus a
217    /// "schema build failed" / "schema JSON is not valid" error
218    /// returned as [`FMError::Unknown`] if the schema is malformed.
219    pub fn respond_with_schema(
220        &self,
221        prompt: &str,
222        schema: &str,
223        include_schema_in_prompt: bool,
224    ) -> Result<String, FMError> {
225        self.respond_with_schema_options(
226            prompt,
227            schema,
228            include_schema_in_prompt,
229            GenerationOptions::new(),
230        )
231    }
232
233    /// [`respond_with_schema`](Self::respond_with_schema) with
234    /// explicit generation options.
235    ///
236    /// # Errors
237    ///
238    /// See [`respond_with_schema`](Self::respond_with_schema).
239    pub fn respond_with_schema_options(
240        &self,
241        prompt: &str,
242        schema: &str,
243        include_schema_in_prompt: bool,
244        options: GenerationOptions,
245    ) -> Result<String, FMError> {
246        let prompt_c = CString::new(prompt)
247            .map_err(|e| FMError::InvalidArgument(format!("prompt NUL byte: {e}")))?;
248        let schema_c = CString::new(schema)
249            .map_err(|e| FMError::InvalidArgument(format!("schema NUL byte: {e}")))?;
250        let opts = options.to_ffi();
251        let (tx, rx) = mpsc::channel();
252        let tx_box: Box<mpsc::Sender<Result<String, FMError>>> = Box::new(tx);
253        let context = Box::into_raw(tx_box).cast::<c_void>();
254
255        unsafe {
256            ffi::fm_session_respond_with_schema(
257                self.ptr,
258                prompt_c.as_ptr(),
259                schema_c.as_ptr(),
260                include_schema_in_prompt,
261                opts.temperature,
262                opts.maximum_response_tokens,
263                opts.sampling_mode,
264                opts.top_k,
265                opts.top_p,
266                context,
267                respond_trampoline,
268            );
269        }
270
271        rx.recv().map_err(|_| FMError::Unknown {
272            code: ffi::status::UNKNOWN,
273            message: "Swift bridge dropped the callback channel".into(),
274        })?
275    }
276
277    /// Stream the response as the model generates it. The callback is invoked
278    /// with each delta and a final invocation with `done == true`.
279    ///
280    /// # Errors
281    ///
282    /// Returns an [`FMError`] mirroring [`respond`](Self::respond). The
283    /// callback may also receive a chunk *and* an error if the stream fails
284    /// midway.
285    pub fn stream<F>(&self, prompt: &str, mut on_chunk: F) -> Result<(), FMError>
286    where
287        F: FnMut(StreamEvent<'_>) + Send + 'static,
288    {
289        self.stream_with(prompt, GenerationOptions::new(), move |event| {
290            on_chunk(event);
291        })
292    }
293
294    /// Like [`stream`](Self::stream), but with explicit generation options.
295    ///
296    /// # Errors
297    ///
298    /// See [`stream`](Self::stream).
299    pub fn stream_with<F>(
300        &self,
301        prompt: &str,
302        options: GenerationOptions,
303        on_chunk: F,
304    ) -> Result<(), FMError>
305    where
306        F: FnMut(StreamEvent<'_>) + Send + 'static,
307    {
308        let payload = respond_request_json(&Prompt::from(prompt), options, None, true)?;
309
310        let (done_tx, done_rx) = mpsc::channel::<Result<(), FMError>>();
311        let state = Arc::new(StreamState {
312            on_chunk: Mutex::new(Box::new(on_chunk)),
313            done_tx: Mutex::new(Some(done_tx)),
314        });
315        let context = Arc::into_raw(state).cast::<c_void>().cast_mut();
316
317        unsafe {
318            ffi::fm_session_stream_request_json(
319                self.ptr,
320                payload.as_ptr(),
321                context,
322                json_text_stream_trampoline,
323            )
324        };
325
326        done_rx.recv().map_err(|_| FMError::Unknown {
327            code: ffi::status::UNKNOWN,
328            message: "Swift bridge dropped the stream channel".into(),
329        })?
330    }
331}
332
333impl LanguageModelSession {
334    /// Create a configurable session builder.
335    #[must_use]
336    pub fn builder<'a>() -> SessionBuilder<'a> {
337        SessionBuilder::new()
338    }
339
340    /// Restore a session from a transcript.
341    ///
342    /// # Errors
343    ///
344    /// Returns an [`FMError`] if the transcript cannot be encoded for Swift.
345    pub fn from_transcript(transcript: Transcript) -> Result<Self, FMError> {
346        Self::builder().transcript(transcript).build()
347    }
348
349    /// Return the typed transcript for this session.
350    ///
351    /// # Errors
352    ///
353    /// Returns an [`FMError`] if the transcript JSON returned by Swift could not
354    /// be decoded.
355    pub fn transcript(&self) -> Result<Transcript, FMError> {
356        Transcript::from_json_str(&self.transcript_json())
357    }
358
359    /// Pre-warm the model using a prompt prefix.
360    ///
361    /// # Errors
362    ///
363    /// Returns an [`FMError`] if the prompt cannot be encoded for Swift.
364    pub fn prewarm_with_prompt<P>(&self, prompt: P) -> Result<(), FMError>
365    where
366        P: ToPrompt,
367    {
368        let prompt = prompt.to_prompt()?;
369        let prompt_json = CString::new(prompt.to_bridge_json()?).map_err(|error| {
370            FMError::InvalidArgument(format!("prompt JSON contains a NUL byte: {error}"))
371        })?;
372        let mut error: *mut c_char = ptr::null_mut();
373        let status = unsafe {
374            ffi::fm_session_prewarm_prompt_json(self.ptr, prompt_json.as_ptr(), &mut error)
375        };
376        if status != ffi::status::OK {
377            return Err(crate::error::from_swift(status, error));
378        }
379        Ok(())
380    }
381
382    /// Respond to a structured prompt and return only the generated text.
383    ///
384    /// # Errors
385    ///
386    /// Returns an [`FMError`] if generation fails.
387    pub fn respond_prompt<P>(&self, prompt: P) -> Result<String, FMError>
388    where
389        P: ToPrompt,
390    {
391        self.respond_prompt_with(prompt, GenerationOptions::new())
392    }
393
394    /// Like [`respond_prompt`](Self::respond_prompt), but with explicit options.
395    ///
396    /// # Errors
397    ///
398    /// Returns an [`FMError`] if generation fails.
399    pub fn respond_prompt_with<P>(
400        &self,
401        prompt: P,
402        options: GenerationOptions,
403    ) -> Result<String, FMError>
404    where
405        P: ToPrompt,
406    {
407        self.respond_prompt_detailed(prompt, options)
408            .map(|response| response.content)
409    }
410
411    /// Respond to a structured prompt and keep the full response metadata.
412    ///
413    /// # Errors
414    ///
415    /// Returns an [`FMError`] if generation fails.
416    pub fn respond_prompt_detailed<P>(
417        &self,
418        prompt: P,
419        options: GenerationOptions,
420    ) -> Result<SessionResponse<String>, FMError>
421    where
422        P: ToPrompt,
423    {
424        let prompt = prompt.to_prompt()?;
425        let payload = respond_request_json(&prompt, options, None, true)?;
426        let payload = request_response(self.ptr, &payload)?;
427        let response: BridgeTextResponse = serde_json::from_str(&payload)
428            .map_err(|error| FMError::DecodingFailure(error.to_string()))?;
429        Ok(SessionResponse {
430            content: response.content,
431            raw_content: GeneratedContent::from_bridge_payload(response.raw_content, true)?,
432            transcript: Transcript::from_json_str(&response.transcript_json)?,
433        })
434    }
435
436    /// Generate structured content using an explicit schema.
437    ///
438    /// # Errors
439    ///
440    /// Returns an [`FMError`] if generation fails or the schema is invalid.
441    pub fn respond_generated<P>(
442        &self,
443        prompt: P,
444        schema: &GenerationSchema,
445        include_schema_in_prompt: bool,
446    ) -> Result<GeneratedContent, FMError>
447    where
448        P: ToPrompt,
449    {
450        self.respond_generated_with(
451            prompt,
452            schema,
453            include_schema_in_prompt,
454            GenerationOptions::new(),
455        )
456        .map(|response| response.content)
457    }
458
459    /// Like [`respond_generated`](Self::respond_generated), but with explicit options.
460    ///
461    /// # Errors
462    ///
463    /// Returns an [`FMError`] if generation fails or the schema is invalid.
464    pub fn respond_generated_with<P>(
465        &self,
466        prompt: P,
467        schema: &GenerationSchema,
468        include_schema_in_prompt: bool,
469        options: GenerationOptions,
470    ) -> Result<SessionResponse<GeneratedContent>, FMError>
471    where
472        P: ToPrompt,
473    {
474        let prompt = prompt.to_prompt()?;
475        let payload =
476            respond_request_json(&prompt, options, Some(schema), include_schema_in_prompt)?;
477        let payload = request_response(self.ptr, &payload)?;
478        let response: BridgeStructuredResponse = serde_json::from_str(&payload)
479            .map_err(|error| FMError::DecodingFailure(error.to_string()))?;
480        Ok(SessionResponse {
481            content: GeneratedContent::from_bridge_payload(response.content, true)?,
482            raw_content: GeneratedContent::from_bridge_payload(response.raw_content, true)?,
483            transcript: Transcript::from_json_str(&response.transcript_json)?,
484        })
485    }
486
487    /// Generate a typed Rust value using a [`crate::schema::Generable`] implementation.
488    ///
489    /// # Errors
490    ///
491    /// Returns an [`FMError`] if generation fails or the generated JSON cannot
492    /// be decoded as `T`.
493    pub fn respond_generating<P, T>(
494        &self,
495        prompt: P,
496        include_schema_in_prompt: bool,
497        options: GenerationOptions,
498    ) -> Result<SessionResponse<T>, FMError>
499    where
500        P: ToPrompt,
501        T: crate::schema::Generable,
502    {
503        let response = self.respond_generated_with(
504            prompt,
505            &T::generation_schema()?,
506            include_schema_in_prompt,
507            options,
508        )?;
509        Ok(SessionResponse {
510            content: T::from_generated_content(&response.content)?,
511            raw_content: response.raw_content,
512            transcript: response.transcript,
513        })
514    }
515
516    /// Stream a structured prompt token-by-token.
517    ///
518    /// # Errors
519    ///
520    /// Returns an [`FMError`] if the prompt cannot be encoded or generation fails.
521    pub fn stream_prompt<P, F>(&self, prompt: P, on_chunk: F) -> Result<(), FMError>
522    where
523        P: ToPrompt,
524        F: FnMut(StreamEvent<'_>) + Send + 'static,
525    {
526        let prompt = prompt.to_prompt()?;
527        let prompt_text = prompt_to_plain_text(&prompt).ok_or_else(|| {
528            FMError::InvalidArgument(
529                "text streaming only supports prompts composed of text segments".into(),
530            )
531        })?;
532        self.stream_with(&prompt_text, GenerationOptions::new(), on_chunk)
533    }
534
535    /// Stream structured generation snapshots.
536    ///
537    /// # Errors
538    ///
539    /// Returns an [`FMError`] if the prompt cannot be encoded or generation fails.
540    pub fn stream_generated<P, F>(
541        &self,
542        prompt: P,
543        schema: &GenerationSchema,
544        include_schema_in_prompt: bool,
545        options: GenerationOptions,
546        on_event: F,
547    ) -> Result<(), FMError>
548    where
549        P: ToPrompt,
550        F: FnMut(StructuredStreamEvent) + Send + 'static,
551    {
552        let prompt = prompt.to_prompt()?;
553        let payload =
554            respond_request_json(&prompt, options, Some(schema), include_schema_in_prompt)?;
555        let (done_tx, done_rx) = mpsc::channel::<Result<(), FMError>>();
556        let state = Arc::new(StructuredStreamState {
557            on_event: Mutex::new(Box::new(on_event)),
558            done_tx: Mutex::new(Some(done_tx)),
559        });
560        let context = Arc::into_raw(state).cast::<c_void>().cast_mut();
561        unsafe {
562            ffi::fm_session_stream_request_json(
563                self.ptr,
564                payload.as_ptr(),
565                context,
566                structured_stream_trampoline,
567            )
568        };
569        done_rx.recv().map_err(|_| FMError::Unknown {
570            code: ffi::status::UNKNOWN,
571            message: "Swift bridge dropped the structured stream channel".into(),
572        })?
573    }
574
575    /// Log a feedback attachment and return the raw bytes Apple produced.
576    ///
577    /// # Errors
578    ///
579    /// Returns an [`FMError`] if the attachment request is invalid.
580    pub fn log_feedback_attachment(
581        &self,
582        request: FeedbackAttachmentRequest,
583    ) -> Result<Vec<u8>, FMError> {
584        let request_json = CString::new(request.to_bridge_json()?).map_err(|error| {
585            FMError::InvalidArgument(format!("feedback request contains a NUL byte: {error}"))
586        })?;
587        let mut length = 0usize;
588        let mut error: *mut c_char = ptr::null_mut();
589        let ptr = unsafe {
590            ffi::fm_session_log_feedback_attachment_json(
591                self.ptr,
592                request_json.as_ptr(),
593                &mut length,
594                &mut error,
595            )
596        };
597        if ptr.is_null() && !error.is_null() {
598            return Err(crate::error::from_swift(
599                ffi::status::INVALID_ARGUMENT,
600                error,
601            ));
602        }
603        if ptr.is_null() || length == 0 {
604            return Ok(Vec::new());
605        }
606        let bytes = unsafe { std::slice::from_raw_parts(ptr.cast::<u8>(), length) }.to_vec();
607        unsafe { ffi::fm_bytes_free(ptr) };
608        Ok(bytes)
609    }
610}
611
612/// Builder for [`LanguageModelSession`].
613pub struct SessionBuilder<'a> {
614    model: Option<&'a ConfiguredSystemLanguageModel>,
615    instructions: Option<Instructions>,
616    transcript: Option<Transcript>,
617    tools: Vec<Tool>,
618}
619
620impl<'a> SessionBuilder<'a> {
621    const fn new() -> Self {
622        Self {
623            model: None,
624            instructions: None,
625            transcript: None,
626            tools: Vec::new(),
627        }
628    }
629
630    /// Use a configured system model.
631    #[must_use]
632    pub const fn model(mut self, model: &'a ConfiguredSystemLanguageModel) -> Self {
633        self.model = Some(model);
634        self
635    }
636
637    /// Set system instructions.
638    pub fn instructions<I>(mut self, instructions: I) -> Result<Self, FMError>
639    where
640        I: ToInstructions,
641    {
642        self.instructions = Some(instructions.to_instructions()?);
643        Ok(self)
644    }
645
646    /// Restore the session from a transcript.
647    #[must_use]
648    pub fn transcript(mut self, transcript: Transcript) -> Self {
649        self.transcript = Some(transcript);
650        self
651    }
652
653    /// Add one tool.
654    #[must_use]
655    pub fn tool(mut self, tool: Tool) -> Self {
656        self.tools.push(tool);
657        self
658    }
659
660    /// Add many tools.
661    #[must_use]
662    pub fn tools(mut self, tools: impl IntoIterator<Item = Tool>) -> Self {
663        self.tools.extend(tools);
664        self
665    }
666
667    /// Build the session.
668    ///
669    /// # Errors
670    ///
671    /// Returns an [`FMError`] if the configuration cannot be encoded for Swift.
672    pub fn build(self) -> Result<LanguageModelSession, FMError> {
673        if self.instructions.is_some() && self.transcript.is_some() {
674            return Err(FMError::InvalidArgument(
675                "session builder accepts either instructions or a transcript, not both".into(),
676            ));
677        }
678
679        let instructions_json = self
680            .instructions
681            .as_ref()
682            .map(Instructions::to_bridge_json)
683            .transpose()?;
684        let transcript_json = self
685            .transcript
686            .as_ref()
687            .map(Transcript::to_json_string)
688            .transpose()?;
689        let tool_registry = if self.tools.is_empty() {
690            None
691        } else {
692            Some(Arc::new(ToolRegistry::new(self.tools)))
693        };
694        let tools_json = tool_registry
695            .as_ref()
696            .map(|registry| registry.specs_json())
697            .transpose()?;
698
699        let instructions_c = instructions_json
700            .as_deref()
701            .map(CString::new)
702            .transpose()
703            .map_err(|error| {
704                FMError::InvalidArgument(format!("instructions JSON contains a NUL byte: {error}"))
705            })?;
706        let transcript_c = transcript_json
707            .as_deref()
708            .map(CString::new)
709            .transpose()
710            .map_err(|error| {
711                FMError::InvalidArgument(format!("transcript JSON contains a NUL byte: {error}"))
712            })?;
713        let tools_c = tools_json
714            .as_deref()
715            .map(CString::new)
716            .transpose()
717            .map_err(|error| {
718                FMError::InvalidArgument(format!("tool JSON contains a NUL byte: {error}"))
719            })?;
720
721        let tool_context = tool_registry.as_ref().map_or(ptr::null_mut(), |registry| {
722            Arc::as_ptr(registry).cast_mut().cast::<c_void>()
723        });
724        let mut error: *mut c_char = ptr::null_mut();
725        let ptr = unsafe {
726            ffi::fm_session_create_ex(
727                self.model.map_or(ptr::null_mut(), |model| model.ptr),
728                instructions_c
729                    .as_ref()
730                    .map_or(ptr::null(), |json| json.as_ptr()),
731                transcript_c
732                    .as_ref()
733                    .map_or(ptr::null(), |json| json.as_ptr()),
734                tools_c.as_ref().map_or(ptr::null(), |json| json.as_ptr()),
735                tool_context,
736                tool_registry
737                    .as_ref()
738                    .map(|_| tool_callback_trampoline as ffi::FmToolCallback),
739                &mut error,
740            )
741        };
742        if ptr.is_null() {
743            return Err(crate::error::from_swift(
744                ffi::status::MODEL_UNAVAILABLE,
745                error,
746            ));
747        }
748        Ok(LanguageModelSession {
749            ptr,
750            _tool_registry: tool_registry,
751        })
752    }
753}
754
755/// A detailed generation response.
756#[derive(Debug, Clone, PartialEq)]
757pub struct SessionResponse<T> {
758    pub content: T,
759    pub raw_content: GeneratedContent,
760    pub transcript: Transcript,
761}
762
763/// One structured-generation stream snapshot.
764#[derive(Debug, Clone, PartialEq, Eq)]
765pub struct StructuredStreamSnapshot {
766    pub content_json: String,
767    pub raw_content_json: String,
768    pub is_complete: bool,
769}
770
771/// One structured stream event.
772#[derive(Debug, Clone, PartialEq)]
773#[non_exhaustive]
774pub enum StructuredStreamEvent {
775    Snapshot(StructuredStreamSnapshot),
776    Done,
777    Error(FMError),
778}
779
780/// One feedback issue category.
781#[derive(Debug, Clone, Copy, PartialEq, Eq)]
782pub enum FeedbackIssueCategory {
783    Unhelpful,
784    TooVerbose,
785    DidNotFollowInstructions,
786    Incorrect,
787    StereotypeOrBias,
788    SuggestiveOrSexual,
789    VulgarOrOffensive,
790    TriggeredGuardrailUnexpectedly,
791}
792
793impl FeedbackIssueCategory {
794    const fn as_str(self) -> &'static str {
795        match self {
796            Self::Unhelpful => "unhelpful",
797            Self::TooVerbose => "too_verbose",
798            Self::DidNotFollowInstructions => "did_not_follow_instructions",
799            Self::Incorrect => "incorrect",
800            Self::StereotypeOrBias => "stereotype_or_bias",
801            Self::SuggestiveOrSexual => "suggestive_or_sexual",
802            Self::VulgarOrOffensive => "vulgar_or_offensive",
803            Self::TriggeredGuardrailUnexpectedly => "triggered_guardrail_unexpectedly",
804        }
805    }
806}
807
808/// One feedback issue.
809#[derive(Debug, Clone, PartialEq, Eq)]
810pub struct FeedbackIssue {
811    pub category: FeedbackIssueCategory,
812    pub explanation: Option<String>,
813}
814
815/// Feedback sentiment.
816#[derive(Debug, Clone, Copy, PartialEq, Eq)]
817pub enum FeedbackSentiment {
818    Positive,
819    Negative,
820    Neutral,
821}
822
823impl FeedbackSentiment {
824    const fn as_str(self) -> &'static str {
825        match self {
826            Self::Positive => "positive",
827            Self::Negative => "negative",
828            Self::Neutral => "neutral",
829        }
830    }
831}
832
833/// A full feedback attachment request.
834#[derive(Debug, Clone, PartialEq)]
835pub struct FeedbackAttachmentRequest {
836    pub sentiment: Option<FeedbackSentiment>,
837    pub issues: Vec<FeedbackIssue>,
838    pub desired_response_text: Option<String>,
839    pub desired_response_content: Option<GeneratedContent>,
840    pub desired_output: Option<crate::transcript::Entry>,
841}
842
843impl FeedbackAttachmentRequest {
844    /// Create an empty feedback request.
845    #[must_use]
846    pub const fn new() -> Self {
847        Self {
848            sentiment: None,
849            issues: Vec::new(),
850            desired_response_text: None,
851            desired_response_content: None,
852            desired_output: None,
853        }
854    }
855
856    fn to_bridge_json(&self) -> Result<String, FMError> {
857        let issues = self
858            .issues
859            .iter()
860            .map(|issue| {
861                json!({
862                    "category": issue.category.as_str(),
863                    "explanation": issue.explanation,
864                })
865            })
866            .collect::<Vec<_>>();
867        let desired_output_json = self
868            .desired_output
869            .as_ref()
870            .map(|entry| Transcript::from(vec![entry.clone()]).to_json_string())
871            .transpose()?;
872        let desired_response_content = self
873            .desired_response_content
874            .as_ref()
875            .map(GeneratedContent::to_bridge_value)
876            .transpose()?;
877        serde_json::to_string(&json!({
878            "sentiment": self.sentiment.map(FeedbackSentiment::as_str),
879            "issues": issues,
880            "desiredResponseText": self.desired_response_text,
881            "desiredResponseContent": desired_response_content,
882            "desiredOutputTranscriptJSON": desired_output_json,
883        }))
884        .map_err(|error| {
885            FMError::InvalidArgument(format!(
886                "feedback request is not JSON-serializable: {error}"
887            ))
888        })
889    }
890}
891
892#[derive(Debug, Deserialize)]
893struct BridgeTextResponse {
894    content: String,
895    #[serde(rename = "rawContent")]
896    raw_content: BridgeGeneratedContent,
897    #[serde(rename = "transcriptJSON")]
898    transcript_json: String,
899}
900
901#[derive(Debug, Deserialize)]
902struct BridgeStructuredResponse {
903    content: BridgeGeneratedContent,
904    #[serde(rename = "rawContent")]
905    raw_content: BridgeGeneratedContent,
906    #[serde(rename = "transcriptJSON")]
907    transcript_json: String,
908}
909
910#[derive(Debug, Deserialize)]
911struct BridgeStructuredSnapshot {
912    content: BridgeGeneratedContent,
913    #[serde(rename = "rawContent")]
914    raw_content: BridgeGeneratedContent,
915    #[serde(rename = "isComplete")]
916    is_complete: bool,
917}
918
919#[derive(Debug, Deserialize)]
920struct BridgeTextStreamSnapshot {
921    delta: String,
922}
923
924fn respond_request_json(
925    prompt: &Prompt,
926    options: GenerationOptions,
927    schema: Option<&GenerationSchema>,
928    include_schema_in_prompt: bool,
929) -> Result<CString, FMError> {
930    let sampling = match options.sampling() {
931        SamplingMode::Default => json!({ "mode": "default" }),
932        SamplingMode::Greedy => json!({ "mode": "greedy" }),
933        SamplingMode::TopK(k) => json!({
934            "mode": "top_k",
935            "topK": k,
936            "seed": options.sampling_seed(),
937        }),
938        SamplingMode::TopP(p) => json!({
939            "mode": "top_p",
940            "topP": p,
941            "seed": options.sampling_seed(),
942        }),
943    };
944    let payload = serde_json::to_string(&json!({
945        "prompt": prompt.to_bridge_value(),
946        "options": {
947            "temperature": options.temperature(),
948            "maximumResponseTokens": options.maximum_response_tokens(),
949            "sampling": sampling,
950        },
951        "schemaJSON": schema.map(GenerationSchema::json_schema),
952        "includeSchemaInPrompt": include_schema_in_prompt,
953    }))
954    .map_err(|error| {
955        FMError::InvalidArgument(format!("request is not JSON-serializable: {error}"))
956    })?;
957    CString::new(payload).map_err(|error| {
958        FMError::InvalidArgument(format!("request JSON contains a NUL byte: {error}"))
959    })
960}
961
962fn request_response(session: *mut c_void, payload: &CString) -> Result<String, FMError> {
963    let (tx, rx) = mpsc::channel();
964    let tx_box: Box<mpsc::Sender<Result<String, FMError>>> = Box::new(tx);
965    let context = Box::into_raw(tx_box).cast::<c_void>();
966    unsafe {
967        ffi::fm_session_respond_request_json(session, payload.as_ptr(), context, respond_trampoline)
968    };
969    rx.recv().map_err(|_| FMError::Unknown {
970        code: ffi::status::UNKNOWN,
971        message: "Swift bridge dropped the JSON response channel".into(),
972    })?
973}
974
975pub(crate) fn decode_bridge_text_response(
976    payload: &str,
977) -> Result<SessionResponse<String>, FMError> {
978    let response: BridgeTextResponse = serde_json::from_str(payload)
979        .map_err(|error| FMError::DecodingFailure(error.to_string()))?;
980    Ok(SessionResponse {
981        content: response.content,
982        raw_content: GeneratedContent::from_bridge_payload(response.raw_content, true)?,
983        transcript: Transcript::from_json_str(&response.transcript_json)?,
984    })
985}
986
987pub(crate) fn request_text_response_with<F>(invoke: F) -> Result<SessionResponse<String>, FMError>
988where
989    F: FnOnce(*mut c_void, ffi::FmRespondCallback),
990{
991    let (tx, rx) = mpsc::channel();
992    let tx_box: Box<mpsc::Sender<Result<String, FMError>>> = Box::new(tx);
993    let context = Box::into_raw(tx_box).cast::<c_void>();
994    invoke(context, respond_trampoline);
995    let payload = rx.recv().map_err(|_| FMError::Unknown {
996        code: ffi::status::UNKNOWN,
997        message: "Swift bridge dropped the JSON response channel".into(),
998    })??;
999    decode_bridge_text_response(&payload)
1000}
1001
1002pub(crate) fn run_text_stream_with<F, C>(invoke: F, on_chunk: C) -> Result<(), FMError>
1003where
1004    F: FnOnce(*mut c_void, ffi::FmStreamCallback),
1005    C: FnMut(StreamEvent<'_>) + Send + 'static,
1006{
1007    let (done_tx, done_rx) = mpsc::channel::<Result<(), FMError>>();
1008    let state = Arc::new(StreamState {
1009        on_chunk: Mutex::new(Box::new(on_chunk)),
1010        done_tx: Mutex::new(Some(done_tx)),
1011    });
1012    let context = Arc::into_raw(state).cast::<c_void>().cast_mut();
1013    invoke(context, json_text_stream_trampoline);
1014    done_rx.recv().map_err(|_| FMError::Unknown {
1015        code: ffi::status::UNKNOWN,
1016        message: "Swift bridge dropped the stream channel".into(),
1017    })?
1018}
1019
1020fn prompt_to_plain_text(prompt: &Prompt) -> Option<String> {
1021    let mut text = String::new();
1022    for segment in prompt.segments() {
1023        match segment {
1024            crate::prompt::Segment::Text(segment) => text.push_str(&segment.text),
1025            crate::prompt::Segment::Structure(_) => return None,
1026        }
1027    }
1028    Some(text)
1029}
1030
1031impl Default for LanguageModelSession {
1032    fn default() -> Self {
1033        Self::new()
1034    }
1035}
1036
1037impl Drop for LanguageModelSession {
1038    fn drop(&mut self) {
1039        if !self.ptr.is_null() {
1040            unsafe { ffi::fm_object_release(self.ptr) };
1041        }
1042    }
1043}
1044
1045impl core::fmt::Debug for LanguageModelSession {
1046    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1047        f.debug_struct("LanguageModelSession")
1048            .field("ptr", &self.ptr)
1049            .finish()
1050    }
1051}
1052
1053/// One event from a streaming generation.
1054#[derive(Debug)]
1055#[non_exhaustive]
1056pub enum StreamEvent<'a> {
1057    /// Incremental text delta. Concatenate these to reconstruct the full reply.
1058    Chunk(&'a str),
1059    /// Stream finished successfully.
1060    Done,
1061    /// Stream failed; the inner error describes why.
1062    Error(FMError),
1063}
1064
1065// ---------- internal callback plumbing ----------
1066
1067unsafe extern "C" fn respond_trampoline(
1068    context: *mut c_void,
1069    response: *mut c_char,
1070    error: *mut c_char,
1071    status: i32,
1072) {
1073    let tx = Box::from_raw(context.cast::<mpsc::Sender<Result<String, FMError>>>());
1074    let result = if status == ffi::status::OK && !response.is_null() {
1075        let s = core::ffi::CStr::from_ptr(response)
1076            .to_string_lossy()
1077            .into_owned();
1078        ffi::fm_string_free(response);
1079        Ok(s)
1080    } else {
1081        Err(crate::error::from_swift(status, error))
1082    };
1083    let _ = tx.send(result);
1084}
1085
1086type StreamCallback = Box<dyn FnMut(StreamEvent<'_>) + Send>;
1087
1088struct StreamState {
1089    on_chunk: Mutex<StreamCallback>,
1090    done_tx: Mutex<Option<mpsc::Sender<Result<(), FMError>>>>,
1091}
1092
1093unsafe extern "C" fn json_text_stream_trampoline(
1094    context: *mut c_void,
1095    chunk: *mut c_char,
1096    done: bool,
1097    status: i32,
1098) {
1099    let state = Arc::from_raw(context.cast::<StreamState>());
1100    let state_for_swift = state.clone();
1101    core::mem::forget(state_for_swift);
1102
1103    let payload: Option<String> = if chunk.is_null() {
1104        None
1105    } else {
1106        let value = core::ffi::CStr::from_ptr(chunk)
1107            .to_string_lossy()
1108            .into_owned();
1109        ffi::fm_string_free(chunk);
1110        Some(value)
1111    };
1112
1113    if status != ffi::status::OK {
1114        let err = payload
1115            .map(|message| {
1116                crate::error::from_swift(
1117                    status,
1118                    ffi::fm_string_dup(
1119                        CString::new(message)
1120                            .expect("stream errors must not contain NUL bytes")
1121                            .as_ptr(),
1122                    ),
1123                )
1124            })
1125            .unwrap_or_else(|| crate::error::from_swift(status, ptr::null_mut()));
1126        let mut callback = state.on_chunk.lock().expect("user callback mutex poisoned");
1127        callback(StreamEvent::Error(err.clone()));
1128        drop(callback);
1129        if let Some(tx) = state.done_tx.lock().expect("done_tx mutex poisoned").take() {
1130            let _ = tx.send(Err(err));
1131        }
1132        drop(Arc::from_raw(Arc::as_ptr(&state)));
1133        drop(state);
1134        return;
1135    }
1136
1137    if let Some(payload) = payload {
1138        match serde_json::from_str::<BridgeTextStreamSnapshot>(&payload) {
1139            Ok(snapshot) if !snapshot.delta.is_empty() => {
1140                let mut callback = state.on_chunk.lock().expect("user callback mutex poisoned");
1141                callback(StreamEvent::Chunk(&snapshot.delta));
1142            }
1143            Ok(_) => {}
1144            Err(error) => {
1145                let err = FMError::DecodingFailure(error.to_string());
1146                let mut callback = state.on_chunk.lock().expect("user callback mutex poisoned");
1147                callback(StreamEvent::Error(err.clone()));
1148                drop(callback);
1149                if let Some(tx) = state.done_tx.lock().expect("done_tx mutex poisoned").take() {
1150                    let _ = tx.send(Err(err));
1151                }
1152                drop(Arc::from_raw(Arc::as_ptr(&state)));
1153                drop(state);
1154                return;
1155            }
1156        }
1157    }
1158
1159    if done {
1160        let mut callback = state.on_chunk.lock().expect("user callback mutex poisoned");
1161        callback(StreamEvent::Done);
1162        drop(callback);
1163        if let Some(tx) = state.done_tx.lock().expect("done_tx mutex poisoned").take() {
1164            let _ = tx.send(Ok(()));
1165        }
1166        drop(Arc::from_raw(Arc::as_ptr(&state)));
1167    }
1168    drop(state);
1169}
1170
1171type StructuredStreamCallback = Box<dyn FnMut(StructuredStreamEvent) + Send>;
1172
1173struct StructuredStreamState {
1174    on_event: Mutex<StructuredStreamCallback>,
1175    done_tx: Mutex<Option<mpsc::Sender<Result<(), FMError>>>>,
1176}
1177
1178unsafe extern "C" fn structured_stream_trampoline(
1179    context: *mut c_void,
1180    chunk: *mut c_char,
1181    done: bool,
1182    status: i32,
1183) {
1184    let state = Arc::from_raw(context.cast::<StructuredStreamState>());
1185    let state_for_swift = state.clone();
1186    core::mem::forget(state_for_swift);
1187
1188    let payload: Option<String> = if chunk.is_null() {
1189        None
1190    } else {
1191        let value = core::ffi::CStr::from_ptr(chunk)
1192            .to_string_lossy()
1193            .into_owned();
1194        ffi::fm_string_free(chunk);
1195        Some(value)
1196    };
1197
1198    if status != ffi::status::OK {
1199        let err = payload
1200            .map(|message| {
1201                crate::error::from_swift(
1202                    status,
1203                    ffi::fm_string_dup(
1204                        CString::new(message)
1205                            .expect("stream errors must not contain NUL bytes")
1206                            .as_ptr(),
1207                    ),
1208                )
1209            })
1210            .unwrap_or_else(|| crate::error::from_swift(status, ptr::null_mut()));
1211        let mut callback = state
1212            .on_event
1213            .lock()
1214            .expect("structured callback mutex poisoned");
1215        callback(StructuredStreamEvent::Error(err.clone()));
1216        drop(callback);
1217        if let Some(tx) = state
1218            .done_tx
1219            .lock()
1220            .expect("structured done_tx mutex poisoned")
1221            .take()
1222        {
1223            let _ = tx.send(Err(err));
1224        }
1225        drop(Arc::from_raw(Arc::as_ptr(&state)));
1226        drop(state);
1227        return;
1228    }
1229
1230    if let Some(payload) = payload {
1231        let snapshot: BridgeStructuredSnapshot = match serde_json::from_str(&payload) {
1232            Ok(snapshot) => snapshot,
1233            Err(error) => {
1234                let err = FMError::DecodingFailure(error.to_string());
1235                let mut callback = state
1236                    .on_event
1237                    .lock()
1238                    .expect("structured callback mutex poisoned");
1239                callback(StructuredStreamEvent::Error(err.clone()));
1240                drop(callback);
1241                if let Some(tx) = state
1242                    .done_tx
1243                    .lock()
1244                    .expect("structured done_tx mutex poisoned")
1245                    .take()
1246                {
1247                    let _ = tx.send(Err(err));
1248                }
1249                drop(Arc::from_raw(Arc::as_ptr(&state)));
1250                drop(state);
1251                return;
1252            }
1253        };
1254        let mut callback = state
1255            .on_event
1256            .lock()
1257            .expect("structured callback mutex poisoned");
1258        callback(StructuredStreamEvent::Snapshot(StructuredStreamSnapshot {
1259            content_json: snapshot.content.json,
1260            raw_content_json: snapshot.raw_content.json,
1261            is_complete: snapshot.is_complete,
1262        }));
1263    }
1264
1265    if done {
1266        let mut callback = state
1267            .on_event
1268            .lock()
1269            .expect("structured callback mutex poisoned");
1270        callback(StructuredStreamEvent::Done);
1271        drop(callback);
1272        if let Some(tx) = state
1273            .done_tx
1274            .lock()
1275            .expect("structured done_tx mutex poisoned")
1276            .take()
1277        {
1278            let _ = tx.send(Ok(()));
1279        }
1280        drop(Arc::from_raw(Arc::as_ptr(&state)));
1281    }
1282    drop(state);
1283}