swiftide_integrations/openai/
chat_completion.rs

1use std::sync::Arc;
2use std::sync::Mutex;
3
4use anyhow::{Context as _, Result};
5use async_openai::types::ChatCompletionStreamOptions;
6use async_openai::types::{
7    ChatCompletionMessageToolCall, ChatCompletionRequestAssistantMessageArgs,
8    ChatCompletionRequestSystemMessageArgs, ChatCompletionRequestToolMessageArgs,
9    ChatCompletionRequestUserMessageArgs, ChatCompletionTool, ChatCompletionToolArgs,
10    ChatCompletionToolType, FunctionCall, FunctionObjectArgs,
11};
12use async_trait::async_trait;
13use futures_util::StreamExt as _;
14use futures_util::stream;
15use itertools::Itertools;
16use serde::Serialize;
17use serde_json::json;
18use swiftide_core::ChatCompletionStream;
19use swiftide_core::chat_completion::{
20    ChatCompletion, ChatCompletionRequest, ChatCompletionResponse, ChatMessage, ToolCall, ToolSpec,
21    errors::LanguageModelError,
22};
23use swiftide_core::chat_completion::{Usage, UsageBuilder};
24#[cfg(feature = "metrics")]
25use swiftide_core::metrics::emit_usage;
26
27use super::GenericOpenAI;
28use super::openai_error_to_language_model_error;
29use super::responses_api::{
30    build_responses_request_from_chat, response_to_chat_completion, responses_stream_adapter,
31};
32use super::{
33    ensure_tool_schema_additional_properties_false, ensure_tool_schema_required_matches_properties,
34};
35use tracing_futures::Instrument;
36
37#[async_trait]
38impl<
39    C: async_openai::config::Config
40        + std::default::Default
41        + Sync
42        + Send
43        + std::fmt::Debug
44        + Clone
45        + 'static,
46> ChatCompletion for GenericOpenAI<C>
47{
48    #[cfg_attr(not(feature = "langfuse"), tracing::instrument(skip_all, err))]
49    #[cfg_attr(
50        feature = "langfuse",
51        tracing::instrument(skip_all, err, fields(langfuse.type = "GENERATION"))
52    )]
53    async fn complete(
54        &self,
55        request: &ChatCompletionRequest,
56    ) -> Result<ChatCompletionResponse, LanguageModelError> {
57        if self.is_responses_api_enabled() {
58            return self.complete_via_responses_api(request).await;
59        }
60
61        let model = self
62            .default_options
63            .prompt_model
64            .as_ref()
65            .context("Model not set")?;
66
67        let messages = request
68            .messages()
69            .iter()
70            .map(message_to_openai)
71            .collect::<Result<Vec<_>>>()?;
72
73        // Build the request to be sent to the OpenAI API.
74        let mut openai_request = self
75            .chat_completion_request_defaults()
76            .model(model)
77            .messages(messages)
78            .to_owned();
79
80        if !request.tools_spec.is_empty() {
81            openai_request
82                .tools(
83                    request
84                        .tools_spec()
85                        .iter()
86                        .map(tools_to_openai)
87                        .collect::<Result<Vec<_>>>()?,
88                )
89                .tool_choice("auto");
90            if let Some(par) = self.default_options.parallel_tool_calls {
91                openai_request.parallel_tool_calls(par);
92            }
93        }
94
95        let request = openai_request
96            .build()
97            .map_err(openai_error_to_language_model_error)?;
98
99        tracing::trace!(model, ?request, "Sending request to OpenAI");
100
101        let response = self
102            .client
103            .chat()
104            .create(request.clone())
105            .await
106            .map_err(openai_error_to_language_model_error)?;
107
108        tracing::trace!(?response, "[ChatCompletion] Full response from OpenAI");
109        // Make sure the debug log is a concise one line
110
111        let mut builder = ChatCompletionResponse::builder()
112            .maybe_message(
113                response
114                    .choices
115                    .first()
116                    .and_then(|choice| choice.message.content.clone()),
117            )
118            .maybe_tool_calls(
119                response
120                    .choices
121                    .first()
122                    .and_then(|choice| choice.message.tool_calls.clone())
123                    .map(|tool_calls| {
124                        tool_calls
125                            .iter()
126                            .map(|tool_call| {
127                                ToolCall::builder()
128                                    .id(tool_call.id.clone())
129                                    .args(tool_call.function.arguments.clone())
130                                    .name(tool_call.function.name.clone())
131                                    .build()
132                                    .expect("infallible")
133                            })
134                            .collect_vec()
135                    }),
136            )
137            .to_owned();
138
139        if let Some(usage) = &response.usage {
140            let usage = UsageBuilder::default()
141                .prompt_tokens(usage.prompt_tokens)
142                .completion_tokens(usage.completion_tokens)
143                .total_tokens(usage.total_tokens)
144                .build()
145                .map_err(LanguageModelError::permanent)?;
146
147            builder.usage(usage);
148        }
149
150        let our_response = builder.build().map_err(LanguageModelError::from)?;
151
152        self.track_completion(
153            model,
154            our_response.usage.as_ref(),
155            Some(&request),
156            Some(&our_response),
157        );
158
159        Ok(our_response)
160    }
161
162    #[tracing::instrument(skip_all)]
163    async fn complete_stream(&self, request: &ChatCompletionRequest) -> ChatCompletionStream {
164        if self.is_responses_api_enabled() {
165            return self.complete_stream_via_responses_api(request).await;
166        }
167
168        let Some(model_name) = self.default_options.prompt_model.clone() else {
169            return LanguageModelError::permanent("Model not set").into();
170        };
171
172        #[cfg(not(any(feature = "metrics", feature = "langfuse")))]
173        let _ = &model_name;
174
175        let messages = match request
176            .messages()
177            .iter()
178            .map(message_to_openai)
179            .collect::<Result<Vec<_>>>()
180        {
181            Ok(messages) => messages,
182            Err(e) => return LanguageModelError::from(e).into(),
183        };
184
185        // Build the request to be sent to the OpenAI API.
186        let mut openai_request = self
187            .chat_completion_request_defaults()
188            .model(&model_name)
189            .messages(messages)
190            .stream_options(ChatCompletionStreamOptions {
191                include_usage: true,
192            })
193            .to_owned();
194
195        if !request.tools_spec.is_empty() {
196            openai_request
197                .tools(
198                    match request
199                        .tools_spec()
200                        .iter()
201                        .map(tools_to_openai)
202                        .collect::<Result<Vec<_>>>()
203                    {
204                        Ok(tools) => tools,
205                        Err(e) => {
206                            return LanguageModelError::from(e).into();
207                        }
208                    },
209                )
210                .tool_choice("auto");
211            if let Some(par) = self.default_options.parallel_tool_calls {
212                openai_request.parallel_tool_calls(par);
213            }
214        }
215
216        let request = match openai_request.build() {
217            Ok(request) => request,
218            Err(e) => {
219                return openai_error_to_language_model_error(e).into();
220            }
221        };
222
223        tracing::trace!(model = %model_name, ?request, "Sending request to OpenAI");
224
225        let response = match self.client.chat().create_stream(request.clone()).await {
226            Ok(response) => response,
227            Err(e) => return openai_error_to_language_model_error(e).into(),
228        };
229
230        let accumulating_response = Arc::new(Mutex::new(ChatCompletionResponse::default()));
231        let final_response = accumulating_response.clone();
232        let stream_full = self.stream_full;
233
234        let span = if cfg!(feature = "langfuse") {
235            tracing::info_span!(
236                "stream",
237                langfuse.type = "GENERATION",
238            )
239        } else {
240            tracing::info_span!("stream")
241        };
242
243        let self_for_stream = self.clone();
244        let stream = response
245            .map(move |chunk| match chunk {
246                Ok(chunk) => {
247                    let accumulating_response = Arc::clone(&accumulating_response);
248
249                    let delta_message = chunk
250                        .choices
251                        .first()
252                        .and_then(|d| d.delta.content.as_deref());
253                    let delta_tool_calls = chunk
254                        .choices
255                        .first()
256                        .and_then(|d| d.delta.tool_calls.as_deref());
257                    let usage = chunk.usage.as_ref();
258
259                    let chat_completion_response = {
260                        let mut lock = accumulating_response.lock().unwrap();
261                        lock.append_message_delta(delta_message);
262
263                        if let Some(delta_tool_calls) = delta_tool_calls {
264                            for tc in delta_tool_calls {
265                                lock.append_tool_call_delta(
266                                    tc.index as usize,
267                                    tc.id.as_deref(),
268                                    tc.function.as_ref().and_then(|f| f.name.as_deref()),
269                                    tc.function.as_ref().and_then(|f| f.arguments.as_deref()),
270                                );
271                            }
272                        }
273
274                        if let Some(usage) = usage {
275                            lock.append_usage_delta(
276                                usage.prompt_tokens,
277                                usage.completion_tokens,
278                                usage.total_tokens,
279                            );
280                        }
281
282                        if stream_full {
283                            lock.clone()
284                        } else {
285                            // If we are not streaming the full response, we return a clone of the
286                            // current state to avoid holding the lock
287                            // for too long.
288                            ChatCompletionResponse {
289                                id: lock.id,
290                                message: None,
291                                tool_calls: None,
292                                usage: None,
293                                delta: lock.delta.clone(),
294                            }
295                        }
296                    };
297
298                    Ok(chat_completion_response)
299                }
300                Err(e) => Err(openai_error_to_language_model_error(e)),
301            })
302            .chain(
303                stream::iter(vec![final_response]).map(move |accumulating_response| {
304                    let lock = accumulating_response.lock().unwrap();
305
306                    self_for_stream.track_completion(
307                        &model_name,
308                        lock.usage.as_ref(),
309                        Some(&request),
310                        Some(&*lock),
311                    );
312
313                    Ok(lock.clone())
314                }),
315            );
316
317        let stream = tracing_futures::Instrument::instrument(stream, span);
318
319        Box::pin(stream)
320    }
321}
322
323impl<
324    C: async_openai::config::Config
325        + std::default::Default
326        + Sync
327        + Send
328        + std::fmt::Debug
329        + Clone
330        + 'static,
331> GenericOpenAI<C>
332{
333    async fn complete_via_responses_api(
334        &self,
335        request: &ChatCompletionRequest,
336    ) -> Result<ChatCompletionResponse, LanguageModelError> {
337        let model = self
338            .default_options
339            .prompt_model
340            .as_ref()
341            .context("Model not set")?;
342
343        let create_request = build_responses_request_from_chat(self, request)?;
344
345        let response = self
346            .client
347            .responses()
348            .create(create_request.clone())
349            .await
350            .map_err(openai_error_to_language_model_error)?;
351
352        let completion = response_to_chat_completion(&response)?;
353
354        self.track_completion(
355            model,
356            completion.usage.as_ref(),
357            Some(&create_request),
358            Some(&completion),
359        );
360
361        Ok(completion)
362    }
363
364    #[allow(clippy::too_many_lines)]
365    async fn complete_stream_via_responses_api(
366        &self,
367        request: &ChatCompletionRequest,
368    ) -> ChatCompletionStream {
369        #[allow(unused_variables)]
370        let Some(model_name) = self.default_options.prompt_model.clone() else {
371            return LanguageModelError::permanent("Model not set").into();
372        };
373
374        let mut create_request = match build_responses_request_from_chat(self, request) {
375            Ok(req) => req,
376            Err(err) => return err.into(),
377        };
378
379        create_request.stream = Some(true);
380
381        let stream = match self
382            .client
383            .responses()
384            .create_stream(create_request.clone())
385            .await
386        {
387            Ok(stream) => stream,
388            Err(err) => return openai_error_to_language_model_error(err).into(),
389        };
390
391        let stream_full = self.stream_full;
392
393        let span = if cfg!(feature = "langfuse") {
394            tracing::info_span!("responses_stream", langfuse.type = "GENERATION")
395        } else {
396            tracing::info_span!("responses_stream")
397        };
398
399        let mapped_stream = responses_stream_adapter(stream, stream_full);
400
401        let this = self.clone();
402        let tracked_request = create_request.clone();
403
404        let mapped_stream = mapped_stream.map(move |result| match result {
405            Ok(item) => {
406                if item.finished {
407                    this.track_completion(
408                        &model_name,
409                        item.response.usage.as_ref(),
410                        Some(&tracked_request),
411                        Some(&item.response),
412                    );
413                }
414
415                Ok(item.response)
416            }
417            Err(err) => Err(err),
418        });
419
420        Box::pin(Instrument::instrument(mapped_stream, span))
421    }
422    #[allow(unused_variables)]
423    pub(crate) fn track_completion<R, S>(
424        &self,
425        model: &str,
426        usage: Option<&Usage>,
427        request: Option<&R>,
428        response: Option<&S>,
429    ) where
430        R: Serialize + ?Sized,
431        S: Serialize + ?Sized,
432    {
433        if let Some(usage) = usage {
434            let cb_usage = usage.clone();
435            if let Some(callback) = &self.on_usage {
436                let callback = callback.clone();
437                tokio::spawn(async move {
438                    if let Err(err) = callback(&cb_usage).await {
439                        tracing::error!("Error in on_usage callback: {err}");
440                    }
441                });
442            }
443
444            #[cfg(feature = "metrics")]
445            emit_usage(
446                model,
447                usage.prompt_tokens.into(),
448                usage.completion_tokens.into(),
449                usage.total_tokens.into(),
450                self.metric_metadata.as_ref(),
451            );
452        }
453
454        #[cfg(feature = "langfuse")]
455        tracing::debug!(
456            langfuse.model = model,
457            langfuse.input = request.and_then(langfuse_json).unwrap_or_default(),
458            langfuse.output = response.and_then(langfuse_json).unwrap_or_default(),
459            langfuse.usage = usage.and_then(langfuse_json).unwrap_or_default(),
460        );
461    }
462}
463
464#[cfg(feature = "langfuse")]
465pub(crate) fn langfuse_json<T: Serialize + ?Sized>(value: &T) -> Option<String> {
466    serde_json::to_string_pretty(value).ok()
467}
468
469#[cfg(not(feature = "langfuse"))]
470#[allow(dead_code)]
471pub(crate) fn langfuse_json<T>(_value: &T) -> Option<String> {
472    None
473}
474
475pub(crate) fn usage_from_counts(
476    prompt_tokens: u32,
477    completion_tokens: u32,
478    total_tokens: u32,
479) -> Usage {
480    Usage {
481        prompt_tokens,
482        completion_tokens,
483        total_tokens,
484    }
485}
486
487fn tools_to_openai(spec: &ToolSpec) -> Result<ChatCompletionTool> {
488    let mut parameters = match &spec.parameters_schema {
489        Some(schema) => serde_json::to_value(schema)?,
490        None => json!({
491            "type": "object",
492            "properties": {},
493            "required": [],
494            "additionalProperties": false,
495        }),
496    };
497
498    ensure_tool_schema_additional_properties_false(&mut parameters)
499        .context("tool schema must allow no additional properties")?;
500    ensure_tool_schema_required_matches_properties(&mut parameters)
501        .context("tool schema must list required properties")?;
502    tracing::debug!(
503        parameters = serde_json::to_string_pretty(&parameters).unwrap(),
504        tool = %spec.name,
505        "Tool parameters schema"
506    );
507
508    ChatCompletionToolArgs::default()
509        .r#type(ChatCompletionToolType::Function)
510        .function(
511            FunctionObjectArgs::default()
512                .name(&spec.name)
513                .description(&spec.description)
514                .strict(true)
515                .parameters(parameters)
516                .build()?,
517        )
518        .build()
519        .map_err(anyhow::Error::from)
520}
521
522fn message_to_openai(
523    message: &ChatMessage,
524) -> Result<async_openai::types::ChatCompletionRequestMessage> {
525    let openai_message = match message {
526        ChatMessage::User(msg) => ChatCompletionRequestUserMessageArgs::default()
527            .content(msg.as_str())
528            .build()?
529            .into(),
530        ChatMessage::System(msg) => ChatCompletionRequestSystemMessageArgs::default()
531            .content(msg.as_str())
532            .build()?
533            .into(),
534        ChatMessage::Summary(msg) => ChatCompletionRequestAssistantMessageArgs::default()
535            .content(msg.as_str())
536            .build()?
537            .into(),
538        ChatMessage::ToolOutput(tool_call, tool_output) => {
539            let Some(content) = tool_output.content() else {
540                return Ok(ChatCompletionRequestToolMessageArgs::default()
541                    .tool_call_id(tool_call.id())
542                    .build()?
543                    .into());
544            };
545
546            ChatCompletionRequestToolMessageArgs::default()
547                .content(content)
548                .tool_call_id(tool_call.id())
549                .build()?
550                .into()
551        }
552        ChatMessage::Assistant(msg, tool_calls) => {
553            let mut builder = ChatCompletionRequestAssistantMessageArgs::default();
554
555            if let Some(msg) = msg {
556                builder.content(msg.as_str());
557            }
558
559            if let Some(tool_calls) = tool_calls {
560                builder.tool_calls(
561                    tool_calls
562                        .iter()
563                        .map(|tool_call| ChatCompletionMessageToolCall {
564                            id: tool_call.id().to_string(),
565                            r#type: ChatCompletionToolType::Function,
566                            function: FunctionCall {
567                                name: tool_call.name().to_string(),
568                                arguments: tool_call.args().unwrap_or_default().to_string(),
569                            },
570                        })
571                        .collect::<Vec<_>>(),
572                );
573            }
574
575            builder.build()?.into()
576        }
577    };
578
579    Ok(openai_message)
580}
581
582#[cfg(test)]
583mod tests {
584    use crate::openai::{OpenAI, Options};
585
586    use super::*;
587    use wiremock::matchers::{method, path};
588    use wiremock::{Mock, MockServer, ResponseTemplate};
589
590    #[allow(dead_code)]
591    #[derive(schemars::JsonSchema)]
592    struct WeatherArgs {
593        city: String,
594    }
595
596    #[test]
597    fn test_tools_to_openai_sets_additional_properties_false() {
598        let spec = ToolSpec::builder()
599            .name("get_weather")
600            .description("Retrieve weather data")
601            .parameters_schema(schemars::schema_for!(WeatherArgs))
602            .build()
603            .unwrap();
604
605        let tool = tools_to_openai(&spec).expect("tool conversion succeeds");
606
607        assert_eq!(tool.r#type, ChatCompletionToolType::Function);
608
609        let additional_properties = tool
610            .function
611            .parameters
612            .as_ref()
613            .and_then(serde_json::Value::as_object)
614            .and_then(|obj| obj.get("additionalProperties"))
615            .cloned();
616
617        assert_eq!(
618            additional_properties,
619            Some(serde_json::Value::Bool(false)),
620            "Chat Completions require additionalProperties=false for tool parameters, got {}",
621            serde_json::to_string_pretty(&tool.function.parameters).unwrap()
622        );
623    }
624
625    #[test_log::test(tokio::test)]
626    async fn test_complete() {
627        let mock_server = MockServer::start().await;
628
629        // Mock OpenAI API response
630        let response_body = json!({
631          "id": "chatcmpl-B9MBs8CjcvOU2jLn4n570S5qMJKcT",
632          "object": "chat.completion",
633          "created": 123,
634          "model": "gpt-4o",
635          "choices": [
636            {
637              "index": 0,
638              "message": {
639                "role": "assistant",
640                "content": "Hello, world!",
641                "refusal": null,
642                "annotations": []
643              },
644              "logprobs": null,
645              "finish_reason": "stop"
646            }
647          ],
648          "usage": {
649            "prompt_tokens": 19,
650            "completion_tokens": 10,
651            "total_tokens": 29,
652            "prompt_tokens_details": {
653              "cached_tokens": 0,
654              "audio_tokens": 0
655            },
656            "completion_tokens_details": {
657              "reasoning_tokens": 0,
658              "audio_tokens": 0,
659              "accepted_prediction_tokens": 0,
660              "rejected_prediction_tokens": 0
661            }
662          },
663          "service_tier": "default"
664        });
665        Mock::given(method("POST"))
666            .and(path("/chat/completions"))
667            .respond_with(ResponseTemplate::new(200).set_body_json(response_body))
668            .mount(&mock_server)
669            .await;
670
671        // Create a GenericOpenAI instance with the mock server URL
672        let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
673        let async_openai = async_openai::Client::with_config(config);
674
675        let openai = OpenAI::builder()
676            .client(async_openai)
677            .default_prompt_model("gpt-4o")
678            .build()
679            .expect("Can create OpenAI client.");
680
681        // Prepare a test request
682        let request = ChatCompletionRequest::builder()
683            .messages(vec![ChatMessage::User("Hi".to_string())])
684            .build()
685            .unwrap();
686
687        // Call the `complete` method
688        let response = openai.complete(&request).await.unwrap();
689
690        // Assert the response
691        assert_eq!(response.message(), Some("Hello, world!"));
692
693        // Usage
694        let usage = response.usage.unwrap();
695        assert_eq!(usage.prompt_tokens, 19);
696        assert_eq!(usage.completion_tokens, 10);
697        assert_eq!(usage.total_tokens, 29);
698    }
699
700    #[test_log::test(tokio::test)]
701    #[allow(clippy::items_after_statements)]
702    async fn test_complete_responses_api() {
703        use serde_json::Value;
704        use wiremock::{Request, Respond};
705
706        let mock_server = MockServer::start().await;
707
708        use async_openai::types::responses::{
709            CompletionTokensDetails, Content, OutputContent, OutputMessage, OutputStatus,
710            OutputText, PromptTokensDetails, Response as ResponsesResponse, Role, Status,
711            Usage as ResponsesUsage,
712        };
713
714        let response = ResponsesResponse {
715            created_at: 123,
716            error: None,
717            id: "resp_123".into(),
718            incomplete_details: None,
719            instructions: None,
720            max_output_tokens: None,
721            metadata: None,
722            model: "gpt-4.1-mini".into(),
723            object: "response".into(),
724            output: vec![OutputContent::Message(OutputMessage {
725                content: vec![Content::OutputText(OutputText {
726                    annotations: Vec::new(),
727                    text: "Hello via responses".into(),
728                })],
729                id: "msg_1".into(),
730                role: Role::Assistant,
731                status: OutputStatus::Completed,
732            })],
733            output_text: Some("Hello via responses".into()),
734            parallel_tool_calls: None,
735            previous_response_id: None,
736            reasoning: None,
737            store: None,
738            service_tier: None,
739            status: Status::Completed,
740            temperature: None,
741            text: None,
742            tool_choice: None,
743            tools: None,
744            top_p: None,
745            truncation: None,
746            usage: Some(ResponsesUsage {
747                input_tokens: 5,
748                input_tokens_details: PromptTokensDetails {
749                    audio_tokens: Some(0),
750                    cached_tokens: Some(0),
751                },
752                output_tokens: 3,
753                output_tokens_details: CompletionTokensDetails {
754                    accepted_prediction_tokens: Some(0),
755                    audio_tokens: Some(0),
756                    reasoning_tokens: Some(0),
757                    rejected_prediction_tokens: Some(0),
758                },
759                total_tokens: 8,
760            }),
761            user: None,
762        };
763
764        let response_body = serde_json::to_value(&response).unwrap();
765
766        struct ValidateResponsesRequest {
767            expected_model: &'static str,
768            response: Value,
769        }
770
771        impl Respond for ValidateResponsesRequest {
772            fn respond(&self, request: &Request) -> ResponseTemplate {
773                let body: Value = serde_json::from_slice(&request.body).unwrap();
774                assert_eq!(body["model"], self.expected_model);
775                let input = body["input"].as_array().expect("input array");
776                assert_eq!(input.len(), 1);
777                assert_eq!(input[0]["role"], "user");
778                assert_eq!(input[0]["content"], "Hello via prompt");
779
780                let _: async_openai::types::responses::Response =
781                    serde_json::from_value(self.response.clone()).unwrap();
782
783                ResponseTemplate::new(200).set_body_json(self.response.clone())
784            }
785        }
786
787        Mock::given(method("POST"))
788            .and(path("/responses"))
789            .respond_with(ValidateResponsesRequest {
790                expected_model: "gpt-4.1-mini",
791                response: response_body,
792            })
793            .mount(&mock_server)
794            .await;
795
796        let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
797        let async_openai = async_openai::Client::with_config(config);
798
799        let openai = OpenAI::builder()
800            .client(async_openai)
801            .default_prompt_model("gpt-4.1-mini")
802            .use_responses_api(true)
803            .build()
804            .expect("Can create OpenAI client.");
805
806        let request = ChatCompletionRequest::builder()
807            .messages(vec![ChatMessage::User("Hello via prompt".to_string())])
808            .build()
809            .unwrap();
810
811        let response = openai.complete(&request).await.unwrap();
812
813        assert_eq!(response.message(), Some("Hello via responses"));
814
815        let usage = response.usage.expect("usage present");
816        assert_eq!(usage.prompt_tokens, 5);
817        assert_eq!(usage.completion_tokens, 3);
818        assert_eq!(usage.total_tokens, 8);
819    }
820
821    #[test_log::test(tokio::test)]
822    #[allow(clippy::items_after_statements)]
823    async fn test_complete_with_all_default_settings() {
824        use serde_json::Value;
825        use wiremock::{Request, Respond, ResponseTemplate};
826
827        let mock_server = wiremock::MockServer::start().await;
828
829        // Custom matcher to validate all settings in the incoming request
830        struct ValidateAllSettings;
831
832        impl Respond for ValidateAllSettings {
833            fn respond(&self, request: &Request) -> ResponseTemplate {
834                let v: Value = serde_json::from_slice(&request.body).unwrap();
835
836                // Validate required fields
837                assert_eq!(v["model"], "gpt-4-turbo");
838                let arr = v["messages"].as_array().unwrap();
839                assert_eq!(arr.len(), 1);
840                assert_eq!(arr[0]["content"], "Test");
841
842                assert_eq!(v["parallel_tool_calls"], true);
843                assert_eq!(v["max_completion_tokens"], 77);
844                assert!((v["temperature"].as_f64().unwrap() - 0.42).abs() < 1e-5);
845                assert_eq!(v["reasoning_effort"], "low");
846                assert_eq!(v["seed"], 42);
847                assert!((v["presence_penalty"].as_f64().unwrap() - 1.1).abs() < 1e-5);
848
849                // Metadata as JSON object and user string
850                assert_eq!(v["metadata"], serde_json::json!({"key": "value"}));
851                assert_eq!(v["user"], "test-user");
852                ResponseTemplate::new(200).set_body_json(serde_json::json!({
853                "id": "chatcmpl-xxx",
854                "object": "chat.completion",
855                "created": 123,
856                "model": "gpt-4-turbo",
857                "choices": [{
858                    "index": 0,
859                    "message": {
860                        "role": "assistant",
861                        "content": "All settings validated",
862                        "refusal": null,
863                        "annotations": []
864                    },
865                    "logprobs": null,
866                    "finish_reason": "stop"
867                }],
868                "usage": {
869                    "prompt_tokens": 19,
870                    "completion_tokens": 10,
871                    "total_tokens": 29,
872                    "prompt_tokens_details": {"cached_tokens": 0, "audio_tokens": 0},
873                    "completion_tokens_details": {"reasoning_tokens": 0, "audio_tokens": 0, "accepted_prediction_tokens": 0, "rejected_prediction_tokens": 0}
874                },
875                "service_tier": "default"
876            }))
877            }
878        }
879
880        wiremock::Mock::given(wiremock::matchers::method("POST"))
881            .and(wiremock::matchers::path("/chat/completions"))
882            .respond_with(ValidateAllSettings)
883            .mount(&mock_server)
884            .await;
885
886        let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
887        let async_openai = async_openai::Client::with_config(config);
888
889        let openai = crate::openai::OpenAI::builder()
890            .client(async_openai)
891            .default_prompt_model("gpt-4-turbo")
892            .default_embed_model("not-used")
893            .parallel_tool_calls(Some(true))
894            .default_options(
895                Options::builder()
896                    .max_completion_tokens(77)
897                    .temperature(0.42)
898                    .reasoning_effort(async_openai::types::ReasoningEffort::Low)
899                    .seed(42)
900                    .presence_penalty(1.1)
901                    .metadata(serde_json::json!({"key": "value"}))
902                    .user("test-user"),
903            )
904            .build()
905            .expect("Can create OpenAI client.");
906
907        let request = swiftide_core::chat_completion::ChatCompletionRequest::builder()
908            .messages(vec![swiftide_core::chat_completion::ChatMessage::User(
909                "Test".to_string(),
910            )])
911            .build()
912            .unwrap();
913
914        let response = openai.complete(&request).await.unwrap();
915
916        assert_eq!(response.message(), Some("All settings validated"));
917    }
918}