Skip to main content

swiftide_integrations/openai/
chat_completion.rs

1use anyhow::{Context as _, Result};
2use async_openai::types::chat::{
3    ChatCompletionMessageToolCall, ChatCompletionMessageToolCalls,
4    ChatCompletionRequestAssistantMessageArgs, ChatCompletionRequestMessageContentPartAudio,
5    ChatCompletionRequestMessageContentPartImage, ChatCompletionRequestMessageContentPartText,
6    ChatCompletionRequestSystemMessageArgs, ChatCompletionRequestToolMessageArgs,
7    ChatCompletionRequestUserMessageArgs, ChatCompletionRequestUserMessageContent,
8    ChatCompletionRequestUserMessageContentPart, ChatCompletionResponseStream,
9    ChatCompletionStreamOptions, ChatCompletionToolChoiceOption, ChatCompletionTools,
10    CreateChatCompletionResponse, CreateChatCompletionStreamResponse, FunctionCall, FunctionObject,
11    ImageUrl, InputAudio, InputAudioFormat, ToolChoiceOptions,
12};
13use async_trait::async_trait;
14use base64::Engine as _;
15use futures_util::StreamExt as _;
16use futures_util::stream;
17use itertools::Itertools;
18use serde::Serialize;
19use swiftide_core::ChatCompletionStream;
20use swiftide_core::chat_completion::Usage;
21use swiftide_core::chat_completion::{
22    ChatCompletion, ChatCompletionRequest, ChatCompletionResponse, ChatMessage,
23    ChatMessageContentPart, ChatMessageContentSource, ToolCall, ToolSpec,
24    errors::LanguageModelError,
25};
26#[cfg(feature = "metrics")]
27use swiftide_core::metrics::emit_usage;
28
29use super::responses_api::{
30    build_responses_request_from_chat, response_to_chat_completion, responses_stream_adapter,
31};
32use super::tool_schema::OpenAiToolSchema;
33use super::{GenericOpenAI, openai_error_to_language_model_error, request_body_with_extra_body};
34use tracing_futures::Instrument;
35
36#[async_trait]
37impl<
38    C: async_openai::config::Config
39        + std::default::Default
40        + Sync
41        + Send
42        + std::fmt::Debug
43        + Clone
44        + 'static,
45> ChatCompletion for GenericOpenAI<C>
46{
47    #[cfg_attr(not(feature = "langfuse"), tracing::instrument(skip_all, err))]
48    #[cfg_attr(
49        feature = "langfuse",
50        tracing::instrument(skip_all, err, fields(langfuse.type = "GENERATION"))
51    )]
52    async fn complete(
53        &self,
54        request: &ChatCompletionRequest<'_>,
55    ) -> Result<ChatCompletionResponse, LanguageModelError> {
56        if self.is_responses_api_enabled() {
57            return self.complete_via_responses_api(request).await;
58        }
59
60        let model = self
61            .default_options
62            .prompt_model
63            .as_ref()
64            .context("Model not set")?;
65
66        let messages = request
67            .messages()
68            .iter()
69            .filter_map(|message| message_to_openai(message).transpose())
70            .collect::<Result<Vec<_>>>()?;
71
72        // Build the request to be sent to the OpenAI API.
73        let mut openai_request = self
74            .chat_completion_request_defaults()
75            .model(model)
76            .messages(messages)
77            .to_owned();
78
79        if !request.tools_spec().is_empty() {
80            openai_request
81                .tools(
82                    request
83                        .tools_spec()
84                        .iter()
85                        .map(tools_to_openai)
86                        .collect::<Result<Vec<_>>>()?,
87                )
88                .tool_choice(ChatCompletionToolChoiceOption::Mode(
89                    ToolChoiceOptions::Auto,
90                ));
91            if let Some(par) = self.default_options.parallel_tool_calls {
92                openai_request.parallel_tool_calls(par);
93            }
94        }
95
96        let openai_request = openai_request
97            .build()
98            .map_err(openai_error_to_language_model_error)?;
99
100        tracing::trace!(model, request = ?request, "Sending request to OpenAI");
101
102        let tracking_request = openai_request.clone();
103        let body = request_body_with_extra_body(&openai_request, &self.default_options.extra_body)?;
104        let response: CreateChatCompletionResponse = self
105            .client
106            .chat()
107            .create_byot(body)
108            .await
109            .map_err(openai_error_to_language_model_error)?;
110
111        tracing::trace!(?response, "[ChatCompletion] Full response from OpenAI");
112        // Make sure the debug log is a concise one line
113
114        let mut builder = ChatCompletionResponse::builder()
115            .maybe_message(
116                response
117                    .choices
118                    .first()
119                    .and_then(|choice| choice.message.content.clone()),
120            )
121            .maybe_tool_calls(
122                response
123                    .choices
124                    .first()
125                    .and_then(|choice| choice.message.tool_calls.as_ref())
126                    .map(|tool_calls| {
127                        tool_calls
128                            .iter()
129                            .filter_map(|tool_call| match tool_call {
130                                ChatCompletionMessageToolCalls::Function(call) => Some(
131                                    ToolCall::builder()
132                                        .id(call.id.clone())
133                                        .args(call.function.arguments.clone())
134                                        .name(call.function.name.clone())
135                                        .build()
136                                        .expect("infallible"),
137                                ),
138                                ChatCompletionMessageToolCalls::Custom(_) => None,
139                            })
140                            .collect_vec()
141                    }),
142            )
143            .to_owned();
144
145        if let Some(usage) = &response.usage {
146            builder.usage(Usage::from(usage));
147        }
148
149        let our_response = builder.build().map_err(LanguageModelError::from)?;
150
151        self.track_completion(
152            model,
153            our_response.usage.as_ref(),
154            Some(&tracking_request),
155            Some(&our_response),
156        );
157
158        Ok(our_response)
159    }
160
161    #[tracing::instrument(skip_all)]
162    async fn complete_stream(&self, request: &ChatCompletionRequest<'_>) -> ChatCompletionStream {
163        if self.is_responses_api_enabled() {
164            return self.complete_stream_via_responses_api(request).await;
165        }
166
167        match self.try_complete_stream(request).await {
168            Ok(stream) => stream,
169            Err(err) => err.into(),
170        }
171    }
172}
173
174impl<
175    C: async_openai::config::Config
176        + std::default::Default
177        + Sync
178        + Send
179        + std::fmt::Debug
180        + Clone
181        + 'static,
182> GenericOpenAI<C>
183{
184    #[allow(clippy::too_many_lines)]
185    async fn try_complete_stream(
186        &self,
187        request: &ChatCompletionRequest<'_>,
188    ) -> Result<ChatCompletionStream, LanguageModelError> {
189        let model_name = self
190            .default_options
191            .prompt_model
192            .clone()
193            .ok_or_else(|| LanguageModelError::permanent("Model not set"))?;
194
195        #[cfg(not(any(feature = "metrics", feature = "langfuse")))]
196        let _ = &model_name;
197
198        let messages = request
199            .messages()
200            .iter()
201            .filter_map(|message| message_to_openai(message).transpose())
202            .collect::<Result<Vec<_>>>()?;
203
204        // Build the request to be sent to the OpenAI API.
205        let mut openai_request = self
206            .chat_completion_request_defaults()
207            .model(&model_name)
208            .messages(messages)
209            .stream(true)
210            .stream_options(ChatCompletionStreamOptions {
211                include_usage: Some(true),
212                include_obfuscation: None,
213            })
214            .to_owned();
215
216        if !request.tools_spec().is_empty() {
217            openai_request
218                .tools(
219                    request
220                        .tools_spec()
221                        .iter()
222                        .map(tools_to_openai)
223                        .collect::<Result<Vec<_>>>()?,
224                )
225                .tool_choice(ChatCompletionToolChoiceOption::Mode(
226                    ToolChoiceOptions::Auto,
227                ));
228            if let Some(par) = self.default_options.parallel_tool_calls {
229                openai_request.parallel_tool_calls(par);
230            }
231        }
232
233        let openai_request = openai_request
234            .build()
235            .map_err(openai_error_to_language_model_error)?;
236
237        tracing::trace!(model = %model_name, request = ?request, "Sending request to OpenAI");
238
239        let body = request_body_with_extra_body(&openai_request, &self.default_options.extra_body)?;
240        let response_stream: ChatCompletionResponseStream = self
241            .client
242            .chat()
243            .create_stream_byot::<_, CreateChatCompletionStreamResponse>(body)
244            .await
245            .map_err(openai_error_to_language_model_error)?;
246
247        let stream_full = self.stream_full;
248        let model_name_for_track = model_name.clone();
249        let self_for_stream = self.clone();
250        let tracking_request = openai_request;
251
252        let span = if cfg!(feature = "langfuse") {
253            tracing::info_span!("stream", langfuse.type = "GENERATION")
254        } else {
255            tracing::info_span!("stream")
256        };
257
258        let stream = stream::unfold(
259            (
260                response_stream,
261                ChatCompletionResponse::default(),
262                tracking_request.clone(),
263                false, // finished
264            ),
265            move |(mut response_stream, mut state, tracking_request, finished)| {
266                let stream_full = stream_full;
267                let self_for_stream = self_for_stream.clone();
268                let model_name_for_track = model_name_for_track.clone();
269                async move {
270                    if finished {
271                        return None;
272                    }
273
274                    match response_stream.next().await {
275                        Some(Ok(chunk)) => {
276                            let delta_message = chunk
277                                .choices
278                                .first()
279                                .and_then(|d| d.delta.content.as_deref());
280                            let delta_tool_calls = chunk
281                                .choices
282                                .first()
283                                .and_then(|d| d.delta.tool_calls.as_deref());
284                            let usage = chunk.usage.as_ref();
285
286                            state.append_message_delta(delta_message);
287                            if let Some(delta_tool_calls) = delta_tool_calls {
288                                for tc in delta_tool_calls {
289                                    state.append_tool_call_delta(
290                                        tc.index as usize,
291                                        tc.id.as_deref(),
292                                        tc.function.as_ref().and_then(|f| f.name.as_deref()),
293                                        tc.function.as_ref().and_then(|f| f.arguments.as_deref()),
294                                    );
295                                }
296                            }
297                            if let Some(usage) = usage {
298                                let usage = Usage::from(usage);
299                                state.append_usage_delta(
300                                    usage.prompt_tokens,
301                                    usage.completion_tokens,
302                                    usage.total_tokens,
303                                );
304                            }
305
306                            let snapshot = if stream_full {
307                                state.clone()
308                            } else {
309                                ChatCompletionResponse {
310                                    id: state.id,
311                                    message: None,
312                                    tool_calls: None,
313                                    usage: None,
314                                    reasoning: None,
315                                    delta: state.delta.clone(),
316                                }
317                            };
318
319                            Some((
320                                Ok(snapshot),
321                                (response_stream, state, tracking_request, false),
322                            ))
323                        }
324                        Some(Err(err)) => Some((
325                            Err(openai_error_to_language_model_error(err)),
326                            (response_stream, state, tracking_request, true),
327                        )),
328                        None => {
329                            // Final emission; track completion with the full state.
330                            self_for_stream.track_completion(
331                                &model_name_for_track,
332                                state.usage.as_ref(),
333                                Some(&tracking_request),
334                                Some(&state),
335                            );
336                            let final_snapshot = state.clone();
337                            Some((
338                                Ok(final_snapshot),
339                                (response_stream, state, tracking_request, true),
340                            ))
341                        }
342                    }
343                }
344            },
345        );
346
347        Ok(Box::pin(tracing_futures::Instrument::instrument(
348            stream, span,
349        )))
350    }
351
352    async fn complete_via_responses_api(
353        &self,
354        request: &ChatCompletionRequest<'_>,
355    ) -> Result<ChatCompletionResponse, LanguageModelError> {
356        let model = self
357            .default_options
358            .prompt_model
359            .as_ref()
360            .context("Model not set")?;
361
362        let create_request = build_responses_request_from_chat(self, request)?;
363
364        let body = request_body_with_extra_body(&create_request, &self.default_options.extra_body)?;
365        let response = self
366            .client
367            .responses()
368            .create_byot(body)
369            .await
370            .map_err(openai_error_to_language_model_error)?;
371
372        let completion = response_to_chat_completion(response)?;
373
374        self.track_completion(
375            model,
376            completion.usage.as_ref(),
377            Some(&create_request),
378            Some(&completion),
379        );
380
381        Ok(completion)
382    }
383
384    #[allow(clippy::too_many_lines)]
385    async fn complete_stream_via_responses_api(
386        &self,
387        request: &ChatCompletionRequest<'_>,
388    ) -> ChatCompletionStream {
389        match self.try_complete_stream_via_responses_api(request).await {
390            Ok(stream) => stream,
391            Err(err) => err.into(),
392        }
393    }
394
395    #[allow(clippy::too_many_lines)]
396    async fn try_complete_stream_via_responses_api(
397        &self,
398        request: &ChatCompletionRequest<'_>,
399    ) -> Result<ChatCompletionStream, LanguageModelError> {
400        #[allow(unused_variables)]
401        let model_name = self
402            .default_options
403            .prompt_model
404            .clone()
405            .ok_or_else(|| LanguageModelError::permanent("Model not set"))?;
406
407        let mut create_request = build_responses_request_from_chat(self, request)?;
408
409        create_request.stream = Some(true);
410
411        let body = request_body_with_extra_body(&create_request, &self.default_options.extra_body)?;
412        let stream = self
413            .client
414            .responses()
415            .create_stream_byot::<_, async_openai::types::responses::ResponseStreamEvent>(body)
416            .await
417            .map_err(openai_error_to_language_model_error)?;
418
419        let stream_full = self.stream_full;
420
421        let span = if cfg!(feature = "langfuse") {
422            tracing::info_span!("responses_stream", langfuse.type = "GENERATION")
423        } else {
424            tracing::info_span!("responses_stream")
425        };
426
427        let mapped_stream = responses_stream_adapter(stream, stream_full);
428
429        let this = self.clone();
430        let tracked_request = create_request;
431
432        let mapped_stream = mapped_stream.map(move |result| match result {
433            Ok(item) => {
434                if item.finished {
435                    this.track_completion(
436                        &model_name,
437                        item.response.usage.as_ref(),
438                        Some(&tracked_request),
439                        Some(&item.response),
440                    );
441                }
442
443                Ok(item.response)
444            }
445            Err(err) => Err(err),
446        });
447
448        Ok(Box::pin(Instrument::instrument(mapped_stream, span)))
449    }
450
451    #[allow(unused_variables)]
452    pub(crate) fn track_completion<R, S>(
453        &self,
454        model: &str,
455        usage: Option<&Usage>,
456        request: Option<&R>,
457        response: Option<&S>,
458    ) where
459        R: Serialize + ?Sized,
460        S: Serialize + ?Sized,
461    {
462        if let Some(usage) = usage {
463            let cb_usage = usage.clone();
464            if let Some(callback) = &self.on_usage {
465                let callback = callback.clone();
466                tokio::spawn(async move {
467                    if let Err(err) = callback(&cb_usage).await {
468                        tracing::error!("Error in on_usage callback: {err}");
469                    }
470                });
471            }
472
473            #[cfg(feature = "metrics")]
474            emit_usage(
475                model,
476                usage.prompt_tokens.into(),
477                usage.completion_tokens.into(),
478                usage.total_tokens.into(),
479                self.metric_metadata.as_ref(),
480            );
481        }
482
483        #[cfg(feature = "langfuse")]
484        tracing::debug!(
485            langfuse.model = model,
486            langfuse.input = request.and_then(langfuse_json_redacted).unwrap_or_default(),
487            langfuse.output = response.and_then(langfuse_json).unwrap_or_default(),
488            langfuse.usage = usage.and_then(langfuse_json).unwrap_or_default(),
489        );
490    }
491}
492
493#[cfg(feature = "langfuse")]
494pub(crate) fn langfuse_json<T: Serialize + ?Sized>(value: &T) -> Option<String> {
495    serde_json::to_string_pretty(value).ok()
496}
497
498#[cfg(feature = "langfuse")]
499pub(crate) fn langfuse_json_redacted<T: Serialize + ?Sized>(value: &T) -> Option<String> {
500    let mut value = serde_json::to_value(value).ok()?;
501    redact_image_urls(&mut value);
502    serde_json::to_string_pretty(&value).ok()
503}
504
505#[cfg(feature = "langfuse")]
506fn redact_image_urls(value: &mut serde_json::Value) {
507    match value {
508        serde_json::Value::Object(map) => {
509            if let Some(image_url) = map.get_mut("image_url")
510                && let serde_json::Value::Object(image_obj) = image_url
511                && let Some(serde_json::Value::String(url)) = image_obj.get_mut("url")
512                && let Some(truncated) = truncate_data_url(url)
513            {
514                *url = truncated;
515            }
516
517            for val in map.values_mut() {
518                redact_image_urls(val);
519            }
520        }
521        serde_json::Value::Array(arr) => {
522            for val in arr {
523                redact_image_urls(val);
524            }
525        }
526        _ => {}
527    }
528}
529
530#[cfg(feature = "langfuse")]
531fn truncate_data_url(url: &str) -> Option<String> {
532    const MAX_DATA_PREVIEW: usize = 32;
533
534    if !url.starts_with("data:") {
535        return None;
536    }
537
538    let (prefix, data) = url.split_once(',')?;
539    if data.len() <= MAX_DATA_PREVIEW {
540        return None;
541    }
542
543    let preview = &data[..MAX_DATA_PREVIEW];
544    let truncated = data.len() - MAX_DATA_PREVIEW;
545
546    Some(format!(
547        "{prefix},{preview}...[truncated {truncated} chars]"
548    ))
549}
550
551#[cfg(not(feature = "langfuse"))]
552#[allow(dead_code)]
553pub(crate) fn langfuse_json<T>(_value: &T) -> Option<String> {
554    None
555}
556
557fn tools_to_openai(spec: &ToolSpec) -> Result<ChatCompletionTools> {
558    let parameters = OpenAiToolSchema::try_from(spec)
559        .context("tool schema must be OpenAI compatible")?
560        .into_value();
561
562    let function = FunctionObject {
563        name: spec.name.clone(),
564        description: Some(spec.description.clone()),
565        parameters: Some(parameters),
566        strict: Some(true),
567    };
568
569    Ok(ChatCompletionTools::Function(
570        async_openai::types::chat::ChatCompletionTool { function },
571    ))
572}
573
574fn message_to_openai(
575    message: &ChatMessage,
576) -> Result<Option<async_openai::types::chat::ChatCompletionRequestMessage>> {
577    let openai_message = match message {
578        ChatMessage::User(msg) => ChatCompletionRequestUserMessageArgs::default()
579            .content(msg.as_ref())
580            .build()?
581            .into(),
582        ChatMessage::UserWithParts(parts) => ChatCompletionRequestUserMessageArgs::default()
583            .content(user_parts_to_openai(parts)?)
584            .build()?
585            .into(),
586        ChatMessage::System(msg) => ChatCompletionRequestSystemMessageArgs::default()
587            .content(msg.as_ref())
588            .build()?
589            .into(),
590        ChatMessage::Summary(msg) => ChatCompletionRequestAssistantMessageArgs::default()
591            .content(msg.as_ref())
592            .build()?
593            .into(),
594        ChatMessage::ToolOutput(tool_call, tool_output) => {
595            let Some(content) = tool_output.content() else {
596                return Ok(Some(
597                    ChatCompletionRequestToolMessageArgs::default()
598                        .tool_call_id(tool_call.id())
599                        .build()?
600                        .into(),
601                ));
602            };
603
604            ChatCompletionRequestToolMessageArgs::default()
605                .content(content)
606                .tool_call_id(tool_call.id())
607                .build()?
608                .into()
609        }
610        ChatMessage::Assistant(content, tool_calls) => {
611            let mut builder = ChatCompletionRequestAssistantMessageArgs::default();
612
613            let has_tool_calls = tool_calls.as_ref().is_some_and(|calls| !calls.is_empty());
614
615            if let Some(content) = content.as_deref() {
616                builder.content(content);
617            }
618
619            if let Some(tool_calls) = tool_calls.as_ref() {
620                let calls = tool_calls
621                    .iter()
622                    .map(|tool_call| {
623                        ChatCompletionMessageToolCalls::Function(ChatCompletionMessageToolCall {
624                            id: tool_call.id().to_string(),
625                            function: FunctionCall {
626                                name: tool_call.name().to_string(),
627                                arguments: tool_call.args().unwrap_or_default().to_string(),
628                            },
629                        })
630                    })
631                    .collect::<Vec<_>>();
632
633                builder.tool_calls(calls);
634            }
635
636            if content.is_none() && !has_tool_calls {
637                return Ok(None);
638            }
639
640            builder.build()?.into()
641        }
642        ChatMessage::Reasoning(_) => return Ok(None),
643    };
644
645    Ok(Some(openai_message))
646}
647
648fn user_parts_to_openai(
649    parts: &[ChatMessageContentPart],
650) -> Result<ChatCompletionRequestUserMessageContent> {
651    let mapped = parts
652        .iter()
653        .map(part_to_openai_user_content_part)
654        .collect::<Result<Vec<_>>>()?;
655
656    Ok(ChatCompletionRequestUserMessageContent::Array(mapped))
657}
658
659fn part_to_openai_user_content_part(
660    part: &ChatMessageContentPart,
661) -> Result<ChatCompletionRequestUserMessageContentPart> {
662    Ok(match part {
663        ChatMessageContentPart::Text { text } => ChatCompletionRequestUserMessageContentPart::from(
664            ChatCompletionRequestMessageContentPartText::from(text.as_ref()),
665        ),
666        ChatMessageContentPart::Image { source, .. } => {
667            let image_url = ImageUrl {
668                url: source_to_openai_url(source)?,
669                detail: None,
670            };
671            ChatCompletionRequestUserMessageContentPart::from(
672                ChatCompletionRequestMessageContentPartImage { image_url },
673            )
674        }
675        ChatMessageContentPart::Audio { source, format } => {
676            let ChatMessageContentSource::Bytes { data, .. } = source else {
677                anyhow::bail!("OpenAI chat input_audio only supports bytes sources");
678            };
679
680            let format = match format.as_deref() {
681                Some("wav") => InputAudioFormat::Wav,
682                Some("mp3") | None => InputAudioFormat::Mp3,
683                Some(other) => anyhow::bail!("Unsupported OpenAI chat input_audio format: {other}"),
684            };
685
686            let input_audio = InputAudio {
687                data: base64::engine::general_purpose::STANDARD.encode(data),
688                format,
689            };
690
691            ChatCompletionRequestUserMessageContentPart::from(
692                ChatCompletionRequestMessageContentPartAudio { input_audio },
693            )
694        }
695        ChatMessageContentPart::Document { .. } => {
696            anyhow::bail!("OpenAI chat file parts are not supported by async-openai yet")
697        }
698        ChatMessageContentPart::Video { .. } => {
699            anyhow::bail!("OpenAI chat completion does not support video parts")
700        }
701    })
702}
703
704fn source_to_openai_url(source: &ChatMessageContentSource) -> Result<String> {
705    match source {
706        ChatMessageContentSource::Url { url } => Ok(url.clone()),
707        ChatMessageContentSource::FileId { .. } => {
708            anyhow::bail!("OpenAI chat image_url does not accept file_id sources")
709        }
710        ChatMessageContentSource::S3 { .. } => {
711            anyhow::bail!("OpenAI chat image_url does not accept s3 sources")
712        }
713        ChatMessageContentSource::Bytes { data, media_type } => {
714            let media_type = media_type.as_deref().unwrap_or("application/octet-stream");
715            let encoded = base64::engine::general_purpose::STANDARD.encode(data);
716            Ok(format!("data:{media_type};base64,{encoded}"))
717        }
718    }
719}
720
721#[cfg(test)]
722mod tests {
723    use crate::openai::{OpenAI, Options};
724
725    use super::*;
726    use futures_util::StreamExt;
727    use serde_json::json;
728    use std::sync::Arc;
729    use swiftide_core::chat_completion::{ToolCallBuilder, ToolOutput, UsageBuilder};
730    use wiremock::matchers::{method, path};
731    use wiremock::{Mock, MockServer, ResponseTemplate};
732
733    #[allow(dead_code)]
734    #[derive(schemars::JsonSchema)]
735    struct WeatherArgs {
736        _city: String,
737    }
738
739    #[allow(dead_code)]
740    #[derive(schemars::JsonSchema)]
741    #[serde(deny_unknown_fields)]
742    struct NestedCommentArgs {
743        request: NestedCommentRequest,
744    }
745
746    #[allow(dead_code)]
747    #[derive(schemars::JsonSchema)]
748    #[serde(deny_unknown_fields)]
749    struct NestedCommentRequest {
750        #[serde(default, skip_serializing_if = "Option::is_none")]
751        body: Option<String>,
752        #[serde(default, skip_serializing_if = "Option::is_none")]
753        text: Option<String>,
754        #[serde(default, skip_serializing_if = "Option::is_none")]
755        page_id: Option<String>,
756        #[serde(default, skip_serializing_if = "Option::is_none")]
757        block_id: Option<String>,
758        #[serde(default, skip_serializing_if = "Option::is_none")]
759        discussion_id: Option<String>,
760    }
761
762    #[test]
763    fn test_tools_to_openai_sets_additional_properties_false() {
764        let spec = ToolSpec::builder()
765            .name("get_weather")
766            .description("Retrieve weather data")
767            .parameters_schema(schemars::schema_for!(WeatherArgs))
768            .build()
769            .unwrap();
770
771        let tool = tools_to_openai(&spec).expect("tool conversion succeeds");
772
773        let function = match tool {
774            ChatCompletionTools::Function(ref tool) => &tool.function,
775            ChatCompletionTools::Custom(_) => panic!("expected function tool"),
776        };
777
778        let additional_properties = function
779            .parameters
780            .as_ref()
781            .and_then(serde_json::Value::as_object)
782            .and_then(|obj| obj.get("additionalProperties"))
783            .cloned();
784
785        assert_eq!(
786            additional_properties,
787            Some(serde_json::Value::Bool(false)),
788            "Chat Completions require additionalProperties=false for tool parameters, got {}",
789            serde_json::to_string_pretty(&function.parameters).unwrap()
790        );
791    }
792
793    #[test]
794    fn test_tools_to_openai_sets_nested_required_for_typed_request_objects() {
795        let spec = ToolSpec::builder()
796            .name("notion_create_comment")
797            .description("Create a comment")
798            .parameters_schema(schemars::schema_for!(NestedCommentArgs))
799            .build()
800            .unwrap();
801
802        let tool = tools_to_openai(&spec).expect("tool conversion succeeds");
803
804        let function = match tool {
805            ChatCompletionTools::Function(ref tool) => &tool.function,
806            ChatCompletionTools::Custom(_) => panic!("expected function tool"),
807        };
808
809        let nested_required = function.parameters.as_ref().and_then(|schema| {
810            let request_schema = schema
811                .get("properties")
812                .and_then(|value| value.get("request"))
813                .and_then(serde_json::Value::as_object)?;
814            let referenced_required = request_schema
815                .get("$ref")
816                .and_then(serde_json::Value::as_str)
817                .and_then(|reference| reference.strip_prefix("#/$defs/"))
818                .and_then(|definition_name| {
819                    schema
820                        .get("$defs")
821                        .and_then(|value| value.get(definition_name))
822                })
823                .and_then(|value| value.get("required"))
824                .and_then(serde_json::Value::as_array);
825
826            referenced_required.or_else(|| {
827                request_schema
828                    .get("required")
829                    .and_then(serde_json::Value::as_array)
830            })
831        });
832
833        let nested_required = nested_required.expect("nested request should have required");
834        let names: std::collections::HashSet<_> = nested_required
835            .iter()
836            .filter_map(serde_json::Value::as_str)
837            .collect();
838
839        assert!(names.contains("body"));
840        assert!(names.contains("text"));
841        assert!(names.contains("page_id"));
842        assert!(names.contains("block_id"));
843        assert!(names.contains("discussion_id"));
844    }
845
846    #[test]
847    fn test_message_to_openai_with_image_parts() {
848        let message = ChatMessage::new_user_with_parts(vec![
849            ChatMessageContentPart::text("Describe this image."),
850            ChatMessageContentPart::image("https://example.com/image.png"),
851        ]);
852
853        let openai_message = message_to_openai(&message)
854            .expect("message conversion succeeds")
855            .expect("message present");
856
857        let value = serde_json::to_value(openai_message).expect("serialize message");
858        let content = value
859            .get("content")
860            .and_then(serde_json::Value::as_array)
861            .expect("content array");
862
863        assert_eq!(content[0]["type"], "text");
864        assert_eq!(content[0]["text"], "Describe this image.");
865        assert_eq!(content[1]["type"], "image_url");
866        assert_eq!(
867            content[1]["image_url"]["url"],
868            "https://example.com/image.png"
869        );
870        assert!(content[1]["image_url"]["detail"].is_null());
871    }
872
873    #[test]
874    fn test_message_to_openai_with_image_bytes_source() {
875        let message = ChatMessage::new_user_with_parts(vec![
876            ChatMessageContentPart::text("Describe this image."),
877            ChatMessageContentPart::Image {
878                source: ChatMessageContentSource::bytes(
879                    vec![0_u8, 1_u8, 2_u8],
880                    Some("image/png".to_string()),
881                ),
882                format: None,
883            },
884        ]);
885
886        let openai_message = message_to_openai(&message)
887            .expect("message conversion succeeds")
888            .expect("message present");
889
890        let value = serde_json::to_value(openai_message).expect("serialize message");
891        let content = value
892            .get("content")
893            .and_then(serde_json::Value::as_array)
894            .expect("content array");
895
896        let image_url = content[1]["image_url"]["url"]
897            .as_str()
898            .expect("image_url must be string");
899        assert!(image_url.starts_with("data:image/png;base64,"));
900    }
901
902    #[test_log::test(tokio::test)]
903    async fn test_complete() {
904        let mock_server = MockServer::start().await;
905
906        // Mock OpenAI API response
907        let response_body = json!({
908          "id": "chatcmpl-B9MBs8CjcvOU2jLn4n570S5qMJKcT",
909          "object": "chat.completion",
910          "created": 123,
911          "model": "gpt-4o",
912          "choices": [
913            {
914              "index": 0,
915              "message": {
916                "role": "assistant",
917                "content": "Hello, world!",
918                "refusal": null,
919                "annotations": []
920              },
921              "logprobs": null,
922              "finish_reason": "stop"
923            }
924          ],
925          "usage": {
926            "prompt_tokens": 19,
927            "completion_tokens": 10,
928            "total_tokens": 29,
929            "prompt_tokens_details": {
930              "cached_tokens": 0,
931              "audio_tokens": 0
932            },
933            "completion_tokens_details": {
934              "reasoning_tokens": 0,
935              "audio_tokens": 0,
936              "accepted_prediction_tokens": 0,
937              "rejected_prediction_tokens": 0
938            }
939          },
940          "service_tier": "default"
941        });
942        Mock::given(method("POST"))
943            .and(path("/chat/completions"))
944            .respond_with(ResponseTemplate::new(200).set_body_json(response_body))
945            .mount(&mock_server)
946            .await;
947
948        // Create a GenericOpenAI instance with the mock server URL
949        let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
950        let async_openai = async_openai::Client::with_config(config);
951
952        let openai = OpenAI::builder()
953            .client(async_openai)
954            .default_prompt_model("gpt-4o")
955            .build()
956            .expect("Can create OpenAI client.");
957
958        // Prepare a test request
959        let request = ChatCompletionRequest::builder()
960            .messages(vec![ChatMessage::User("Hi".into())])
961            .build()
962            .unwrap();
963
964        // Call the `complete` method
965        let response = openai.complete(&request).await.unwrap();
966
967        // Assert the response
968        assert_eq!(response.message(), Some("Hello, world!"));
969
970        // Usage
971        let usage = response.usage.unwrap();
972        assert_eq!(usage.prompt_tokens, 19);
973        assert_eq!(usage.completion_tokens, 10);
974        assert_eq!(usage.total_tokens, 29);
975
976        let details = usage.details.as_ref().expect("usage details");
977        assert_eq!(
978            details
979                .prompt_tokens_details
980                .as_ref()
981                .and_then(|d| d.cached_tokens),
982            Some(0)
983        );
984        assert_eq!(
985            details
986                .completion_tokens_details
987                .as_ref()
988                .and_then(|d| d.reasoning_tokens),
989            Some(0)
990        );
991
992        let normalized = usage.normalized();
993        let normalized_details = normalized.details.expect("normalized details");
994        assert_eq!(normalized_details.input.cached_tokens, Some(0));
995        assert_eq!(normalized_details.output.reasoning_tokens, Some(0));
996    }
997
998    #[test_log::test(tokio::test)]
999    async fn test_complete_http_200_provider_error_envelope() {
1000        let mock_server = MockServer::start().await;
1001
1002        Mock::given(method("POST"))
1003            .and(path("/chat/completions"))
1004            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1005                "error": {
1006                    "message": "Service unavailable.",
1007                    "code": 504,
1008                    "metadata": { "error_type": "timeout" }
1009                }
1010            })))
1011            .mount(&mock_server)
1012            .await;
1013
1014        let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1015        let openai = OpenAI::builder()
1016            .client(async_openai::Client::with_config(config))
1017            .default_prompt_model("gpt-4o")
1018            .build()
1019            .unwrap();
1020        let request = ChatCompletionRequest::from(vec![ChatMessage::User("Hi".into())]);
1021
1022        let error = openai.complete(&request).await.unwrap_err();
1023        assert!(matches!(error, LanguageModelError::TransientError(_)));
1024        let message = error.to_string();
1025        assert!(message.contains("Service unavailable."));
1026        assert!(message.contains("504"));
1027        assert!(message.contains("timeout"));
1028        assert!(!message.contains("missing field `id`"));
1029    }
1030
1031    #[test_log::test(tokio::test)]
1032    async fn test_complete_partial_provider_error() {
1033        let mock_server = MockServer::start().await;
1034
1035        Mock::given(method("POST"))
1036            .and(path("/chat/completions"))
1037            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1038                "choices": [{
1039                    "message": { "role": "assistant", "content": "partial output" },
1040                    "finish_reason": "error",
1041                    "error": {
1042                        "message": "Provider disconnected mid-generation",
1043                        "code": 502,
1044                        "metadata": { "error_type": "provider_unavailable" }
1045                    }
1046                }]
1047            })))
1048            .mount(&mock_server)
1049            .await;
1050
1051        let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1052        let openai = OpenAI::builder()
1053            .client(async_openai::Client::with_config(config))
1054            .default_prompt_model("gpt-4o")
1055            .build()
1056            .unwrap();
1057        let request = ChatCompletionRequest::from(vec![ChatMessage::User("Hi".into())]);
1058
1059        let error = openai.complete(&request).await.unwrap_err();
1060        assert!(matches!(error, LanguageModelError::TransientError(_)));
1061        let message = error.to_string();
1062        assert!(message.contains("Provider disconnected mid-generation"));
1063        assert!(message.contains("502"));
1064        assert!(message.contains("provider_unavailable"));
1065    }
1066
1067    #[test_log::test(tokio::test)]
1068    async fn test_complete_stream_mid_stream_provider_error() {
1069        let mock_server = MockServer::start().await;
1070        let sse_body = concat!(
1071            "data: {\"id\":\"gen-123\",\"object\":\"chat.completion.chunk\",",
1072            "\"created\":123,\"model\":\"gpt-4o\",\"provider\":\"OpenAI\",",
1073            "\"error\":{\"code\":429,\"message\":\"Rate limit exceeded\",",
1074            "\"metadata\":{\"error_type\":\"rate_limit_exceeded\"}},",
1075            "\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},",
1076            "\"finish_reason\":\"error\"}]}\n\n",
1077            "data: [DONE]\n\n",
1078        );
1079
1080        Mock::given(method("POST"))
1081            .and(path("/chat/completions"))
1082            .respond_with(ResponseTemplate::new(200).set_body_raw(sse_body, "text/event-stream"))
1083            .mount(&mock_server)
1084            .await;
1085
1086        let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1087        let openai = OpenAI::builder()
1088            .client(async_openai::Client::with_config(config))
1089            .default_prompt_model("gpt-4o")
1090            .build()
1091            .unwrap();
1092        let request = ChatCompletionRequest::from(vec![ChatMessage::User("Hi".into())]);
1093
1094        let mut stream = openai.complete_stream(&request).await;
1095        let error = stream.next().await.unwrap().unwrap_err();
1096        assert!(matches!(error, LanguageModelError::TransientError(_)));
1097        let message = error.to_string();
1098        assert!(message.contains("Rate limit exceeded"));
1099        assert!(message.contains("429"));
1100        assert!(message.contains("rate_limit_exceeded"));
1101        assert!(stream.next().await.is_none());
1102    }
1103
1104    #[test_log::test(tokio::test)]
1105    #[allow(clippy::items_after_statements)]
1106    #[allow(clippy::too_many_lines)]
1107    async fn test_complete_responses_api() {
1108        use serde_json::{Value, json};
1109        use wiremock::{Request, Respond};
1110
1111        let mock_server = MockServer::start().await;
1112
1113        let response_body = json!({
1114            "created_at": 123,
1115            "id": "resp_123",
1116            "model": "gpt-4.1-mini",
1117            "object": "response",
1118            "status": "completed",
1119            "output": [
1120                {
1121                    "type": "message",
1122                    "id": "msg_1",
1123                    "role": "assistant",
1124                    "status": "completed",
1125                    "content": [
1126                        {"type": "output_text", "text": "Hello via responses", "annotations": []}
1127                    ]
1128                }
1129            ],
1130            "usage": {
1131                "input_tokens": 5,
1132                "input_tokens_details": {"cached_tokens": 0},
1133                "output_tokens": 3,
1134                "output_tokens_details": {"reasoning_tokens": 0},
1135                "total_tokens": 8
1136            }
1137        });
1138
1139        struct ValidateResponsesRequest {
1140            expected_model: &'static str,
1141            response: Value,
1142        }
1143
1144        impl Respond for ValidateResponsesRequest {
1145            fn respond(&self, request: &Request) -> ResponseTemplate {
1146                let body: Value = serde_json::from_slice(&request.body).unwrap();
1147                assert_eq!(body["model"], self.expected_model);
1148                assert_eq!(body["tensorzero::episode_id"], "responses-episode");
1149                assert_eq!(body["tensorzero::tags"]["user_id"], "responses-user");
1150                let input = body["input"].as_array().expect("input array");
1151                assert_eq!(input.len(), 1);
1152                assert_eq!(input[0]["role"], "user");
1153                assert_eq!(input[0]["content"], "Hello via prompt");
1154
1155                let _: async_openai::types::responses::Response =
1156                    serde_json::from_value(self.response.clone()).unwrap();
1157
1158                ResponseTemplate::new(200).set_body_json(self.response.clone())
1159            }
1160        }
1161
1162        Mock::given(method("POST"))
1163            .and(path("/responses"))
1164            .respond_with(ValidateResponsesRequest {
1165                expected_model: "gpt-4.1-mini",
1166                response: response_body,
1167            })
1168            .mount(&mock_server)
1169            .await;
1170
1171        let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1172        let async_openai = async_openai::Client::with_config(config);
1173
1174        let extra_body = serde_json::Map::from_iter([
1175            (
1176                "tensorzero::episode_id".to_string(),
1177                serde_json::json!("responses-episode"),
1178            ),
1179            (
1180                "tensorzero::tags".to_string(),
1181                serde_json::json!({"user_id": "responses-user"}),
1182            ),
1183        ]);
1184
1185        let openai = OpenAI::builder()
1186            .client(async_openai)
1187            .default_prompt_model("gpt-4.1-mini")
1188            .default_options(Options::builder().extra_body(extra_body))
1189            .use_responses_api(true)
1190            .build()
1191            .expect("Can create OpenAI client.");
1192
1193        let request = ChatCompletionRequest::builder()
1194            .messages(vec![ChatMessage::User("Hello via prompt".into())])
1195            .build()
1196            .unwrap();
1197
1198        let response = openai.complete(&request).await.unwrap();
1199
1200        assert_eq!(response.message(), Some("Hello via responses"));
1201
1202        let usage = response.usage.expect("usage present");
1203        assert_eq!(usage.prompt_tokens, 5);
1204        assert_eq!(usage.completion_tokens, 3);
1205        assert_eq!(usage.total_tokens, 8);
1206
1207        let details = usage.details.as_ref().expect("usage details");
1208        assert_eq!(
1209            details
1210                .input_tokens_details
1211                .as_ref()
1212                .and_then(|d| d.cached_tokens),
1213            Some(0)
1214        );
1215        assert_eq!(
1216            details
1217                .output_tokens_details
1218                .as_ref()
1219                .and_then(|d| d.reasoning_tokens),
1220            Some(0)
1221        );
1222
1223        let normalized = usage.normalized();
1224        let normalized_details = normalized.details.expect("normalized details");
1225        assert_eq!(normalized_details.input.cached_tokens, Some(0));
1226        assert_eq!(normalized_details.output.reasoning_tokens, Some(0));
1227    }
1228
1229    #[test_log::test(tokio::test)]
1230    async fn test_complete_stream_responses_api_sends_extra_body() {
1231        use serde_json::Value;
1232        use wiremock::{Request, Respond};
1233
1234        struct ValidateResponsesStreamRequest {
1235            sse_body: &'static str,
1236        }
1237
1238        impl Respond for ValidateResponsesStreamRequest {
1239            fn respond(&self, request: &Request) -> ResponseTemplate {
1240                let body: Value = serde_json::from_slice(&request.body).unwrap();
1241
1242                assert_eq!(body["tensorzero::episode_id"], "responses-stream-episode");
1243                assert_eq!(body["tensorzero::tags"]["user_id"], "responses-stream-user");
1244                assert_eq!(body["stream"], true);
1245
1246                ResponseTemplate::new(200).set_body_raw(self.sse_body, "text/event-stream")
1247            }
1248        }
1249
1250        let mock_server = MockServer::start().await;
1251
1252        let sse_body = "\
1253data: {\"type\":\"response.completed\",\"sequence_number\":0,\"response\":{\"id\":\"resp_stream\",\"object\":\"response\",\"created_at\":123,\"status\":\"completed\",\"model\":\"gpt-4.1-mini\",\"output\":[{\"type\":\"message\",\"id\":\"msg_1\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"stream via responses\",\"annotations\":[]}]}],\"usage\":{\"input_tokens\":5,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":3,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":8}}}\n\
1254\n\
1255data: [DONE]\n\n";
1256
1257        Mock::given(method("POST"))
1258            .and(path("/responses"))
1259            .respond_with(ValidateResponsesStreamRequest { sse_body })
1260            .mount(&mock_server)
1261            .await;
1262
1263        let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1264        let async_openai = async_openai::Client::with_config(config);
1265
1266        let extra_body = serde_json::Map::from_iter([
1267            (
1268                "tensorzero::episode_id".to_string(),
1269                serde_json::json!("responses-stream-episode"),
1270            ),
1271            (
1272                "tensorzero::tags".to_string(),
1273                serde_json::json!({"user_id": "responses-stream-user"}),
1274            ),
1275        ]);
1276
1277        let openai = OpenAI::builder()
1278            .client(async_openai)
1279            .default_prompt_model("gpt-4.1-mini")
1280            .default_options(Options::builder().extra_body(extra_body))
1281            .use_responses_api(true)
1282            .build()
1283            .expect("Can create OpenAI client.");
1284
1285        let request = ChatCompletionRequest::builder()
1286            .messages(vec![ChatMessage::User("Hello via responses stream".into())])
1287            .build()
1288            .unwrap();
1289
1290        let results: Vec<_> = openai.complete_stream(&request).await.collect().await;
1291        let last = results.last().unwrap().as_ref().unwrap();
1292        assert_eq!(last.message(), Some("stream via responses"));
1293        assert_eq!(last.usage.as_ref().map(|u| u.total_tokens), Some(8));
1294    }
1295
1296    #[test_log::test(tokio::test)]
1297    #[allow(clippy::items_after_statements)]
1298    async fn test_complete_with_all_default_settings() {
1299        use serde_json::Value;
1300        use wiremock::{Request, Respond, ResponseTemplate};
1301
1302        let mock_server = wiremock::MockServer::start().await;
1303
1304        // Custom matcher to validate all settings in the incoming request
1305        struct ValidateAllSettings;
1306
1307        impl Respond for ValidateAllSettings {
1308            fn respond(&self, request: &Request) -> ResponseTemplate {
1309                let v: Value = serde_json::from_slice(&request.body).unwrap();
1310
1311                // Validate required fields
1312                assert_eq!(v["model"], "gpt-4-turbo");
1313                let arr = v["messages"].as_array().unwrap();
1314                assert_eq!(arr.len(), 1);
1315                assert_eq!(arr[0]["content"], "Test");
1316
1317                assert_eq!(v["parallel_tool_calls"], true);
1318                assert_eq!(v["max_completion_tokens"], 77);
1319                assert!((v["temperature"].as_f64().unwrap() - 0.42).abs() < 1e-5);
1320                assert_eq!(v["reasoning_effort"], "low");
1321                assert_eq!(v["seed"], 42);
1322                assert!((v["presence_penalty"].as_f64().unwrap() - 1.1).abs() < 1e-5);
1323
1324                // Metadata as JSON object and user string
1325                assert_eq!(v["metadata"], serde_json::json!({"key": "value"}));
1326                assert_eq!(v["user"], "test-user");
1327                ResponseTemplate::new(200).set_body_json(serde_json::json!({
1328                "id": "chatcmpl-xxx",
1329                "object": "chat.completion",
1330                "created": 123,
1331                "model": "gpt-4-turbo",
1332                "choices": [{
1333                    "index": 0,
1334                    "message": {
1335                        "role": "assistant",
1336                        "content": "All settings validated",
1337                        "refusal": null,
1338                        "annotations": []
1339                    },
1340                    "logprobs": null,
1341                    "finish_reason": "stop"
1342                }],
1343                "usage": {
1344                    "prompt_tokens": 19,
1345                    "completion_tokens": 10,
1346                    "total_tokens": 29,
1347                    "prompt_tokens_details": {"cached_tokens": 0, "audio_tokens": 0},
1348                    "completion_tokens_details": {"reasoning_tokens": 0, "audio_tokens": 0, "accepted_prediction_tokens": 0, "rejected_prediction_tokens": 0}
1349                },
1350                "service_tier": "default"
1351            }))
1352            }
1353        }
1354
1355        wiremock::Mock::given(wiremock::matchers::method("POST"))
1356            .and(wiremock::matchers::path("/chat/completions"))
1357            .respond_with(ValidateAllSettings)
1358            .mount(&mock_server)
1359            .await;
1360
1361        let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1362        let async_openai = async_openai::Client::with_config(config);
1363
1364        let openai = crate::openai::OpenAI::builder()
1365            .client(async_openai)
1366            .default_prompt_model("gpt-4-turbo")
1367            .default_embed_model("not-used")
1368            .parallel_tool_calls(Some(true))
1369            .default_options(
1370                Options::builder()
1371                    .max_completion_tokens(77)
1372                    .temperature(0.42)
1373                    .reasoning_effort(async_openai::types::responses::ReasoningEffort::Low)
1374                    .seed(42)
1375                    .presence_penalty(1.1)
1376                    .metadata(serde_json::json!({"key": "value"}))
1377                    .user("test-user"),
1378            )
1379            .build()
1380            .expect("Can create OpenAI client.");
1381
1382        let request = swiftide_core::chat_completion::ChatCompletionRequest::builder()
1383            .messages(vec![swiftide_core::chat_completion::ChatMessage::User(
1384                "Test".into(),
1385            )])
1386            .build()
1387            .unwrap();
1388
1389        let response = openai.complete(&request).await.unwrap();
1390
1391        assert_eq!(response.message(), Some("All settings validated"));
1392    }
1393
1394    #[test_log::test(tokio::test)]
1395    async fn test_complete_sends_extra_body() {
1396        use serde_json::Value;
1397        use wiremock::{Request, Respond, ResponseTemplate};
1398
1399        struct ValidateExtraBody;
1400
1401        impl Respond for ValidateExtraBody {
1402            fn respond(&self, request: &Request) -> ResponseTemplate {
1403                let body: Value = serde_json::from_slice(&request.body).unwrap();
1404
1405                assert_eq!(body["tensorzero::episode_id"], "episode-1");
1406                assert_eq!(body["tensorzero::tags"]["user_id"], "user-1");
1407                assert_eq!(body["model"], "gpt-4-turbo");
1408
1409                ResponseTemplate::new(200).set_body_json(serde_json::json!({
1410                    "id": "chatcmpl-extra-body",
1411                    "object": "chat.completion",
1412                    "created": 123,
1413                    "model": "gpt-4-turbo",
1414                    "choices": [{
1415                        "index": 0,
1416                        "message": {
1417                            "role": "assistant",
1418                            "content": "extra body validated",
1419                            "refusal": null,
1420                            "annotations": []
1421                        },
1422                        "logprobs": null,
1423                        "finish_reason": "stop"
1424                    }],
1425                    "usage": {
1426                        "prompt_tokens": 1,
1427                        "completion_tokens": 2,
1428                        "total_tokens": 3
1429                    },
1430                    "service_tier": "default"
1431                }))
1432            }
1433        }
1434
1435        let mock_server = wiremock::MockServer::start().await;
1436
1437        wiremock::Mock::given(wiremock::matchers::method("POST"))
1438            .and(wiremock::matchers::path("/chat/completions"))
1439            .respond_with(ValidateExtraBody)
1440            .mount(&mock_server)
1441            .await;
1442
1443        let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1444        let async_openai = async_openai::Client::with_config(config);
1445
1446        let extra_body = serde_json::Map::from_iter([
1447            (
1448                "tensorzero::episode_id".to_string(),
1449                serde_json::json!("episode-1"),
1450            ),
1451            (
1452                "tensorzero::tags".to_string(),
1453                serde_json::json!({"user_id": "user-1"}),
1454            ),
1455        ]);
1456
1457        let openai = crate::openai::OpenAI::builder()
1458            .client(async_openai)
1459            .default_prompt_model("gpt-4-turbo")
1460            .default_options(Options::builder().extra_body(extra_body))
1461            .build()
1462            .expect("Can create OpenAI client.");
1463
1464        let request = swiftide_core::chat_completion::ChatCompletionRequest::builder()
1465            .messages(vec![swiftide_core::chat_completion::ChatMessage::User(
1466                "Test".into(),
1467            )])
1468            .build()
1469            .unwrap();
1470
1471        let response = openai.complete(&request).await.unwrap();
1472
1473        assert_eq!(response.message(), Some("extra body validated"));
1474    }
1475
1476    #[test_log::test(tokio::test)]
1477    async fn test_complete_with_tools_sets_auto_choice_and_parallel_calls() {
1478        use serde_json::Value;
1479        use wiremock::{Request, Respond, ResponseTemplate};
1480
1481        #[derive(schemars::JsonSchema)]
1482        struct WeatherArgs {
1483            _city: String,
1484        }
1485
1486        struct Validate(Value);
1487
1488        impl Respond for Validate {
1489            fn respond(&self, request: &Request) -> ResponseTemplate {
1490                let v: Value = serde_json::from_slice(&request.body).unwrap();
1491                assert_eq!(v["model"], "gpt-4o");
1492                assert_eq!(v["parallel_tool_calls"], true);
1493                assert_eq!(v["tool_choice"], "auto");
1494                let tools = v["tools"].as_array().unwrap();
1495                assert_eq!(tools.len(), 2);
1496                let tool_names = tools
1497                    .iter()
1498                    .map(|tool| tool["function"]["name"].as_str().unwrap())
1499                    .collect::<Vec<_>>();
1500                assert_eq!(tool_names, vec!["alpha_tool", "get_weather"]);
1501                ResponseTemplate::new(200)
1502                    .insert_header("content-type", "application/json")
1503                    .set_body_json(self.0.clone())
1504            }
1505        }
1506
1507        let weather_tool = ToolSpec::builder()
1508            .name("get_weather")
1509            .description("weather")
1510            .parameters_schema(schemars::schema_for!(WeatherArgs))
1511            .build()
1512            .unwrap();
1513
1514        let alpha_tool = ToolSpec::builder()
1515            .name("alpha_tool")
1516            .description("alpha")
1517            .parameters_schema(schemars::schema_for!(WeatherArgs))
1518            .build()
1519            .unwrap();
1520
1521        let mock_server = MockServer::start().await;
1522
1523        let response_body = json!({
1524          "id": "chatcmpl-xyz",
1525          "object": "chat.completion",
1526          "created": 1,
1527          "model": "gpt-4o",
1528          "choices": [{
1529            "index": 0,
1530            "message": {
1531              "role": "assistant",
1532              "content": "Here",
1533              "refusal": null,
1534              "annotations": []
1535            },
1536            "finish_reason": "stop"
1537          }],
1538          "usage": {
1539            "prompt_tokens": 2,
1540            "completion_tokens": 3,
1541            "total_tokens": 5,
1542            "prompt_tokens_details": {"cached_tokens": 0, "audio_tokens": 0},
1543            "completion_tokens_details": {"reasoning_tokens": 0, "audio_tokens": 0, "accepted_prediction_tokens": 0, "rejected_prediction_tokens": 0}
1544          }
1545        });
1546
1547        Mock::given(method("POST"))
1548            .and(path("/chat/completions"))
1549            .respond_with(Validate(response_body.clone()))
1550            .mount(&mock_server)
1551            .await;
1552
1553        let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1554        let async_openai = async_openai::Client::with_config(config);
1555
1556        let openai = OpenAI::builder()
1557            .client(async_openai)
1558            .default_prompt_model("gpt-4o")
1559            .parallel_tool_calls(Some(true))
1560            .build()
1561            .unwrap();
1562
1563        let req = ChatCompletionRequest::builder()
1564            .messages(vec![ChatMessage::User("hi".into())])
1565            .tool_specs([weather_tool, alpha_tool])
1566            .build()
1567            .unwrap();
1568
1569        let resp = openai.complete(&req).await.unwrap();
1570        assert_eq!(resp.message(), Some("Here"));
1571        assert_eq!(resp.usage.unwrap().total_tokens, 5);
1572    }
1573
1574    #[test_log::test(tokio::test)]
1575    async fn test_complete_stream_happy_path() {
1576        use serde_json::Value;
1577        use wiremock::{Request, Respond};
1578
1579        struct ValidateStreamExtraBody {
1580            sse_body: &'static str,
1581        }
1582
1583        impl Respond for ValidateStreamExtraBody {
1584            fn respond(&self, request: &Request) -> ResponseTemplate {
1585                let body: Value = serde_json::from_slice(&request.body).unwrap();
1586
1587                assert_eq!(body["tensorzero::episode_id"], "stream-episode");
1588                assert_eq!(body["tensorzero::tags"]["user_id"], "stream-user");
1589                assert_eq!(body["stream"], true);
1590
1591                ResponseTemplate::new(200).set_body_raw(self.sse_body, "text/event-stream")
1592            }
1593        }
1594
1595        let mock_server = MockServer::start().await;
1596
1597        let sse_body = "\
1598data: {\"id\":\"chatcmpl-123\",\"created\":1,\"object\":\"chat.completion.chunk\",\"model\":\"gpt-4o-mini\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hi\"},\"finish_reason\":null}]}\n\
1599\n\
1600data: {\"id\":\"chatcmpl-123\",\"created\":1,\"object\":\"chat.completion.chunk\",\"model\":\"gpt-4o-mini\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\
1601\n\
1602data: [DONE]\n\n";
1603
1604        Mock::given(method("POST"))
1605            .and(path("/chat/completions"))
1606            .respond_with(ValidateStreamExtraBody { sse_body })
1607            .mount(&mock_server)
1608            .await;
1609
1610        let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1611        let async_openai = async_openai::Client::with_config(config);
1612
1613        let extra_body = serde_json::Map::from_iter([
1614            (
1615                "tensorzero::episode_id".to_string(),
1616                serde_json::json!("stream-episode"),
1617            ),
1618            (
1619                "tensorzero::tags".to_string(),
1620                serde_json::json!({"user_id": "stream-user"}),
1621            ),
1622        ]);
1623
1624        let openai = OpenAI::builder()
1625            .client(async_openai)
1626            .default_prompt_model("gpt-4o-mini")
1627            .default_options(Options::builder().extra_body(extra_body))
1628            .build()
1629            .unwrap();
1630
1631        let req = ChatCompletionRequest::builder()
1632            .messages(vec![ChatMessage::User("Hello".into())])
1633            .build()
1634            .unwrap();
1635
1636        let results: Vec<_> = openai.complete_stream(&req).await.collect().await;
1637        let last = results.last().unwrap().as_ref().unwrap();
1638        assert_eq!(last.message(), Some("Hi"));
1639        assert_eq!(last.usage.as_ref().map(|u| u.total_tokens), Some(3));
1640    }
1641
1642    #[test_log::test(tokio::test)]
1643    async fn test_complete_stream_delta_only_mode() {
1644        let mock_server = MockServer::start().await;
1645
1646        let sse_body = "\
1647data: {\"id\":\"chatcmpl-123\",\"created\":1,\"object\":\"chat.completion.chunk\",\"model\":\"gpt-4o-mini\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hi\"},\"finish_reason\":null}]}\n\
1648\n\
1649data: {\"id\":\"chatcmpl-123\",\"created\":1,\"object\":\"chat.completion.chunk\",\"model\":\"gpt-4o-mini\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\
1650\n\
1651data: [DONE]\n\n";
1652
1653        Mock::given(method("POST"))
1654            .and(path("/chat/completions"))
1655            .respond_with(ResponseTemplate::new(200).set_body_raw(sse_body, "text/event-stream"))
1656            .mount(&mock_server)
1657            .await;
1658
1659        let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1660        let async_openai = async_openai::Client::with_config(config);
1661
1662        let openai = OpenAI::builder()
1663            .client(async_openai)
1664            .default_prompt_model("gpt-4o-mini")
1665            .stream_full(false)
1666            .build()
1667            .unwrap();
1668
1669        let req = ChatCompletionRequest::builder()
1670            .messages(vec![ChatMessage::User("Hello".into())])
1671            .build()
1672            .unwrap();
1673
1674        let mut stream = openai.complete_stream(&req).await;
1675        let first = stream.next().await.unwrap().unwrap();
1676        assert!(first.message.is_none());
1677        assert!(first.usage.is_none());
1678        assert!(
1679            first.delta.is_some(),
1680            "delta-only mode should emit delta snapshots"
1681        );
1682
1683        let final_snapshot = stream.next().await.unwrap().unwrap();
1684        // Final snapshot should arrive in delta-only mode.
1685        assert!(final_snapshot.usage.is_none() || final_snapshot.usage.is_some());
1686        while let Some(item) = stream.next().await {
1687            item.expect("stream should not error");
1688        }
1689    }
1690
1691    #[test_log::test(tokio::test)]
1692    async fn test_complete_stream_invalid_tool_schema_errors() {
1693        let invalid_schema = schemars::Schema::from(true);
1694
1695        let err = ToolSpec::builder()
1696            .name("bad")
1697            .description("bad schema")
1698            .parameters_schema(invalid_schema)
1699            .build()
1700            .expect_err("invalid tool schemas should be rejected at build time");
1701
1702        assert!(
1703            err.to_string()
1704                .contains("tool schema must be a JSON object")
1705        );
1706    }
1707
1708    #[test_log::test(tokio::test)]
1709    async fn test_complete_invalid_tool_schema_errors() {
1710        let invalid_schema = schemars::Schema::from(true);
1711
1712        let err = ToolSpec::builder()
1713            .name("bad")
1714            .description("bad schema")
1715            .parameters_schema(invalid_schema)
1716            .build()
1717            .expect_err("invalid tool schemas should be rejected at build time");
1718
1719        assert!(
1720            err.to_string()
1721                .contains("tool schema must be a JSON object")
1722        );
1723    }
1724
1725    #[test_log::test(tokio::test)]
1726    async fn test_complete_stream_rate_limit_transient_error() {
1727        let mock_server = MockServer::start().await;
1728
1729        Mock::given(method("POST"))
1730            .and(path("/chat/completions"))
1731            .respond_with(ResponseTemplate::new(429).set_body_string("rate limit"))
1732            .mount(&mock_server)
1733            .await;
1734
1735        let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
1736        let async_openai = async_openai::Client::with_config(config);
1737
1738        let openai = OpenAI::builder()
1739            .client(async_openai)
1740            .default_prompt_model("gpt-4o-mini")
1741            .build()
1742            .unwrap();
1743
1744        let req = ChatCompletionRequest::builder()
1745            .messages(vec![ChatMessage::User("hi".into())])
1746            .build()
1747            .unwrap();
1748
1749        let mut stream = openai.complete_stream(&req).await;
1750        let first = stream.next().await.expect("stream yields one item");
1751        assert!(matches!(first, Err(LanguageModelError::TransientError(_))));
1752        assert!(stream.next().await.is_none());
1753    }
1754
1755    #[test]
1756    fn test_message_to_openai_tool_output_without_content() {
1757        let tool_call = ToolCallBuilder::default()
1758            .id("call_1")
1759            .name("noop")
1760            .build()
1761            .unwrap();
1762        let msg = ChatMessage::ToolOutput(tool_call, ToolOutput::stop());
1763
1764        let converted = message_to_openai(&msg)
1765            .expect("conversion succeeds")
1766            .expect("message is not filtered");
1767        match converted {
1768            async_openai::types::chat::ChatCompletionRequestMessage::Tool(m) => {
1769                assert_eq!(m.tool_call_id, "call_1");
1770                assert_eq!(
1771                    m.content,
1772                    async_openai::types::chat::ChatCompletionRequestToolMessageContent::Text(
1773                        String::new()
1774                    )
1775                );
1776            }
1777            other => panic!("expected tool message, got {other:?}"),
1778        }
1779    }
1780
1781    #[test]
1782    fn test_message_to_openai_assistant_with_tool_calls_and_text() {
1783        let tool_call = ToolCallBuilder::default()
1784            .id("call_2")
1785            .name("math")
1786            .args("{\"x\":1}")
1787            .build()
1788            .unwrap();
1789
1790        let msg = ChatMessage::new_assistant(Some("pending"), Some(vec![tool_call.clone()]));
1791        let converted = message_to_openai(&msg)
1792            .expect("conversion succeeds")
1793            .expect("message is not filtered");
1794        match converted {
1795            async_openai::types::chat::ChatCompletionRequestMessage::Assistant(m) => {
1796                assert_eq!(m.content.unwrap(), "pending".into());
1797                let calls = m.tool_calls.unwrap();
1798                assert_eq!(calls.len(), 1);
1799                let async_openai::types::chat::ChatCompletionMessageToolCalls::Function(call) =
1800                    &calls[0]
1801                else {
1802                    panic!("expected function tool call");
1803                };
1804                assert_eq!(call.id, "call_2");
1805                assert_eq!(call.function.name, "math");
1806                assert_eq!(call.function.arguments, "{\"x\":1}");
1807            }
1808            other => panic!("expected assistant message, got {other:?}"),
1809        }
1810    }
1811
1812    #[test_log::test(tokio::test)]
1813    async fn test_complete_stream_model_missing_errors_immediately() {
1814        let openai = OpenAI::builder()
1815            .default_embed_model("unused")
1816            .build()
1817            .expect("builder without prompt model still constructs");
1818
1819        let request = ChatCompletionRequest::builder()
1820            .messages(vec![ChatMessage::User("hi".into())])
1821            .build()
1822            .unwrap();
1823
1824        let mut stream = openai.complete_stream(&request).await;
1825        let first = stream.next().await.expect("stream yields one item");
1826        assert!(
1827            matches!(first, Err(LanguageModelError::PermanentError(msg)) if msg.to_string().contains("Model not set"))
1828        );
1829        assert!(stream.next().await.is_none(), "stream ends after error");
1830    }
1831
1832    #[test_log::test(tokio::test)]
1833    async fn test_track_completion_invokes_on_usage_callback() {
1834        use std::sync::atomic::{AtomicUsize, Ordering};
1835        let hits = Arc::new(AtomicUsize::new(0));
1836        let hits_clone = hits.clone();
1837        let openai = OpenAI::builder()
1838            .default_prompt_model("gpt-4o")
1839            .on_usage(move |_usage| {
1840                hits_clone.fetch_add(1, Ordering::SeqCst);
1841                Ok(())
1842            })
1843            .build()
1844            .unwrap();
1845
1846        let usage = UsageBuilder::default()
1847            .prompt_tokens(1)
1848            .completion_tokens(1)
1849            .total_tokens(2)
1850            .build()
1851            .unwrap();
1852
1853        openai.track_completion(
1854            "gpt-4o",
1855            Some(&usage),
1856            Option::<&()>::None,
1857            Option::<&()>::None,
1858        );
1859
1860        // give spawned task a tick
1861        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1862        assert_eq!(hits.load(Ordering::SeqCst), 1);
1863    }
1864}