Skip to main content

rig_core/providers/openai/completion/
streaming.rs

1use crate::telemetry::{CompletionOperation, CompletionSpanBuilder};
2use http::Request;
3use serde::{Deserialize, Serialize};
4use serde_json::json;
5use tracing::{Level, enabled};
6
7use crate::completion::{CompletionError, CompletionRequest, GetTokenUsage};
8use crate::http_client::HttpClientExt;
9use crate::json_utils::{self, merge};
10use crate::providers::internal::openai_chat_completions_compatible::{
11    self, CompatibleChoiceData, CompatibleChunk, CompatibleFinishReason, CompatibleStreamProfile,
12    CompatibleToolCallChunk,
13};
14use crate::providers::openai::completion::{
15    CompletionModelOptions, GenericCompletionModel, OpenAICompatibleProvider, Usage,
16};
17use crate::streaming;
18
19// ================================================================
20// OpenAI Completion Streaming API
21// ================================================================
22#[derive(Default, Deserialize, Debug)]
23pub(crate) struct StreamingFunction {
24    pub(crate) name: Option<String>,
25    #[serde(
26        default,
27        deserialize_with = "crate::json_utils::deserialize_json_string_or_value"
28    )]
29    pub(crate) arguments: Option<String>,
30}
31
32#[derive(Deserialize, Debug)]
33pub(crate) struct StreamingToolCall {
34    // Optional in several compatible dialects (e.g. Mistral); missing means
35    // a single in-flight tool call.
36    #[serde(default)]
37    pub(crate) index: usize,
38    pub(crate) id: Option<String>,
39    #[serde(default, deserialize_with = "json_utils::null_or_default")]
40    pub(crate) function: StreamingFunction,
41}
42
43impl From<&StreamingToolCall> for CompatibleToolCallChunk {
44    fn from(value: &StreamingToolCall) -> Self {
45        Self {
46            index: value.index,
47            id: value.id.clone(),
48            name: value.function.name.clone(),
49            arguments: value.function.arguments.clone(),
50        }
51    }
52}
53
54fn deserialize_delta_content<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
55where
56    D: serde::Deserializer<'de>,
57{
58    // Some compatible providers (e.g. Mistral's reasoning models) stream
59    // delta content as an array of content parts rather than a string.
60    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
61    Ok(value.and_then(|value| match value {
62        serde_json::Value::String(text) => Some(text),
63        serde_json::Value::Array(parts) => {
64            let text = crate::providers::openai::completion::joined_text_parts(&parts);
65            (!text.is_empty()).then_some(text)
66        }
67        _ => None,
68    }))
69}
70
71#[derive(Deserialize, Debug)]
72struct StreamingDelta {
73    #[serde(default, deserialize_with = "deserialize_delta_content")]
74    content: Option<String>,
75    #[serde(default)]
76    reasoning_content: Option<String>,
77    // Not part of the official OpenAI API; some compatible providers (e.g.
78    // Groq) send the same payload under `reasoning`. A separate field rather
79    // than a serde alias so a delta carrying BOTH keys is not a
80    // duplicate-field error that drops the whole chunk.
81    #[serde(default)]
82    reasoning: Option<String>,
83    #[serde(default, deserialize_with = "json_utils::null_or_vec")]
84    tool_calls: Vec<StreamingToolCall>,
85    #[serde(default, deserialize_with = "json_utils::null_or_vec")]
86    reasoning_details: Vec<serde_json::Value>,
87}
88
89#[derive(Deserialize, Debug, PartialEq)]
90#[serde(rename_all = "snake_case")]
91pub enum FinishReason {
92    ToolCalls,
93    Stop,
94    ContentFilter,
95    Length,
96    #[serde(untagged)]
97    Other(String), // This will handle the deprecated function_call
98}
99
100#[derive(Deserialize, Debug)]
101struct StreamingChoice {
102    delta: StreamingDelta,
103    finish_reason: Option<FinishReason>,
104}
105
106#[derive(Deserialize, Debug)]
107struct StreamingCompletionChunk<U = Usage> {
108    id: Option<String>,
109    model: Option<String>,
110    choices: Vec<StreamingChoice>,
111    usage: Option<U>,
112}
113
114/// Final streaming response. `U` is the provider's streaming usage payload
115/// ([`Usage`] for OpenAI itself; providers with richer usage accounting, e.g.
116/// Mistral and DeepSeek, substitute their own via
117/// [`OpenAICompatibleProvider::StreamingUsage`].
118#[derive(Clone, Serialize, Deserialize)]
119pub struct StreamingCompletionResponse<U = Usage> {
120    pub usage: U,
121}
122
123impl<U> GetTokenUsage for StreamingCompletionResponse<U>
124where
125    U: GetTokenUsage,
126{
127    fn token_usage(&self) -> crate::completion::Usage {
128        self.usage.token_usage()
129    }
130}
131
132impl<Ext, H> GenericCompletionModel<Ext, H>
133where
134    crate::client::Client<Ext, H>: HttpClientExt + Clone + 'static,
135    Ext: crate::client::Provider
136        + OpenAICompatibleProvider
137        + Clone
138        + crate::wasm_compat::WasmCompatSend
139        + 'static,
140{
141    pub(crate) async fn stream(
142        &self,
143        completion_request: CompletionRequest,
144    ) -> Result<
145        streaming::StreamingCompletionResponse<StreamingCompletionResponse<Ext::StreamingUsage>>,
146        CompletionError,
147    > {
148        let preamble = completion_request.preamble.clone();
149        let record_telemetry_content = completion_request.record_telemetry_content;
150        let options = CompletionModelOptions {
151            strict_tools: self.strict_tools,
152            tool_result_array_content: self.tool_result_array_content,
153            prompt_caching: self.prompt_caching,
154        };
155        let mut request = self.client.ext().build_completion_request(
156            self.model.clone(),
157            completion_request,
158            options,
159        )?;
160        self.client.ext().prepare_request(&mut request)?;
161
162        // Deliberately the configured model, not the per-request override:
163        // Azure's deployment URL is pinned to the model handle.
164        let path = self.client.ext().completion_path(&self.model);
165        let resolved_model = request.model.clone();
166        let mut request_as_json = serde_json::to_value(request)?;
167
168        // `merge` is shallow, so include_usage is inserted into any
169        // caller-supplied stream_options rather than merged over it: the
170        // caller's keys survive and the usage chunk is still requested.
171        if Ext::STREAM_INCLUDE_USAGE {
172            match request_as_json.get_mut("stream_options") {
173                Some(serde_json::Value::Object(options)) => {
174                    options
175                        .entry("include_usage")
176                        .or_insert(serde_json::Value::Bool(true));
177                }
178                Some(_) => {}
179                None => {
180                    request_as_json = merge(
181                        request_as_json,
182                        json!({"stream_options": {"include_usage": true}}),
183                    );
184                }
185            }
186        }
187        request_as_json = merge(request_as_json, json!({"stream": true}));
188        self.client
189            .ext()
190            .finalize_request_body_with_options(&mut request_as_json, options)?;
191
192        if enabled!(Level::TRACE) {
193            tracing::trace!(
194                target: "rig::completions",
195                "OpenAI Chat Completions streaming completion request: {}",
196                serde_json::to_string_pretty(&request_as_json)?
197            );
198        }
199
200        let req_body = serde_json::to_vec(&request_as_json)?;
201
202        let req = self
203            .client
204            .post(&path)?
205            .body(req_body)
206            .map_err(|e| CompletionError::HttpError(e.into()))?;
207
208        let span = CompletionSpanBuilder::new(
209            Ext::PROVIDER_NAME,
210            &resolved_model,
211            CompletionOperation::Chat,
212        )
213        .system_instructions(preamble.as_deref(), record_telemetry_content)
214        .build();
215
216        let client = self.client.clone();
217
218        tracing::Instrument::instrument(
219            openai_chat_completions_compatible::send_compatible_streaming_request(
220                client,
221                req,
222                OpenAICompatibleProfile::<Ext, Ext::StreamingUsage> {
223                    provider: self.client.ext().clone(),
224                    emits_complete_single_chunk_tool_calls:
225                        Ext::EMITS_COMPLETE_SINGLE_CHUNK_TOOL_CALLS,
226                    usage: std::marker::PhantomData,
227                },
228            ),
229            span,
230        )
231        .await
232    }
233}
234
235#[derive(Clone, Copy, Default)]
236struct OpenAICompatibleProfile<Ext = crate::providers::openai::OpenAICompletionsExt, U = Usage> {
237    provider: Ext,
238    emits_complete_single_chunk_tool_calls: bool,
239    usage: std::marker::PhantomData<U>,
240}
241
242impl<Ext, U> CompatibleStreamProfile for OpenAICompatibleProfile<Ext, U>
243where
244    Ext: OpenAICompatibleProvider + Clone + crate::wasm_compat::WasmCompatSend,
245    U: Clone
246        + Default
247        + GetTokenUsage
248        + serde::de::DeserializeOwned
249        + crate::wasm_compat::WasmCompatSend
250        + Unpin
251        + 'static,
252{
253    type Usage = U;
254    type Detail = serde_json::Value;
255    type FinalResponse = StreamingCompletionResponse<U>;
256
257    fn normalize_chunk(
258        &self,
259        data: &str,
260    ) -> Result<Option<CompatibleChunk<Self::Usage, Self::Detail>>, CompletionError> {
261        let data = match serde_json::from_str::<StreamingCompletionChunk<U>>(data) {
262            Ok(data) => data,
263            Err(error) => {
264                tracing::error!(?error, message = data, "Failed to parse SSE message");
265                return Ok(None);
266            }
267        };
268
269        Ok(Some(
270            openai_chat_completions_compatible::normalize_first_choice_chunk(
271                data.id,
272                data.model,
273                data.usage,
274                &data.choices,
275                |choice| CompatibleChoiceData {
276                    // `function_call` is the deprecated pre-tools finish reason
277                    // some compatible providers still emit for tool calls.
278                    finish_reason: match &choice.finish_reason {
279                        Some(FinishReason::ToolCalls) => CompatibleFinishReason::ToolCalls,
280                        Some(FinishReason::Other(other)) if other == "function_call" => {
281                            CompatibleFinishReason::ToolCalls
282                        }
283                        _ => CompatibleFinishReason::Other,
284                    },
285                    text: choice.delta.content.clone(),
286                    reasoning: choice
287                        .delta
288                        .reasoning_content
289                        .clone()
290                        .or_else(|| choice.delta.reasoning.clone()),
291                    tool_calls: openai_chat_completions_compatible::tool_call_chunks(
292                        &choice.delta.tool_calls,
293                    ),
294                    details: choice.delta.reasoning_details.clone(),
295                },
296            ),
297        ))
298    }
299
300    fn build_final_response(&self, usage: Self::Usage) -> Self::FinalResponse {
301        StreamingCompletionResponse { usage }
302    }
303
304    fn decorate_tool_call(
305        &self,
306        detail: &Self::Detail,
307        tool_calls: &mut std::collections::HashMap<usize, crate::streaming::RawStreamingToolCall>,
308    ) {
309        self.provider
310            .decorate_streaming_tool_call(detail, tool_calls);
311    }
312
313    fn uses_distinct_tool_call_eviction(&self) -> bool {
314        true
315    }
316
317    fn emits_complete_single_chunk_tool_calls(&self) -> bool {
318        self.emits_complete_single_chunk_tool_calls
319    }
320}
321
322pub async fn send_compatible_streaming_request<T>(
323    http_client: T,
324    req: Request<Vec<u8>>,
325) -> Result<streaming::StreamingCompletionResponse<StreamingCompletionResponse>, CompletionError>
326where
327    T: HttpClientExt + Clone + 'static,
328{
329    openai_chat_completions_compatible::send_compatible_streaming_request(
330        http_client,
331        req,
332        OpenAICompatibleProfile::<crate::providers::openai::OpenAICompletionsExt, Usage>::default(),
333    )
334    .await
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use crate::providers::internal::openai_chat_completions_compatible::test_support::{
341        assert_zero_arg_tool_call_is_emitted, sse_bytes_from_data_lines,
342    };
343
344    #[test]
345    fn test_streaming_function_deserialization() {
346        let json = r#"{"name": "get_weather", "arguments": "{\"location\":\"Paris\"}"}"#;
347        let function: StreamingFunction = serde_json::from_str(json).unwrap();
348        assert_eq!(function.name, Some("get_weather".to_string()));
349        assert_eq!(
350            function.arguments.as_ref().unwrap(),
351            r#"{"location":"Paris"}"#
352        );
353    }
354
355    #[test]
356    fn test_streaming_function_object_arguments() {
357        // Some OpenAI-compatible gateways send `arguments` as a JSON object
358        // instead of the spec-mandated JSON-encoded string. Accept it by
359        // re-serializing to the string form rather than dropping the chunk.
360        let json = r#"{"name": "list_dir", "arguments": {}}"#;
361        let function: StreamingFunction = serde_json::from_str(json).unwrap();
362        assert_eq!(function.name, Some("list_dir".to_string()));
363        assert_eq!(function.arguments.as_ref().unwrap(), "{}");
364
365        let json = r#"{"name": "get_weather", "arguments": {"city": "London"}}"#;
366        let function: StreamingFunction = serde_json::from_str(json).unwrap();
367        assert_eq!(function.arguments.as_ref().unwrap(), r#"{"city":"London"}"#);
368    }
369
370    #[test]
371    fn test_streaming_function_null_arguments() {
372        let json = r#"{"name": "list_dir", "arguments": null}"#;
373        let function: StreamingFunction = serde_json::from_str(json).unwrap();
374        assert!(function.arguments.is_none());
375
376        let json = r#"{"name": "list_dir"}"#;
377        let function: StreamingFunction = serde_json::from_str(json).unwrap();
378        assert!(function.arguments.is_none());
379    }
380
381    #[test]
382    fn test_streaming_tool_call_deserialization() {
383        let json = r#"{
384            "index": 0,
385            "id": "call_abc123",
386            "function": {
387                "name": "get_weather",
388                "arguments": "{\"city\":\"London\"}"
389            }
390        }"#;
391        let tool_call: StreamingToolCall = serde_json::from_str(json).unwrap();
392        assert_eq!(tool_call.index, 0);
393        assert_eq!(tool_call.id, Some("call_abc123".to_string()));
394        assert_eq!(tool_call.function.name, Some("get_weather".to_string()));
395    }
396
397    #[test]
398    fn test_streaming_tool_call_partial_deserialization() {
399        // Partial tool calls have no name and partial arguments
400        let json = r#"{
401            "index": 0,
402            "id": null,
403            "function": {
404                "name": null,
405                "arguments": "Paris"
406            }
407        }"#;
408        let tool_call: StreamingToolCall = serde_json::from_str(json).unwrap();
409        assert_eq!(tool_call.index, 0);
410        assert!(tool_call.id.is_none());
411        assert!(tool_call.function.name.is_none());
412        assert_eq!(tool_call.function.arguments.as_ref().unwrap(), "Paris");
413    }
414
415    #[test]
416    fn test_streaming_tool_call_missing_function_deserialization() {
417        let json = r#"{
418            "index": 0,
419            "id": "call_abc123"
420        }"#;
421        let tool_call: StreamingToolCall = serde_json::from_str(json).unwrap();
422        assert_eq!(tool_call.index, 0);
423        assert_eq!(tool_call.id, Some("call_abc123".to_string()));
424        assert!(tool_call.function.name.is_none());
425        assert!(tool_call.function.arguments.is_none());
426    }
427
428    #[test]
429    fn test_streaming_tool_call_null_function_deserialization() {
430        let json = r#"{
431            "index": 0,
432            "id": "call_abc123",
433            "function": null
434        }"#;
435        let tool_call: StreamingToolCall = serde_json::from_str(json).unwrap();
436        assert_eq!(tool_call.index, 0);
437        assert_eq!(tool_call.id, Some("call_abc123".to_string()));
438        assert!(tool_call.function.name.is_none());
439        assert!(tool_call.function.arguments.is_none());
440    }
441
442    #[test]
443    fn test_streaming_delta_with_tool_calls() {
444        let json = r#"{
445            "content": null,
446            "tool_calls": [{
447                "index": 0,
448                "id": "call_xyz",
449                "function": {
450                    "name": "search",
451                    "arguments": ""
452                }
453            }]
454        }"#;
455        let delta: StreamingDelta = serde_json::from_str(json).unwrap();
456        assert!(delta.content.is_none());
457        assert_eq!(delta.tool_calls.len(), 1);
458        assert_eq!(delta.tool_calls[0].id, Some("call_xyz".to_string()));
459    }
460
461    #[test]
462    fn test_streaming_delta_with_null_tool_calls() {
463        let json = r#"{
464            "content": "Hello",
465            "tool_calls": null
466        }"#;
467        let delta: StreamingDelta = serde_json::from_str(json).unwrap();
468        assert_eq!(delta.content, Some("Hello".to_string()));
469        assert!(delta.tool_calls.is_empty());
470    }
471
472    #[test]
473    fn test_streaming_chunk_deserialization() {
474        let json = r#"{
475            "choices": [{
476                "delta": {
477                    "content": "Hello",
478                    "tool_calls": []
479                }
480            }],
481            "usage": {
482                "prompt_tokens": 10,
483                "completion_tokens": 5,
484                "total_tokens": 15
485            }
486        }"#;
487        let chunk: StreamingCompletionChunk = serde_json::from_str(json).unwrap();
488        assert_eq!(chunk.choices.len(), 1);
489        assert_eq!(chunk.choices[0].delta.content, Some("Hello".to_string()));
490        assert!(chunk.usage.is_some());
491    }
492
493    #[test]
494    fn test_streaming_chunk_with_multiple_tool_call_deltas() {
495        // Simulates multiple partial tool call chunks arriving
496        let json_start = r#"{
497            "choices": [{
498                "delta": {
499                    "content": null,
500                    "tool_calls": [{
501                        "index": 0,
502                        "id": "call_123",
503                        "function": {
504                            "name": "get_weather",
505                            "arguments": ""
506                        }
507                    }]
508                }
509            }],
510            "usage": null
511        }"#;
512
513        let json_chunk1 = r#"{
514            "choices": [{
515                "delta": {
516                    "content": null,
517                    "tool_calls": [{
518                        "index": 0,
519                        "id": null,
520                        "function": {
521                            "name": null,
522                            "arguments": "{\"loc"
523                        }
524                    }]
525                }
526            }],
527            "usage": null
528        }"#;
529
530        let json_chunk2 = r#"{
531            "choices": [{
532                "delta": {
533                    "content": null,
534                    "tool_calls": [{
535                        "index": 0,
536                        "id": null,
537                        "function": {
538                            "name": null,
539                            "arguments": "ation\":\"NYC\"}"
540                        }
541                    }]
542                }
543            }],
544            "usage": null
545        }"#;
546
547        // Verify each chunk deserializes correctly
548        let start_chunk: StreamingCompletionChunk = serde_json::from_str(json_start).unwrap();
549        assert_eq!(start_chunk.choices[0].delta.tool_calls.len(), 1);
550        assert_eq!(
551            start_chunk.choices[0].delta.tool_calls[0]
552                .function
553                .name
554                .as_ref()
555                .unwrap(),
556            "get_weather"
557        );
558
559        let chunk1: StreamingCompletionChunk = serde_json::from_str(json_chunk1).unwrap();
560        assert_eq!(chunk1.choices[0].delta.tool_calls.len(), 1);
561        assert_eq!(
562            chunk1.choices[0].delta.tool_calls[0]
563                .function
564                .arguments
565                .as_ref()
566                .unwrap(),
567            "{\"loc"
568        );
569
570        let chunk2: StreamingCompletionChunk = serde_json::from_str(json_chunk2).unwrap();
571        assert_eq!(chunk2.choices[0].delta.tool_calls.len(), 1);
572        assert_eq!(
573            chunk2.choices[0].delta.tool_calls[0]
574                .function
575                .arguments
576                .as_ref()
577                .unwrap(),
578            "ation\":\"NYC\"}"
579        );
580    }
581
582    #[tokio::test]
583    async fn test_streaming_usage_only_chunk_is_not_ignored() {
584        use crate::test_utils::MockStreamingClient;
585        use futures::StreamExt;
586
587        // Some providers emit a final "usage-only" chunk where `choices` is empty.
588        let client = MockStreamingClient {
589            sse_bytes: sse_bytes_from_data_lines([
590                "{\"choices\":[{\"delta\":{\"content\":\"Hello\",\"tool_calls\":[]}}],\"usage\":null}",
591                "{\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}",
592                "[DONE]",
593            ]),
594        };
595
596        let req = http::Request::builder()
597            .method("POST")
598            .uri("http://localhost/v1/chat/completions")
599            .body(Vec::new())
600            .unwrap();
601
602        let mut stream = send_compatible_streaming_request(client, req)
603            .await
604            .unwrap();
605
606        let mut final_usage = None;
607        while let Some(chunk) = stream.next().await {
608            if let streaming::StreamedAssistantContent::Final(res) = chunk.unwrap() {
609                final_usage = Some(res.usage);
610                break;
611            }
612        }
613
614        let usage = final_usage.expect("expected a final response with usage");
615        assert_eq!(usage.prompt_tokens, 10);
616        assert_eq!(usage.total_tokens, 15);
617    }
618
619    #[tokio::test]
620    async fn test_streaming_reasoning_content_and_text_chunks_are_incremental() {
621        use crate::test_utils::MockStreamingClient;
622        use futures::StreamExt;
623
624        let client = MockStreamingClient {
625            sse_bytes: sse_bytes_from_data_lines([
626                "{\"id\":\"cmpl-1\",\"model\":\"Qwen/Qwen3-4B\",\"choices\":[{\"delta\":{\"reasoning_content\":\"think \",\"tool_calls\":[]},\"finish_reason\":null}],\"usage\":null}",
627                "{\"id\":\"cmpl-1\",\"model\":\"Qwen/Qwen3-4B\",\"choices\":[{\"delta\":{\"reasoning_content\":\"more\",\"tool_calls\":[]},\"finish_reason\":null}],\"usage\":null}",
628                "{\"id\":\"cmpl-1\",\"model\":\"Qwen/Qwen3-4B\",\"choices\":[{\"delta\":{\"content\":\"hel\",\"tool_calls\":[]},\"finish_reason\":null}],\"usage\":null}",
629                "{\"id\":\"cmpl-1\",\"model\":\"Qwen/Qwen3-4B\",\"choices\":[{\"delta\":{\"content\":\"lo\",\"tool_calls\":[]},\"finish_reason\":\"stop\"}],\"usage\":null}",
630                "{\"choices\":[],\"usage\":{\"prompt_tokens\":4,\"completion_tokens\":6,\"total_tokens\":10}}",
631                "[DONE]",
632            ]),
633        };
634
635        let req = http::Request::builder()
636            .method("POST")
637            .uri("http://localhost/v1/chat/completions")
638            .body(Vec::new())
639            .unwrap();
640
641        let mut stream = send_compatible_streaming_request(client, req)
642            .await
643            .unwrap();
644
645        let mut reasoning_chunks = Vec::new();
646        let mut text_chunks = Vec::new();
647        let mut final_usage = None;
648
649        while let Some(chunk) = stream.next().await {
650            match chunk.unwrap() {
651                streaming::StreamedAssistantContent::ReasoningDelta { reasoning, .. } => {
652                    reasoning_chunks.push(reasoning)
653                }
654                streaming::StreamedAssistantContent::Text(text) => text_chunks.push(text.text),
655                streaming::StreamedAssistantContent::Final(response) => {
656                    final_usage = Some(response.usage)
657                }
658                _ => {}
659            }
660        }
661
662        assert_eq!(
663            reasoning_chunks,
664            vec!["think ".to_string(), "more".to_string()]
665        );
666        assert_eq!(text_chunks, vec!["hel".to_string(), "lo".to_string()]);
667
668        let usage = final_usage.expect("expected final usage");
669        assert_eq!(usage.prompt_tokens, 4);
670        assert_eq!(usage.total_tokens, 10);
671        let token_usage = usage.token_usage();
672        assert_eq!(token_usage.output_tokens, 6);
673    }
674
675    #[tokio::test]
676    async fn test_streaming_cached_input_tokens_populated() {
677        use crate::test_utils::MockStreamingClient;
678        use futures::StreamExt;
679
680        // Usage chunk includes prompt_tokens_details with cached_tokens.
681        let client = MockStreamingClient {
682            sse_bytes: sse_bytes_from_data_lines([
683                "{\"choices\":[{\"delta\":{\"content\":\"Hi\",\"tool_calls\":[]}}],\"usage\":null}",
684                "{\"choices\":[],\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":10,\"total_tokens\":110,\"prompt_tokens_details\":{\"cached_tokens\":80}}}",
685                "[DONE]",
686            ]),
687        };
688
689        let req = http::Request::builder()
690            .method("POST")
691            .uri("http://localhost/v1/chat/completions")
692            .body(Vec::new())
693            .unwrap();
694
695        let mut stream = send_compatible_streaming_request(client, req)
696            .await
697            .unwrap();
698
699        let mut final_response = None;
700        while let Some(chunk) = stream.next().await {
701            if let streaming::StreamedAssistantContent::Final(res) = chunk.unwrap() {
702                final_response = Some(res);
703                break;
704            }
705        }
706
707        let res = final_response.expect("expected a final response");
708
709        // Verify provider-level usage has the cached_tokens
710        assert_eq!(
711            res.usage
712                .prompt_tokens_details
713                .as_ref()
714                .unwrap()
715                .cached_tokens,
716            80
717        );
718
719        // Verify core Usage also has cached_input_tokens via GetTokenUsage
720        let core_usage = res.token_usage();
721        assert_eq!(core_usage.cached_input_tokens, 80);
722        assert_eq!(core_usage.input_tokens, 100);
723        assert_eq!(core_usage.total_tokens, 110);
724    }
725
726    /// Reproduces the bug where a proxy/gateway sends multiple parallel tool
727    /// calls all sharing `index: 0` but with distinct `id` values.  Without
728    /// the fix, rig merges both calls into one corrupted entry.
729    #[tokio::test]
730    async fn test_duplicate_index_different_id_tool_calls() {
731        use crate::test_utils::MockStreamingClient;
732        use futures::StreamExt;
733
734        // Simulate a gateway that sends two tool calls both at index 0.
735        // First tool call: id="call_aaa", name="command", args={"cmd":"ls"}
736        // Second tool call: id="call_bbb", name="git", args={"action":"log"}
737        let client = MockStreamingClient {
738            sse_bytes: sse_bytes_from_data_lines([
739                "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_aaa\",\"function\":{\"name\":\"command\",\"arguments\":\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
740                "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":null,\"function\":{\"name\":null,\"arguments\":\"{\\\"cmd\\\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
741                "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":null,\"function\":{\"name\":null,\"arguments\":\":\\\"ls\\\"}\"}}]},\"finish_reason\":null}],\"usage\":null}",
742                "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_bbb\",\"function\":{\"name\":\"git\",\"arguments\":\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
743                "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":null,\"function\":{\"name\":null,\"arguments\":\"{\\\"action\\\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
744                "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":null,\"function\":{\"name\":null,\"arguments\":\":\\\"log\\\"}\"}}]},\"finish_reason\":null}],\"usage\":null}",
745                "{\"choices\":[{\"delta\":{\"tool_calls\":[]},\"finish_reason\":\"tool_calls\"}],\"usage\":null}",
746                "{\"choices\":[],\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":10,\"total_tokens\":30}}",
747                "[DONE]",
748            ]),
749        };
750
751        let req = http::Request::builder()
752            .method("POST")
753            .uri("http://localhost/v1/chat/completions")
754            .body(Vec::new())
755            .unwrap();
756
757        let mut stream = send_compatible_streaming_request(client, req)
758            .await
759            .unwrap();
760
761        let mut collected_tool_calls = Vec::new();
762        while let Some(chunk) = stream.next().await {
763            if let streaming::StreamedAssistantContent::ToolCall {
764                tool_call,
765                internal_call_id: _,
766            } = chunk.unwrap()
767            {
768                collected_tool_calls.push(tool_call);
769            }
770        }
771
772        assert_eq!(
773            collected_tool_calls.len(),
774            2,
775            "expected 2 separate tool calls, got {collected_tool_calls:?}"
776        );
777
778        assert_eq!(collected_tool_calls[0].id, "call_aaa");
779        assert_eq!(collected_tool_calls[0].function.name, "command");
780        assert_eq!(
781            collected_tool_calls[0].function.arguments,
782            serde_json::json!({"cmd": "ls"})
783        );
784
785        assert_eq!(collected_tool_calls[1].id, "call_bbb");
786        assert_eq!(collected_tool_calls[1].function.name, "git");
787        assert_eq!(
788            collected_tool_calls[1].function.arguments,
789            serde_json::json!({"action": "log"})
790        );
791    }
792
793    #[tokio::test]
794    async fn test_tool_call_id_chunk_without_function_is_preserved() {
795        use crate::test_utils::MockStreamingClient;
796        use futures::StreamExt;
797
798        let client = MockStreamingClient {
799            sse_bytes: sse_bytes_from_data_lines([
800                "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_abc123\"}]},\"finish_reason\":null}],\"usage\":null}",
801                "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":null,\"function\":{\"name\":\"lookup\",\"arguments\":\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
802                "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":null,\"function\":{\"name\":null,\"arguments\":\"{\\\"id\\\":1}\"}}]},\"finish_reason\":null}],\"usage\":null}",
803                "{\"choices\":[{\"delta\":{\"tool_calls\":[]},\"finish_reason\":\"tool_calls\"}],\"usage\":null}",
804                "[DONE]",
805            ]),
806        };
807
808        let req = http::Request::builder()
809            .method("POST")
810            .uri("http://localhost/v1/chat/completions")
811            .body(Vec::new())
812            .unwrap();
813
814        let mut stream = send_compatible_streaming_request(client, req)
815            .await
816            .unwrap();
817
818        let mut collected_tool_calls = Vec::new();
819        while let Some(chunk) = stream.next().await {
820            if let streaming::StreamedAssistantContent::ToolCall {
821                tool_call,
822                internal_call_id: _,
823            } = chunk.unwrap()
824            {
825                collected_tool_calls.push(tool_call);
826            }
827        }
828
829        assert_eq!(
830            collected_tool_calls.len(),
831            1,
832            "expected id-only chunk to be retained for later tool-call deltas"
833        );
834        assert_eq!(collected_tool_calls[0].id, "call_abc123");
835        assert_eq!(collected_tool_calls[0].function.name, "lookup");
836        assert_eq!(
837            collected_tool_calls[0].function.arguments,
838            serde_json::json!({"id": 1})
839        );
840    }
841
842    /// Reproduces the bug where a provider (e.g. GLM-4 via OpenAI-compatible
843    /// endpoint) sends a unique `id` on every SSE delta chunk for the same
844    /// logical tool call.  Without the fix, each chunk triggers an eviction,
845    /// yielding incomplete fragments as "completed" tool calls.
846    #[tokio::test]
847    async fn test_unique_id_per_chunk_single_tool_call() {
848        use crate::test_utils::MockStreamingClient;
849        use futures::StreamExt;
850
851        // Each chunk carries a different id but they all represent delta
852        // fragments of the SAME tool call at index 0.
853        let client = MockStreamingClient {
854            sse_bytes: sse_bytes_from_data_lines([
855                "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"chatcmpl-tool-aaa\",\"function\":{\"name\":\"web_search\",\"arguments\":\"null\"}}]},\"finish_reason\":null}],\"usage\":null}",
856                "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"chatcmpl-tool-bbb\",\"function\":{\"name\":\"\",\"arguments\":\"{\\\"query\\\": \\\"META\"}}]},\"finish_reason\":null}],\"usage\":null}",
857                "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"chatcmpl-tool-ccc\",\"function\":{\"name\":\"\",\"arguments\":\" Platforms news\\\"}\"}}]},\"finish_reason\":null}],\"usage\":null}",
858                "{\"choices\":[{\"delta\":{\"tool_calls\":[]},\"finish_reason\":\"tool_calls\"}],\"usage\":null}",
859                "{\"choices\":[],\"usage\":{\"prompt_tokens\":15,\"completion_tokens\":8,\"total_tokens\":23}}",
860                "[DONE]",
861            ]),
862        };
863
864        let req = http::Request::builder()
865            .method("POST")
866            .uri("http://localhost/v1/chat/completions")
867            .body(Vec::new())
868            .unwrap();
869
870        let mut stream = send_compatible_streaming_request(client, req)
871            .await
872            .unwrap();
873
874        let mut collected_tool_calls = Vec::new();
875        while let Some(chunk) = stream.next().await {
876            if let streaming::StreamedAssistantContent::ToolCall {
877                tool_call,
878                internal_call_id: _,
879            } = chunk.unwrap()
880            {
881                collected_tool_calls.push(tool_call);
882            }
883        }
884
885        assert_eq!(
886            collected_tool_calls.len(),
887            1,
888            "expected 1 tool call (all chunks are fragments of the same call), got {collected_tool_calls:?}"
889        );
890
891        assert_eq!(collected_tool_calls[0].function.name, "web_search");
892        // The arguments should be the fully accumulated string, not fragments
893        let args_str = match &collected_tool_calls[0].function.arguments {
894            serde_json::Value::String(s) => s.clone(),
895            v => v.to_string(),
896        };
897        assert!(
898            args_str.contains("META Platforms news"),
899            "expected accumulated arguments containing the full query, got: {args_str}"
900        );
901    }
902
903    #[tokio::test]
904    async fn test_zero_arg_tool_call_normalized_on_finish_reason() {
905        use crate::test_utils::MockStreamingClient;
906
907        let client = MockStreamingClient {
908            sse_bytes: sse_bytes_from_data_lines([
909                "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_123\",\"function\":{\"name\":\"ping\",\"arguments\":\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
910                "{\"choices\":[{\"delta\":{\"tool_calls\":[]},\"finish_reason\":\"tool_calls\"}],\"usage\":null}",
911                "[DONE]",
912            ]),
913        };
914
915        let req = http::Request::builder()
916            .method("POST")
917            .uri("http://localhost/v1/chat/completions")
918            .body(Vec::new())
919            .unwrap();
920
921        let stream = send_compatible_streaming_request(client, req)
922            .await
923            .unwrap();
924
925        assert_zero_arg_tool_call_is_emitted(stream, "call_123", "ping", true).await;
926    }
927
928    #[tokio::test]
929    async fn test_zero_arg_tool_call_is_preserved_at_eof() {
930        use crate::test_utils::MockStreamingClient;
931
932        let client = MockStreamingClient {
933            sse_bytes: sse_bytes_from_data_lines([
934                "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_123\",\"function\":{\"name\":\"ping\",\"arguments\":\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
935            ]),
936        };
937
938        let req = http::Request::builder()
939            .method("POST")
940            .uri("http://localhost/v1/chat/completions")
941            .body(Vec::new())
942            .unwrap();
943
944        let stream = send_compatible_streaming_request(client, req)
945            .await
946            .unwrap();
947
948        assert_zero_arg_tool_call_is_emitted(stream, "call_123", "ping", true).await;
949    }
950}