1use std::collections::BTreeMap;
9use std::pin::Pin;
10
11use futures::{Stream, StreamExt};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15use crate::{
16 LlmClientError,
17 format::FormatId,
18 llm::{AggLlmResponse, ContentBlock, ResponseOutput, Role, StopReason, ToolCall, Usage},
19};
20
21const MID_STREAM_UPSTREAM_STATUS: u16 = 502;
25
26pub type LlmResponseStream =
28 Pin<Box<dyn Stream<Item = Result<LlmResponseStreamEvent, LlmClientError>> + Send>>;
29
30#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
32pub struct ProviderStreamEvent {
33 source: FormatId,
34 raw: Value,
35}
36
37impl ProviderStreamEvent {
38 pub fn source(&self) -> &FormatId {
40 &self.source
41 }
42
43 pub fn raw(&self) -> &Value {
45 &self.raw
46 }
47
48 pub fn into_parts(self) -> (FormatId, Value) {
50 (self.source, self.raw)
51 }
52}
53
54#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
59pub struct LlmResponseStreamEvent {
60 preservation: Option<ProviderStreamEvent>,
61 normalized: Vec<LlmResponseChunk>,
62}
63
64impl LlmResponseStreamEvent {
65 pub fn new(normalized: Vec<LlmResponseChunk>) -> Self {
67 Self {
68 preservation: None,
69 normalized,
70 }
71 }
72
73 pub fn preserved(
75 source: impl Into<FormatId>,
76 raw: Value,
77 normalized: Vec<LlmResponseChunk>,
78 ) -> Self {
79 Self {
80 preservation: Some(ProviderStreamEvent {
81 source: source.into(),
82 raw,
83 }),
84 normalized,
85 }
86 }
87
88 pub fn preservation(&self) -> Option<&ProviderStreamEvent> {
90 self.preservation.as_ref()
91 }
92
93 pub fn normalized(&self) -> &[LlmResponseChunk] {
95 &self.normalized
96 }
97
98 pub fn into_parts(self) -> (Option<ProviderStreamEvent>, Vec<LlmResponseChunk>) {
100 (self.preservation, self.normalized)
101 }
102
103 pub fn replace_normalized(self, normalized: Vec<LlmResponseChunk>) -> Self {
105 Self::new(normalized)
106 }
107}
108
109impl From<LlmResponseChunk> for LlmResponseStreamEvent {
110 fn from(chunk: LlmResponseChunk) -> Self {
111 Self::new(vec![chunk])
112 }
113}
114
115pub enum LlmResponse {
122 Stream(LlmResponseStream),
124 Agg(AggLlmResponse),
126}
127
128impl LlmResponse {
129 pub fn as_agg(&self) -> Option<&AggLlmResponse> {
131 match self {
132 LlmResponse::Agg(agg) => Some(agg),
133 LlmResponse::Stream(_) => None,
134 }
135 }
136
137 pub async fn into_agg(self) -> Result<AggLlmResponse, LlmClientError> {
143 match self {
144 LlmResponse::Agg(agg) => Ok(agg),
145 LlmResponse::Stream(mut stream) => {
146 let mut accumulator = ResponseAccumulator::new();
147 while let Some(item) = stream.next().await {
148 for chunk in item?.normalized {
149 push_checked_chunk(&mut accumulator, chunk)?;
150 }
151 }
152 Ok(accumulator.finish())
153 }
154 }
155 }
156
157 pub fn selected_model(&self) -> Option<&str> {
162 match self {
163 LlmResponse::Agg(agg) => agg.model.as_deref(),
164 LlmResponse::Stream(_) => None,
166 }
167 }
168}
169
170impl AggLlmResponse {
171 pub fn into_stream(self) -> LlmResponseStream {
181 let mut chunks: Vec<LlmResponseChunk> = Vec::new();
182 chunks.push(LlmResponseChunk::MessageStart {
183 id: self.id,
184 model: self.model,
185 });
186 let mut tool_call_index = 0usize;
187 for (output_index, output) in self.outputs.into_iter().enumerate() {
188 for block in output.content {
189 match block {
190 ContentBlock::Text { text } => {
191 chunks.push(LlmResponseChunk::TextDelta {
192 index: output_index,
193 text,
194 });
195 }
196 ContentBlock::Reasoning { text, .. } => {
197 chunks.push(LlmResponseChunk::ReasoningDelta {
198 index: output_index,
199 text,
200 });
201 }
202 ContentBlock::ToolCall(tool) => {
203 let args = serde_json::to_string(&tool.arguments).unwrap_or_default();
204 chunks.push(LlmResponseChunk::ToolCallDelta {
205 index: tool_call_index,
206 id: Some(tool.id),
207 name: Some(tool.name),
208 arguments_delta: Some(args),
209 });
210 tool_call_index += 1;
211 }
212 _ => {}
215 }
216 }
217 chunks.push(LlmResponseChunk::MessageStop {
218 reason: output.stop_reason.and_then(|r| {
219 serde_json::to_value(r)
220 .ok()
221 .and_then(|v| v.as_str().map(String::from))
222 }),
223 });
224 }
225 chunks.push(LlmResponseChunk::Usage(self.usage));
226 Box::pin(futures::stream::iter(
227 chunks.into_iter().map(|chunk| Ok(chunk.into())),
228 ))
229 }
230}
231
232fn push_checked_chunk(
233 accumulator: &mut ResponseAccumulator,
234 chunk: LlmResponseChunk,
235) -> Result<(), LlmClientError> {
236 match chunk {
237 LlmResponseChunk::DecodeError { message } => {
238 Err(LlmClientError::ResponseTranslation(message))
239 }
240 LlmResponseChunk::StreamError { message } => Err(LlmClientError::UpstreamHttp {
241 status: MID_STREAM_UPSTREAM_STATUS,
242 body: message,
243 }),
244 chunk => {
245 accumulator.push(chunk);
246 Ok(())
247 }
248 }
249}
250
251#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
253pub enum LlmResponseChunk {
254 MessageStart {
256 id: Option<String>,
258 model: Option<String>,
260 },
261 TextDelta {
263 index: usize,
265 text: String,
267 },
268 ReasoningDelta {
270 index: usize,
272 text: String,
274 },
275 ToolCallDelta {
277 index: usize,
279 id: Option<String>,
281 name: Option<String>,
283 arguments_delta: Option<String>,
285 },
286 Usage(Usage),
288 MessageStop {
290 reason: Option<String>,
292 },
293 DecodeError {
295 message: String,
297 },
298 StreamError {
300 message: String,
302 },
303}
304
305#[derive(Default)]
320pub struct ResponseAccumulator {
321 id: Option<String>,
322 model: Option<String>,
323 text: String,
324 reasoning: Option<String>,
325 tool_calls: BTreeMap<usize, PartialToolCall>,
326 usage: Usage,
327 stop_reason: Option<StopReason>,
328}
329
330#[derive(Default)]
332struct PartialToolCall {
333 id: Option<String>,
334 name: Option<String>,
335 arguments: String,
336}
337
338impl ResponseAccumulator {
339 pub fn new() -> Self {
341 Self::default()
342 }
343
344 pub fn push(&mut self, chunk: LlmResponseChunk) {
347 match chunk {
348 LlmResponseChunk::MessageStart { id, model } => {
349 if id.is_some() {
350 self.id = id;
351 }
352 if model.is_some() {
353 self.model = model;
354 }
355 }
356 LlmResponseChunk::TextDelta { text, .. } => self.text.push_str(&text),
357 LlmResponseChunk::ReasoningDelta { text, .. } => {
358 self.reasoning
359 .get_or_insert_with(String::new)
360 .push_str(&text);
361 }
362 LlmResponseChunk::ToolCallDelta {
363 index,
364 id,
365 name,
366 arguments_delta,
367 } => {
368 let call = self.tool_calls.entry(index).or_default();
369 if id.is_some() {
370 call.id = id;
371 }
372 if name.is_some() {
373 call.name = name;
374 }
375 if let Some(delta) = arguments_delta {
376 call.arguments.push_str(&delta);
377 }
378 }
379 LlmResponseChunk::Usage(usage) => self.usage = usage,
380 LlmResponseChunk::MessageStop { reason } => {
381 self.stop_reason = Some(stop_reason_from_str(reason.as_deref()));
382 }
383 LlmResponseChunk::DecodeError { .. } | LlmResponseChunk::StreamError { .. } => {}
384 }
385 }
386
387 pub fn finish(self) -> AggLlmResponse {
390 let mut content = Vec::new();
391 if let Some(reasoning) = self.reasoning {
392 content.push(ContentBlock::Reasoning {
393 text: reasoning,
394 signature: None,
395 });
396 }
397 if !self.text.is_empty() {
398 content.push(ContentBlock::Text { text: self.text });
399 }
400 for call in self.tool_calls.into_values() {
401 content.push(ContentBlock::ToolCall(ToolCall {
402 id: call.id.unwrap_or_default(),
403 name: call.name.unwrap_or_default(),
404 arguments: parse_tool_arguments(&call.arguments),
405 }));
406 }
407 AggLlmResponse {
408 id: self.id,
409 model: self.model,
410 outputs: vec![ResponseOutput {
411 role: Role::Assistant,
412 content,
413 stop_reason: self.stop_reason,
414 }],
415 usage: self.usage,
416 ..AggLlmResponse::default()
417 }
418 }
419}
420
421fn parse_tool_arguments(arguments: &str) -> Value {
424 if arguments.is_empty() {
425 return Value::Object(serde_json::Map::new());
426 }
427 serde_json::from_str(arguments).unwrap_or_else(|_| Value::String(arguments.to_string()))
428}
429
430fn stop_reason_from_str(reason: Option<&str>) -> StopReason {
433 match reason {
434 Some("length" | "max_tokens") => StopReason::MaxTokens,
435 Some("tool_calls" | "function_call" | "tool_use") => StopReason::ToolUse,
436 Some("content_filter") => StopReason::ContentFilter,
437 Some("stop" | "end_turn" | "stop_sequence") | None => StopReason::EndTurn,
438 Some(_) => StopReason::Unknown,
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use futures::executor::block_on;
445 use futures::stream;
446
447 use super::*;
448 use serde_json::json;
449
450 fn fold(chunks: Vec<LlmResponseChunk>) -> AggLlmResponse {
451 let mut accumulator = ResponseAccumulator::new();
452 for chunk in chunks {
453 accumulator.push(chunk);
454 }
455 accumulator.finish()
456 }
457
458 #[test]
459 fn folds_text_usage_and_stop_reason() {
460 let agg = fold(vec![
461 LlmResponseChunk::MessageStart {
462 id: Some("id1".to_string()),
463 model: Some("m".to_string()),
464 },
465 LlmResponseChunk::TextDelta {
466 index: 0,
467 text: "Hel".to_string(),
468 },
469 LlmResponseChunk::TextDelta {
470 index: 0,
471 text: "lo".to_string(),
472 },
473 LlmResponseChunk::Usage(Usage {
474 output_tokens: Some(2),
475 ..Usage::default()
476 }),
477 LlmResponseChunk::MessageStop {
478 reason: Some("length".to_string()),
479 },
480 ]);
481 assert_eq!(agg.id.as_deref(), Some("id1"));
482 assert_eq!(agg.model.as_deref(), Some("m"));
483 assert_eq!(agg.usage.output_tokens, Some(2));
484 assert_eq!(agg.outputs[0].stop_reason, Some(StopReason::MaxTokens));
485 assert_eq!(
486 agg.outputs[0].content,
487 vec![ContentBlock::Text {
488 text: "Hello".to_string()
489 }]
490 );
491 }
492
493 #[test]
494 fn aggregates_normalized_chunks_inside_stream_event() {
495 let response = LlmResponse::Stream(Box::pin(stream::iter([Ok(
496 LlmResponseStreamEvent::preserved(
497 crate::WireFormat::OpenAiChat,
498 json!({
499 "choices": [{"delta": {"content": "hello"}}],
500 "system_fingerprint": "fp_exact"
501 }),
502 vec![LlmResponseChunk::TextDelta {
503 index: 0,
504 text: "hello".to_string(),
505 }],
506 ),
507 )])));
508 let aggregate = block_on(response.into_agg()).expect("stream event should aggregate");
509
510 assert_eq!(
511 aggregate.outputs[0].content,
512 vec![ContentBlock::Text {
513 text: "hello".to_string()
514 }]
515 );
516 }
517
518 #[test]
519 fn replacing_normalized_content_drops_preservation() {
520 let event = LlmResponseStreamEvent::preserved(
521 crate::WireFormat::OpenAiChat,
522 json!({"choices": [{"delta": {"content": "old"}}]}),
523 vec![LlmResponseChunk::TextDelta {
524 index: 0,
525 text: "old".to_string(),
526 }],
527 )
528 .replace_normalized(vec![LlmResponseChunk::TextDelta {
529 index: 0,
530 text: "new".to_string(),
531 }]);
532
533 assert!(event.preservation().is_none());
534 assert_eq!(
535 event.normalized(),
536 &[LlmResponseChunk::TextDelta {
537 index: 0,
538 text: "new".to_string(),
539 }]
540 );
541 }
542
543 #[test]
544 fn stream_errors_inside_preserved_events_remain_typed() {
545 let response = LlmResponse::Stream(Box::pin(stream::iter([Ok(
546 LlmResponseStreamEvent::preserved(
547 crate::WireFormat::OpenAiChat,
548 json!({"error": {"message": "provider failed"}}),
549 vec![LlmResponseChunk::StreamError {
550 message: "provider failed".to_string(),
551 }],
552 ),
553 )])));
554
555 let error = block_on(response.into_agg()).err();
556 assert!(matches!(
557 error,
558 Some(LlmClientError::UpstreamHttp {
559 status: MID_STREAM_UPSTREAM_STATUS,
560 ..
561 })
562 ));
563 }
564
565 #[test]
566 fn assembles_tool_calls_by_index() {
567 let agg = fold(vec![
569 LlmResponseChunk::ToolCallDelta {
570 index: 0,
571 id: Some("call_1".to_string()),
572 name: Some("lookup".to_string()),
573 arguments_delta: Some("{\"q\":".to_string()),
574 },
575 LlmResponseChunk::ToolCallDelta {
576 index: 0,
577 id: None,
578 name: None,
579 arguments_delta: Some("\"rust\"}".to_string()),
580 },
581 LlmResponseChunk::MessageStop {
582 reason: Some("tool_calls".to_string()),
583 },
584 ]);
585 assert_eq!(agg.outputs[0].stop_reason, Some(StopReason::ToolUse));
586 assert_eq!(
587 agg.outputs[0].content,
588 vec![ContentBlock::ToolCall(ToolCall {
589 id: "call_1".to_string(),
590 name: "lookup".to_string(),
591 arguments: json!({"q": "rust"}),
592 })]
593 );
594 }
595
596 #[test]
597 fn reasoning_precedes_text_in_content() {
598 let agg = fold(vec![
599 LlmResponseChunk::ReasoningDelta {
600 index: 0,
601 text: "think".to_string(),
602 },
603 LlmResponseChunk::TextDelta {
604 index: 0,
605 text: "answer".to_string(),
606 },
607 ]);
608 assert_eq!(
609 agg.outputs[0].content,
610 vec![
611 ContentBlock::Reasoning {
612 text: "think".to_string(),
613 signature: None,
614 },
615 ContentBlock::Text {
616 text: "answer".to_string(),
617 },
618 ]
619 );
620 }
621
622 #[test]
623 fn into_stream_round_trips_through_into_agg() {
624 let original = AggLlmResponse {
625 id: Some("id1".to_string()),
626 model: Some("m".to_string()),
627 outputs: vec![ResponseOutput {
628 role: Role::Assistant,
629 content: vec![ContentBlock::Text {
630 text: "hello".to_string(),
631 }],
632 stop_reason: Some(StopReason::EndTurn),
633 }],
634 usage: Usage {
635 output_tokens: Some(3),
636 ..Usage::default()
637 },
638 ..AggLlmResponse::default()
639 };
640 let stream = LlmResponse::Stream(original.clone().into_stream());
641 let recovered = block_on(stream.into_agg()).expect("into_agg failed");
642 assert_eq!(recovered.id, original.id);
643 assert_eq!(recovered.model, original.model);
644 assert_eq!(recovered.usage.output_tokens, original.usage.output_tokens);
645 assert_eq!(
646 recovered.outputs[0].stop_reason,
647 original.outputs[0].stop_reason
648 );
649 assert_eq!(recovered.outputs[0].content, original.outputs[0].content);
650 }
651
652 #[test]
653 fn into_agg_preserves_stream_item_error() {
654 let response = LlmResponse::Stream(Box::pin(stream::once(async {
655 Err(LlmClientError::Timeout {
656 source: Box::new(std::io::Error::other("timed out")),
657 })
658 })));
659
660 let Err(error) = block_on(response.into_agg()) else {
661 panic!("expected stream aggregation to fail");
662 };
663 assert!(matches!(error, LlmClientError::Timeout { .. }));
664 }
665}