Skip to main content

roder_api/catalog/
image_models.rs

1//! Built-in image generation provider/model catalog.
2//!
3//! Image generation models are deliberately separate from the chat model
4//! catalog so they never appear in chat model pickers; media-provider code
5//! queries them through [`built_in_image_providers`] and
6//! [`image_models_for_provider`].
7
8use 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",
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
83/// All Nano Banana models share the same documented aspect ratio set.
84const 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",
93        display_name: "GPT Image 2",
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-1.5",
107        display_name: "GPT Image 1.5",
108        provider: IMAGE_PROVIDER_OPENAI,
109        is_default: false,
110        legacy: true,
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-1",
121        display_name: "GPT Image 1",
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-mini",
135        display_name: "GPT Image 1 Mini",
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: "gemini-3.1-flash-image",
149        display_name: "Nano Banana 2",
150        provider: IMAGE_PROVIDER_GOOGLE,
151        is_default: true,
152        legacy: false,
153        supports_edit: true,
154        supports_multiple_outputs: false,
155        supported_aspect_ratios: GOOGLE_ASPECT_RATIOS,
156        supported_sizes: &[],
157        supported_image_sizes: GOOGLE_IMAGE_SIZES,
158        supports_transparent_background: false,
159        supports_partial_images: false,
160    },
161    ImageModelCatalogEntry {
162        id: "gemini-3-pro-image",
163        display_name: "Nano Banana Pro",
164        provider: IMAGE_PROVIDER_GOOGLE,
165        is_default: false,
166        legacy: false,
167        supports_edit: true,
168        supports_multiple_outputs: false,
169        supported_aspect_ratios: GOOGLE_ASPECT_RATIOS,
170        supported_sizes: &[],
171        supported_image_sizes: GOOGLE_IMAGE_SIZES,
172        supports_transparent_background: false,
173        supports_partial_images: false,
174    },
175    ImageModelCatalogEntry {
176        id: "gemini-2.5-flash-image",
177        display_name: "Nano Banana",
178        provider: IMAGE_PROVIDER_GOOGLE,
179        is_default: false,
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: &[],
186        supports_transparent_background: false,
187        supports_partial_images: false,
188    },
189];
190
191pub fn built_in_image_providers() -> &'static [ImageProviderCatalogEntry] {
192    IMAGE_PROVIDERS
193}
194
195pub fn lookup_image_provider(id: &str) -> Option<&'static ImageProviderCatalogEntry> {
196    IMAGE_PROVIDERS.iter().find(|provider| provider.id == id)
197}
198
199pub fn image_models_for_provider(provider: &str) -> Vec<&'static ImageModelCatalogEntry> {
200    IMAGE_MODELS
201        .iter()
202        .filter(|model| model.provider == provider)
203        .collect()
204}
205
206pub fn lookup_image_model(provider: &str, id: &str) -> Option<&'static ImageModelCatalogEntry> {
207    IMAGE_MODELS
208        .iter()
209        .find(|model| model.provider == provider && model.id == id)
210}
211
212pub fn image_model_descriptors(provider: &str) -> Vec<ImageModelDescriptor> {
213    image_models_for_provider(provider)
214        .into_iter()
215        .map(ImageModelCatalogEntry::descriptor)
216        .collect()
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn image_generation_catalog_lists_openai_and_google_models() {
225        let openai_ids: Vec<&str> = image_models_for_provider(IMAGE_PROVIDER_OPENAI)
226            .iter()
227            .map(|model| model.id)
228            .collect();
229        assert_eq!(
230            openai_ids,
231            vec![
232                "gpt-image-2",
233                "gpt-image-1.5",
234                "gpt-image-1",
235                "gpt-image-1-mini"
236            ]
237        );
238
239        let google_ids: Vec<&str> = image_models_for_provider(IMAGE_PROVIDER_GOOGLE)
240            .iter()
241            .map(|model| model.id)
242            .collect();
243        assert_eq!(
244            google_ids,
245            vec![
246                "gemini-3.1-flash-image",
247                "gemini-3-pro-image",
248                "gemini-2.5-flash-image"
249            ]
250        );
251    }
252
253    #[test]
254    fn image_generation_default_models_match_provider_entries() {
255        for provider in built_in_image_providers() {
256            let default = image_models_for_provider(provider.id)
257                .into_iter()
258                .find(|model| model.is_default)
259                .expect("image provider declares a default model");
260            assert_eq!(default.id, provider.default_model);
261        }
262    }
263
264    #[test]
265    fn non_primary_openai_image_models_are_marked_legacy() {
266        for model in image_models_for_provider(IMAGE_PROVIDER_OPENAI) {
267            assert_eq!(model.legacy, model.id != "gpt-image-2", "{}", model.id);
268        }
269    }
270
271    #[test]
272    fn google_image_size_support_is_model_specific() {
273        let nano_banana = lookup_image_model(IMAGE_PROVIDER_GOOGLE, "gemini-2.5-flash-image")
274            .expect("nano banana entry");
275        assert!(nano_banana.supported_image_sizes.is_empty());
276        assert!(!nano_banana.supported_aspect_ratios.is_empty());
277
278        for id in ["gemini-3.1-flash-image", "gemini-3-pro-image"] {
279            let model = lookup_image_model(IMAGE_PROVIDER_GOOGLE, id).expect("model entry");
280            assert_eq!(model.supported_image_sizes, GOOGLE_IMAGE_SIZES, "{id}");
281        }
282    }
283
284    #[test]
285    fn image_models_use_display_aliases_for_nano_banana() {
286        assert_eq!(
287            lookup_image_model(IMAGE_PROVIDER_GOOGLE, "gemini-3.1-flash-image")
288                .unwrap()
289                .display_name,
290            "Nano Banana 2"
291        );
292        assert_eq!(
293            lookup_image_model(IMAGE_PROVIDER_GOOGLE, "gemini-3-pro-image")
294                .unwrap()
295                .display_name,
296            "Nano Banana Pro"
297        );
298        assert_eq!(
299            lookup_image_model(IMAGE_PROVIDER_GOOGLE, "gemini-2.5-flash-image")
300                .unwrap()
301                .display_name,
302            "Nano Banana"
303        );
304    }
305
306    #[test]
307    fn image_model_descriptor_conversion_keeps_capability_metadata() {
308        let descriptor = lookup_image_model(IMAGE_PROVIDER_OPENAI, "gpt-image-2")
309            .unwrap()
310            .descriptor();
311        assert!(descriptor.is_default);
312        assert!(descriptor.supports_edit);
313        assert!(descriptor.supports_transparent_background);
314        assert!(
315            descriptor
316                .supported_sizes
317                .contains(&"1536x1024".to_string())
318        );
319    }
320
321    #[test]
322    fn image_generation_models_are_absent_from_chat_model_catalog() {
323        for model in IMAGE_MODELS {
324            assert!(
325                crate::catalog::lookup_model(model.id).is_none(),
326                "image model {} must not appear in the chat model catalog",
327                model.id
328            );
329        }
330    }
331}