Skip to main content

perspt_sdk/model/
transport.rs

1//! The `ModelTransport` port (PSP-9, Layer A).
2//!
3//! The SDK declares the port; `perspt-core` provides the `genai` driver;
4//! `perspt-agent::transport` supplies the adapter. The tool loop is written
5//! against `dyn ModelTransport`, so it can inspect provider-neutral
6//! capabilities and model identity for routing and attribution, but it
7//! cannot inspect vendor adapter types or credentials. This is what makes
8//! Gate S structural rather than aspirational: a consumer who brings a
9//! different transport never links `genai`.
10//!
11//! The boxed-future signature is deliberate: an `async fn` directly on the
12//! trait would not be object-safe on this workspace's toolchain, and the SDK
13//! stays free of any async runtime.
14
15use std::future::Future;
16use std::pin::Pin;
17
18use super::capabilities::ProviderCapabilities;
19use super::conversation::Conversation;
20use super::family::ModelFamily;
21use super::id::ModelId;
22use super::tool::{ToolChoicePolicy, ToolSpec, TurnOutput};
23use crate::error::Result;
24use crate::prompt::PromptRoute;
25
26/// Provider-neutral generation controls for a single model turn.
27#[derive(Debug, Clone, Default, PartialEq)]
28pub struct GenerationOptions {
29    pub max_tokens: Option<u32>,
30    pub temperature: Option<f32>,
31    pub stop_sequences: Vec<String>,
32}
33
34/// Boxed future returned by transport calls.
35pub type TransportFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T>> + Send + 'a>>;
36
37/// The transport port: one assistant turn against one fully qualified model.
38pub trait ModelTransport: Send + Sync {
39    /// Run one chat turn: render the conversation for the route's provider,
40    /// send the tool specs, and return either tool calls or text.
41    ///
42    /// The transport records nothing; observation recording is the caller's
43    /// obligation (R2) so that replay never re-invokes a provider.
44    fn chat_turn<'a>(
45        &'a self,
46        model: &'a ModelId,
47        conversation: &'a Conversation,
48        tools: &'a [ToolSpec],
49        choice: ToolChoicePolicy,
50    ) -> TransportFuture<'a, TurnOutput>;
51
52    /// Run one turn with explicit generation controls. Implementations that
53    /// cannot express them retain their ordinary turn behavior.
54    fn chat_turn_with_options<'a>(
55        &'a self,
56        model: &'a ModelId,
57        conversation: &'a Conversation,
58        tools: &'a [ToolSpec],
59        choice: ToolChoicePolicy,
60        _options: GenerationOptions,
61    ) -> TransportFuture<'a, TurnOutput> {
62        self.chat_turn(model, conversation, tools, choice)
63    }
64
65    /// The route's capability record (declared or probed).
66    fn capabilities(&self, model: &ModelId) -> ProviderCapabilities;
67
68    /// The model's training lineage — a routing prior, never a measurement.
69    fn family_of(&self, model: &ModelId) -> ModelFamily;
70
71    /// The stable transport adapter identity (PSP-10 system 24). This is
72    /// NOT the configured endpoint id: endpoint names identify locations
73    /// and credentials, never model behavior. Scripted transports return
74    /// `"scripted"`.
75    fn adapter_kind(&self) -> &'static str;
76
77    /// The prompt identity of one call: adapter kind, model family, and the
78    /// exact model. A model served through a compatible gateway keeps its
79    /// own family identity.
80    fn prompt_route(&self, model: &ModelId) -> PromptRoute {
81        PromptRoute {
82            adapter: self.adapter_kind().to_owned(),
83            family: self.family_of(model),
84            exact_model: Some(model.model.clone()),
85        }
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    /// A scripted transport: proves the port is object-safe and lets loop
94    /// tests run without a network.
95    struct Scripted(Vec<TurnOutput>);
96
97    impl ModelTransport for Scripted {
98        fn chat_turn<'a>(
99            &'a self,
100            _model: &'a ModelId,
101            conversation: &'a Conversation,
102            _tools: &'a [ToolSpec],
103            _choice: ToolChoicePolicy,
104        ) -> TransportFuture<'a, TurnOutput> {
105            let index = conversation.len().min(self.0.len().saturating_sub(1));
106            let output = self.0[index].clone();
107            Box::pin(async move { Ok(output) })
108        }
109
110        fn capabilities(&self, _model: &ModelId) -> ProviderCapabilities {
111            ProviderCapabilities::text_only(8192)
112        }
113
114        fn family_of(&self, model: &ModelId) -> ModelFamily {
115            ModelFamily::from_model_name(&model.model)
116        }
117
118        fn adapter_kind(&self) -> &'static str {
119            "scripted"
120        }
121    }
122
123    #[test]
124    fn the_port_is_object_safe() {
125        let transport: Box<dyn ModelTransport> =
126            Box::new(Scripted(vec![TurnOutput::Text("ok".into())]));
127        let model = ModelId::new("test", "scripted");
128        assert_eq!(
129            transport.family_of(&model),
130            ModelFamily::Other("scripted".into())
131        );
132        let route = transport.prompt_route(&model);
133        assert_eq!(route.adapter, "scripted");
134        assert_eq!(route.exact_model.as_deref(), Some("scripted"));
135    }
136
137    #[test]
138    fn a_scripted_turn_resolves_without_a_runtime_dependency() {
139        let transport = Scripted(vec![TurnOutput::Text("done".into())]);
140        let model = ModelId::new("test", "scripted");
141        let conversation = Conversation::with_system("s");
142        let future = transport.chat_turn(&model, &conversation, &[], ToolChoicePolicy::Auto);
143        // Poll to completion on a trivial executor: the SDK has no tokio.
144        let output = futures_executor(future).unwrap();
145        assert_eq!(output, TurnOutput::Text("done".into()));
146    }
147
148    /// Minimal single-future executor for tests (no async runtime in the SDK).
149    fn futures_executor<T>(mut future: TransportFuture<'_, T>) -> Result<T> {
150        use std::task::{Context, Poll, Waker};
151        let waker = Waker::noop();
152        let mut context = Context::from_waker(waker);
153        loop {
154            match future.as_mut().poll(&mut context) {
155                Poll::Ready(value) => return value,
156                Poll::Pending => std::thread::yield_now(),
157            }
158        }
159    }
160}