1use crate::error_display;
4use crate::provider::{LLMError, LLMResponse, LLMStreamEvent, ToolCall};
5use crate::providers::common::{extract_reasoning_text_from_serialized_details, map_finish_reason_common};
6use crate::providers::split_reasoning_from_text;
7use serde_json::{Map, Value};
8
9use super::sse::find_sse_boundary;
10use super::{ReasoningBuffer, TagStreamSanitizer, Utf8StreamDecoder, extract_data_payload, find_sse_boundary_bytes};
11
12#[derive(Debug, thiserror::Error)]
13pub enum StreamAssemblyError {
14 #[error("missing field `{0}` in stream payload")]
15 MissingField(&'static str),
16 #[error("invalid stream payload: {0}")]
17 InvalidPayload(String),
18}
19
20impl StreamAssemblyError {
21 #[cold]
22 pub(crate) fn into_llm_error(self, provider: &str) -> LLMError {
23 let message = self.to_string();
24 let formatted = error_display::format_llm_error(provider, &message);
25 LLMError::Provider { message: formatted, metadata: None }
26 }
27}
28
29pub trait StreamTelemetry: Send + Sync {
30 fn on_content_delta(&self, _delta: &str) {}
31 fn on_reasoning_delta(&self, _delta: &str) {}
32 fn on_reasoning_stage(&self, _stage: &str) {}
33 fn on_tool_call_delta(&self) {}
34}
35
36#[derive(Default)]
37pub struct NoopStreamTelemetry;
38
39impl StreamTelemetry for NoopStreamTelemetry {}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum StreamFragment {
43 Content(String),
44 Reasoning(String),
45}
46
47#[derive(Default, Debug)]
48pub struct StreamDelta {
49 fragments: Vec<StreamFragment>,
50}
51
52impl StreamDelta {
53 pub(crate) fn push_content(&mut self, text: &str) {
54 if text.is_empty() {
55 return;
56 }
57
58 match self.fragments.last_mut() {
59 Some(StreamFragment::Content(existing)) => existing.push_str(text),
60 _ => self.fragments.push(StreamFragment::Content(text.to_string())),
61 }
62 }
63
64 pub(crate) fn push_reasoning(&mut self, text: &str) {
65 if text.is_empty() {
66 return;
67 }
68
69 match self.fragments.last_mut() {
70 Some(StreamFragment::Reasoning(existing)) => existing.push_str(text),
71 _ => self.fragments.push(StreamFragment::Reasoning(text.to_string())),
72 }
73 }
74
75 pub(crate) fn is_empty(&self) -> bool {
76 self.fragments.is_empty()
77 }
78
79 pub(crate) fn into_fragments(self) -> Vec<StreamFragment> {
80 self.fragments
81 }
82
83 pub fn extend(&mut self, other: StreamDelta) {
84 self.fragments.extend(other.fragments);
85 }
86}
87
88pub(crate) fn generate_tool_call_id() -> String {
95 format!("call_{}", uuid::Uuid::new_v4().simple())
96}
97
98#[derive(Default, Clone)]
99pub struct ToolCallBuilder {
100 id: Option<String>,
101 namespace: Option<String>,
102 name: Option<String>,
103 arguments: String,
104}
105
106impl ToolCallBuilder {
107 pub(crate) fn apply_delta(&mut self, delta: &Value) {
108 if let Some(id) = delta.get("id").and_then(|value| value.as_str()) {
109 self.id = Some(id.to_string());
110 }
111
112 if let Some(namespace) = delta.get("namespace").and_then(|value| value.as_str()) {
113 self.namespace = Some(namespace.to_string());
114 }
115
116 if let Some(function) = delta.get("function") {
117 if let Some(namespace) = function.get("namespace").and_then(|value| value.as_str()) {
118 self.namespace = Some(namespace.to_string());
119 }
120
121 if let Some(name) = function.get("name").and_then(|value| value.as_str()) {
122 self.name = Some(name.to_string());
123 }
124
125 if let Some(arguments_value) = function.get("arguments") {
126 if let Some(arguments) = arguments_value.as_str() {
127 self.arguments.push_str(arguments);
128 } else if arguments_value.is_object() || arguments_value.is_array() {
129 self.arguments.push_str(&arguments_value.to_string());
130 }
131 }
132 }
133 }
134
135 fn finalize(self) -> Option<ToolCall> {
136 let name = self.name?;
137 let id = self.id.unwrap_or_else(generate_tool_call_id);
138 let arguments = if self.arguments.is_empty() {
139 "{}".to_string()
140 } else {
141 self.arguments
142 };
143
144 Some(ToolCall::function_with_namespace(id, self.namespace, name, arguments))
145 }
146}
147
148fn update_tool_calls(builders: &mut Vec<ToolCallBuilder>, deltas: &[Value]) {
149 for (position, delta) in deltas.iter().enumerate() {
150 let index = delta
151 .get("index")
152 .and_then(|value| value.as_u64())
153 .map(|value| value as usize)
154 .unwrap_or(position);
155
156 if builders.len() <= index {
157 builders.resize_with(index + 1, ToolCallBuilder::default);
158 }
159 let Some(builder) = builders.get_mut(index) else {
160 continue;
161 };
162
163 builder.apply_delta(delta);
164 }
165}
166
167fn finalize_tool_calls(builders: Vec<ToolCallBuilder>) -> Option<Vec<ToolCall>> {
168 let calls: Vec<ToolCall> = builders.into_iter().filter_map(ToolCallBuilder::finalize).collect();
169
170 (!calls.is_empty()).then_some(calls)
171}
172
173pub(crate) struct StreamAggregator {
175 model: String,
176 pub(crate) content: String,
177 pub(crate) reasoning: String,
178 reasoning_details: Vec<String>,
179 reasoning_buffer: ReasoningBuffer,
180 pub(crate) tool_builders: Vec<ToolCallBuilder>,
181 pub(crate) usage: Option<crate::provider::Usage>,
182 finish_reason: crate::provider::FinishReason,
183 pub(crate) sanitizer: TagStreamSanitizer,
184 pub(crate) compaction: Option<String>,
185}
186
187#[derive(Clone, Copy, Debug, PartialEq, Eq)]
188pub enum OpenAiDeltaOrder {
189 ReasoningFirst,
190 ContentFirst,
191}
192
193fn emit_reasoning_delta(
194 aggregator: &mut StreamAggregator,
195 tx: &tokio::sync::mpsc::UnboundedSender<Result<LLMStreamEvent, LLMError>>,
196 delta: &Value,
197 reasoning_fields: &[&'static str],
198) {
199 let Some(reasoning) = reasoning_fields
204 .iter()
205 .find_map(|field| delta.get(*field).and_then(Value::as_str).filter(|s| !s.is_empty()))
206 else {
207 return;
208 };
209 let Some(delta) = aggregator.handle_reasoning(reasoning) else {
210 return;
211 };
212 let _ = tx.send(Ok(LLMStreamEvent::Reasoning { delta }));
213}
214
215fn emit_content_delta(
216 aggregator: &mut StreamAggregator,
217 tx: &tokio::sync::mpsc::UnboundedSender<Result<LLMStreamEvent, LLMError>>,
218 delta: &Value,
219) {
220 let Some(content) = delta.get("content").and_then(Value::as_str) else {
221 return;
222 };
223 for event in aggregator.handle_content(content) {
224 let _ = tx.send(Ok(event));
225 }
226}
227
228pub(crate) fn handle_openai_compatible_chunk(
229 value: &Value,
230 aggregator: &mut StreamAggregator,
231 tx: &tokio::sync::mpsc::UnboundedSender<Result<LLMStreamEvent, LLMError>>,
232 reasoning_fields: &[&'static str],
233 delta_order: OpenAiDeltaOrder,
234 include_cache_metrics: bool,
235) {
236 if let Some(choices) = value.get("choices").and_then(Value::as_array)
237 && let Some(choice) = choices.first()
238 {
239 if let Some(delta) = choice.get("delta") {
240 match delta_order {
241 OpenAiDeltaOrder::ReasoningFirst => {
242 emit_reasoning_delta(aggregator, tx, delta, reasoning_fields);
243 emit_content_delta(aggregator, tx, delta);
244 }
245 OpenAiDeltaOrder::ContentFirst => {
246 emit_content_delta(aggregator, tx, delta);
247 emit_reasoning_delta(aggregator, tx, delta, reasoning_fields);
248 }
249 }
250
251 if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
252 aggregator.handle_tool_calls(tool_calls);
253 }
254 }
255
256 if let Some(reason) = choice.get("finish_reason").and_then(Value::as_str) {
257 aggregator.set_finish_reason(map_finish_reason_common(reason));
258 }
259 }
260
261 if let Some(_usage_value) = value.get("usage")
262 && let Some(usage) = crate::providers::common::parse_usage_openai_format(value, include_cache_metrics)
263 {
264 aggregator.set_usage(usage);
265 }
266}
267
268impl StreamAggregator {
269 pub(crate) fn new(model: String) -> Self {
270 Self {
271 model,
272 content: String::new(),
273 reasoning: String::new(),
274 reasoning_details: Vec::new(),
275 reasoning_buffer: ReasoningBuffer::default(),
276 tool_builders: Vec::new(),
277 usage: None,
278 finish_reason: crate::provider::FinishReason::Stop,
279 sanitizer: TagStreamSanitizer::new(),
280 compaction: None,
281 }
282 }
283
284 pub(crate) fn handle_content(&mut self, delta: &str) -> Vec<LLMStreamEvent> {
286 self.content.push_str(delta);
287 self.sanitizer.process_chunk(delta)
288 }
289
290 pub(crate) fn handle_reasoning(&mut self, delta: &str) -> Option<String> {
292 let result = self.reasoning_buffer.push(delta);
293 if let Some(ref d) = result {
294 self.reasoning.push_str(d);
295 }
296 result
297 }
298
299 pub(crate) fn set_reasoning_details(&mut self, details: &[Value]) {
301 if details.is_empty() {
302 return;
303 }
304
305 self.reasoning_details = details
306 .iter()
307 .map(|detail| detail.as_str().map(ToOwned::to_owned).unwrap_or_else(|| detail.to_string()))
308 .collect();
309 }
310
311 pub(crate) fn append_reasoning_detail(&mut self, detail: &Value) {
314 let serialized = detail.as_str().map(ToOwned::to_owned).unwrap_or_else(|| detail.to_string());
315 if !self.reasoning_details.iter().any(|existing| existing == &serialized) {
316 self.reasoning_details.push(serialized);
317 }
318 }
319
320 pub(crate) fn handle_tool_calls(&mut self, deltas: &[Value]) {
322 update_tool_calls(&mut self.tool_builders, deltas);
323 }
324
325 pub(crate) fn set_usage(&mut self, usage: crate::provider::Usage) {
327 self.usage = Some(usage);
328 }
329
330 pub(crate) fn set_finish_reason(&mut self, reason: crate::provider::FinishReason) {
332 self.finish_reason = reason;
333 }
334
335 pub(crate) fn finalize(mut self) -> LLMResponse {
337 for event in self.sanitizer.finalize() {
339 match event {
340 LLMStreamEvent::Token { delta } => {
341 self.content.push_str(&delta);
342 }
343 LLMStreamEvent::Reasoning { delta } => {
344 self.reasoning.push_str(&delta);
345 }
346 _ => {}
347 }
348 }
349
350 let reasoning_details = if self.reasoning_details.is_empty() {
351 None
352 } else {
353 Some(self.reasoning_details)
354 };
355 let mut reasoning = if self.reasoning.is_empty() {
356 self.reasoning_buffer.finalize()
357 } else {
358 Some(self.reasoning)
359 };
360 if reasoning.is_none() {
361 reasoning = reasoning_details
362 .as_ref()
363 .and_then(|details| extract_reasoning_text_from_serialized_details(details));
364 }
365
366 LLMResponse {
367 content: if self.content.is_empty() {
368 None
369 } else {
370 Some(self.content)
371 },
372 tool_calls: finalize_tool_calls(self.tool_builders),
373 model: self.model,
374 usage: self.usage,
375 finish_reason: self.finish_reason,
376 reasoning,
377 reasoning_details,
378 tool_references: Vec::new(),
379 request_id: None,
380 organization_id: None,
381 compaction: self.compaction,
382 }
383 }
384}
385
386pub(crate) async fn process_openai_stream<S, E, F>(
391 mut byte_stream: S,
392 provider_name: &'static str,
393 model: String,
394 mut on_chunk: F,
395) -> Result<LLMResponse, LLMError>
396where
397 S: futures::Stream<Item = Result<bytes::Bytes, E>> + Unpin,
398 E: std::fmt::Display,
399 F: FnMut(Value) -> Result<(), LLMError>,
400{
401 use crate::providers::error_handling::format_network_error;
402 use futures::StreamExt;
403
404 let mut buf: Vec<u8> = Vec::new();
405 let mut offset = 0usize;
406 let mut decoder = Utf8StreamDecoder::new();
407 let mut last_response_value = None;
408
409 while let Some(chunk_result) = byte_stream.next().await {
410 let chunk_bytes = chunk_result.map_err(|e| format_network_error(provider_name, &e.to_string()))?;
411 decoder.push_bytes(&chunk_bytes, &mut buf);
412
413 while let Some((boundary_idx, boundary_len)) = find_sse_boundary_bytes(&buf, offset) {
414 let event = std::str::from_utf8(&buf[offset..boundary_idx]).expect("valid utf-8 stream data");
415 offset = boundary_idx + boundary_len;
416
417 if let Some(data) = extract_data_payload(event) {
418 if data == "[DONE]" {
419 break;
420 }
421
422 for line in data.lines() {
423 let trimmed = line.trim();
424 if trimmed.is_empty() {
425 continue;
426 }
427
428 if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
429 on_chunk(value.clone())?;
430 last_response_value = Some(value);
431 }
432 }
433 }
434 }
435
436 if offset > 0 {
439 buf.drain(..offset);
440 offset = 0;
441 }
442 }
443
444 let mut final_response = LLMResponse {
446 content: None,
447 tool_calls: None,
448 model,
449 usage: None,
450 finish_reason: crate::provider::FinishReason::Stop,
451 reasoning: None,
452 reasoning_details: None,
453 tool_references: Vec::new(),
454 request_id: None,
455 organization_id: None,
456 compaction: None,
457 };
458
459 if let Some(value) = last_response_value
460 && value.get("usage").is_some()
461 {
462 final_response.usage = crate::providers::common::parse_usage_openai_format(&value, true);
463 }
464
465 Ok(final_response)
466}
467
468pub(crate) fn parse_openai_tool_calls(calls: &[Value]) -> Vec<ToolCall> {
469 calls
470 .iter()
471 .filter_map(|call| {
472 let id = call.get("id").and_then(|v| v.as_str())?;
473 let function = call.get("function")?;
474 let namespace = call
475 .get("namespace")
476 .and_then(|v| v.as_str())
477 .or_else(|| function.get("namespace").and_then(|v| v.as_str()))
478 .map(ToOwned::to_owned);
479 let name = function.get("name").and_then(|v| v.as_str())?;
480 let arguments = function.get("arguments");
481 let serialized = arguments.map_or_else(
482 || "{}".to_string(),
483 |value| {
484 if value.is_string() {
485 value.as_str().unwrap_or("").to_string()
486 } else {
487 value.to_string()
488 }
489 },
490 );
491 Some(ToolCall::function_with_namespace(id.to_string(), namespace, name.to_string(), serialized))
492 })
493 .collect()
494}
495
496fn push_unique_tool_reference(tool_references: &mut Vec<String>, tool_name: &str) {
497 if !tool_references.iter().any(|existing| existing == tool_name) {
498 tool_references.push(tool_name.to_string());
499 }
500}
501
502pub(crate) fn collect_tool_references_from_tool_search_output(value: &Value, tool_references: &mut Vec<String>) {
503 match value {
504 Value::Array(items) => {
505 for item in items {
506 collect_tool_references_from_tool_search_output(item, tool_references);
507 }
508 }
509 Value::Object(object) => {
510 if let Some(tools) = object.get("tools").and_then(Value::as_array) {
511 for tool in tools {
512 collect_tool_references_from_tool_search_output(tool, tool_references);
513 }
514 } else if let Some(tool_name) = object.get("tool_name").and_then(Value::as_str) {
515 push_unique_tool_reference(tool_references, tool_name);
516 } else if let Some(function) = object.get("function").and_then(Value::as_object)
517 && let Some(tool_name) = function.get("name").and_then(Value::as_str)
518 {
519 push_unique_tool_reference(tool_references, tool_name);
520 } else if let Some(tool_name) = object.get("name").and_then(Value::as_str) {
521 push_unique_tool_reference(tool_references, tool_name);
522 }
523
524 if let Some(tool_refs) = object.get("tool_references").and_then(Value::as_array) {
525 for tool_ref in tool_refs {
526 collect_tool_references_from_tool_search_output(tool_ref, tool_references);
527 }
528 }
529 }
530 _ => {}
531 }
532}
533
534fn append_text_with_reasoning(
535 text: &str,
536 aggregated_content: &mut String,
537 reasoning: &mut ReasoningBuffer,
538 deltas: &mut StreamDelta,
539 telemetry: &impl StreamTelemetry,
540) {
541 let (segments, cleaned) = split_reasoning_from_text(text);
542
543 if segments.is_empty() && cleaned.is_none() {
544 if !text.is_empty() {
545 aggregated_content.push_str(text);
546 deltas.push_content(text);
547 telemetry.on_content_delta(text);
548 }
549 return;
550 }
551
552 for segment in segments {
553 if let Some(stage) = &segment.stage {
554 telemetry.on_reasoning_stage(stage);
555 }
556 if let Some(delta) = reasoning.push(&segment.text) {
557 telemetry.on_reasoning_delta(&delta);
558 deltas.push_reasoning(&delta);
559 }
560 }
561
562 if let Some(cleaned_text) = cleaned
563 && !cleaned_text.is_empty()
564 {
565 aggregated_content.push_str(&cleaned_text);
566 telemetry.on_content_delta(&cleaned_text);
567 deltas.push_content(&cleaned_text);
568 }
569}
570
571fn apply_tool_call_delta_from_content(
572 builders: &mut Vec<ToolCallBuilder>,
573 container: &Map<String, Value>,
574 telemetry: &impl StreamTelemetry,
575) {
576 apply_tool_call_delta_with_index(builders, container, telemetry, None, None);
577}
578
579fn apply_tool_call_delta_with_index(
580 builders: &mut Vec<ToolCallBuilder>,
581 container: &Map<String, Value>,
582 telemetry: &impl StreamTelemetry,
583 fallback_index: Option<usize>,
584 fallback_id: Option<Value>,
585) {
586 fn extract_tool_call_id(container: &Map<String, Value>) -> Option<Value> {
587 container.get("id").cloned().or_else(|| {
588 container
589 .get("tool_call")
590 .and_then(|value| value.as_object())
591 .and_then(|inner| inner.get("id"))
592 .cloned()
593 })
594 }
595
596 let explicit_index = container
597 .get("tool_call")
598 .and_then(|value| value.as_object())
599 .and_then(|tool_call| tool_call.get("index"))
600 .and_then(|value| value.as_u64())
601 .or_else(|| container.get("index").and_then(|value| value.as_u64()));
602
603 let index = explicit_index.map(|value| value as usize).or(fallback_index).unwrap_or(0);
604
605 let current_id = extract_tool_call_id(container).or_else(|| fallback_id.clone());
606
607 if let Some(nested) = container.get("delta").and_then(|value| value.as_object()) {
608 apply_tool_call_delta_with_index(builders, nested, telemetry, Some(index), current_id.clone());
609 }
610
611 let delta_source = container
612 .get("tool_call")
613 .and_then(|value| value.as_object())
614 .unwrap_or(container);
615
616 let mut delta_map = Map::new();
617
618 if let Some(id_value) = extract_tool_call_id(delta_source).or_else(|| current_id.clone()) {
619 delta_map.insert("id".to_string(), id_value);
620 }
621
622 if let Some(function_value) = delta_source.get("function").or_else(|| container.get("function")) {
623 delta_map.insert("function".to_string(), function_value.clone());
624 }
625
626 if delta_map.is_empty() {
627 return;
628 }
629
630 if builders.len() <= index {
631 builders.resize_with(index + 1, ToolCallBuilder::default);
632 }
633
634 let mut deltas = vec![Value::Null; index + 1];
635 deltas[index] = Value::Object(delta_map);
636 update_tool_calls(builders, &deltas);
637 telemetry.on_tool_call_delta();
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643 use serde_json::json;
644
645 #[test]
646 fn finalize_tool_calls_drops_empty_builders() {
647 let builders = vec![ToolCallBuilder::default()];
648 assert!(finalize_tool_calls(builders).is_none());
649 }
650
651 #[test]
652 fn finalize_fabricates_unique_ids_across_batches() {
653 let idless_builder = || {
654 let mut builder = ToolCallBuilder::default();
655 builder.apply_delta(&json!({"function": {"name": "foo", "arguments": "{}"}}));
656 builder
657 };
658
659 let first = finalize_tool_calls(vec![idless_builder(), idless_builder()]).expect("calls expected");
660 let second = finalize_tool_calls(vec![idless_builder(), idless_builder()]).expect("calls expected");
661
662 let ids: Vec<&str> = first.iter().chain(second.iter()).map(|call| call.id.as_str()).collect();
663 let unique: std::collections::HashSet<&str> = ids.iter().copied().collect();
664 assert_eq!(unique.len(), ids.len(), "fabricated ids must be unique across responses");
665
666 for id in ids {
667 let hex = id.strip_prefix("call_").expect("fabricated id prefix");
668 assert_eq!(hex.len(), 32);
669 assert!(hex.chars().all(|ch| ch.is_ascii_hexdigit()));
670 }
671 }
672
673 #[test]
674 fn finalize_preserves_provider_supplied_id() {
675 let mut builder = ToolCallBuilder::default();
676 builder.apply_delta(&json!({"id": "provider-id-1", "function": {"name": "foo"}}));
677 let call = builder.finalize().expect("call expected");
678 assert_eq!(call.id, "provider-id-1");
679 }
680
681 #[test]
682 fn append_text_with_reasoning_tracks_segments() {
683 let telemetry = NoopStreamTelemetry;
684 let mut aggregated = String::new();
685 let mut reasoning = ReasoningBuffer::default();
686 let mut delta = StreamDelta::default();
687 append_text_with_reasoning("Hello", &mut aggregated, &mut reasoning, &mut delta, &telemetry);
688 assert_eq!(aggregated, "Hello");
689 assert_eq!(delta.into_fragments(), vec![StreamFragment::Content("Hello".into())]);
690 }
691
692 #[test]
693 fn apply_tool_call_delta_updates_builder() {
694 let telemetry = NoopStreamTelemetry;
695 let mut builders = Vec::new();
696 let container = json!({
697 "index": 0,
698 "function": {"name": "foo", "arguments": "{}"}
699 })
700 .as_object()
701 .cloned()
702 .unwrap();
703 apply_tool_call_delta_from_content(&mut builders, &container, &telemetry);
704 let calls = finalize_tool_calls(builders).expect("call expected");
705 let func = calls[0].function.as_ref().expect("function call should be present");
706 assert_eq!(func.name, "foo");
707 }
708
709 #[test]
710 fn apply_tool_call_delta_uses_outer_index_for_nested_delta() {
711 let telemetry = NoopStreamTelemetry;
712 let mut builders = Vec::new();
713 let container = json!({
714 "delta": {
715 "tool_call": {
716 "function": {
717 "name": "foo",
718 "arguments": "{\"value\":1}"
719 }
720 }
721 },
722 "index": 1,
723 "id": "call-1"
724 })
725 .as_object()
726 .cloned()
727 .unwrap();
728
729 apply_tool_call_delta_from_content(&mut builders, &container, &telemetry);
730
731 let calls = finalize_tool_calls(builders).expect("call expected");
732 assert_eq!(calls.len(), 1);
733 assert_eq!(calls[0].id, "call-1");
734 let func = calls[0].function.as_ref().expect("function call should be present");
735 assert_eq!(func.arguments, "{\"value\":1}");
736 }
737
738 #[test]
739 fn update_tool_calls_respects_explicit_index() {
740 let mut builders = Vec::new();
741 let deltas = vec![json!({
742 "index": 2,
743 "id": "call_3",
744 "function": {
745 "name": "get_weather",
746 "arguments": "{\"city\":\"Beijing\"}"
747 }
748 })];
749
750 update_tool_calls(&mut builders, &deltas);
751
752 let calls = finalize_tool_calls(builders).expect("call expected");
753 assert_eq!(calls.len(), 1);
754 assert_eq!(calls[0].id, "call_3");
755 let function = calls[0].function.as_ref().expect("function expected");
756 assert_eq!(function.name, "get_weather");
757 assert_eq!(function.arguments, "{\"city\":\"Beijing\"}");
758 }
759
760 #[test]
761 fn stream_aggregator_derives_reasoning_from_details_when_missing() {
762 let mut aggregator = StreamAggregator::new("test-model".to_string());
763 aggregator.set_reasoning_details(&[json!({
764 "type": "reasoning.text",
765 "text": "step one"
766 })]);
767
768 let response = aggregator.finalize();
769 assert_eq!(response.reasoning.as_deref(), Some("step one"));
770 assert!(response.reasoning_details.is_some());
771 }
772
773 #[test]
774 fn handle_chunk_extracts_content_delta() {
775 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
776 let mut aggregator = StreamAggregator::new("test-model".to_string());
777 let chunk = json!({
778 "choices": [{"delta": {"content": "hello"}}]
779 });
780
781 handle_openai_compatible_chunk(&chunk, &mut aggregator, &tx, &[], OpenAiDeltaOrder::ContentFirst, false);
782
783 let event = rx.try_recv().expect("event expected");
784 match event.unwrap() {
785 LLMStreamEvent::Token { delta } => {
786 assert_eq!(delta, "hello");
787 }
788 other => panic!("expected Token event, got {other:?}"),
789 }
790 }
791
792 #[test]
793 fn handle_chunk_extracts_reasoning_delta() {
794 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
795 let mut aggregator = StreamAggregator::new("test-model".to_string());
796 let chunk = json!({
797 "choices": [{"delta": {"reasoning_content": "thinking..."}}]
798 });
799
800 handle_openai_compatible_chunk(
801 &chunk,
802 &mut aggregator,
803 &tx,
804 &["reasoning_content"],
805 OpenAiDeltaOrder::ReasoningFirst,
806 false,
807 );
808
809 let event = rx.try_recv().expect("event expected");
810 match event.unwrap() {
811 LLMStreamEvent::Reasoning { delta } => {
812 assert_eq!(delta, "thinking...");
813 }
814 other => panic!("expected Reasoning event, got {other:?}"),
815 }
816 }
817
818 #[test]
819 fn handle_chunk_aggregates_tool_calls() {
820 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
821 let mut aggregator = StreamAggregator::new("test-model".to_string());
822 let chunk = json!({
823 "choices": [{
824 "delta": {
825 "tool_calls": [{
826 "index": 0,
827 "id": "call_1",
828 "function": {"name": "search", "arguments": "{\"q\":\"test\"}"}
829 }]
830 }
831 }]
832 });
833
834 handle_openai_compatible_chunk(&chunk, &mut aggregator, &tx, &[], OpenAiDeltaOrder::ContentFirst, false);
835
836 let response = aggregator.finalize();
837 let calls = response.tool_calls.expect("tool calls expected");
838 assert_eq!(calls.len(), 1);
839 assert_eq!(calls[0].id, "call_1");
840 let func = calls[0].function.as_ref().expect("function expected");
841 assert_eq!(func.name, "search");
842 assert_eq!(func.arguments, "{\"q\":\"test\"}");
843 }
844
845 #[test]
846 fn handle_chunk_skips_empty_reasoning_and_falls_back() {
847 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
848 let mut aggregator = StreamAggregator::new("test-model".to_string());
849 let chunk = json!({
851 "choices": [{"delta": {"reasoning": "", "reasoning_content": "actual reasoning"}}]
852 });
853
854 handle_openai_compatible_chunk(
855 &chunk,
856 &mut aggregator,
857 &tx,
858 &["reasoning", "reasoning_content"],
859 OpenAiDeltaOrder::ReasoningFirst,
860 false,
861 );
862
863 let event = rx.try_recv().expect("event expected");
864 match event.unwrap() {
865 LLMStreamEvent::Reasoning { delta } => {
866 assert_eq!(delta, "actual reasoning");
867 }
868 other => panic!("expected Reasoning event, got {other:?}"),
869 }
870 }
871
872 #[test]
873 fn handle_chunk_passes_include_cache_metrics_to_usage() {
874 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
875 let mut aggregator = StreamAggregator::new("test-model".to_string());
876 let chunk = json!({
877 "choices": [{"delta": {}}],
878 "usage": {
879 "prompt_tokens": 100,
880 "completion_tokens": 50,
881 "total_tokens": 150,
882 "prompt_cache_hit_tokens": 30,
883 "prompt_cache_miss_tokens": 70
884 }
885 });
886
887 handle_openai_compatible_chunk(&chunk, &mut aggregator, &tx, &[], OpenAiDeltaOrder::ContentFirst, false);
889
890 let response = aggregator.finalize();
891 let usage = response.usage.expect("usage expected");
892 assert_eq!(usage.prompt_tokens, 100);
893 assert_eq!(usage.cached_prompt_tokens, None);
894 assert_eq!(usage.cache_creation_tokens, None);
895
896 let mut aggregator2 = StreamAggregator::new("test-model".to_string());
898 handle_openai_compatible_chunk(&chunk, &mut aggregator2, &tx, &[], OpenAiDeltaOrder::ContentFirst, true);
899
900 let response2 = aggregator2.finalize();
901 let usage2 = response2.usage.expect("usage expected");
902 assert_eq!(usage2.prompt_tokens, 100);
903 assert_eq!(usage2.cached_prompt_tokens, Some(30));
904 assert_eq!(usage2.cache_creation_tokens, Some(70));
905 }
906}