Skip to main content

vtcode_llm/providers/shared/
stream.rs

1//! OpenAI-compatible stream assembly, ordered deltas, and tool-call accumulation.
2
3use 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
88/// Generate a globally unique fallback id for tool calls whose provider
89/// omitted one. Index-based fallbacks (`tool_call_{index}`) reset every
90/// response, so the same id recurs across assistant messages and corrupts
91/// id-keyed history correlation downstream. Uniqueness across responses and
92/// process restarts (resumed sessions replay ids from checkpoints) is
93/// required, hence a random uuid rather than a counter.
94pub(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
173/// Helper to aggregate streaming events and produce a final LLMResponse.
174pub(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    // Pick the first non-empty reasoning field, allowing providers to declare a
200    // fallback order (e.g. `["reasoning", "reasoning_content"]`).
201    // Empty strings are skipped so that a present-but-empty primary field
202    // does not block fallback to the next candidate.
203    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    /// Process a content delta, applying sanitization for reasoning tags.
285    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    /// Process a reasoning delta from a dedicated field.
291    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    /// Store structured reasoning details received from streaming deltas.
300    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    /// Append a completed structured item received from a streaming
312    /// output-item event.
313    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    /// Process tool call deltas.
321    pub(crate) fn handle_tool_calls(&mut self, deltas: &[Value]) {
322        update_tool_calls(&mut self.tool_builders, deltas);
323    }
324
325    /// Set usage metrics.
326    pub(crate) fn set_usage(&mut self, usage: crate::provider::Usage) {
327        self.usage = Some(usage);
328    }
329
330    /// Set finish reason.
331    pub(crate) fn set_finish_reason(&mut self, reason: crate::provider::FinishReason) {
332        self.finish_reason = reason;
333    }
334
335    /// Finalize and produce the completed LLMResponse.
336    pub(crate) fn finalize(mut self) -> LLMResponse {
337        // Collect any leftover bits from sanitizer
338        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
386/// Common helper for processing OpenAI-compatible SSE streams.
387///
388/// This simplifies stream implementations across providers like DeepSeek, ZAI, Moonshot, etc.
389/// Especially optimized for high-performance models like Gemini 3 and GLM-5.
390pub(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        // Drain the consumed prefix so `buf` stays bounded to the unprocessed
437        // tail rather than growing for the entire stream lifetime.
438        if offset > 0 {
439            buf.drain(..offset);
440            offset = 0;
441        }
442    }
443
444    // Attempt to extract final response metadata (usage, etc) from last chunk if not already done
445    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        // Evolink-style: reasoning is empty string, reasoning_content has actual content
850        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        // With include_cache_metrics = false (Evolink/Moonshot/StepFun/ZAI behavior)
888        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        // With include_cache_metrics = true (DeepSeek behavior)
897        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}