Files
baibot-withmcp/src/agent/provider/openai/controller.rs

657 lines
23 KiB
Rust
Raw Normal View History

2024-09-12 13:44:06 +03:00
use std::ops::Deref;
use async_openai::{
2025-02-27 07:46:16 +02:00
Client as OpenAIClient,
2024-09-12 13:44:06 +03:00
config::OpenAIConfig,
types::{
audio::{AudioInput, CreateSpeechRequestArgs, CreateTranscriptionRequestArgs},
images::{
CreateImageEditRequestArgs, CreateImageRequestArgs, Image, ImageInput, ImageModel,
ImageResponseFormat,
},
responses::{
CodeInterpreterContainerAuto, CodeInterpreterTool, CodeInterpreterToolContainer,
CreateResponseArgs, OutputItem, OutputMessageContent, Tool, WebSearchTool,
},
2024-09-12 13:44:06 +03:00
},
};
use super::super::ControllerTrait;
use crate::{
agent::provider::{
2025-05-10 11:47:50 +03:00
ImageEditParams, ImageGenerationParams, SpeechToTextParams, SpeechToTextResult,
entity::{TextGenerationParams, TextGenerationResult},
2024-09-12 13:44:06 +03:00
},
conversation::llm::{
2025-02-27 07:46:16 +02:00
Author as LLMAuthor, Conversation as LLMConversation, Message as LLMMessage,
MessageContent as LLMMessageContent, shorten_messages_list_to_context_size,
2024-09-12 13:44:06 +03:00
},
utils::base64::base64_decode,
2024-09-12 13:44:06 +03:00
};
2025-05-10 11:47:50 +03:00
use crate::{
agent::{
AgentPurpose,
provider::entity::{
ImageEditResult, ImageGenerationResult, ImageSource, PingResult, TextToSpeechParams,
TextToSpeechResult,
2025-05-10 11:47:50 +03:00
},
},
strings,
};
2024-09-12 13:44:06 +03:00
use super::config::Config;
#[derive(Debug, Clone)]
pub struct Controller {
config: Config,
client: OpenAIClient<OpenAIConfig>,
}
impl Controller {
pub fn new(config: Config) -> Self {
let openai_config = OpenAIConfig::new()
.with_api_base(config.base_url.clone())
.with_api_key(config.api_key.clone());
let client = OpenAIClient::with_config(openai_config);
Self { config, client }
}
}
impl ControllerTrait for Controller {
async fn ping(&self) -> anyhow::Result<PingResult> {
if !self.supports_purpose(AgentPurpose::TextGeneration) {
return Ok(PingResult::Inconclusive);
}
let messages = vec![LLMMessage {
author: LLMAuthor::User,
sender_id: None,
content: LLMMessageContent::Text("Hello!".to_string()),
timestamp: chrono::Utc::now(),
2024-09-12 13:44:06 +03:00
}];
let conversation = LLMConversation { messages };
self.generate_text(conversation, TextGenerationParams::default())
.await?;
Ok(PingResult::Successful)
}
async fn generate_text(
&self,
conversation: LLMConversation,
params: TextGenerationParams,
) -> anyhow::Result<TextGenerationResult> {
let Some(text_generation_config) = &self.config.text_generation else {
return Err(anyhow::anyhow!(
strings::agent::no_configuration_for_purpose_so_cannot_be_used(
&AgentPurpose::TextGeneration
),
));
};
let prompt_text = params.prompt_variables.format(
params
.prompt_override
.unwrap_or(self.text_generation_prompt().unwrap_or("".to_owned()))
.trim(),
);
2024-09-12 13:44:06 +03:00
let prompt_message = if prompt_text.is_empty() {
None
} else {
Some(LLMMessage {
author: LLMAuthor::Prompt,
sender_id: None,
content: LLMMessageContent::Text(prompt_text),
timestamp: chrono::Utc::now(),
2024-09-12 13:44:06 +03:00
})
};
let mut conversation_messages = conversation.messages;
if params.context_management_enabled {
tracing::trace!("Shortening messages list to context size");
conversation_messages = shorten_messages_list_to_context_size(
&text_generation_config.model_id,
&prompt_message,
conversation_messages,
text_generation_config.max_response_tokens,
text_generation_config.max_context_tokens,
);
tracing::trace!("Finished shortening messages list to context size");
};
if let Some(prompt_message) = prompt_message {
conversation_messages.insert(0, prompt_message);
}
let input =
super::utils::convert_llm_messages_to_openai_response_input(conversation_messages);
2024-09-12 13:44:06 +03:00
let messages_count = match &input {
async_openai::types::responses::InputParam::Items(items) => items.len(),
_ => 1,
};
2024-09-12 13:44:06 +03:00
let temperature = params
.temperature_override
.unwrap_or(text_generation_config.temperature);
let mut request_builder = CreateResponseArgs::default();
request_builder
2024-09-12 13:44:06 +03:00
.model(&text_generation_config.model_id)
.temperature(temperature)
.input(input);
let mut tools = Vec::new();
if text_generation_config.tools.web_search {
tools.push(Tool::WebSearch(WebSearchTool::default()));
}
if text_generation_config.tools.code_interpreter {
tools.push(Tool::CodeInterpreter(CodeInterpreterTool {
container: CodeInterpreterToolContainer::Auto(
CodeInterpreterContainerAuto::default(),
),
}));
}
if !tools.is_empty() {
request_builder.tools(tools);
}
if let Some(max_response_tokens) = text_generation_config.max_response_tokens {
request_builder.max_output_tokens(max_response_tokens);
} else if let Some(max_completion_tokens) = text_generation_config.max_completion_tokens {
request_builder.max_output_tokens(max_completion_tokens);
}
let request = request_builder.build()?;
2024-09-12 13:44:06 +03:00
if let Ok(request_as_json) = serde_json::to_string(&request) {
tracing::trace!(
model = format!("{:?}", request.model),
?messages_count,
request = request_as_json,
"Sending OpenAI response API request"
2024-09-12 13:44:06 +03:00
);
}
let response = self.client.responses().create(request).await?;
2024-09-12 13:44:06 +03:00
tracing::trace!(?response, "Got response from the OpenAI response API");
2024-09-12 13:44:06 +03:00
for item in response.output {
if let OutputItem::Message(message) = item {
for content in message.content {
if let OutputMessageContent::OutputText(text_content) = content {
return Ok(TextGenerationResult {
text: text_content.text,
});
}
2024-09-12 13:44:06 +03:00
}
}
}
Err(anyhow::anyhow!(
"No response messages choices were returned from the OpenAI response API"
2024-09-12 13:44:06 +03:00
))
}
async fn speech_to_text(
&self,
mime_type: &mxlink::mime::Mime,
media: Vec<u8>,
params: SpeechToTextParams,
) -> anyhow::Result<SpeechToTextResult> {
let Some(speech_to_text_config) = &self.config.speech_to_text else {
return Err(anyhow::anyhow!(
strings::agent::no_configuration_for_purpose_so_cannot_be_used(
&AgentPurpose::SpeechToText
),
));
};
let filename = audio_mime_type_to_file_name(mime_type).unwrap_or("audio.ogg".to_string());
let language = params.language_override.unwrap_or("".to_string());
let request = CreateTranscriptionRequestArgs::default()
.model(&speech_to_text_config.model_id)
.file(AudioInput::from_vec_u8(filename, media))
2024-09-12 13:44:06 +03:00
.language(language.clone())
.build()?;
tracing::trace!(
model_id = speech_to_text_config.model_id,
?language,
"Sending OpenAI speech-to-text API request"
);
let response = self.client.audio().transcription().create(request).await?;
2024-09-12 13:44:06 +03:00
tracing::trace!(
?response,
"Got response from the OpenAI audio transcription API"
);
Ok(SpeechToTextResult {
text: response.text,
})
}
async fn generate_image(
&self,
prompt: &str,
params: ImageGenerationParams,
) -> anyhow::Result<ImageGenerationResult> {
let Some(image_generation_config) = &self.config.image_generation else {
return Err(anyhow::anyhow!(
strings::agent::no_configuration_for_purpose_so_cannot_be_used(
&AgentPurpose::ImageGeneration
),
));
};
let original_model = image_generation_config
.model_id_as_openai_image_model()
.map_err(|err| anyhow::anyhow!(err))?;
let model = if params.cheaper_model_switching_allowed {
// Switch to a cheaper model
match original_model {
ImageModel::DallE2 => ImageModel::DallE2,
ImageModel::DallE3 => ImageModel::DallE2,
ImageModel::GptImage1 => ImageModel::GptImage1Mini,
ImageModel::GptImage1dot5 => ImageModel::GptImage1Mini,
ImageModel::GptImage1Mini => ImageModel::GptImage1Mini,
ImageModel::GptImage2 => ImageModel::GptImage1Mini,
ImageModel::Other(_) => ImageModel::DallE2,
2024-09-12 13:44:06 +03:00
}
} else {
original_model
};
let quality = if params.cheaper_quality_switching_allowed {
// Switch to a cheaper quality
match &image_generation_config.quality {
Some(quality) => match quality {
async_openai::types::images::ImageQuality::Standard => {
Some(async_openai::types::images::ImageQuality::Standard)
}
async_openai::types::images::ImageQuality::HD => {
Some(async_openai::types::images::ImageQuality::Standard)
}
// New quality levels - keep as-is or downgrade to Standard
async_openai::types::images::ImageQuality::High => {
Some(async_openai::types::images::ImageQuality::Standard)
}
async_openai::types::images::ImageQuality::Medium => {
Some(async_openai::types::images::ImageQuality::Medium)
}
async_openai::types::images::ImageQuality::Low => {
Some(async_openai::types::images::ImageQuality::Low)
}
async_openai::types::images::ImageQuality::Auto => {
Some(async_openai::types::images::ImageQuality::Auto)
}
2025-05-10 11:47:50 +03:00
},
None => None,
2024-09-12 13:44:06 +03:00
}
} else {
image_generation_config.quality.clone()
};
let size = if params.smallest_size_possible {
Some(get_sticker_size(&model))
} else {
image_generation_config.size.clone()
};
2024-09-12 13:44:06 +03:00
let response_format = match model.clone() {
ImageModel::DallE2 => Some(ImageResponseFormat::B64Json),
ImageModel::DallE3 => Some(ImageResponseFormat::B64Json),
// gpt-image-1 only outputs base64 and we don't need to specify the response format.
// In fact, specifying the response format results in an error.
ImageModel::GptImage1 => None,
ImageModel::GptImage1Mini => None,
ImageModel::GptImage1dot5 => None,
ImageModel::GptImage2 => None,
ImageModel::Other(_) => Some(ImageResponseFormat::B64Json),
};
let mut request_builder = CreateImageRequestArgs::default();
request_builder.model(model).prompt(prompt.to_owned());
if let Some(response_format) = response_format {
request_builder.response_format(response_format);
}
if let Some(style) = &image_generation_config.style {
request_builder.style(style.clone());
}
if let Some(quality) = quality {
request_builder.quality(quality.clone());
}
if let Some(size) = size {
request_builder.size(size);
}
let request = request_builder.build()?;
2024-09-12 13:44:06 +03:00
tracing::trace!(
?prompt,
model = format!("{:?}", request.model),
size = format!("{:?}", request.size),
style = format!("{:?}", request.style),
quality = format!("{:?}", request.quality),
"Sending OpenAI image generation API request"
);
let response = self.client.images().generate(request).await?;
2024-09-12 13:44:06 +03:00
if let Some(image) = response.data.into_iter().next() {
match image.deref() {
Image::B64Json {
2024-09-12 13:44:06 +03:00
b64_json,
revised_prompt,
} => {
let bytes = base64_decode(b64_json.as_ref())?;
2024-09-12 13:44:06 +03:00
return Ok(ImageGenerationResult {
bytes,
mime_type: mxlink::mime::IMAGE_PNG,
revised_prompt: revised_prompt.clone(),
});
}
_ => {
return Err(anyhow::anyhow!("Unexpected image type"));
}
}
}
Err(anyhow::anyhow!(
"The OpenAI image generation API returned no images"
))
}
async fn create_image_edit(
&self,
prompt: &str,
images: Vec<ImageSource>,
_params: ImageEditParams,
) -> anyhow::Result<ImageEditResult> {
let Some(image_generation_config) = &self.config.image_generation else {
return Err(anyhow::anyhow!(
strings::agent::no_configuration_for_purpose_so_cannot_be_used(
&AgentPurpose::ImageGeneration
),
));
};
if images.is_empty() {
return Err(anyhow::anyhow!("No image sources provided"));
}
let mut image_inputs: Vec<ImageInput> = Vec::new();
for image in images {
image_inputs.push(image.into());
}
let dalle2_size = match image_generation_config.size {
Some(async_openai::types::images::ImageSize::S256x256) => {
Some(async_openai::types::images::ImageSize::S256x256)
}
Some(async_openai::types::images::ImageSize::S512x512) => {
Some(async_openai::types::images::ImageSize::S512x512)
}
Some(async_openai::types::images::ImageSize::S1024x1024) => {
Some(async_openai::types::images::ImageSize::S1024x1024)
}
_ => None,
};
let model = image_generation_config
.model_id_as_openai_image_model()
.map_err(|err| anyhow::anyhow!(err))?;
let response_format = match model.clone() {
ImageModel::DallE2 => Some(ImageResponseFormat::B64Json),
ImageModel::DallE3 => Some(ImageResponseFormat::B64Json),
// gpt-image-1 only outputs base64 and we don't need to specify the response format.
// In fact, specifying the response format results in an error.
ImageModel::GptImage1 => None,
ImageModel::GptImage1Mini => None,
ImageModel::GptImage1dot5 => None,
ImageModel::GptImage2 => None,
ImageModel::Other(_) => Some(ImageResponseFormat::B64Json),
};
let mut request_builder = CreateImageEditRequestArgs::default();
request_builder
.image(image_inputs)
.prompt(prompt.to_owned())
.model(model);
if let Some(size) = dalle2_size {
request_builder.size(size);
}
if let Some(response_format) = response_format {
request_builder.response_format(response_format);
}
2025-05-10 11:47:50 +03:00
let request = request_builder
.build()
.map_err(|e| anyhow::anyhow!("Failed to build CreateImageEditRequest: {}", e))?;
tracing::trace!(
model = format!("{:?}", request.model),
size = format!("{:?}", request.size),
response_format = format!("{:?}", request.response_format),
"Sending OpenAI image edit API request"
);
let response = self.client.images().edit(request).await?;
if let Some(image_data) = response.data.into_iter().next() {
match image_data.deref() {
Image::B64Json { b64_json, .. } => {
let bytes = base64_decode(b64_json.as_ref())?;
return Ok(ImageEditResult {
bytes,
mime_type: mxlink::mime::IMAGE_PNG,
});
}
Image::Url { url, .. } => {
tracing::warn!(?url, "Received URL instead of B64Json for image edit");
return Err(anyhow::anyhow!(
"Unexpected image type (URL) when B64Json was requested"
));
}
}
}
Err(anyhow::anyhow!(
"The OpenAI image edit API returned no images"
))
}
2024-09-12 13:44:06 +03:00
async fn text_to_speech(
&self,
input: &str,
params: TextToSpeechParams,
) -> anyhow::Result<TextToSpeechResult> {
let Some(text_to_speech_config) = &self.config.text_to_speech else {
return Err(anyhow::anyhow!(
strings::agent::no_configuration_for_purpose_so_cannot_be_used(
&AgentPurpose::TextToSpeech
),
));
};
let speed = params.speed_override.unwrap_or(text_to_speech_config.speed);
let voice = if let Some(voice_string) = params.voice_override {
// This is a hacky way to construct a Voice enum from the string we have.
let voice: serde_json::Result<async_openai::types::audio::Voice> =
2024-09-12 13:44:06 +03:00
serde_json::from_str(&format!("\"{}\"", voice_string));
match voice {
Ok(voice) => voice,
Err(err) => {
tracing::debug!(?voice_string, ?err, "Failed to parse voice");
return Err(anyhow::anyhow!(
"The configured voice ({}) is not supported.",
voice_string
));
}
}
} else {
text_to_speech_config.voice.clone()
};
let response_format = text_to_speech_config.response_format;
let mime_type = response_format_to_mime_type(&response_format).unwrap_or(
"audio/mp3"
.parse()
.expect("Failed parsing default mime type"),
);
let request = CreateSpeechRequestArgs::default()
.model(text_to_speech_config.model_id.clone())
.voice(voice)
.speed(speed)
.response_format(response_format)
.input(input)
.build()?;
tracing::trace!(
model = format!("{:?}", request.model),
voice = format!("{:?}", request.voice),
speed = format!("{:?}", request.speed),
"Sending OpenAI text-to-speech API request"
);
let result = self.client.audio().speech().create(request).await?;
2024-09-12 13:44:06 +03:00
Ok(TextToSpeechResult {
bytes: result.bytes.into(),
mime_type,
})
}
fn supports_purpose(&self, purpose: AgentPurpose) -> bool {
match purpose {
AgentPurpose::TextGeneration => self.config.text_generation.is_some(),
AgentPurpose::SpeechToText => self.config.speech_to_text.is_some(),
AgentPurpose::TextToSpeech => self.config.text_to_speech.is_some(),
AgentPurpose::ImageGeneration => self.config.image_generation.is_some(),
AgentPurpose::CatchAll => true,
}
}
fn text_generation_model_id(&self) -> Option<String> {
self.config
.text_generation
.as_ref()
.map(|config| config.model_id.to_owned())
}
2024-09-12 13:44:06 +03:00
fn text_generation_prompt(&self) -> Option<String> {
self.config
.text_generation
.as_ref()
.and_then(|config| config.prompt.clone())
2024-09-12 13:44:06 +03:00
}
fn text_generation_temperature(&self) -> Option<f32> {
self.config
.text_generation
.as_ref()
.map(|config| config.temperature)
2024-09-12 13:44:06 +03:00
}
fn text_to_speech_voice(&self) -> Option<String> {
let Some(text_to_speech_config) = &self.config.text_to_speech else {
return None;
};
// A hacky way to turn this enum to a string
let voice_as_string = serde_json::to_string(&text_to_speech_config.voice).ok()?;
Some(voice_as_string.replace("\"", ""))
}
fn text_to_speech_speed(&self) -> Option<f32> {
let Some(text_to_speech_config) = &self.config.text_to_speech else {
return None;
};
Some(text_to_speech_config.speed)
}
}
fn response_format_to_mime_type(
response_format: &async_openai::types::audio::SpeechResponseFormat,
2024-09-12 13:44:06 +03:00
) -> Option<mxlink::mime::Mime> {
let content_type = match response_format {
async_openai::types::audio::SpeechResponseFormat::Mp3 => "audio/mp3".to_owned(),
async_openai::types::audio::SpeechResponseFormat::Wav => "audio/wav".to_owned(),
async_openai::types::audio::SpeechResponseFormat::Opus => "audio/ogg".to_owned(),
async_openai::types::audio::SpeechResponseFormat::Aac => "audio/aac".to_owned(),
async_openai::types::audio::SpeechResponseFormat::Flac => "audio/flac".to_owned(),
async_openai::types::audio::SpeechResponseFormat::Pcm => "audio/L8".to_owned(),
2024-09-12 13:44:06 +03:00
};
match content_type.parse() {
Ok(content_type) => Some(content_type),
Err(err) => {
tracing::error!(?err, "Failed to parse content type");
None
}
}
}
fn audio_mime_type_to_file_name(mime_type: &mxlink::mime::Mime) -> Option<String> {
let mime_type_string = mime_type.to_string();
let file_extension = match mime_type_string.as_str() {
"audio/flac" => "flac",
"audio/x-m4a" | "audio/m4a" => "m4a",
"audio/mp3" | "audio/mpeg" => "mp3",
"audio/mp4" => "mp4",
"application/ogg" | "audio/ogg" => "ogg",
"audio/wav" | "audio/x-wav" => "wav",
"audio/webm" => "webm",
_ => return None,
};
Some(format!("audio.{}", file_extension))
}
/// Returns the smallest supported size for stickers based on what the image model supports.
fn get_sticker_size(model: &ImageModel) -> async_openai::types::images::ImageSize {
use async_openai::types::images::ImageSize;
match model {
ImageModel::DallE2 => ImageSize::S256x256,
ImageModel::DallE3 => ImageSize::S1024x1024,
ImageModel::GptImage1 => ImageSize::S1024x1024,
ImageModel::GptImage1Mini => ImageSize::S1024x1024,
ImageModel::GptImage1dot5 => ImageSize::S1024x1024,
ImageModel::GptImage2 => ImageSize::S1024x1024,
ImageModel::Other(_) => ImageSize::S1024x1024,
}
}