1use std::collections::BTreeMap;
9use std::pin::Pin;
10
11use futures::{Stream, StreamExt};
12use http::StatusCode;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use thiserror::Error;
16
17use crate::{
18 LlmClientError,
19 format::FormatId,
20 llm::{AggLlmResponse, ContentBlock, ResponseOutput, Role, StopReason, ToolCall, Usage},
21};
22
23const MID_STREAM_UPSTREAM_STATUS: StatusCode = StatusCode::BAD_GATEWAY;
27
28#[derive(Debug, Error)]
30pub enum LlmStreamError {
31 #[error("upstream stream error: {0}")]
33 Upstream(Value),
34
35 #[error(transparent)]
37 Client(#[from] LlmClientError),
38}
39
40pub type LlmResponseStream =
42 Pin<Box<dyn Stream<Item = Result<LlmResponseStreamEvent, LlmClientError>> + Send>>;
43
44#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
46pub struct ProviderStreamEvent {
47 source: FormatId,
48 raw: Value,
49}
50
51impl ProviderStreamEvent {
52 pub fn source(&self) -> &FormatId {
54 &self.source
55 }
56
57 pub fn raw(&self) -> &Value {
59 &self.raw
60 }
61
62 pub fn into_parts(self) -> (FormatId, Value) {
64 (self.source, self.raw)
65 }
66}
67
68#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
73pub struct LlmResponseStreamEvent {
74 preservation: Option<ProviderStreamEvent>,
75 normalized: Vec<LlmResponseChunk>,
76}
77
78impl LlmResponseStreamEvent {
79 pub fn new(normalized: Vec<LlmResponseChunk>) -> Self {
81 Self {
82 preservation: None,
83 normalized,
84 }
85 }
86
87 pub fn preserved(
89 source: impl Into<FormatId>,
90 raw: Value,
91 normalized: Vec<LlmResponseChunk>,
92 ) -> Self {
93 Self {
94 preservation: Some(ProviderStreamEvent {
95 source: source.into(),
96 raw,
97 }),
98 normalized,
99 }
100 }
101
102 pub fn preservation(&self) -> Option<&ProviderStreamEvent> {
104 self.preservation.as_ref()
105 }
106
107 pub fn normalized(&self) -> &[LlmResponseChunk] {
109 &self.normalized
110 }
111
112 pub fn into_parts(self) -> (Option<ProviderStreamEvent>, Vec<LlmResponseChunk>) {
114 (self.preservation, self.normalized)
115 }
116
117 pub fn replace_normalized(self, normalized: Vec<LlmResponseChunk>) -> Self {
119 Self::new(normalized)
120 }
121}
122
123impl From<LlmResponseChunk> for LlmResponseStreamEvent {
124 fn from(chunk: LlmResponseChunk) -> Self {
125 Self::new(vec![chunk])
126 }
127}
128
129#[allow(clippy::large_enum_variant)]
136pub enum LlmResponse {
137 Stream(LlmResponseStream),
139 Agg(AggLlmResponse),
141}
142
143impl LlmResponse {
144 pub fn as_agg(&self) -> Option<&AggLlmResponse> {
146 match self {
147 LlmResponse::Agg(agg) => Some(agg),
148 LlmResponse::Stream(_) => None,
149 }
150 }
151
152 pub async fn into_agg(self) -> Result<AggLlmResponse, LlmClientError> {
158 match self {
159 LlmResponse::Agg(agg) => Ok(agg),
160 LlmResponse::Stream(mut stream) => {
161 let mut accumulator = ResponseAccumulator::new();
162 while let Some(item) = stream.next().await {
163 for chunk in item?.normalized {
164 push_checked_chunk(&mut accumulator, chunk)?;
165 }
166 }
167 Ok(accumulator.finish())
168 }
169 }
170 }
171
172 pub fn selected_model(&self) -> Option<&str> {
177 match self {
178 LlmResponse::Agg(agg) => agg.model.as_deref(),
179 LlmResponse::Stream(_) => None,
181 }
182 }
183}
184
185fn is_reasoning_id_announcement(detail: &Value) -> bool {
187 detail.get("type").and_then(Value::as_str) == Some("reasoning.encrypted")
188 && detail.get("data").is_none()
189}
190
191fn push_reasoning_detail(details: &mut Vec<Value>, detail: Value) {
195 if detail.get("type").and_then(Value::as_str) == Some("reasoning.encrypted")
196 && let Some(id) = detail.get("id").and_then(Value::as_str)
197 && let Some(announcement) = details.iter_mut().find(|existing| {
198 is_reasoning_id_announcement(existing)
199 && existing.get("id").and_then(Value::as_str) == Some(id)
200 })
201 {
202 *announcement = detail;
203 return;
204 }
205 details.push(detail);
206}
207
208impl AggLlmResponse {
209 pub fn into_stream(self) -> LlmResponseStream {
219 let mut chunks: Vec<LlmResponseChunk> = Vec::new();
220 chunks.push(LlmResponseChunk::MessageStart {
221 id: self.id,
222 model: self.model,
223 });
224 let mut tool_call_index = 0usize;
225 for (output_index, output) in self.outputs.into_iter().enumerate() {
226 for block in output.content {
227 match block {
228 ContentBlock::Text { text } => {
229 chunks.push(LlmResponseChunk::TextDelta {
230 index: output_index,
231 text,
232 });
233 }
234 ContentBlock::Reasoning { text, details, .. } => {
235 if !details.is_empty() {
236 chunks.push(LlmResponseChunk::ReasoningDetailsDelta {
237 index: output_index,
238 details,
239 text,
240 });
241 } else {
242 chunks.push(LlmResponseChunk::ReasoningDelta {
243 index: output_index,
244 text,
245 });
246 }
247 }
248 ContentBlock::ToolCall(tool) => {
249 let args = serde_json::to_string(&tool.arguments).unwrap_or_default();
250 chunks.push(LlmResponseChunk::ToolCallDelta {
251 index: tool_call_index,
252 id: Some(tool.id),
253 name: Some(tool.name),
254 arguments_delta: Some(args),
255 });
256 tool_call_index += 1;
257 }
258 _ => {}
260 }
261 }
262 chunks.push(LlmResponseChunk::MessageStop {
263 reason: output.stop_reason.and_then(|r| {
264 serde_json::to_value(r)
265 .ok()
266 .and_then(|v| v.as_str().map(String::from))
267 }),
268 });
269 }
270 chunks.push(LlmResponseChunk::Usage(self.usage));
271 Box::pin(futures::stream::iter(
272 chunks.into_iter().map(|chunk| Ok(chunk.into())),
273 ))
274 }
275}
276
277fn push_checked_chunk(
278 accumulator: &mut ResponseAccumulator,
279 chunk: LlmResponseChunk,
280) -> Result<(), LlmClientError> {
281 match chunk {
282 LlmResponseChunk::DecodeError { message } => {
283 Err(LlmClientError::ResponseTranslation(message))
284 }
285 LlmResponseChunk::StreamError { message } => Err(LlmClientError::UpstreamHttp {
286 status: MID_STREAM_UPSTREAM_STATUS,
287 body: message,
288 }),
289 chunk => {
290 accumulator.push(chunk);
291 Ok(())
292 }
293 }
294}
295
296#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
298pub enum LlmResponseChunk {
299 MessageStart {
301 id: Option<String>,
303 model: Option<String>,
305 },
306 TextDelta {
308 index: usize,
310 text: String,
312 },
313 ReasoningDelta {
315 index: usize,
317 text: String,
319 },
320 ReasoningDetailsDelta {
322 index: usize,
324 details: Vec<Value>,
326 text: String,
328 },
329 ToolCallDelta {
331 index: usize,
333 id: Option<String>,
335 name: Option<String>,
337 arguments_delta: Option<String>,
339 },
340 Usage(Usage),
342 MessageStop {
344 reason: Option<String>,
346 },
347 DecodeError {
349 message: String,
351 },
352 StreamError {
354 message: String,
356 },
357}
358
359#[derive(Default)]
374pub struct ResponseAccumulator {
375 id: Option<String>,
376 model: Option<String>,
377 text: String,
378 reasoning: Option<String>,
379 reasoning_details: Vec<Value>,
380 tool_calls: BTreeMap<usize, PartialToolCall>,
381 usage: Usage,
382 stop_reason: Option<StopReason>,
383}
384
385#[derive(Default)]
387struct PartialToolCall {
388 id: Option<String>,
389 name: Option<String>,
390 arguments: String,
391}
392
393impl ResponseAccumulator {
394 pub fn new() -> Self {
396 Self::default()
397 }
398
399 pub fn push(&mut self, chunk: LlmResponseChunk) {
402 match chunk {
403 LlmResponseChunk::MessageStart { id, model } => {
404 if id.is_some() {
405 self.id = id;
406 }
407 if model.is_some() {
408 self.model = model;
409 }
410 }
411 LlmResponseChunk::TextDelta { text, .. } => self.text.push_str(&text),
412 LlmResponseChunk::ReasoningDelta { text, .. } => {
413 self.reasoning
414 .get_or_insert_with(String::new)
415 .push_str(&text);
416 }
417 LlmResponseChunk::ReasoningDetailsDelta { details, text, .. } => {
418 for detail in details {
419 push_reasoning_detail(&mut self.reasoning_details, detail);
420 }
421 if !text.is_empty() {
422 self.reasoning
423 .get_or_insert_with(String::new)
424 .push_str(&text);
425 }
426 }
427 LlmResponseChunk::ToolCallDelta {
428 index,
429 id,
430 name,
431 arguments_delta,
432 } => {
433 let call = self.tool_calls.entry(index).or_default();
434 if id.is_some() {
435 call.id = id;
436 }
437 if name.is_some() {
438 call.name = name;
439 }
440 if let Some(delta) = arguments_delta {
441 call.arguments.push_str(&delta);
442 }
443 }
444 LlmResponseChunk::Usage(usage) => self.usage = usage,
445 LlmResponseChunk::MessageStop { reason } => {
446 self.stop_reason = Some(stop_reason_from_str(reason.as_deref()));
447 }
448 LlmResponseChunk::DecodeError { .. } | LlmResponseChunk::StreamError { .. } => {}
449 }
450 }
451
452 pub fn finish(self) -> AggLlmResponse {
455 let mut content = Vec::new();
456 if self.reasoning.is_some() || !self.reasoning_details.is_empty() {
457 content.push(ContentBlock::Reasoning {
458 text: self.reasoning.unwrap_or_default(),
459 signature: None,
460 details: self
462 .reasoning_details
463 .into_iter()
464 .filter(|detail| !is_reasoning_id_announcement(detail))
465 .collect(),
466 });
467 }
468 if !self.text.is_empty() {
469 content.push(ContentBlock::Text { text: self.text });
470 }
471 for call in self.tool_calls.into_values() {
472 content.push(ContentBlock::ToolCall(ToolCall {
473 id: call.id.unwrap_or_default(),
474 name: call.name.unwrap_or_default(),
475 arguments: parse_tool_arguments(&call.arguments),
476 }));
477 }
478 AggLlmResponse {
479 id: self.id,
480 model: self.model,
481 outputs: vec![ResponseOutput {
482 role: Role::Assistant,
483 content,
484 url_citations: Vec::new(),
485 stop_reason: self.stop_reason,
486 }],
487 usage: self.usage,
488 ..AggLlmResponse::default()
489 }
490 }
491}
492
493fn parse_tool_arguments(arguments: &str) -> Value {
496 if arguments.is_empty() {
497 return Value::Object(serde_json::Map::new());
498 }
499 serde_json::from_str(arguments).unwrap_or_else(|_| Value::String(arguments.to_string()))
500}
501
502fn stop_reason_from_str(reason: Option<&str>) -> StopReason {
505 match reason {
506 Some("length" | "max_tokens") => StopReason::MaxTokens,
507 Some("tool_calls" | "function_call" | "tool_use") => StopReason::ToolUse,
508 Some("content_filter" | "refusal") => StopReason::ContentFilter,
511 Some("stop" | "end_turn" | "stop_sequence") | None => StopReason::EndTurn,
512 Some(_) => StopReason::Unknown,
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use futures::executor::block_on;
519 use futures::stream;
520
521 use super::*;
522 use serde_json::json;
523
524 fn fold(chunks: Vec<LlmResponseChunk>) -> AggLlmResponse {
525 let mut accumulator = ResponseAccumulator::new();
526 for chunk in chunks {
527 accumulator.push(chunk);
528 }
529 accumulator.finish()
530 }
531
532 #[test]
533 fn folds_text_usage_and_stop_reason() {
534 let agg = fold(vec![
535 LlmResponseChunk::MessageStart {
536 id: Some("id1".to_string()),
537 model: Some("m".to_string()),
538 },
539 LlmResponseChunk::TextDelta {
540 index: 0,
541 text: "Hel".to_string(),
542 },
543 LlmResponseChunk::TextDelta {
544 index: 0,
545 text: "lo".to_string(),
546 },
547 LlmResponseChunk::Usage(Usage {
548 output_tokens: Some(2),
549 ..Usage::default()
550 }),
551 LlmResponseChunk::MessageStop {
552 reason: Some("length".to_string()),
553 },
554 ]);
555 assert_eq!(agg.id.as_deref(), Some("id1"));
556 assert_eq!(agg.model.as_deref(), Some("m"));
557 assert_eq!(agg.usage.output_tokens, Some(2));
558 assert_eq!(agg.outputs[0].stop_reason, Some(StopReason::MaxTokens));
559 assert_eq!(
560 agg.outputs[0].content,
561 vec![ContentBlock::Text {
562 text: "Hello".to_string()
563 }]
564 );
565 }
566
567 #[test]
568 fn stop_reason_spellings_normalize_the_same_streamed_as_buffered() {
569 for (reason, expected) in [
573 ("length", StopReason::MaxTokens),
574 ("max_tokens", StopReason::MaxTokens),
575 ("tool_calls", StopReason::ToolUse),
576 ("tool_use", StopReason::ToolUse),
577 ("content_filter", StopReason::ContentFilter),
578 ("refusal", StopReason::ContentFilter),
579 ("stop", StopReason::EndTurn),
580 ("end_turn", StopReason::EndTurn),
581 ] {
582 let agg = fold(vec![LlmResponseChunk::MessageStop {
583 reason: Some(reason.to_string()),
584 }]);
585
586 assert_eq!(
587 agg.outputs.first().and_then(|output| output.stop_reason),
588 Some(expected),
589 "stop reason {reason:?}"
590 );
591 }
592 }
593
594 #[test]
595 fn aggregates_normalized_chunks_inside_stream_event() {
596 let response = LlmResponse::Stream(Box::pin(stream::iter([Ok(
597 LlmResponseStreamEvent::preserved(
598 crate::WireFormat::OpenAiChat,
599 json!({
600 "choices": [{"delta": {"content": "hello"}}],
601 "system_fingerprint": "fp_exact"
602 }),
603 vec![LlmResponseChunk::TextDelta {
604 index: 0,
605 text: "hello".to_string(),
606 }],
607 ),
608 )])));
609 let aggregate = block_on(response.into_agg()).expect("stream event should aggregate");
610
611 assert_eq!(
612 aggregate.outputs[0].content,
613 vec![ContentBlock::Text {
614 text: "hello".to_string()
615 }]
616 );
617 }
618
619 #[test]
620 fn replacing_normalized_content_drops_preservation() {
621 let event = LlmResponseStreamEvent::preserved(
622 crate::WireFormat::OpenAiChat,
623 json!({"choices": [{"delta": {"content": "old"}}]}),
624 vec![LlmResponseChunk::TextDelta {
625 index: 0,
626 text: "old".to_string(),
627 }],
628 )
629 .replace_normalized(vec![LlmResponseChunk::TextDelta {
630 index: 0,
631 text: "new".to_string(),
632 }]);
633
634 assert!(event.preservation().is_none());
635 assert_eq!(
636 event.normalized(),
637 &[LlmResponseChunk::TextDelta {
638 index: 0,
639 text: "new".to_string(),
640 }]
641 );
642 }
643
644 #[test]
645 fn stream_errors_inside_preserved_events_remain_typed() {
646 let response = LlmResponse::Stream(Box::pin(stream::iter([Ok(
647 LlmResponseStreamEvent::preserved(
648 crate::WireFormat::OpenAiChat,
649 json!({"error": {"message": "provider failed"}}),
650 vec![LlmResponseChunk::StreamError {
651 message: "provider failed".to_string(),
652 }],
653 ),
654 )])));
655
656 let error = block_on(response.into_agg()).err();
657 assert!(matches!(
658 error,
659 Some(LlmClientError::UpstreamHttp {
660 status: MID_STREAM_UPSTREAM_STATUS,
661 ..
662 })
663 ));
664 }
665
666 #[test]
667 fn assembles_tool_calls_by_index() {
668 let agg = fold(vec![
670 LlmResponseChunk::ToolCallDelta {
671 index: 0,
672 id: Some("call_1".to_string()),
673 name: Some("lookup".to_string()),
674 arguments_delta: Some("{\"q\":".to_string()),
675 },
676 LlmResponseChunk::ToolCallDelta {
677 index: 0,
678 id: None,
679 name: None,
680 arguments_delta: Some("\"rust\"}".to_string()),
681 },
682 LlmResponseChunk::MessageStop {
683 reason: Some("tool_calls".to_string()),
684 },
685 ]);
686 assert_eq!(agg.outputs[0].stop_reason, Some(StopReason::ToolUse));
687 assert_eq!(
688 agg.outputs[0].content,
689 vec![ContentBlock::ToolCall(ToolCall {
690 id: "call_1".to_string(),
691 name: "lookup".to_string(),
692 arguments: json!({"q": "rust"}),
693 })]
694 );
695 }
696
697 #[test]
698 fn reasoning_precedes_text_in_content() {
699 let agg = fold(vec![
700 LlmResponseChunk::ReasoningDelta {
701 index: 0,
702 text: "think".to_string(),
703 },
704 LlmResponseChunk::TextDelta {
705 index: 0,
706 text: "answer".to_string(),
707 },
708 ]);
709 assert_eq!(
710 agg.outputs[0].content,
711 vec![
712 ContentBlock::Reasoning {
713 text: "think".to_string(),
714 signature: None,
715 details: Vec::new(),
716 },
717 ContentBlock::Text {
718 text: "answer".to_string(),
719 },
720 ]
721 );
722 }
723
724 #[test]
725 fn into_stream_round_trips_through_into_agg() {
726 let original = AggLlmResponse {
727 id: Some("id1".to_string()),
728 model: Some("m".to_string()),
729 outputs: vec![ResponseOutput {
730 role: Role::Assistant,
731 content: vec![ContentBlock::Text {
732 text: "hello".to_string(),
733 }],
734 url_citations: Vec::new(),
735 stop_reason: Some(StopReason::EndTurn),
736 }],
737 usage: Usage {
738 output_tokens: Some(3),
739 ..Usage::default()
740 },
741 ..AggLlmResponse::default()
742 };
743 let stream = LlmResponse::Stream(original.clone().into_stream());
744 let recovered = block_on(stream.into_agg()).expect("into_agg failed");
745 assert_eq!(recovered.id, original.id);
746 assert_eq!(recovered.model, original.model);
747 assert_eq!(recovered.usage.output_tokens, original.usage.output_tokens);
748 assert_eq!(
749 recovered.outputs[0].stop_reason,
750 original.outputs[0].stop_reason
751 );
752 assert_eq!(recovered.outputs[0].content, original.outputs[0].content);
753 }
754
755 #[test]
756 fn into_stream_retains_encrypted_reasoning_and_text() {
757 let details = vec![json!({
758 "type": "reasoning.encrypted",
759 "data": "opaque-encrypted-reasoning"
760 })];
761 let original = AggLlmResponse {
762 outputs: vec![ResponseOutput {
763 role: Role::Assistant,
764 content: vec![ContentBlock::Reasoning {
765 text: "fallback reasoning".to_string(),
766 signature: None,
767 details: details.clone(),
768 }],
769 url_citations: Vec::new(),
770 stop_reason: Some(StopReason::EndTurn),
771 }],
772 ..AggLlmResponse::default()
773 };
774
775 let recovered = block_on(LlmResponse::Stream(original.into_stream()).into_agg())
776 .expect("into_agg failed");
777 let ContentBlock::Reasoning {
778 text,
779 details: recovered_details,
780 ..
781 } = &recovered.outputs[0].content[0]
782 else {
783 panic!("expected reasoning block");
784 };
785 assert_eq!(text, "fallback reasoning");
786 assert_eq!(recovered_details, &details);
787 }
788
789 #[test]
790 fn into_agg_preserves_stream_item_error() {
791 let response = LlmResponse::Stream(Box::pin(stream::once(async {
792 Err(LlmClientError::Timeout {
793 source: Box::new(std::io::Error::other("timed out")),
794 })
795 })));
796
797 let Err(error) = block_on(response.into_agg()) else {
798 panic!("expected stream aggregation to fail");
799 };
800 assert!(matches!(error, LlmClientError::Timeout { .. }));
801 }
802}