1use std::fmt;
2
3use base64::{Engine as _, engine::general_purpose::STANDARD};
4use serde_json::{Value, json};
5
6use crate::{DEFAULT_TRANSCRIPTION_PROMPT, Error, GPT_5_6, Result};
7
8pub(crate) const MAX_AUDIO_BYTES: usize = 25 * 1024 * 1024;
9const MAX_AUDIO_FILENAME_CHARACTERS: usize = 120;
10const MAX_IMAGE_ANALYSIS_BYTES: usize = 20 * 1024 * 1024;
11const MAX_IMAGE_ANALYSIS_PROMPT_CHARACTERS: usize = 32_000;
12const MAX_IMAGE_PROMPT_CHARACTERS: usize = 32_000;
13const MIN_IMAGE_PIXELS: u64 = 655_360;
14const MAX_IMAGE_PIXELS: u64 = 8_294_400;
15const MAX_IMAGE_EDGE: u16 = 3_840;
16
17#[derive(Clone, Eq, PartialEq)]
19pub struct AudioInput {
20 file_name: String,
21 mime_type: String,
22 data: Vec<u8>,
23}
24
25impl AudioInput {
26 pub fn new(
28 file_name: impl Into<String>,
29 mime_type: impl Into<String>,
30 data: Vec<u8>,
31 ) -> Result<Self> {
32 let value = Self {
33 file_name: file_name.into(),
34 mime_type: mime_type.into().to_ascii_lowercase(),
35 data,
36 };
37 value.validate()?;
38 Ok(value)
39 }
40
41 pub fn file_name(&self) -> &str {
43 &self.file_name
44 }
45
46 pub fn mime_type(&self) -> &str {
48 &self.mime_type
49 }
50
51 pub fn data(&self) -> &[u8] {
53 &self.data
54 }
55
56 pub fn len(&self) -> usize {
58 self.data.len()
59 }
60
61 pub fn is_empty(&self) -> bool {
63 self.data.is_empty()
64 }
65
66 pub(crate) fn into_parts(self) -> (String, String, Vec<u8>) {
67 (self.file_name, self.mime_type, self.data)
68 }
69
70 fn validate(&self) -> Result<()> {
71 if self.file_name.is_empty()
72 || self.file_name.chars().count() > MAX_AUDIO_FILENAME_CHARACTERS
73 || !self.file_name.chars().all(|character| {
74 character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_')
75 })
76 || matches!(self.file_name.as_str(), "." | "..")
77 {
78 return Err(Error::InvalidInput(format!(
79 "audio filename must contain 1 through {MAX_AUDIO_FILENAME_CHARACTERS} ASCII letters, digits, dots, hyphens, or underscores"
80 )));
81 }
82 if !matches!(
83 self.mime_type.as_str(),
84 "audio/flac"
85 | "audio/x-flac"
86 | "audio/m4a"
87 | "audio/mp3"
88 | "audio/mp4"
89 | "audio/mpeg"
90 | "audio/mpga"
91 | "audio/ogg"
92 | "audio/opus"
93 | "audio/wav"
94 | "audio/x-wav"
95 | "audio/webm"
96 | "application/ogg"
97 | "video/mp4"
98 | "video/webm"
99 ) {
100 return Err(Error::InvalidInput(
101 "audio MIME type must describe a supported FLAC, MP3, MP4, M4A, OGG, WAV, or WebM recording".into(),
102 ));
103 }
104 let extension = self
105 .file_name
106 .rsplit_once('.')
107 .map(|(_, extension)| extension.to_ascii_lowercase());
108 if !matches!(
109 extension.as_deref(),
110 Some("flac" | "mp3" | "mp4" | "mpeg" | "mpga" | "m4a" | "ogg" | "wav" | "webm")
111 ) {
112 return Err(Error::InvalidInput(
113 "audio filename must use a supported flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, or webm extension".into(),
114 ));
115 }
116 if self.data.is_empty() || self.data.len() > MAX_AUDIO_BYTES {
117 return Err(Error::InvalidInput(format!(
118 "audio must contain between 1 and {MAX_AUDIO_BYTES} bytes"
119 )));
120 }
121 Ok(())
122 }
123}
124
125impl fmt::Debug for AudioInput {
126 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127 f.debug_struct("AudioInput")
128 .field("file_name", &self.file_name)
129 .field("mime_type", &self.mime_type)
130 .field("bytes", &self.data.len())
131 .finish()
132 }
133}
134
135#[derive(Clone, Debug, Eq, PartialEq)]
137pub struct TranscriptionRequest {
138 pub audio: AudioInput,
140 pub prompt: Option<String>,
142 pub language: Option<String>,
144}
145
146impl TranscriptionRequest {
147 pub fn new(audio: AudioInput) -> Self {
149 Self {
150 audio,
151 prompt: Some(DEFAULT_TRANSCRIPTION_PROMPT.into()),
152 language: None,
153 }
154 }
155
156 pub(crate) fn validate(&self) -> Result<()> {
157 self.audio.validate()?;
158 if let Some(prompt) = &self.prompt
159 && prompt.trim().is_empty()
160 {
161 return Err(Error::InvalidInput(
162 "transcription prompt must not be blank when supplied".into(),
163 ));
164 }
165 if let Some(language) = &self.language
166 && (language.len() != 2 || !language.bytes().all(|value| value.is_ascii_lowercase()))
167 {
168 return Err(Error::InvalidInput(
169 "transcription language must be a two-letter lowercase ISO-639-1 code".into(),
170 ));
171 }
172 Ok(())
173 }
174}
175
176#[derive(Clone, Debug, Default, Eq, PartialEq)]
178pub struct TranscriptionTokenDetails {
179 pub audio_tokens: Option<u64>,
181 pub text_tokens: Option<u64>,
183}
184
185#[derive(Clone, Debug, Eq, PartialEq)]
187pub struct TranscriptionTokenUsage {
188 pub input_tokens: u64,
190 pub output_tokens: u64,
192 pub total_tokens: u64,
194 pub input_details: Option<TranscriptionTokenDetails>,
196}
197
198#[derive(Clone, Debug, PartialEq)]
200pub enum TranscriptionUsage {
201 Tokens(TranscriptionTokenUsage),
203 DurationSeconds(f64),
205}
206
207#[derive(Clone, Debug, PartialEq)]
209pub struct Transcription {
210 pub text: String,
212 pub usage: Option<TranscriptionUsage>,
214 pub request_id: Option<String>,
216}
217
218#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
220pub enum ImageMediaType {
221 Png,
223 Jpeg,
225 WebP,
227 Gif,
229}
230
231impl ImageMediaType {
232 pub const fn mime_type(self) -> &'static str {
234 match self {
235 Self::Png => "image/png",
236 Self::Jpeg => "image/jpeg",
237 Self::WebP => "image/webp",
238 Self::Gif => "image/gif",
239 }
240 }
241}
242
243#[derive(Clone, Eq, PartialEq)]
245pub struct ImageInput {
246 media_type: ImageMediaType,
247 data: Vec<u8>,
248}
249
250impl ImageInput {
251 pub fn new(media_type: ImageMediaType, data: Vec<u8>) -> Result<Self> {
253 let value = Self { media_type, data };
254 value.validate()?;
255 Ok(value)
256 }
257
258 pub const fn media_type(&self) -> ImageMediaType {
260 self.media_type
261 }
262
263 pub fn data(&self) -> &[u8] {
265 &self.data
266 }
267
268 pub fn len(&self) -> usize {
270 self.data.len()
271 }
272
273 pub fn is_empty(&self) -> bool {
275 self.data.is_empty()
276 }
277
278 fn validate(&self) -> Result<()> {
279 if self.data.is_empty() || self.data.len() > MAX_IMAGE_ANALYSIS_BYTES {
280 return Err(Error::InvalidInput(format!(
281 "analysis image must contain between 1 and {MAX_IMAGE_ANALYSIS_BYTES} bytes"
282 )));
283 }
284 Ok(())
285 }
286}
287
288impl fmt::Debug for ImageInput {
289 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290 f.debug_struct("ImageInput")
291 .field("media_type", &self.media_type)
292 .field("bytes", &self.data.len())
293 .finish()
294 }
295}
296
297#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
299pub enum ImageDetail {
300 #[default]
302 Auto,
303 Low,
305 High,
307 Original,
309}
310
311impl ImageDetail {
312 pub(crate) const fn as_str(self) -> &'static str {
313 match self {
314 Self::Auto => "auto",
315 Self::Low => "low",
316 Self::High => "high",
317 Self::Original => "original",
318 }
319 }
320}
321
322#[derive(Clone, Debug, Eq, PartialEq)]
324pub struct ImageAnalysisRequest {
325 pub image: ImageInput,
327 pub prompt: String,
329 pub detail: ImageDetail,
331}
332
333impl ImageAnalysisRequest {
334 pub fn new(image: ImageInput, prompt: impl Into<String>) -> Self {
336 Self {
337 image,
338 prompt: prompt.into(),
339 detail: ImageDetail::Auto,
340 }
341 }
342
343 pub(crate) fn validate(&self) -> Result<()> {
344 self.image.validate()?;
345 if self.prompt.trim().is_empty()
346 || self.prompt.chars().count() > MAX_IMAGE_ANALYSIS_PROMPT_CHARACTERS
347 {
348 return Err(Error::InvalidInput(format!(
349 "image-analysis prompt must contain 1 through {MAX_IMAGE_ANALYSIS_PROMPT_CHARACTERS} characters"
350 )));
351 }
352 Ok(())
353 }
354
355 pub(crate) fn payload(&self) -> Value {
356 let image_url = format!(
357 "data:{};base64,{}",
358 self.image.media_type.mime_type(),
359 STANDARD.encode(&self.image.data)
360 );
361 json!({
362 "model": GPT_5_6,
363 "store": false,
364 "input": [{
365 "role": "user",
366 "content": [
367 {
368 "type": "input_text",
369 "text": self.prompt
370 },
371 {
372 "type": "input_image",
373 "image_url": image_url,
374 "detail": self.detail.as_str()
375 }
376 ]
377 }]
378 })
379 }
380}
381
382#[derive(Clone, Debug, Eq, PartialEq)]
384pub enum ImageAnalysisStatus {
385 Completed,
387 Incomplete {
389 reason: Option<String>,
391 },
392}
393
394#[derive(Clone, Debug, Eq, PartialEq)]
396pub struct ImageAnalysisUsage {
397 pub input_tokens: u64,
399 pub output_tokens: u64,
401 pub total_tokens: u64,
403 pub cached_input_tokens: Option<u64>,
405 pub cache_write_input_tokens: Option<u64>,
407 pub reasoning_output_tokens: Option<u64>,
409}
410
411#[derive(Clone, Debug, Eq, PartialEq)]
413pub struct ImageAnalysis {
414 pub text: String,
416 pub response_id: String,
418 pub model: String,
420 pub status: ImageAnalysisStatus,
422 pub usage: Option<ImageAnalysisUsage>,
424 pub request_id: Option<String>,
426}
427
428#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
430pub enum ImageSize {
431 #[default]
433 Auto,
434 Dimensions {
436 width: u16,
438 height: u16,
440 },
441}
442
443impl ImageSize {
444 pub fn dimensions(width: u16, height: u16) -> Result<Self> {
446 let value = Self::Dimensions { width, height };
447 value.validate()?;
448 Ok(value)
449 }
450
451 pub(crate) fn as_api_value(self) -> String {
452 match self {
453 Self::Auto => "auto".into(),
454 Self::Dimensions { width, height } => format!("{width}x{height}"),
455 }
456 }
457
458 pub(crate) fn validate(self) -> Result<()> {
459 let Self::Dimensions { width, height } = self else {
460 return Ok(());
461 };
462 let pixels = u64::from(width).saturating_mul(u64::from(height));
463 let short = width.min(height);
464 let long = width.max(height);
465 if width % 16 != 0
466 || height % 16 != 0
467 || width > MAX_IMAGE_EDGE
468 || height > MAX_IMAGE_EDGE
469 || short == 0
470 || u32::from(long) > u32::from(short).saturating_mul(3)
471 || !(MIN_IMAGE_PIXELS..=MAX_IMAGE_PIXELS).contains(&pixels)
472 {
473 return Err(Error::InvalidInput(format!(
474 "GPT Image 2 dimensions must be multiples of 16, no edge may exceed {MAX_IMAGE_EDGE}, the aspect ratio must be at most 3:1, and total pixels must be between {MIN_IMAGE_PIXELS} and {MAX_IMAGE_PIXELS}"
475 )));
476 }
477 Ok(())
478 }
479}
480
481#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
483pub enum ImageQuality {
484 #[default]
486 Auto,
487 Low,
489 Medium,
491 High,
493}
494
495impl ImageQuality {
496 pub(crate) const fn as_str(self) -> &'static str {
497 match self {
498 Self::Auto => "auto",
499 Self::Low => "low",
500 Self::Medium => "medium",
501 Self::High => "high",
502 }
503 }
504
505 pub(crate) fn parse(value: &str) -> Option<Self> {
506 match value {
507 "auto" => Some(Self::Auto),
508 "low" => Some(Self::Low),
509 "medium" => Some(Self::Medium),
510 "high" => Some(Self::High),
511 _ => None,
512 }
513 }
514}
515
516#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
518pub enum ImageFormat {
519 #[default]
521 Png,
522 Jpeg,
524 WebP,
526}
527
528impl ImageFormat {
529 pub const fn mime_type(self) -> &'static str {
531 match self {
532 Self::Png => "image/png",
533 Self::Jpeg => "image/jpeg",
534 Self::WebP => "image/webp",
535 }
536 }
537
538 pub(crate) const fn as_str(self) -> &'static str {
539 match self {
540 Self::Png => "png",
541 Self::Jpeg => "jpeg",
542 Self::WebP => "webp",
543 }
544 }
545
546 pub(crate) fn parse(value: &str) -> Option<Self> {
547 match value {
548 "png" => Some(Self::Png),
549 "jpeg" => Some(Self::Jpeg),
550 "webp" => Some(Self::WebP),
551 _ => None,
552 }
553 }
554}
555
556#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
558pub enum ImageBackground {
559 #[default]
561 Auto,
562 Opaque,
564}
565
566impl ImageBackground {
567 pub(crate) const fn as_str(self) -> &'static str {
568 match self {
569 Self::Auto => "auto",
570 Self::Opaque => "opaque",
571 }
572 }
573}
574
575#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
577pub enum Moderation {
578 #[default]
580 Auto,
581 Low,
583}
584
585impl Moderation {
586 pub(crate) const fn as_str(self) -> &'static str {
587 match self {
588 Self::Auto => "auto",
589 Self::Low => "low",
590 }
591 }
592}
593
594#[derive(Clone, Debug, Eq, PartialEq)]
596pub struct ImageGenerationRequest {
597 pub prompt: String,
599 pub size: ImageSize,
601 pub quality: ImageQuality,
603 pub output_format: ImageFormat,
605 pub output_compression: Option<u8>,
607 pub background: ImageBackground,
609 pub moderation: Moderation,
611 pub user: Option<String>,
613}
614
615#[derive(Clone, Debug, Eq, PartialEq)]
617pub struct ImageEditRequest {
618 pub images: Vec<ImageInput>,
620 pub prompt: String,
622 pub size: ImageSize,
624 pub quality: ImageQuality,
626 pub output_format: ImageFormat,
628 pub output_compression: Option<u8>,
630 pub background: ImageBackground,
632 pub moderation: Moderation,
634 pub user: Option<String>,
636}
637
638impl ImageEditRequest {
639 pub fn new(image: ImageInput, prompt: impl Into<String>) -> Self {
641 Self {
642 images: vec![image],
643 prompt: prompt.into(),
644 size: ImageSize::Auto,
645 quality: ImageQuality::Auto,
646 output_format: ImageFormat::Png,
647 output_compression: None,
648 background: ImageBackground::Auto,
649 moderation: Moderation::Auto,
650 user: None,
651 }
652 }
653
654 pub(crate) fn validate(&self) -> Result<()> {
655 if self.images.is_empty() || self.images.len() > 16 {
656 return Err(Error::InvalidInput(
657 "image edit requires between 1 and 16 reference images".into(),
658 ));
659 }
660 for image in &self.images {
661 image.validate()?;
662 }
663 let generation = ImageGenerationRequest {
664 prompt: self.prompt.clone(),
665 size: self.size,
666 quality: self.quality,
667 output_format: self.output_format,
668 output_compression: self.output_compression,
669 background: self.background,
670 moderation: self.moderation,
671 user: self.user.clone(),
672 };
673 generation.validate()
674 }
675}
676
677impl ImageGenerationRequest {
678 pub fn new(prompt: impl Into<String>) -> Self {
680 Self {
681 prompt: prompt.into(),
682 size: ImageSize::Auto,
683 quality: ImageQuality::Auto,
684 output_format: ImageFormat::Png,
685 output_compression: None,
686 background: ImageBackground::Auto,
687 moderation: Moderation::Auto,
688 user: None,
689 }
690 }
691
692 pub(crate) fn validate(&self) -> Result<()> {
693 if self.prompt.trim().is_empty()
694 || self.prompt.chars().count() > MAX_IMAGE_PROMPT_CHARACTERS
695 {
696 return Err(Error::InvalidInput(format!(
697 "image prompt must contain 1 through {MAX_IMAGE_PROMPT_CHARACTERS} characters"
698 )));
699 }
700 self.size.validate()?;
701 if self.output_compression.is_some_and(|value| value > 100) {
702 return Err(Error::InvalidInput(
703 "output compression must be between 0 and 100".into(),
704 ));
705 }
706 if self.output_compression.is_some() && self.output_format == ImageFormat::Png {
707 return Err(Error::InvalidInput(
708 "output compression is supported only for JPEG and WebP images".into(),
709 ));
710 }
711 if let Some(user) = &self.user
712 && (user.trim().is_empty()
713 || user.chars().count() > 512
714 || user.chars().any(char::is_control))
715 {
716 return Err(Error::InvalidInput(
717 "image user identifier must contain 1 through 512 non-control characters when supplied".into(),
718 ));
719 }
720 Ok(())
721 }
722
723 pub(crate) fn payload(&self) -> Value {
724 let mut payload = json!({
725 "model": crate::GPT_IMAGE_2,
726 "prompt": self.prompt,
727 "n": 1,
728 "size": self.size.as_api_value(),
729 "quality": self.quality.as_str(),
730 "output_format": self.output_format.as_str(),
731 "background": self.background.as_str(),
732 "moderation": self.moderation.as_str(),
733 "stream": false
734 });
735 let object = payload.as_object_mut().expect("image payload is an object");
736 if let Some(compression) = self.output_compression {
737 object.insert("output_compression".into(), json!(compression));
738 }
739 if let Some(user) = &self.user {
740 object.insert("user".into(), json!(user));
741 }
742 payload
743 }
744}
745
746#[derive(Clone, Eq, PartialEq)]
748pub struct GeneratedImage {
749 pub data: Vec<u8>,
751 pub format: ImageFormat,
753}
754
755impl fmt::Debug for GeneratedImage {
756 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
757 f.debug_struct("GeneratedImage")
758 .field("format", &self.format)
759 .field("bytes", &self.data.len())
760 .finish()
761 }
762}
763
764#[derive(Clone, Debug, Default, Eq, PartialEq)]
766pub struct ImageTokenDetails {
767 pub text_tokens: u64,
769 pub image_tokens: u64,
771}
772
773#[derive(Clone, Debug, Eq, PartialEq)]
775pub struct ImageUsage {
776 pub input_tokens: u64,
778 pub output_tokens: u64,
780 pub total_tokens: u64,
782 pub input_details: ImageTokenDetails,
784 pub output_details: Option<ImageTokenDetails>,
786}
787
788#[derive(Clone, Debug, PartialEq)]
790pub struct ImageGeneration {
791 pub created: u64,
793 pub image: GeneratedImage,
795 pub size: Option<String>,
797 pub quality: Option<ImageQuality>,
799 pub usage: Option<ImageUsage>,
801 pub request_id: Option<String>,
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808
809 #[test]
810 fn transcription_accepts_long_prompt_unchanged_and_rejects_blank_prompt() {
811 let audio = AudioInput::new("note.webm", "audio/webm", vec![1]).unwrap();
812 let mut request = TranscriptionRequest::new(audio);
813 let prompt = "x".repeat(4 * 1024 * 1024 + 1);
814 request.prompt = Some(prompt.clone());
815 request.validate().unwrap();
816 assert_eq!(request.prompt.as_deref(), Some(prompt.as_str()));
817
818 request.prompt = Some(" \n\t".into());
819 assert!(request.validate().is_err());
820 }
821
822 #[test]
823 fn audio_debug_omits_bytes_and_rejects_unsafe_names() {
824 let audio = AudioInput::new("note.webm", "audio/webm", vec![7, 8, 9]).unwrap();
825 let debug = format!("{audio:?}");
826 assert!(debug.contains("bytes: 3"));
827 assert!(!debug.contains("7, 8, 9"));
828 assert!(AudioInput::new("../note.webm", "audio/webm", vec![1]).is_err());
829 }
830
831 #[test]
832 fn image_analysis_input_is_bounded_and_redacted() {
833 let image = ImageInput::new(ImageMediaType::Png, vec![1, 2, 3]).unwrap();
834 let debug = format!("{image:?}");
835 assert!(debug.contains("Png"));
836 assert!(debug.contains("bytes: 3"));
837 assert!(!debug.contains("1, 2, 3"));
838 assert!(ImageInput::new(ImageMediaType::Png, Vec::new()).is_err());
839 }
840
841 #[test]
842 fn image_analysis_payload_preserves_prompt_and_has_no_output_cap() {
843 let image = ImageInput::new(ImageMediaType::Jpeg, vec![1, 2, 3]).unwrap();
844 let mut request = ImageAnalysisRequest::new(image, " Explain this image. ");
845 request.detail = ImageDetail::High;
846 request.validate().unwrap();
847
848 let payload = request.payload();
849 assert_eq!(payload["model"], "gpt-5.6");
850 assert_eq!(payload["store"], false);
851 assert_eq!(
852 payload["input"][0]["content"][0]["text"],
853 " Explain this image. "
854 );
855 assert_eq!(
856 payload["input"][0]["content"][1]["image_url"],
857 "data:image/jpeg;base64,AQID"
858 );
859 assert_eq!(payload["input"][0]["content"][1]["detail"], "high");
860 assert!(payload.get("max_output_tokens").is_none());
861 assert!(payload.get("tools").is_none());
862 }
863
864 #[test]
865 fn image_dimensions_enforce_current_gpt_image_2_constraints() {
866 assert_eq!(
867 ImageSize::dimensions(2048, 2048).unwrap(),
868 ImageSize::Dimensions {
869 width: 2048,
870 height: 2048
871 }
872 );
873 assert!(ImageSize::dimensions(1000, 1000).is_err());
874 assert!(ImageSize::dimensions(3840, 3840).is_err());
875 assert!(ImageSize::dimensions(3072, 1024).is_ok());
876 assert!(ImageSize::dimensions(3088, 1024).is_err());
877 }
878
879 #[test]
880 fn png_rejects_compression_but_jpeg_accepts_it() {
881 let mut request = ImageGenerationRequest::new("draw a lighthouse");
882 request.output_compression = Some(80);
883 assert!(request.validate().is_err());
884 request.output_format = ImageFormat::Jpeg;
885 assert!(request.validate().is_ok());
886 request.output_compression = Some(101);
887 assert!(request.validate().is_err());
888 }
889
890 #[test]
891 fn image_payload_is_single_shot_gpt_image_2() {
892 let request = ImageGenerationRequest::new("draw a lighthouse");
893 let payload = request.payload();
894 assert_eq!(payload["model"], "gpt-image-2");
895 assert_eq!(payload["n"], 1);
896 assert_eq!(payload["stream"], false);
897 assert_eq!(payload["background"], "auto");
898 assert_eq!(payload["output_format"], "png");
899 }
900
901 #[test]
902 fn image_edits_require_a_bounded_ordered_reference_set() {
903 let first = ImageInput::new(ImageMediaType::Png, vec![1]).unwrap();
904 let mut request = ImageEditRequest::new(first, "make the sky darker");
905 request
906 .images
907 .push(ImageInput::new(ImageMediaType::Jpeg, vec![2]).unwrap());
908 assert!(request.validate().is_ok());
909 assert_eq!(request.images[0].media_type(), ImageMediaType::Png);
910 assert_eq!(request.images[1].media_type(), ImageMediaType::Jpeg);
911
912 request.images.clear();
913 assert!(request.validate().is_err());
914 }
915}