1use 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
30pub 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
37pub 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
44pub 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
51pub 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
68pub 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
78pub 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 let codec = StreamCodecRegistry::with_builtins()
93 .codec(target_format.clone())
94 .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
137fn 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
168pub 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 let codec = StreamCodecRegistry::with_builtins()
186 .codec(source_format.clone())
187 .map_err(|error| LlmClientError::ResponseTranslation(error.to_string()))?;
188 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 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 #[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
245fn 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 type BoxError = Box<dyn std::error::Error + Send + Sync>;
278
279 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 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 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 assert!(
373 events
374 .iter()
375 .any(|event| event["choices"][0]["finish_reason"] == "stop")
376 );
377 Ok(())
378 }
379
380 #[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 #[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 #[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 #[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 #[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 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 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 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 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}