1use crate::telemetry::{CompletionOperation, CompletionSpanBuilder};
2use http::Request;
3use serde::{Deserialize, Serialize};
4use serde_json::json;
5
6use crate::completion::{CompletionError, CompletionRequest};
7use crate::http_client::HttpClientExt;
8use crate::json_utils::{self, merge};
9use crate::providers::internal::openai_chat_completions_compatible::{
10 self, CompatibleChoiceData, CompatibleChunk, CompatibleFinishReason, CompatibleStreamProfile,
11 CompatibleTerminal, CompatibleToolCallChunk,
12};
13use crate::providers::internal::wire;
14use crate::providers::openai::completion::{
15 CompletionModelOptions, GenericCompletionModel, OpenAICompatibleProvider, Usage,
16};
17use crate::streaming::{self, RawStreamingResult, StreamFinal};
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, Default)]
72struct StreamingDelta {
73 #[serde(default, deserialize_with = "deserialize_delta_content")]
74 content: Option<String>,
75 #[serde(default)]
80 refusal: Option<String>,
81 #[serde(default)]
82 reasoning_content: Option<String>,
83 #[serde(default)]
88 reasoning: Option<String>,
89 #[serde(default, deserialize_with = "json_utils::null_or_default")]
90 tool_calls: Vec<StreamingToolCall>,
91 #[serde(default, deserialize_with = "json_utils::null_or_default")]
92 reasoning_details: Vec<serde_json::Value>,
93}
94
95#[derive(Deserialize, Debug, PartialEq)]
96#[serde(rename_all = "snake_case")]
97pub enum FinishReason {
98 ToolCalls,
99 Stop,
100 ContentFilter,
101 Length,
102 #[serde(untagged)]
103 Other(String), }
105
106impl FinishReason {
107 fn as_wire(&self) -> &str {
115 match self {
116 Self::ToolCalls => "tool_calls",
117 Self::Stop => "stop",
118 Self::ContentFilter => "content_filter",
119 Self::Length => "length",
120 Self::Other(other) => other,
121 }
122 }
123}
124
125#[cfg(test)]
132pub(crate) fn map_finish_reason(reason: Option<&FinishReason>) -> CompatibleFinishReason {
133 CompatibleFinishReason::from_wire(reason.map(FinishReason::as_wire))
134}
135
136fn delta_text(delta: &StreamingDelta) -> Option<String> {
145 match delta.content.as_deref() {
146 Some(content) if !content.is_empty() => delta.content.clone(),
147 content => delta
148 .refusal
149 .clone()
150 .filter(|refusal| !refusal.is_empty())
151 .or_else(|| content.map(str::to_owned)),
152 }
153}
154
155#[derive(Deserialize, Debug)]
156struct StreamingChoice {
157 #[serde(default)]
163 delta: StreamingDelta,
164 finish_reason: Option<FinishReason>,
165 native_finish_reason: Option<String>,
168 #[serde(default)]
172 index: Option<usize>,
173 #[serde(
178 default,
179 deserialize_with = "crate::message::optional_additional_params"
180 )]
181 logprobs: Option<crate::message::AdditionalParams>,
182}
183
184#[derive(Deserialize, Debug)]
185struct StreamingCompletionChunk<U = Usage> {
186 id: Option<String>,
187 model: Option<String>,
188 choices: Vec<StreamingChoice>,
189 usage: Option<U>,
190 #[serde(flatten)]
195 additional_params: serde_json::Map<String, serde_json::Value>,
196}
197
198#[derive(Clone, Debug, Serialize, Deserialize)]
208pub struct StreamingCompletionResponse<U = Usage> {
209 pub usage: U,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub finish_reason: Option<crate::completion::FinishReason>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub response_id: Option<String>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub model: Option<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub provider_request_id: Option<String>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub logprobs: Option<serde_json::Value>,
239 #[serde(
243 default,
244 skip_serializing_if = "Option::is_none",
245 deserialize_with = "crate::message::optional_additional_params"
246 )]
247 pub additional_params: Option<crate::message::AdditionalParams>,
248}
249
250impl<U> StreamingCompletionResponse<U> {
251 pub fn new(usage: U) -> Self {
254 Self {
255 usage,
256 finish_reason: None,
257 response_id: None,
258 model: None,
259 provider_request_id: None,
260 logprobs: None,
261 additional_params: None,
262 }
263 }
264
265 pub(crate) fn from_terminal(terminal: CompatibleTerminal<U>) -> Self {
268 Self {
269 usage: terminal.usage,
270 finish_reason: terminal.finish_reason,
271 response_id: terminal.response_id,
272 model: terminal.model,
273 provider_request_id: None,
276 logprobs: terminal.logprobs.map(Into::into),
277 additional_params: terminal.additional_params,
278 }
279 }
280}
281
282impl<U> From<(&str, StreamingCompletionResponse<U>)> for StreamFinal
289where
290 U: Into<crate::completion::Usage>,
291{
292 fn from((provider, response): (&str, StreamingCompletionResponse<U>)) -> Self {
293 StreamFinal::new(provider, response.usage.into())
294 .with_optional_finish_reason(response.finish_reason)
295 .with_optional_response_id(response.response_id)
296 .with_optional_provider_request_id(response.provider_request_id)
297 .with_optional_model(response.model)
298 }
299}
300
301impl<Ext, H> GenericCompletionModel<Ext, H>
302where
303 crate::client::Client<Ext, H>: HttpClientExt + Clone + 'static,
304 Ext: crate::client::Provider
305 + OpenAICompatibleProvider
306 + Clone
307 + crate::wasm_compat::WasmCompatSend
308 + 'static,
309{
310 pub async fn raw_stream(
320 &self,
321 completion_request: CompletionRequest,
322 ) -> Result<RawStreamingResult<StreamingCompletionResponse<Ext::StreamingUsage>>, CompletionError>
323 {
324 let preamble = completion_request.preamble.clone();
325 let record_telemetry_content = completion_request.record_telemetry_content;
326 let options = CompletionModelOptions {
327 strict_tools: self.strict_tools,
328 tool_result_array_content: self.tool_result_array_content,
329 prompt_caching: self.prompt_caching,
330 };
331 let mut request = self.client.ext().build_completion_request(
332 self.model.clone(),
333 completion_request,
334 options,
335 )?;
336 self.client.ext().prepare_request(&mut request)?;
337
338 let path = self.client.ext().completion_path(&self.model);
341 let resolved_model = request.model.clone();
342 let modern_output_cap = self.sends_modern_output_cap(&request.model);
343 let mut request_as_json =
344 crate::providers::openai::completion::request_body(&request, modern_output_cap)?;
345
346 if Ext::STREAM_INCLUDE_USAGE {
350 match request_as_json.get_mut("stream_options") {
351 Some(serde_json::Value::Object(options)) => {
352 options
353 .entry("include_usage")
354 .or_insert(serde_json::Value::Bool(true));
355 }
356 Some(_) => {}
357 None => {
358 request_as_json = merge(
359 request_as_json,
360 json!({"stream_options": {"include_usage": true}}),
361 );
362 }
363 }
364 }
365 request_as_json = merge(request_as_json, json!({"stream": true}));
366 self.client
367 .ext()
368 .finalize_request_body_with_options(&mut request_as_json, options)?;
369
370 crate::providers::internal::trace_json(
371 crate::providers::internal::LogTarget::Completions,
372 "OpenAI Chat Completions streaming completion request",
373 &request_as_json,
374 );
375
376 let req_body = serde_json::to_vec(&request_as_json)?;
377
378 let req = self
379 .client
380 .post(&path)?
381 .body(req_body)
382 .map_err(|e| CompletionError::HttpError(e.into()))?;
383
384 let span = CompletionSpanBuilder::new(
385 Ext::PROVIDER_NAME,
386 &resolved_model,
387 CompletionOperation::Chat,
388 )
389 .system_instructions(preamble.as_deref(), record_telemetry_content)
390 .build();
391
392 let client = self.client.clone();
393
394 tracing::Instrument::instrument(
395 openai_chat_completions_compatible::send_compatible_raw_streaming_request(
396 client,
397 req,
398 Ext::REQUEST_ID_HEADER,
399 OpenAICompatibleProfile::<Ext, Ext::StreamingUsage> {
400 provider: self.client.ext().clone(),
401 emits_complete_single_chunk_tool_calls:
402 Ext::EMITS_COMPLETE_SINGLE_CHUNK_TOOL_CALLS,
403 usage: std::marker::PhantomData,
404 },
405 ),
406 span,
407 )
408 .await
409 }
410
411 pub(crate) async fn stream(
416 &self,
417 completion_request: CompletionRequest,
418 ) -> Result<streaming::StreamingCompletionResponse, CompletionError> {
419 let stream = self.raw_stream(completion_request).await?;
420
421 Ok(streaming::StreamingCompletionResponse::stream(
422 Ext::PROVIDER_NAME,
423 streaming::normalize_stream(stream, |response| {
424 Ok((Ext::PROVIDER_NAME, response).into())
425 }),
426 ))
427 }
428}
429
430#[derive(Clone, Copy, Default)]
431struct OpenAICompatibleProfile<Ext = crate::providers::openai::OpenAICompletionsExt, U = Usage> {
432 provider: Ext,
433 emits_complete_single_chunk_tool_calls: bool,
434 usage: std::marker::PhantomData<U>,
435}
436
437impl<Ext, U> CompatibleStreamProfile for OpenAICompatibleProfile<Ext, U>
438where
439 Ext: OpenAICompatibleProvider + Clone + crate::wasm_compat::WasmCompatSend,
440 U: Clone
441 + Default
442 + Into<crate::completion::Usage>
443 + serde::de::DeserializeOwned
444 + crate::wasm_compat::WasmCompatSend
445 + 'static,
446{
447 type Usage = U;
448 type Detail = serde_json::Value;
449 type FinalResponse = StreamingCompletionResponse<Self::Usage>;
450
451 fn stamp_request_id(response: &mut Self::FinalResponse, request_id: String) {
452 response.provider_request_id = Some(request_id);
453 }
454
455 fn classify_chunk(
456 &self,
457 data: &str,
458 ) -> wire::WireEvent<CompatibleChunk<Self::Usage, Self::Detail>> {
459 wire::classify_chat_completions_frame::<StreamingCompletionChunk<U>>(data).map(|data| {
462 let primary = data
468 .choices
469 .iter()
470 .position(|choice| choice.index.is_none_or(|index| index == 0))
471 .and_then(|position| data.choices.get(position))
472 .map(std::slice::from_ref)
473 .unwrap_or_default();
474
475 openai_chat_completions_compatible::normalize_first_choice_chunk(
476 data.id,
477 data.model,
478 data.usage,
479 crate::message::AdditionalParams::new(data.additional_params),
480 primary,
481 |choice| CompatibleChoiceData {
482 finish_reason: match self.provider.map_streaming_finish_reason(
486 choice.finish_reason.as_ref().map(FinishReason::as_wire),
487 choice.native_finish_reason.as_deref(),
488 ) {
489 Some(reason) => CompatibleFinishReason::Reported(reason),
490 None => CompatibleFinishReason::Absent,
491 },
492 text: delta_text(&choice.delta),
493 reasoning: choice
494 .delta
495 .reasoning_content
496 .clone()
497 .or_else(|| choice.delta.reasoning.clone()),
498 tool_calls: openai_chat_completions_compatible::tool_call_chunks(
499 &choice.delta.tool_calls,
500 ),
501 details: choice.delta.reasoning_details.clone(),
502 logprobs: choice.logprobs.clone(),
503 },
504 )
505 })
506 }
507
508 fn build_final_response(
509 &self,
510 terminal: CompatibleTerminal<Self::Usage>,
511 ) -> Self::FinalResponse {
512 StreamingCompletionResponse::from_terminal(terminal)
513 }
514
515 fn detail_reasoning(
516 &self,
517 detail: &Self::Detail,
518 ) -> Option<(
519 crate::streaming::StreamPartId,
520 Option<crate::streaming::WireId>,
521 crate::message::ReasoningContent,
522 )> {
523 self.provider.streaming_detail_reasoning(detail)
524 }
525
526 fn reasoning_signature(&self, detail: &Self::Detail) -> Option<String> {
527 self.provider.streaming_reasoning_signature(detail)
528 }
529
530 fn decorate_tool_call(
531 &self,
532 detail: &Self::Detail,
533 ) -> Option<crate::streaming::ToolCallDecoration> {
534 self.provider.decorate_streaming_tool_call(detail)
535 }
536
537 fn uses_distinct_tool_call_eviction(&self) -> bool {
538 true
539 }
540
541 fn emits_complete_single_chunk_tool_calls(&self) -> bool {
542 self.emits_complete_single_chunk_tool_calls
543 }
544}
545
546pub(crate) async fn send_compatible_raw_streaming_request<T>(
549 http_client: T,
550 req: Request<Vec<u8>>,
551) -> Result<RawStreamingResult<StreamingCompletionResponse<Usage>>, CompletionError>
552where
553 T: HttpClientExt + Clone + 'static,
554{
555 openai_chat_completions_compatible::send_compatible_raw_streaming_request(
556 http_client,
557 req,
558 <crate::providers::openai::OpenAICompletionsExt as OpenAICompatibleProvider>::REQUEST_ID_HEADER,
559 OpenAICompatibleProfile::<crate::providers::openai::OpenAICompletionsExt, Usage>::default(),
560 )
561 .await
562}
563
564pub async fn send_compatible_streaming_request<T>(
572 http_client: T,
573 req: Request<Vec<u8>>,
574 provider: impl Into<String>,
575) -> Result<streaming::StreamingCompletionResponse, CompletionError>
576where
577 T: HttpClientExt + Clone + 'static,
578{
579 let provider = provider.into();
580 let stream = send_compatible_raw_streaming_request(http_client, req).await?;
581
582 let mapper_provider = provider.clone();
583 Ok(streaming::StreamingCompletionResponse::stream(
584 provider,
585 streaming::normalize_stream(stream, move |response| {
586 Ok((mapper_provider.as_str(), response).into())
587 }),
588 ))
589}
590
591#[cfg(test)]
592mod tests {
593 use super::*;
594 use crate::completion::FinishReason as NormalizedFinishReason;
595 use crate::providers::internal::openai_chat_completions_compatible::test_support::{
596 assert_zero_arg_tool_call_is_emitted, sse_bytes_from_data_lines,
597 };
598
599 fn streaming_request() -> http::Request<Vec<u8>> {
600 http::Request::builder()
601 .method("POST")
602 .uri("http://localhost/v1/chat/completions")
603 .body(Vec::new())
604 .unwrap()
605 }
606
607 #[test]
608 fn test_finish_reason_mapping_covers_every_wire_value() {
609 for (wire, expected) in [
610 (FinishReason::Stop, NormalizedFinishReason::Stop),
611 (FinishReason::Length, NormalizedFinishReason::Length),
612 (FinishReason::ToolCalls, NormalizedFinishReason::ToolCalls),
613 (
614 FinishReason::ContentFilter,
615 NormalizedFinishReason::ContentFilter,
616 ),
617 (
619 FinishReason::Other("function_call".to_string()),
620 NormalizedFinishReason::ToolCalls,
621 ),
622 (
624 FinishReason::Other("max_tokens".to_string()),
625 NormalizedFinishReason::Length,
626 ),
627 ] {
628 assert_eq!(
629 map_finish_reason(Some(&wire)),
630 CompatibleFinishReason::Reported(expected),
631 "unexpected mapping for {wire:?}"
632 );
633 }
634 }
635
636 #[test]
637 fn test_unknown_finish_reason_is_preserved_verbatim() {
638 let wire = FinishReason::Other("GUARDRAIL_INTERVENED".to_string());
639
640 assert_eq!(
641 map_finish_reason(Some(&wire)),
642 CompatibleFinishReason::Reported(NormalizedFinishReason::Other(
643 "GUARDRAIL_INTERVENED".to_string()
644 )),
645 "an unrecognized reason must survive in the provider's own spelling"
646 );
647 }
648
649 #[test]
650 fn test_missing_or_empty_finish_reason_is_absent() {
651 assert_eq!(map_finish_reason(None), CompatibleFinishReason::Absent);
652 assert_eq!(
653 map_finish_reason(Some(&FinishReason::Other(String::new()))),
654 CompatibleFinishReason::Absent,
655 "an empty finish_reason must not read as a provider-reported reason"
656 );
657 }
658
659 fn delta(wire: serde_json::Value) -> StreamingDelta {
661 serde_json::from_value(wire).expect("delta should decode")
662 }
663
664 async fn collect_openai_stream(
667 chunks: &[&str],
668 ) -> (String, Option<crate::streaming::StreamFinal>) {
669 use crate::test_utils::MockStreamingClient;
670 use futures::StreamExt;
671
672 let client = MockStreamingClient {
673 sse_bytes: sse_bytes_from_data_lines(
674 chunks.iter().copied().chain(std::iter::once("[DONE]")),
675 ),
676 };
677 let mut stream = send_compatible_streaming_request(client, streaming_request(), "openai")
678 .await
679 .expect("stream should open");
680
681 let mut text = String::new();
682 let mut terminal = None;
683 while let Some(chunk) = stream.next().await {
684 match chunk.expect("stream item") {
685 streaming::StreamedAssistantContent::Text(chunk) => text.push_str(&chunk.text),
686 streaming::StreamedAssistantContent::Final(final_record) => {
687 terminal = Some(final_record);
688 }
689 _ => {}
690 }
691 }
692
693 (text, terminal)
694 }
695
696 async fn collect_openai_raw_terminal(chunks: &[&str]) -> Option<StreamingCompletionResponse> {
699 use crate::test_utils::MockStreamingClient;
700 use futures::StreamExt;
701
702 let client = MockStreamingClient {
703 sse_bytes: sse_bytes_from_data_lines(
704 chunks.iter().copied().chain(std::iter::once("[DONE]")),
705 ),
706 };
707 let mut stream = send_compatible_raw_streaming_request(client, streaming_request())
708 .await
709 .expect("raw stream should open");
710
711 let mut terminal = None;
712 while let Some(chunk) = stream.next().await {
713 if let streaming::RawStreamingChoice::FinalResponse(response) =
714 chunk.expect("stream item")
715 {
716 terminal = Some(response);
717 }
718 }
719 terminal
720 }
721
722 #[tokio::test]
727 async fn raw_terminal_accumulates_streamed_logprobs() {
728 let chunks = [
729 r#"{"choices":[{"index":0,"delta":{"reasoning_content":"why"},"finish_reason":null,"logprobs":{"reasoning_content":[{"token":"why","top_logprobs":[{"token":"why"}]}]}}]}"#,
730 r#"{"choices":[{"index":0,"delta":{"content":"co"},"finish_reason":null,"logprobs":{"content":[{"token":"co","top_logprobs":[{"token":"co"}]}]}}]}"#,
731 r#"{"choices":[{"index":0,"delta":{"content":"balt"},"finish_reason":null,"logprobs":{"content":[{"token":"balt","top_logprobs":[{"token":"balt"}]}]}}]}"#,
732 r#"{"choices":[{"index":0,"delta":{},"finish_reason":"stop","logprobs":null}]}"#,
733 ];
734
735 let terminal = collect_openai_raw_terminal(&chunks)
736 .await
737 .expect("stream should terminate");
738 assert_eq!(
739 terminal.logprobs,
740 Some(json!({
741 "reasoning_content": [{
742 "token": "why",
743 "top_logprobs": [{"token": "why"}]
744 }],
745 "content": [
746 {"token": "co", "top_logprobs": [{"token": "co"}]},
747 {"token": "balt", "top_logprobs": [{"token": "balt"}]}
748 ]
749 }))
750 );
751 }
752
753 #[tokio::test]
758 async fn raw_terminal_retains_top_level_chunk_metadata() {
759 let chunks = [
760 r#"{"id":"chatcmpl-1","model":"gpt-test","object":"chat.completion.chunk","created":17,"system_fingerprint":"fp_one","service_tier":"default","provider":"OpenAI","choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":null}]}"#,
761 r#"{"id":"chatcmpl-1","model":"gpt-test","object":"chat.completion.chunk","created":17,"system_fingerprint":"fp_one","service_tier":"priority","provider":"OpenAI","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}"#,
762 ];
763
764 let terminal = collect_openai_raw_terminal(&chunks)
765 .await
766 .expect("stream should terminate");
767 let params = terminal
768 .additional_params
769 .expect("top-level metadata should survive");
770
771 assert_eq!(params["object"], "chat.completion.chunk");
772 assert_eq!(params["created"], 17);
773 assert_eq!(params["system_fingerprint"], "fp_one");
774 assert_eq!(params["service_tier"], "priority");
775 assert_eq!(params["provider"], "OpenAI");
776 }
777
778 #[test]
782 fn empty_and_null_streamed_logprobs_canonicalize_to_absence() {
783 for logprobs in [serde_json::Value::Null, json!({})] {
784 let chunk = json!({
785 "choices": [{
786 "index": 0,
787 "delta": {"content": "hi"},
788 "finish_reason": null,
789 "logprobs": logprobs
790 }]
791 });
792 let decoded = serde_json::from_value::<StreamingCompletionChunk<Usage>>(chunk)
793 .expect("an empty optional metadata shape should decode");
794 assert!(
795 decoded
796 .choices
797 .first()
798 .expect("the fixture has one choice")
799 .logprobs
800 .is_none()
801 );
802 }
803 }
804
805 #[test]
808 fn non_object_streamed_logprobs_remain_loud() {
809 for logprobs in [json!([]), json!("invalid"), json!(42)] {
810 let chunk = json!({
811 "choices": [{
812 "index": 0,
813 "delta": {"content": "hi"},
814 "finish_reason": null,
815 "logprobs": logprobs
816 }]
817 });
818 assert!(
819 serde_json::from_value::<StreamingCompletionChunk<Usage>>(chunk).is_err(),
820 "non-object logprobs must not be silently discarded"
821 );
822 }
823 }
824
825 #[test]
830 fn delta_text_takes_the_refusal_when_content_is_null() {
831 assert_eq!(
832 delta_text(&delta(json!({ "content": null, "refusal": "I'm" }))),
833 Some("I'm".to_string())
834 );
835 assert_eq!(
836 delta_text(&delta(json!({ "refusal": " sorry" }))),
837 Some(" sorry".to_string())
838 );
839 }
840
841 #[test]
844 fn delta_text_ignores_the_opening_empty_refusal() {
845 assert_eq!(
846 delta_text(&delta(
847 json!({ "role": "assistant", "content": null, "refusal": "" })
848 )),
849 None
850 );
851 }
852
853 #[test]
856 fn delta_text_prefers_content_and_leaves_it_unchanged() {
857 assert_eq!(
858 delta_text(&delta(json!({ "content": "hello" }))),
859 Some("hello".to_string())
860 );
861 assert_eq!(
862 delta_text(&delta(json!({ "content": "" }))),
863 Some(String::new())
864 );
865 assert_eq!(delta_text(&delta(json!({}))), None);
866 }
867
868 #[test]
881 fn delta_text_prefers_content_over_a_simultaneous_refusal() {
882 assert_eq!(
883 delta_text(&delta(json!({ "content": "answer", "refusal": "no" }))),
884 Some("answer".to_string())
885 );
886 assert_eq!(
887 delta_text(&delta(json!({ "content": "", "refusal": "no" }))),
888 Some("no".to_string()),
889 "an empty content string must not suppress a real refusal"
890 );
891 }
892
893 #[tokio::test]
896 async fn refusal_only_stream_delivers_the_refusal_text() {
897 let chunks = [
898 r#"{"id":"chatcmpl-1","model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":null,"refusal":""},"finish_reason":null}]}"#,
899 r#"{"id":"chatcmpl-1","model":"gpt-4o","choices":[{"index":0,"delta":{"refusal":"I'm sorry"},"finish_reason":null}]}"#,
900 r#"{"id":"chatcmpl-1","model":"gpt-4o","choices":[{"index":0,"delta":{"refusal":", I can't help."},"finish_reason":null}]}"#,
901 r#"{"id":"chatcmpl-1","model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#,
902 r#"{"id":"chatcmpl-1","model":"gpt-4o","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18}}"#,
903 ];
904
905 let (text, terminal) = collect_openai_stream(&chunks).await;
906
907 assert_eq!(text, "I'm sorry, I can't help.");
908 let terminal = terminal.expect("a refusal turn still ends with a terminal record");
909 assert_eq!(terminal.finish_reason, Some(NormalizedFinishReason::Stop));
910 assert_eq!(terminal.usage.output_tokens, 8);
911 }
912
913 #[test]
914 fn test_streaming_function_deserialization() {
915 let json = r#"{"name": "get_weather", "arguments": "{\"location\":\"Paris\"}"}"#;
916 let function: StreamingFunction = serde_json::from_str(json).unwrap();
917 assert_eq!(function.name, Some("get_weather".to_string()));
918 assert_eq!(
919 function.arguments.as_ref().unwrap(),
920 r#"{"location":"Paris"}"#
921 );
922 }
923
924 #[test]
925 fn test_streaming_function_object_arguments() {
926 let json = r#"{"name": "list_dir", "arguments": {}}"#;
930 let function: StreamingFunction = serde_json::from_str(json).unwrap();
931 assert_eq!(function.name, Some("list_dir".to_string()));
932 assert_eq!(function.arguments.as_ref().unwrap(), "{}");
933
934 let json = r#"{"name": "get_weather", "arguments": {"city": "London"}}"#;
935 let function: StreamingFunction = serde_json::from_str(json).unwrap();
936 assert_eq!(function.arguments.as_ref().unwrap(), r#"{"city":"London"}"#);
937 }
938
939 #[test]
940 fn test_streaming_function_null_arguments() {
941 let json = r#"{"name": "list_dir", "arguments": null}"#;
942 let function: StreamingFunction = serde_json::from_str(json).unwrap();
943 assert!(function.arguments.is_none());
944
945 let json = r#"{"name": "list_dir"}"#;
946 let function: StreamingFunction = serde_json::from_str(json).unwrap();
947 assert!(function.arguments.is_none());
948 }
949
950 #[test]
951 fn test_streaming_tool_call_deserialization() {
952 let json = r#"{
953 "index": 0,
954 "id": "call_abc123",
955 "function": {
956 "name": "get_weather",
957 "arguments": "{\"city\":\"London\"}"
958 }
959 }"#;
960 let tool_call: StreamingToolCall = serde_json::from_str(json).unwrap();
961 assert_eq!(tool_call.index, 0);
962 assert_eq!(tool_call.id, Some("call_abc123".to_string()));
963 assert_eq!(tool_call.function.name, Some("get_weather".to_string()));
964 }
965
966 #[test]
967 fn test_streaming_tool_call_partial_deserialization() {
968 let json = r#"{
970 "index": 0,
971 "id": null,
972 "function": {
973 "name": null,
974 "arguments": "Paris"
975 }
976 }"#;
977 let tool_call: StreamingToolCall = serde_json::from_str(json).unwrap();
978 assert_eq!(tool_call.index, 0);
979 assert!(tool_call.id.is_none());
980 assert!(tool_call.function.name.is_none());
981 assert_eq!(tool_call.function.arguments.as_ref().unwrap(), "Paris");
982 }
983
984 #[test]
985 fn test_streaming_tool_call_missing_function_deserialization() {
986 let json = r#"{
987 "index": 0,
988 "id": "call_abc123"
989 }"#;
990 let tool_call: StreamingToolCall = serde_json::from_str(json).unwrap();
991 assert_eq!(tool_call.index, 0);
992 assert_eq!(tool_call.id, Some("call_abc123".to_string()));
993 assert!(tool_call.function.name.is_none());
994 assert!(tool_call.function.arguments.is_none());
995 }
996
997 #[test]
998 fn test_streaming_tool_call_null_function_deserialization() {
999 let json = r#"{
1000 "index": 0,
1001 "id": "call_abc123",
1002 "function": null
1003 }"#;
1004 let tool_call: StreamingToolCall = serde_json::from_str(json).unwrap();
1005 assert_eq!(tool_call.index, 0);
1006 assert_eq!(tool_call.id, Some("call_abc123".to_string()));
1007 assert!(tool_call.function.name.is_none());
1008 assert!(tool_call.function.arguments.is_none());
1009 }
1010
1011 #[test]
1012 fn test_streaming_delta_with_tool_calls() {
1013 let json = r#"{
1014 "content": null,
1015 "tool_calls": [{
1016 "index": 0,
1017 "id": "call_xyz",
1018 "function": {
1019 "name": "search",
1020 "arguments": ""
1021 }
1022 }]
1023 }"#;
1024 let delta: StreamingDelta = serde_json::from_str(json).unwrap();
1025 assert!(delta.content.is_none());
1026 assert_eq!(delta.tool_calls.len(), 1);
1027 assert_eq!(delta.tool_calls[0].id, Some("call_xyz".to_string()));
1028 }
1029
1030 #[test]
1031 fn test_streaming_delta_with_null_tool_calls() {
1032 let json = r#"{
1033 "content": "Hello",
1034 "tool_calls": null
1035 }"#;
1036 let delta: StreamingDelta = serde_json::from_str(json).unwrap();
1037 assert_eq!(delta.content, Some("Hello".to_string()));
1038 assert!(delta.tool_calls.is_empty());
1039 }
1040
1041 #[test]
1042 fn test_streaming_chunk_deserialization() {
1043 let json = r#"{
1044 "choices": [{
1045 "delta": {
1046 "content": "Hello",
1047 "tool_calls": []
1048 }
1049 }],
1050 "usage": {
1051 "prompt_tokens": 10,
1052 "completion_tokens": 5,
1053 "total_tokens": 15
1054 }
1055 }"#;
1056 let chunk: StreamingCompletionChunk = serde_json::from_str(json).unwrap();
1057 assert_eq!(chunk.choices.len(), 1);
1058 assert_eq!(chunk.choices[0].delta.content, Some("Hello".to_string()));
1059 assert!(chunk.usage.is_some());
1060 }
1061
1062 #[test]
1063 fn test_streaming_chunk_with_multiple_tool_call_deltas() {
1064 let json_start = r#"{
1066 "choices": [{
1067 "delta": {
1068 "content": null,
1069 "tool_calls": [{
1070 "index": 0,
1071 "id": "call_123",
1072 "function": {
1073 "name": "get_weather",
1074 "arguments": ""
1075 }
1076 }]
1077 }
1078 }],
1079 "usage": null
1080 }"#;
1081
1082 let json_chunk1 = r#"{
1083 "choices": [{
1084 "delta": {
1085 "content": null,
1086 "tool_calls": [{
1087 "index": 0,
1088 "id": null,
1089 "function": {
1090 "name": null,
1091 "arguments": "{\"loc"
1092 }
1093 }]
1094 }
1095 }],
1096 "usage": null
1097 }"#;
1098
1099 let json_chunk2 = r#"{
1100 "choices": [{
1101 "delta": {
1102 "content": null,
1103 "tool_calls": [{
1104 "index": 0,
1105 "id": null,
1106 "function": {
1107 "name": null,
1108 "arguments": "ation\":\"NYC\"}"
1109 }
1110 }]
1111 }
1112 }],
1113 "usage": null
1114 }"#;
1115
1116 let start_chunk: StreamingCompletionChunk = serde_json::from_str(json_start).unwrap();
1118 assert_eq!(start_chunk.choices[0].delta.tool_calls.len(), 1);
1119 assert_eq!(
1120 start_chunk.choices[0].delta.tool_calls[0]
1121 .function
1122 .name
1123 .as_ref()
1124 .unwrap(),
1125 "get_weather"
1126 );
1127
1128 let chunk1: StreamingCompletionChunk = serde_json::from_str(json_chunk1).unwrap();
1129 assert_eq!(chunk1.choices[0].delta.tool_calls.len(), 1);
1130 assert_eq!(
1131 chunk1.choices[0].delta.tool_calls[0]
1132 .function
1133 .arguments
1134 .as_ref()
1135 .unwrap(),
1136 "{\"loc"
1137 );
1138
1139 let chunk2: StreamingCompletionChunk = serde_json::from_str(json_chunk2).unwrap();
1140 assert_eq!(chunk2.choices[0].delta.tool_calls.len(), 1);
1141 assert_eq!(
1142 chunk2.choices[0].delta.tool_calls[0]
1143 .function
1144 .arguments
1145 .as_ref()
1146 .unwrap(),
1147 "ation\":\"NYC\"}"
1148 );
1149 }
1150
1151 #[tokio::test]
1152 async fn test_streaming_usage_only_chunk_is_not_ignored() {
1153 use crate::test_utils::MockStreamingClient;
1154 use futures::StreamExt;
1155
1156 let client = MockStreamingClient {
1158 sse_bytes: sse_bytes_from_data_lines([
1159 "{\"choices\":[{\"delta\":{\"content\":\"Hello\",\"tool_calls\":[]}}],\"usage\":null}",
1160 "{\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}",
1161 "[DONE]",
1162 ]),
1163 };
1164
1165 let mut stream = send_compatible_streaming_request(client, streaming_request(), "openai")
1166 .await
1167 .unwrap();
1168
1169 let mut final_usage = None;
1170 while let Some(chunk) = stream.next().await {
1171 if let streaming::StreamedAssistantContent::Final(res) = chunk.unwrap() {
1172 final_usage = Some(res.usage);
1173 break;
1174 }
1175 }
1176
1177 let usage = final_usage.expect("expected a final response with usage");
1178 assert_eq!(usage.input_tokens, 10);
1179 assert_eq!(usage.total_tokens, 15);
1180 }
1181
1182 #[tokio::test]
1183 async fn test_streaming_final_record_carries_provider_metadata() {
1184 use crate::test_utils::MockStreamingClient;
1185 use futures::StreamExt;
1186
1187 let client = MockStreamingClient {
1188 sse_bytes: sse_bytes_from_data_lines([
1189 "{\"id\":\"chatcmpl-42\",\"model\":\"gpt-5.2-2026-01-01\",\"choices\":[{\"delta\":{\"content\":\"hi\"},\"finish_reason\":null}],\"usage\":null}",
1190 "{\"id\":\"chatcmpl-42\",\"model\":\"gpt-5.2-2026-01-01\",\"choices\":[{\"delta\":{},\"finish_reason\":\"length\"}],\"usage\":null}",
1191 "[DONE]",
1192 ]),
1193 };
1194
1195 let mut stream = send_compatible_streaming_request(client, streaming_request(), "openai")
1196 .await
1197 .unwrap();
1198
1199 let mut final_response = None;
1200 while let Some(chunk) = stream.next().await {
1201 if let streaming::StreamedAssistantContent::Final(res) = chunk.unwrap() {
1202 final_response = Some(res);
1203 break;
1204 }
1205 }
1206
1207 let res = final_response.expect("expected a final response");
1208 assert_eq!(res.provider, "openai");
1209 assert_eq!(res.response_id.as_deref(), Some("chatcmpl-42"));
1210 assert_eq!(res.message_id, None);
1211 assert_eq!(res.model.as_deref(), Some("gpt-5.2-2026-01-01"));
1212 assert_eq!(res.finish_reason, Some(NormalizedFinishReason::Length));
1213 }
1214
1215 #[tokio::test]
1216 async fn test_streaming_unknown_finish_reason_reaches_the_final_record() {
1217 use crate::test_utils::MockStreamingClient;
1218 use futures::StreamExt;
1219
1220 let client = MockStreamingClient {
1221 sse_bytes: sse_bytes_from_data_lines([
1222 "{\"choices\":[{\"delta\":{\"content\":\"hi\"},\"finish_reason\":null}],\"usage\":null}",
1223 "{\"choices\":[{\"delta\":{},\"finish_reason\":\"GUARDRAIL_INTERVENED\"}],\"usage\":null}",
1224 "[DONE]",
1225 ]),
1226 };
1227
1228 let mut stream = send_compatible_streaming_request(client, streaming_request(), "openai")
1229 .await
1230 .unwrap();
1231
1232 let mut final_response = None;
1233 while let Some(chunk) = stream.next().await {
1234 if let streaming::StreamedAssistantContent::Final(res) = chunk.unwrap() {
1235 final_response = Some(res);
1236 break;
1237 }
1238 }
1239
1240 let res = final_response.expect("expected a final response");
1241 assert_eq!(
1242 res.finish_reason,
1243 Some(NormalizedFinishReason::Other(
1244 "GUARDRAIL_INTERVENED".to_string()
1245 ))
1246 );
1247 }
1248
1249 #[tokio::test]
1254 async fn test_stop_finish_reason_upgrades_to_tool_calls() {
1255 use crate::test_utils::MockStreamingClient;
1256 use futures::StreamExt;
1257
1258 let client = MockStreamingClient {
1259 sse_bytes: sse_bytes_from_data_lines([
1260 "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"ping\",\"arguments\":\"{}\"}}]},\"finish_reason\":null}],\"usage\":null}",
1261 "{\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":null}",
1262 "[DONE]",
1263 ]),
1264 };
1265
1266 let mut stream = send_compatible_streaming_request(client, streaming_request(), "openai")
1267 .await
1268 .unwrap();
1269
1270 let mut saw_tool_call = false;
1271 let mut final_response = None;
1272 while let Some(chunk) = stream.next().await {
1273 match chunk.unwrap() {
1274 streaming::StreamedAssistantContent::ToolCall { .. } => saw_tool_call = true,
1275 streaming::StreamedAssistantContent::Final(res) => final_response = Some(res),
1276 _ => {}
1277 }
1278 }
1279
1280 assert!(saw_tool_call, "expected the tool call to be emitted");
1281 let res = final_response.expect("expected a final response");
1282 assert_eq!(res.finish_reason, Some(NormalizedFinishReason::ToolCalls));
1283 }
1284
1285 #[tokio::test]
1286 async fn test_streaming_reasoning_content_and_text_chunks_are_incremental() {
1287 use crate::test_utils::MockStreamingClient;
1288 use futures::StreamExt;
1289
1290 let client = MockStreamingClient {
1291 sse_bytes: sse_bytes_from_data_lines([
1292 "{\"id\":\"cmpl-1\",\"model\":\"Qwen/Qwen3-4B\",\"choices\":[{\"delta\":{\"reasoning_content\":\"think \",\"tool_calls\":[]},\"finish_reason\":null}],\"usage\":null}",
1293 "{\"id\":\"cmpl-1\",\"model\":\"Qwen/Qwen3-4B\",\"choices\":[{\"delta\":{\"reasoning_content\":\"more\",\"tool_calls\":[]},\"finish_reason\":null}],\"usage\":null}",
1294 "{\"id\":\"cmpl-1\",\"model\":\"Qwen/Qwen3-4B\",\"choices\":[{\"delta\":{\"content\":\"hel\",\"tool_calls\":[]},\"finish_reason\":null}],\"usage\":null}",
1295 "{\"id\":\"cmpl-1\",\"model\":\"Qwen/Qwen3-4B\",\"choices\":[{\"delta\":{\"content\":\"lo\",\"tool_calls\":[]},\"finish_reason\":\"stop\"}],\"usage\":null}",
1296 "{\"choices\":[],\"usage\":{\"prompt_tokens\":4,\"completion_tokens\":6,\"total_tokens\":10}}",
1297 "[DONE]",
1298 ]),
1299 };
1300
1301 let mut stream = send_compatible_streaming_request(client, streaming_request(), "openai")
1302 .await
1303 .unwrap();
1304
1305 let mut reasoning_chunks = Vec::new();
1306 let mut text_chunks = Vec::new();
1307 let mut final_response = None;
1308
1309 while let Some(chunk) = stream.next().await {
1310 match chunk.unwrap() {
1311 streaming::StreamedAssistantContent::ReasoningDelta { reasoning, .. } => {
1312 reasoning_chunks.push(reasoning)
1313 }
1314 streaming::StreamedAssistantContent::Text(text) => text_chunks.push(text.text),
1315 streaming::StreamedAssistantContent::Final(response) => {
1316 final_response = Some(response)
1317 }
1318 _ => {}
1319 }
1320 }
1321
1322 assert_eq!(
1323 reasoning_chunks,
1324 vec!["think ".to_string(), "more".to_string()]
1325 );
1326 assert_eq!(text_chunks, vec!["hel".to_string(), "lo".to_string()]);
1327
1328 let response = final_response.expect("expected final usage");
1329 assert_eq!(response.usage.input_tokens, 4);
1330 assert_eq!(response.usage.output_tokens, 6);
1331 assert_eq!(response.usage.total_tokens, 10);
1332 assert_eq!(response.finish_reason, Some(NormalizedFinishReason::Stop));
1333 }
1334
1335 #[tokio::test]
1336 async fn test_streaming_cached_input_tokens_populated() {
1337 use crate::streaming::RawStreamingChoice;
1338 use crate::test_utils::MockStreamingClient;
1339 use futures::StreamExt;
1340
1341 let client = MockStreamingClient {
1343 sse_bytes: sse_bytes_from_data_lines([
1344 "{\"choices\":[{\"delta\":{\"content\":\"Hi\",\"tool_calls\":[]}}],\"usage\":null}",
1345 "{\"choices\":[],\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":10,\"total_tokens\":110,\"prompt_tokens_details\":{\"cached_tokens\":80}}}",
1346 "[DONE]",
1347 ]),
1348 };
1349
1350 let mut stream = send_compatible_raw_streaming_request(client, streaming_request())
1354 .await
1355 .unwrap();
1356
1357 let mut final_response = None;
1358 while let Some(chunk) = stream.next().await {
1359 if let RawStreamingChoice::FinalResponse(res) = chunk.unwrap() {
1360 final_response = Some(res);
1361 break;
1362 }
1363 }
1364
1365 let res = final_response.expect("expected a final response");
1366
1367 assert_eq!(
1369 res.usage
1370 .prompt_tokens_details
1371 .as_ref()
1372 .unwrap()
1373 .cached_tokens,
1374 80
1375 );
1376
1377 let core_usage = crate::completion::Usage::from(res.usage);
1379 assert_eq!(core_usage.cached_input_tokens, 80);
1380 assert_eq!(core_usage.input_tokens, 100);
1381 assert_eq!(core_usage.total_tokens, 110);
1382 }
1383
1384 #[tokio::test]
1388 async fn test_duplicate_index_different_id_tool_calls() {
1389 use crate::test_utils::MockStreamingClient;
1390 use futures::StreamExt;
1391
1392 let client = MockStreamingClient {
1396 sse_bytes: sse_bytes_from_data_lines([
1397 "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_aaa\",\"function\":{\"name\":\"command\",\"arguments\":\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
1398 "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":null,\"function\":{\"name\":null,\"arguments\":\"{\\\"cmd\\\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
1399 "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":null,\"function\":{\"name\":null,\"arguments\":\":\\\"ls\\\"}\"}}]},\"finish_reason\":null}],\"usage\":null}",
1400 "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_bbb\",\"function\":{\"name\":\"git\",\"arguments\":\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
1401 "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":null,\"function\":{\"name\":null,\"arguments\":\"{\\\"action\\\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
1402 "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":null,\"function\":{\"name\":null,\"arguments\":\":\\\"log\\\"}\"}}]},\"finish_reason\":null}],\"usage\":null}",
1403 "{\"choices\":[{\"delta\":{\"tool_calls\":[]},\"finish_reason\":\"tool_calls\"}],\"usage\":null}",
1404 "{\"choices\":[],\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":10,\"total_tokens\":30}}",
1405 "[DONE]",
1406 ]),
1407 };
1408
1409 let mut stream = send_compatible_streaming_request(client, streaming_request(), "openai")
1410 .await
1411 .unwrap();
1412
1413 let mut collected_tool_calls = Vec::new();
1414 while let Some(chunk) = stream.next().await {
1415 if let streaming::StreamedAssistantContent::ToolCall {
1416 tool_call,
1417 internal_call_id: _,
1418 } = chunk.unwrap()
1419 {
1420 collected_tool_calls.push(tool_call);
1421 }
1422 }
1423
1424 assert_eq!(
1425 collected_tool_calls.len(),
1426 2,
1427 "expected 2 separate tool calls, got {collected_tool_calls:?}"
1428 );
1429
1430 assert_eq!(collected_tool_calls[0].id, "call_aaa");
1431 assert_eq!(collected_tool_calls[0].function.name, "command");
1432 assert_eq!(
1433 collected_tool_calls[0].function.arguments,
1434 serde_json::json!({"cmd": "ls"})
1435 );
1436
1437 assert_eq!(collected_tool_calls[1].id, "call_bbb");
1438 assert_eq!(collected_tool_calls[1].function.name, "git");
1439 assert_eq!(
1440 collected_tool_calls[1].function.arguments,
1441 serde_json::json!({"action": "log"})
1442 );
1443 }
1444
1445 #[tokio::test]
1446 async fn test_tool_call_id_chunk_without_function_is_preserved() {
1447 use crate::test_utils::MockStreamingClient;
1448 use futures::StreamExt;
1449
1450 let client = MockStreamingClient {
1451 sse_bytes: sse_bytes_from_data_lines([
1452 "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_abc123\"}]},\"finish_reason\":null}],\"usage\":null}",
1453 "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":null,\"function\":{\"name\":\"lookup\",\"arguments\":\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
1454 "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":null,\"function\":{\"name\":null,\"arguments\":\"{\\\"id\\\":1}\"}}]},\"finish_reason\":null}],\"usage\":null}",
1455 "{\"choices\":[{\"delta\":{\"tool_calls\":[]},\"finish_reason\":\"tool_calls\"}],\"usage\":null}",
1456 "[DONE]",
1457 ]),
1458 };
1459
1460 let mut stream = send_compatible_streaming_request(client, streaming_request(), "openai")
1461 .await
1462 .unwrap();
1463
1464 let mut collected_tool_calls = Vec::new();
1465 while let Some(chunk) = stream.next().await {
1466 if let streaming::StreamedAssistantContent::ToolCall {
1467 tool_call,
1468 internal_call_id: _,
1469 } = chunk.unwrap()
1470 {
1471 collected_tool_calls.push(tool_call);
1472 }
1473 }
1474
1475 assert_eq!(
1476 collected_tool_calls.len(),
1477 1,
1478 "expected id-only chunk to be retained for later tool-call deltas"
1479 );
1480 assert_eq!(collected_tool_calls[0].id, "call_abc123");
1481 assert_eq!(collected_tool_calls[0].function.name, "lookup");
1482 assert_eq!(
1483 collected_tool_calls[0].function.arguments,
1484 serde_json::json!({"id": 1})
1485 );
1486 }
1487
1488 #[tokio::test]
1493 async fn test_unique_id_per_chunk_single_tool_call() {
1494 use crate::test_utils::MockStreamingClient;
1495 use futures::StreamExt;
1496
1497 let client = MockStreamingClient {
1500 sse_bytes: sse_bytes_from_data_lines([
1501 "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"chatcmpl-tool-aaa\",\"function\":{\"name\":\"web_search\",\"arguments\":\"null\"}}]},\"finish_reason\":null}],\"usage\":null}",
1502 "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"chatcmpl-tool-bbb\",\"function\":{\"name\":\"\",\"arguments\":\"{\\\"query\\\": \\\"META\"}}]},\"finish_reason\":null}],\"usage\":null}",
1503 "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"chatcmpl-tool-ccc\",\"function\":{\"name\":\"\",\"arguments\":\" Platforms news\\\"}\"}}]},\"finish_reason\":null}],\"usage\":null}",
1504 "{\"choices\":[{\"delta\":{\"tool_calls\":[]},\"finish_reason\":\"tool_calls\"}],\"usage\":null}",
1505 "{\"choices\":[],\"usage\":{\"prompt_tokens\":15,\"completion_tokens\":8,\"total_tokens\":23}}",
1506 "[DONE]",
1507 ]),
1508 };
1509
1510 let mut stream = send_compatible_streaming_request(client, streaming_request(), "openai")
1511 .await
1512 .unwrap();
1513
1514 let mut collected_tool_calls = Vec::new();
1515 while let Some(chunk) = stream.next().await {
1516 if let streaming::StreamedAssistantContent::ToolCall {
1517 tool_call,
1518 internal_call_id: _,
1519 } = chunk.unwrap()
1520 {
1521 collected_tool_calls.push(tool_call);
1522 }
1523 }
1524
1525 assert_eq!(
1526 collected_tool_calls.len(),
1527 1,
1528 "expected 1 tool call (all chunks are fragments of the same call), got {collected_tool_calls:?}"
1529 );
1530
1531 assert_eq!(collected_tool_calls[0].function.name, "web_search");
1532 let args_str = match &collected_tool_calls[0].function.arguments {
1534 serde_json::Value::String(s) => s.clone(),
1535 v => v.to_string(),
1536 };
1537 assert!(
1538 args_str.contains("META Platforms news"),
1539 "expected accumulated arguments containing the full query, got: {args_str}"
1540 );
1541 }
1542
1543 #[tokio::test]
1544 async fn test_zero_arg_tool_call_normalized_on_finish_reason() {
1545 use crate::test_utils::MockStreamingClient;
1546
1547 let client = MockStreamingClient {
1548 sse_bytes: sse_bytes_from_data_lines([
1549 "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_123\",\"function\":{\"name\":\"ping\",\"arguments\":\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
1550 "{\"choices\":[{\"delta\":{\"tool_calls\":[]},\"finish_reason\":\"tool_calls\"}],\"usage\":null}",
1551 "[DONE]",
1552 ]),
1553 };
1554
1555 let stream = send_compatible_streaming_request(client, streaming_request(), "openai")
1556 .await
1557 .unwrap();
1558
1559 assert_zero_arg_tool_call_is_emitted(stream, "call_123", "ping", true).await;
1560 }
1561
1562 #[tokio::test]
1563 async fn test_zero_arg_tool_call_is_preserved_at_eof() {
1564 use crate::test_utils::MockStreamingClient;
1565
1566 let client = MockStreamingClient {
1567 sse_bytes: sse_bytes_from_data_lines([
1568 "{\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_123\",\"function\":{\"name\":\"ping\",\"arguments\":\"\"}}]},\"finish_reason\":null}],\"usage\":null}",
1569 ]),
1570 };
1571
1572 let stream = send_compatible_streaming_request(client, streaming_request(), "openai")
1573 .await
1574 .unwrap();
1575
1576 assert_zero_arg_tool_call_is_emitted(stream, "call_123", "ping", false).await;
1580 }
1581
1582 #[tokio::test]
1587 async fn test_default_profile_surfaces_unparseable_frames_as_errors() {
1588 use crate::test_utils::MockStreamingClient;
1589 use futures::StreamExt;
1590
1591 let client = MockStreamingClient {
1592 sse_bytes: sse_bytes_from_data_lines([
1593 "{bad",
1595 "{\"object\":\"chat.completion.chunk\",\"choices\":\"nope\"}",
1597 "{\"type\":\"ping\"}",
1599 "[DONE]",
1600 ]),
1601 };
1602
1603 let mut stream = send_compatible_streaming_request(client, streaming_request(), "openai")
1604 .await
1605 .unwrap();
1606
1607 let mut error_count = 0;
1608 let mut saw_final = false;
1609 let mut unknown = None;
1610 while let Some(item) = stream.next().await {
1611 match item {
1612 Ok(streaming::StreamedAssistantContent::Final(_)) => saw_final = true,
1613 Ok(streaming::StreamedAssistantContent::Unknown(value)) => unknown = Some(value),
1616 Ok(other) => panic!("unexpected stream item: {other:?}"),
1617 Err(_) => error_count += 1,
1618 }
1619 }
1620 assert_eq!(unknown, Some(serde_json::json!({"type": "ping"}).into()));
1621
1622 assert_eq!(
1623 error_count, 2,
1624 "each corrupt frame must surface as an error item"
1625 );
1626 assert!(
1627 !saw_final,
1628 "a stream with no successfully decoded frame must not emit a terminal record"
1629 );
1630 assert!(stream.response.is_none());
1631 }
1632
1633 #[tokio::test]
1634 async fn azure_content_filter_prelude_chunk_is_a_no_op_not_an_error() {
1635 use crate::test_utils::MockStreamingClient;
1636 use futures::StreamExt;
1637
1638 let client = MockStreamingClient {
1642 sse_bytes: sse_bytes_from_data_lines([
1643 r#"{"id":"","object":"","choices":[{"prompt_index":0,"content_filter_results":{"hate":{"filtered":false,"severity":"safe"}}}]}"#,
1644 r#"{"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"delta":{"content":"hi"},"finish_reason":null}]}"#,
1645 r#"{"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}"#,
1646 "[DONE]",
1647 ]),
1648 };
1649
1650 let mut stream = send_compatible_streaming_request(client, streaming_request(), "openai")
1651 .await
1652 .unwrap();
1653
1654 let mut texts = Vec::new();
1655 let mut saw_final = false;
1656 while let Some(item) = stream.next().await {
1657 match item {
1658 Ok(streaming::StreamedAssistantContent::Text(text)) => texts.push(text.text),
1659 Ok(streaming::StreamedAssistantContent::Final(_)) => saw_final = true,
1660 Ok(_) => {}
1661 Err(error) => panic!("the filter prelude chunk must not error: {error}"),
1662 }
1663 }
1664
1665 assert_eq!(texts, ["hi"]);
1666 assert!(saw_final, "the genuine terminal must still arrive");
1667 }
1668
1669 mod raw_capture {
1675 use super::*;
1676 use crate::test_utils::MockStreamingClient;
1677 use futures::StreamExt;
1678
1679 const CHUNKS: [&str; 3] = [
1683 "{\"id\":\"chatcmpl-raw-7\",\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_stream\",\"choices\":[{\"delta\":{\"content\":\"hi\"},\"finish_reason\":null}],\"usage\":null}",
1684 "{\"id\":\"chatcmpl-raw-7\",\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_stream\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":null}",
1685 "{\"id\":\"chatcmpl-raw-7\",\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_stream\",\"choices\":[],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":1,\"total_tokens\":4}}",
1686 ];
1687
1688 async fn terminal() -> streaming::StreamFinal {
1689 let client = MockStreamingClient {
1690 sse_bytes: sse_bytes_from_data_lines(
1691 CHUNKS.iter().copied().chain(std::iter::once("[DONE]")),
1692 ),
1693 };
1694 let mut stream =
1695 send_compatible_streaming_request(client, streaming_request(), "openai")
1696 .await
1697 .expect("stream should open");
1698
1699 let mut terminal = None;
1700 while let Some(item) = stream.next().await {
1701 if let streaming::StreamedAssistantContent::Final(record) =
1702 item.expect("stream item")
1703 {
1704 terminal = Some(record);
1705 }
1706 }
1707 terminal.expect("the stream must end with a terminal record")
1708 }
1709
1710 #[tokio::test]
1716 async fn terminal_captures_raw_that_round_trips_into_the_terminal_type() {
1717 let record = terminal().await;
1718
1719 let raw = &record.raw;
1720 let typed: StreamingCompletionResponse =
1721 serde_json::from_value(raw.clone()).expect("raw must deserialize");
1722 assert_eq!(
1723 serde_json::to_value(&typed).expect("re-serialize"),
1724 *raw,
1725 "the capture must be exactly what the terminal type serializes to"
1726 );
1727 assert_eq!(typed.response_id.as_deref(), Some("chatcmpl-raw-7"));
1728 assert_eq!(raw["additional_params"]["service_tier"], "default");
1729 assert_eq!(raw["additional_params"]["system_fingerprint"], "fp_stream");
1730
1731 let renormalized: streaming::StreamFinal = ("openai", typed).into();
1732 assert_eq!(record.identity(), renormalized.identity());
1733 assert_eq!(record.finish_reason, renormalized.finish_reason);
1734 assert_eq!(record.model, renormalized.model);
1735 assert_eq!(record.usage, renormalized.usage);
1736 assert_eq!(record.finish_reason, Some(NormalizedFinishReason::Stop));
1737 assert_eq!(record.model.as_deref(), Some("gpt-4o-mini-2024-07-18"));
1738 assert_eq!(record.usage.total_tokens, 4);
1739 }
1740 }
1741}