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::{
|
2025-11-30 10:31:06 +02:00
|
|
|
audio::{AudioInput, CreateSpeechRequestArgs, CreateTranscriptionRequestArgs},
|
2026-02-10 14:33:19 +02:00
|
|
|
images::{
|
|
|
|
|
CreateImageEditRequestArgs, CreateImageRequestArgs, Image, ImageInput, ImageModel,
|
|
|
|
|
ImageResponseFormat,
|
|
|
|
|
},
|
2026-01-23 22:05:53 +01:00
|
|
|
responses::{
|
|
|
|
|
CodeInterpreterContainerAuto, CodeInterpreterTool, CodeInterpreterToolContainer,
|
|
|
|
|
CreateResponseArgs, OutputItem, OutputMessageContent, Tool, WebSearchTool,
|
|
|
|
|
},
|
2024-09-12 13:44:06 +03:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
use super::super::ControllerTrait;
|
|
|
|
|
use crate::{
|
2025-05-10 09:18:01 +03:00
|
|
|
agent::provider::{
|
2025-05-10 11:47:50 +03:00
|
|
|
ImageEditParams, ImageGenerationParams, SpeechToTextParams, SpeechToTextResult,
|
2025-05-10 09:18:01 +03:00
|
|
|
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,
|
2025-05-10 09:18:01 +03:00
|
|
|
MessageContent as LLMMessageContent, shorten_messages_list_to_context_size,
|
2024-09-12 13:44:06 +03:00
|
|
|
},
|
2025-05-10 09:18:01 +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,
|
Fix sticker generation for newer GPT image models
Sticker generation was failing when using newer GPT image models
(gpt-image-1, gpt-image-1-mini, gpt-image-1.5). The issue occurred
because stickers requested 256x256 size, but these models only support
1024x1024, 1536x1024, 1024x1536, and auto.
To reproduce, send `!bai sticker Something` to an agent configured
with a GPT image model. The error was:
invalid_request_error: Invalid value: '256x256'. Supported values
are: '1024x1024', '1024x1536', '1536x1024', and 'auto'. (param: size)
(code: invalid_value)
The fix replaces the hardcoded 256x256 size override with a
`smallest_size_possible` flag, letting each provider determine the
appropriate sticker size based on the model being used.
The `openai_compat` provider still defaults to requesting 256x256 in all cases
(regardless of model name).
2026-02-04 02:36:50 +02:00
|
|
|
provider::entity::{
|
2026-02-10 14:33:19 +02:00
|
|
|
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,
|
2025-05-10 09:18:01 +03:00
|
|
|
content: LLMMessageContent::Text("Hello!".to_string()),
|
2024-10-03 11:48:14 +03:00
|
|
|
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
|
|
|
|
|
),
|
|
|
|
|
));
|
|
|
|
|
};
|
|
|
|
|
|
2024-09-21 14:05:19 +00:00
|
|
|
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,
|
2025-05-10 09:18:01 +03:00
|
|
|
content: LLMMessageContent::Text(prompt_text),
|
2024-10-03 11:48:14 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 14:33:19 +02:00
|
|
|
let input =
|
|
|
|
|
super::utils::convert_llm_messages_to_openai_response_input(conversation_messages);
|
2024-09-12 13:44:06 +03:00
|
|
|
|
2026-01-23 22:05:53 +01: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);
|
|
|
|
|
|
2026-01-23 22:05:53 +01:00
|
|
|
let mut request_builder = CreateResponseArgs::default();
|
2024-10-03 10:36:01 +03:00
|
|
|
|
|
|
|
|
request_builder
|
2024-09-12 13:44:06 +03:00
|
|
|
.model(&text_generation_config.model_id)
|
|
|
|
|
.temperature(temperature)
|
2026-01-23 22:05:53 +01:00
|
|
|
.input(input);
|
2024-10-03 10:36:01 +03:00
|
|
|
|
2026-01-23 22:05:53 +01:00
|
|
|
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(),
|
|
|
|
|
),
|
|
|
|
|
}));
|
2024-10-03 10:36:01 +03:00
|
|
|
}
|
|
|
|
|
|
2026-01-23 22:05:53 +01:00
|
|
|
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);
|
2025-02-27 09:52:24 +02:00
|
|
|
}
|
|
|
|
|
|
2024-10-03 10:36:01 +03:00
|
|
|
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,
|
2026-01-23 22:05:53 +01:00
|
|
|
"Sending OpenAI response API request"
|
2024-09-12 13:44:06 +03:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-23 22:05:53 +01:00
|
|
|
let response = self.client.responses().create(request).await?;
|
2024-09-12 13:44:06 +03:00
|
|
|
|
2026-02-10 14:33:19 +02:00
|
|
|
tracing::trace!(?response, "Got response from the OpenAI response API");
|
2024-09-12 13:44:06 +03:00
|
|
|
|
2026-01-23 22:05:53 +01: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!(
|
2026-01-23 22:05:53 +01:00
|
|
|
"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)
|
2025-11-30 10:31:06 +02:00
|
|
|
.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"
|
|
|
|
|
);
|
|
|
|
|
|
2025-11-30 10:31:06 +02:00
|
|
|
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 {
|
2025-11-30 10:31:06 +02:00
|
|
|
ImageModel::DallE2 => ImageModel::DallE2,
|
|
|
|
|
ImageModel::DallE3 => ImageModel::DallE2,
|
2026-01-22 22:31:51 +02:00
|
|
|
ImageModel::GptImage1 => ImageModel::GptImage1Mini,
|
|
|
|
|
ImageModel::GptImage1dot5 => ImageModel::GptImage1Mini,
|
|
|
|
|
ImageModel::GptImage1Mini => ImageModel::GptImage1Mini,
|
2026-02-10 14:33:19 +02:00
|
|
|
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 {
|
2025-05-03 09:37:10 +03:00
|
|
|
Some(quality) => match quality {
|
2025-11-30 10:31:06 +02:00
|
|
|
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)
|
2025-05-03 09:37:10 +03:00
|
|
|
}
|
2025-11-30 10:31:06 +02:00
|
|
|
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-03 09:37:10 +03:00
|
|
|
}
|
2025-05-10 11:47:50 +03:00
|
|
|
},
|
|
|
|
|
None => None,
|
2024-09-12 13:44:06 +03:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
image_generation_config.quality.clone()
|
|
|
|
|
};
|
|
|
|
|
|
Fix sticker generation for newer GPT image models
Sticker generation was failing when using newer GPT image models
(gpt-image-1, gpt-image-1-mini, gpt-image-1.5). The issue occurred
because stickers requested 256x256 size, but these models only support
1024x1024, 1536x1024, 1024x1536, and auto.
To reproduce, send `!bai sticker Something` to an agent configured
with a GPT image model. The error was:
invalid_request_error: Invalid value: '256x256'. Supported values
are: '1024x1024', '1024x1536', '1536x1024', and 'auto'. (param: size)
(code: invalid_value)
The fix replaces the hardcoded 256x256 size override with a
`smallest_size_possible` flag, letting each provider determine the
appropriate sticker size based on the model being used.
The `openai_compat` provider still defaults to requesting 256x256 in all cases
(regardless of model name).
2026-02-04 02:36:50 +02:00
|
|
|
let size = if params.smallest_size_possible {
|
|
|
|
|
Some(get_sticker_size(&model))
|
|
|
|
|
} else {
|
|
|
|
|
image_generation_config.size
|
|
|
|
|
};
|
2024-09-12 13:44:06 +03:00
|
|
|
|
2025-05-03 09:37:10 +03:00
|
|
|
let response_format = match model.clone() {
|
2025-05-10 09:18:01 +03:00
|
|
|
ImageModel::DallE2 => Some(ImageResponseFormat::B64Json),
|
|
|
|
|
ImageModel::DallE3 => Some(ImageResponseFormat::B64Json),
|
2025-11-30 10:31:06 +02:00
|
|
|
// 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,
|
2025-12-21 12:23:25 +02:00
|
|
|
ImageModel::GptImage1dot5 => None,
|
2025-11-30 10:31:06 +02:00
|
|
|
ImageModel::Other(_) => Some(ImageResponseFormat::B64Json),
|
2025-05-03 09:37:10 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut request_builder = CreateImageRequestArgs::default();
|
|
|
|
|
|
2025-05-11 23:18:19 +03:00
|
|
|
request_builder.model(model).prompt(prompt.to_owned());
|
2025-05-03 09:37:10 +03:00
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-11 23:18:19 +03:00
|
|
|
if let Some(size) = size {
|
|
|
|
|
request_builder.size(size);
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-03 09:37:10 +03:00
|
|
|
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"
|
|
|
|
|
);
|
|
|
|
|
|
2025-11-30 10:31:06 +02:00
|
|
|
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() {
|
2025-11-30 10:31:06 +02:00
|
|
|
Image::B64Json {
|
2024-09-12 13:44:06 +03:00
|
|
|
b64_json,
|
|
|
|
|
revised_prompt,
|
|
|
|
|
} => {
|
2025-11-30 10:31:06 +02:00
|
|
|
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"
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-10 09:18:01 +03:00
|
|
|
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
|
|
|
|
|
),
|
|
|
|
|
));
|
|
|
|
|
};
|
|
|
|
|
|
2025-05-10 09:59:27 +03:00
|
|
|
if images.is_empty() {
|
2025-05-10 09:18:01 +03:00
|
|
|
return Err(anyhow::anyhow!("No image sources provided"));
|
2025-05-10 09:59:27 +03:00
|
|
|
}
|
2025-05-10 09:18:01 +03:00
|
|
|
|
2025-11-30 10:31:06 +02:00
|
|
|
let mut image_inputs: Vec<ImageInput> = Vec::new();
|
2025-05-10 09:59:27 +03:00
|
|
|
for image in images {
|
|
|
|
|
image_inputs.push(image.into());
|
|
|
|
|
}
|
2025-05-10 09:18:01 +03:00
|
|
|
|
|
|
|
|
let dalle2_size = match image_generation_config.size {
|
2026-02-10 14:33:19 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2025-05-10 09:18:01 +03:00
|
|
|
_ => None,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let model = image_generation_config
|
|
|
|
|
.model_id_as_openai_image_model()
|
|
|
|
|
.map_err(|err| anyhow::anyhow!(err))?;
|
|
|
|
|
|
|
|
|
|
let response_format = match model.clone() {
|
2026-02-10 14:33:19 +02:00
|
|
|
ImageModel::DallE2 => Some(ImageResponseFormat::B64Json),
|
|
|
|
|
ImageModel::DallE3 => Some(ImageResponseFormat::B64Json),
|
2025-11-30 10:31:06 +02:00
|
|
|
// 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,
|
2025-12-21 12:23:25 +02:00
|
|
|
ImageModel::GptImage1dot5 => None,
|
2025-11-30 10:31:06 +02:00
|
|
|
ImageModel::Other(_) => Some(ImageResponseFormat::B64Json),
|
2025-05-10 09:18:01 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut request_builder = CreateImageEditRequestArgs::default();
|
|
|
|
|
|
|
|
|
|
request_builder
|
2025-05-10 09:59:27 +03:00
|
|
|
.image(image_inputs)
|
2025-05-10 09:18:01 +03:00
|
|
|
.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()
|
2025-05-10 09:18:01 +03:00
|
|
|
.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"
|
|
|
|
|
);
|
|
|
|
|
|
2025-11-30 10:31:06 +02:00
|
|
|
let response = self.client.images().edit(request).await?;
|
2025-05-10 09:18:01 +03:00
|
|
|
|
|
|
|
|
if let Some(image_data) = response.data.into_iter().next() {
|
|
|
|
|
match image_data.deref() {
|
|
|
|
|
Image::B64Json { b64_json, .. } => {
|
2025-11-30 10:31:06 +02:00
|
|
|
let bytes = base64_decode(b64_json.as_ref())?;
|
2025-05-10 09:18:01 +03:00
|
|
|
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.
|
2025-11-30 10:31:06 +02:00
|
|
|
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"
|
|
|
|
|
);
|
|
|
|
|
|
2025-11-30 10:31:06 +02:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-21 14:05:19 +00:00
|
|
|
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
|
|
|
|
2024-09-21 14:05:19 +00: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> {
|
2024-09-21 14:05:19 +00:00
|
|
|
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(
|
2025-11-30 10:31:06 +02:00
|
|
|
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 {
|
2025-11-30 10:31:06 +02:00
|
|
|
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))
|
|
|
|
|
}
|
Fix sticker generation for newer GPT image models
Sticker generation was failing when using newer GPT image models
(gpt-image-1, gpt-image-1-mini, gpt-image-1.5). The issue occurred
because stickers requested 256x256 size, but these models only support
1024x1024, 1536x1024, 1024x1536, and auto.
To reproduce, send `!bai sticker Something` to an agent configured
with a GPT image model. The error was:
invalid_request_error: Invalid value: '256x256'. Supported values
are: '1024x1024', '1024x1536', '1536x1024', and 'auto'. (param: size)
(code: invalid_value)
The fix replaces the hardcoded 256x256 size override with a
`smallest_size_possible` flag, letting each provider determine the
appropriate sticker size based on the model being used.
The `openai_compat` provider still defaults to requesting 256x256 in all cases
(regardless of model name).
2026-02-04 02:36:50 +02:00
|
|
|
|
|
|
|
|
/// 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::Other(_) => ImageSize::S1024x1024,
|
|
|
|
|
}
|
|
|
|
|
}
|