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, LlmResponseChunk, LlmResponseStream,
22 LlmResponseStreamEvent, LlmStreamError, Result, StreamCodecRegistry, StreamTranslationState,
23 TranslationEngine, TranslationPolicy, WireFormat,
24};
25
26static DEFAULT_TRANSLATION_POLICY: LazyLock<TranslationPolicy> =
27 LazyLock::new(TranslationPolicy::default);
28static DEFAULT_TRANSLATION_ENGINE: LazyLock<TranslationEngine> =
29 LazyLock::new(TranslationEngine::default);
30
31pub fn decode_request(wire_format: WireFormat, body: &Value) -> Result<LlmRequest> {
33 Ok(DEFAULT_TRANSLATION_ENGINE
34 .decode_request(wire_format, body, &DEFAULT_TRANSLATION_POLICY)?
35 .request)
36}
37
38pub fn encode_request(request: &LlmRequest, wire_format: WireFormat) -> Result<Value> {
40 Ok(DEFAULT_TRANSLATION_ENGINE
41 .encode_request(wire_format, request, &DEFAULT_TRANSLATION_POLICY)?
42 .body)
43}
44
45pub fn decode_aggregated_response(body: &Value, wire_format: WireFormat) -> Result<AggLlmResponse> {
47 Ok(DEFAULT_TRANSLATION_ENGINE
48 .decode_response(wire_format, body, &DEFAULT_TRANSLATION_POLICY)?
49 .response)
50}
51
52pub fn encode_aggregated_response(
56 agg: &AggLlmResponse,
57 wire_format: WireFormat,
58 served_model: Option<&str>,
59) -> Result<Value> {
60 encode_aggregated_response_with_extensions(
61 agg,
62 wire_format,
63 served_model,
64 &switchyard_protocol::ProviderExtensions::default(),
65 )
66}
67
68pub fn encode_aggregated_response_with_extensions(
73 agg: &AggLlmResponse,
74 wire_format: WireFormat,
75 served_model: Option<&str>,
76 request_extensions: &switchyard_protocol::ProviderExtensions,
77) -> Result<Value> {
78 let mut body = DEFAULT_TRANSLATION_ENGINE
79 .encode_response_with_extensions(
80 wire_format,
81 agg,
82 request_extensions,
83 &DEFAULT_TRANSLATION_POLICY,
84 )?
85 .body;
86 if let (Some(model), Value::Object(object)) = (served_model, &mut body) {
87 object.insert("model".to_string(), Value::String(model.to_string()));
88 }
89 Ok(body)
90}
91
92pub type RawEventStream =
96 Pin<Box<dyn Stream<Item = std::result::Result<Value, LlmStreamError>> + Send>>;
97
98pub fn encode_stream(
105 chunks: LlmResponseStream,
106 target: WireFormat,
107 served_model: Option<String>,
108) -> std::result::Result<RawEventStream, LlmClientError> {
109 encode_stream_with_extensions(
110 chunks,
111 target,
112 served_model,
113 &switchyard_protocol::ProviderExtensions::default(),
114 )
115}
116
117pub fn encode_stream_with_extensions(
122 chunks: LlmResponseStream,
123 target: WireFormat,
124 served_model: Option<String>,
125 request_extensions: &switchyard_protocol::ProviderExtensions,
126) -> std::result::Result<RawEventStream, LlmClientError> {
127 let origins = crate::codex_namespaces::qualified_tool_origins(request_extensions);
128 let custom_tools = crate::codex_custom_tools::custom_tool_names(request_extensions);
129 let mut custom_state = crate::codex_custom_tools::CustomToolCallStreamState::default();
130 let target_format: FormatId = target.into();
131 let codec = StreamCodecRegistry::with_builtins()
134 .codec(target_format.clone())
135 .map_err(|err| LlmClientError::Configuration {
137 message: err.to_string(),
138 })?;
139
140 let served_model_for_events = served_model.clone();
141 let mut state = StreamTranslationState {
142 target: Some(target_format.clone()),
143 target_model: served_model,
144 ..Default::default()
145 };
146 let mut chunks = chunks;
147
148 let events = try_stream! {
149 while let Some(item) = chunks.next().await {
150 let event = item.map_err(LlmStreamError::Client)?;
151 let mut encoded =
152 encode_response_stream_event(&mut state, codec.as_ref(), &target_format, event);
153 for value in &mut encoded {
154 stamp_streamed_response_model(value, target, served_model_for_events.as_deref());
155 crate::codex_namespaces::restore_qualified_tool_names(value, &origins);
156 }
157 let mut encoded: Vec<Value> = encoded
160 .into_iter()
161 .filter_map(|mut value| {
162 crate::codex_custom_tools::restore_custom_tool_calls_in_event(
163 &mut value,
164 &custom_tools,
165 &mut custom_state,
166 )
167 .then_some(value)
168 })
169 .collect();
170 let terminal = encoded.pop();
171 for value in encoded {
172 yield value;
173 }
174 match (state.errored, terminal) {
175 (false, Some(value)) => yield value,
176 (true, Some(value)) => Err(LlmStreamError::Upstream(value))?,
177 (true, None) => Err(LlmStreamError::Client(LlmClientError::ResponseTranslation(
178 "stream ended after an error without a terminal event".to_string(),
179 )))?,
180 _ => ()
181 }
182 }
183 for mut value in codec.finish(&mut state) {
184 stamp_streamed_response_model(
185 &mut value,
186 target,
187 served_model_for_events.as_deref(),
188 );
189 crate::codex_namespaces::restore_qualified_tool_names(&mut value, &origins);
190 if crate::codex_custom_tools::restore_custom_tool_calls_in_event(
191 &mut value,
192 &custom_tools,
193 &mut custom_state,
194 ) {
195 yield value;
196 }
197 }
198 };
199
200 Ok(Box::pin(events))
201}
202
203fn stamp_streamed_response_model(
207 event: &mut Value,
208 target: WireFormat,
209 served_model: Option<&str>,
210) {
211 let Some(served_model) = served_model else {
212 return;
213 };
214
215 match target {
216 WireFormat::OpenAiChat => {
217 if let Some(event) = event.as_object_mut() {
218 event.insert("model".to_string(), Value::String(served_model.to_string()));
219 }
220 }
221 WireFormat::OpenAiResponses => {
222 if let Some(response) = event.get_mut("response").and_then(Value::as_object_mut) {
223 response.insert("model".to_string(), Value::String(served_model.to_string()));
224 }
225 }
226 WireFormat::AnthropicMessages => {
227 if let Some(message) = event.get_mut("message").and_then(Value::as_object_mut) {
228 message.insert("model".to_string(), Value::String(served_model.to_string()));
229 }
230 }
231 }
232}
233
234pub fn decode_stream<S>(
245 bytes: S,
246 source: WireFormat,
247) -> std::result::Result<LlmResponseStream, LlmClientError>
248where
249 S: Stream<Item = std::result::Result<Vec<u8>, LlmClientError>> + Send + 'static,
250{
251 let marker = sse::done_marker(source);
252 let source_format: FormatId = source.into();
253 let codec = StreamCodecRegistry::with_builtins()
256 .codec(source_format.clone())
257 .map_err(|error| LlmClientError::ResponseTranslation(error.to_string()))?;
258 let io_bytes: Pin<Box<dyn Stream<Item = std::io::Result<Vec<u8>>> + Send>> =
265 Box::pin(bytes.map(|item| item.map_err(std::io::Error::other)));
266 let lines = futures::io::BufReader::new(io_bytes.into_async_read()).lines();
267
268 let mut state = StreamTranslationState {
269 source: Some(source_format.clone()),
270 ..StreamTranslationState::default()
271 };
272 let mut frame = String::new();
273 let mut saw_terminal = false;
274 let mut saw_error = false;
275 let stream = Box::pin(try_stream! {
276 futures::pin_mut!(lines);
277 while let Some(line) = lines.next().await {
278 let line = line.map_err(llm_client_error_from_io)?;
279 if line.trim_end().is_empty() {
281 let parsed = sse::parse_json_sse_frame(&frame, marker)
282 .map_err(|error| LlmClientError::ResponseTranslation(error.to_string()))?;
283 frame.clear();
284 match parsed {
285 sse::SseFrame::Empty => {}
286 sse::SseFrame::Done => {
287 saw_terminal |= source != WireFormat::AnthropicMessages;
288 break;
289 }
290 sse::SseFrame::Data(value) => {
291 saw_terminal |= sse::is_terminal_event(source, &value);
292 let normalized = codec.decode_event(&mut state, &value);
293 saw_error |= normalized.iter().any(|chunk| matches!(
294 chunk,
295 LlmResponseChunk::DecodeError { .. }
296 | LlmResponseChunk::StreamError { .. }
297 ));
298 yield LlmResponseStreamEvent::preserved(
299 source_format.clone(),
300 value,
301 normalized,
302 );
303 }
304 }
305 } else {
306 frame.push_str(&line);
307 frame.push('\n');
308 }
309 }
310
311 #[allow(clippy::collapsible_if)]
314 if !frame.trim_end().is_empty() {
315 let parsed = sse::parse_json_sse_frame(&frame, marker)
316 .map_err(|error| LlmClientError::ResponseTranslation(error.to_string()))?;
317 match parsed {
318 sse::SseFrame::Done => {
319 saw_terminal |= source != WireFormat::AnthropicMessages;
320 }
321 sse::SseFrame::Data(value) => {
322 saw_terminal |= sse::is_terminal_event(source, &value);
323 let normalized = codec.decode_event(&mut state, &value);
324 saw_error |= normalized.iter().any(|chunk| matches!(
325 chunk,
326 LlmResponseChunk::DecodeError { .. }
327 | LlmResponseChunk::StreamError { .. }
328 ));
329 yield LlmResponseStreamEvent::preserved(source_format, value, normalized);
330 }
331 sse::SseFrame::Empty => {}
332 }
333 }
334
335 if !saw_terminal && !saw_error {
336 Err(LlmClientError::ResponseTranslation(format!(
337 "{source} stream ended before a terminal event"
338 )))?;
339 }
340 });
341 Ok(stream)
342}
343
344fn llm_client_error_from_io(error: std::io::Error) -> LlmClientError {
347 let kind = error.kind();
348 let message = error.to_string();
349 match error.into_inner() {
350 Some(source) => match source.downcast::<LlmClientError>() {
351 Ok(error) => *error,
352 Err(source) => LlmClientError::InvalidResponse { source },
353 },
354 None => LlmClientError::InvalidResponse {
355 source: Box::new(std::io::Error::new(kind, message)),
356 },
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use futures::executor::block_on;
363 use futures::{Stream, StreamExt, stream};
364 use serde_json::{Value, json};
365 use switchyard_protocol::{
366 LlmClientError, LlmResponseChunk, LlmResponseStreamEvent, completion_text,
367 };
368
369 use super::{
370 decode_aggregated_response, decode_request, decode_stream, encode_aggregated_response,
371 encode_request, encode_stream, stamp_streamed_response_model,
372 };
373 use crate::{LlmResponseStream, LlmStreamError, WireFormat};
374
375 type BoxError = Box<dyn std::error::Error + Send + Sync>;
377
378 fn decode_all(
380 bytes: impl Stream<Item = Result<Vec<u8>, LlmClientError>> + Send + 'static,
381 source: WireFormat,
382 ) -> Result<Vec<LlmResponseStreamEvent>, LlmClientError> {
383 block_on(decode_stream(bytes, source)?.collect::<Vec<_>>())
384 .into_iter()
385 .collect()
386 }
387
388 fn text_of(events: &[LlmResponseStreamEvent]) -> String {
390 events
391 .iter()
392 .flat_map(LlmResponseStreamEvent::normalized)
393 .filter_map(|chunk| {
394 if let LlmResponseChunk::TextDelta { text, .. } = chunk {
395 Some(text.as_str())
396 } else {
397 None
398 }
399 })
400 .collect()
401 }
402
403 #[test]
404 fn request_round_trips_through_openai_chat() -> Result<(), BoxError> {
405 let body = json!({"model": "gpt", "messages": [{"role": "user", "content": "hi"}]});
406 let request = decode_request(WireFormat::OpenAiChat, &body)?;
407 assert_eq!(request.model.as_deref(), Some("gpt"));
408
409 let encoded = encode_request(&request, WireFormat::OpenAiChat)?;
410 assert_eq!(encoded["model"], "gpt");
411 assert_eq!(encoded["messages"][0]["content"], "hi");
412 Ok(())
413 }
414
415 #[test]
416 fn aggregated_response_round_trips_and_stamps_the_served_model() -> Result<(), BoxError> {
417 let body = json!({
418 "id": "1",
419 "model": "upstream",
420 "choices": [{
421 "index": 0,
422 "message": {"role": "assistant", "content": "Hi there"},
423 "finish_reason": "stop"
424 }],
425 "usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
426 });
427 let agg = decode_aggregated_response(&body, WireFormat::OpenAiChat)?;
428 assert_eq!(completion_text(&agg), "Hi there");
429
430 let encoded =
432 encode_aggregated_response(&agg, WireFormat::OpenAiChat, Some("served/model"))?;
433 assert_eq!(encoded["model"], "served/model");
434 assert_eq!(encoded["choices"][0]["message"]["content"], "Hi there");
435 Ok(())
436 }
437
438 #[test]
439 fn encode_stream_reassembles_deltas_and_finishes() -> Result<(), BoxError> {
440 let chunks: LlmResponseStream = stream::iter(vec![
441 Ok(LlmResponseChunk::TextDelta {
442 index: 0,
443 text: "Hello".to_string(),
444 }
445 .into()),
446 Ok(LlmResponseChunk::TextDelta {
447 index: 0,
448 text: " world".to_string(),
449 }
450 .into()),
451 Ok(LlmResponseChunk::MessageStop {
452 reason: Some("stop".to_string()),
453 }
454 .into()),
455 ])
456 .boxed();
457
458 let events = block_on(
459 encode_stream(chunks, WireFormat::OpenAiChat, Some("m".to_string()))?
460 .collect::<Vec<_>>(),
461 )
462 .into_iter()
463 .collect::<Result<Vec<Value>, LlmStreamError>>()?;
464
465 let content: String = events
466 .iter()
467 .filter_map(|event| event["choices"][0]["delta"]["content"].as_str())
468 .collect();
469 assert_eq!(content, "Hello world");
470 assert!(
472 events
473 .iter()
474 .any(|event| event["choices"][0]["finish_reason"] == "stop")
475 );
476 Ok(())
477 }
478
479 #[test]
482 fn encode_stream_stamps_the_served_model_on_message_start() -> Result<(), BoxError> {
483 let chunks: LlmResponseStream = stream::iter(vec![
484 Ok(LlmResponseChunk::MessageStart {
485 id: Some("msg_1".to_string()),
486 model: Some("upstream/model".to_string()),
487 }
488 .into()),
489 Ok(LlmResponseChunk::TextDelta {
490 index: 0,
491 text: "hi".to_string(),
492 }
493 .into()),
494 ])
495 .boxed();
496
497 let events = block_on(
498 encode_stream(
499 chunks,
500 WireFormat::AnthropicMessages,
501 Some("served/model".to_string()),
502 )?
503 .collect::<Vec<_>>(),
504 )
505 .into_iter()
506 .collect::<Result<Vec<Value>, LlmStreamError>>()?;
507
508 assert_eq!(events[0]["type"], "message_start");
509 assert_eq!(events[0]["message"]["model"], "served/model");
510 Ok(())
511 }
512
513 #[test]
516 fn encode_stream_falls_back_to_the_source_model() -> Result<(), BoxError> {
517 let chunks: LlmResponseStream = stream::iter(vec![
518 Ok(LlmResponseChunk::MessageStart {
519 id: Some("msg_1".to_string()),
520 model: Some("upstream/model".to_string()),
521 }
522 .into()),
523 Ok(LlmResponseChunk::TextDelta {
524 index: 0,
525 text: "hi".to_string(),
526 }
527 .into()),
528 ])
529 .boxed();
530
531 let events = block_on(
532 encode_stream(chunks, WireFormat::AnthropicMessages, None)?.collect::<Vec<_>>(),
533 )
534 .into_iter()
535 .collect::<Result<Vec<Value>, LlmStreamError>>()?;
536
537 assert_eq!(events[0]["message"]["model"], "upstream/model");
538 Ok(())
539 }
540
541 #[test]
542 fn encode_stream_propagates_chunk_errors() -> Result<(), BoxError> {
543 let chunks: LlmResponseStream =
544 stream::iter(vec![Err::<LlmResponseStreamEvent, LlmClientError>(
545 LlmClientError::General("chunk exploded".to_string()),
546 )])
547 .boxed();
548 let results =
549 block_on(encode_stream(chunks, WireFormat::OpenAiChat, None)?.collect::<Vec<_>>());
550 assert!(results.iter().any(Result::is_err));
551 Ok(())
552 }
553
554 #[test]
559 fn encode_stream_stops_after_an_in_band_error() -> Result<(), BoxError> {
560 for message in [
561 LlmResponseChunk::StreamError {
562 message: "boom".to_string(),
563 },
564 LlmResponseChunk::DecodeError {
565 message: "boom".to_string(),
566 },
567 ] {
568 for target in [
569 WireFormat::OpenAiChat,
570 WireFormat::OpenAiResponses,
571 WireFormat::AnthropicMessages,
572 ] {
573 let chunks: LlmResponseStream = stream::iter(vec![
574 Ok(LlmResponseChunk::TextDelta {
575 index: 0,
576 text: "before".to_string(),
577 }
578 .into()),
579 Ok(message.clone().into()),
580 Ok(LlmResponseChunk::TextDelta {
581 index: 0,
582 text: "after".to_string(),
583 }
584 .into()),
585 ])
586 .boxed();
587 let results = block_on(encode_stream(chunks, target, None)?.collect::<Vec<_>>());
588 let (data, errors): (Vec<_>, Vec<_>) = results.into_iter().partition(Result::is_ok);
589 let data = data.into_iter().flatten().collect::<Vec<Value>>();
590 let body = serde_json::to_string(&data)?;
591
592 assert!(
593 body.contains("before"),
594 "{target:?}: pre-error content missing:\n{body}"
595 );
596 assert!(
597 !body.contains("after"),
598 "{target:?}/{message:?}: content leaked after the error:\n{body}"
599 );
600 let [Err(LlmStreamError::Upstream(event))] = errors.as_slice() else {
603 panic!("{target:?}/{message:?}: expected one in-band error, got {errors:?}");
604 };
605 assert!(
606 serde_json::to_string(event)?.contains("boom"),
607 "{target:?}: error event lost its message: {event}"
608 );
609 }
610 }
611 Ok(())
612 }
613
614 #[test]
617 fn encode_stream_keeps_a_normal_stop_off_the_error_channel() -> Result<(), BoxError> {
618 let chunks: LlmResponseStream = stream::iter(vec![
619 Ok(LlmResponseChunk::TextDelta {
620 index: 0,
621 text: "hi".to_string(),
622 }
623 .into()),
624 Ok(LlmResponseChunk::MessageStop {
625 reason: Some("stop".to_string()),
626 }
627 .into()),
628 ])
629 .boxed();
630
631 let results =
632 block_on(encode_stream(chunks, WireFormat::OpenAiChat, None)?.collect::<Vec<_>>());
633
634 assert!(
635 results.iter().all(Result::is_ok),
636 "a completed turn reported an error: {results:?}"
637 );
638 Ok(())
639 }
640
641 #[test]
643 fn encode_stream_stops_polling_after_a_replayed_error() -> Result<(), BoxError> {
644 let error = LlmResponseStreamEvent::preserved(
645 WireFormat::OpenAiResponses,
646 json!({"type": "error", "message": "boom"}),
647 vec![LlmResponseChunk::StreamError {
648 message: "boom".to_string(),
649 }],
650 );
651 let chunks: LlmResponseStream = stream::iter([Ok(error)])
652 .chain(stream::poll_fn(|_| {
653 panic!("encode_stream polled the source after an in-band error")
654 }))
655 .boxed();
656
657 let events =
658 block_on(encode_stream(chunks, WireFormat::OpenAiResponses, None)?.collect::<Vec<_>>());
659
660 let [Err(LlmStreamError::Upstream(event))] = events.as_slice() else {
663 return Err(format!("expected one in-band error, got {events:?}").into());
664 };
665 assert_eq!(*event, json!({"type": "error", "message": "boom"}));
666 Ok(())
667 }
668
669 #[test]
672 fn encode_stream_keeps_trailing_usage_after_a_normal_stop() -> Result<(), BoxError> {
673 let chunks: LlmResponseStream = stream::iter(vec![
674 Ok(LlmResponseChunk::TextDelta {
675 index: 0,
676 text: "hi".to_string(),
677 }
678 .into()),
679 Ok(LlmResponseChunk::MessageStop {
680 reason: Some("stop".to_string()),
681 }
682 .into()),
683 Ok(LlmResponseChunk::Usage(switchyard_protocol::llm::Usage {
684 output_tokens: Some(7),
685 ..Default::default()
686 })
687 .into()),
688 ])
689 .boxed();
690 let events =
691 block_on(encode_stream(chunks, WireFormat::OpenAiChat, None)?.collect::<Vec<_>>())
692 .into_iter()
693 .collect::<Result<Vec<Value>, LlmStreamError>>()?;
694 let body = serde_json::to_string(&events)?;
695 assert!(
696 events
697 .iter()
698 .any(|event| event["choices"][0]["finish_reason"] == "stop"),
699 "missing stop terminal:\n{body}"
700 );
701 assert!(
702 body.contains("\"usage\""),
703 "trailing usage dropped after a normal stop:\n{body}"
704 );
705 Ok(())
706 }
707
708 #[test]
709 fn decode_stream_parses_sse_bytes_into_ir_chunks() -> Result<(), LlmClientError> {
710 let sse = b"data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n\
711 data: {\"choices\":[{\"delta\":{\"content\":\" world\"}}]}\n\n\
712 data: [DONE]\n\n\
713 data: {\"choices\":[{\"delta\":{\"content\":\" ignored\"}}]}\n\n"
714 .to_vec();
715 let bytes = stream::once(async move { Ok::<Vec<u8>, LlmClientError>(sse) });
716 let chunks = decode_all(bytes, WireFormat::OpenAiChat)?;
717 assert_eq!(text_of(&chunks), "Hello world");
718 Ok(())
719 }
720
721 #[test]
722 fn stream_helpers_replay_same_format_provider_fields() -> Result<(), BoxError> {
723 let provider_event = json!({
724 "id": "chatcmpl-test",
725 "object": "chat.completion.chunk",
726 "system_fingerprint": "fp_provider_specific",
727 "choices": [{
728 "index": 0,
729 "delta": {"content": "Hello"},
730 "finish_reason": "stop"
731 }]
732 });
733 let bytes = stream::once({
734 let frame = format!("data: {provider_event}\n\n").into_bytes();
735 async move { Ok::<Vec<u8>, LlmClientError>(frame) }
736 });
737 let decoded = decode_stream(bytes, WireFormat::OpenAiChat)?;
738 let replayed =
739 block_on(encode_stream(decoded, WireFormat::OpenAiChat, None)?.collect::<Vec<_>>())
740 .into_iter()
741 .collect::<Result<Vec<Value>, LlmStreamError>>()?;
742
743 assert_eq!(replayed, vec![provider_event]);
744 Ok(())
745 }
746
747 #[test]
748 fn openai_chat_replay_stamps_the_served_model_without_losing_extensions() {
749 let mut event = json!({
750 "choices": [{"delta": {"content": "Hello"}}],
751 "system_fingerprint": "fp_provider_specific",
752 });
753
754 stamp_streamed_response_model(&mut event, WireFormat::OpenAiChat, Some("served/model"));
755
756 assert_eq!(event["model"], "served/model");
757 assert_eq!(event["system_fingerprint"], "fp_provider_specific");
758 }
759
760 #[test]
761 fn responses_replay_stamps_the_served_model_inside_response() {
762 let mut event = json!({
763 "type": "response.created",
764 "response": {
765 "id": "resp_1",
766 "model": "provider/model",
767 "provider_extension": true,
768 },
769 });
770
771 stamp_streamed_response_model(
772 &mut event,
773 WireFormat::OpenAiResponses,
774 Some("served/model"),
775 );
776
777 assert_eq!(event["response"]["model"], "served/model");
778 assert_eq!(event["response"]["provider_extension"], true);
779 }
780
781 #[test]
782 fn anthropic_replay_stamps_the_served_model_inside_message() {
783 let mut event = json!({
784 "type": "message_start",
785 "message": {
786 "id": "msg_1",
787 "model": "provider/model",
788 "provider_extension": true,
789 },
790 });
791
792 stamp_streamed_response_model(
793 &mut event,
794 WireFormat::AnthropicMessages,
795 Some("served/model"),
796 );
797
798 assert_eq!(event["message"]["model"], "served/model");
799 assert_eq!(event["message"]["provider_extension"], true);
800 }
801
802 #[test]
803 fn decode_stream_reassembles_frames_split_across_chunks() -> Result<(), BoxError> {
804 let payload = json!({"choices": [{"delta": {"content": "café"}}]});
807 let sse = format!("data: {payload}\n\ndata: [DONE]\n\n");
808 let bytes = stream::iter(
809 sse.into_bytes()
810 .into_iter()
811 .map(|byte| Ok::<Vec<u8>, LlmClientError>(vec![byte])),
812 );
813 let chunks = decode_all(bytes, WireFormat::OpenAiChat)?;
814 assert_eq!(text_of(&chunks), "café");
815 Ok(())
816 }
817
818 #[test]
819 fn decode_stream_decodes_trailing_frame_without_blank_line() -> Result<(), BoxError> {
820 let sse =
823 b"data: {\"choices\":[{\"delta\":{\"content\":\"tail\"}}]}\n\ndata: [DONE]".to_vec();
824 let bytes = stream::once(async move { Ok::<Vec<u8>, LlmClientError>(sse) });
825 let chunks = decode_all(bytes, WireFormat::OpenAiChat)?;
826 assert_eq!(text_of(&chunks), "tail");
827 Ok(())
828 }
829
830 #[test]
831 fn decode_stream_rejects_eof_before_a_terminal_event() -> Result<(), BoxError> {
832 let sse = b"data: {\"choices\":[{\"delta\":{\"content\":\"partial\"},\"finish_reason\":null}]}\n\n".to_vec();
834 let bytes = stream::once(async move { Ok::<Vec<u8>, LlmClientError>(sse) });
835 let results = block_on(decode_stream(bytes, WireFormat::OpenAiChat)?.collect::<Vec<_>>());
836
837 let Some(Ok(first)) = results.first() else {
838 return Err("expected the partial event".into());
839 };
840 assert_eq!(text_of(std::slice::from_ref(first)), "partial");
841 let Some(Err(LlmClientError::ResponseTranslation(message))) = results.last() else {
842 panic!("expected incomplete OpenAI stream to fail");
843 };
844 assert_eq!(message, "openai_chat stream ended before a terminal event");
845 Ok(())
846 }
847
848 #[test]
849 fn responses_failed_before_done_remains_a_stream_error() -> Result<(), BoxError> {
850 let sse = b"data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"message\":\"upstream failed\"}}}\n\ndata: [DONE]\n\n".to_vec();
851 let bytes = stream::once(async move { Ok::<Vec<u8>, LlmClientError>(sse) });
852 let events = decode_all(bytes, WireFormat::OpenAiResponses)?;
853
854 let Some(LlmResponseChunk::StreamError { message }) =
855 events.first().and_then(|event| event.normalized().first())
856 else {
857 return Err("expected response.failed to decode as StreamError".into());
858 };
859 assert_eq!(message, "upstream failed");
860
861 let chunks: LlmResponseStream = stream::iter(
862 events
863 .into_iter()
864 .map(Ok::<LlmResponseStreamEvent, LlmClientError>),
865 )
866 .boxed();
867 let replayed =
868 block_on(encode_stream(chunks, WireFormat::OpenAiResponses, None)?.collect::<Vec<_>>());
869
870 let [Err(LlmStreamError::Upstream(event))] = replayed.as_slice() else {
872 return Err(format!("expected one in-band error, got {replayed:?}").into());
873 };
874 assert_eq!(
875 *event,
876 json!({
877 "type": "response.failed",
878 "response": {"error": {"message": "upstream failed"}}
879 })
880 );
881 Ok(())
882 }
883
884 #[test]
885 fn decode_stream_decodes_crlf_delimited_frames() -> Result<(), BoxError> {
886 let sse =
889 b"data: {\"choices\":[{\"delta\":{\"content\":\"crlf\"}}]}\r\n\r\ndata: [DONE]\r\n\r\n"
890 .to_vec();
891 let bytes = stream::once(async move { Ok::<Vec<u8>, LlmClientError>(sse) });
892 let chunks = decode_all(bytes, WireFormat::OpenAiChat)?;
893 assert_eq!(text_of(&chunks), "crlf");
894 Ok(())
895 }
896
897 #[test]
898 fn decode_stream_propagates_source_errors() -> Result<(), BoxError> {
899 let bytes = stream::iter(vec![
901 Ok::<Vec<u8>, LlmClientError>(
902 b"data: {\"choices\":[{\"delta\":{\"content\":\"x\"}}]}\n\n".to_vec(),
903 ),
904 Err::<Vec<u8>, LlmClientError>(LlmClientError::Transport {
905 source: Box::new(std::io::Error::other("upstream exploded")),
906 }),
907 ]);
908 let results = block_on(decode_stream(bytes, WireFormat::OpenAiChat)?.collect::<Vec<_>>());
909 let Some(Err(error)) = results.last() else {
910 panic!("expected the source error");
911 };
912 assert!(matches!(error, LlmClientError::Transport { .. }));
913 Ok(())
914 }
915
916 #[test]
917 fn decode_stream_classifies_invalid_sse_json() -> Result<(), BoxError> {
918 let bytes =
919 stream::once(async { Ok::<Vec<u8>, LlmClientError>(b"data: {invalid}\n\n".to_vec()) });
920 let results = block_on(decode_stream(bytes, WireFormat::OpenAiChat)?.collect::<Vec<_>>());
921 let Some(Err(error)) = results.last() else {
922 panic!("expected invalid SSE JSON to fail");
923 };
924 assert!(matches!(error, LlmClientError::ResponseTranslation(_)));
925 Ok(())
926 }
927}