1use 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#[derive(Clone)]
41pub struct Session {
42 inner: Arc<NativeSession>,
43}
44
45impl Session {
46 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 pub async fn process_request(&self, request: Request) -> Result<Response> {
75 let inner = Arc::clone(&self.inner);
76 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 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 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 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 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
148fn 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
173struct 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
207fn worker_ended_without_response() -> FoundryLocalError {
209 FoundryLocalError::Internal {
210 reason: "streaming response worker ended without a terminal response".into(),
211 }
212}
213
214pub struct ItemStream {
220 rx: UnboundedReceiver<Result<Item>>,
221 response_rx: Option<oneshot::Receiver<Result<Response>>>,
222}
223
224impl ItemStream {
225 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#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct ToolDefinition {
288 pub name: String,
290 pub description: Option<String>,
292 pub json_schema: String,
294}
295
296impl ToolDefinition {
297 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 pub fn with_description(mut self, description: impl Into<String>) -> Self {
308 self.description = Some(description.into());
309 self
310 }
311}
312
313fn 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
329fn 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#[derive(Clone)]
357pub struct ChatSession {
358 session: Session,
359}
360
361impl ChatSession {
362 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 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 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 pub fn turn_count(&self) -> usize {
400 self.session.inner.turn_count()
401 }
402
403 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 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#[derive(Clone)]
427pub struct EmbeddingsSession {
428 session: Session,
429}
430
431impl EmbeddingsSession {
432 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 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 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 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#[derive(Clone)]
510pub struct AudioSession {
511 session: Session,
512}
513
514impl AudioSession {
515 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 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 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}