1use super::openai::{parse_response, send_request};
9use super::{ImageEditRequest, ImageError, ImageModel, ImageProvider, ImageRequest, ImageResponse};
10use agentai_core::message::{Media, MediaSource};
11use agentai_core::{HttpClient, HttpRequest, ReqwestClient};
12use async_trait::async_trait;
13use serde::{Deserialize, Serialize};
14use std::sync::Arc;
15
16pub const DEFAULT_BASE_URL: &str = "https://openrouter.ai/api/v1";
18pub const API_KEY_ENV: &str = "OPENROUTER_API_KEY";
20pub const BASE_URL_ENV: &str = "OPENROUTER_BASE_URL";
22
23#[derive(Clone)]
37pub struct OpenRouterImageProvider {
38 model: String,
39 api_key: String,
40 base_url: String,
41 client: Arc<dyn HttpClient>,
42}
43
44impl std::fmt::Debug for OpenRouterImageProvider {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 f.debug_struct("OpenRouterImageProvider")
47 .field("model", &self.model)
48 .field("base_url", &self.base_url)
49 .finish_non_exhaustive()
50 }
51}
52
53impl OpenRouterImageProvider {
54 pub fn new(
57 model: impl Into<String>,
58 api_key: impl Into<String>,
59 base_url: impl Into<String>,
60 ) -> Self {
61 Self {
62 model: model.into(),
63 api_key: api_key.into(),
64 base_url: base_url.into().trim_end_matches('/').to_string(),
65 client: Arc::new(ReqwestClient::new()),
66 }
67 }
68
69 pub fn from_env(model: impl Into<String>) -> Result<Self, ImageError> {
72 let api_key =
73 std::env::var(API_KEY_ENV).map_err(|_| ImageError::MissingApiKey(API_KEY_ENV))?;
74 let base_url = std::env::var(BASE_URL_ENV).unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
75 Ok(Self::new(model, api_key, base_url))
76 }
77
78 pub fn model(&self) -> &str {
80 &self.model
81 }
82
83 #[cfg(test)]
84 fn with_client(mut self, client: Arc<dyn HttpClient>) -> Self {
85 self.client = client;
86 self
87 }
88
89 async fn send(&self, body: &ImageBody<'_>) -> Result<ImageResponse, ImageError> {
90 let request = HttpRequest::post(format!("{}/images", self.base_url)).with_json(body)?;
91 parse_response(&send_request(&*self.client, &self.api_key, request).await?)
92 }
93
94 fn origin(&self) -> &str {
96 let after_scheme = self.base_url.find("://").map_or(0, |i| i + 3);
97 match self.base_url[after_scheme..].find('/') {
98 Some(i) => &self.base_url[..after_scheme + i],
99 None => &self.base_url,
100 }
101 }
102}
103
104#[derive(Serialize)]
105struct ImageBody<'a> {
106 model: &'a str,
107 prompt: &'a str,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 size: Option<&'a str>,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 quality: Option<&'a str>,
112 #[serde(skip_serializing_if = "Vec::is_empty")]
113 input_references: Vec<ImageReference>,
114}
115
116#[derive(Serialize)]
117struct ImageReference {
118 r#type: &'static str,
119 image_url: ImageUrl,
120}
121
122#[derive(Serialize)]
123struct ImageUrl {
124 url: String,
125}
126
127impl ImageReference {
128 fn new(media: &Media) -> Self {
130 let url = match &media.source {
131 MediaSource::Base64(data) => format!("data:{};base64,{data}", media.content_type),
132 MediaSource::Url(url) => url.clone(),
133 };
134 Self {
135 r#type: "image_url",
136 image_url: ImageUrl { url },
137 }
138 }
139}
140
141#[derive(Deserialize)]
142struct ModelList {
143 #[serde(default)]
144 data: Vec<ModelEntry>,
145 links: Option<Links>,
146}
147
148#[derive(Deserialize)]
149struct Links {
150 next: Option<String>,
151}
152
153#[derive(Deserialize)]
154struct ModelEntry {
155 id: String,
156 name: Option<String>,
157 description: Option<String>,
158 architecture: Option<Architecture>,
159}
160
161#[derive(Deserialize)]
162struct Architecture {
163 input_modalities: Option<Vec<String>>,
164}
165
166#[async_trait]
167impl ImageProvider for OpenRouterImageProvider {
168 fn model(&self) -> Option<&str> {
169 Some(&self.model)
170 }
171
172 async fn generate(&self, request: ImageRequest) -> Result<ImageResponse, ImageError> {
173 self.send(&ImageBody {
174 model: &self.model,
175 prompt: &request.prompt,
176 size: request.size.as_deref(),
177 quality: request.quality.as_deref(),
178 input_references: Vec::new(),
179 })
180 .await
181 }
182
183 fn supports_edit(&self) -> bool {
184 true
185 }
186
187 async fn edit(&self, request: ImageEditRequest) -> Result<ImageResponse, ImageError> {
190 self.send(&ImageBody {
191 model: &self.model,
192 prompt: &request.prompt,
193 size: request.size.as_deref(),
194 quality: request.quality.as_deref(),
195 input_references: request.images.iter().map(ImageReference::new).collect(),
196 })
197 .await
198 }
199
200 async fn list_models(&self) -> Result<Vec<ImageModel>, ImageError> {
202 let mut url = format!("{}/models?output_modalities=image", self.base_url);
203 let mut models = Vec::new();
204 loop {
205 let response =
206 send_request(&*self.client, &self.api_key, HttpRequest::get(&url)).await?;
207 let list: ModelList = response
208 .json()
209 .map_err(|e| ImageError::InvalidResponse(e.to_string()))?;
210 models.extend(list.data.into_iter().map(|entry| {
211 let mut model = ImageModel::new(format!("openrouter::{}", entry.id), entry.id);
212 model.name = entry.name;
213 model.description = entry.description;
214 model.accepts_images = entry
215 .architecture
216 .and_then(|a| a.input_modalities)
217 .map(|inputs| inputs.iter().any(|input| input == "image"));
218 model
219 }));
220 match list.links.and_then(|links| links.next) {
221 Some(next) if next.starts_with('/') => url = format!("{}{next}", self.origin()),
222 Some(next) => url = next,
223 None => return Ok(models),
224 }
225 }
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use crate::provider::openai::tests::{MockHttp, json_response};
233 use agentai_core::HttpRequest;
234 use serde_json::{Value, json};
235
236 fn provider(http: &Arc<MockHttp>) -> OpenRouterImageProvider {
237 OpenRouterImageProvider::new("vendor/model", "key", "https://router.test/api/v1/")
238 .with_client(http.clone())
239 }
240
241 fn body_json(request: &HttpRequest) -> Value {
242 serde_json::from_slice(request.body.as_ref().unwrap()).unwrap()
243 }
244
245 #[tokio::test]
246 async fn generate_posts_to_images_and_reads_the_media_type() {
247 let http = MockHttp::new(vec![json_response(
248 200,
249 json!({"created": 1, "data": [{"b64_json": "aGk=", "media_type": "image/webp"}]}),
250 )]);
251
252 let response = provider(&http)
253 .generate(ImageRequest::new("a cat").with_size("1K"))
254 .await
255 .unwrap();
256
257 let request = http.request(0);
258 assert_eq!(request.url, "https://router.test/api/v1/images");
259 assert_eq!(request.header("Authorization"), Some("Bearer key"));
260 assert_eq!(
261 body_json(&request),
262 json!({"model": "vendor/model", "prompt": "a cat", "size": "1K"})
263 );
264 assert_eq!(response.image, Media::from_base64("image/webp", "aGk="));
265 }
266
267 #[tokio::test]
268 async fn edit_sends_bytes_as_data_urls_and_urls_as_they_are() {
269 let http = MockHttp::new(vec![json_response(
270 200,
271 json!({"data": [{"b64_json": "aGk="}]}),
272 )]);
273 let images = vec![
274 Media::from_base64("image/png", "aGk="),
275 Media::image_url("https://example.com/cat.jpg"),
276 ];
277
278 provider(&http)
279 .edit(ImageEditRequest::new("add a hat", images))
280 .await
281 .unwrap();
282
283 assert_eq!(
284 body_json(&http.request(0))["input_references"],
285 json!([
286 {"type": "image_url", "image_url": {"url": "data:image/png;base64,aGk="}},
287 {"type": "image_url", "image_url": {"url": "https://example.com/cat.jpg"}},
288 ])
289 );
290 }
291
292 #[tokio::test]
293 async fn listing_asks_for_image_models_and_follows_every_page() {
294 let http = MockHttp::new(vec![
295 json_response(
296 200,
297 json!({
298 "data": [{
299 "id": "a/edit",
300 "name": "Edit",
301 "architecture": {"input_modalities": ["text", "image"]},
302 }],
303 "links": {"next": "/api/v1/models?output_modalities=image&offset=1"},
304 }),
305 ),
306 json_response(
307 200,
308 json!({
309 "data": [{"id": "b/draw", "architecture": {"input_modalities": ["text"]}}],
310 "links": {"next": null},
311 }),
312 ),
313 ]);
314
315 let models = provider(&http).list_models().await.unwrap();
316
317 assert_eq!(
318 http.request(0).url,
319 "https://router.test/api/v1/models?output_modalities=image"
320 );
321 assert_eq!(
322 http.request(1).url,
323 "https://router.test/api/v1/models?output_modalities=image&offset=1"
324 );
325 let mut edit = ImageModel::new("openrouter::a/edit", "a/edit");
326 edit.name = Some("Edit".to_string());
327 edit.accepts_images = Some(true);
328 let mut draw = ImageModel::new("openrouter::b/draw", "b/draw");
329 draw.accepts_images = Some(false);
330 assert_eq!(models, [edit, draw]);
331 }
332}