pub mod openai;
pub mod openrouter;
use agentai_core::HttpError;
use agentai_core::message::Media;
use agentai_core::storage::StorageError;
use async_trait::async_trait;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct ImageRequest {
pub prompt: String,
pub size: Option<String>,
pub quality: Option<String>,
pub name: Option<String>,
}
impl ImageRequest {
pub fn new(prompt: impl Into<String>) -> Self {
Self {
prompt: prompt.into(),
size: None,
quality: None,
name: None,
}
}
pub fn with_size(mut self, size: impl Into<String>) -> Self {
self.size = Some(size.into());
self
}
pub fn with_quality(mut self, quality: impl Into<String>) -> Self {
self.quality = Some(quality.into());
self
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct ImageEditRequest {
pub prompt: String,
pub images: Vec<Media>,
pub size: Option<String>,
pub quality: Option<String>,
pub name: Option<String>,
}
impl ImageEditRequest {
pub fn new(prompt: impl Into<String>, images: Vec<Media>) -> Self {
Self {
prompt: prompt.into(),
images,
size: None,
quality: None,
name: None,
}
}
pub fn with_size(mut self, size: impl Into<String>) -> Self {
self.size = Some(size.into());
self
}
pub fn with_quality(mut self, quality: impl Into<String>) -> Self {
self.quality = Some(quality.into());
self
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct ImageResponse {
pub image: Media,
pub revised_prompt: Option<String>,
pub id: Option<String>,
}
impl ImageResponse {
pub fn new(image: Media) -> Self {
Self {
image,
revised_prompt: None,
id: None,
}
}
pub fn with_revised_prompt(mut self, prompt: impl Into<String>) -> Self {
self.revised_prompt = Some(prompt.into());
self
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct ImageModel {
pub model: String,
pub id: String,
pub name: Option<String>,
pub description: Option<String>,
pub accepts_images: Option<bool>,
}
impl ImageModel {
pub fn new(model: impl Into<String>, id: impl Into<String>) -> Self {
Self {
model: model.into(),
id: id.into(),
name: None,
description: None,
accepts_images: None,
}
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ImageError {
#[error(transparent)]
Http(#[from] HttpError),
#[error("image API error (status {status}): {message}")]
Api { status: u16, message: String },
#[error("invalid image API response: {0}")]
InvalidResponse(String),
#[error("not supported by the image provider: {0}")]
Unsupported(&'static str),
#[error("no image provider for model '{0}'")]
UnknownProvider(String),
#[error("the API key is missing, set {0}")]
MissingApiKey(&'static str),
#[error("cannot save the image: {0}")]
Storage(#[from] StorageError),
}
impl ImageError {
pub fn is_auth(&self) -> bool {
matches!(
self,
Self::Api {
status: 401 | 403,
..
}
)
}
}
#[async_trait]
pub trait ImageProvider: Send + Sync + 'static {
fn model(&self) -> Option<&str> {
None
}
async fn generate(&self, request: ImageRequest) -> Result<ImageResponse, ImageError>;
fn supports_edit(&self) -> bool {
false
}
async fn edit(&self, request: ImageEditRequest) -> Result<ImageResponse, ImageError> {
let _ = request;
Err(ImageError::Unsupported("image editing"))
}
async fn list_models(&self) -> Result<Vec<ImageModel>, ImageError> {
Err(ImageError::Unsupported("listing models"))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProviderKind {
OpenAi,
OpenRouter,
}
impl ProviderKind {
fn from_name(name: &str) -> Option<Self> {
match name {
"openai" => Some(Self::OpenAi),
"openrouter" => Some(Self::OpenRouter),
_ => None,
}
}
fn build(self, model: &str) -> Result<Box<dyn ImageProvider>, ImageError> {
Ok(match self {
Self::OpenAi => Box::new(openai::OpenAiImageProvider::from_env(model)?),
Self::OpenRouter => Box::new(openrouter::OpenRouterImageProvider::from_env(model)?),
})
}
}
fn resolve(model: &str) -> Result<(ProviderKind, &str), ImageError> {
let unknown = || ImageError::UnknownProvider(model.to_string());
match model.split_once("::") {
Some((provider, name)) => ProviderKind::from_name(provider)
.map(|kind| (kind, name))
.ok_or_else(unknown),
None if model.starts_with("gpt-image") || model.starts_with("dall-e") => {
Ok((ProviderKind::OpenAi, model))
}
None => Err(unknown()),
}
}
pub fn from_model(model: &str) -> Result<Box<dyn ImageProvider>, ImageError> {
let (kind, name) = resolve(model)?;
kind.build(name)
}
pub async fn list_models(provider: &str) -> Result<Vec<ImageModel>, ImageError> {
let kind = ProviderKind::from_name(provider)
.ok_or_else(|| ImageError::UnknownProvider(provider.to_string()))?;
kind.build("")?.list_models().await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn model_names_select_the_provider() {
assert_eq!(
resolve("openai::my-model").unwrap(),
(ProviderKind::OpenAi, "my-model")
);
assert_eq!(
resolve("gpt-image-1").unwrap(),
(ProviderKind::OpenAi, "gpt-image-1")
);
assert_eq!(
resolve("openrouter::black-forest-labs/flux.2").unwrap(),
(ProviderKind::OpenRouter, "black-forest-labs/flux.2")
);
assert_eq!(
resolve("dall-e-3").unwrap(),
(ProviderKind::OpenAi, "dall-e-3")
);
for model in ["flux-1", "other::gpt-image-1"] {
assert!(matches!(resolve(model), Err(ImageError::UnknownProvider(m)) if m == model));
}
}
}