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}