1use 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
25pub 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
32pub 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
39pub 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
46pub 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
62pub 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
86pub 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
100pub 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
118pub 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 type BoxError = Box<dyn std::error::Error + Send + Sync>;
155
156 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 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 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 assert!(
250 events
251 .iter()
252 .any(|event| event["choices"][0]["finish_reason"] == "stop")
253 );
254 Ok(())
255 }
256
257 #[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 #[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 #[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 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 #[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 #[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 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 #[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 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 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 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 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 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 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}