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") => 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 url_citations: Vec::new(),
706 stop_reason: Some(StopReason::EndTurn),
707 }],
708 usage: Usage {
709 output_tokens: Some(3),
710 ..Usage::default()
711 },
712 ..AggLlmResponse::default()
713 };
714 let stream = LlmResponse::Stream(original.clone().into_stream());
715 let recovered = block_on(stream.into_agg()).expect("into_agg failed");
716 assert_eq!(recovered.id, original.id);
717 assert_eq!(recovered.model, original.model);
718 assert_eq!(recovered.usage.output_tokens, original.usage.output_tokens);
719 assert_eq!(
720 recovered.outputs[0].stop_reason,
721 original.outputs[0].stop_reason
722 );
723 assert_eq!(recovered.outputs[0].content, original.outputs[0].content);
724 }
725
726 #[test]
727 fn into_stream_retains_encrypted_reasoning_and_text() {
728 let details = vec![json!({
729 "type": "reasoning.encrypted",
730 "data": "opaque-encrypted-reasoning"
731 })];
732 let original = AggLlmResponse {
733 outputs: vec![ResponseOutput {
734 role: Role::Assistant,
735 content: vec![ContentBlock::Reasoning {
736 text: "fallback reasoning".to_string(),
737 signature: None,
738 details: details.clone(),
739 }],
740 url_citations: Vec::new(),
741 stop_reason: Some(StopReason::EndTurn),
742 }],
743 ..AggLlmResponse::default()
744 };
745
746 let recovered = block_on(LlmResponse::Stream(original.into_stream()).into_agg())
747 .expect("into_agg failed");
748 let ContentBlock::Reasoning {
749 text,
750 details: recovered_details,
751 ..
752 } = &recovered.outputs[0].content[0]
753 else {
754 panic!("expected reasoning block");
755 };
756 assert_eq!(text, "fallback reasoning");
757 assert_eq!(recovered_details, &details);
758 }
759
760 #[test]
761 fn into_agg_preserves_stream_item_error() {
762 let response = LlmResponse::Stream(Box::pin(stream::once(async {
763 Err(LlmClientError::Timeout {
764 source: Box::new(std::io::Error::other("timed out")),
765 })
766 })));
767
768 let Err(error) = block_on(response.into_agg()) else {
769 panic!("expected stream aggregation to fail");
770 };
771 assert!(matches!(error, LlmClientError::Timeout { .. }));
772 }
773}