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;
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// ================================================================
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, Default)]
72struct StreamingDelta {
73    #[serde(default, deserialize_with = "deserialize_delta_content")]
74    content: Option<String>,
75    /// A structured-output refusal streams here, on its own key, with
76    /// `content` held at `null` for the whole turn — the same sibling-of-
77    /// `content` spelling the unary path sees. Its deltas are the turn's
78    /// visible text, so they join the text stream (see [`delta_text`]).
79    #[serde(default)]
80    refusal: Option<String>,
81    #[serde(default)]
82    reasoning_content: Option<String>,
83    // Not part of the official OpenAI API; some compatible providers (e.g.
84    // Groq) send the same payload under `reasoning`. A separate field rather
85    // than a serde alias so a delta carrying BOTH keys is not a
86    // duplicate-field error that drops the whole chunk.
87    #[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), // This will handle the deprecated function_call
104}
105
106impl FinishReason {
107    /// This reason in the provider's own wire spelling.
108    ///
109    /// Round-tripping through the wire form keeps `map_openai_finish_reason`
110    /// the single place the OpenAI-compatible vocabulary is interpreted, so the
111    /// streaming and unary paths cannot drift — including on the deprecated
112    /// `function_call` spelling, which this enum captures in
113    /// [`FinishReason::Other`].
114    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/// Normalize a streamed OpenAI-compatible `finish_reason` field.
126///
127/// A missing value — or an empty one, as some gateways send — is reported as
128/// [`CompatibleFinishReason::Absent`]; anything outside the normalized
129/// vocabulary is preserved verbatim in
130/// [`crate::completion::FinishReason::Other`].
131#[cfg(test)]
132pub(crate) fn map_finish_reason(reason: Option<&FinishReason>) -> CompatibleFinishReason {
133    CompatibleFinishReason::from_wire(reason.map(FinishReason::as_wire))
134}
135
136/// The visible text a delta carries: its `content`, or — when `content` has
137/// none — its `refusal`.
138///
139/// A refusal turn streams `"content": null` beside the refusal deltas (and
140/// opens with an empty `"refusal": ""`), so preferring non-empty content keeps
141/// ordinary turns byte-identical while letting a refusal reach the caller
142/// instead of vanishing. An empty `content` string with no refusal to fall
143/// back on stays exactly as it was.
144fn 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    // Defaulted because a choice on the wire is not guaranteed to carry a
158    // delta: Azure prepends a `prompt_filter_results` chunk (delta-less
159    // choice) to every stream when content filtering is enabled. An empty
160    // delta with no finish reason is a no-op frame, matching how the
161    // reference SDKs treat it (skip at consumption, never an error).
162    #[serde(default)]
163    delta: StreamingDelta,
164    finish_reason: Option<FinishReason>,
165    /// Upstream provider spelling forwarded by gateways such as OpenRouter.
166    /// Direct providers omit it; their profile's default mapper ignores it.
167    native_finish_reason: Option<String>,
168    /// Which candidate this delta belongs to when the caller asked for
169    /// `n > 1`. Optional because providers streaming a single candidate may
170    /// omit it; absent is read as candidate 0.
171    #[serde(default)]
172    index: Option<usize>,
173    /// Per-token probabilities for this chunk. Kept as provider metadata:
174    /// OpenAI-compatible services extend the object independently, while the
175    /// raw terminal response must retain every chunk rather than choosing a
176    /// provider-specific token schema here.
177    #[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    /// Provider-specific top-level chunk fields. Chat-completions-compatible
191    /// services add fields independently (`service_tier`, `provider`, and
192    /// similar metadata), and `raw_stream` must not erase them merely because
193    /// the shared wire shape does not know their names yet.
194    #[serde(flatten)]
195    additional_params: serde_json::Map<String, serde_json::Value>,
196}
197
198/// Final streaming response. `U` is the provider's streaming usage payload
199/// ([`Usage`] for OpenAI itself; providers with richer usage accounting, e.g.
200/// Mistral and DeepSeek, substitute their own via
201/// [`OpenAICompatibleProvider::StreamingUsage`]).
202///
203/// This is the provider-native terminal record yielded by
204/// [`GenericCompletionModel::raw_stream`]. The normalized path maps it into a
205/// [`StreamFinal`] exactly once, through
206/// [`normalize_stream`](crate::streaming::normalize_stream).
207#[derive(Clone, Debug, Serialize, Deserialize)]
208pub struct StreamingCompletionResponse<U = Usage> {
209    /// Usage reported on the stream's terminal event.
210    pub usage: U,
211    /// Why the model stopped generating, when the stream reported it.
212    ///
213    /// Normalized out of the OpenAI-compatible `finish_reason` vocabulary, with
214    /// unrecognized values preserved verbatim. The `Stop` -> `ToolCalls`
215    /// upgrade is deliberately *not* applied here: it belongs to
216    /// [`normalize_stream`](crate::streaming::normalize_stream), the only place
217    /// that sees which tool calls the stream actually emitted.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub finish_reason: Option<crate::completion::FinishReason>,
220    /// Provider-assigned response identifier, when the stream emitted one.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub response_id: Option<String>,
223    /// Provider-reported model identifier, when the stream emitted one.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub model: Option<String>,
226    /// The transport request id from the SSE connection's `x-request-id`
227    /// response header — not part of any stream frame; stamped by the
228    /// transport. `None` when the provider did not report one.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub provider_request_id: Option<String>,
231    /// Token log probabilities accumulated from all primary-choice chunks.
232    ///
233    /// This stays provider-native on [`GenericCompletionModel::raw_stream`]:
234    /// normalized completions do not currently model log probabilities, just
235    /// as the blocking normalized path omits `Choice::logprobs` while its raw
236    /// response retains them.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub logprobs: Option<serde_json::Value>,
239    /// Provider-specific top-level fields accumulated from the stream's
240    /// chunks, such as OpenAI's `service_tier` and `system_fingerprint` or
241    /// OpenRouter's routed `provider`.
242    #[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    /// Create a terminal record carrying `usage`; the optional metadata starts
252    /// unset.
253    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    /// Build the terminal record from the shared streaming layer's terminal
266    /// state.
267    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            // Stamped by the transport layer; the shared chunk accumulator
274            // never sees connection headers.
275            provider_request_id: None,
276            logprobs: terminal.logprobs.map(Into::into),
277            additional_params: terminal.additional_params,
278        }
279    }
280}
281
282/// Normalize an OpenAI-compatible streaming terminal record.
283///
284/// As on the unary path, the provider descriptor name is an *input* rather than
285/// a constant: this terminal record is shared by every OpenAI-compatible
286/// provider, so baking in `"openai"` here would mislabel Groq, Together,
287/// DeepSeek and the rest.
288impl<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    /// Open a chat-completions stream whose terminal record stays
311    /// provider-native.
312    ///
313    /// This is the escape hatch for provider-specific terminal fields rig does
314    /// not normalize. It shares the request builder, transport, telemetry, and
315    /// error handling with
316    /// [`CompletionModel::stream`](crate::completion::CompletionModel::stream),
317    /// which calls it and normalizes the terminal record — one network request
318    /// either way.
319    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        // Deliberately the configured model, not the per-request override:
339        // Azure's deployment URL is pinned to the model handle.
340        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        // `merge` is shallow, so include_usage is inserted into any
347        // caller-supplied stream_options rather than merged over it: the
348        // caller's keys survive and the usage chunk is still requested.
349        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    /// Open a chat-completions stream with a normalized terminal record.
412    ///
413    /// Delegates to [`raw_stream`](Self::raw_stream) and maps only its terminal
414    /// record; every incremental event passes through untouched.
415    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        // Classification only — the unknown/corrupt policy (warn-skip vs.
460        // in-band `Err` item) lives in the shared driver, not here.
461        wire::classify_chat_completions_frame::<StreamingCompletionChunk<U>>(data).map(|data| {
462            // `n > 1` streams as interleaved chunks distinguished only by
463            // `choices[].index`. Taking each *chunk's* first choice would
464            // concatenate every candidate into one garbled answer, while the
465            // blocking path answers the same request from candidate 0 alone;
466            // selecting by index keeps the two transports agreeing.
467            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                    // The shared mapping also folds `function_call` — the
483                    // deprecated pre-tools finish reason some compatible
484                    // providers still emit — onto `ToolCalls`.
485                    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
546/// Send an OpenAI chat-completions streaming request, keeping the terminal
547/// record provider-native.
548pub(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
564/// Send an OpenAI chat-completions streaming request and normalize its terminal
565/// record.
566///
567/// `provider` is the descriptor name to attribute the stream to. It is a
568/// parameter rather than a constant because this helper is public and the
569/// chat-completions wire shape is shared: hardcoding `"openai"` would label
570/// every out-of-tree compatible provider's stream as OpenAI's.
571pub 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            // The deprecated pre-tools spelling still means a tool call.
618            (
619                FinishReason::Other("function_call".to_string()),
620                NormalizedFinishReason::ToolCalls,
621            ),
622            // Some gateways report the token limit under OpenAI's older name.
623            (
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    /// One `choices[].delta` object, decoded from the wire.
660    fn delta(wire: serde_json::Value) -> StreamingDelta {
661        serde_json::from_value(wire).expect("delta should decode")
662    }
663
664    /// Replay `chunks` as an OpenAI chat-completions SSE body, returning the
665    /// visible text the stream produced and its terminal record.
666    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    /// Replay Chat Completions chunks without normalizing the terminal, so
697    /// provider-native metadata can be asserted directly.
698    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    /// Log probabilities are distributed across token chunks. The raw
723    /// terminal must reconstruct both documented arrays in arrival order,
724    /// including nested top-token arrays, instead of retaining only the last
725    /// chunk or dropping the field entirely.
726    #[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    /// Top-level metadata is not part of a choice, but it is still native
754    /// response data. Compatible providers add keys independently, so the raw
755    /// terminal preserves and merges both familiar and previously unknown
756    /// fields instead of requiring a shared-wire release for each new key.
757    #[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    /// Empty and null probability objects are both documented absence shapes
779    /// for optional provider metadata. This is a synthetic wire test because
780    /// a live model cannot be instructed to choose the empty-object spelling.
781    #[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    /// The compatibility allowance is limited to object-or-null metadata;
806    /// accepting other JSON kinds would hide a malformed provider response.
807    #[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    /// The refusal shape the wire actually sends: `content` held at `null` for
826    /// the whole turn while the refusal arrives on its own key. Rig modeled no
827    /// `refusal` field at all, so every one of these deltas was visible-text-less
828    /// and a refused turn streamed nothing.
829    #[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    /// The turn's opening delta carries `"refusal": ""` beside the assistant
842    /// role; an empty refusal is not text.
843    #[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    /// Ordinary content deltas are untouched, including the empty-string form
854    /// some gateways send.
855    #[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    /// A delta carrying both keys is not a shape OpenAI has been observed to
869    /// send; within a delta, content wins so the visible answer is never
870    /// displaced.
871    ///
872    /// This rule is per-delta, and deliberately so — a stream cannot know
873    /// whether text arrives later without buffering the turn. The unary
874    /// path's `assistant_refusal_fallback` is a *whole-message* rule, so on a
875    /// hypothetical turn that mixed text and a refusal across deltas the two
876    /// would differ: blocking would report only the text, streaming both in
877    /// arrival order. Recorded here rather than claimed away; no observed
878    /// turn mixes them, because a refusal turn holds `content` at `null` for
879    /// its whole length.
880    #[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    /// The whole refusal turn, assembled: the deltas concatenate into the same
894    /// text the blocking path reports, and the terminal is a clean `stop`.
895    #[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        // Some OpenAI-compatible gateways send `arguments` as a JSON object
927        // instead of the spec-mandated JSON-encoded string. Accept it by
928        // re-serializing to the string form rather than dropping the chunk.
929        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        // Partial tool calls have no name and partial arguments
969        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        // Simulates multiple partial tool call chunks arriving
1065        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        // Verify each chunk deserializes correctly
1117        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        // Some providers emit a final "usage-only" chunk where `choices` is empty.
1157        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    /// A `stop` reported on a turn that streamed a tool call must surface as
1250    /// `ToolCalls`. The provider mapper deliberately does not do this — the
1251    /// upgrade belongs to `normalize_stream`, which sees the emitted tool
1252    /// calls — so this pins the wiring rather than the mapping.
1253    #[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        // Usage chunk includes prompt_tokens_details with cached_tokens.
1342        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        // The raw stream keeps the provider's own usage payload, so this
1351        // asserts both halves: what the provider reported and what it
1352        // normalizes into.
1353        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        // Verify provider-level usage has the cached_tokens
1368        assert_eq!(
1369            res.usage
1370                .prompt_tokens_details
1371                .as_ref()
1372                .unwrap()
1373                .cached_tokens,
1374            80
1375        );
1376
1377        // Verify core Usage also has cached_input_tokens
1378        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    /// Reproduces the bug where a proxy/gateway sends multiple parallel tool
1385    /// calls all sharing `index: 0` but with distinct `id` values.  Without
1386    /// the fix, rig merges both calls into one corrupted entry.
1387    #[tokio::test]
1388    async fn test_duplicate_index_different_id_tool_calls() {
1389        use crate::test_utils::MockStreamingClient;
1390        use futures::StreamExt;
1391
1392        // Simulate a gateway that sends two tool calls both at index 0.
1393        // First tool call: id="call_aaa", name="command", args={"cmd":"ls"}
1394        // Second tool call: id="call_bbb", name="git", args={"action":"log"}
1395        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    /// Reproduces the bug where a provider (e.g. GLM-4 via OpenAI-compatible
1489    /// endpoint) sends a unique `id` on every SSE delta chunk for the same
1490    /// logical tool call.  Without the fix, each chunk triggers an eviction,
1491    /// yielding incomplete fragments as "completed" tool calls.
1492    #[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        // Each chunk carries a different id but they all represent delta
1498        // fragments of the SAME tool call at index 0.
1499        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        // The arguments should be the fully accumulated string, not fragments
1533        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        // The tool call was fully delivered, so it is still flushed at EOF —
1577        // but the stream reached EOF without `[DONE]` or a finish reason, so
1578        // no terminal record is synthesized for the truncated turn.
1579        assert_zero_arg_tool_call_is_emitted(stream, "call_123", "ping", false).await;
1580    }
1581
1582    /// The default OpenAI profile must not let a stream end silently: corrupt
1583    /// frames surface as error items, and a bare `[DONE]` with no successfully
1584    /// decoded frame yields no terminal record. Unknown-shaped events (no
1585    /// `object`/`choices`) stay skippable for forward compatibility.
1586    #[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                // Not JSON at all.
1594                "{bad",
1595                // Recognizable chat chunk with a schema defect.
1596                "{\"object\":\"chat.completion.chunk\",\"choices\":\"nope\"}",
1597                // Unknown event shape: skipped, not an error.
1598                "{\"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                // The unknown-shaped event skips the semantic path but
1614                // surfaces verbatim on the raw passthrough channel.
1615                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        // Azure prepends a delta-less choice carrying `prompt_filter_results`
1639        // to every stream when content filtering is enabled. It must parse as
1640        // a no-op frame, never surface as an error item.
1641        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    /// Raw-capture tests for the streaming terminal, through
1670    /// [`send_compatible_streaming_request`] — the shared helper every
1671    /// OpenAI-compatible stream (and every out-of-tree compatible provider)
1672    /// funnels through, so the terminal it produces is the whole streaming
1673    /// capture story for this wire shape.
1674    mod raw_capture {
1675        use super::*;
1676        use crate::test_utils::MockStreamingClient;
1677        use futures::StreamExt;
1678
1679        /// A stream whose terminal carries metadata that only the
1680        /// provider-native terminal keeps (`service_tier`, `system_fingerprint`
1681        /// under `additional_params`, plus usage and `finish_reason`).
1682        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        /// The load-bearing streaming property: the terminal's `raw` is the
1711        /// provider-native terminal record — it deserializes back into
1712        /// [`StreamingCompletionResponse`] and re-serializes identically — and
1713        /// re-normalizing that capture reproduces every normalized field.
1714        /// Also reads terminal-only metadata off the capture.
1715        #[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}