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#[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 #[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 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 #[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), }
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#[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 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 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 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 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 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 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 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 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 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 assert_eq!(
711 res.usage
712 .prompt_tokens_details
713 .as_ref()
714 .unwrap()
715 .cached_tokens,
716 80
717 );
718
719 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 #[tokio::test]
730 async fn test_duplicate_index_different_id_tool_calls() {
731 use crate::test_utils::MockStreamingClient;
732 use futures::StreamExt;
733
734 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 #[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 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 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}