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",
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",
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}