1use crate::media::ImageModelDescriptor;
9
10pub const IMAGE_PROVIDER_OPENAI: &str = "openai";
11pub const IMAGE_PROVIDER_GOOGLE: &str = "google";
12
13#[derive(Debug, Clone, Copy)]
14pub struct ImageProviderCatalogEntry {
15 pub id: &'static str,
16 pub name: &'static str,
17 pub default_model: &'static str,
18 pub base_url: &'static str,
19 pub env_key: &'static str,
20 pub env_aliases: &'static [&'static str],
21}
22
23#[derive(Debug, Clone, Copy)]
24pub struct ImageModelCatalogEntry {
25 pub id: &'static str,
26 pub display_name: &'static str,
27 pub provider: &'static str,
28 pub is_default: bool,
29 pub legacy: bool,
30 pub supports_edit: bool,
31 pub supports_multiple_outputs: bool,
32 pub supported_aspect_ratios: &'static [&'static str],
33 pub supported_sizes: &'static [&'static str],
34 pub supported_image_sizes: &'static [&'static str],
35 pub supports_transparent_background: bool,
36 pub supports_partial_images: bool,
37}
38
39impl ImageModelCatalogEntry {
40 pub fn descriptor(&self) -> ImageModelDescriptor {
41 ImageModelDescriptor {
42 id: self.id.to_string(),
43 display_name: self.display_name.to_string(),
44 provider: self.provider.to_string(),
45 is_default: self.is_default,
46 legacy: self.legacy,
47 supports_edit: self.supports_edit,
48 supports_multiple_outputs: self.supports_multiple_outputs,
49 supported_aspect_ratios: to_strings(self.supported_aspect_ratios),
50 supported_sizes: to_strings(self.supported_sizes),
51 supported_image_sizes: to_strings(self.supported_image_sizes),
52 supports_transparent_background: self.supports_transparent_background,
53 supports_partial_images: self.supports_partial_images,
54 }
55 }
56}
57
58fn to_strings(values: &[&str]) -> Vec<String> {
59 values.iter().map(|value| value.to_string()).collect()
60}
61
62const IMAGE_PROVIDERS: &[ImageProviderCatalogEntry] = &[
63 ImageProviderCatalogEntry {
64 id: IMAGE_PROVIDER_OPENAI,
65 name: "OpenAI GPT Image",
66 default_model: "gpt-image-2.5-flare",
67 base_url: "https://api.openai.com/v1",
68 env_key: "OPENAI_API_KEY",
69 env_aliases: &[],
70 },
71 ImageProviderCatalogEntry {
72 id: IMAGE_PROVIDER_GOOGLE,
73 name: "Google Gemini Images",
74 default_model: "gemini-3.1-flash-image",
75 base_url: "https://generativelanguage.googleapis.com/v1beta",
76 env_key: "GEMINI_API_KEY",
77 env_aliases: &["GEMINI_API_TOKEN", "GOOGLE_API_KEY"],
78 },
79];
80
81const OPENAI_SIZES: &[&str] = &["auto", "1024x1024", "1536x1024", "1024x1536"];
82
83const GOOGLE_ASPECT_RATIOS: &[&str] = &[
85 "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9",
86];
87
88const GOOGLE_IMAGE_SIZES: &[&str] = &["1K", "2K", "4K"];
89
90const IMAGE_MODELS: &[ImageModelCatalogEntry] = &[
91 ImageModelCatalogEntry {
92 id: "gpt-image-2.5-flare",
93 display_name: "GPT Image 2.5 Flare",
94 provider: IMAGE_PROVIDER_OPENAI,
95 is_default: true,
96 legacy: false,
97 supports_edit: true,
98 supports_multiple_outputs: true,
99 supported_aspect_ratios: &[],
100 supported_sizes: OPENAI_SIZES,
101 supported_image_sizes: &[],
102 supports_transparent_background: true,
103 supports_partial_images: false,
104 },
105 ImageModelCatalogEntry {
106 id: "gpt-image-2.5-sunburst",
107 display_name: "GPT Image 2.5 Sunburst",
108 provider: IMAGE_PROVIDER_OPENAI,
109 is_default: false,
110 legacy: false,
111 supports_edit: true,
112 supports_multiple_outputs: true,
113 supported_aspect_ratios: &[],
114 supported_sizes: OPENAI_SIZES,
115 supported_image_sizes: &[],
116 supports_transparent_background: true,
117 supports_partial_images: false,
118 },
119 ImageModelCatalogEntry {
120 id: "gpt-image-2",
121 display_name: "GPT Image 2",
122 provider: IMAGE_PROVIDER_OPENAI,
123 is_default: false,
124 legacy: true,
125 supports_edit: true,
126 supports_multiple_outputs: true,
127 supported_aspect_ratios: &[],
128 supported_sizes: OPENAI_SIZES,
129 supported_image_sizes: &[],
130 supports_transparent_background: true,
131 supports_partial_images: false,
132 },
133 ImageModelCatalogEntry {
134 id: "gpt-image-1.5",
135 display_name: "GPT Image 1.5",
136 provider: IMAGE_PROVIDER_OPENAI,
137 is_default: false,
138 legacy: true,
139 supports_edit: true,
140 supports_multiple_outputs: true,
141 supported_aspect_ratios: &[],
142 supported_sizes: OPENAI_SIZES,
143 supported_image_sizes: &[],
144 supports_transparent_background: true,
145 supports_partial_images: false,
146 },
147 ImageModelCatalogEntry {
148 id: "gpt-image-1",
149 display_name: "GPT Image 1",
150 provider: IMAGE_PROVIDER_OPENAI,
151 is_default: false,
152 legacy: true,
153 supports_edit: true,
154 supports_multiple_outputs: true,
155 supported_aspect_ratios: &[],
156 supported_sizes: OPENAI_SIZES,
157 supported_image_sizes: &[],
158 supports_transparent_background: true,
159 supports_partial_images: false,
160 },
161 ImageModelCatalogEntry {
162 id: "gpt-image-1-mini",
163 display_name: "GPT Image 1 Mini",
164 provider: IMAGE_PROVIDER_OPENAI,
165 is_default: false,
166 legacy: true,
167 supports_edit: true,
168 supports_multiple_outputs: true,
169 supported_aspect_ratios: &[],
170 supported_sizes: OPENAI_SIZES,
171 supported_image_sizes: &[],
172 supports_transparent_background: true,
173 supports_partial_images: false,
174 },
175 ImageModelCatalogEntry {
176 id: "gemini-3.1-flash-image",
177 display_name: "Nano Banana 2",
178 provider: IMAGE_PROVIDER_GOOGLE,
179 is_default: true,
180 legacy: false,
181 supports_edit: true,
182 supports_multiple_outputs: false,
183 supported_aspect_ratios: GOOGLE_ASPECT_RATIOS,
184 supported_sizes: &[],
185 supported_image_sizes: GOOGLE_IMAGE_SIZES,
186 supports_transparent_background: false,
187 supports_partial_images: false,
188 },
189 ImageModelCatalogEntry {
190 id: "gemini-3-pro-image",
191 display_name: "Nano Banana Pro",
192 provider: IMAGE_PROVIDER_GOOGLE,
193 is_default: false,
194 legacy: false,
195 supports_edit: true,
196 supports_multiple_outputs: false,
197 supported_aspect_ratios: GOOGLE_ASPECT_RATIOS,
198 supported_sizes: &[],
199 supported_image_sizes: GOOGLE_IMAGE_SIZES,
200 supports_transparent_background: false,
201 supports_partial_images: false,
202 },
203 ImageModelCatalogEntry {
204 id: "gemini-2.5-flash-image",
205 display_name: "Nano Banana",
206 provider: IMAGE_PROVIDER_GOOGLE,
207 is_default: false,
208 legacy: false,
209 supports_edit: true,
210 supports_multiple_outputs: false,
211 supported_aspect_ratios: GOOGLE_ASPECT_RATIOS,
212 supported_sizes: &[],
213 supported_image_sizes: &[],
214 supports_transparent_background: false,
215 supports_partial_images: false,
216 },
217];
218
219pub fn built_in_image_providers() -> &'static [ImageProviderCatalogEntry] {
220 IMAGE_PROVIDERS
221}
222
223pub fn lookup_image_provider(id: &str) -> Option<&'static ImageProviderCatalogEntry> {
224 IMAGE_PROVIDERS.iter().find(|provider| provider.id == id)
225}
226
227pub fn image_models_for_provider(provider: &str) -> Vec<&'static ImageModelCatalogEntry> {
228 IMAGE_MODELS
229 .iter()
230 .filter(|model| model.provider == provider)
231 .collect()
232}
233
234pub fn lookup_image_model(provider: &str, id: &str) -> Option<&'static ImageModelCatalogEntry> {
235 IMAGE_MODELS
236 .iter()
237 .find(|model| model.provider == provider && model.id == id)
238}
239
240pub fn image_model_descriptors(provider: &str) -> Vec<ImageModelDescriptor> {
241 image_models_for_provider(provider)
242 .into_iter()
243 .map(ImageModelCatalogEntry::descriptor)
244 .collect()
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[test]
252 fn image_generation_catalog_lists_openai_and_google_models() {
253 let openai_ids: Vec<&str> = image_models_for_provider(IMAGE_PROVIDER_OPENAI)
254 .iter()
255 .map(|model| model.id)
256 .collect();
257 assert_eq!(
258 openai_ids,
259 vec![
260 "gpt-image-2.5-flare",
261 "gpt-image-2.5-sunburst",
262 "gpt-image-2",
263 "gpt-image-1.5",
264 "gpt-image-1",
265 "gpt-image-1-mini"
266 ]
267 );
268
269 let google_ids: Vec<&str> = image_models_for_provider(IMAGE_PROVIDER_GOOGLE)
270 .iter()
271 .map(|model| model.id)
272 .collect();
273 assert_eq!(
274 google_ids,
275 vec![
276 "gemini-3.1-flash-image",
277 "gemini-3-pro-image",
278 "gemini-2.5-flash-image"
279 ]
280 );
281 }
282
283 #[test]
284 fn image_generation_default_models_match_provider_entries() {
285 for provider in built_in_image_providers() {
286 let default = image_models_for_provider(provider.id)
287 .into_iter()
288 .find(|model| model.is_default)
289 .expect("image provider declares a default model");
290 assert_eq!(default.id, provider.default_model);
291 }
292 }
293
294 #[test]
295 fn non_primary_openai_image_models_are_marked_legacy() {
296 for model in image_models_for_provider(IMAGE_PROVIDER_OPENAI) {
297 assert_eq!(
298 model.legacy,
299 !model.id.starts_with("gpt-image-2.5-"),
300 "{}",
301 model.id
302 );
303 }
304 }
305
306 #[test]
307 fn google_image_size_support_is_model_specific() {
308 let nano_banana = lookup_image_model(IMAGE_PROVIDER_GOOGLE, "gemini-2.5-flash-image")
309 .expect("nano banana entry");
310 assert!(nano_banana.supported_image_sizes.is_empty());
311 assert!(!nano_banana.supported_aspect_ratios.is_empty());
312
313 for id in ["gemini-3.1-flash-image", "gemini-3-pro-image"] {
314 let model = lookup_image_model(IMAGE_PROVIDER_GOOGLE, id).expect("model entry");
315 assert_eq!(model.supported_image_sizes, GOOGLE_IMAGE_SIZES, "{id}");
316 }
317 }
318
319 #[test]
320 fn image_models_use_display_aliases_for_nano_banana() {
321 assert_eq!(
322 lookup_image_model(IMAGE_PROVIDER_GOOGLE, "gemini-3.1-flash-image")
323 .unwrap()
324 .display_name,
325 "Nano Banana 2"
326 );
327 assert_eq!(
328 lookup_image_model(IMAGE_PROVIDER_GOOGLE, "gemini-3-pro-image")
329 .unwrap()
330 .display_name,
331 "Nano Banana Pro"
332 );
333 assert_eq!(
334 lookup_image_model(IMAGE_PROVIDER_GOOGLE, "gemini-2.5-flash-image")
335 .unwrap()
336 .display_name,
337 "Nano Banana"
338 );
339 }
340
341 #[test]
342 fn image_model_descriptor_conversion_keeps_capability_metadata() {
343 let descriptor = lookup_image_model(IMAGE_PROVIDER_OPENAI, "gpt-image-2.5-flare")
344 .unwrap()
345 .descriptor();
346 assert!(descriptor.is_default);
347 assert!(descriptor.supports_edit);
348 assert!(descriptor.supports_transparent_background);
349 assert!(
350 descriptor
351 .supported_sizes
352 .contains(&"1536x1024".to_string())
353 );
354 }
355
356 #[test]
357 fn image_generation_models_are_absent_from_chat_model_catalog() {
358 for model in IMAGE_MODELS {
359 assert!(
360 crate::catalog::lookup_model(model.id).is_none(),
361 "image model {} must not appear in the chat model catalog",
362 model.id
363 );
364 }
365 }
366}