turnframe_provider/provider.rs
1//! The trait every adapter implements (spec §20.1).
2//!
3//! One [`ModelProvider`] instance is one **provider-model profile**: one
4//! endpoint, one credential, one model, one honest set of declared
5//! capabilities. Two models behind the same vendor are two instances, because
6//! conformance is per provider-model combination and a capability declaration
7//! that covers "the vendor" is a declaration about nothing.
8//!
9//! An adapter's whole job is translation: normalized request in, vendor request
10//! out, vendor response in, normalized response out, vendor error in,
11//! [`ProviderError`] out. It holds no workflow policy, never inspects a
12//! workflow view, has no opinion about which acts are safe, and never receives
13//! a command handler or an external credential (spec §21.3).
14//!
15//! # The one rule an adapter can break on its own
16//!
17//! [`capabilities`](ModelProvider::capabilities) must be true. Everything
18//! downstream — routing, the refusal to downgrade a critical stage, the choice
19//! to trust a parsed plan — rests on it. An optimistic declaration is the most
20//! dangerous misconfiguration in the system, and the conformance suite exists
21//! largely to catch it: if the profile says
22//! [`NativeJsonSchema`](crate::capabilities::StructuredOutputCapability::NativeJsonSchema),
23//! the request that goes on the wire must actually carry the schema.
24
25use async_trait::async_trait;
26
27use crate::capabilities::{
28 CapabilityMismatch, CapabilityRequirements, ModelProfile, ProviderCapabilities,
29};
30use crate::error::ProviderError;
31use crate::ids::{ModelKey, ModelRef, ProviderKey};
32use crate::request::ModelRequest;
33use crate::response::ModelResponse;
34use crate::stream::ModelStream;
35
36/// One configured provider-model pair the runtime can call.
37///
38/// ```
39/// use async_trait::async_trait;
40/// use turnframe_provider::prelude::*;
41/// use turnframe_provider::stream::ModelStream;
42///
43/// struct EchoProvider;
44///
45/// #[async_trait]
46/// impl ModelProvider for EchoProvider {
47/// fn provider_key(&self) -> ProviderKey {
48/// ProviderKey::from("echo")
49/// }
50/// fn model_key(&self) -> ModelKey {
51/// ModelKey::from("echo-1")
52/// }
53/// fn capabilities(&self) -> ProviderCapabilities {
54/// ProviderCapabilities::minimal()
55/// }
56/// async fn generate(&self, request: ModelRequest) -> Result<ModelResponse, ProviderError> {
57/// let text = request.messages.last().map(Message::text).unwrap_or_default();
58/// Ok(ModelResponse::new(request.request_id, self.provider_key(), self.model_key())
59/// .with_text(text))
60/// }
61/// }
62///
63/// futures::executor::block_on(async {
64/// let provider = EchoProvider;
65/// let request = ModelRequest::new(ModelPurpose::Acknowledge).with_message(Message::user("ciao"));
66/// let response = provider.generate(request).await.unwrap();
67/// assert_eq!(response.text(), "ciao");
68///
69/// // Streaming is not implemented, and the default says so instead of
70/// // chunking a complete answer to look like a stream.
71/// let refused = provider.stream(ModelRequest::new(ModelPurpose::Acknowledge)).await;
72/// assert!(refused.is_err());
73/// });
74/// ```
75#[async_trait]
76pub trait ModelProvider: Send + Sync {
77 /// The configured provider key, e.g. `"openai"`. Labels every metric,
78 /// attempt record and replay entry this provider produces.
79 fn provider_key(&self) -> ProviderKey;
80
81 /// The configured model key, e.g. `"gpt-4o-2024-08-06"`.
82 fn model_key(&self) -> ModelKey;
83
84 /// What this provider-model pair can actually do (spec §20.3).
85 ///
86 /// Configured or probed, never inferred from the brand.
87 fn capabilities(&self) -> ProviderCapabilities;
88
89 /// The full routing profile: keys, capabilities, and the cost, region and
90 /// tags routing filters on.
91 ///
92 /// The default builds a profile with no cost, no region and no tags, which
93 /// is enough for a router that only filters on capabilities. An adapter
94 /// that knows its price or its region overrides this.
95 fn profile(&self) -> ModelProfile {
96 ModelProfile::new(self.provider_key(), self.model_key(), self.capabilities())
97 }
98
99 /// The provider-model pair, as routing and health tracking key it.
100 fn reference(&self) -> ModelRef {
101 ModelRef {
102 provider: self.provider_key(),
103 model: self.model_key(),
104 }
105 }
106
107 /// Checks the declared capabilities against `requirements`.
108 ///
109 /// The single place a caller asks "may this profile serve this stage?".
110 ///
111 /// # Errors
112 ///
113 /// Returns the [`CapabilityMismatch`] naming every unmet requirement.
114 fn supports(&self, requirements: &CapabilityRequirements) -> Result<(), CapabilityMismatch> {
115 requirements.satisfied_by(&self.capabilities())
116 }
117
118 /// Runs one model call and returns the whole answer.
119 ///
120 /// The implementation must honour [`ModelRequest::timeout`], must send
121 /// [`ModelRequest::request_id`] as an idempotency hint where the vendor
122 /// supports one, and must be cancel-safe: dropping the returned future
123 /// aborts the call and leaves nothing running.
124 ///
125 /// # Errors
126 ///
127 /// Returns the normalized [`ProviderError`] for the vendor failure.
128 async fn generate(&self, request: ModelRequest) -> Result<ModelResponse, ProviderError>;
129
130 /// Runs one model call and returns its answer incrementally.
131 ///
132 /// The default refuses with
133 /// [`Unsupported`](crate::error::ProviderErrorKind::Unsupported), which is
134 /// the right answer for an adapter whose profile declares
135 /// [`streaming: false`](crate::capabilities::ProviderCapabilities::streaming).
136 /// Emulating a stream by chunking a complete response would make the
137 /// declaration a lie and defeat the point of streaming.
138 ///
139 /// An implementation must reassemble, through
140 /// [`reconstruct`](crate::stream::reconstruct), to the same response
141 /// [`generate`](Self::generate) returns for the same exchange.
142 ///
143 /// # Errors
144 ///
145 /// Returns the normalized [`ProviderError`] for the vendor failure, before
146 /// the stream starts or as an item within it.
147 async fn stream(&self, request: ModelRequest) -> Result<ModelStream, ProviderError> {
148 let _ = request;
149 Err(ProviderError::unsupported("streaming").with_model(&self.reference()))
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use crate::capabilities::StructuredOutputCapability;
157 use crate::ids::RequestId;
158 use crate::purpose::ModelPurpose;
159 use crate::request::Message;
160
161 struct Fixed {
162 capabilities: ProviderCapabilities,
163 }
164
165 #[async_trait]
166 impl ModelProvider for Fixed {
167 fn provider_key(&self) -> ProviderKey {
168 ProviderKey::from("fixed")
169 }
170
171 fn model_key(&self) -> ModelKey {
172 ModelKey::from("fixed-1")
173 }
174
175 fn capabilities(&self) -> ProviderCapabilities {
176 self.capabilities.clone()
177 }
178
179 async fn generate(&self, request: ModelRequest) -> Result<ModelResponse, ProviderError> {
180 Ok(
181 ModelResponse::new(request.request_id, self.provider_key(), self.model_key())
182 .with_text("ok"),
183 )
184 }
185 }
186
187 fn provider() -> Fixed {
188 Fixed {
189 capabilities: ProviderCapabilities::minimal()
190 .with_structured_output(StructuredOutputCapability::NativeJsonSchema),
191 }
192 }
193
194 #[tokio::test]
195 async fn the_default_profile_mirrors_the_declared_capabilities() {
196 let provider = provider();
197 let profile = provider.profile();
198 assert_eq!(profile.provider, provider.provider_key());
199 assert_eq!(profile.model, provider.model_key());
200 assert_eq!(profile.capabilities, provider.capabilities());
201 assert_eq!(provider.reference().to_string(), "fixed/fixed-1");
202 assert!(profile.max_cost_per_million().is_none());
203 }
204
205 #[tokio::test]
206 async fn supports_answers_with_the_mismatch() {
207 let provider = provider();
208 let ok = ModelPurpose::Extract.requirements();
209 assert!(provider.supports(&ok).is_ok());
210
211 let needs_streaming = CapabilityRequirements::none().with_streaming();
212 let mismatch = provider.supports(&needs_streaming).unwrap_err();
213 assert_eq!(mismatch.missing.len(), 1);
214 assert!(!mismatch.structured_output_unmet());
215 }
216
217 #[tokio::test]
218 async fn streaming_defaults_to_an_honest_refusal() {
219 let provider = provider();
220 let request =
221 ModelRequest::new(ModelPurpose::Acknowledge).with_message(Message::user("hi"));
222 let error = provider.stream(request).await.unwrap_err();
223 assert!(matches!(
224 error.kind(),
225 crate::error::ProviderErrorKind::Unsupported { .. }
226 ));
227 assert_eq!(error.retry_class(), crate::error::RetryClass::Fallback);
228 assert_eq!(error.provider().map(ProviderKey::as_str), Some("fixed"));
229 }
230
231 #[tokio::test]
232 async fn generate_echoes_the_request_id() {
233 let provider = provider();
234 let request =
235 ModelRequest::new(ModelPurpose::Acknowledge).with_request_id(RequestId::nil());
236 let response = provider.generate(request).await.unwrap();
237 assert_eq!(response.request_id, RequestId::nil());
238 }
239
240 #[tokio::test]
241 async fn the_trait_is_object_safe() {
242 let provider: Box<dyn ModelProvider> = Box::new(provider());
243 assert_eq!(provider.provider_key().as_str(), "fixed");
244 let response = provider
245 .generate(ModelRequest::new(ModelPurpose::Acknowledge))
246 .await
247 .unwrap();
248 assert_eq!(response.text(), "ok");
249 }
250}