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 _ => {}
261 }
262 }
263 chunks.push(LlmResponseChunk::MessageStop {
264 reason: output.stop_reason.and_then(|r| {
265 serde_json::to_value(r)
266 .ok()
267 .and_then(|v| v.as_str().map(String::from))
268 }),
269 });
270 }
271 chunks.push(LlmResponseChunk::Usage(self.usage));
272 Box::pin(futures::stream::iter(
273 chunks.into_iter().map(|chunk| Ok(chunk.into())),
274 ))
275 }
276}
277
278fn push_checked_chunk(
279 accumulator: &mut ResponseAccumulator,
280 chunk: LlmResponseChunk,
281) -> Result<(), LlmClientError> {
282 match chunk {
283 LlmResponseChunk::DecodeError { message } => {
284 Err(LlmClientError::ResponseTranslation(message))
285 }
286 LlmResponseChunk::StreamError { message } => Err(LlmClientError::UpstreamHttp {
287 status: MID_STREAM_UPSTREAM_STATUS,
288 body: message,
289 }),
290 chunk => {
291 accumulator.push(chunk);
292 Ok(())
293 }
294 }
295}
296
297#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
299pub enum LlmResponseChunk {
300 MessageStart {
302 id: Option<String>,
304 model: Option<String>,
306 },
307 TextDelta {
309 index: usize,
311 text: String,
313 },
314 ReasoningDelta {
316 index: usize,
318 text: String,
320 },
321 ReasoningDetailsDelta {
323 index: usize,
325 details: Vec<Value>,
327 text: String,
329 },
330 ToolCallDelta {
332 index: usize,
334 id: Option<String>,
336 name: Option<String>,
338 arguments_delta: Option<String>,
340 },
341 Usage(Usage),
343 MessageStop {
345 reason: Option<String>,
347 },
348 DecodeError {
350 message: String,
352 },
353 StreamError {
355 message: String,
357 },
358}
359
360#[derive(Default)]
375pub struct ResponseAccumulator {
376 id: Option<String>,
377 model: Option<String>,
378 text: String,
379 reasoning: Option<String>,
380 reasoning_details: Vec<Value>,
381 tool_calls: BTreeMap<usize, PartialToolCall>,
382 usage: Usage,
383 stop_reason: Option<StopReason>,
384}
385
386#[derive(Default)]
388struct PartialToolCall {
389 id: Option<String>,
390 name: Option<String>,
391 arguments: String,
392}
393
394impl ResponseAccumulator {
395 pub fn new() -> Self {
397 Self::default()
398 }
399
400 pub fn push(&mut self, chunk: LlmResponseChunk) {
403 match chunk {
404 LlmResponseChunk::MessageStart { id, model } => {
405 if id.is_some() {
406 self.id = id;
407 }
408 if model.is_some() {
409 self.model = model;
410 }
411 }
412 LlmResponseChunk::TextDelta { text, .. } => self.text.push_str(&text),
413 LlmResponseChunk::ReasoningDelta { text, .. } => {
414 self.reasoning
415 .get_or_insert_with(String::new)
416 .push_str(&text);
417 }
418 LlmResponseChunk::ReasoningDetailsDelta { details, text, .. } => {
419 for detail in details {
420 push_reasoning_detail(&mut self.reasoning_details, detail);
421 }
422 if !text.is_empty() {
423 self.reasoning
424 .get_or_insert_with(String::new)
425 .push_str(&text);
426 }
427 }
428 LlmResponseChunk::ToolCallDelta {
429 index,
430 id,
431 name,
432 arguments_delta,
433 } => {
434 let call = self.tool_calls.entry(index).or_default();
435 if id.is_some() {
436 call.id = id;
437 }
438 if name.is_some() {
439 call.name = name;
440 }
441 if let Some(delta) = arguments_delta {
442 call.arguments.push_str(&delta);
443 }
444 }
445 LlmResponseChunk::Usage(usage) => self.usage = usage,
446 LlmResponseChunk::MessageStop { reason } => {
447 self.stop_reason = Some(stop_reason_from_str(reason.as_deref()));
448 }
449 LlmResponseChunk::DecodeError { .. } | LlmResponseChunk::StreamError { .. } => {}
450 }
451 }
452
453 pub fn finish(self) -> AggLlmResponse {
456 let mut content = Vec::new();
457 if self.reasoning.is_some() || !self.reasoning_details.is_empty() {
458 content.push(ContentBlock::Reasoning {
459 text: self.reasoning.unwrap_or_default(),
460 signature: None,
461 details: self
463 .reasoning_details
464 .into_iter()
465 .filter(|detail| !is_reasoning_id_announcement(detail))
466 .collect(),
467 });
468 }
469 if !self.text.is_empty() {
470 content.push(ContentBlock::Text { text: self.text });
471 }
472 for call in self.tool_calls.into_values() {
473 content.push(ContentBlock::ToolCall(ToolCall {
474 id: call.id.unwrap_or_default(),
475 name: call.name.unwrap_or_default(),
476 arguments: parse_tool_arguments(&call.arguments),
477 }));
478 }
479 AggLlmResponse {
480 id: self.id,
481 model: self.model,
482 outputs: vec![ResponseOutput {
483 role: Role::Assistant,
484 content,
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") => StopReason::ContentFilter,
509 Some("stop" | "end_turn" | "stop_sequence") | None => StopReason::EndTurn,
510 Some(_) => StopReason::Unknown,
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use futures::executor::block_on;
517 use futures::stream;
518
519 use super::*;
520 use serde_json::json;
521
522 fn fold(chunks: Vec<LlmResponseChunk>) -> AggLlmResponse {
523 let mut accumulator = ResponseAccumulator::new();
524 for chunk in chunks {
525 accumulator.push(chunk);
526 }
527 accumulator.finish()
528 }
529
530 #[test]
531 fn folds_text_usage_and_stop_reason() {
532 let agg = fold(vec![
533 LlmResponseChunk::MessageStart {
534 id: Some("id1".to_string()),
535 model: Some("m".to_string()),
536 },
537 LlmResponseChunk::TextDelta {
538 index: 0,
539 text: "Hel".to_string(),
540 },
541 LlmResponseChunk::TextDelta {
542 index: 0,
543 text: "lo".to_string(),
544 },
545 LlmResponseChunk::Usage(Usage {
546 output_tokens: Some(2),
547 ..Usage::default()
548 }),
549 LlmResponseChunk::MessageStop {
550 reason: Some("length".to_string()),
551 },
552 ]);
553 assert_eq!(agg.id.as_deref(), Some("id1"));
554 assert_eq!(agg.model.as_deref(), Some("m"));
555 assert_eq!(agg.usage.output_tokens, Some(2));
556 assert_eq!(agg.outputs[0].stop_reason, Some(StopReason::MaxTokens));
557 assert_eq!(
558 agg.outputs[0].content,
559 vec![ContentBlock::Text {
560 text: "Hello".to_string()
561 }]
562 );
563 }
564
565 #[test]
566 fn aggregates_normalized_chunks_inside_stream_event() {
567 let response = LlmResponse::Stream(Box::pin(stream::iter([Ok(
568 LlmResponseStreamEvent::preserved(
569 crate::WireFormat::OpenAiChat,
570 json!({
571 "choices": [{"delta": {"content": "hello"}}],
572 "system_fingerprint": "fp_exact"
573 }),
574 vec![LlmResponseChunk::TextDelta {
575 index: 0,
576 text: "hello".to_string(),
577 }],
578 ),
579 )])));
580 let aggregate = block_on(response.into_agg()).expect("stream event should aggregate");
581
582 assert_eq!(
583 aggregate.outputs[0].content,
584 vec![ContentBlock::Text {
585 text: "hello".to_string()
586 }]
587 );
588 }
589
590 #[test]
591 fn replacing_normalized_content_drops_preservation() {
592 let event = LlmResponseStreamEvent::preserved(
593 crate::WireFormat::OpenAiChat,
594 json!({"choices": [{"delta": {"content": "old"}}]}),
595 vec![LlmResponseChunk::TextDelta {
596 index: 0,
597 text: "old".to_string(),
598 }],
599 )
600 .replace_normalized(vec![LlmResponseChunk::TextDelta {
601 index: 0,
602 text: "new".to_string(),
603 }]);
604
605 assert!(event.preservation().is_none());
606 assert_eq!(
607 event.normalized(),
608 &[LlmResponseChunk::TextDelta {
609 index: 0,
610 text: "new".to_string(),
611 }]
612 );
613 }
614
615 #[test]
616 fn stream_errors_inside_preserved_events_remain_typed() {
617 let response = LlmResponse::Stream(Box::pin(stream::iter([Ok(
618 LlmResponseStreamEvent::preserved(
619 crate::WireFormat::OpenAiChat,
620 json!({"error": {"message": "provider failed"}}),
621 vec![LlmResponseChunk::StreamError {
622 message: "provider failed".to_string(),
623 }],
624 ),
625 )])));
626
627 let error = block_on(response.into_agg()).err();
628 assert!(matches!(
629 error,
630 Some(LlmClientError::UpstreamHttp {
631 status: MID_STREAM_UPSTREAM_STATUS,
632 ..
633 })
634 ));
635 }
636
637 #[test]
638 fn assembles_tool_calls_by_index() {
639 let agg = fold(vec![
641 LlmResponseChunk::ToolCallDelta {
642 index: 0,
643 id: Some("call_1".to_string()),
644 name: Some("lookup".to_string()),
645 arguments_delta: Some("{\"q\":".to_string()),
646 },
647 LlmResponseChunk::ToolCallDelta {
648 index: 0,
649 id: None,
650 name: None,
651 arguments_delta: Some("\"rust\"}".to_string()),
652 },
653 LlmResponseChunk::MessageStop {
654 reason: Some("tool_calls".to_string()),
655 },
656 ]);
657 assert_eq!(agg.outputs[0].stop_reason, Some(StopReason::ToolUse));
658 assert_eq!(
659 agg.outputs[0].content,
660 vec![ContentBlock::ToolCall(ToolCall {
661 id: "call_1".to_string(),
662 name: "lookup".to_string(),
663 arguments: json!({"q": "rust"}),
664 })]
665 );
666 }
667
668 #[test]
669 fn reasoning_precedes_text_in_content() {
670 let agg = fold(vec![
671 LlmResponseChunk::ReasoningDelta {
672 index: 0,
673 text: "think".to_string(),
674 },
675 LlmResponseChunk::TextDelta {
676 index: 0,
677 text: "answer".to_string(),
678 },
679 ]);
680 assert_eq!(
681 agg.outputs[0].content,
682 vec![
683 ContentBlock::Reasoning {
684 text: "think".to_string(),
685 signature: None,
686 details: Vec::new(),
687 },
688 ContentBlock::Text {
689 text: "answer".to_string(),
690 },
691 ]
692 );
693 }
694
695 #[test]
696 fn into_stream_round_trips_through_into_agg() {
697 let original = AggLlmResponse {
698 id: Some("id1".to_string()),
699 model: Some("m".to_string()),
700 outputs: vec![ResponseOutput {
701 role: Role::Assistant,
702 content: vec![ContentBlock::Text {
703 text: "hello".to_string(),
704 }],
705 stop_reason: Some(StopReason::EndTurn),
706 }],
707 usage: Usage {
708 output_tokens: Some(3),
709 ..Usage::default()
710 },
711 ..AggLlmResponse::default()
712 };
713 let stream = LlmResponse::Stream(original.clone().into_stream());
714 let recovered = block_on(stream.into_agg()).expect("into_agg failed");
715 assert_eq!(recovered.id, original.id);
716 assert_eq!(recovered.model, original.model);
717 assert_eq!(recovered.usage.output_tokens, original.usage.output_tokens);
718 assert_eq!(
719 recovered.outputs[0].stop_reason,
720 original.outputs[0].stop_reason
721 );
722 assert_eq!(recovered.outputs[0].content, original.outputs[0].content);
723 }
724
725 #[test]
726 fn into_stream_retains_encrypted_reasoning_and_text() {
727 let details = vec![json!({
728 "type": "reasoning.encrypted",
729 "data": "opaque-encrypted-reasoning"
730 })];
731 let original = AggLlmResponse {
732 outputs: vec![ResponseOutput {
733 role: Role::Assistant,
734 content: vec![ContentBlock::Reasoning {
735 text: "fallback reasoning".to_string(),
736 signature: None,
737 details: details.clone(),
738 }],
739 stop_reason: Some(StopReason::EndTurn),
740 }],
741 ..AggLlmResponse::default()
742 };
743
744 let recovered = block_on(LlmResponse::Stream(original.into_stream()).into_agg())
745 .expect("into_agg failed");
746 let ContentBlock::Reasoning {
747 text,
748 details: recovered_details,
749 ..
750 } = &recovered.outputs[0].content[0]
751 else {
752 panic!("expected reasoning block");
753 };
754 assert_eq!(text, "fallback reasoning");
755 assert_eq!(recovered_details, &details);
756 }
757
758 #[test]
759 fn into_agg_preserves_stream_item_error() {
760 let response = LlmResponse::Stream(Box::pin(stream::once(async {
761 Err(LlmClientError::Timeout {
762 source: Box::new(std::io::Error::other("timed out")),
763 })
764 })));
765
766 let Err(error) = block_on(response.into_agg()) else {
767 panic!("expected stream aggregation to fail");
768 };
769 assert!(matches!(error, LlmClientError::Timeout { .. }));
770 }
771}