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::pin::Pin;
10use std::sync::LazyLock;
11
12use async_stream::try_stream;
13use futures::io::AsyncBufReadExt;
14use futures::{Stream, StreamExt, TryStreamExt};
15use serde_json::Value;
16use switchyard_protocol::LlmClientError;
17
18use crate::codecs::stream::encode_response_stream_event;
19use crate::sse;
20use crate::{
21    AggLlmResponse, FormatId, LlmRequest, LlmResponseStream, LlmResponseStreamEvent, Result,
22    StreamCodecRegistry, StreamTranslationState, TranslationEngine, TranslationPolicy, WireFormat,
23};
24
25static DEFAULT_TRANSLATION_POLICY: LazyLock<TranslationPolicy> =
26    LazyLock::new(TranslationPolicy::default);
27static DEFAULT_TRANSLATION_ENGINE: LazyLock<TranslationEngine> =
28    LazyLock::new(TranslationEngine::default);
29
30/// Decodes a `wire_format` request body into the neutral IR.
31pub fn decode_request(wire_format: WireFormat, body: &Value) -> Result<LlmRequest> {
32    Ok(DEFAULT_TRANSLATION_ENGINE
33        .decode_request(wire_format, body, &DEFAULT_TRANSLATION_POLICY)?
34        .request)
35}
36
37/// Encodes a normalized request into `wire_format`'s JSON body.
38pub fn encode_request(request: &LlmRequest, wire_format: WireFormat) -> Result<Value> {
39    Ok(DEFAULT_TRANSLATION_ENGINE
40        .encode_request(wire_format, request, &DEFAULT_TRANSLATION_POLICY)?
41        .body)
42}
43
44/// Decodes a buffered `wire_format` response body into the neutral aggregate.
45pub fn decode_aggregated_response(body: &Value, wire_format: WireFormat) -> Result<AggLlmResponse> {
46    Ok(DEFAULT_TRANSLATION_ENGINE
47        .decode_response(wire_format, body, &DEFAULT_TRANSLATION_POLICY)?
48        .response)
49}
50
51/// Encodes a buffered aggregate into `wire_format`'s JSON body, stamping
52/// `served_model` over the encoded id so the caller sees which model answered.
53/// Passing `None` leaves the id the upstream reported.
54pub fn encode_aggregated_response(
55    agg: &AggLlmResponse,
56    wire_format: WireFormat,
57    served_model: Option<&str>,
58) -> Result<Value> {
59    let mut body = DEFAULT_TRANSLATION_ENGINE
60        .encode_response(wire_format, agg, &DEFAULT_TRANSLATION_POLICY)?
61        .body;
62    if let (Some(model), Value::Object(object)) = (served_model, &mut body) {
63        object.insert("model".to_string(), Value::String(model.to_string()));
64    }
65    Ok(body)
66}
67
68/// A stream of wire-format event objects in one format — the unframed body of an
69/// SSE response. The serving layer frames each `Value` (e.g. as an SSE
70/// `data:`/`event:` block).
71pub type RawEventStream = Pin<
72    Box<
73        dyn Stream<Item = std::result::Result<Value, Box<dyn std::error::Error + Send + Sync>>>
74            + Send,
75    >,
76>;
77
78/// Encodes a stream of IR chunks into a stream of target-format wire events.
79///
80/// `served_model` is exposed as the response model (via the stream state's
81/// `target_model`); `None` falls back to the id observed on the source stream.
82/// The target stream codec is resolved once and reused per chunk; terminal events
83/// (`message_stop` / `response.completed`) come from `finish`.
84pub fn encode_stream(
85    chunks: LlmResponseStream,
86    target: WireFormat,
87    served_model: Option<String>,
88) -> std::result::Result<RawEventStream, LlmClientError> {
89    let target_format: FormatId = target.into();
90    // The target is always a built-in wire format, so this lookup cannot fail; a
91    // failure returns as an `Err` rather than a panic.
92    let codec = StreamCodecRegistry::with_builtins()
93        .codec(target_format.clone())
94        // Currently the only error is that the codec is missing, which is Configuration
95        .map_err(|err| LlmClientError::Configuration {
96            message: err.to_string(),
97        })?;
98
99    let served_model_for_events = served_model.clone();
100    let mut state = StreamTranslationState {
101        target: Some(target_format.clone()),
102        target_model: served_model,
103        ..Default::default()
104    };
105    let mut chunks = chunks;
106
107    let events = try_stream! {
108        while let Some(item) = chunks.next().await {
109            let event = item?;
110            for mut value in
111                encode_response_stream_event(&mut state, codec.as_ref(), &target_format, event)
112            {
113                stamp_streamed_response_model(
114                    &mut value,
115                    target,
116                    served_model_for_events.as_deref(),
117                );
118                yield value;
119            }
120            if state.errored {
121                return;
122            }
123        }
124        for mut value in codec.finish(&mut state) {
125            stamp_streamed_response_model(
126                &mut value,
127                target,
128                served_model_for_events.as_deref(),
129            );
130            yield value;
131        }
132    };
133
134    Ok(Box::pin(events))
135}
136
137// The raw-response helper promises that the caller sees the model that served the
138// request. Same-format preservation bypasses provider codecs, so apply that
139// helper-specific override after replay without disturbing any other raw fields.
140fn stamp_streamed_response_model(
141    event: &mut Value,
142    target: WireFormat,
143    served_model: Option<&str>,
144) {
145    let Some(served_model) = served_model else {
146        return;
147    };
148
149    match target {
150        WireFormat::OpenAiChat => {
151            if let Some(event) = event.as_object_mut() {
152                event.insert("model".to_string(), Value::String(served_model.to_string()));
153            }
154        }
155        WireFormat::OpenAiResponses => {
156            if let Some(response) = event.get_mut("response").and_then(Value::as_object_mut) {
157                response.insert("model".to_string(), Value::String(served_model.to_string()));
158            }
159        }
160        WireFormat::AnthropicMessages => {
161            if let Some(message) = event.get_mut("message").and_then(Value::as_object_mut) {
162                message.insert("model".to_string(), Value::String(served_model.to_string()));
163            }
164        }
165    }
166}
167
168/// Decodes a byte stream of `source`-format SSE frames into neutral IR chunks.
169///
170/// Operates on raw bytes, not any HTTP client type: the caller adapts its
171/// transport's body stream into `Stream<Item = Result<Vec<u8>, _>>`. Frames are
172/// buffered across chunks (a partial frame waits for its boundary); the source
173/// stream codec is resolved once and reused for every frame.
174pub fn decode_stream<S>(
175    bytes: S,
176    source: WireFormat,
177) -> std::result::Result<LlmResponseStream, LlmClientError>
178where
179    S: Stream<Item = std::result::Result<Vec<u8>, LlmClientError>> + Send + 'static,
180{
181    let marker = sse::done_marker(source);
182    let source_format: FormatId = source.into();
183    // The source is always a built-in wire format, so this lookup cannot fail; a
184    // failure returns as an `Err` rather than a panic.
185    let codec = StreamCodecRegistry::with_builtins()
186        .codec(source_format.clone())
187        .map_err(|error| LlmClientError::ResponseTranslation(error.to_string()))?;
188    // Adapt the byte-chunk stream into an async line reader. The BufReader
189    // reassembles data split across network chunks (including multi-byte UTF-8),
190    // and `lines()` yields one SSE field line at a time. The stream is boxed to
191    // an `io::Error` item so `into_async_read`'s error bound resolves cleanly. The
192    // source error is boxed intact rather than stringified, so
193    // `llm_client_error_from_io` can recover its original variant on the way out.
194    let io_bytes: Pin<Box<dyn Stream<Item = std::io::Result<Vec<u8>>> + Send>> =
195        Box::pin(bytes.map(|item| item.map_err(std::io::Error::other)));
196    let lines = futures::io::BufReader::new(io_bytes.into_async_read()).lines();
197
198    let mut state = StreamTranslationState {
199        source: Some(source_format.clone()),
200        ..StreamTranslationState::default()
201    };
202    let mut frame = String::new();
203    let stream = Box::pin(try_stream! {
204        futures::pin_mut!(lines);
205        while let Some(line) = lines.next().await {
206            let line = line.map_err(llm_client_error_from_io)?;
207            // A blank line (allowing a bare CR for CRLF streams) ends the frame.
208            if line.trim_end().is_empty() {
209                let parsed = sse::parse_json_sse_frame(&frame, marker)
210                    .map_err(|error| LlmClientError::ResponseTranslation(error.to_string()))?;
211                frame.clear();
212                match parsed {
213                    sse::SseFrame::Empty => {}
214                    sse::SseFrame::Done => break,
215                    sse::SseFrame::Data(value) => {
216                        let normalized = codec.decode_event(&mut state, &value);
217                        yield LlmResponseStreamEvent::preserved(
218                            source_format.clone(),
219                            value,
220                            normalized,
221                        );
222                    }
223                }
224            } else {
225                frame.push_str(&line);
226                frame.push('\n');
227            }
228        }
229
230        // A non-standard upstream might omit the final blank line; parse a trailing
231        // complete frame instead of losing its last chunk.
232        #[allow(clippy::collapsible_if)]
233        if !frame.trim_end().is_empty() {
234            let parsed = sse::parse_json_sse_frame(&frame, marker)
235                .map_err(|error| LlmClientError::ResponseTranslation(error.to_string()))?;
236            if let sse::SseFrame::Data(value) = parsed {
237                let normalized = codec.decode_event(&mut state, &value);
238                yield LlmResponseStreamEvent::preserved(source_format, value, normalized);
239            }
240        }
241    });
242    Ok(stream)
243}
244
245// Recover transport errors wrapped for `AsyncRead`; other reader failures are
246// invalid upstream responses.
247fn llm_client_error_from_io(error: std::io::Error) -> LlmClientError {
248    let kind = error.kind();
249    let message = error.to_string();
250    match error.into_inner() {
251        Some(source) => match source.downcast::<LlmClientError>() {
252            Ok(error) => *error,
253            Err(source) => LlmClientError::InvalidResponse { source },
254        },
255        None => LlmClientError::InvalidResponse {
256            source: Box::new(std::io::Error::new(kind, message)),
257        },
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use futures::executor::block_on;
264    use futures::{Stream, StreamExt, stream};
265    use serde_json::{Value, json};
266    use switchyard_protocol::{
267        LlmClientError, LlmResponseChunk, LlmResponseStreamEvent, completion_text,
268    };
269
270    use super::{
271        decode_aggregated_response, decode_request, decode_stream, encode_aggregated_response,
272        encode_request, encode_stream, stamp_streamed_response_model,
273    };
274    use crate::{LlmResponseStream, WireFormat};
275
276    // A boxed stream item error, matching the streamed IR contract.
277    type BoxError = Box<dyn std::error::Error + Send + Sync>;
278
279    // Collects a decoded IR stream, surfacing the first error instead of panicking.
280    fn decode_all(
281        bytes: impl Stream<Item = Result<Vec<u8>, LlmClientError>> + Send + 'static,
282        source: WireFormat,
283    ) -> Result<Vec<LlmResponseStreamEvent>, LlmClientError> {
284        block_on(decode_stream(bytes, source)?.collect::<Vec<_>>())
285            .into_iter()
286            .collect()
287    }
288
289    // Concatenates the text of every `TextDelta` chunk.
290    fn text_of(events: &[LlmResponseStreamEvent]) -> String {
291        events
292            .iter()
293            .flat_map(LlmResponseStreamEvent::normalized)
294            .filter_map(|chunk| {
295                if let LlmResponseChunk::TextDelta { text, .. } = chunk {
296                    Some(text.as_str())
297                } else {
298                    None
299                }
300            })
301            .collect()
302    }
303
304    #[test]
305    fn request_round_trips_through_openai_chat() -> Result<(), BoxError> {
306        let body = json!({"model": "gpt", "messages": [{"role": "user", "content": "hi"}]});
307        let request = decode_request(WireFormat::OpenAiChat, &body)?;
308        assert_eq!(request.model.as_deref(), Some("gpt"));
309
310        let encoded = encode_request(&request, WireFormat::OpenAiChat)?;
311        assert_eq!(encoded["model"], "gpt");
312        assert_eq!(encoded["messages"][0]["content"], "hi");
313        Ok(())
314    }
315
316    #[test]
317    fn aggregated_response_round_trips_and_stamps_the_served_model() -> Result<(), BoxError> {
318        let body = json!({
319            "id": "1",
320            "model": "upstream",
321            "choices": [{
322                "index": 0,
323                "message": {"role": "assistant", "content": "Hi there"},
324                "finish_reason": "stop"
325            }],
326            "usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
327        });
328        let agg = decode_aggregated_response(&body, WireFormat::OpenAiChat)?;
329        assert_eq!(completion_text(&agg), "Hi there");
330
331        // `served_model` overrides the id the upstream reported.
332        let encoded =
333            encode_aggregated_response(&agg, WireFormat::OpenAiChat, Some("served/model"))?;
334        assert_eq!(encoded["model"], "served/model");
335        assert_eq!(encoded["choices"][0]["message"]["content"], "Hi there");
336        Ok(())
337    }
338
339    #[test]
340    fn encode_stream_reassembles_deltas_and_finishes() -> Result<(), BoxError> {
341        let chunks: LlmResponseStream = stream::iter(vec![
342            Ok(LlmResponseChunk::TextDelta {
343                index: 0,
344                text: "Hello".to_string(),
345            }
346            .into()),
347            Ok(LlmResponseChunk::TextDelta {
348                index: 0,
349                text: " world".to_string(),
350            }
351            .into()),
352            Ok(LlmResponseChunk::MessageStop {
353                reason: Some("stop".to_string()),
354            }
355            .into()),
356        ])
357        .boxed();
358
359        let events = block_on(
360            encode_stream(chunks, WireFormat::OpenAiChat, Some("m".to_string()))?
361                .collect::<Vec<_>>(),
362        )
363        .into_iter()
364        .collect::<Result<Vec<Value>, BoxError>>()?;
365
366        let content: String = events
367            .iter()
368            .filter_map(|event| event["choices"][0]["delta"]["content"].as_str())
369            .collect();
370        assert_eq!(content, "Hello world");
371        // The terminal chunk carries the stop reason through to the wire events.
372        assert!(
373            events
374                .iter()
375                .any(|event| event["choices"][0]["finish_reason"] == "stop")
376        );
377        Ok(())
378    }
379
380    // The served model must win over the id the source stream announced, so a
381    // routed response never reports the route the caller addressed.
382    #[test]
383    fn encode_stream_stamps_the_served_model_on_message_start() -> Result<(), BoxError> {
384        let chunks: LlmResponseStream = stream::iter(vec![
385            Ok(LlmResponseChunk::MessageStart {
386                id: Some("msg_1".to_string()),
387                model: Some("upstream/model".to_string()),
388            }
389            .into()),
390            Ok(LlmResponseChunk::TextDelta {
391                index: 0,
392                text: "hi".to_string(),
393            }
394            .into()),
395        ])
396        .boxed();
397
398        let events = block_on(
399            encode_stream(
400                chunks,
401                WireFormat::AnthropicMessages,
402                Some("served/model".to_string()),
403            )?
404            .collect::<Vec<_>>(),
405        )
406        .into_iter()
407        .collect::<Result<Vec<Value>, BoxError>>()?;
408
409        assert_eq!(events[0]["type"], "message_start");
410        assert_eq!(events[0]["message"]["model"], "served/model");
411        Ok(())
412    }
413
414    // Without a served model the upstream id survives, so passthrough routes keep
415    // reporting whatever the provider announced.
416    #[test]
417    fn encode_stream_falls_back_to_the_source_model() -> Result<(), BoxError> {
418        let chunks: LlmResponseStream = stream::iter(vec![
419            Ok(LlmResponseChunk::MessageStart {
420                id: Some("msg_1".to_string()),
421                model: Some("upstream/model".to_string()),
422            }
423            .into()),
424            Ok(LlmResponseChunk::TextDelta {
425                index: 0,
426                text: "hi".to_string(),
427            }
428            .into()),
429        ])
430        .boxed();
431
432        let events = block_on(
433            encode_stream(chunks, WireFormat::AnthropicMessages, None)?.collect::<Vec<_>>(),
434        )
435        .into_iter()
436        .collect::<Result<Vec<Value>, BoxError>>()?;
437
438        assert_eq!(events[0]["message"]["model"], "upstream/model");
439        Ok(())
440    }
441
442    #[test]
443    fn encode_stream_propagates_chunk_errors() -> Result<(), BoxError> {
444        let chunks: LlmResponseStream =
445            stream::iter(vec![Err::<LlmResponseStreamEvent, LlmClientError>(
446                LlmClientError::General("chunk exploded".to_string()),
447            )])
448            .boxed();
449        let results =
450            block_on(encode_stream(chunks, WireFormat::OpenAiChat, None)?.collect::<Vec<_>>());
451        assert!(results.iter().any(Result::is_err));
452        Ok(())
453    }
454
455    // An in-band error is terminal for every target format: the encoder emits the pre-error
456    // content and the error, then drops any later chunk. Production truncates the source before
457    // the encoder, so this contract is only observable by driving encode_stream directly.
458    #[test]
459    fn encode_stream_stops_after_an_in_band_error() -> Result<(), BoxError> {
460        for message in [
461            LlmResponseChunk::StreamError {
462                message: "boom".to_string(),
463            },
464            LlmResponseChunk::DecodeError {
465                message: "boom".to_string(),
466            },
467        ] {
468            for target in [
469                WireFormat::OpenAiChat,
470                WireFormat::OpenAiResponses,
471                WireFormat::AnthropicMessages,
472            ] {
473                let chunks: LlmResponseStream = stream::iter(vec![
474                    Ok(LlmResponseChunk::TextDelta {
475                        index: 0,
476                        text: "before".to_string(),
477                    }
478                    .into()),
479                    Ok(message.clone().into()),
480                    Ok(LlmResponseChunk::TextDelta {
481                        index: 0,
482                        text: "after".to_string(),
483                    }
484                    .into()),
485                ])
486                .boxed();
487                let events = block_on(encode_stream(chunks, target, None)?.collect::<Vec<_>>())
488                    .into_iter()
489                    .collect::<Result<Vec<Value>, BoxError>>()?;
490                let body = serde_json::to_string(&events)?;
491                assert!(
492                    body.contains("before"),
493                    "{target:?}: pre-error content missing:\n{body}"
494                );
495                assert!(
496                    body.contains("boom"),
497                    "{target:?}: error event missing:\n{body}"
498                );
499                assert!(
500                    !body.contains("after"),
501                    "{target:?}/{message:?}: content leaked after the error:\n{body}"
502                );
503            }
504        }
505        Ok(())
506    }
507
508    // A replayed provider error ends the stream before the encoder polls the source again.
509    #[test]
510    fn encode_stream_stops_polling_after_a_replayed_error() -> Result<(), BoxError> {
511        let error = LlmResponseStreamEvent::preserved(
512            WireFormat::OpenAiResponses,
513            json!({"type": "error", "message": "boom"}),
514            vec![LlmResponseChunk::StreamError {
515                message: "boom".to_string(),
516            }],
517        );
518        let chunks: LlmResponseStream = stream::iter([Ok(error)])
519            .chain(stream::poll_fn(|_| {
520                panic!("encode_stream polled the source after an in-band error")
521            }))
522            .boxed();
523
524        let events =
525            block_on(encode_stream(chunks, WireFormat::OpenAiResponses, None)?.collect::<Vec<_>>())
526                .into_iter()
527                .collect::<Result<Vec<Value>, BoxError>>()?;
528
529        assert_eq!(events, vec![json!({"type": "error", "message": "boom"})]);
530        Ok(())
531    }
532
533    // The guard keys on `errored`, not `finished`, so a normal completion still emits the
534    // trailing usage chunk the OpenAI chat codec reports only after `finished` is set.
535    #[test]
536    fn encode_stream_keeps_trailing_usage_after_a_normal_stop() -> Result<(), BoxError> {
537        let chunks: LlmResponseStream = stream::iter(vec![
538            Ok(LlmResponseChunk::TextDelta {
539                index: 0,
540                text: "hi".to_string(),
541            }
542            .into()),
543            Ok(LlmResponseChunk::MessageStop {
544                reason: Some("stop".to_string()),
545            }
546            .into()),
547            Ok(LlmResponseChunk::Usage(switchyard_protocol::llm::Usage {
548                output_tokens: Some(7),
549                ..Default::default()
550            })
551            .into()),
552        ])
553        .boxed();
554        let events =
555            block_on(encode_stream(chunks, WireFormat::OpenAiChat, None)?.collect::<Vec<_>>())
556                .into_iter()
557                .collect::<Result<Vec<Value>, BoxError>>()?;
558        let body = serde_json::to_string(&events)?;
559        assert!(
560            events
561                .iter()
562                .any(|event| event["choices"][0]["finish_reason"] == "stop"),
563            "missing stop terminal:\n{body}"
564        );
565        assert!(
566            body.contains("\"usage\""),
567            "trailing usage dropped after a normal stop:\n{body}"
568        );
569        Ok(())
570    }
571
572    #[test]
573    fn decode_stream_parses_sse_bytes_into_ir_chunks() -> Result<(), LlmClientError> {
574        let sse = b"data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n\
575             data: {\"choices\":[{\"delta\":{\"content\":\" world\"}}]}\n\n\
576             data: [DONE]\n\n\
577             data: {\"choices\":[{\"delta\":{\"content\":\" ignored\"}}]}\n\n"
578            .to_vec();
579        let bytes = stream::once(async move { Ok::<Vec<u8>, LlmClientError>(sse) });
580        let chunks = decode_all(bytes, WireFormat::OpenAiChat)?;
581        assert_eq!(text_of(&chunks), "Hello world");
582        Ok(())
583    }
584
585    #[test]
586    fn stream_helpers_replay_same_format_provider_fields() -> Result<(), BoxError> {
587        let provider_event = json!({
588            "id": "chatcmpl-test",
589            "object": "chat.completion.chunk",
590            "system_fingerprint": "fp_provider_specific",
591            "choices": [{
592                "index": 0,
593                "delta": {"content": "Hello"},
594                "finish_reason": "stop"
595            }]
596        });
597        let bytes = stream::once({
598            let frame = format!("data: {provider_event}\n\n").into_bytes();
599            async move { Ok::<Vec<u8>, LlmClientError>(frame) }
600        });
601        let decoded = decode_stream(bytes, WireFormat::OpenAiChat)?;
602        let replayed =
603            block_on(encode_stream(decoded, WireFormat::OpenAiChat, None)?.collect::<Vec<_>>())
604                .into_iter()
605                .collect::<Result<Vec<Value>, BoxError>>()?;
606
607        assert_eq!(replayed, vec![provider_event]);
608        Ok(())
609    }
610
611    #[test]
612    fn openai_chat_replay_stamps_the_served_model_without_losing_extensions() {
613        let mut event = json!({
614            "choices": [{"delta": {"content": "Hello"}}],
615            "system_fingerprint": "fp_provider_specific",
616        });
617
618        stamp_streamed_response_model(&mut event, WireFormat::OpenAiChat, Some("served/model"));
619
620        assert_eq!(event["model"], "served/model");
621        assert_eq!(event["system_fingerprint"], "fp_provider_specific");
622    }
623
624    #[test]
625    fn responses_replay_stamps_the_served_model_inside_response() {
626        let mut event = json!({
627            "type": "response.created",
628            "response": {
629                "id": "resp_1",
630                "model": "provider/model",
631                "provider_extension": true,
632            },
633        });
634
635        stamp_streamed_response_model(
636            &mut event,
637            WireFormat::OpenAiResponses,
638            Some("served/model"),
639        );
640
641        assert_eq!(event["response"]["model"], "served/model");
642        assert_eq!(event["response"]["provider_extension"], true);
643    }
644
645    #[test]
646    fn anthropic_replay_stamps_the_served_model_inside_message() {
647        let mut event = json!({
648            "type": "message_start",
649            "message": {
650                "id": "msg_1",
651                "model": "provider/model",
652                "provider_extension": true,
653            },
654        });
655
656        stamp_streamed_response_model(
657            &mut event,
658            WireFormat::AnthropicMessages,
659            Some("served/model"),
660        );
661
662        assert_eq!(event["message"]["model"], "served/model");
663        assert_eq!(event["message"]["provider_extension"], true);
664    }
665
666    #[test]
667    fn decode_stream_reassembles_frames_split_across_chunks() -> Result<(), BoxError> {
668        // A multi-byte codepoint and the frame boundaries are split across
669        // one-byte chunks; the BufReader must reassemble them losslessly.
670        let payload = json!({"choices": [{"delta": {"content": "café"}}]});
671        let sse = format!("data: {payload}\n\ndata: [DONE]\n\n");
672        let bytes = stream::iter(
673            sse.into_bytes()
674                .into_iter()
675                .map(|byte| Ok::<Vec<u8>, LlmClientError>(vec![byte])),
676        );
677        let chunks = decode_all(bytes, WireFormat::OpenAiChat)?;
678        assert_eq!(text_of(&chunks), "café");
679        Ok(())
680    }
681
682    #[test]
683    fn decode_stream_decodes_trailing_frame_without_blank_line() -> Result<(), BoxError> {
684        // A non-standard upstream omits the final blank line; the last frame
685        // must still be decoded rather than dropped.
686        let sse = b"data: {\"choices\":[{\"delta\":{\"content\":\"tail\"}}]}".to_vec();
687        let bytes = stream::once(async move { Ok::<Vec<u8>, LlmClientError>(sse) });
688        let chunks = decode_all(bytes, WireFormat::OpenAiChat)?;
689        assert_eq!(text_of(&chunks), "tail");
690        Ok(())
691    }
692
693    #[test]
694    fn decode_stream_decodes_crlf_delimited_frames() -> Result<(), BoxError> {
695        // CRLF framing: blank lines are `\r\n\r\n` and the bare `\r` must not
696        // block the frame boundary.
697        let sse =
698            b"data: {\"choices\":[{\"delta\":{\"content\":\"crlf\"}}]}\r\n\r\ndata: [DONE]\r\n\r\n"
699                .to_vec();
700        let bytes = stream::once(async move { Ok::<Vec<u8>, LlmClientError>(sse) });
701        let chunks = decode_all(bytes, WireFormat::OpenAiChat)?;
702        assert_eq!(text_of(&chunks), "crlf");
703        Ok(())
704    }
705
706    #[test]
707    fn decode_stream_propagates_source_errors() -> Result<(), BoxError> {
708        // A transport error mid-stream surfaces as an error item, not a panic.
709        let bytes = stream::iter(vec![
710            Ok::<Vec<u8>, LlmClientError>(
711                b"data: {\"choices\":[{\"delta\":{\"content\":\"x\"}}]}\n\n".to_vec(),
712            ),
713            Err::<Vec<u8>, LlmClientError>(LlmClientError::Transport {
714                source: Box::new(std::io::Error::other("upstream exploded")),
715            }),
716        ]);
717        let results = block_on(decode_stream(bytes, WireFormat::OpenAiChat)?.collect::<Vec<_>>());
718        let Some(Err(error)) = results.last() else {
719            panic!("expected the source error");
720        };
721        assert!(matches!(error, LlmClientError::Transport { .. }));
722        Ok(())
723    }
724
725    #[test]
726    fn decode_stream_classifies_invalid_sse_json() -> Result<(), BoxError> {
727        let bytes =
728            stream::once(async { Ok::<Vec<u8>, LlmClientError>(b"data: {invalid}\n\n".to_vec()) });
729        let results = block_on(decode_stream(bytes, WireFormat::OpenAiChat)?.collect::<Vec<_>>());
730        let Some(Err(error)) = results.last() else {
731            panic!("expected invalid SSE JSON to fail");
732        };
733        assert!(matches!(error, LlmClientError::ResponseTranslation(_)));
734        Ok(())
735    }
736}