Skip to main content

swiftide_integrations/openai/
mod.rs

1//! This module provides integration with `OpenAI`'s API, enabling the use of language models and
2//! embeddings within the Swiftide project. It includes the `OpenAI` struct for managing API clients
3//! and default options for embedding and prompt models. The module is conditionally compiled based
4//! on the "openai" feature flag.
5
6use async_openai::error::OpenAIError;
7use async_openai::types::chat::CreateChatCompletionRequestArgs;
8use async_openai::types::embeddings::CreateEmbeddingRequestArgs;
9use derive_builder::Builder;
10use serde::Serialize;
11use std::pin::Pin;
12use std::sync::Arc;
13use swiftide_core::chat_completion::Usage;
14use swiftide_core::chat_completion::errors::LanguageModelError;
15
16mod chat_completion;
17mod embed;
18mod provider_error;
19mod responses_api;
20mod simple_prompt;
21mod structured_prompt;
22mod tool_schema;
23
24// expose type aliases to simplify downstream use of the open ai builder invocations
25pub use async_openai::config::AzureConfig;
26pub use async_openai::config::OpenAIConfig;
27pub use async_openai::types::responses::ReasoningEffort;
28
29#[cfg(feature = "tiktoken")]
30use crate::tiktoken::TikToken;
31#[cfg(feature = "tiktoken")]
32use anyhow::Result;
33#[cfg(feature = "tiktoken")]
34use swiftide_core::Estimatable;
35#[cfg(feature = "tiktoken")]
36use swiftide_core::EstimateTokens;
37
38/// The `OpenAI` struct encapsulates an `OpenAI` client and default options for embedding and prompt
39/// models. It uses the `Builder` pattern for flexible and customizable instantiation.
40///
41/// # Example
42///
43/// ```no_run
44/// # use swiftide_integrations::openai::{OpenAI, Options};
45/// # use swiftide_integrations::openai::OpenAIConfig;
46///
47/// // Create an OpenAI client with default options. The client will use the OPENAI_API_KEY environment variable.
48/// let openai = OpenAI::builder()
49///     .default_embed_model("text-embedding-3-small")
50///     .default_prompt_model("gpt-4")
51///     .build().unwrap();
52///
53/// // Create an OpenAI client with a custom api key.
54/// let openai = OpenAI::builder()
55///     .default_embed_model("text-embedding-3-small")
56///     .default_prompt_model("gpt-4")
57///     .client(async_openai::Client::with_config(async_openai::config::OpenAIConfig::default().with_api_key("my-api-key")))
58///     .build().unwrap();
59///
60/// // Create an OpenAI client with custom options
61/// let openai = OpenAI::builder()
62///     .default_embed_model("text-embedding-3-small")
63///     .default_prompt_model("gpt-4")
64///     .default_options(
65///         Options::builder()
66///           .temperature(1.0)
67///           .parallel_tool_calls(false)
68///           .user("MyUserId")
69///     )
70///     .build().unwrap();
71/// ```
72pub type OpenAI = GenericOpenAI<OpenAIConfig>;
73pub type OpenAIBuilder = GenericOpenAIBuilder<OpenAIConfig>;
74
75#[derive(Builder, Clone)]
76#[builder(setter(into, strip_option))]
77/// Generic client for `OpenAI` APIs.
78pub struct GenericOpenAI<
79    C: async_openai::config::Config + Default = async_openai::config::OpenAIConfig,
80> {
81    /// The `OpenAI` client, wrapped in an `Arc` for thread-safe reference counting.
82    /// Defaults to a new instance of `async_openai::Client`.
83    #[builder(
84        default = "Arc::new(async_openai::Client::<C>::default())",
85        setter(custom)
86    )]
87    client: Arc<async_openai::Client<C>>,
88
89    /// Default options for embedding and prompt models.
90    #[builder(default, setter(custom))]
91    pub(crate) default_options: Options,
92
93    #[cfg(feature = "tiktoken")]
94    #[cfg_attr(feature = "tiktoken", builder(default))]
95    pub(crate) tiktoken: TikToken,
96
97    /// Convenience option to stream the full response. Defaults to true, because nobody has time
98    /// to reconstruct the delta. Disabling this will make the streamed content only return the
99    /// delta, for when performance matters. This only has effect when streaming is enabled.
100    #[builder(default = true)]
101    pub stream_full: bool,
102
103    #[cfg(feature = "metrics")]
104    #[builder(default)]
105    /// Optional metadata to attach to metrics emitted by this client.
106    metric_metadata: Option<std::collections::HashMap<String, String>>,
107
108    /// Opt-in flag to use `OpenAI`'s Responses API instead of the legacy Chat Completions API.
109    #[builder(default)]
110    pub(crate) use_responses_api: bool,
111
112    /// A callback function that is called when usage information is available.
113    #[builder(default, setter(custom))]
114    #[allow(clippy::type_complexity)]
115    on_usage: Option<
116        Arc<
117            dyn for<'a> Fn(
118                    &'a Usage,
119                ) -> Pin<
120                    Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send + 'a>,
121                > + Send
122                + Sync,
123        >,
124    >,
125}
126
127impl<C: async_openai::config::Config + Default + std::fmt::Debug> std::fmt::Debug
128    for GenericOpenAI<C>
129{
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        f.debug_struct("GenericOpenAI")
132            .field("client", &self.client)
133            .field("default_options", &self.default_options)
134            .field("stream_full", &self.stream_full)
135            .field("use_responses_api", &self.use_responses_api)
136            .finish_non_exhaustive()
137    }
138}
139
140/// The `Options` struct holds configuration options for the `OpenAI` client.
141/// It includes optional fields for specifying the embedding and prompt models.
142#[derive(Debug, Clone, Builder, Default)]
143#[builder(setter(strip_option))]
144pub struct Options {
145    /// The default embedding model to use, if specified.
146    #[builder(default, setter(into))]
147    pub embed_model: Option<String>,
148    /// The default prompt model to use, if specified.
149    #[builder(default, setter(into))]
150    pub prompt_model: Option<String>,
151
152    #[builder(default)]
153    /// Option to enable or disable parallel tool calls for completions.
154    ///
155    /// At this moment, o1 and o3-mini do not support it and should be set to `None`.
156    pub parallel_tool_calls: Option<bool>,
157
158    /// Maximum number of tokens to generate in the completion.
159    ///
160    /// By default, the limit is disabled
161    #[builder(default)]
162    pub max_completion_tokens: Option<u32>,
163
164    /// Temperature setting for the model.
165    #[builder(default)]
166    pub temperature: Option<f32>,
167
168    /// Reasoning effor for reasoning models.
169    #[builder(default, setter(into))]
170    pub reasoning_effort: Option<ReasoningEffort>,
171
172    /// Enable reasoning summary/encrypted content handling for the Responses API.
173    ///
174    /// This is enabled by default, but only takes effect when `reasoning_effort` is set.
175    /// Disable it with `reasoning_features(false)` if you do not want summaries or encrypted
176    /// reasoning stored and replayed.
177    ///
178    /// Note: reasoning summaries/encrypted content require an `OpenAI` organization that is
179    /// verified for reasoning access; unverified orgs may receive no summaries.
180    #[builder(default, setter(into))]
181    pub reasoning_features: Option<bool>,
182
183    /// This feature is in Beta. If specified, our system will make a best effort to sample
184    /// deterministically, such that repeated requests with the same seed and parameters should
185    /// return the same result. Determinism is not guaranteed, and you should refer to the
186    /// `system_fingerprint` response parameter to monitor changes in the backend.
187    #[builder(default)]
188    pub seed: Option<i64>,
189
190    /// Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they
191    /// appear in the text so far, increasing the model’s likelihood to talk about new topics.
192    #[builder(default)]
193    pub presence_penalty: Option<f32>,
194
195    /// Developer-defined tags and values used for filtering completions in the dashboard.
196    #[builder(default, setter(into))]
197    pub metadata: Option<serde_json::Value>,
198
199    /// A unique identifier representing your end-user, which can help `OpenAI` to monitor and
200    /// detect abuse.
201    #[builder(default, setter(into))]
202    pub user: Option<String>,
203
204    /// Additional top-level JSON fields for OpenAI-compatible providers.
205    #[builder(default, setter(custom))]
206    extra_body: serde_json::Map<String, serde_json::Value>,
207
208    #[builder(default)]
209    /// The number of dimensions the resulting output embeddings should have. Only supported in
210    /// text-embedding-3 and later models.
211    pub dimensions: Option<u32>,
212}
213
214impl Options {
215    /// Creates a new `OptionsBuilder` for constructing `Options` instances.
216    pub fn builder() -> OptionsBuilder {
217        OptionsBuilder::default()
218    }
219
220    /// Extends options with other options
221    pub fn merge(&mut self, other: &Options) {
222        if let Some(embed_model) = &other.embed_model {
223            self.embed_model = Some(embed_model.clone());
224        }
225        if let Some(prompt_model) = &other.prompt_model {
226            self.prompt_model = Some(prompt_model.clone());
227        }
228        if let Some(parallel_tool_calls) = other.parallel_tool_calls {
229            self.parallel_tool_calls = Some(parallel_tool_calls);
230        }
231        if let Some(max_completion_tokens) = other.max_completion_tokens {
232            self.max_completion_tokens = Some(max_completion_tokens);
233        }
234        if let Some(temperature) = other.temperature {
235            self.temperature = Some(temperature);
236        }
237        if let Some(reasoning_effort) = &other.reasoning_effort {
238            self.reasoning_effort = Some(reasoning_effort.clone());
239        }
240        if let Some(reasoning_features) = other.reasoning_features {
241            self.reasoning_features = Some(reasoning_features);
242        }
243        if let Some(seed) = other.seed {
244            self.seed = Some(seed);
245        }
246        if let Some(presence_penalty) = other.presence_penalty {
247            self.presence_penalty = Some(presence_penalty);
248        }
249        if let Some(metadata) = &other.metadata {
250            self.metadata = Some(metadata.clone());
251        }
252        if let Some(user) = &other.user {
253            self.user = Some(user.clone());
254        }
255        self.extra_body.extend(other.extra_body.clone());
256        if let Some(dimensions) = other.dimensions {
257            self.dimensions = Some(dimensions);
258        }
259    }
260}
261
262impl From<OptionsBuilder> for Options {
263    fn from(value: OptionsBuilder) -> Self {
264        Self {
265            embed_model: value.embed_model.flatten(),
266            prompt_model: value.prompt_model.flatten(),
267            parallel_tool_calls: value.parallel_tool_calls.flatten(),
268            max_completion_tokens: value.max_completion_tokens.flatten(),
269            temperature: value.temperature.flatten(),
270            reasoning_effort: value.reasoning_effort.flatten(),
271            reasoning_features: value.reasoning_features.flatten(),
272            presence_penalty: value.presence_penalty.flatten(),
273            seed: value.seed.flatten(),
274            metadata: value.metadata.flatten(),
275            user: value.user.flatten(),
276            extra_body: value.extra_body.unwrap_or_default(),
277            dimensions: value.dimensions.flatten(),
278        }
279    }
280}
281
282impl From<&mut OptionsBuilder> for Options {
283    fn from(value: &mut OptionsBuilder) -> Self {
284        let value = value.clone();
285        Self {
286            embed_model: value.embed_model.flatten(),
287            prompt_model: value.prompt_model.flatten(),
288            parallel_tool_calls: value.parallel_tool_calls.flatten(),
289            max_completion_tokens: value.max_completion_tokens.flatten(),
290            temperature: value.temperature.flatten(),
291            reasoning_effort: value.reasoning_effort.flatten(),
292            reasoning_features: value.reasoning_features.flatten(),
293            presence_penalty: value.presence_penalty.flatten(),
294            seed: value.seed.flatten(),
295            metadata: value.metadata.flatten(),
296            user: value.user.flatten(),
297            extra_body: value.extra_body.unwrap_or_default(),
298            dimensions: value.dimensions.flatten(),
299        }
300    }
301}
302
303impl OptionsBuilder {
304    /// Adds additional top-level JSON fields for OpenAI-compatible providers.
305    pub fn extra_body(
306        &mut self,
307        extra_body: serde_json::Map<String, serde_json::Value>,
308    ) -> &mut Self {
309        self.extra_body = Some(extra_body);
310        self
311    }
312}
313
314pub(crate) fn request_body_with_extra_body<T: Serialize>(
315    request: &T,
316    extra_body: &serde_json::Map<String, serde_json::Value>,
317) -> Result<serde_json::Value, LanguageModelError> {
318    let mut body = serde_json::to_value(request).map_err(LanguageModelError::permanent)?;
319
320    if extra_body.is_empty() {
321        return Ok(body);
322    }
323
324    let object = body.as_object_mut().ok_or_else(|| {
325        LanguageModelError::permanent("OpenAI request must serialize to a JSON object")
326    })?;
327
328    object.extend(extra_body.clone());
329
330    Ok(body)
331}
332
333impl OpenAI {
334    /// Creates a new `OpenAIBuilder` for constructing `OpenAI` instances.
335    pub fn builder() -> OpenAIBuilder {
336        let mut builder = OpenAIBuilder::default();
337        builder.default_options(Options {
338            reasoning_features: Some(true),
339            ..Default::default()
340        });
341        builder
342    }
343}
344
345impl<C: async_openai::config::Config + Default + Sync + Send + std::fmt::Debug>
346    GenericOpenAIBuilder<C>
347{
348    /// Adds a callback function that will be called when usage information is available.
349    pub fn on_usage<F>(&mut self, func: F) -> &mut Self
350    where
351        F: Fn(&Usage) -> anyhow::Result<()> + Send + Sync + 'static,
352    {
353        let func = Arc::new(func);
354        self.on_usage = Some(Some(Arc::new(move |usage: &Usage| {
355            let func = func.clone();
356            Box::pin(async move { func(usage) })
357        })));
358
359        self
360    }
361
362    /// Adds an asynchronous callback function that will be called when usage information is
363    /// available.
364    pub fn on_usage_async<F>(&mut self, func: F) -> &mut Self
365    where
366        F: for<'a> Fn(
367                &'a Usage,
368            )
369                -> Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send + 'a>>
370            + Send
371            + Sync
372            + 'static,
373    {
374        let func = Arc::new(func);
375        self.on_usage = Some(Some(Arc::new(move |usage: &Usage| {
376            let func = func.clone();
377            Box::pin(async move { func(usage).await })
378        })));
379
380        self
381    }
382    /// Sets the `OpenAI` client for the `OpenAI` instance.
383    ///
384    /// # Parameters
385    /// - `client`: The `OpenAI` client to set.
386    ///
387    /// # Returns
388    /// A mutable reference to the `OpenAIBuilder`.
389    pub fn client(&mut self, client: async_openai::Client<C>) -> &mut Self {
390        self.client = Some(Arc::new(client));
391        self
392    }
393
394    /// Sets the default embedding model for the `OpenAI` instance.
395    ///
396    /// # Parameters
397    /// - `model`: The embedding model to set.
398    ///
399    /// # Returns
400    /// A mutable reference to the `OpenAIBuilder`.
401    pub fn default_embed_model(&mut self, model: impl Into<String>) -> &mut Self {
402        if let Some(options) = self.default_options.as_mut() {
403            options.embed_model = Some(model.into());
404        } else {
405            self.default_options = Some(Options {
406                embed_model: Some(model.into()),
407                ..Default::default()
408            });
409        }
410        self
411    }
412
413    /// Sets the `user` field used by `OpenAI` to monitor and detect usage and abuse.
414    pub fn for_end_user(&mut self, user: impl Into<String>) -> &mut Self {
415        if let Some(options) = self.default_options.as_mut() {
416            options.user = Some(user.into());
417        } else {
418            self.default_options = Some(Options {
419                user: Some(user.into()),
420                ..Default::default()
421            });
422        }
423        self
424    }
425
426    /// Enable or disable parallel tool calls for completions.
427    ///
428    /// Note that currently reasoning models do not support parallel tool calls
429    ///
430    /// Defaults to `true`
431    pub fn parallel_tool_calls(&mut self, parallel_tool_calls: Option<bool>) -> &mut Self {
432        if let Some(options) = self.default_options.as_mut() {
433            options.parallel_tool_calls = parallel_tool_calls;
434        } else {
435            self.default_options = Some(Options {
436                parallel_tool_calls,
437                ..Default::default()
438            });
439        }
440        self
441    }
442
443    /// Sets the default prompt model for the `OpenAI` instance.
444    ///
445    /// # Parameters
446    /// - `model`: The prompt model to set.
447    ///
448    /// # Returns
449    /// A mutable reference to the `OpenAIBuilder`.
450    pub fn default_prompt_model(&mut self, model: impl Into<String>) -> &mut Self {
451        if let Some(options) = self.default_options.as_mut() {
452            options.prompt_model = Some(model.into());
453        } else {
454            self.default_options = Some(Options {
455                prompt_model: Some(model.into()),
456                ..Default::default()
457            });
458        }
459        self
460    }
461
462    /// Sets the default options to use for requests to the `OpenAI` API.
463    ///
464    /// Merges with any existing options
465    pub fn default_options(&mut self, options: impl Into<Options>) -> &mut Self {
466        if let Some(existing_options) = self.default_options.as_mut() {
467            existing_options.merge(&options.into());
468        } else {
469            self.default_options = Some(options.into());
470        }
471        self
472    }
473}
474
475impl<C: async_openai::config::Config + Default> GenericOpenAI<C> {
476    /// Estimates the number of tokens for implementors of the `Estimatable` trait.
477    ///
478    /// I.e. `String`, `ChatMessage` etc
479    ///
480    /// # Errors
481    ///
482    /// Errors if tokinization fails in any way
483    #[cfg(feature = "tiktoken")]
484    pub async fn estimate_tokens(&self, value: impl Estimatable) -> Result<usize> {
485        self.tiktoken.estimate(value).await
486    }
487
488    pub fn with_default_prompt_model(&mut self, model: impl Into<String>) -> &mut Self {
489        self.default_options = Options {
490            prompt_model: Some(model.into()),
491            ..self.default_options.clone()
492        };
493        self
494    }
495
496    pub fn with_default_embed_model(&mut self, model: impl Into<String>) -> &mut Self {
497        self.default_options = Options {
498            embed_model: Some(model.into()),
499            ..self.default_options.clone()
500        };
501        self
502    }
503
504    /// Retrieve a reference to the inner `OpenAI` client.
505    pub fn client(&self) -> &Arc<async_openai::Client<C>> {
506        &self.client
507    }
508
509    /// Retrieve a reference to the default options for the `OpenAI` instance.
510    pub fn options(&self) -> &Options {
511        &self.default_options
512    }
513
514    /// Retrieve a mutable reference to the default options for the `OpenAI` instance.
515    pub fn options_mut(&mut self) -> &mut Options {
516        &mut self.default_options
517    }
518
519    /// Returns whether the Responses API is enabled for this client.
520    pub fn is_responses_api_enabled(&self) -> bool {
521        self.use_responses_api
522    }
523
524    fn chat_completion_request_defaults(&self) -> CreateChatCompletionRequestArgs {
525        let mut args = CreateChatCompletionRequestArgs::default();
526
527        let options = &self.default_options;
528
529        if let Some(parallel_tool_calls) = options.parallel_tool_calls {
530            args.parallel_tool_calls(parallel_tool_calls);
531        }
532
533        if let Some(max_tokens) = options.max_completion_tokens {
534            args.max_completion_tokens(max_tokens);
535        }
536
537        if let Some(temperature) = options.temperature {
538            args.temperature(temperature);
539        }
540
541        if let Some(reasoning_effort) = &options.reasoning_effort {
542            args.reasoning_effort(reasoning_effort.clone());
543        }
544
545        if let Some(seed) = options.seed {
546            args.seed(seed);
547        }
548
549        if let Some(presence_penalty) = options.presence_penalty {
550            args.presence_penalty(presence_penalty);
551        }
552
553        if let Some(metadata) = &options.metadata {
554            args.metadata(metadata.clone());
555        }
556
557        if let Some(user) = &options.user {
558            args.user(user.clone());
559        }
560
561        args
562    }
563
564    fn embed_request_defaults(&self) -> CreateEmbeddingRequestArgs {
565        let mut args = CreateEmbeddingRequestArgs::default();
566
567        let options = &self.default_options;
568
569        if let Some(user) = &options.user {
570            args.user(user.clone());
571        }
572
573        if let Some(dimensions) = options.dimensions {
574            args.dimensions(dimensions);
575        }
576
577        args
578    }
579}
580
581pub fn openai_error_to_language_model_error(e: OpenAIError) -> LanguageModelError {
582    provider_error::openai_error_to_language_model_error(e)
583}
584
585#[cfg(test)]
586mod test {
587    use super::*;
588    use async_openai::error::{ApiError, ApiErrorResponse, OpenAIError, StreamError};
589    use eventsource_stream::Event;
590    use reqwest::StatusCode;
591
592    /// test default embed model
593    #[test]
594    fn test_default_embed_and_prompt_model() {
595        let openai: OpenAI = OpenAI::builder()
596            .default_embed_model("gpt-3")
597            .default_prompt_model("gpt-4")
598            .build()
599            .unwrap();
600        assert_eq!(
601            openai.default_options.embed_model,
602            Some("gpt-3".to_string())
603        );
604        assert_eq!(
605            openai.default_options.prompt_model,
606            Some("gpt-4".to_string())
607        );
608
609        let openai: OpenAI = OpenAI::builder()
610            .default_prompt_model("gpt-4")
611            .default_embed_model("gpt-3")
612            .build()
613            .unwrap();
614        assert_eq!(
615            openai.default_options.prompt_model,
616            Some("gpt-4".to_string())
617        );
618        assert_eq!(
619            openai.default_options.embed_model,
620            Some("gpt-3".to_string())
621        );
622    }
623
624    #[test]
625    fn test_use_responses_api_flag() {
626        let openai: OpenAI = OpenAI::builder().use_responses_api(true).build().unwrap();
627
628        assert!(openai.is_responses_api_enabled());
629    }
630
631    #[test]
632    fn test_context_length_exceeded_error() {
633        // Create an API error with the context_length_exceeded code
634        let api_error = ApiError {
635            message: "This model's maximum context length is 8192 tokens".to_string(),
636            r#type: Some("invalid_request_error".to_string()),
637            param: Some("messages".to_string()),
638            code: Some("context_length_exceeded".to_string()),
639        };
640
641        let openai_error = OpenAIError::ApiError(ApiErrorResponse {
642            status_code: StatusCode::BAD_REQUEST,
643            api_error,
644        });
645        let result = openai_error_to_language_model_error(openai_error);
646
647        // Verify it's categorized as ContextLengthExceeded
648        match result {
649            LanguageModelError::ContextLengthExceeded(_) => {} // Expected
650            _ => panic!("Expected ContextLengthExceeded error, got {result:?}"),
651        }
652    }
653
654    #[test]
655    fn test_api_error_permanent() {
656        // Create a generic API error (not context length exceeded)
657        let api_error = ApiError {
658            message: "Invalid API key".to_string(),
659            r#type: Some("invalid_request_error".to_string()),
660            param: Some("api_key".to_string()),
661            code: Some("invalid_api_key".to_string()),
662        };
663
664        let openai_error = OpenAIError::ApiError(ApiErrorResponse {
665            status_code: StatusCode::UNAUTHORIZED,
666            api_error,
667        });
668        let result = openai_error_to_language_model_error(openai_error);
669
670        // Verify it's categorized as PermanentError
671        match result {
672            LanguageModelError::PermanentError(_) => {} // Expected
673            _ => panic!("Expected PermanentError, got {result:?}"),
674        }
675    }
676
677    #[test]
678    fn test_rate_limit_api_error_is_transient() {
679        let api_error = ApiError {
680            message: "Rate limit exceeded".to_string(),
681            r#type: Some("rate_limit_error".to_string()),
682            param: None,
683            code: None,
684        };
685
686        let openai_error = OpenAIError::ApiError(ApiErrorResponse {
687            status_code: StatusCode::TOO_MANY_REQUESTS,
688            api_error,
689        });
690        let result = openai_error_to_language_model_error(openai_error);
691
692        assert!(matches!(result, LanguageModelError::TransientError(_)));
693    }
694
695    #[test]
696    fn test_file_save_error_is_permanent() {
697        // Create a file save error
698        let openai_error = OpenAIError::FileSaveError("Failed to save file".to_string());
699        let result = openai_error_to_language_model_error(openai_error);
700
701        // Verify it's categorized as PermanentError
702        match result {
703            LanguageModelError::PermanentError(_) => {} // Expected
704            _ => panic!("Expected PermanentError, got {result:?}"),
705        }
706    }
707
708    #[test]
709    fn test_file_read_error_is_permanent() {
710        // Create a file read error
711        let openai_error = OpenAIError::FileReadError("Failed to read file".to_string());
712        let result = openai_error_to_language_model_error(openai_error);
713
714        // Verify it's categorized as PermanentError
715        match result {
716            LanguageModelError::PermanentError(_) => {} // Expected
717            _ => panic!("Expected PermanentError, got {result:?}"),
718        }
719    }
720
721    #[test]
722    fn test_stream_error_is_permanent() {
723        // Create a stream error
724        let openai_error =
725            OpenAIError::StreamError(Box::new(StreamError::UnknownEvent(Event::default())));
726        let result = openai_error_to_language_model_error(openai_error);
727
728        // Verify it's categorized as PermanentError
729        match result {
730            LanguageModelError::PermanentError(_) => {} // Expected
731            _ => panic!("Expected PermanentError, got {result:?}"),
732        }
733    }
734
735    #[test]
736    fn test_invalid_argument_is_permanent() {
737        // Create an invalid argument error
738        let openai_error = OpenAIError::InvalidArgument("Invalid argument".to_string());
739        let result = openai_error_to_language_model_error(openai_error);
740
741        // Verify it's categorized as PermanentError
742        match result {
743            LanguageModelError::PermanentError(_) => {} // Expected
744            _ => panic!("Expected PermanentError, got {result:?}"),
745        }
746    }
747
748    #[test]
749    fn test_options_merge_overrides_set_fields() {
750        let mut base = Options::builder()
751            .prompt_model("a")
752            .temperature(0.1)
753            .build()
754            .unwrap();
755
756        let overlay = Options::builder()
757            .prompt_model("b")
758            .presence_penalty(0.2)
759            .build()
760            .unwrap();
761
762        base.merge(&overlay);
763
764        assert_eq!(base.prompt_model.as_deref(), Some("b"));
765        assert_eq!(base.temperature, Some(0.1));
766        assert_eq!(base.presence_penalty, Some(0.2));
767    }
768
769    #[test]
770    #[allow(deprecated)]
771    fn test_chat_completion_request_defaults_sends_reasoning_effort() {
772        let openai: OpenAI = OpenAI::builder()
773            .default_options(
774                Options::builder()
775                    .parallel_tool_calls(true)
776                    .max_completion_tokens(42)
777                    .temperature(0.3)
778                    .reasoning_effort(ReasoningEffort::Low)
779                    .seed(7)
780                    .presence_penalty(1.1)
781                    .metadata(serde_json::json!({"tag": "demo"}))
782                    .user("user-1"),
783            )
784            .build()
785            .unwrap();
786
787        let built = openai
788            .chat_completion_request_defaults()
789            .messages(Vec::new())
790            .model("gpt-4o")
791            .build()
792            .unwrap();
793
794        assert_eq!(built.parallel_tool_calls, Some(true));
795        assert_eq!(built.max_completion_tokens, Some(42));
796        assert_eq!(built.temperature, Some(0.3));
797        assert_eq!(built.reasoning_effort, Some(ReasoningEffort::Low));
798        assert_eq!(built.seed, Some(7));
799        assert_eq!(built.presence_penalty, Some(1.1));
800        assert_eq!(
801            built.metadata,
802            Some(async_openai::types::Metadata::from(
803                serde_json::json!({"tag": "demo"})
804            ))
805        );
806        assert_eq!(built.user, Some("user-1".to_string()));
807    }
808
809    #[test]
810    #[allow(deprecated)]
811    fn test_embed_request_defaults_sets_user_and_dimensions() {
812        let openai: OpenAI = OpenAI::builder()
813            .default_options(Options::builder().user("end-user").dimensions(128))
814            .build()
815            .unwrap();
816
817        let built = openai
818            .embed_request_defaults()
819            .model("text-embedding-3-small")
820            .input("hello")
821            .build()
822            .unwrap();
823        assert_eq!(built.user, Some("end-user".to_string()));
824        assert_eq!(built.dimensions, Some(128));
825    }
826}