1pub mod openai;
18pub mod openrouter;
19
20use agentai_core::HttpError;
21use agentai_core::message::Media;
22use agentai_core::storage::StorageError;
23use async_trait::async_trait;
24use thiserror::Error;
25
26#[derive(Debug, Clone, PartialEq)]
28#[non_exhaustive]
29pub struct ImageRequest {
30 pub prompt: String,
32 pub size: Option<String>,
34 pub quality: Option<String>,
36 pub name: Option<String>,
39}
40
41impl ImageRequest {
42 pub fn new(prompt: impl Into<String>) -> Self {
44 Self {
45 prompt: prompt.into(),
46 size: None,
47 quality: None,
48 name: None,
49 }
50 }
51
52 pub fn with_size(mut self, size: impl Into<String>) -> Self {
54 self.size = Some(size.into());
55 self
56 }
57
58 pub fn with_quality(mut self, quality: impl Into<String>) -> Self {
60 self.quality = Some(quality.into());
61 self
62 }
63
64 pub fn with_name(mut self, name: impl Into<String>) -> Self {
66 self.name = Some(name.into());
67 self
68 }
69}
70
71#[derive(Debug, Clone, PartialEq)]
73#[non_exhaustive]
74pub struct ImageEditRequest {
75 pub prompt: String,
77 pub images: Vec<Media>,
79 pub size: Option<String>,
81 pub quality: Option<String>,
83 pub name: Option<String>,
85}
86
87impl ImageEditRequest {
88 pub fn new(prompt: impl Into<String>, images: Vec<Media>) -> Self {
90 Self {
91 prompt: prompt.into(),
92 images,
93 size: None,
94 quality: None,
95 name: None,
96 }
97 }
98
99 pub fn with_size(mut self, size: impl Into<String>) -> Self {
101 self.size = Some(size.into());
102 self
103 }
104
105 pub fn with_quality(mut self, quality: impl Into<String>) -> Self {
107 self.quality = Some(quality.into());
108 self
109 }
110
111 pub fn with_name(mut self, name: impl Into<String>) -> Self {
113 self.name = Some(name.into());
114 self
115 }
116}
117
118#[derive(Debug, Clone, PartialEq)]
120#[non_exhaustive]
121pub struct ImageResponse {
122 pub image: Media,
124 pub revised_prompt: Option<String>,
126 pub id: Option<String>,
129}
130
131impl ImageResponse {
132 pub fn new(image: Media) -> Self {
134 Self {
135 image,
136 revised_prompt: None,
137 id: None,
138 }
139 }
140
141 pub fn with_revised_prompt(mut self, prompt: impl Into<String>) -> Self {
143 self.revised_prompt = Some(prompt.into());
144 self
145 }
146}
147
148#[derive(Debug, Clone, PartialEq)]
150#[non_exhaustive]
151pub struct ImageModel {
152 pub model: String,
155 pub id: String,
157 pub name: Option<String>,
159 pub description: Option<String>,
161 pub accepts_images: Option<bool>,
163}
164
165impl ImageModel {
166 pub fn new(model: impl Into<String>, id: impl Into<String>) -> Self {
168 Self {
169 model: model.into(),
170 id: id.into(),
171 name: None,
172 description: None,
173 accepts_images: None,
174 }
175 }
176}
177
178#[derive(Debug, Error)]
180#[non_exhaustive]
181pub enum ImageError {
182 #[error(transparent)]
184 Http(#[from] HttpError),
185 #[error("image API error (status {status}): {message}")]
187 Api { status: u16, message: String },
188 #[error("invalid image API response: {0}")]
190 InvalidResponse(String),
191 #[error("not supported by the image provider: {0}")]
193 Unsupported(&'static str),
194 #[error("no image provider for model '{0}'")]
196 UnknownProvider(String),
197 #[error("the API key is missing, set {0}")]
199 MissingApiKey(&'static str),
200 #[error("cannot save the image: {0}")]
202 Storage(#[from] StorageError),
203}
204
205impl ImageError {
206 pub fn is_auth(&self) -> bool {
208 matches!(
209 self,
210 Self::Api {
211 status: 401 | 403,
212 ..
213 }
214 )
215 }
216}
217
218#[async_trait]
224pub trait ImageProvider: Send + Sync + 'static {
225 fn model(&self) -> Option<&str> {
227 None
228 }
229
230 async fn generate(&self, request: ImageRequest) -> Result<ImageResponse, ImageError>;
232
233 fn supports_edit(&self) -> bool {
235 false
236 }
237
238 async fn edit(&self, request: ImageEditRequest) -> Result<ImageResponse, ImageError> {
240 let _ = request;
241 Err(ImageError::Unsupported("image editing"))
242 }
243
244 async fn list_models(&self) -> Result<Vec<ImageModel>, ImageError> {
247 Err(ImageError::Unsupported("listing models"))
248 }
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253enum ProviderKind {
254 OpenAi,
255 OpenRouter,
256}
257
258impl ProviderKind {
259 fn from_name(name: &str) -> Option<Self> {
260 match name {
261 "openai" => Some(Self::OpenAi),
262 "openrouter" => Some(Self::OpenRouter),
263 _ => None,
264 }
265 }
266
267 fn build(self, model: &str) -> Result<Box<dyn ImageProvider>, ImageError> {
268 Ok(match self {
269 Self::OpenAi => Box::new(openai::OpenAiImageProvider::from_env(model)?),
270 Self::OpenRouter => Box::new(openrouter::OpenRouterImageProvider::from_env(model)?),
271 })
272 }
273}
274
275fn resolve(model: &str) -> Result<(ProviderKind, &str), ImageError> {
277 let unknown = || ImageError::UnknownProvider(model.to_string());
278 match model.split_once("::") {
279 Some((provider, name)) => ProviderKind::from_name(provider)
280 .map(|kind| (kind, name))
281 .ok_or_else(unknown),
282 None if model.starts_with("gpt-image") || model.starts_with("dall-e") => {
283 Ok((ProviderKind::OpenAi, model))
284 }
285 None => Err(unknown()),
286 }
287}
288
289pub fn from_model(model: &str) -> Result<Box<dyn ImageProvider>, ImageError> {
297 let (kind, name) = resolve(model)?;
298 kind.build(name)
299}
300
301pub async fn list_models(provider: &str) -> Result<Vec<ImageModel>, ImageError> {
313 let kind = ProviderKind::from_name(provider)
314 .ok_or_else(|| ImageError::UnknownProvider(provider.to_string()))?;
315 kind.build("")?.list_models().await
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn model_names_select_the_provider() {
324 assert_eq!(
325 resolve("openai::my-model").unwrap(),
326 (ProviderKind::OpenAi, "my-model")
327 );
328 assert_eq!(
329 resolve("gpt-image-1").unwrap(),
330 (ProviderKind::OpenAi, "gpt-image-1")
331 );
332 assert_eq!(
333 resolve("openrouter::black-forest-labs/flux.2").unwrap(),
334 (ProviderKind::OpenRouter, "black-forest-labs/flux.2")
335 );
336 assert_eq!(
337 resolve("dall-e-3").unwrap(),
338 (ProviderKind::OpenAi, "dall-e-3")
339 );
340 for model in ["flux-1", "other::gpt-image-1"] {
341 assert!(matches!(resolve(model), Err(ImageError::UnknownProvider(m)) if m == model));
342 }
343 }
344}