Skip to main content

foundry_local_sdk/
session.rs

1//! The [`Session`] handle and its typed variants.
2//!
3//! A `Session` binds a loaded [`Model`] to a stateful native inference session.
4//! Unlike the pure-data [`Item`]/[`Request`]/[`Response`] values, a session is
5//! *handle-backed*: it owns a native lifetime and is shared via an `Arc`, so it
6//! is cheap to clone and safe to use from multiple tasks.
7//!
8//! [`Item`]: crate::Item
9//! [`Request`]: crate::Request
10//! [`Response`]: crate::Response
11
12use std::ops::Deref;
13use std::pin::Pin;
14use std::sync::Arc;
15use std::task::{Context, Poll};
16
17use tokio::sync::{mpsc::UnboundedReceiver, oneshot};
18
19use crate::detail::api::{Api, Kvps};
20use crate::detail::model::Model;
21use crate::detail::session::{run_item_streaming, NativeItemQueue, NativeRequest, NativeSession};
22use crate::detail::task::spawn_blocking;
23use crate::error::{FoundryLocalError, Result};
24use crate::item::Item;
25use crate::item_queue::ItemQueue;
26use crate::request::{Request, RequestOptions};
27use crate::response::Response;
28
29/// A stateful inference session bound to a [`Model`].
30///
31/// `Session` is the low-level, modality-agnostic entry point: submit a
32/// [`Request`] of [`Item`]s and receive a [`Response`] of [`Item`]s. For
33/// higher-level, task-specific ergonomics use [`ChatSession`],
34/// [`EmbeddingsSession`], or [`AudioSession`], which [`Deref`] to `Session`.
35///
36/// Cloning a `Session` produces another handle to the same underlying native
37/// session (shared conversation state).
38///
39/// [`Item`]: crate::Item
40#[derive(Clone)]
41pub struct Session {
42    inner: Arc<NativeSession>,
43}
44
45impl Session {
46    /// Open a session on a loaded model.
47    ///
48    /// The model must already be loaded (see [`Model::load`]). The session
49    /// inherits the model's default generation parameters until overridden with
50    /// [`set_options`](Self::set_options) or per-request options.
51    ///
52    /// [`Model::load`]: crate::Model::load
53    pub async fn new(model: &Model) -> Result<Session> {
54        let native = model.selected_native().clone();
55        let inner = spawn_blocking(move || NativeSession::create(&native)).await?;
56        Ok(Session {
57            inner: Arc::new(inner),
58        })
59    }
60
61    /// Process a request and return the complete response.
62    ///
63    /// Runs on a blocking worker thread; the returned future resolves when
64    /// generation finishes.
65    ///
66    /// # Cancellation
67    ///
68    /// The returned future is *cancel-on-drop*: if it is dropped before it
69    /// resolves — for example via [`tokio::time::timeout`], `tokio::select!`, or
70    /// aborting the task — the in-flight native request is cancelled and
71    /// generation stops as soon as possible rather than running to completion on
72    /// the detached worker. This mirrors the drop-cancels-generation behaviour of
73    /// [`process_streaming_request`](Self::process_streaming_request).
74    pub async fn process_request(&self, request: Request) -> Result<Response> {
75        let inner = Arc::clone(&self.inner);
76        // Create the native request up front (cheap) so its cancel handle can be
77        // shared with the drop guard before the blocking work begins.
78        let native = Arc::new(NativeRequest::new(Arc::clone(&inner.api))?);
79        let native_task = Arc::clone(&native);
80        let handle = tokio::task::spawn_blocking(move || {
81            let _guard = inner.lock_ops();
82            populate_native_request(&inner.api, &native_task, &request)?;
83            let response = inner.process_request(&native_task)?;
84            Response::from_native(&response)
85        });
86
87        // Cancel the in-flight request if this future is dropped before the
88        // worker finishes; disarmed on normal completion. See `CancelGuard`.
89        let guard = CancelGuard::new(native);
90        let joined = handle.await;
91        guard.disarm();
92        joined.map_err(|e| FoundryLocalError::Internal {
93            reason: format!("blocking task join error: {e}"),
94        })?
95    }
96
97    /// Process a request, streaming each output [`Item`](crate::Item) as it is
98    /// produced.
99    ///
100    /// The returned [`ItemStream`] yields items until generation completes;
101    /// dropping it cancels generation.
102    pub fn process_streaming_request(&self, request: Request) -> ItemStream {
103        let Request {
104            items,
105            input_queue,
106            options,
107        } = request;
108        let option_pairs = options
109            .as_ref()
110            .map(RequestOptions::to_pairs)
111            .unwrap_or_default();
112        let input_queue = input_queue.map(ItemQueue::into_native);
113        let (rx, response_rx) =
114            run_item_streaming(Arc::clone(&self.inner), items, input_queue, option_pairs);
115        ItemStream {
116            rx,
117            response_rx: Some(response_rx),
118        }
119    }
120
121    /// Apply session-scoped options that persist across subsequent requests.
122    pub async fn set_options(&self, options: RequestOptions) -> Result<()> {
123        let inner = Arc::clone(&self.inner);
124        spawn_blocking(move || {
125            let pairs = options.to_pairs();
126            let kvps = Kvps::from_pairs(Arc::clone(&inner.api), pairs)?;
127            inner.set_options(kvps.as_ptr())
128        })
129        .await
130    }
131
132    /// Create an [`ItemQueue`] for streaming incremental input into a request.
133    ///
134    /// Attach the returned queue to a [`Request`] via
135    /// [`Request::with_input_queue`], then push items as they become available.
136    pub fn create_input_queue(&self) -> Result<ItemQueue> {
137        let native = NativeItemQueue::new(Arc::clone(&self.inner.api))?;
138        Ok(ItemQueue::from_native(Arc::new(native)))
139    }
140}
141
142impl std::fmt::Debug for Session {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        f.debug_struct("Session").finish_non_exhaustive()
145    }
146}
147
148/// Populate a freshly-created native request from a pure-data [`Request`].
149///
150/// The native layer copies every buffer it needs, so the owned `Request` (and
151/// its items) may be dropped as soon as processing returns. The `NativeRequest`
152/// is created by the caller (rather than here) so a cancel handle can be shared
153/// with the drop guard before the blocking population/processing begins.
154fn populate_native_request(
155    api: &Arc<Api>,
156    native: &NativeRequest,
157    request: &Request,
158) -> Result<()> {
159    for item in &request.items {
160        native.add_item_value(item)?;
161    }
162    if let Some(queue) = &request.input_queue {
163        native.add_input_queue(queue.native())?;
164    }
165    let pairs = request.option_pairs();
166    if !pairs.is_empty() {
167        let kvps = Kvps::from_pairs(Arc::clone(api), pairs)?;
168        native.set_options(kvps.as_ptr())?;
169    }
170    Ok(())
171}
172
173/// Cancels an in-flight native request if dropped while still armed.
174///
175/// [`Session::process_request`] holds one of these across the `.await` on its
176/// blocking worker. If the caller drops that future first (via
177/// [`tokio::time::timeout`], `tokio::select!`, task abort, …) the guard fires
178/// `Request_Cancel` so generation stops promptly instead of running to
179/// completion on the detached worker. Disarmed once the worker completes
180/// normally.
181struct CancelGuard {
182    native: Arc<NativeRequest>,
183    armed: bool,
184}
185
186impl CancelGuard {
187    fn new(native: Arc<NativeRequest>) -> Self {
188        Self {
189            native,
190            armed: true,
191        }
192    }
193
194    fn disarm(mut self) {
195        self.armed = false;
196    }
197}
198
199impl Drop for CancelGuard {
200    fn drop(&mut self) {
201        if self.armed {
202            self.native.cancel();
203        }
204    }
205}
206
207/// The terminal-response oneshot was dropped before the worker sent a response.
208fn worker_ended_without_response() -> FoundryLocalError {
209    FoundryLocalError::Internal {
210        reason: "streaming response worker ended without a terminal response".into(),
211    }
212}
213
214/// An asynchronous stream of output [`Item`](crate::Item)s from
215/// [`Session::process_streaming_request`].
216///
217/// Implements [`futures_core::Stream`]; use with the `futures`/`tokio-stream`
218/// combinators (e.g. `while let Some(item) = stream.next().await`).
219pub struct ItemStream {
220    rx: UnboundedReceiver<Result<Item>>,
221    response_rx: Option<oneshot::Receiver<Result<Response>>>,
222}
223
224impl ItemStream {
225    /// Wait for and return the terminal response, including finish reason and
226    /// token usage.
227    ///
228    /// This may be called after draining the item stream or instead of draining
229    /// it. The terminal response can be taken only once.
230    pub async fn response(&mut self) -> Result<Response> {
231        if self.response_rx.is_none() {
232            return Err(FoundryLocalError::Validation {
233                reason: "the streaming response has already been taken".into(),
234            });
235        }
236
237        loop {
238            match self.rx.try_recv() {
239                Ok(Ok(_)) => continue,
240                Ok(Err(error)) => return Err(error),
241                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
242                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
243            }
244
245            let response_rx = self.response_rx.as_mut().expect("checked above");
246            tokio::select! {
247                response = &mut *response_rx => {
248                    self.response_rx.take();
249                    while let Ok(item) = self.rx.try_recv() {
250                        item?;
251                    }
252                    return response.map_err(|_| worker_ended_without_response())?;
253                }
254                item = self.rx.recv() => {
255                    match item {
256                        Some(Ok(_)) => {}
257                        Some(Err(error)) => return Err(error),
258                        None => break,
259                    }
260                }
261            }
262        }
263
264        let response = self
265            .response_rx
266            .as_mut()
267            .expect("checked above")
268            .await
269            .map_err(|_| worker_ended_without_response())?;
270        self.response_rx.take();
271        response
272    }
273}
274
275impl Unpin for ItemStream {}
276
277impl futures_core::Stream for ItemStream {
278    type Item = Result<Item>;
279
280    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
281        self.rx.poll_recv(cx)
282    }
283}
284
285/// A tool the model may call, registered on a [`ChatSession`].
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct ToolDefinition {
288    /// The tool's unique name.
289    pub name: String,
290    /// An optional human/model-readable description of what the tool does.
291    pub description: Option<String>,
292    /// A JSON Schema string describing the tool's parameters.
293    pub json_schema: String,
294}
295
296impl ToolDefinition {
297    /// A tool definition with a name and JSON-schema parameter description.
298    pub fn new(name: impl Into<String>, json_schema: impl Into<String>) -> Self {
299        Self {
300            name: name.into(),
301            description: None,
302            json_schema: json_schema.into(),
303        }
304    }
305
306    /// Attach a description (builder-style).
307    pub fn with_description(mut self, description: impl Into<String>) -> Self {
308        self.description = Some(description.into());
309        self
310    }
311}
312
313/// Reject a model whose task is not one of `allowed` before a typed session is
314/// created.
315///
316/// The native session constructor requires the model to already be loaded and
317/// only rejects a wrong-task model *after* that load requirement is satisfied.
318/// The model's task, however, is catalog metadata available without loading, so
319/// validating it here surfaces a precise, task-specific error regardless of load
320/// state and keeps the error identical whether or not the model happens to be
321/// loaded. This mirrors the C#, JavaScript, and Python bindings, which validate
322/// the task the same way for the same reason.
323fn validate_session_task(model: &Model, session: &str, allowed: &[&str]) -> Result<()> {
324    let info = model.info()?;
325
326    check_task(info.task.as_deref(), session, allowed)
327}
328
329/// Pure task-compatibility check shared by [`validate_session_task`]; split out
330/// so the accept/reject logic and error message can be unit-tested without a
331/// native model.
332fn check_task(task: Option<&str>, session: &str, allowed: &[&str]) -> Result<()> {
333    let task = task.unwrap_or("");
334
335    if !allowed.contains(&task) {
336        let expected = allowed
337            .iter()
338            .map(|t| format!("'{t}'"))
339            .collect::<Vec<_>>()
340            .join(" or ");
341
342        return Err(FoundryLocalError::Validation {
343            reason: format!("{session} requires a model with task {expected}, but got '{task}'."),
344        });
345    }
346
347    Ok(())
348}
349
350/// A chat-oriented [`Session`] with tool registration and turn management.
351///
352/// Dereferences to [`Session`], so all base methods
353/// ([`process_request`](Session::process_request),
354/// [`process_streaming_request`](Session::process_streaming_request), …) are
355/// available directly.
356#[derive(Clone)]
357pub struct ChatSession {
358    session: Session,
359}
360
361impl ChatSession {
362    /// Open a chat session on a loaded model.
363    ///
364    /// Returns [`FoundryLocalError::Validation`] if the model's task is not
365    /// `"chat-completion"` or `"vision-language-chat"`.
366    pub async fn new(model: &Model) -> Result<ChatSession> {
367        validate_session_task(
368            model,
369            "ChatSession",
370            &["chat-completion", "vision-language-chat"],
371        )?;
372
373        Ok(ChatSession {
374            session: Session::new(model).await?,
375        })
376    }
377
378    /// Register a [`ToolDefinition`] for the lifetime of the session.
379    pub async fn add_tool_definition(&self, definition: ToolDefinition) -> Result<()> {
380        let inner = Arc::clone(&self.session.inner);
381        spawn_blocking(move || {
382            inner.add_tool_definition(
383                &definition.name,
384                definition.description.as_deref(),
385                &definition.json_schema,
386            )
387        })
388        .await
389    }
390
391    /// Remove a previously-registered tool by name. Returns whether one was removed.
392    pub async fn remove_tool_definition(&self, name: impl Into<String>) -> Result<bool> {
393        let inner = Arc::clone(&self.session.inner);
394        let name = name.into();
395        spawn_blocking(move || inner.remove_tool_definition(&name)).await
396    }
397
398    /// The number of completed conversation turns.
399    pub fn turn_count(&self) -> usize {
400        self.session.inner.turn_count()
401    }
402
403    /// Rewind the last `count` turns, dropping their messages and replies.
404    pub async fn undo_turns(&self, count: usize) -> Result<()> {
405        let inner = Arc::clone(&self.session.inner);
406        spawn_blocking(move || inner.undo_turns(count)).await
407    }
408
409    /// Consume this handle, yielding the underlying base [`Session`].
410    pub fn into_session(self) -> Session {
411        self.session
412    }
413}
414
415impl Deref for ChatSession {
416    type Target = Session;
417
418    fn deref(&self) -> &Session {
419        &self.session
420    }
421}
422
423/// An embeddings-oriented [`Session`] producing dense vectors for text input.
424///
425/// Dereferences to [`Session`].
426#[derive(Clone)]
427pub struct EmbeddingsSession {
428    session: Session,
429}
430
431impl EmbeddingsSession {
432    /// Open an embeddings session on a loaded model.
433    ///
434    /// Returns [`FoundryLocalError::Validation`] if the model's task is not
435    /// `"embeddings"`.
436    pub async fn new(model: &Model) -> Result<EmbeddingsSession> {
437        validate_session_task(model, "EmbeddingsSession", &["embeddings"])?;
438
439        Ok(EmbeddingsSession {
440            session: Session::new(model).await?,
441        })
442    }
443
444    /// Embed a single text input, returning its dense vector.
445    pub async fn embed(&self, input: impl Into<String>) -> Result<Vec<f32>> {
446        let vectors = self.embed_batch(vec![input.into()]).await?;
447        vectors
448            .into_iter()
449            .next()
450            .ok_or_else(|| FoundryLocalError::Validation {
451                reason: "embeddings response contained no vectors".to_string(),
452            })
453    }
454
455    /// Embed a batch of text inputs, returning one dense vector per input (in
456    /// order).
457    pub async fn embed_batch(&self, inputs: Vec<String>) -> Result<Vec<Vec<f32>>> {
458        let items: Vec<Item> = inputs.iter().map(|s| Item::text(s.as_str())).collect();
459        let request = Request::from_items(items);
460        let response = self.session.process_request(request).await?;
461
462        if response.items.len() != inputs.len() {
463            return Err(FoundryLocalError::Validation {
464                reason: format!(
465                    "embeddings response returned {} vectors for {} inputs",
466                    response.items.len(),
467                    inputs.len()
468                ),
469            });
470        }
471
472        let mut vectors = Vec::with_capacity(response.items.len());
473        for item in &response.items {
474            let tensor = item
475                .as_tensor()
476                .ok_or_else(|| FoundryLocalError::Validation {
477                    reason: "embeddings response item was not a tensor".to_string(),
478                })?;
479            let floats = tensor
480                .as_f32()
481                .ok_or_else(|| FoundryLocalError::Validation {
482                    reason: "embeddings tensor was not float data".to_string(),
483                })?;
484            vectors.push(floats);
485        }
486        Ok(vectors)
487    }
488
489    /// Consume this handle, yielding the underlying base [`Session`].
490    pub fn into_session(self) -> Session {
491        self.session
492    }
493}
494
495impl Deref for EmbeddingsSession {
496    type Target = Session;
497
498    fn deref(&self) -> &Session {
499        &self.session
500    }
501}
502
503/// An audio-oriented [`Session`] for speech tasks (e.g. transcription).
504///
505/// Dereferences to [`Session`]. Submit [`Item::audio_data`](crate::Item::audio_data)
506/// / [`Item::audio_uri`](crate::Item::audio_uri) input and read
507/// [`Item::SpeechResult`](crate::Item::SpeechResult) output, or use
508/// [`transcribe`](Self::transcribe) for the common case.
509#[derive(Clone)]
510pub struct AudioSession {
511    session: Session,
512}
513
514impl AudioSession {
515    /// Open an audio session on a loaded model.
516    ///
517    /// Returns [`FoundryLocalError::Validation`] if the model's task is not
518    /// `"automatic-speech-recognition"`.
519    pub async fn new(model: &Model) -> Result<AudioSession> {
520        validate_session_task(model, "AudioSession", &["automatic-speech-recognition"])?;
521
522        Ok(AudioSession {
523            session: Session::new(model).await?,
524        })
525    }
526
527    /// Transcribe a single audio item, returning the recognized text.
528    ///
529    /// A convenience over [`process_request`](Session::process_request): submits
530    /// `audio` and concatenates the text of every returned speech result.
531    pub async fn transcribe(&self, audio: Item) -> Result<String> {
532        let response = self
533            .session
534            .process_request(Request::from_items(vec![audio]))
535            .await?;
536        let mut text = String::new();
537        for item in &response.items {
538            if let Some(result) = item.as_speech_result() {
539                text.push_str(&result.text);
540            } else if let Some(t) = item.as_text() {
541                text.push_str(t);
542            }
543        }
544        Ok(text)
545    }
546
547    /// Consume this handle, yielding the underlying base [`Session`].
548    pub fn into_session(self) -> Session {
549        self.session
550    }
551}
552
553impl Deref for AudioSession {
554    type Target = Session;
555
556    fn deref(&self) -> &Session {
557        &self.session
558    }
559}
560
561#[cfg(test)]
562mod tests {
563    use std::future::Future;
564    use std::sync::Arc;
565    use std::task::{Wake, Waker};
566
567    use super::*;
568
569    struct NoopWake;
570
571    impl Wake for NoopWake {
572        fn wake(self: Arc<Self>) {}
573    }
574
575    #[tokio::test]
576    async fn item_stream_returns_terminal_response_once() {
577        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
578        let (response_tx, response_rx) = oneshot::channel();
579        let expected = Response {
580            items: vec![Item::text("complete")],
581            finish_reason: crate::FinishReason::Stop,
582            usage: crate::Usage {
583                prompt_tokens: 3,
584                completion_tokens: 1,
585                total_tokens: 4,
586            },
587        };
588        tx.send(Ok(Item::text("chunk one"))).unwrap();
589        tx.send(Ok(Item::text("chunk two"))).unwrap();
590        drop(tx);
591        response_tx.send(Ok(expected.clone())).unwrap();
592
593        let mut stream = ItemStream {
594            rx,
595            response_rx: Some(response_rx),
596        };
597
598        assert_eq!(stream.response().await.unwrap(), expected);
599        assert!(stream.rx.is_empty());
600        assert!(matches!(
601            stream.response().await,
602            Err(FoundryLocalError::Validation { .. })
603        ));
604    }
605
606    #[tokio::test]
607    async fn cancelling_response_await_keeps_terminal_response_available() {
608        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
609        let (response_tx, response_rx) = oneshot::channel();
610        let mut stream = ItemStream {
611            rx,
612            response_rx: Some(response_rx),
613        };
614
615        let mut response = Box::pin(stream.response());
616        let waker = Waker::from(Arc::new(NoopWake));
617        let mut context = Context::from_waker(&waker);
618        assert!(matches!(
619            response.as_mut().poll(&mut context),
620            Poll::Pending
621        ));
622        drop(response);
623
624        response_tx
625            .send(Ok(Response {
626                items: Vec::new(),
627                finish_reason: crate::FinishReason::Stop,
628                usage: crate::Usage::default(),
629            }))
630            .unwrap();
631        assert_eq!(
632            stream.response().await.unwrap().finish_reason,
633            crate::FinishReason::Stop
634        );
635    }
636
637    #[test]
638    fn check_task_accepts_allowed_tasks() {
639        assert!(check_task(
640            Some("chat-completion"),
641            "ChatSession",
642            &["chat-completion", "vision-language-chat"]
643        )
644        .is_ok());
645        assert!(check_task(
646            Some("vision-language-chat"),
647            "ChatSession",
648            &["chat-completion", "vision-language-chat"]
649        )
650        .is_ok());
651        assert!(check_task(Some("embeddings"), "EmbeddingsSession", &["embeddings"]).is_ok());
652    }
653
654    #[test]
655    fn check_task_rejects_wrong_task() {
656        let err = check_task(
657            Some("chat-completion"),
658            "EmbeddingsSession",
659            &["embeddings"],
660        )
661        .expect_err("wrong task should be rejected");
662
663        match err {
664            FoundryLocalError::Validation { reason } => {
665                assert!(reason.contains("EmbeddingsSession"), "reason: {reason}");
666                assert!(reason.contains("'embeddings'"), "reason: {reason}");
667                assert!(reason.contains("'chat-completion'"), "reason: {reason}");
668            }
669            other => panic!("expected Validation error, got: {other:?}"),
670        }
671    }
672
673    #[test]
674    fn check_task_treats_missing_task_as_mismatch() {
675        let err = check_task(None, "AudioSession", &["automatic-speech-recognition"])
676            .expect_err("missing task should be rejected");
677
678        assert!(matches!(err, FoundryLocalError::Validation { .. }));
679    }
680}