swiftide_integrations/openai/
mod.rs1use 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
24pub 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
38pub type OpenAI = GenericOpenAI<OpenAIConfig>;
73pub type OpenAIBuilder = GenericOpenAIBuilder<OpenAIConfig>;
74
75#[derive(Builder, Clone)]
76#[builder(setter(into, strip_option))]
77pub struct GenericOpenAI<
79 C: async_openai::config::Config + Default = async_openai::config::OpenAIConfig,
80> {
81 #[builder(
84 default = "Arc::new(async_openai::Client::<C>::default())",
85 setter(custom)
86 )]
87 client: Arc<async_openai::Client<C>>,
88
89 #[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 #[builder(default = true)]
101 pub stream_full: bool,
102
103 #[cfg(feature = "metrics")]
104 #[builder(default)]
105 metric_metadata: Option<std::collections::HashMap<String, String>>,
107
108 #[builder(default)]
110 pub(crate) use_responses_api: bool,
111
112 #[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#[derive(Debug, Clone, Builder, Default)]
143#[builder(setter(strip_option))]
144pub struct Options {
145 #[builder(default, setter(into))]
147 pub embed_model: Option<String>,
148 #[builder(default, setter(into))]
150 pub prompt_model: Option<String>,
151
152 #[builder(default)]
153 pub parallel_tool_calls: Option<bool>,
157
158 #[builder(default)]
162 pub max_completion_tokens: Option<u32>,
163
164 #[builder(default)]
166 pub temperature: Option<f32>,
167
168 #[builder(default, setter(into))]
170 pub reasoning_effort: Option<ReasoningEffort>,
171
172 #[builder(default, setter(into))]
181 pub reasoning_features: Option<bool>,
182
183 #[builder(default)]
188 pub seed: Option<i64>,
189
190 #[builder(default)]
193 pub presence_penalty: Option<f32>,
194
195 #[builder(default, setter(into))]
197 pub metadata: Option<serde_json::Value>,
198
199 #[builder(default, setter(into))]
202 pub user: Option<String>,
203
204 #[builder(default, setter(custom))]
206 extra_body: serde_json::Map<String, serde_json::Value>,
207
208 #[builder(default)]
209 pub dimensions: Option<u32>,
212}
213
214impl Options {
215 pub fn builder() -> OptionsBuilder {
217 OptionsBuilder::default()
218 }
219
220 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 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 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 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 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 pub fn client(&mut self, client: async_openai::Client<C>) -> &mut Self {
390 self.client = Some(Arc::new(client));
391 self
392 }
393
394 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 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 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 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 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 #[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 pub fn client(&self) -> &Arc<async_openai::Client<C>> {
506 &self.client
507 }
508
509 pub fn options(&self) -> &Options {
511 &self.default_options
512 }
513
514 pub fn options_mut(&mut self) -> &mut Options {
516 &mut self.default_options
517 }
518
519 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]
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 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 match result {
649 LanguageModelError::ContextLengthExceeded(_) => {} _ => panic!("Expected ContextLengthExceeded error, got {result:?}"),
651 }
652 }
653
654 #[test]
655 fn test_api_error_permanent() {
656 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 match result {
672 LanguageModelError::PermanentError(_) => {} _ => 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 let openai_error = OpenAIError::FileSaveError("Failed to save file".to_string());
699 let result = openai_error_to_language_model_error(openai_error);
700
701 match result {
703 LanguageModelError::PermanentError(_) => {} _ => panic!("Expected PermanentError, got {result:?}"),
705 }
706 }
707
708 #[test]
709 fn test_file_read_error_is_permanent() {
710 let openai_error = OpenAIError::FileReadError("Failed to read file".to_string());
712 let result = openai_error_to_language_model_error(openai_error);
713
714 match result {
716 LanguageModelError::PermanentError(_) => {} _ => panic!("Expected PermanentError, got {result:?}"),
718 }
719 }
720
721 #[test]
722 fn test_stream_error_is_permanent() {
723 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 match result {
730 LanguageModelError::PermanentError(_) => {} _ => panic!("Expected PermanentError, got {result:?}"),
732 }
733 }
734
735 #[test]
736 fn test_invalid_argument_is_permanent() {
737 let openai_error = OpenAIError::InvalidArgument("Invalid argument".to_string());
739 let result = openai_error_to_language_model_error(openai_error);
740
741 match result {
743 LanguageModelError::PermanentError(_) => {} _ => 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}