Skip to main content

switchyard_translation/
helpers.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Convenience wrappers over the default [`TranslationEngine`] — decode a wire
5//! request/response to the neutral IR, encode the IR back, and decode/encode a
6//! streamed response — so callers can translate without threading an engine and
7//! policy through every call.
8
9use std::sync::LazyLock;
10
11use futures::Stream;
12use serde_json::Value;
13use switchyard_protocol::LlmClientError;
14
15use crate::{
16    AggLlmResponse, LlmRequest, LlmResponseStream, RawEventStream, Result, TranslationEngine,
17    TranslationPolicy, WireFormat,
18};
19
20static DEFAULT_TRANSLATION_POLICY: LazyLock<TranslationPolicy> =
21    LazyLock::new(TranslationPolicy::default);
22static DEFAULT_TRANSLATION_ENGINE: LazyLock<TranslationEngine> =
23    LazyLock::new(TranslationEngine::default);
24
25/// Decodes a `wire_format` request body into the neutral IR.
26pub fn decode_request(wire_format: WireFormat, body: &Value) -> Result<LlmRequest> {
27    Ok(DEFAULT_TRANSLATION_ENGINE
28        .decode_request(wire_format, body, &DEFAULT_TRANSLATION_POLICY)?
29        .request)
30}
31
32/// Encodes a normalized request into `wire_format`'s JSON body.
33pub fn encode_request(request: &LlmRequest, wire_format: WireFormat) -> Result<Value> {
34    Ok(DEFAULT_TRANSLATION_ENGINE
35        .encode_request(wire_format, request, &DEFAULT_TRANSLATION_POLICY)?
36        .body)
37}
38
39/// Decodes a buffered `wire_format` response body into the neutral aggregate.
40pub fn decode_aggregated_response(body: &Value, wire_format: WireFormat) -> Result<AggLlmResponse> {
41    Ok(DEFAULT_TRANSLATION_ENGINE
42        .decode_response(wire_format, body, &DEFAULT_TRANSLATION_POLICY)?
43        .response)
44}
45
46/// Encodes a buffered aggregate into `wire_format`'s JSON body, stamping
47/// `served_model` over the encoded id so the caller sees which model answered.
48/// Passing `None` leaves the id the upstream reported.
49pub fn encode_aggregated_response(
50    agg: &AggLlmResponse,
51    wire_format: WireFormat,
52    served_model: Option<&str>,
53) -> Result<Value> {
54    encode_aggregated_response_with_extensions(
55        agg,
56        wire_format,
57        served_model,
58        &switchyard_protocol::ProviderExtensions::default(),
59    )
60}
61
62/// Encodes an aggregated response, honouring request extensions.
63///
64/// Identical to [`encode_aggregated_response`] except that Codex tool
65/// namespaces recorded on the request are restored on the encoded body.
66pub fn encode_aggregated_response_with_extensions(
67    agg: &AggLlmResponse,
68    wire_format: WireFormat,
69    served_model: Option<&str>,
70    request_extensions: &switchyard_protocol::ProviderExtensions,
71) -> Result<Value> {
72    let mut body = DEFAULT_TRANSLATION_ENGINE
73        .encode_response_with_extensions(
74            wire_format,
75            agg,
76            request_extensions,
77            &DEFAULT_TRANSLATION_POLICY,
78        )?
79        .body;
80    if let (Some(model), Value::Object(object)) = (served_model, &mut body) {
81        object.insert("model".to_string(), Value::String(model.to_string()));
82    }
83    Ok(body)
84}
85
86/// Encodes a stream of IR chunks into a stream of target-format wire events.
87///
88/// `served_model` is exposed as the response model (via the stream state's
89/// `target_model`); `None` falls back to the id observed on the source stream.
90/// The target stream codec is resolved once and reused per chunk; terminal events
91/// (`message_stop` / `response.completed`) come from `finish`.
92pub fn encode_stream(
93    chunks: LlmResponseStream,
94    target: WireFormat,
95    served_model: Option<String>,
96) -> std::result::Result<RawEventStream, LlmClientError> {
97    DEFAULT_TRANSLATION_ENGINE.encode_stream(target, chunks, served_model)
98}
99
100/// Encodes a response stream, honouring request extensions.
101///
102/// Identical to [`encode_stream`] except that Codex tool namespaces recorded on
103/// the request are restored on each encoded event.
104pub fn encode_stream_with_extensions(
105    chunks: LlmResponseStream,
106    target: WireFormat,
107    served_model: Option<String>,
108    request_extensions: &switchyard_protocol::ProviderExtensions,
109) -> std::result::Result<RawEventStream, LlmClientError> {
110    DEFAULT_TRANSLATION_ENGINE.encode_stream_with_extensions(
111        target,
112        chunks,
113        served_model,
114        request_extensions,
115    )
116}
117
118/// Decodes provider SSE bytes into normalized stream events.
119///
120/// Operates on raw bytes, not any HTTP client type: the caller adapts its
121/// transport's body stream into `Stream<Item = Result<Vec<u8>, _>>`. Frames are
122/// buffered across chunks (a partial frame waits for its boundary); the source
123/// stream codec is resolved once and reused for every frame.
124///
125/// The decoder tracks the source format's protocol-specific terminal event. If
126/// EOF arrives without that required event, the stream yields a deferred
127/// [`LlmClientError::ResponseTranslation`] error after any valid decoded events.
128pub fn decode_stream<S>(
129    bytes: S,
130    source: WireFormat,
131) -> std::result::Result<LlmResponseStream, LlmClientError>
132where
133    S: Stream<Item = std::result::Result<Vec<u8>, LlmClientError>> + Send + 'static,
134{
135    DEFAULT_TRANSLATION_ENGINE.decode_stream(source, bytes)
136}
137
138#[cfg(test)]
139mod tests {
140    use futures::executor::block_on;
141    use futures::{Stream, StreamExt, stream};
142    use serde_json::{Value, json};
143    use switchyard_protocol::{
144        LlmClientError, LlmResponseChunk, LlmResponseStreamEvent, completion_text,
145    };
146
147    use super::{
148        decode_aggregated_response, decode_request, decode_stream, encode_aggregated_response,
149        encode_request, encode_stream,
150    };
151    use crate::{LlmResponseStream, LlmStreamError, WireFormat};
152
153    // A boxed stream item error, matching the streamed IR contract.
154    type BoxError = Box<dyn std::error::Error + Send + Sync>;
155
156    // Collects a decoded IR stream, surfacing the first error instead of panicking.
157    fn decode_all(
158        bytes: impl Stream<Item = Result<Vec<u8>, LlmClientError>> + Send + 'static,
159        source: WireFormat,
160    ) -> Result<Vec<LlmResponseStreamEvent>, LlmClientError> {
161        block_on(decode_stream(bytes, source)?.collect::<Vec<_>>())
162            .into_iter()
163            .collect()
164    }
165
166    // Concatenates the text of every `TextDelta` chunk.
167    fn text_of(events: &[LlmResponseStreamEvent]) -> String {
168        events
169            .iter()
170            .flat_map(LlmResponseStreamEvent::normalized)
171            .filter_map(|chunk| {
172                if let LlmResponseChunk::TextDelta { text, .. } = chunk {
173                    Some(text.as_str())
174                } else {
175                    None
176                }
177            })
178            .collect()
179    }
180
181    #[test]
182    fn request_round_trips_through_openai_chat() -> Result<(), BoxError> {
183        let body = json!({"model": "gpt", "messages": [{"role": "user", "content": "hi"}]});
184        let request = decode_request(WireFormat::OpenAiChat, &body)?;
185        assert_eq!(request.model.as_deref(), Some("gpt"));
186
187        let encoded = encode_request(&request, WireFormat::OpenAiChat)?;
188        assert_eq!(encoded["model"], "gpt");
189        assert_eq!(encoded["messages"][0]["content"], "hi");
190        Ok(())
191    }
192
193    #[test]
194    fn aggregated_response_round_trips_and_stamps_the_served_model() -> Result<(), BoxError> {
195        let body = json!({
196            "id": "1",
197            "model": "upstream",
198            "choices": [{
199                "index": 0,
200                "message": {"role": "assistant", "content": "Hi there"},
201                "finish_reason": "stop"
202            }],
203            "usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
204        });
205        let agg = decode_aggregated_response(&body, WireFormat::OpenAiChat)?;
206        assert_eq!(completion_text(&agg), "Hi there");
207
208        // `served_model` overrides the id the upstream reported.
209        let encoded =
210            encode_aggregated_response(&agg, WireFormat::OpenAiChat, Some("served/model"))?;
211        assert_eq!(encoded["model"], "served/model");
212        assert_eq!(encoded["choices"][0]["message"]["content"], "Hi there");
213        Ok(())
214    }
215
216    #[test]
217    fn encode_stream_reassembles_deltas_and_finishes() -> Result<(), BoxError> {
218        let chunks: LlmResponseStream = stream::iter(vec![
219            Ok(LlmResponseChunk::TextDelta {
220                index: 0,
221                text: "Hello".to_string(),
222            }
223            .into()),
224            Ok(LlmResponseChunk::TextDelta {
225                index: 0,
226                text: " world".to_string(),
227            }
228            .into()),
229            Ok(LlmResponseChunk::MessageStop {
230                reason: Some("stop".to_string()),
231            }
232            .into()),
233        ])
234        .boxed();
235
236        let events = block_on(
237            encode_stream(chunks, WireFormat::OpenAiChat, Some("m".to_string()))?
238                .collect::<Vec<_>>(),
239        )
240        .into_iter()
241        .collect::<Result<Vec<Value>, LlmStreamError>>()?;
242
243        let content: String = events
244            .iter()
245            .filter_map(|event| event["choices"][0]["delta"]["content"].as_str())
246            .collect();
247        assert_eq!(content, "Hello world");
248        // The terminal chunk carries the stop reason through to the wire events.
249        assert!(
250            events
251                .iter()
252                .any(|event| event["choices"][0]["finish_reason"] == "stop")
253        );
254        Ok(())
255    }
256
257    // The served model must win over the id the source stream announced, so a
258    // routed response never reports the route the caller addressed.
259    #[test]
260    fn encode_stream_stamps_the_served_model_on_message_start() -> Result<(), BoxError> {
261        let chunks: LlmResponseStream = stream::iter(vec![
262            Ok(LlmResponseChunk::MessageStart {
263                id: Some("msg_1".to_string()),
264                model: Some("upstream/model".to_string()),
265            }
266            .into()),
267            Ok(LlmResponseChunk::TextDelta {
268                index: 0,
269                text: "hi".to_string(),
270            }
271            .into()),
272        ])
273        .boxed();
274
275        let events = block_on(
276            encode_stream(
277                chunks,
278                WireFormat::AnthropicMessages,
279                Some("served/model".to_string()),
280            )?
281            .collect::<Vec<_>>(),
282        )
283        .into_iter()
284        .collect::<Result<Vec<Value>, LlmStreamError>>()?;
285
286        assert_eq!(events[0]["type"], "message_start");
287        assert_eq!(events[0]["message"]["model"], "served/model");
288        Ok(())
289    }
290
291    // Without a served model the upstream id survives, so passthrough routes keep
292    // reporting whatever the provider announced.
293    #[test]
294    fn encode_stream_falls_back_to_the_source_model() -> Result<(), BoxError> {
295        let chunks: LlmResponseStream = stream::iter(vec![
296            Ok(LlmResponseChunk::MessageStart {
297                id: Some("msg_1".to_string()),
298                model: Some("upstream/model".to_string()),
299            }
300            .into()),
301            Ok(LlmResponseChunk::TextDelta {
302                index: 0,
303                text: "hi".to_string(),
304            }
305            .into()),
306        ])
307        .boxed();
308
309        let events = block_on(
310            encode_stream(chunks, WireFormat::AnthropicMessages, None)?.collect::<Vec<_>>(),
311        )
312        .into_iter()
313        .collect::<Result<Vec<Value>, LlmStreamError>>()?;
314
315        assert_eq!(events[0]["message"]["model"], "upstream/model");
316        Ok(())
317    }
318
319    #[test]
320    fn encode_stream_propagates_chunk_errors() -> Result<(), BoxError> {
321        let chunks: LlmResponseStream =
322            stream::iter(vec![Err::<LlmResponseStreamEvent, LlmClientError>(
323                LlmClientError::General("chunk exploded".to_string()),
324            )])
325            .boxed();
326        let results =
327            block_on(encode_stream(chunks, WireFormat::OpenAiChat, None)?.collect::<Vec<_>>());
328        assert!(results.iter().any(Result::is_err));
329        Ok(())
330    }
331
332    // An in-band error is terminal for every target format: the encoder emits the pre-error
333    // content, surfaces the error event through the error channel, and drops any later chunk.
334    // Production truncates the source before the encoder, so this contract is only observable
335    // by driving encode_stream directly.
336    #[test]
337    fn encode_stream_stops_after_an_in_band_error() -> Result<(), BoxError> {
338        for message in [
339            LlmResponseChunk::StreamError {
340                message: "boom".to_string(),
341            },
342            LlmResponseChunk::DecodeError {
343                message: "boom".to_string(),
344            },
345        ] {
346            for target in [
347                WireFormat::OpenAiChat,
348                WireFormat::OpenAiResponses,
349                WireFormat::AnthropicMessages,
350            ] {
351                let chunks: LlmResponseStream = stream::iter(vec![
352                    Ok(LlmResponseChunk::TextDelta {
353                        index: 0,
354                        text: "before".to_string(),
355                    }
356                    .into()),
357                    Ok(message.clone().into()),
358                    Ok(LlmResponseChunk::TextDelta {
359                        index: 0,
360                        text: "after".to_string(),
361                    }
362                    .into()),
363                ])
364                .boxed();
365                let results = block_on(encode_stream(chunks, target, None)?.collect::<Vec<_>>());
366                let (data, errors): (Vec<_>, Vec<_>) = results.into_iter().partition(Result::is_ok);
367                let data = data.into_iter().flatten().collect::<Vec<Value>>();
368                let body = serde_json::to_string(&data)?;
369
370                assert!(
371                    body.contains("before"),
372                    "{target:?}: pre-error content missing:\n{body}"
373                );
374                assert!(
375                    !body.contains("after"),
376                    "{target:?}/{message:?}: content leaked after the error:\n{body}"
377                );
378                // The error rides the error channel, carrying the codec's own encoded event
379                // so the consumer can forward the upstream payload rather than rebuild it.
380                let [Err(LlmStreamError::Upstream(event))] = errors.as_slice() else {
381                    panic!("{target:?}/{message:?}: expected one in-band error, got {errors:?}");
382                };
383                assert!(
384                    serde_json::to_string(event)?.contains("boom"),
385                    "{target:?}: error event lost its message: {event}"
386                );
387            }
388        }
389        Ok(())
390    }
391
392    // A clean stop stays entirely on the data channel, so a consumer that appends a
393    // format-level success sentinel only does so for a turn that actually completed.
394    #[test]
395    fn encode_stream_keeps_a_normal_stop_off_the_error_channel() -> Result<(), BoxError> {
396        let chunks: LlmResponseStream = stream::iter(vec![
397            Ok(LlmResponseChunk::TextDelta {
398                index: 0,
399                text: "hi".to_string(),
400            }
401            .into()),
402            Ok(LlmResponseChunk::MessageStop {
403                reason: Some("stop".to_string()),
404            }
405            .into()),
406        ])
407        .boxed();
408
409        let results =
410            block_on(encode_stream(chunks, WireFormat::OpenAiChat, None)?.collect::<Vec<_>>());
411
412        assert!(
413            results.iter().all(Result::is_ok),
414            "a completed turn reported an error: {results:?}"
415        );
416        Ok(())
417    }
418
419    // A replayed provider error ends the stream before the encoder polls the source again.
420    #[test]
421    fn encode_stream_stops_polling_after_a_replayed_error() -> Result<(), BoxError> {
422        let error = LlmResponseStreamEvent::preserved(
423            WireFormat::OpenAiResponses,
424            json!({"type": "error", "message": "boom"}),
425            vec![LlmResponseChunk::StreamError {
426                message: "boom".to_string(),
427            }],
428        );
429        let chunks: LlmResponseStream = stream::iter([Ok(error)])
430            .chain(stream::poll_fn(|_| {
431                panic!("encode_stream polled the source after an in-band error")
432            }))
433            .boxed();
434
435        let events =
436            block_on(encode_stream(chunks, WireFormat::OpenAiResponses, None)?.collect::<Vec<_>>());
437
438        // The replayed provider event is the terminal frame, so it arrives verbatim on the
439        // error channel and nothing follows it.
440        let [Err(LlmStreamError::Upstream(event))] = events.as_slice() else {
441            return Err(format!("expected one in-band error, got {events:?}").into());
442        };
443        assert_eq!(*event, json!({"type": "error", "message": "boom"}));
444        Ok(())
445    }
446
447    // The guard keys on `errored`, not `finished`, so a normal completion still emits the
448    // trailing usage chunk the OpenAI chat codec reports only after `finished` is set.
449    #[test]
450    fn encode_stream_keeps_trailing_usage_after_a_normal_stop() -> Result<(), BoxError> {
451        let chunks: LlmResponseStream = stream::iter(vec![
452            Ok(LlmResponseChunk::TextDelta {
453                index: 0,
454                text: "hi".to_string(),
455            }
456            .into()),
457            Ok(LlmResponseChunk::MessageStop {
458                reason: Some("stop".to_string()),
459            }
460            .into()),
461            Ok(LlmResponseChunk::Usage(switchyard_protocol::llm::Usage {
462                output_tokens: Some(7),
463                ..Default::default()
464            })
465            .into()),
466        ])
467        .boxed();
468        let events =
469            block_on(encode_stream(chunks, WireFormat::OpenAiChat, None)?.collect::<Vec<_>>())
470                .into_iter()
471                .collect::<Result<Vec<Value>, LlmStreamError>>()?;
472        let body = serde_json::to_string(&events)?;
473        assert!(
474            events
475                .iter()
476                .any(|event| event["choices"][0]["finish_reason"] == "stop"),
477            "missing stop terminal:\n{body}"
478        );
479        assert!(
480            body.contains("\"usage\""),
481            "trailing usage dropped after a normal stop:\n{body}"
482        );
483        Ok(())
484    }
485
486    #[test]
487    fn decode_stream_parses_sse_bytes_into_ir_chunks() -> Result<(), LlmClientError> {
488        let sse = b"data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n\
489             data: {\"choices\":[{\"delta\":{\"content\":\" world\"}}]}\n\n\
490             data: [DONE]\n\n\
491             data: {\"choices\":[{\"delta\":{\"content\":\" ignored\"}}]}\n\n"
492            .to_vec();
493        let bytes = stream::once(async move { Ok::<Vec<u8>, LlmClientError>(sse) });
494        let chunks = decode_all(bytes, WireFormat::OpenAiChat)?;
495        assert_eq!(text_of(&chunks), "Hello world");
496        Ok(())
497    }
498
499    #[test]
500    fn stream_helpers_replay_same_format_provider_fields() -> Result<(), BoxError> {
501        let provider_event = json!({
502            "id": "chatcmpl-test",
503            "object": "chat.completion.chunk",
504            "system_fingerprint": "fp_provider_specific",
505            "choices": [{
506                "index": 0,
507                "delta": {"content": "Hello"},
508                "finish_reason": "stop"
509            }]
510        });
511        let bytes = stream::once({
512            let frame = format!("data: {provider_event}\n\n").into_bytes();
513            async move { Ok::<Vec<u8>, LlmClientError>(frame) }
514        });
515        let decoded = decode_stream(bytes, WireFormat::OpenAiChat)?;
516        let replayed =
517            block_on(encode_stream(decoded, WireFormat::OpenAiChat, None)?.collect::<Vec<_>>())
518                .into_iter()
519                .collect::<Result<Vec<Value>, LlmStreamError>>()?;
520
521        assert_eq!(replayed, vec![provider_event]);
522        Ok(())
523    }
524
525    #[test]
526    fn decode_stream_reassembles_frames_split_across_chunks() -> Result<(), BoxError> {
527        // A multi-byte codepoint and the frame boundaries are split across
528        // one-byte chunks; the BufReader must reassemble them losslessly.
529        let payload = json!({"choices": [{"delta": {"content": "café"}}]});
530        let sse = format!("data: {payload}\n\ndata: [DONE]\n\n");
531        let bytes = stream::iter(
532            sse.into_bytes()
533                .into_iter()
534                .map(|byte| Ok::<Vec<u8>, LlmClientError>(vec![byte])),
535        );
536        let chunks = decode_all(bytes, WireFormat::OpenAiChat)?;
537        assert_eq!(text_of(&chunks), "café");
538        Ok(())
539    }
540
541    #[test]
542    fn decode_stream_decodes_trailing_frame_without_blank_line() -> Result<(), BoxError> {
543        // A non-standard upstream omits the final blank line; the last frame
544        // must still be decoded rather than dropped.
545        let sse =
546            b"data: {\"choices\":[{\"delta\":{\"content\":\"tail\"}}]}\n\ndata: [DONE]".to_vec();
547        let bytes = stream::once(async move { Ok::<Vec<u8>, LlmClientError>(sse) });
548        let chunks = decode_all(bytes, WireFormat::OpenAiChat)?;
549        assert_eq!(text_of(&chunks), "tail");
550        Ok(())
551    }
552
553    #[test]
554    fn decode_stream_rejects_eof_before_a_terminal_event() -> Result<(), BoxError> {
555        // Preserve valid content before surfacing premature EOF as the terminal stream error.
556        let sse = b"data: {\"choices\":[{\"delta\":{\"content\":\"partial\"},\"finish_reason\":null}]}\n\n".to_vec();
557        let bytes = stream::once(async move { Ok::<Vec<u8>, LlmClientError>(sse) });
558        let results = block_on(decode_stream(bytes, WireFormat::OpenAiChat)?.collect::<Vec<_>>());
559
560        let Some(Ok(first)) = results.first() else {
561            return Err("expected the partial event".into());
562        };
563        assert_eq!(text_of(std::slice::from_ref(first)), "partial");
564        let Some(Err(LlmClientError::ResponseTranslation(message))) = results.last() else {
565            panic!("expected incomplete OpenAI stream to fail");
566        };
567        assert_eq!(message, "openai_chat stream ended before a terminal event");
568        Ok(())
569    }
570
571    #[test]
572    fn responses_failed_before_done_remains_a_stream_error() -> Result<(), BoxError> {
573        let sse = b"data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"message\":\"upstream failed\"}}}\n\ndata: [DONE]\n\n".to_vec();
574        let bytes = stream::once(async move { Ok::<Vec<u8>, LlmClientError>(sse) });
575        let events = decode_all(bytes, WireFormat::OpenAiResponses)?;
576
577        let Some(LlmResponseChunk::StreamError { message }) =
578            events.first().and_then(|event| event.normalized().first())
579        else {
580            return Err("expected response.failed to decode as StreamError".into());
581        };
582        assert_eq!(message, "upstream failed");
583
584        let chunks: LlmResponseStream = stream::iter(
585            events
586                .into_iter()
587                .map(Ok::<LlmResponseStreamEvent, LlmClientError>),
588        )
589        .boxed();
590        let replayed =
591            block_on(encode_stream(chunks, WireFormat::OpenAiResponses, None)?.collect::<Vec<_>>());
592
593        // `response.failed` stays a terminal error, replayed with its payload intact.
594        let [Err(LlmStreamError::Upstream(event))] = replayed.as_slice() else {
595            return Err(format!("expected one in-band error, got {replayed:?}").into());
596        };
597        assert_eq!(
598            *event,
599            json!({
600                "type": "response.failed",
601                "response": {"error": {"message": "upstream failed"}}
602            })
603        );
604        Ok(())
605    }
606
607    #[test]
608    fn decode_stream_decodes_crlf_delimited_frames() -> Result<(), BoxError> {
609        // CRLF framing: blank lines are `\r\n\r\n` and the bare `\r` must not
610        // block the frame boundary.
611        let sse =
612            b"data: {\"choices\":[{\"delta\":{\"content\":\"crlf\"}}]}\r\n\r\ndata: [DONE]\r\n\r\n"
613                .to_vec();
614        let bytes = stream::once(async move { Ok::<Vec<u8>, LlmClientError>(sse) });
615        let chunks = decode_all(bytes, WireFormat::OpenAiChat)?;
616        assert_eq!(text_of(&chunks), "crlf");
617        Ok(())
618    }
619
620    #[test]
621    fn decode_stream_propagates_source_errors() -> Result<(), BoxError> {
622        // A transport error mid-stream surfaces as an error item, not a panic.
623        let bytes = stream::iter(vec![
624            Ok::<Vec<u8>, LlmClientError>(
625                b"data: {\"choices\":[{\"delta\":{\"content\":\"x\"}}]}\n\n".to_vec(),
626            ),
627            Err::<Vec<u8>, LlmClientError>(LlmClientError::Transport {
628                source: Box::new(std::io::Error::other("upstream exploded")),
629            }),
630        ]);
631        let results = block_on(decode_stream(bytes, WireFormat::OpenAiChat)?.collect::<Vec<_>>());
632        let Some(Err(error)) = results.last() else {
633            panic!("expected the source error");
634        };
635        assert!(matches!(error, LlmClientError::Transport { .. }));
636        Ok(())
637    }
638
639    #[test]
640    fn decode_stream_classifies_invalid_sse_json() -> Result<(), BoxError> {
641        let bytes =
642            stream::once(async { Ok::<Vec<u8>, LlmClientError>(b"data: {invalid}\n\n".to_vec()) });
643        let results = block_on(decode_stream(bytes, WireFormat::OpenAiChat)?.collect::<Vec<_>>());
644        let Some(Err(error)) = results.last() else {
645            panic!("expected invalid SSE JSON to fail");
646        };
647        assert!(matches!(error, LlmClientError::ResponseTranslation(_)));
648        Ok(())
649    }
650}