use async_openai::error::OpenAIError;
use async_openai::types::chat::CreateChatCompletionRequestArgs;
use async_openai::types::embeddings::CreateEmbeddingRequestArgs;
use derive_builder::Builder;
use serde::Serialize;
use std::pin::Pin;
use std::sync::Arc;
use swiftide_core::chat_completion::Usage;
use swiftide_core::chat_completion::errors::LanguageModelError;
mod chat_completion;
mod embed;
mod provider_error;
mod responses_api;
mod simple_prompt;
mod structured_prompt;
mod tool_schema;
pub use async_openai::config::AzureConfig;
pub use async_openai::config::OpenAIConfig;
pub use async_openai::types::responses::ReasoningEffort;
#[cfg(feature = "tiktoken")]
use crate::tiktoken::TikToken;
#[cfg(feature = "tiktoken")]
use anyhow::Result;
#[cfg(feature = "tiktoken")]
use swiftide_core::Estimatable;
#[cfg(feature = "tiktoken")]
use swiftide_core::EstimateTokens;
pub type OpenAI = GenericOpenAI<OpenAIConfig>;
pub type OpenAIBuilder = GenericOpenAIBuilder<OpenAIConfig>;
#[derive(Builder, Clone)]
#[builder(setter(into, strip_option))]
pub struct GenericOpenAI<
C: async_openai::config::Config + Default = async_openai::config::OpenAIConfig,
> {
#[builder(
default = "Arc::new(async_openai::Client::<C>::default())",
setter(custom)
)]
client: Arc<async_openai::Client<C>>,
#[builder(default, setter(custom))]
pub(crate) default_options: Options,
#[cfg(feature = "tiktoken")]
#[cfg_attr(feature = "tiktoken", builder(default))]
pub(crate) tiktoken: TikToken,
#[builder(default = true)]
pub stream_full: bool,
#[cfg(feature = "metrics")]
#[builder(default)]
metric_metadata: Option<std::collections::HashMap<String, String>>,
#[builder(default)]
pub(crate) use_responses_api: bool,
#[builder(default, setter(custom))]
#[allow(clippy::type_complexity)]
on_usage: Option<
Arc<
dyn for<'a> Fn(
&'a Usage,
) -> Pin<
Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send + 'a>,
> + Send
+ Sync,
>,
>,
}
impl<C: async_openai::config::Config + Default + std::fmt::Debug> std::fmt::Debug
for GenericOpenAI<C>
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GenericOpenAI")
.field("client", &self.client)
.field("default_options", &self.default_options)
.field("stream_full", &self.stream_full)
.field("use_responses_api", &self.use_responses_api)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Builder, Default)]
#[builder(setter(strip_option))]
pub struct Options {
#[builder(default, setter(into))]
pub embed_model: Option<String>,
#[builder(default, setter(into))]
pub prompt_model: Option<String>,
#[builder(default)]
pub parallel_tool_calls: Option<bool>,
#[builder(default)]
pub max_completion_tokens: Option<u32>,
#[builder(default)]
pub temperature: Option<f32>,
#[builder(default, setter(into))]
pub reasoning_effort: Option<ReasoningEffort>,
#[builder(default, setter(into))]
pub reasoning_features: Option<bool>,
#[builder(default)]
pub seed: Option<i64>,
#[builder(default)]
pub presence_penalty: Option<f32>,
#[builder(default, setter(into))]
pub metadata: Option<serde_json::Value>,
#[builder(default, setter(into))]
pub user: Option<String>,
#[builder(default, setter(custom))]
extra_body: serde_json::Map<String, serde_json::Value>,
#[builder(default)]
pub dimensions: Option<u32>,
}
impl Options {
pub fn builder() -> OptionsBuilder {
OptionsBuilder::default()
}
pub fn merge(&mut self, other: &Options) {
if let Some(embed_model) = &other.embed_model {
self.embed_model = Some(embed_model.clone());
}
if let Some(prompt_model) = &other.prompt_model {
self.prompt_model = Some(prompt_model.clone());
}
if let Some(parallel_tool_calls) = other.parallel_tool_calls {
self.parallel_tool_calls = Some(parallel_tool_calls);
}
if let Some(max_completion_tokens) = other.max_completion_tokens {
self.max_completion_tokens = Some(max_completion_tokens);
}
if let Some(temperature) = other.temperature {
self.temperature = Some(temperature);
}
if let Some(reasoning_effort) = &other.reasoning_effort {
self.reasoning_effort = Some(reasoning_effort.clone());
}
if let Some(reasoning_features) = other.reasoning_features {
self.reasoning_features = Some(reasoning_features);
}
if let Some(seed) = other.seed {
self.seed = Some(seed);
}
if let Some(presence_penalty) = other.presence_penalty {
self.presence_penalty = Some(presence_penalty);
}
if let Some(metadata) = &other.metadata {
self.metadata = Some(metadata.clone());
}
if let Some(user) = &other.user {
self.user = Some(user.clone());
}
self.extra_body.extend(other.extra_body.clone());
if let Some(dimensions) = other.dimensions {
self.dimensions = Some(dimensions);
}
}
}
impl From<OptionsBuilder> for Options {
fn from(value: OptionsBuilder) -> Self {
Self {
embed_model: value.embed_model.flatten(),
prompt_model: value.prompt_model.flatten(),
parallel_tool_calls: value.parallel_tool_calls.flatten(),
max_completion_tokens: value.max_completion_tokens.flatten(),
temperature: value.temperature.flatten(),
reasoning_effort: value.reasoning_effort.flatten(),
reasoning_features: value.reasoning_features.flatten(),
presence_penalty: value.presence_penalty.flatten(),
seed: value.seed.flatten(),
metadata: value.metadata.flatten(),
user: value.user.flatten(),
extra_body: value.extra_body.unwrap_or_default(),
dimensions: value.dimensions.flatten(),
}
}
}
impl From<&mut OptionsBuilder> for Options {
fn from(value: &mut OptionsBuilder) -> Self {
let value = value.clone();
Self {
embed_model: value.embed_model.flatten(),
prompt_model: value.prompt_model.flatten(),
parallel_tool_calls: value.parallel_tool_calls.flatten(),
max_completion_tokens: value.max_completion_tokens.flatten(),
temperature: value.temperature.flatten(),
reasoning_effort: value.reasoning_effort.flatten(),
reasoning_features: value.reasoning_features.flatten(),
presence_penalty: value.presence_penalty.flatten(),
seed: value.seed.flatten(),
metadata: value.metadata.flatten(),
user: value.user.flatten(),
extra_body: value.extra_body.unwrap_or_default(),
dimensions: value.dimensions.flatten(),
}
}
}
impl OptionsBuilder {
pub fn extra_body(
&mut self,
extra_body: serde_json::Map<String, serde_json::Value>,
) -> &mut Self {
self.extra_body = Some(extra_body);
self
}
}
pub(crate) fn request_body_with_extra_body<T: Serialize>(
request: &T,
extra_body: &serde_json::Map<String, serde_json::Value>,
) -> Result<serde_json::Value, LanguageModelError> {
let mut body = serde_json::to_value(request).map_err(LanguageModelError::permanent)?;
if extra_body.is_empty() {
return Ok(body);
}
let object = body.as_object_mut().ok_or_else(|| {
LanguageModelError::permanent("OpenAI request must serialize to a JSON object")
})?;
object.extend(extra_body.clone());
Ok(body)
}
impl OpenAI {
pub fn builder() -> OpenAIBuilder {
let mut builder = OpenAIBuilder::default();
builder.default_options(Options {
reasoning_features: Some(true),
..Default::default()
});
builder
}
}
impl<C: async_openai::config::Config + Default + Sync + Send + std::fmt::Debug>
GenericOpenAIBuilder<C>
{
pub fn on_usage<F>(&mut self, func: F) -> &mut Self
where
F: Fn(&Usage) -> anyhow::Result<()> + Send + Sync + 'static,
{
let func = Arc::new(func);
self.on_usage = Some(Some(Arc::new(move |usage: &Usage| {
let func = func.clone();
Box::pin(async move { func(usage) })
})));
self
}
pub fn on_usage_async<F>(&mut self, func: F) -> &mut Self
where
F: for<'a> Fn(
&'a Usage,
)
-> Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send + 'a>>
+ Send
+ Sync
+ 'static,
{
let func = Arc::new(func);
self.on_usage = Some(Some(Arc::new(move |usage: &Usage| {
let func = func.clone();
Box::pin(async move { func(usage).await })
})));
self
}
pub fn client(&mut self, client: async_openai::Client<C>) -> &mut Self {
self.client = Some(Arc::new(client));
self
}
pub fn default_embed_model(&mut self, model: impl Into<String>) -> &mut Self {
if let Some(options) = self.default_options.as_mut() {
options.embed_model = Some(model.into());
} else {
self.default_options = Some(Options {
embed_model: Some(model.into()),
..Default::default()
});
}
self
}
pub fn for_end_user(&mut self, user: impl Into<String>) -> &mut Self {
if let Some(options) = self.default_options.as_mut() {
options.user = Some(user.into());
} else {
self.default_options = Some(Options {
user: Some(user.into()),
..Default::default()
});
}
self
}
pub fn parallel_tool_calls(&mut self, parallel_tool_calls: Option<bool>) -> &mut Self {
if let Some(options) = self.default_options.as_mut() {
options.parallel_tool_calls = parallel_tool_calls;
} else {
self.default_options = Some(Options {
parallel_tool_calls,
..Default::default()
});
}
self
}
pub fn default_prompt_model(&mut self, model: impl Into<String>) -> &mut Self {
if let Some(options) = self.default_options.as_mut() {
options.prompt_model = Some(model.into());
} else {
self.default_options = Some(Options {
prompt_model: Some(model.into()),
..Default::default()
});
}
self
}
pub fn default_options(&mut self, options: impl Into<Options>) -> &mut Self {
if let Some(existing_options) = self.default_options.as_mut() {
existing_options.merge(&options.into());
} else {
self.default_options = Some(options.into());
}
self
}
}
impl<C: async_openai::config::Config + Default> GenericOpenAI<C> {
#[cfg(feature = "tiktoken")]
pub async fn estimate_tokens(&self, value: impl Estimatable) -> Result<usize> {
self.tiktoken.estimate(value).await
}
pub fn with_default_prompt_model(&mut self, model: impl Into<String>) -> &mut Self {
self.default_options = Options {
prompt_model: Some(model.into()),
..self.default_options.clone()
};
self
}
pub fn with_default_embed_model(&mut self, model: impl Into<String>) -> &mut Self {
self.default_options = Options {
embed_model: Some(model.into()),
..self.default_options.clone()
};
self
}
pub fn client(&self) -> &Arc<async_openai::Client<C>> {
&self.client
}
pub fn options(&self) -> &Options {
&self.default_options
}
pub fn options_mut(&mut self) -> &mut Options {
&mut self.default_options
}
pub fn is_responses_api_enabled(&self) -> bool {
self.use_responses_api
}
fn chat_completion_request_defaults(&self) -> CreateChatCompletionRequestArgs {
let mut args = CreateChatCompletionRequestArgs::default();
let options = &self.default_options;
if let Some(parallel_tool_calls) = options.parallel_tool_calls {
args.parallel_tool_calls(parallel_tool_calls);
}
if let Some(max_tokens) = options.max_completion_tokens {
args.max_completion_tokens(max_tokens);
}
if let Some(temperature) = options.temperature {
args.temperature(temperature);
}
if let Some(reasoning_effort) = &options.reasoning_effort {
args.reasoning_effort(reasoning_effort.clone());
}
if let Some(seed) = options.seed {
args.seed(seed);
}
if let Some(presence_penalty) = options.presence_penalty {
args.presence_penalty(presence_penalty);
}
if let Some(metadata) = &options.metadata {
args.metadata(metadata.clone());
}
if let Some(user) = &options.user {
args.user(user.clone());
}
args
}
fn embed_request_defaults(&self) -> CreateEmbeddingRequestArgs {
let mut args = CreateEmbeddingRequestArgs::default();
let options = &self.default_options;
if let Some(user) = &options.user {
args.user(user.clone());
}
if let Some(dimensions) = options.dimensions {
args.dimensions(dimensions);
}
args
}
}
pub fn openai_error_to_language_model_error(e: OpenAIError) -> LanguageModelError {
provider_error::openai_error_to_language_model_error(e)
}
#[cfg(test)]
mod test {
use super::*;
use async_openai::error::{ApiError, ApiErrorResponse, OpenAIError, StreamError};
use eventsource_stream::Event;
use reqwest::StatusCode;
#[test]
fn test_default_embed_and_prompt_model() {
let openai: OpenAI = OpenAI::builder()
.default_embed_model("gpt-3")
.default_prompt_model("gpt-4")
.build()
.unwrap();
assert_eq!(
openai.default_options.embed_model,
Some("gpt-3".to_string())
);
assert_eq!(
openai.default_options.prompt_model,
Some("gpt-4".to_string())
);
let openai: OpenAI = OpenAI::builder()
.default_prompt_model("gpt-4")
.default_embed_model("gpt-3")
.build()
.unwrap();
assert_eq!(
openai.default_options.prompt_model,
Some("gpt-4".to_string())
);
assert_eq!(
openai.default_options.embed_model,
Some("gpt-3".to_string())
);
}
#[test]
fn test_use_responses_api_flag() {
let openai: OpenAI = OpenAI::builder().use_responses_api(true).build().unwrap();
assert!(openai.is_responses_api_enabled());
}
#[test]
fn test_context_length_exceeded_error() {
let api_error = ApiError {
message: "This model's maximum context length is 8192 tokens".to_string(),
r#type: Some("invalid_request_error".to_string()),
param: Some("messages".to_string()),
code: Some("context_length_exceeded".to_string()),
};
let openai_error = OpenAIError::ApiError(ApiErrorResponse {
status_code: StatusCode::BAD_REQUEST,
api_error,
});
let result = openai_error_to_language_model_error(openai_error);
match result {
LanguageModelError::ContextLengthExceeded(_) => {} _ => panic!("Expected ContextLengthExceeded error, got {result:?}"),
}
}
#[test]
fn test_api_error_permanent() {
let api_error = ApiError {
message: "Invalid API key".to_string(),
r#type: Some("invalid_request_error".to_string()),
param: Some("api_key".to_string()),
code: Some("invalid_api_key".to_string()),
};
let openai_error = OpenAIError::ApiError(ApiErrorResponse {
status_code: StatusCode::UNAUTHORIZED,
api_error,
});
let result = openai_error_to_language_model_error(openai_error);
match result {
LanguageModelError::PermanentError(_) => {} _ => panic!("Expected PermanentError, got {result:?}"),
}
}
#[test]
fn test_rate_limit_api_error_is_transient() {
let api_error = ApiError {
message: "Rate limit exceeded".to_string(),
r#type: Some("rate_limit_error".to_string()),
param: None,
code: None,
};
let openai_error = OpenAIError::ApiError(ApiErrorResponse {
status_code: StatusCode::TOO_MANY_REQUESTS,
api_error,
});
let result = openai_error_to_language_model_error(openai_error);
assert!(matches!(result, LanguageModelError::TransientError(_)));
}
#[test]
fn test_file_save_error_is_permanent() {
let openai_error = OpenAIError::FileSaveError("Failed to save file".to_string());
let result = openai_error_to_language_model_error(openai_error);
match result {
LanguageModelError::PermanentError(_) => {} _ => panic!("Expected PermanentError, got {result:?}"),
}
}
#[test]
fn test_file_read_error_is_permanent() {
let openai_error = OpenAIError::FileReadError("Failed to read file".to_string());
let result = openai_error_to_language_model_error(openai_error);
match result {
LanguageModelError::PermanentError(_) => {} _ => panic!("Expected PermanentError, got {result:?}"),
}
}
#[test]
fn test_stream_error_is_permanent() {
let openai_error =
OpenAIError::StreamError(Box::new(StreamError::UnknownEvent(Event::default())));
let result = openai_error_to_language_model_error(openai_error);
match result {
LanguageModelError::PermanentError(_) => {} _ => panic!("Expected PermanentError, got {result:?}"),
}
}
#[test]
fn test_invalid_argument_is_permanent() {
let openai_error = OpenAIError::InvalidArgument("Invalid argument".to_string());
let result = openai_error_to_language_model_error(openai_error);
match result {
LanguageModelError::PermanentError(_) => {} _ => panic!("Expected PermanentError, got {result:?}"),
}
}
#[test]
fn test_options_merge_overrides_set_fields() {
let mut base = Options::builder()
.prompt_model("a")
.temperature(0.1)
.build()
.unwrap();
let overlay = Options::builder()
.prompt_model("b")
.presence_penalty(0.2)
.build()
.unwrap();
base.merge(&overlay);
assert_eq!(base.prompt_model.as_deref(), Some("b"));
assert_eq!(base.temperature, Some(0.1));
assert_eq!(base.presence_penalty, Some(0.2));
}
#[test]
#[allow(deprecated)]
fn test_chat_completion_request_defaults_sends_reasoning_effort() {
let openai: OpenAI = OpenAI::builder()
.default_options(
Options::builder()
.parallel_tool_calls(true)
.max_completion_tokens(42)
.temperature(0.3)
.reasoning_effort(ReasoningEffort::Low)
.seed(7)
.presence_penalty(1.1)
.metadata(serde_json::json!({"tag": "demo"}))
.user("user-1"),
)
.build()
.unwrap();
let built = openai
.chat_completion_request_defaults()
.messages(Vec::new())
.model("gpt-4o")
.build()
.unwrap();
assert_eq!(built.parallel_tool_calls, Some(true));
assert_eq!(built.max_completion_tokens, Some(42));
assert_eq!(built.temperature, Some(0.3));
assert_eq!(built.reasoning_effort, Some(ReasoningEffort::Low));
assert_eq!(built.seed, Some(7));
assert_eq!(built.presence_penalty, Some(1.1));
assert_eq!(
built.metadata,
Some(async_openai::types::Metadata::from(
serde_json::json!({"tag": "demo"})
))
);
assert_eq!(built.user, Some("user-1".to_string()));
}
#[test]
#[allow(deprecated)]
fn test_embed_request_defaults_sets_user_and_dimensions() {
let openai: OpenAI = OpenAI::builder()
.default_options(Options::builder().user("end-user").dimensions(128))
.build()
.unwrap();
let built = openai
.embed_request_defaults()
.model("text-embedding-3-small")
.input("hello")
.build()
.unwrap();
assert_eq!(built.user, Some("end-user".to_string()));
assert_eq!(built.dimensions, Some(128));
}
}