Skip to main content

rig_core/test_utils/
completion.rs

1//! Completion helpers for deterministic agent-loop tests.
2
3use std::{
4    collections::VecDeque,
5    sync::{Arc, Mutex, MutexGuard},
6};
7
8use crate::driver::{Exchange, Model, Opened, Opening, Transport};
9use crate::error::{EncodeError, ProviderError};
10use crate::operation::Completion;
11use crate::wire::{Capabilities, Descriptor, Mode, Wire};
12use crate::{
13    completion::{AssistantContent, CompletionRequest, CompletionResponse, Usage},
14    message::{ToolCall, ToolFunction},
15};
16
17use super::streaming::{MOCK_PROVIDER, MockDecoder, MockDocument, MockFrame, MockStreamEvent};
18
19/// Scripted error returned by [`MockCompletionModel`].
20#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
21pub enum MockError {
22    /// Provider error.
23    Provider(String),
24    /// Request construction error.
25    Request(String),
26    /// A preserved provider error response (rig#2314), id included.
27    ProviderResponse(crate::provider_response::ProviderResponseError),
28}
29
30impl MockError {
31    /// Create a provider error.
32    pub fn provider(message: impl Into<String>) -> Self {
33        Self::Provider(message.into())
34    }
35
36    /// Create a request error.
37    pub fn request(message: impl Into<String>) -> Self {
38        Self::Request(message.into())
39    }
40
41    pub(crate) fn into_completion_error(self) -> ProviderError {
42        match self {
43            Self::Provider(message) => ProviderError::Provider(message),
44            Self::Request(message) => ProviderError::request(message),
45            Self::ProviderResponse(response) => ProviderError::ProviderResponse(response),
46        }
47    }
48}
49
50/// A scripted non-streaming mock completion turn.
51///
52/// A turn is data: a script serializes, so a scripted model can be written
53/// to a fixture and read back (see `MockCompletionModel::script`).
54#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
55pub struct MockTurn {
56    response: Result<MockTurnResponse, MockError>,
57}
58
59#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
60struct MockTurnResponse {
61    choice: Vec<AssistantContent>,
62    usage: Usage,
63    response_id: Option<String>,
64    provider_request_id: Option<String>,
65    finish_reason: Option<crate::completion::FinishReason>,
66    /// A scripted provider document, when the test supplies one; otherwise
67    /// the turn itself, serialized, is the mock's document. Absent from the
68    /// serialized turn when unscripted, so that document never nests
69    /// itself; a scripted one survives a serde round trip of the script.
70    #[serde(
71        default,
72        skip_serializing_if = "Option::is_none",
73        deserialize_with = "deserialize_scripted_raw"
74    )]
75    raw: Option<serde_json::Value>,
76}
77
78fn deserialize_scripted_raw<'de, D: serde::Deserializer<'de>>(
79    deserializer: D,
80) -> Result<Option<serde_json::Value>, D::Error> {
81    // Only a missing field means unscripted; explicit JSON null is a document.
82    serde::Deserialize::deserialize(deserializer).map(Some)
83}
84
85impl MockTurn {
86    /// Create a text response turn.
87    pub fn text(text: impl Into<String>) -> Self {
88        Self::from_content(AssistantContent::text(text.into()))
89    }
90
91    /// Create a tool-call response turn. An empty `name` scripts a provider
92    /// error instead: no call can be built without a name.
93    pub fn tool_call(
94        id: impl Into<String>,
95        name: impl Into<String>,
96        arguments: serde_json::Value,
97    ) -> Self {
98        match crate::message::ToolName::new(name) {
99            Ok(name) => Self::from_content(AssistantContent::ToolCall(ToolCall::from_wire(
100                id,
101                ToolFunction::new(name, arguments),
102            ))),
103            Err(error) => Self::error(error.to_string()),
104        }
105    }
106
107    /// Create a provider-error response turn.
108    pub fn error(message: impl Into<String>) -> Self {
109        Self {
110            response: Err(MockError::provider(message)),
111        }
112    }
113
114    /// Create a provider-response error turn carrying a transport request id
115    /// (rig#2314): the scripted failure a test uses to assert error-identity
116    /// attribution.
117    pub fn provider_response_error(
118        status: http::StatusCode,
119        body: impl Into<String>,
120        request_id: impl Into<String>,
121    ) -> Self {
122        Self {
123            response: Err(MockError::ProviderResponse(
124                crate::provider_response::ProviderResponseError::new(status, body)
125                    .with_provider_request_id(Some(request_id.into())),
126            )),
127        }
128    }
129
130    /// Create a request-error response turn.
131    pub fn request_error(message: impl Into<String>) -> Self {
132        Self {
133            response: Err(MockError::request(message)),
134        }
135    }
136
137    /// Create a response turn from one assistant content item.
138    pub fn from_content(content: AssistantContent) -> Self {
139        Self {
140            response: Ok(MockTurnResponse {
141                choice: vec![content],
142                usage: Usage::default(),
143                response_id: None,
144                provider_request_id: None,
145                finish_reason: None,
146                raw: None,
147            }),
148        }
149    }
150
151    /// Create a response turn from assistant content items.
152    ///
153    /// Infallible now that content is a `Vec`: an empty turn is a shape a
154    /// provider can genuinely return, so it is a value to build, not an error.
155    pub fn from_contents(content: impl IntoIterator<Item = AssistantContent>) -> Self {
156        Self {
157            response: Ok(MockTurnResponse {
158                choice: content.into_iter().collect(),
159                usage: Usage::default(),
160                response_id: None,
161                provider_request_id: None,
162                finish_reason: None,
163                raw: None,
164            }),
165        }
166    }
167
168    /// Attach a provider-specific call ID to a tool-call response turn.
169    pub fn with_call_id(mut self, call_id: impl Into<String>) -> Self {
170        let call_id = call_id.into();
171        if let Ok(response) = &mut self.response {
172            for content in response.choice.iter_mut() {
173                if let AssistantContent::ToolCall(tool_call) = content {
174                    tool_call.id = crate::message::CallId::from_wire(call_id);
175                    break;
176                }
177            }
178        }
179        self
180    }
181
182    /// Override usage for this turn.
183    pub fn with_usage(mut self, usage: Usage) -> Self {
184        if let Ok(response) = &mut self.response {
185            response.usage = usage;
186        }
187        self
188    }
189
190    /// Set a provider-assigned response-scoped ID for this turn.
191    pub fn with_response_id(mut self, response_id: impl Into<String>) -> Self {
192        if let Ok(response) = &mut self.response {
193            response.response_id = Some(response_id.into());
194        }
195        self
196    }
197
198    /// Set a provider transport request id for this turn.
199    pub fn with_provider_request_id(mut self, request_id: impl Into<String>) -> Self {
200        if let Ok(response) = &mut self.response {
201            response.provider_request_id = Some(request_id.into());
202        }
203        self
204    }
205
206    /// Set the terminal finish reason for this turn.
207    ///
208    /// Without this, a mocked blocking turn always reports `None`, which
209    /// leaves the whole blocking half of the truncation contract (rig#2322)
210    /// unexercisable — the streamed mock could script a reason and the
211    /// blocking one could not.
212    pub fn with_finish_reason(mut self, finish_reason: crate::completion::FinishReason) -> Self {
213        if let Ok(response) = &mut self.response {
214            response.finish_reason = Some(finish_reason);
215        }
216        self
217    }
218
219    /// The provider document the mock attaches to this turn's response: the
220    /// scripted payload when one was supplied, otherwise the turn itself,
221    /// serialized. Public so a test can state the expected `raw` of a
222    /// recorded call without repeating the mock's serialization. An error
223    /// turn has no document.
224    pub fn raw(&self) -> Result<serde_json::Value, ProviderError> {
225        let response = self
226            .response
227            .as_ref()
228            .map_err(|error| error.clone().into_completion_error())?;
229        match &response.raw {
230            Some(raw) => Ok(raw.clone()),
231            None => Ok(serde_json::to_value(response)?),
232        }
233    }
234
235    fn into_completion_response(self) -> Result<CompletionResponse, ProviderError> {
236        let raw = self.raw()?;
237        let response = self.response.map_err(MockError::into_completion_error)?;
238        let mut origin = crate::message::Origin::new(MOCK_API, MOCK_PROVIDER, "");
239        origin.response_id = response.response_id;
240        let mut completion = CompletionResponse::new(response.choice, response.usage, origin, raw)
241            .with_optional_finish_reason(response.finish_reason);
242        completion.provider_request_id = response.provider_request_id;
243        Ok(completion)
244    }
245}
246
247type MockInvocation = (CompletionRequest, Option<crate::observe::AdapterContext>);
248
249#[derive(Default)]
250struct MockScriptState {
251    turns: Mutex<VecDeque<MockTurn>>,
252    stream_turns: Mutex<VecDeque<Vec<MockStreamEvent>>>,
253    requests: Mutex<Vec<MockInvocation>>,
254}
255
256/// The scripted completion wire: its payload is the request, and each frame
257/// is a scripted step its decoder writes through the completion writer's
258/// part handles, as any wire's decoder does. A runtime that answers from a
259/// script ([`MockRuntime`], or a test's own) is its transport.
260#[derive(Clone, Debug, PartialEq, Eq)]
261pub struct MockScript {
262    name: String,
263    id: Option<String>,
264    capabilities: Capabilities,
265    /// The facts its replies are priced by, as a real wire's are.
266    facts: crate::catalog::ModelFacts,
267}
268
269impl MockScript {
270    /// A scripted wire named `name`, as records and telemetry name it, with
271    /// default capabilities.
272    pub fn new(name: impl Into<String>) -> Self {
273        Self {
274            name: name.into(),
275            id: None,
276            capabilities: Capabilities::default(),
277            facts: crate::catalog::ModelFacts::default(),
278        }
279    }
280
281    /// The same wire, addressing the model `id`.
282    pub fn with_id(mut self, id: impl Into<String>) -> Self {
283        self.id = Some(id.into());
284        self
285    }
286
287    /// The same wire, reporting `capabilities`.
288    pub fn with_capabilities(mut self, capabilities: Capabilities) -> Self {
289        self.capabilities = capabilities;
290        self
291    }
292
293    /// The same wire answering as `spec`'s model: named as its provider and
294    /// id, and pricing each reply's usage at the spec's prices, as the
295    /// provider's own wire would. A reply whose usage states its cost keeps
296    /// it.
297    pub fn priced_as(self, spec: impl Into<std::sync::Arc<crate::catalog::ModelSpec>>) -> Self {
298        let spec = spec.into();
299        Self {
300            name: spec.provider.vendor().to_owned(),
301            id: Some(spec.id.clone()),
302            facts: crate::catalog::ModelFacts::new(spec),
303            ..self
304        }
305    }
306}
307
308/// The wire format the scripted wire names.
309pub const MOCK_API: crate::message::Api = crate::message::Api::from_static("mock.script");
310
311/// The model a scripted wire addresses when it names none.
312pub const MOCK_MODEL: &str = "mock-model";
313
314impl crate::completion::ReplayTarget for MockScript {
315    fn api(&self) -> crate::message::Api {
316        MOCK_API
317    }
318
319    /// A script takes every option and sends nothing for it, so a test
320    /// reads the options it set on the request the script records.
321    fn map_options(
322        &self,
323        _request: &crate::completion::CompletionRequest,
324        fields: crate::completion::options::OptionFields<'_>,
325    ) -> crate::completion::options::OptionMap {
326        each_set(fields, || {
327            crate::completion::options::Mapping::Omit("a scripted reply ignores options")
328        })
329    }
330
331    // Scripts state a finish reason only when a test is about one.
332    fn states_finish_reason(&self) -> bool {
333        false
334    }
335
336    fn provider(&self) -> &str {
337        &self.name
338    }
339
340    /// The model the script addresses: its id, or [`MOCK_MODEL`].
341    fn model(&self) -> &str {
342        self.id.as_deref().unwrap_or(MOCK_MODEL)
343    }
344
345    fn accepts(&self, _model: &str) -> crate::completion::Accepts {
346        crate::completion::Accepts::ALL
347    }
348}
349
350impl Default for MockScript {
351    fn default() -> Self {
352        Self::new(MOCK_PROVIDER)
353    }
354}
355
356impl Wire for MockScript {
357    type Op = Completion;
358    type Payload = CompletionRequest;
359    type Frame = MockFrame;
360    type Event = MockFrame;
361    type Decoder<'id> = MockDecoder<'id>;
362    type Reassembler = MockDocument;
363
364    fn describe(&self) -> Descriptor<'_> {
365        Descriptor::new(&self.name)
366            .model(self.id.as_deref())
367            .capabilities(self.capabilities)
368            .facts(&self.facts)
369            .replay(self)
370    }
371
372    /// The request as the runtime received it, its history already shaped
373    /// for this wire.
374    fn encode(
375        &self,
376        request: CompletionRequest,
377        _mode: Mode,
378    ) -> Result<CompletionRequest, EncodeError> {
379        Ok(request)
380    }
381
382    fn decoder<'id>(&self) -> MockDecoder<'id> {
383        MockDecoder::default()
384    }
385}
386
387/// The scripted runtime behind [`MockCompletionModel`]: the transport of a
388/// [`MockScript`] wire named [`MOCK_PROVIDER`].
389///
390/// Each call consumes exactly one scripted turn. If no turn is available,
391/// the call fails with [`ProviderError::Provider`] and a clear message
392/// instead of repeating previous responses.
393#[derive(Clone, Default)]
394pub struct MockRuntime {
395    state: Arc<MockScriptState>,
396}
397
398/// A cloneable scripted completion model for tests: the scripted wire over
399/// its runtime. Clones share the script and the recorded requests.
400pub type MockCompletionModel = Model<MockScript, MockRuntime>;
401
402impl MockCompletionModel {
403    /// Create a mock model that returns one text completion.
404    pub fn text(text: impl Into<String>) -> Self {
405        Self::from_turns([MockTurn::text(text)])
406    }
407
408    /// Create a mock model from scripted non-streaming turns.
409    pub fn from_turns(turns: impl IntoIterator<Item = MockTurn>) -> Self {
410        Self::scripted(turns.into_iter().collect(), VecDeque::new())
411    }
412
413    /// Create a mock model from scripted streaming turns.
414    pub fn from_stream_turns(
415        stream_turns: impl IntoIterator<Item = impl IntoIterator<Item = MockStreamEvent>>,
416    ) -> Self {
417        Self::scripted(
418            VecDeque::new(),
419            stream_turns
420                .into_iter()
421                .map(|turn| turn.into_iter().collect())
422                .collect(),
423        )
424    }
425
426    fn scripted(turns: VecDeque<MockTurn>, stream_turns: VecDeque<Vec<MockStreamEvent>>) -> Self {
427        Model::new(
428            MockScript::default(),
429            MockRuntime {
430                state: Arc::new(MockScriptState {
431                    turns: Mutex::new(turns),
432                    stream_turns: Mutex::new(stream_turns),
433                    requests: Mutex::new(Vec::new()),
434                }),
435            },
436        )
437    }
438
439    /// The same scripted model answering as `spec`'s model and pricing its
440    /// replies at the spec's prices ([`MockScript::priced_as`]), for a test
441    /// of what spending is counted.
442    pub fn priced_as(mut self, spec: impl Into<std::sync::Arc<crate::catalog::ModelSpec>>) -> Self {
443        self.wire = self.wire.priced_as(spec);
444        self
445    }
446
447    /// Return cloned requests received by this model.
448    pub fn requests(&self) -> Vec<CompletionRequest> {
449        self.transport
450            .requests_guard()
451            .iter()
452            .map(|(request, _)| request.clone())
453            .collect()
454    }
455
456    /// Return invocation contexts in the same order as the captured requests.
457    pub fn contexts(&self) -> Vec<Option<crate::observe::AdapterContext>> {
458        self.transport
459            .requests_guard()
460            .iter()
461            .map(|(_, context)| context.clone())
462            .collect()
463    }
464
465    /// Return the number of requests received by this model.
466    pub fn request_count(&self) -> usize {
467        self.transport.requests_guard().len()
468    }
469
470    /// The non-streaming turns not yet consumed, in order — the read-back
471    /// half of the script, so a script is serde in and serde out.
472    pub fn script(&self) -> Vec<MockTurn> {
473        lock(&self.transport.state.turns).iter().cloned().collect()
474    }
475}
476
477impl MockRuntime {
478    fn requests_guard(&self) -> MutexGuard<'_, Vec<MockInvocation>> {
479        lock(&self.state.requests)
480    }
481}
482
483fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
484    match mutex.lock() {
485        Ok(guard) => guard,
486        Err(poisoned) => poisoned.into_inner(),
487    }
488}
489
490impl Transport<MockScript> for MockRuntime {
491    fn send(&self, request: CompletionRequest, exchange: Exchange) -> Opening<MockFrame> {
492        let mode = exchange.mode;
493        self.requests_guard().push((request, exchange.observation));
494        match mode {
495            // A whole turn is one response, its document the response's
496            // `raw`; a scripted failure fails the reply.
497            Mode::Unary => {
498                let Some(turn) = lock(&self.state.turns).pop_front() else {
499                    return Opening::failed(ProviderError::Provider(
500                        "mock completion model has no scripted completion turn".to_string(),
501                    ));
502                };
503                match turn.into_completion_response() {
504                    Ok(response) => {
505                        let document = response.raw.clone();
506                        let request_id = response.provider_request_id.clone();
507                        Opening::ready(
508                            Opened::new(futures::stream::iter([Ok(MockFrame::Response(
509                                Box::new(response),
510                            ))]))
511                            .with_document(document)
512                            .with_request_id(request_id),
513                        )
514                    }
515                    Err(error) => Opening::ready(Opened::failed(error)),
516                }
517            }
518            Mode::Streaming => {
519                let Some(turn) = lock(&self.state.stream_turns).pop_front() else {
520                    return Opening::failed(ProviderError::Provider(
521                        "mock completion model has no scripted streaming turn".to_string(),
522                    ));
523                };
524                let request_id = turn.iter().find_map(|event| match event {
525                    MockStreamEvent::RequestId(id) => Some(id.clone()),
526                    _ => None,
527                });
528                Opening::ready(
529                    Opened::new(futures::stream::iter(
530                        turn.into_iter()
531                            .filter(|event| !matches!(event, MockStreamEvent::RequestId(_)))
532                            .map(|event| Ok(MockFrame::Event(event))),
533                    ))
534                    .with_request_id(request_id),
535                )
536            }
537        }
538    }
539}
540
541#[cfg(test)]
542mod tests;
543
544/// Every set option refused, for a test target that takes none.
545pub fn refuse_options(
546    fields: crate::completion::options::OptionFields<'_>,
547) -> crate::completion::options::OptionMap {
548    each_set(fields, || {
549        crate::completion::options::Mapping::unsupported("the test target takes no options")
550    })
551}
552
553/// `answer` for every set option but the cache key, which is unrouted.
554fn each_set(
555    fields: crate::completion::options::OptionFields<'_>,
556    answer: impl Fn() -> crate::completion::options::Mapping,
557) -> crate::completion::options::OptionMap {
558    use crate::completion::options::{Mapping, OptionMap};
559    let [
560        reasoning,
561        cache,
562        service_tier,
563        verbosity,
564        parallel_tool_calls,
565        top_p,
566        seed,
567        stop,
568        _,
569    ] = fields
570        .set()
571        .map(|set| if set { answer() } else { Mapping::Nothing });
572    OptionMap {
573        reasoning,
574        cache,
575        service_tier,
576        verbosity,
577        parallel_tool_calls,
578        top_p,
579        seed,
580        stop,
581        cache_key: Mapping::unrouted(fields.cache_key),
582    }
583}