Skip to main content

providers_kit/
session.rs

1//! Provider-native interactive values and the unique session handle.
2//!
3//! [`SessionConfig`] carries no model selector: open it through one exact
4//! [`crate::ModelBackend`]. Drive [`SessionBackend::send`] and [`SessionBackend::recv`]
5//! through a unique mutable Session, then consume it with [`SessionBackend::close`].
6//! Provider shutdown remains shared. Ordinary multi-turn generation uses messages
7//! and opaque continuation; it does not require a native Session.
8//!
9//! # Supplied support and a complete text turn
10//!
11//! The supplied Session implementation is **OpenAI-only**. Supplied Gemini,
12//! Anthropic and Ollama Models return Unsupported; their stateless operations remain
13//! separate. The portable trait also supports independently implemented caller
14//! Sessions, as illustrated on [`crate::ProviderBackend`]. Stateless OpenAI audio
15//! input is unsupported; OpenAI Realtime audio is a Session operation.
16//!
17//! This example requires OpenAI credentials and access to `gpt-realtime-2.1`.
18//! A text turn may produce several audio and transcription events before completion.
19//! Advisory usage/output is not a terminal signal; cancellation is not completion.
20//! The caller applies a single turn deadline and consumes close even after a turn
21//! error. Function requests would need a configured application handler and exact
22//! `FunctionResult` correlation; this text-only example configures no functions.
23//!
24//! ```no_run
25//! use std::time::Duration;
26//! use providers_kit::{
27//!     ModelBackend, ModelId, Provider, ProviderBackend, ProviderConfig, SessionBackend,
28//! };
29//! use providers_kit::config::{ApiKey, OpenAiConfig, ProviderKind, TransportConfig};
30//! use providers_kit::session::{SessionConfig, SessionEvent, SessionInput};
31//! # #[tokio::main(flavor = "current_thread")]
32//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
33//! let provider = Provider::from_config(ProviderConfig::new(
34//!     ProviderKind::OpenAi(OpenAiConfig::new(ApiKey::new("replace-with-your-key")?)),
35//!     TransportConfig::default(),
36//! ))?;
37//! let work = async {
38//!     let model = provider.model(ModelId::new("gpt-realtime-2.1")?)?;
39//!     let config = SessionConfig::default().with_output_audio("audio/pcm;rate=24000", true)?;
40//!     let mut session = model.open_session(config).await?;
41//!     let turn = tokio::time::timeout(Duration::from_secs(90), async {
42//!         session.send(SessionInput::Text { text: "Describe one use of a bicycle.".into() }).await?;
43//!         let mut audio_bytes = 0usize;
44//!         let mut transcript_bytes = 0usize;
45//!         let mut substantive_transcript = false;
46//!         loop {
47//!             match session.recv().await? {
48//!                 SessionEvent::Audio { data, .. } => audio_bytes += data.len(),
49//!                 SessionEvent::OutputTranscriptionDelta { delta } => {
50//!                     transcript_bytes += delta.len();
51//!                     substantive_transcript |= !delta.trim().is_empty();
52//!                 }
53//!                 SessionEvent::Completed => break,
54//!                 SessionEvent::TurnCancelled | SessionEvent::FunctionCall(_)
55//!                 | SessionEvent::FunctionCallsCancelled { .. } => return Err("unexpected control event".into()),
56//!                 SessionEvent::TextDelta { .. } | SessionEvent::InputTranscriptionDelta { .. }
57//!                 | SessionEvent::Usage(_) => {}
58//!             }
59//!             if audio_bytes > 8 * 1024 * 1024 || transcript_bytes > 1024 * 1024 {
60//!                 return Err("example output bound exceeded".into());
61//!             }
62//!         }
63//!         if audio_bytes == 0 || !substantive_transcript { return Err("missing output".into()); }
64//!         Ok::<(), Box<dyn std::error::Error>>(())
65//!     }).await;
66//!     let closed = session.close().await;
67//!     if let Err(error) = &closed {
68//!         eprintln!("Cleanup failed: {error}");
69//!     }
70//!     turn??;
71//!     closed?;
72//!     Ok::<(), Box<dyn std::error::Error>>(())
73//! }.await;
74//! let shutdown = provider.shutdown().await;
75//! if let Err(error) = &shutdown {
76//!     eprintln!("Cleanup failed: {error}");
77//! }
78//! work?;
79//! shutdown?;
80//! # Ok(())
81//! # }
82//! ```
83//!
84//! For manual audio, send `TurnStart`, one or more raw `Audio` chunks in the exact
85//! configured format, then `TurnEnd`; receive until a native terminal event. A raw
86//! chunk is not a whole-turn or automatic flush operation. Output wording is
87//! model-authored, not a Kit exact-copy guarantee.
88
89use std::collections::BTreeSet;
90use std::convert::Infallible;
91use std::fmt;
92use std::future::Future;
93use std::sync::Arc;
94
95use reqwest::multipart::Part;
96use serde::{Deserialize, Serialize};
97
98use crate::KitError;
99use crate::core::filter_tools_for_choice;
100use crate::error::Operation;
101use crate::error::ValidationViolation;
102use crate::generation::{
103    FunctionCall, FunctionResult, ProviderFunctionTool, ProviderToolSpec, ToolChoice, Usage,
104};
105use crate::models::ModelId;
106use crate::provider::ProviderRuntime;
107use crate::providers::openai::OpenAiSession;
108
109/// Defaults applied to one provider-native interactive session.
110///
111/// Configuration is passive. Select the exact model first and call
112/// [`crate::ModelBackend::open_session`] only when native sessions are supported.
113///
114/// # Examples
115///
116/// ```
117/// use providers_kit::session::SessionConfig;
118/// let config = SessionConfig::default().with_instructions("Answer briefly")?;
119/// assert_eq!(config.instructions(), Some("Answer briefly"));
120///
121/// # Ok::<(), providers_kit::KitError>(())
122/// ```
123///
124#[derive(Clone, Debug, PartialEq)]
125pub struct SessionConfig {
126    instructions: Option<String>,
127    input_audio: Option<(String, bool)>,
128    output_audio: Option<(String, bool)>,
129    turn_detection: SessionTurnDetection,
130    functions: Vec<ProviderFunctionTool>,
131    tool_choice: Option<ToolChoice>,
132}
133
134impl Default for SessionConfig {
135    fn default() -> Self {
136        Self {
137            instructions: None,
138            input_audio: None,
139            output_audio: None,
140            turn_detection: SessionTurnDetection::Automatic,
141            functions: Vec::new(),
142            tool_choice: None,
143        }
144    }
145}
146
147impl SessionConfig {
148    /// Sets nonblank text instructions for the session.
149    ///
150    /// # Errors
151    ///
152    /// Returns an input error for empty or whitespace-only instructions.
153    ///
154    /// See [`crate::session::SessionConfig`] for a complete example.
155    ///
156    pub fn with_instructions(mut self, instructions: impl Into<String>) -> Result<Self, KitError> {
157        let instructions = instructions.into();
158        if instructions.trim().is_empty() {
159            return Err(KitError::invalid_input(
160                ValidationViolation::Empty {
161                    field: "session.instructions",
162                },
163                "interactive session instructions must not be blank",
164            ));
165        }
166        self.instructions = Some(instructions);
167        Ok(self)
168    }
169
170    /// Enables audio input with an exact IANA media type.
171    ///
172    /// # Errors
173    ///
174    /// Returns an input error unless the value is an unpadded valid IANA audio media type without control characters. Exact format/model support is checked on session opening.
175    ///
176    /// # Examples
177    ///
178    /// ```
179    /// use providers_kit::session::SessionConfig;
180    /// let config = SessionConfig::default().with_input_audio("audio/pcm", true)?;
181    /// assert_eq!(config.input_audio_format(), Some("audio/pcm"));
182    /// assert!(config.input_transcription());
183    ///
184    /// # Ok::<(), providers_kit::KitError>(())
185    /// ```
186    ///
187    pub fn with_input_audio(
188        mut self,
189        media_type: impl Into<String>,
190        transcription: bool,
191    ) -> Result<Self, KitError> {
192        self.input_audio = Some((
193            Self::validate_audio_format(media_type.into())?,
194            transcription,
195        ));
196        Ok(self)
197    }
198
199    /// Enables audio output with an exact IANA media type.
200    ///
201    /// # Errors
202    ///
203    /// Returns an input error unless the value is an unpadded valid IANA audio media type without control characters. Exact format/model support is checked on session opening.
204    ///
205    /// # Examples
206    ///
207    /// ```
208    /// use providers_kit::session::SessionConfig;
209    /// let config = SessionConfig::default().with_output_audio("audio/pcm", true)?;
210    /// assert_eq!(config.output_audio_format(), Some("audio/pcm"));
211    /// assert!(config.output_transcription());
212    ///
213    /// # Ok::<(), providers_kit::KitError>(())
214    /// ```
215    ///
216    pub fn with_output_audio(
217        mut self,
218        media_type: impl Into<String>,
219        transcription: bool,
220    ) -> Result<Self, KitError> {
221        self.output_audio = Some((
222            Self::validate_audio_format(media_type.into())?,
223            transcription,
224        ));
225        Ok(self)
226    }
227
228    /// Selects provider-managed or caller-managed turn boundaries.
229    ///
230    /// # Examples
231    ///
232    /// ```
233    /// use providers_kit::session::{SessionConfig, SessionTurnDetection};
234    /// let config = SessionConfig::default().with_turn_detection(SessionTurnDetection::Manual);
235    /// assert_eq!(config.turn_detection(), SessionTurnDetection::Manual);
236    /// ```
237    ///
238    #[must_use]
239    pub const fn with_turn_detection(mut self, turn_detection: SessionTurnDetection) -> Self {
240        self.turn_detection = turn_detection;
241        self
242    }
243
244    /// Sets the client functions available throughout the session.
245    ///
246    /// # Errors
247    ///
248    /// Returns an input error for duplicate functions, required tool choice with an empty inventory, or a selection that cannot be resolved against the supplied functions.
249    ///
250    /// # Examples
251    ///
252    /// ```
253    /// use providers_kit::generation::{ProviderFunctionTool, JsonSchema};
254    /// let tool = ProviderFunctionTool::new(
255    ///     "lookup",
256    ///     Some("Find a record".into()),
257    ///     JsonSchema::new(serde_json::json!({"type":"object"}))?,
258    /// )?;
259    /// use providers_kit::{session::SessionConfig, generation::ToolChoice};
260    /// let config = SessionConfig::default().with_functions(vec![tool], Some(ToolChoice::Auto))?;
261    /// assert_eq!(config.functions()[0].name(), "lookup");
262    /// assert_eq!(config.tool_choice(), Some(&ToolChoice::Auto));
263    ///
264    /// # Ok::<(), providers_kit::KitError>(())
265    /// ```
266    ///
267    pub fn with_functions(
268        mut self,
269        functions: Vec<ProviderFunctionTool>,
270        tool_choice: Option<ToolChoice>,
271    ) -> Result<Self, KitError> {
272        let mut names = BTreeSet::new();
273        for function in &functions {
274            if !names.insert(function.name()) {
275                return Err(KitError::invalid_input(
276                    ValidationViolation::Duplicate {
277                        field: "session.functions.name",
278                    },
279                    format!(
280                        "interactive function name `{}` is duplicated",
281                        function.name()
282                    ),
283                ));
284            }
285        }
286        if functions.is_empty()
287            && matches!(
288                tool_choice.as_ref(),
289                Some(ToolChoice::Required | ToolChoice::Any)
290            )
291        {
292            return Err(KitError::invalid_input(
293                ValidationViolation::MissingField {
294                    field: "session.functions",
295                },
296                "interactive required tool choice needs at least one function",
297            ));
298        }
299        let specs = functions
300            .iter()
301            .cloned()
302            .map(ProviderToolSpec::Function)
303            .collect::<Vec<_>>();
304        filter_tools_for_choice(&specs, tool_choice.as_ref())?;
305        self.functions = functions;
306        self.tool_choice = tool_choice;
307        Ok(self)
308    }
309
310    /// Returns the session instructions, when configured.
311    ///
312    /// See [`crate::session::SessionConfig`] for a complete example.
313    ///
314    #[must_use]
315    pub fn instructions(&self) -> Option<&str> {
316        self.instructions.as_deref()
317    }
318
319    /// Returns the required input-audio media type, when enabled.
320    ///
321    /// See [`crate::session::SessionConfig::with_input_audio`] for a complete example.
322    ///
323    #[must_use]
324    pub fn input_audio_format(&self) -> Option<&str> {
325        self.input_audio
326            .as_ref()
327            .map(|(media_type, _)| media_type.as_str())
328    }
329
330    /// Returns the requested output-audio media type, when enabled.
331    ///
332    /// See [`crate::session::SessionConfig::with_output_audio`] for a complete example.
333    ///
334    #[must_use]
335    pub fn output_audio_format(&self) -> Option<&str> {
336        self.output_audio
337            .as_ref()
338            .map(|(media_type, _)| media_type.as_str())
339    }
340
341    /// Reports whether input transcription was requested.
342    ///
343    /// See [`crate::session::SessionConfig::with_input_audio`] for a complete example.
344    ///
345    #[must_use]
346    pub fn input_transcription(&self) -> bool {
347        self.input_audio
348            .as_ref()
349            .is_some_and(|(_, transcription)| *transcription)
350    }
351
352    /// Reports whether output transcription was requested.
353    ///
354    /// See [`crate::session::SessionConfig::with_output_audio`] for a complete example.
355    ///
356    #[must_use]
357    pub fn output_transcription(&self) -> bool {
358        self.output_audio
359            .as_ref()
360            .is_some_and(|(_, transcription)| *transcription)
361    }
362
363    /// Returns the configured turn-boundary policy.
364    ///
365    /// See [`crate::session::SessionConfig::with_turn_detection`] for a complete example.
366    ///
367    #[must_use]
368    pub const fn turn_detection(&self) -> SessionTurnDetection {
369        self.turn_detection
370    }
371
372    /// Returns the client functions available to the session.
373    ///
374    /// See [`crate::session::SessionConfig::with_functions`] for a complete example.
375    ///
376    #[must_use]
377    pub fn functions(&self) -> &[ProviderFunctionTool] {
378        &self.functions
379    }
380
381    /// Returns the configured function-selection policy.
382    ///
383    /// See [`crate::session::SessionConfig::with_functions`] for a complete example.
384    ///
385    #[must_use]
386    pub const fn tool_choice(&self) -> Option<&ToolChoice> {
387        self.tool_choice.as_ref()
388    }
389
390    fn validate_audio_format(media_type: String) -> Result<String, KitError> {
391        if !media_type.starts_with("audio/")
392            || media_type.trim() != media_type
393            || media_type.chars().any(char::is_control)
394            || Part::bytes(Vec::new()).mime_str(&media_type).is_err()
395        {
396            return Err(KitError::invalid_input(
397                ValidationViolation::InvalidFormat {
398                    field: "session.audio_format",
399                    rule: "unpadded IANA audio media type",
400                },
401                "interactive audio format must be an unpadded IANA audio media type",
402            ));
403        }
404        Ok(media_type)
405    }
406}
407
408/// Portable interactive audio turn detection.
409#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(rename_all = "snake_case")]
411pub enum SessionTurnDetection {
412    /// Provider-managed boundaries.
413    #[default]
414    Automatic,
415    /// Caller-managed boundaries.
416    Manual,
417}
418
419/// Input sent to an interactive session.
420#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
421#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
422pub enum SessionInput {
423    /// One complete text turn.
424    Text {
425        /// Nonblank caller text.
426        text: String,
427    },
428    /// One raw audio chunk in the configured input format.
429    Audio {
430        /// Exact media type configured for session input.
431        media_type: String,
432        /// Raw, unencoded audio bytes.
433        #[serde(with = "crate::core::content::base64_bytes")]
434        data: Vec<u8>,
435    },
436    /// A completed client-function result.
437    FunctionResult(FunctionResult),
438    /// Starts a caller-managed audio turn.
439    TurnStart,
440    /// Ends caller-managed audio input or, where supported, explicitly ends
441    /// the currently submitted automatic-audio segment.
442    ///
443    /// The supplied OpenAI backend requires manual turn detection.
444    /// This input does not complete or cancel the provider response.
445    TurnEnd,
446    /// Cancels the active provider response when supported.
447    CancelTurn,
448}
449
450/// Event received from an interactive session.
451#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
452#[serde(tag = "event", rename_all = "snake_case", deny_unknown_fields)]
453pub enum SessionEvent {
454    /// Incremental model text.
455    TextDelta {
456        /// Newly received text.
457        delta: String,
458    },
459    /// Raw model audio in its declared media type.
460    Audio {
461        /// Provider-declared audio media type.
462        media_type: String,
463        /// Raw, decoded audio bytes.
464        #[serde(with = "crate::core::content::base64_bytes")]
465        data: Vec<u8>,
466    },
467    /// Incremental transcription of caller audio.
468    InputTranscriptionDelta {
469        /// Newly transcribed caller text.
470        delta: String,
471    },
472    /// Incremental transcription of model audio.
473    OutputTranscriptionDelta {
474        /// Newly transcribed model text.
475        delta: String,
476    },
477    /// Client function requested by the model.
478    FunctionCall(FunctionCall),
479    /// Provider-cancelled client-function calls.
480    FunctionCallsCancelled {
481        /// Provider correlation identifiers for the cancelled calls.
482        call_ids: Vec<String>,
483    },
484    /// Usage update.
485    Usage(Usage),
486    /// The active model turn was interrupted or cancelled.
487    TurnCancelled,
488    /// Current interactive turn completed.
489    Completed,
490}
491
492/// Public implementation contract for one unique provider-native session.
493///
494/// Caller models return their concrete session implementation directly through
495/// [`crate::ModelBackend::Session`].
496///
497/// Sessions are unique, mutable, and non-Clone. Consuming close settles
498/// the session transport; the Provider retains separate shared shutdown authority.
499/// Ordinary multi-turn generation instead uses messages and continuation.
500///
501/// See the [complete native turn](crate::session) for service setup and cleanup,
502/// or [`crate::ProviderBackend`] for an executable caller-owned session.
503///
504pub trait SessionBackend: Send + 'static {
505    /// Stable session identifier.
506    ///
507    /// See [`crate::SessionBackend`] for a complete example.
508    ///
509    fn id(&self) -> &str;
510    /// Exact selected model.
511    ///
512    /// See [`crate::SessionBackend`] for a complete example.
513    ///
514    fn model_id(&self) -> &ModelId;
515    /// Sends interactive input.
516    ///
517    /// # Errors
518    ///
519    /// Returns the owning input, unsupported, closed, transport, deadline, service-rejection, or protocol error.
520    ///
521    /// See the [complete session turn](crate::session) for bounded observation and cleanup.
522    ///
523    fn send(
524        &mut self,
525        input: SessionInput,
526    ) -> impl Future<Output = Result<(), KitError>> + Send + '_;
527    /// Receives the next interactive event.
528    ///
529    /// A delta or usage event does not finish the turn. Continue receiving until
530    /// its terminal event, as in the [complete session turn](crate::session).
531    ///
532    /// # Errors
533    ///
534    /// Returns the owning input, unsupported, closed, transport, deadline, service-rejection, or protocol error.
535    ///
536    /// See the [complete session turn](crate::session) for bounded observation and cleanup.
537    ///
538    fn recv(&mut self) -> impl Future<Output = Result<SessionEvent, KitError>> + Send + '_;
539    /// Closes the unique session and its transport.
540    ///
541    /// Consuming close does not make the session reusable, even when it fails.
542    ///
543    /// # Errors
544    ///
545    /// Returns the owning input, unsupported, closed, transport, deadline, service-rejection, or protocol error.
546    ///
547    /// See the [complete session turn](crate::session) for bounded observation and cleanup.
548    ///
549    fn close(self) -> impl Future<Output = Result<(), KitError>> + Send;
550}
551
552impl SessionBackend for Infallible {
553    fn id(&self) -> &str {
554        match *self {}
555    }
556
557    fn model_id(&self) -> &ModelId {
558        match *self {}
559    }
560
561    async fn send(&mut self, _input: SessionInput) -> Result<(), KitError> {
562        match *self {}
563    }
564
565    async fn recv(&mut self) -> Result<SessionEvent, KitError> {
566        match *self {}
567    }
568
569    async fn close(self) -> Result<(), KitError> {
570        match self {}
571    }
572}
573
574pub(crate) enum SessionInner {
575    OpenAi(OpenAiSession),
576}
577
578/// Unique opaque provider-native interactive session supplied for OpenAI models.
579///
580/// Sessions are unique, mutable, and non-Clone. Consuming close settles
581/// the session transport; the Provider retains separate shared shutdown authority.
582/// Ordinary multi-turn generation instead uses messages and continuation.
583///
584/// See [`crate::SessionBackend`] for a complete example.
585///
586pub struct Session {
587    inner: SessionInner,
588    provider: Arc<ProviderRuntime>,
589}
590
591impl Session {
592    pub(crate) fn new(inner: SessionInner, provider: Arc<ProviderRuntime>) -> Self {
593        Self { inner, provider }
594    }
595}
596
597impl SessionBackend for SessionInner {
598    fn id(&self) -> &str {
599        match self {
600            Self::OpenAi(session) => session.id(),
601        }
602    }
603
604    fn model_id(&self) -> &ModelId {
605        match self {
606            Self::OpenAi(session) => session.model_id(),
607        }
608    }
609
610    async fn send(&mut self, input: SessionInput) -> Result<(), KitError> {
611        match self {
612            Self::OpenAi(session) => session.send(input).await,
613        }
614    }
615
616    async fn recv(&mut self) -> Result<SessionEvent, KitError> {
617        match self {
618            Self::OpenAi(session) => session.recv().await,
619        }
620    }
621
622    async fn close(self) -> Result<(), KitError> {
623        match self {
624            Self::OpenAi(session) => session.close().await,
625        }
626    }
627}
628
629impl SessionBackend for Session {
630    fn id(&self) -> &str {
631        self.inner.id()
632    }
633
634    fn model_id(&self) -> &ModelId {
635        self.inner.model_id()
636    }
637
638    async fn send(&mut self, input: SessionInput) -> Result<(), KitError> {
639        let _admission = self.provider.admit(Operation::SessionSend)?;
640        self.inner
641            .send(input)
642            .await
643            .map_err(|error| error.at_operation(Operation::SessionSend))
644    }
645
646    async fn recv(&mut self) -> Result<SessionEvent, KitError> {
647        let _admission = self.provider.admit(Operation::SessionReceive)?;
648        self.inner
649            .recv()
650            .await
651            .map_err(|error| error.at_operation(Operation::SessionReceive))
652    }
653
654    async fn close(self) -> Result<(), KitError> {
655        self.inner
656            .close()
657            .await
658            .map_err(|error| error.at_operation(Operation::SessionClose))
659    }
660}
661
662impl fmt::Debug for Session {
663    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
664        formatter
665            .debug_struct("Session")
666            .field("id", &self.id())
667            .field("model_id", &self.model_id())
668            .finish()
669    }
670}