Skip to main content

vtcode_core/llm/providers/shared/
responses_stream.rs

1use crate::llm::error_display;
2use crate::llm::provider::{LLMError, LLMNormalizedStream, LLMResponse, NormalizedStreamEvent};
3use crate::llm::providers::shared::{extract_data_payload, find_sse_boundary};
4use async_stream::try_stream;
5use futures::StreamExt;
6use hashbrown::{HashMap, HashSet};
7use serde_json::{Value, json};
8
9use super::{StreamAggregator, parse_cached_prompt_tokens_from_usage};
10
11// Retained shared Responses stream processor.
12// Rig 0.38.2 can consume SSE, but VTCode needs a provider-agnostic
13// NormalizedStreamEvent contract: text/refusal/reasoning deltas, tool-call
14// start and argument deltas, tolerant empty-final-response recovery, and
15// backend error text. Protected by this module's `responses_stream` tests.
16// Remove only when Rig exposes an event adapter with the same normalised
17// surface for all VTCode providers that use Responses-style streaming.
18pub struct ResponsesNormalizedStreamOptions {
19    pub provider_name: &'static str,
20    pub model: String,
21    pub emit_reasoning: bool,
22    pub include_cached_prompt_metrics: bool,
23}
24
25struct ResponsesNormalizedStreamProcessor<P> {
26    options: ResponsesNormalizedStreamOptions,
27    parse_final_response: P,
28    aggregator: StreamAggregator,
29    seen_tool_calls: HashSet<String>,
30    tool_call_indexes: HashMap<String, usize>,
31    tool_call_names: HashMap<String, String>,
32    next_tool_call_index: usize,
33    final_response: Option<Value>,
34    done: bool,
35}
36
37impl<P> ResponsesNormalizedStreamProcessor<P>
38where
39    P: Fn(Value) -> Result<LLMResponse, LLMError>,
40{
41    fn new(options: ResponsesNormalizedStreamOptions, parse_final_response: P) -> Self {
42        Self {
43            aggregator: StreamAggregator::new(options.model.clone()),
44            options,
45            parse_final_response,
46            seen_tool_calls: HashSet::new(),
47            tool_call_indexes: HashMap::new(),
48            tool_call_names: HashMap::new(),
49            next_tool_call_index: 0,
50            final_response: None,
51            done: false,
52        }
53    }
54
55    fn is_done(&self) -> bool {
56        self.done
57    }
58
59    fn handle_payload(&mut self, payload: Value) -> Result<Vec<NormalizedStreamEvent>, LLMError> {
60        let mut events = Vec::new();
61
62        if let Some(usage) = payload.get("usage").cloned()
63            && let Ok(usage) = serde_json::from_value(usage)
64        {
65            self.aggregator.set_usage(usage);
66        }
67
68        let event_type = payload.get("type").and_then(Value::as_str).unwrap_or("");
69        match event_type {
70            "response.output_text.delta" => {
71                let delta = payload
72                    .get("delta")
73                    .and_then(Value::as_str)
74                    .ok_or_else(|| provider_error(self.options.provider_name, "missing delta"))?;
75                for event in self.aggregator.handle_content(delta) {
76                    match event {
77                        crate::llm::provider::LLMStreamEvent::Token { delta } => {
78                            events.push(NormalizedStreamEvent::TextDelta { delta });
79                        }
80                        crate::llm::provider::LLMStreamEvent::Reasoning { delta }
81                            if self.options.emit_reasoning =>
82                        {
83                            events.push(NormalizedStreamEvent::ReasoningDelta { delta });
84                        }
85                        _ => {}
86                    }
87                }
88            }
89            "response.refusal.delta" => {
90                let delta = payload
91                    .get("delta")
92                    .and_then(Value::as_str)
93                    .ok_or_else(|| provider_error(self.options.provider_name, "missing delta"))?;
94                if !delta.is_empty() {
95                    self.aggregator.content.push_str(delta);
96                    events.push(NormalizedStreamEvent::TextDelta {
97                        delta: delta.to_string(),
98                    });
99                }
100            }
101            "response.reasoning_text.delta"
102            | "response.reasoning_summary_text.delta"
103            | "response.reasoning_content.delta" => {
104                if self.options.emit_reasoning
105                    && let Some(delta) = payload.get("delta").and_then(Value::as_str)
106                    && let Some(delta) = self.aggregator.handle_reasoning(delta)
107                {
108                    events.push(NormalizedStreamEvent::ReasoningDelta { delta });
109                }
110            }
111            "response.output_item.added" | "response.output_item.done" => {
112                if let Some(item) = payload.get("item") {
113                    let tool_call = self.capture_tool_call_metadata(
114                        item,
115                        payload
116                            .get("output_index")
117                            .and_then(Value::as_u64)
118                            .map(|value| value as usize),
119                    );
120                    if let Some((call_id, name)) = tool_call {
121                        self.push_tool_call_start(&mut events, call_id, Some(name));
122                    }
123                }
124            }
125            "response.function_call_arguments.delta" => {
126                let delta = payload
127                    .get("delta")
128                    .and_then(Value::as_str)
129                    .ok_or_else(|| provider_error(self.options.provider_name, "missing delta"))?;
130                let call_id = payload
131                    .get("item_id")
132                    .and_then(Value::as_str)
133                    .or_else(|| payload.get("call_id").and_then(Value::as_str))
134                    .filter(|value| !value.is_empty())
135                    .map(ToOwned::to_owned)
136                    .unwrap_or_else(|| format!("tool_call_{}", self.next_tool_call_index));
137                let index = self.resolve_tool_call_index(
138                    &call_id,
139                    payload
140                        .get("output_index")
141                        .and_then(Value::as_u64)
142                        .map(|value| value as usize),
143                );
144
145                let name = self.tool_call_names.get(&call_id).cloned();
146                self.push_tool_call_start(&mut events, call_id.clone(), name);
147
148                if !delta.is_empty() {
149                    self.aggregator.handle_tool_calls(&[json!({
150                        "index": index,
151                        "id": call_id,
152                        "function": {
153                            "arguments": delta,
154                        }
155                    })]);
156                    events.push(NormalizedStreamEvent::ToolCallDelta {
157                        call_id,
158                        delta: delta.to_string(),
159                    });
160                }
161            }
162            "response.completed" => {
163                if let Some(response) = payload.get("response") {
164                    self.final_response = Some(response.clone());
165                }
166                self.done = true;
167            }
168            "response.failed" | "response.incomplete" | "error" => {
169                let message = extract_error_message(&payload)
170                    .unwrap_or_else(|| "unknown error from responses stream".to_string());
171                return Err(provider_error(self.options.provider_name, message));
172            }
173            _ => {}
174        }
175
176        Ok(events)
177    }
178
179    fn finish(self) -> Result<Vec<NormalizedStreamEvent>, LLMError> {
180        let streamed = self.aggregator.finalize();
181        let mut response = if let Some(final_response) = self.final_response {
182            match (self.parse_final_response)(final_response.clone()) {
183                Ok(response) => response,
184                Err(_)
185                    if final_response_output_is_empty(&final_response)
186                        && streamed_response_is_usable(&streamed) =>
187                {
188                    let mut response = streamed.clone();
189                    merge_final_response_metadata(
190                        &mut response,
191                        &final_response,
192                        self.options.include_cached_prompt_metrics,
193                    );
194                    response
195                }
196                Err(err) => return Err(err),
197            }
198        } else {
199            streamed.clone()
200        };
201
202        merge_streamed_response(&mut response, streamed);
203
204        let mut events = Vec::new();
205        if let Some(usage) = response.usage.clone() {
206            events.push(NormalizedStreamEvent::Usage { usage });
207        }
208        events.push(NormalizedStreamEvent::Done {
209            response: Box::new(response),
210        });
211        Ok(events)
212    }
213
214    fn capture_tool_call_metadata(
215        &mut self,
216        item: &Value,
217        output_index: Option<usize>,
218    ) -> Option<(String, String)> {
219        let item_type = item.get("type").and_then(Value::as_str).unwrap_or("");
220        if item_type != "function_call" {
221            return None;
222        }
223
224        let call_id = item
225            .get("id")
226            .and_then(Value::as_str)
227            .or_else(|| item.get("call_id").and_then(Value::as_str))
228            .filter(|value| !value.is_empty());
229        let name = item.get("name").and_then(Value::as_str).or_else(|| {
230            item.get("function")
231                .and_then(|function| function.get("name"))
232                .and_then(Value::as_str)
233        });
234        if let (Some(call_id), Some(name)) = (call_id, name) {
235            self.tool_call_names
236                .entry(call_id.to_string())
237                .or_insert_with(|| name.to_string());
238            let index = self.resolve_tool_call_index(call_id, output_index);
239            self.aggregator.handle_tool_calls(&[json!({
240                "index": index,
241                "id": call_id,
242                "function": {
243                    "name": name,
244                }
245            })]);
246            return Some((call_id.to_string(), name.to_string()));
247        }
248
249        None
250    }
251
252    fn push_tool_call_start(
253        &mut self,
254        events: &mut Vec<NormalizedStreamEvent>,
255        call_id: String,
256        name: Option<String>,
257    ) {
258        if self.seen_tool_calls.insert(call_id.clone()) {
259            events.push(NormalizedStreamEvent::ToolCallStart { call_id, name });
260        }
261    }
262
263    fn resolve_tool_call_index(&mut self, call_id: &str, output_index: Option<usize>) -> usize {
264        if let Some(index) = output_index {
265            self.tool_call_indexes.insert(call_id.to_string(), index);
266            self.next_tool_call_index = self.next_tool_call_index.max(index + 1);
267            return index;
268        }
269
270        if let Some(index) = self.tool_call_indexes.get(call_id).copied() {
271            return index;
272        }
273
274        let index = self.next_tool_call_index;
275        self.tool_call_indexes.insert(call_id.to_string(), index);
276        self.next_tool_call_index += 1;
277        index
278    }
279}
280
281pub fn create_responses_normalized_stream<P>(
282    response: reqwest::Response,
283    options: ResponsesNormalizedStreamOptions,
284    parse_final_response: P,
285) -> LLMNormalizedStream
286where
287    P: Fn(Value) -> Result<LLMResponse, LLMError> + Send + 'static,
288{
289    let stream = try_stream! {
290        let provider_name = options.provider_name;
291        let mut processor = ResponsesNormalizedStreamProcessor::new(options, parse_final_response);
292        let mut body_stream = response.bytes_stream();
293        let mut buffer = String::new();
294
295        while let Some(chunk_result) = body_stream.next().await {
296            let chunk = chunk_result.map_err(|err| provider_error(
297                provider_name,
298                format!("streaming error: {err}"),
299            ))?;
300            buffer.push_str(&String::from_utf8_lossy(&chunk));
301
302            while let Some((split_idx, delimiter_len)) = find_sse_boundary(&buffer) {
303                let event = buffer[..split_idx].to_string();
304                buffer.drain(..split_idx + delimiter_len);
305
306                if let Some(data_payload) = extract_data_payload(&event) {
307                    let trimmed_payload = data_payload.trim();
308                    if trimmed_payload.is_empty() || trimmed_payload == "[DONE]" {
309                        continue;
310                    }
311
312                    let payload: Value = serde_json::from_str(trimmed_payload).map_err(|err| {
313                        provider_error(provider_name, format!("invalid stream payload: {err}"))
314                    })?;
315
316                    for event in processor.handle_payload(payload)? {
317                        yield event;
318                    }
319
320                    if processor.is_done() {
321                        break;
322                    }
323                }
324            }
325
326            if processor.is_done() {
327                break;
328            }
329        }
330
331        for event in processor.finish()? {
332            yield event;
333        }
334    };
335
336    Box::pin(stream)
337}
338
339fn streamed_response_is_usable(response: &LLMResponse) -> bool {
340    response
341        .content
342        .as_deref()
343        .is_some_and(|content| !content.is_empty())
344        || response
345            .tool_calls
346            .as_ref()
347            .is_some_and(|tool_calls| !tool_calls.is_empty())
348        || response
349            .reasoning
350            .as_deref()
351            .is_some_and(|reasoning| !reasoning.is_empty())
352        || response
353            .reasoning_details
354            .as_ref()
355            .is_some_and(|details| !details.is_empty())
356}
357
358fn final_response_output_is_empty(final_response: &Value) -> bool {
359    final_response
360        .get("output")
361        .and_then(Value::as_array)
362        .is_some_and(Vec::is_empty)
363}
364
365fn merge_streamed_response(response: &mut LLMResponse, streamed: LLMResponse) {
366    if response.content.as_deref().unwrap_or_default().is_empty() {
367        response.content = streamed.content;
368    } else if let (Some(content), Some(streamed_content)) =
369        (&mut response.content, streamed.content)
370        && !streamed_content.is_empty()
371        && !content.contains(&streamed_content)
372    {
373        content.push_str(&streamed_content);
374    }
375
376    if response.tool_calls.is_none() {
377        response.tool_calls = streamed.tool_calls;
378    }
379
380    if response.usage.is_none() {
381        response.usage = streamed.usage;
382    }
383
384    if response.reasoning.is_none() {
385        response.reasoning = streamed.reasoning;
386    }
387
388    if response.reasoning_details.is_none() {
389        response.reasoning_details = streamed.reasoning_details;
390    }
391
392    if response.tool_references.is_empty() && !streamed.tool_references.is_empty() {
393        response.tool_references = streamed.tool_references;
394    }
395
396    if response.request_id.is_none() {
397        response.request_id = streamed.request_id;
398    }
399
400    if response.organization_id.is_none() {
401        response.organization_id = streamed.organization_id;
402    }
403}
404
405fn merge_final_response_metadata(
406    response: &mut LLMResponse,
407    final_response: &Value,
408    include_cached_prompt_metrics: bool,
409) {
410    if let Some(usage) = parse_responses_usage(final_response, include_cached_prompt_metrics) {
411        response.usage = Some(usage);
412    }
413
414    if let Some(request_id) = final_response
415        .get("id")
416        .and_then(Value::as_str)
417        .or_else(|| final_response.get("request_id").and_then(Value::as_str))
418    {
419        response.request_id = Some(request_id.to_string());
420    }
421}
422
423fn parse_responses_usage(
424    final_response: &Value,
425    include_cached_prompt_metrics: bool,
426) -> Option<crate::llm::provider::Usage> {
427    let usage_value = final_response.get("usage")?;
428    let cached_prompt_tokens =
429        parse_cached_prompt_tokens_from_usage(usage_value, include_cached_prompt_metrics);
430    Some(crate::llm::provider::Usage {
431        prompt_tokens: usage_value
432            .get("input_tokens")
433            .or_else(|| usage_value.get("prompt_tokens"))
434            .and_then(Value::as_u64)
435            .and_then(|value| u32::try_from(value).ok())
436            .unwrap_or(0),
437        completion_tokens: usage_value
438            .get("output_tokens")
439            .or_else(|| usage_value.get("completion_tokens"))
440            .and_then(Value::as_u64)
441            .and_then(|value| u32::try_from(value).ok())
442            .unwrap_or(0),
443        total_tokens: usage_value
444            .get("total_tokens")
445            .and_then(Value::as_u64)
446            .and_then(|value| u32::try_from(value).ok())
447            .unwrap_or(0),
448        cached_prompt_tokens,
449        cache_creation_tokens: None,
450        cache_read_tokens: None,
451        iterations: None,
452    })
453}
454
455fn extract_error_message(payload: &Value) -> Option<String> {
456    payload
457        .get("error")
458        .and_then(|error| error.get("message"))
459        .and_then(Value::as_str)
460        .map(ToOwned::to_owned)
461        .or_else(|| {
462            payload
463                .get("response")
464                .and_then(|response| response.get("error"))
465                .and_then(|error| error.get("message"))
466                .and_then(Value::as_str)
467                .map(ToOwned::to_owned)
468        })
469}
470
471fn provider_error(provider_name: &str, message: impl Into<String>) -> LLMError {
472    let message = error_display::format_llm_error(provider_name, &message.into());
473    LLMError::Provider {
474        message,
475        metadata: None,
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::{
482        ResponsesNormalizedStreamOptions, ResponsesNormalizedStreamProcessor, provider_error,
483    };
484    use crate::llm::provider::{FinishReason, LLMResponse, NormalizedStreamEvent, ToolCall};
485    use serde_json::{Value, json};
486
487    fn options() -> ResponsesNormalizedStreamOptions {
488        ResponsesNormalizedStreamOptions {
489            provider_name: "TestProvider",
490            model: "gpt-5".to_string(),
491            emit_reasoning: true,
492            include_cached_prompt_metrics: false,
493        }
494    }
495
496    fn parse_response(value: Value) -> Result<LLMResponse, crate::llm::provider::LLMError> {
497        let content = value
498            .get("output")
499            .and_then(Value::as_array)
500            .and_then(|items| items.first())
501            .and_then(|item| item.get("content"))
502            .and_then(Value::as_array)
503            .and_then(|content| content.first())
504            .and_then(|item| item.get("text"))
505            .and_then(Value::as_str)
506            .map(ToOwned::to_owned);
507
508        Ok(LLMResponse {
509            content,
510            model: "gpt-5".to_string(),
511            finish_reason: FinishReason::Stop,
512            ..Default::default()
513        })
514    }
515
516    #[test]
517    fn text_delta_and_completed_yield_text_then_done() {
518        let mut processor = ResponsesNormalizedStreamProcessor::new(options(), parse_response);
519
520        let events = processor
521            .handle_payload(json!({
522                "type": "response.output_text.delta",
523                "delta": "hello"
524            }))
525            .expect("text delta should parse");
526        assert!(matches!(
527            events.as_slice(),
528            [NormalizedStreamEvent::TextDelta { delta }] if delta == "hello"
529        ));
530
531        let completed_events = processor
532            .handle_payload(json!({
533                "type": "response.completed",
534                "response": {
535                    "output": [{
536                        "type": "message",
537                        "content": [{"type": "output_text", "text": "hello"}]
538                    }]
539                }
540            }))
541            .expect("completed event should parse");
542        assert!(completed_events.is_empty());
543
544        let finished = processor.finish().expect("finish should succeed");
545        assert!(matches!(
546            finished.as_slice(),
547            [NormalizedStreamEvent::Done { response }]
548                if response.content.as_deref() == Some("hello")
549        ));
550    }
551
552    #[test]
553    fn empty_final_response_uses_streamed_text_and_preserves_metadata() {
554        let mut options = options();
555        options.include_cached_prompt_metrics = true;
556        let mut processor = ResponsesNormalizedStreamProcessor::new(options, |value| {
557            let output = value
558                .get("output")
559                .and_then(Value::as_array)
560                .ok_or_else(|| provider_error("TestProvider", "missing output"))?;
561            if output.is_empty() {
562                return Err(provider_error("TestProvider", "No output in response"));
563            }
564            parse_response(value)
565        });
566
567        processor
568            .handle_payload(json!({
569                "type": "response.output_text.delta",
570                "delta": "streamed answer"
571            }))
572            .expect("text delta should parse");
573        processor
574            .handle_payload(json!({
575                "type": "response.completed",
576                "response": {
577                    "id": "resp_streamed",
578                    "output": [],
579                    "usage": {
580                        "input_tokens": 11,
581                        "output_tokens": 7,
582                        "total_tokens": 18,
583                        "input_tokens_details": {
584                            "cached_tokens": 5
585                        }
586                    }
587                }
588            }))
589            .expect("completed event should parse");
590
591        let finished = processor.finish().expect("finish should succeed");
592        let [
593            NormalizedStreamEvent::Usage { usage },
594            NormalizedStreamEvent::Done { response },
595        ] = finished.as_slice()
596        else {
597            panic!("expected usage then done");
598        };
599        assert_eq!(usage.prompt_tokens, 11);
600        assert_eq!(usage.completion_tokens, 7);
601        assert_eq!(usage.total_tokens, 18);
602        assert_eq!(usage.cached_prompt_tokens, Some(5));
603        assert_eq!(response.content.as_deref(), Some("streamed answer"));
604        assert_eq!(response.request_id.as_deref(), Some("resp_streamed"));
605    }
606
607    #[test]
608    fn tool_call_deltas_emit_start_and_finish_with_assembled_tool_call() {
609        let mut processor = ResponsesNormalizedStreamProcessor::new(options(), |_| {
610            Ok(LLMResponse {
611                model: "gpt-5".to_string(),
612                ..Default::default()
613            })
614        });
615
616        let started = processor
617            .handle_payload(json!({
618                "type": "response.output_item.added",
619                "output_index": 0,
620                "item": {
621                    "type": "function_call",
622                    "id": "call_1",
623                    "name": "search_workspace"
624                }
625            }))
626            .expect("output item metadata should parse");
627        assert!(matches!(
628            started.as_slice(),
629            [NormalizedStreamEvent::ToolCallStart { call_id, name }]
630                if call_id == "call_1" && name.as_deref() == Some("search_workspace")
631        ));
632
633        let first = processor
634            .handle_payload(json!({
635                "type": "response.function_call_arguments.delta",
636                "item_id": "call_1",
637                "delta": "{\"query\":\"vt"
638            }))
639            .expect("first tool delta should parse");
640        assert!(matches!(
641            first.as_slice(),
642            [NormalizedStreamEvent::ToolCallDelta { call_id: delta_call_id, delta }]
643            if delta_call_id == "call_1"
644                && delta == "{\"query\":\"vt"
645        ));
646
647        let second = processor
648            .handle_payload(json!({
649                "type": "response.function_call_arguments.delta",
650                "item_id": "call_1",
651                "delta": "code\"}"
652            }))
653            .expect("second tool delta should parse");
654        assert!(matches!(
655            second.as_slice(),
656            [NormalizedStreamEvent::ToolCallDelta { call_id, delta }]
657                if call_id == "call_1" && delta == "code\"}"
658        ));
659
660        let finished = processor.finish().expect("finish should succeed");
661        let response = match finished.as_slice() {
662            [NormalizedStreamEvent::Done { response }] => response,
663            _ => panic!("expected done event"),
664        };
665        let tool_calls = response
666            .tool_calls
667            .as_ref()
668            .expect("tool call should be assembled");
669        assert_eq!(
670            tool_calls,
671            &vec![ToolCall::function(
672                "call_1".to_string(),
673                "search_workspace".to_string(),
674                "{\"query\":\"vtcode\"}".to_string(),
675            )]
676        );
677    }
678
679    #[test]
680    fn refusal_delta_streams_visible_output() {
681        let mut processor = ResponsesNormalizedStreamProcessor::new(options(), parse_response);
682
683        let events = processor
684            .handle_payload(json!({
685                "type": "response.refusal.delta",
686                "delta": "I can't help with that"
687            }))
688            .expect("refusal delta should parse");
689        assert!(matches!(
690            events.as_slice(),
691            [NormalizedStreamEvent::TextDelta { delta }]
692                if delta == "I can't help with that"
693        ));
694
695        let finished = processor.finish().expect("finish should succeed");
696        assert!(matches!(
697            finished.as_slice(),
698            [NormalizedStreamEvent::Done { response }]
699                if response.content.as_deref() == Some("I can't help with that")
700        ));
701    }
702
703    #[test]
704    fn failed_incomplete_and_error_events_surface_backend_message() {
705        for payload in [
706            json!({"type": "response.failed", "response": {"error": {"message": "failed"}}}),
707            json!({"type": "response.incomplete", "response": {"error": {"message": "incomplete"}}}),
708            json!({"type": "error", "error": {"message": "errored"}}),
709        ] {
710            let mut processor = ResponsesNormalizedStreamProcessor::new(options(), parse_response);
711            let error = processor
712                .handle_payload(payload)
713                .expect_err("error payload should fail");
714            assert!(
715                error.to_string().contains("failed")
716                    || error.to_string().contains("incomplete")
717                    || error.to_string().contains("errored")
718            );
719        }
720    }
721
722    #[test]
723    fn unknown_documented_events_are_ignored() {
724        let mut processor = ResponsesNormalizedStreamProcessor::new(options(), parse_response);
725        let events = processor
726            .handle_payload(json!({
727                "type": "response.file_search_call.searching",
728                "query": "needle"
729            }))
730            .expect("unknown documented event should be ignored");
731        assert!(events.is_empty());
732        processor
733            .handle_payload(json!({
734                "type": "response.code_interpreter_call.code.delta",
735                "delta": "print(1)"
736            }))
737            .expect("code interpreter event should be ignored");
738
739        let finished = processor.finish().expect("finish should succeed");
740        assert!(matches!(
741            finished.as_slice(),
742            [NormalizedStreamEvent::Done { .. }]
743        ));
744    }
745
746    #[test]
747    fn missing_delta_reports_provider_error() {
748        let mut processor = ResponsesNormalizedStreamProcessor::new(options(), parse_response);
749        let error = processor
750            .handle_payload(json!({"type": "response.output_text.delta"}))
751            .expect_err("missing delta should fail");
752        assert_eq!(
753            error.to_string(),
754            provider_error("TestProvider", "missing delta").to_string()
755        );
756    }
757}