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 url_citations: Vec::new(),
486 stop_reason: self.stop_reason,
487 }],
488 usage: self.usage,
489 ..AggLlmResponse::default()
490 }
491 }
492}
493
494fn parse_tool_arguments(arguments: &str) -> Value {
497 if arguments.is_empty() {
498 return Value::Object(serde_json::Map::new());
499 }
500 serde_json::from_str(arguments).unwrap_or_else(|_| Value::String(arguments.to_string()))
501}
502
503fn stop_reason_from_str(reason: Option<&str>) -> StopReason {
506 match reason {
507 Some("length" | "max_tokens") => StopReason::MaxTokens,
508 Some("tool_calls" | "function_call" | "tool_use") => StopReason::ToolUse,
509 Some("content_filter") => StopReason::ContentFilter,
510 Some("stop" | "end_turn" | "stop_sequence") | None => StopReason::EndTurn,
511 Some(_) => StopReason::Unknown,
512 }
513}
514
515#[cfg(test)]
516mod tests {
517 use futures::executor::block_on;
518 use futures::stream;
519
520 use super::*;
521 use serde_json::json;
522
523 fn fold(chunks: Vec<LlmResponseChunk>) -> AggLlmResponse {
524 let mut accumulator = ResponseAccumulator::new();
525 for chunk in chunks {
526 accumulator.push(chunk);
527 }
528 accumulator.finish()
529 }
530
531 #[test]
532 fn folds_text_usage_and_stop_reason() {
533 let agg = fold(vec![
534 LlmResponseChunk::MessageStart {
535 id: Some("id1".to_string()),
536 model: Some("m".to_string()),
537 },
538 LlmResponseChunk::TextDelta {
539 index: 0,
540 text: "Hel".to_string(),
541 },
542 LlmResponseChunk::TextDelta {
543 index: 0,
544 text: "lo".to_string(),
545 },
546 LlmResponseChunk::Usage(Usage {
547 output_tokens: Some(2),
548 ..Usage::default()
549 }),
550 LlmResponseChunk::MessageStop {
551 reason: Some("length".to_string()),
552 },
553 ]);
554 assert_eq!(agg.id.as_deref(), Some("id1"));
555 assert_eq!(agg.model.as_deref(), Some("m"));
556 assert_eq!(agg.usage.output_tokens, Some(2));
557 assert_eq!(agg.outputs[0].stop_reason, Some(StopReason::MaxTokens));
558 assert_eq!(
559 agg.outputs[0].content,
560 vec![ContentBlock::Text {
561 text: "Hello".to_string()
562 }]
563 );
564 }
565
566 #[test]
567 fn aggregates_normalized_chunks_inside_stream_event() {
568 let response = LlmResponse::Stream(Box::pin(stream::iter([Ok(
569 LlmResponseStreamEvent::preserved(
570 crate::WireFormat::OpenAiChat,
571 json!({
572 "choices": [{"delta": {"content": "hello"}}],
573 "system_fingerprint": "fp_exact"
574 }),
575 vec![LlmResponseChunk::TextDelta {
576 index: 0,
577 text: "hello".to_string(),
578 }],
579 ),
580 )])));
581 let aggregate = block_on(response.into_agg()).expect("stream event should aggregate");
582
583 assert_eq!(
584 aggregate.outputs[0].content,
585 vec![ContentBlock::Text {
586 text: "hello".to_string()
587 }]
588 );
589 }
590
591 #[test]
592 fn replacing_normalized_content_drops_preservation() {
593 let event = LlmResponseStreamEvent::preserved(
594 crate::WireFormat::OpenAiChat,
595 json!({"choices": [{"delta": {"content": "old"}}]}),
596 vec![LlmResponseChunk::TextDelta {
597 index: 0,
598 text: "old".to_string(),
599 }],
600 )
601 .replace_normalized(vec![LlmResponseChunk::TextDelta {
602 index: 0,
603 text: "new".to_string(),
604 }]);
605
606 assert!(event.preservation().is_none());
607 assert_eq!(
608 event.normalized(),
609 &[LlmResponseChunk::TextDelta {
610 index: 0,
611 text: "new".to_string(),
612 }]
613 );
614 }
615
616 #[test]
617 fn stream_errors_inside_preserved_events_remain_typed() {
618 let response = LlmResponse::Stream(Box::pin(stream::iter([Ok(
619 LlmResponseStreamEvent::preserved(
620 crate::WireFormat::OpenAiChat,
621 json!({"error": {"message": "provider failed"}}),
622 vec![LlmResponseChunk::StreamError {
623 message: "provider failed".to_string(),
624 }],
625 ),
626 )])));
627
628 let error = block_on(response.into_agg()).err();
629 assert!(matches!(
630 error,
631 Some(LlmClientError::UpstreamHttp {
632 status: MID_STREAM_UPSTREAM_STATUS,
633 ..
634 })
635 ));
636 }
637
638 #[test]
639 fn assembles_tool_calls_by_index() {
640 let agg = fold(vec![
642 LlmResponseChunk::ToolCallDelta {
643 index: 0,
644 id: Some("call_1".to_string()),
645 name: Some("lookup".to_string()),
646 arguments_delta: Some("{\"q\":".to_string()),
647 },
648 LlmResponseChunk::ToolCallDelta {
649 index: 0,
650 id: None,
651 name: None,
652 arguments_delta: Some("\"rust\"}".to_string()),
653 },
654 LlmResponseChunk::MessageStop {
655 reason: Some("tool_calls".to_string()),
656 },
657 ]);
658 assert_eq!(agg.outputs[0].stop_reason, Some(StopReason::ToolUse));
659 assert_eq!(
660 agg.outputs[0].content,
661 vec![ContentBlock::ToolCall(ToolCall {
662 id: "call_1".to_string(),
663 name: "lookup".to_string(),
664 arguments: json!({"q": "rust"}),
665 })]
666 );
667 }
668
669 #[test]
670 fn reasoning_precedes_text_in_content() {
671 let agg = fold(vec![
672 LlmResponseChunk::ReasoningDelta {
673 index: 0,
674 text: "think".to_string(),
675 },
676 LlmResponseChunk::TextDelta {
677 index: 0,
678 text: "answer".to_string(),
679 },
680 ]);
681 assert_eq!(
682 agg.outputs[0].content,
683 vec![
684 ContentBlock::Reasoning {
685 text: "think".to_string(),
686 signature: None,
687 details: Vec::new(),
688 },
689 ContentBlock::Text {
690 text: "answer".to_string(),
691 },
692 ]
693 );
694 }
695
696 #[test]
697 fn into_stream_round_trips_through_into_agg() {
698 let original = AggLlmResponse {
699 id: Some("id1".to_string()),
700 model: Some("m".to_string()),
701 outputs: vec![ResponseOutput {
702 role: Role::Assistant,
703 content: vec![ContentBlock::Text {
704 text: "hello".to_string(),
705 }],
706 url_citations: Vec::new(),
707 stop_reason: Some(StopReason::EndTurn),
708 }],
709 usage: Usage {
710 output_tokens: Some(3),
711 ..Usage::default()
712 },
713 ..AggLlmResponse::default()
714 };
715 let stream = LlmResponse::Stream(original.clone().into_stream());
716 let recovered = block_on(stream.into_agg()).expect("into_agg failed");
717 assert_eq!(recovered.id, original.id);
718 assert_eq!(recovered.model, original.model);
719 assert_eq!(recovered.usage.output_tokens, original.usage.output_tokens);
720 assert_eq!(
721 recovered.outputs[0].stop_reason,
722 original.outputs[0].stop_reason
723 );
724 assert_eq!(recovered.outputs[0].content, original.outputs[0].content);
725 }
726
727 #[test]
728 fn into_stream_retains_encrypted_reasoning_and_text() {
729 let details = vec![json!({
730 "type": "reasoning.encrypted",
731 "data": "opaque-encrypted-reasoning"
732 })];
733 let original = AggLlmResponse {
734 outputs: vec![ResponseOutput {
735 role: Role::Assistant,
736 content: vec![ContentBlock::Reasoning {
737 text: "fallback reasoning".to_string(),
738 signature: None,
739 details: details.clone(),
740 }],
741 url_citations: Vec::new(),
742 stop_reason: Some(StopReason::EndTurn),
743 }],
744 ..AggLlmResponse::default()
745 };
746
747 let recovered = block_on(LlmResponse::Stream(original.into_stream()).into_agg())
748 .expect("into_agg failed");
749 let ContentBlock::Reasoning {
750 text,
751 details: recovered_details,
752 ..
753 } = &recovered.outputs[0].content[0]
754 else {
755 panic!("expected reasoning block");
756 };
757 assert_eq!(text, "fallback reasoning");
758 assert_eq!(recovered_details, &details);
759 }
760
761 #[test]
762 fn into_agg_preserves_stream_item_error() {
763 let response = LlmResponse::Stream(Box::pin(stream::once(async {
764 Err(LlmClientError::Timeout {
765 source: Box::new(std::io::Error::other("timed out")),
766 })
767 })));
768
769 let Err(error) = block_on(response.into_agg()) else {
770 panic!("expected stream aggregation to fail");
771 };
772 assert!(matches!(error, LlmClientError::Timeout { .. }));
773 }
774}