Skip to main content

ingot_runtime/
provider.rs

1//! The model provider interface.
2//!
3//! Everything vendor-specific lives behind [`ModelProvider`]. The interpreter
4//! builds a [`CompletionRequest`] from the IR and never learns which provider
5//! answered it.
6
7use std::fmt;
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use sha2::{Digest, Sha256};
12
13use crate::schema::ResponseShape;
14
15/// Which model to use, as the artifact stated it.
16#[derive(Debug, Clone, PartialEq)]
17pub enum ModelSelection {
18    /// A pinned `provider/model` reference.
19    Exact(String),
20    /// Capability requirements the provider must satisfy.
21    Capabilities {
22        capabilities: Vec<String>,
23        min_context_tokens: Option<i64>,
24    },
25    /// The artifact stated no preference.
26    Default,
27}
28
29/// One model call.
30#[derive(Debug, Clone)]
31pub struct CompletionRequest {
32    /// Node id, for cassette matching and error messages.
33    pub node: String,
34    pub model: ModelSelection,
35    pub system: Option<String>,
36    pub prompt: String,
37    /// Named context values rendered into the request, in declaration order.
38    pub context: Vec<(String, Value)>,
39    /// The Ingot type the caller declared.
40    pub response_type: String,
41    pub shape: ResponseShape,
42    /// Upper bound on output tokens for this call.
43    pub max_tokens: u32,
44}
45
46impl CompletionRequest {
47    /// A stable digest of everything that determines the answer.
48    ///
49    /// Cassette replay compares this, so an edited prompt produces a loud
50    /// mismatch instead of a stale answer from the previous recording.
51    pub fn digest(&self) -> String {
52        let mut hasher = Sha256::new();
53        hasher.update(self.node.as_bytes());
54        hasher.update([0]);
55        hasher.update(self.system.as_deref().unwrap_or("").as_bytes());
56        hasher.update([0]);
57        hasher.update(self.prompt.as_bytes());
58        hasher.update([0]);
59        hasher.update(self.response_type.as_bytes());
60        for (name, value) in &self.context {
61            hasher.update([0]);
62            hasher.update(name.as_bytes());
63            hasher.update([0]);
64            // to_string on serde_json::Value sorts object keys, so this is
65            // stable across runs.
66            hasher.update(value.to_string().as_bytes());
67        }
68        format!("{:x}", hasher.finalize())
69    }
70}
71
72#[derive(Debug, Clone, PartialEq)]
73pub struct CompletionResponse {
74    /// The value, already unwrapped and typed as the caller declared.
75    pub value: Value,
76    pub usage: Usage,
77    /// The model that actually answered, for the event stream.
78    pub model: String,
79}
80
81#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct Usage {
84    pub input_tokens: u64,
85    pub output_tokens: u64,
86    #[serde(default, skip_serializing_if = "is_zero")]
87    pub cache_read_tokens: u64,
88}
89
90fn is_zero(value: &u64) -> bool {
91    *value == 0
92}
93
94impl Usage {
95    pub fn total(&self) -> u64 {
96        self.input_tokens + self.output_tokens
97    }
98
99    pub fn add(&mut self, other: Usage) {
100        self.input_tokens += other.input_tokens;
101        self.output_tokens += other.output_tokens;
102        self.cache_read_tokens += other.cache_read_tokens;
103    }
104}
105
106#[derive(Debug)]
107pub enum ProviderError {
108    /// The provider could not be reached, or the transport failed.
109    Transport(String),
110    /// The provider rejected the request.
111    Request { status: u16, message: String },
112    /// The provider is rate limiting; retry after this many seconds if known.
113    RateLimited { retry_after_seconds: Option<u64> },
114    /// The provider declined to answer on safety grounds.
115    Refused {
116        category: Option<String>,
117        explanation: Option<String>,
118    },
119    /// The answer did not match the declared response type.
120    InvalidResponse(String),
121    /// The response was cut off before it finished.
122    Truncated { limit: u32 },
123    /// Configuration is missing or wrong (no API key, unusable model).
124    Configuration(String),
125    /// Cassette replay could not serve this request.
126    Cassette(String),
127}
128
129impl fmt::Display for ProviderError {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        match self {
132            ProviderError::Transport(message) => write!(f, "provider transport failed: {message}"),
133            ProviderError::Request { status, message } => {
134                write!(f, "provider rejected the request ({status}): {message}")
135            }
136            ProviderError::RateLimited {
137                retry_after_seconds: Some(seconds),
138            } => {
139                write!(f, "provider is rate limiting; retry after {seconds}s")
140            }
141            ProviderError::RateLimited { .. } => write!(f, "provider is rate limiting"),
142            ProviderError::Refused {
143                category,
144                explanation,
145            } => {
146                write!(f, "the provider declined to answer")?;
147                if let Some(category) = category {
148                    write!(f, " ({category})")?;
149                }
150                if let Some(explanation) = explanation {
151                    write!(f, ": {explanation}")?;
152                }
153                Ok(())
154            }
155            ProviderError::InvalidResponse(message) => {
156                write!(f, "the response did not match the declared type: {message}")
157            }
158            ProviderError::Truncated { limit } => {
159                write!(f, "the response was cut off at the {limit} token limit")
160            }
161            ProviderError::Configuration(message) => {
162                write!(f, "provider not configured: {message}")
163            }
164            ProviderError::Cassette(message) => write!(f, "cassette replay failed: {message}"),
165        }
166    }
167}
168
169impl std::error::Error for ProviderError {}
170
171/// Text as it arrives, handed over before the answer is complete.
172///
173/// A delta is for watching, never for deciding. Nothing downstream may parse
174/// one, validate one, or bind one to a name: the value a run uses is always
175/// assembled from the finished response and validated whole. See
176/// [Runtime 0.3 §2](../../../specs/runtime/v0.3.md).
177pub type DeltaSink<'a> = &'a mut dyn FnMut(&str);
178
179/// A source of model completions.
180pub trait ModelProvider {
181    /// Short name used in events and diagnostics, e.g. `anthropic` or `replay`.
182    fn name(&self) -> &str;
183
184    fn complete(
185        &mut self,
186        request: &CompletionRequest,
187    ) -> Result<CompletionResponse, ProviderError>;
188
189    /// Whether this provider delivers an answer incrementally.
190    ///
191    /// Read by the interpreter before it decides how many output tokens one
192    /// call may ask for: a service that must compose a whole body before
193    /// sending it holds the connection open for the length of the answer, and
194    /// several refuse a large `max_tokens` outright unless the request streams.
195    /// The ceiling is therefore a property of the transport, and a provider
196    /// that says `false` here keeps the smaller one.
197    fn streams(&self) -> bool {
198        false
199    }
200
201    /// Complete a request, handing text to `on_delta` as it arrives.
202    ///
203    /// The default is [`ModelProvider::complete`] with the deltas dropped,
204    /// which is the honest answer for a provider that has nothing live to
205    /// show — a cassette replay produces its answer at once, and inventing
206    /// deltas for it would make a replayed run look like a call that never
207    /// happened.
208    ///
209    /// The returned response is what the run uses. Whatever reached `on_delta`
210    /// is a display artifact: on any error it is discarded, including when the
211    /// answer was cut off part-way through.
212    fn complete_streaming(
213        &mut self,
214        request: &CompletionRequest,
215        on_delta: DeltaSink<'_>,
216    ) -> Result<CompletionResponse, ProviderError> {
217        let _ = on_delta;
218        self.complete(request)
219    }
220}
221
222/// Lets a boxed provider be used wherever a provider is expected — including as
223/// the inner provider of a [`crate::RecordingProvider`], which is how the CLI
224/// wraps a recorder around a provider it chose at runtime.
225impl<P: ModelProvider + ?Sized> ModelProvider for Box<P> {
226    fn name(&self) -> &str {
227        (**self).name()
228    }
229
230    fn complete(
231        &mut self,
232        request: &CompletionRequest,
233    ) -> Result<CompletionResponse, ProviderError> {
234        (**self).complete(request)
235    }
236
237    fn streams(&self) -> bool {
238        (**self).streams()
239    }
240
241    fn complete_streaming(
242        &mut self,
243        request: &CompletionRequest,
244        on_delta: DeltaSink<'_>,
245    ) -> Result<CompletionResponse, ProviderError> {
246        (**self).complete_streaming(request, on_delta)
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use serde_json::json;
254
255    fn request() -> CompletionRequest {
256        CompletionRequest {
257            node: "n0".into(),
258            model: ModelSelection::Default,
259            system: None,
260            prompt: "Summarise this".into(),
261            context: vec![("document".into(), json!("hello"))],
262            response_type: "markdown".into(),
263            shape: ResponseShape::Prose,
264            max_tokens: 4096,
265        }
266    }
267
268    #[test]
269    fn the_digest_is_stable() {
270        assert_eq!(request().digest(), request().digest());
271    }
272
273    #[test]
274    fn the_digest_changes_with_the_prompt() {
275        let mut other = request();
276        other.prompt = "Summarise this document".into();
277        assert_ne!(request().digest(), other.digest());
278    }
279
280    #[test]
281    fn the_digest_changes_with_the_context() {
282        let mut other = request();
283        other.context = vec![("document".into(), json!("goodbye"))];
284        assert_ne!(request().digest(), other.digest());
285    }
286
287    #[test]
288    fn the_digest_changes_with_the_response_type() {
289        let mut other = request();
290        other.response_type = "text".into();
291        assert_ne!(request().digest(), other.digest());
292    }
293
294    #[test]
295    fn a_provider_that_cannot_stream_answers_at_once_and_shows_nothing() {
296        struct AtOnce;
297        impl ModelProvider for AtOnce {
298            fn name(&self) -> &str {
299                "at-once"
300            }
301            fn complete(
302                &mut self,
303                _request: &CompletionRequest,
304            ) -> Result<CompletionResponse, ProviderError> {
305                Ok(CompletionResponse {
306                    value: json!("the whole answer"),
307                    usage: Usage::default(),
308                    model: "at-once".into(),
309                })
310            }
311        }
312
313        let mut seen = Vec::new();
314        let response = AtOnce
315            .complete_streaming(&request(), &mut |text| seen.push(text.to_string()))
316            .unwrap();
317        assert_eq!(response.value, json!("the whole answer"));
318        assert!(
319            seen.is_empty(),
320            "a provider with nothing live to show must not invent deltas: {seen:?}"
321        );
322        assert!(!AtOnce.streams());
323    }
324
325    #[test]
326    fn usage_accumulates() {
327        let mut total = Usage::default();
328        total.add(Usage {
329            input_tokens: 10,
330            output_tokens: 5,
331            cache_read_tokens: 0,
332        });
333        total.add(Usage {
334            input_tokens: 1,
335            output_tokens: 2,
336            cache_read_tokens: 3,
337        });
338        assert_eq!(total.input_tokens, 11);
339        assert_eq!(total.output_tokens, 7);
340        assert_eq!(total.cache_read_tokens, 3);
341        assert_eq!(total.total(), 18);
342    }
343}