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_TRANSCRIPTION_PROMPT_CHARACTERS: usize = 32_000;
11const MAX_IMAGE_ANALYSIS_BYTES: usize = 20 * 1024 * 1024;
12const MAX_IMAGE_ANALYSIS_PROMPT_CHARACTERS: usize = 32_000;
13const MAX_IMAGE_PROMPT_CHARACTERS: usize = 32_000;
14const MIN_IMAGE_PIXELS: u64 = 655_360;
15const MAX_IMAGE_PIXELS: u64 = 8_294_400;
16const MAX_IMAGE_EDGE: u16 = 3_840;
17
18#[derive(Clone, Eq, PartialEq)]
20pub struct AudioInput {
21 file_name: String,
22 mime_type: String,
23 data: Vec<u8>,
24}
25
26impl AudioInput {
27 pub fn new(
29 file_name: impl Into<String>,
30 mime_type: impl Into<String>,
31 data: Vec<u8>,
32 ) -> Result<Self> {
33 let value = Self {
34 file_name: file_name.into(),
35 mime_type: mime_type.into().to_ascii_lowercase(),
36 data,
37 };
38 value.validate()?;
39 Ok(value)
40 }
41
42 pub fn file_name(&self) -> &str {
44 &self.file_name
45 }
46
47 pub fn mime_type(&self) -> &str {
49 &self.mime_type
50 }
51
52 pub fn data(&self) -> &[u8] {
54 &self.data
55 }
56
57 pub fn len(&self) -> usize {
59 self.data.len()
60 }
61
62 pub fn is_empty(&self) -> bool {
64 self.data.is_empty()
65 }
66
67 pub(crate) fn into_parts(self) -> (String, String, Vec<u8>) {
68 (self.file_name, self.mime_type, self.data)
69 }
70
71 fn validate(&self) -> Result<()> {
72 if self.file_name.is_empty()
73 || self.file_name.chars().count() > MAX_AUDIO_FILENAME_CHARACTERS
74 || !self.file_name.chars().all(|character| {
75 character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_')
76 })
77 || matches!(self.file_name.as_str(), "." | "..")
78 {
79 return Err(Error::InvalidInput(format!(
80 "audio filename must contain 1 through {MAX_AUDIO_FILENAME_CHARACTERS} ASCII letters, digits, dots, hyphens, or underscores"
81 )));
82 }
83 if !matches!(
84 self.mime_type.as_str(),
85 "audio/flac"
86 | "audio/x-flac"
87 | "audio/m4a"
88 | "audio/mp3"
89 | "audio/mp4"
90 | "audio/mpeg"
91 | "audio/mpga"
92 | "audio/ogg"
93 | "audio/opus"
94 | "audio/wav"
95 | "audio/x-wav"
96 | "audio/webm"
97 | "application/ogg"
98 | "video/mp4"
99 | "video/webm"
100 ) {
101 return Err(Error::InvalidInput(
102 "audio MIME type must describe a supported FLAC, MP3, MP4, M4A, OGG, WAV, or WebM recording".into(),
103 ));
104 }
105 let extension = self
106 .file_name
107 .rsplit_once('.')
108 .map(|(_, extension)| extension.to_ascii_lowercase());
109 if !matches!(
110 extension.as_deref(),
111 Some("flac" | "mp3" | "mp4" | "mpeg" | "mpga" | "m4a" | "ogg" | "wav" | "webm")
112 ) {
113 return Err(Error::InvalidInput(
114 "audio filename must use a supported flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, or webm extension".into(),
115 ));
116 }
117 if self.data.is_empty() || self.data.len() > MAX_AUDIO_BYTES {
118 return Err(Error::InvalidInput(format!(
119 "audio must contain between 1 and {MAX_AUDIO_BYTES} bytes"
120 )));
121 }
122 Ok(())
123 }
124}
125
126impl fmt::Debug for AudioInput {
127 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128 f.debug_struct("AudioInput")
129 .field("file_name", &self.file_name)
130 .field("mime_type", &self.mime_type)
131 .field("bytes", &self.data.len())
132 .finish()
133 }
134}
135
136#[derive(Clone, Debug, Eq, PartialEq)]
138pub struct TranscriptionRequest {
139 pub audio: AudioInput,
141 pub prompt: Option<String>,
143 pub language: Option<String>,
145}
146
147impl TranscriptionRequest {
148 pub fn new(audio: AudioInput) -> Self {
150 Self {
151 audio,
152 prompt: Some(DEFAULT_TRANSCRIPTION_PROMPT.into()),
153 language: None,
154 }
155 }
156
157 pub(crate) fn validate(&self) -> Result<()> {
158 self.audio.validate()?;
159 if let Some(prompt) = &self.prompt
160 && (prompt.trim().is_empty()
161 || prompt.chars().count() > MAX_TRANSCRIPTION_PROMPT_CHARACTERS)
162 {
163 return Err(Error::InvalidInput(format!(
164 "transcription prompt must contain 1 through {MAX_TRANSCRIPTION_PROMPT_CHARACTERS} characters when supplied"
165 )));
166 }
167 if let Some(language) = &self.language
168 && (language.len() != 2 || !language.bytes().all(|value| value.is_ascii_lowercase()))
169 {
170 return Err(Error::InvalidInput(
171 "transcription language must be a two-letter lowercase ISO-639-1 code".into(),
172 ));
173 }
174 Ok(())
175 }
176}
177
178#[derive(Clone, Debug, Default, Eq, PartialEq)]
180pub struct TranscriptionTokenDetails {
181 pub audio_tokens: Option<u64>,
183 pub text_tokens: Option<u64>,
185}
186
187#[derive(Clone, Debug, Eq, PartialEq)]
189pub struct TranscriptionTokenUsage {
190 pub input_tokens: u64,
192 pub output_tokens: u64,
194 pub total_tokens: u64,
196 pub input_details: Option<TranscriptionTokenDetails>,
198}
199
200#[derive(Clone, Debug, PartialEq)]
202pub enum TranscriptionUsage {
203 Tokens(TranscriptionTokenUsage),
205 DurationSeconds(f64),
207}
208
209#[derive(Clone, Debug, PartialEq)]
211pub struct Transcription {
212 pub text: String,
214 pub usage: Option<TranscriptionUsage>,
216 pub request_id: Option<String>,
218}
219
220#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
222pub enum ImageMediaType {
223 Png,
225 Jpeg,
227 WebP,
229 Gif,
231}
232
233impl ImageMediaType {
234 pub const fn mime_type(self) -> &'static str {
236 match self {
237 Self::Png => "image/png",
238 Self::Jpeg => "image/jpeg",
239 Self::WebP => "image/webp",
240 Self::Gif => "image/gif",
241 }
242 }
243}
244
245#[derive(Clone, Eq, PartialEq)]
247pub struct ImageInput {
248 media_type: ImageMediaType,
249 data: Vec<u8>,
250}
251
252impl ImageInput {
253 pub fn new(media_type: ImageMediaType, data: Vec<u8>) -> Result<Self> {
255 let value = Self { media_type, data };
256 value.validate()?;
257 Ok(value)
258 }
259
260 pub const fn media_type(&self) -> ImageMediaType {
262 self.media_type
263 }
264
265 pub fn data(&self) -> &[u8] {
267 &self.data
268 }
269
270 pub fn len(&self) -> usize {
272 self.data.len()
273 }
274
275 pub fn is_empty(&self) -> bool {
277 self.data.is_empty()
278 }
279
280 fn validate(&self) -> Result<()> {
281 if self.data.is_empty() || self.data.len() > MAX_IMAGE_ANALYSIS_BYTES {
282 return Err(Error::InvalidInput(format!(
283 "analysis image must contain between 1 and {MAX_IMAGE_ANALYSIS_BYTES} bytes"
284 )));
285 }
286 Ok(())
287 }
288}
289
290impl fmt::Debug for ImageInput {
291 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292 f.debug_struct("ImageInput")
293 .field("media_type", &self.media_type)
294 .field("bytes", &self.data.len())
295 .finish()
296 }
297}
298
299#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
301pub enum ImageDetail {
302 #[default]
304 Auto,
305 Low,
307 High,
309 Original,
311}
312
313impl ImageDetail {
314 pub(crate) const fn as_str(self) -> &'static str {
315 match self {
316 Self::Auto => "auto",
317 Self::Low => "low",
318 Self::High => "high",
319 Self::Original => "original",
320 }
321 }
322}
323
324#[derive(Clone, Debug, Eq, PartialEq)]
326pub struct ImageAnalysisRequest {
327 pub image: ImageInput,
329 pub prompt: String,
331 pub detail: ImageDetail,
333}
334
335impl ImageAnalysisRequest {
336 pub fn new(image: ImageInput, prompt: impl Into<String>) -> Self {
338 Self {
339 image,
340 prompt: prompt.into(),
341 detail: ImageDetail::Auto,
342 }
343 }
344
345 pub(crate) fn validate(&self) -> Result<()> {
346 self.image.validate()?;
347 if self.prompt.trim().is_empty()
348 || self.prompt.chars().count() > MAX_IMAGE_ANALYSIS_PROMPT_CHARACTERS
349 {
350 return Err(Error::InvalidInput(format!(
351 "image-analysis prompt must contain 1 through {MAX_IMAGE_ANALYSIS_PROMPT_CHARACTERS} characters"
352 )));
353 }
354 Ok(())
355 }
356
357 pub(crate) fn payload(&self) -> Value {
358 let image_url = format!(
359 "data:{};base64,{}",
360 self.image.media_type.mime_type(),
361 STANDARD.encode(&self.image.data)
362 );
363 json!({
364 "model": GPT_5_6,
365 "store": false,
366 "input": [{
367 "role": "user",
368 "content": [
369 {
370 "type": "input_text",
371 "text": self.prompt
372 },
373 {
374 "type": "input_image",
375 "image_url": image_url,
376 "detail": self.detail.as_str()
377 }
378 ]
379 }]
380 })
381 }
382}
383
384#[derive(Clone, Debug, Eq, PartialEq)]
386pub enum ImageAnalysisStatus {
387 Completed,
389 Incomplete {
391 reason: Option<String>,
393 },
394}
395
396#[derive(Clone, Debug, Eq, PartialEq)]
398pub struct ImageAnalysisUsage {
399 pub input_tokens: u64,
401 pub output_tokens: u64,
403 pub total_tokens: u64,
405 pub cached_input_tokens: Option<u64>,
407 pub cache_write_input_tokens: Option<u64>,
409 pub reasoning_output_tokens: Option<u64>,
411}
412
413#[derive(Clone, Debug, Eq, PartialEq)]
415pub struct ImageAnalysis {
416 pub text: String,
418 pub response_id: String,
420 pub model: String,
422 pub status: ImageAnalysisStatus,
424 pub usage: Option<ImageAnalysisUsage>,
426 pub request_id: Option<String>,
428}
429
430#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
432pub enum ImageSize {
433 #[default]
435 Auto,
436 Dimensions {
438 width: u16,
440 height: u16,
442 },
443}
444
445impl ImageSize {
446 pub fn dimensions(width: u16, height: u16) -> Result<Self> {
448 let value = Self::Dimensions { width, height };
449 value.validate()?;
450 Ok(value)
451 }
452
453 pub(crate) fn as_api_value(self) -> String {
454 match self {
455 Self::Auto => "auto".into(),
456 Self::Dimensions { width, height } => format!("{width}x{height}"),
457 }
458 }
459
460 pub(crate) fn validate(self) -> Result<()> {
461 let Self::Dimensions { width, height } = self else {
462 return Ok(());
463 };
464 let pixels = u64::from(width).saturating_mul(u64::from(height));
465 let short = width.min(height);
466 let long = width.max(height);
467 if width % 16 != 0
468 || height % 16 != 0
469 || width > MAX_IMAGE_EDGE
470 || height > MAX_IMAGE_EDGE
471 || short == 0
472 || u32::from(long) > u32::from(short).saturating_mul(3)
473 || !(MIN_IMAGE_PIXELS..=MAX_IMAGE_PIXELS).contains(&pixels)
474 {
475 return Err(Error::InvalidInput(format!(
476 "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}"
477 )));
478 }
479 Ok(())
480 }
481}
482
483#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
485pub enum ImageQuality {
486 #[default]
488 Auto,
489 Low,
491 Medium,
493 High,
495}
496
497impl ImageQuality {
498 pub(crate) const fn as_str(self) -> &'static str {
499 match self {
500 Self::Auto => "auto",
501 Self::Low => "low",
502 Self::Medium => "medium",
503 Self::High => "high",
504 }
505 }
506
507 pub(crate) fn parse(value: &str) -> Option<Self> {
508 match value {
509 "auto" => Some(Self::Auto),
510 "low" => Some(Self::Low),
511 "medium" => Some(Self::Medium),
512 "high" => Some(Self::High),
513 _ => None,
514 }
515 }
516}
517
518#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
520pub enum ImageFormat {
521 #[default]
523 Png,
524 Jpeg,
526 WebP,
528}
529
530impl ImageFormat {
531 pub const fn mime_type(self) -> &'static str {
533 match self {
534 Self::Png => "image/png",
535 Self::Jpeg => "image/jpeg",
536 Self::WebP => "image/webp",
537 }
538 }
539
540 pub(crate) const fn as_str(self) -> &'static str {
541 match self {
542 Self::Png => "png",
543 Self::Jpeg => "jpeg",
544 Self::WebP => "webp",
545 }
546 }
547
548 pub(crate) fn parse(value: &str) -> Option<Self> {
549 match value {
550 "png" => Some(Self::Png),
551 "jpeg" => Some(Self::Jpeg),
552 "webp" => Some(Self::WebP),
553 _ => None,
554 }
555 }
556}
557
558#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
560pub enum ImageBackground {
561 #[default]
563 Auto,
564 Opaque,
566}
567
568impl ImageBackground {
569 pub(crate) const fn as_str(self) -> &'static str {
570 match self {
571 Self::Auto => "auto",
572 Self::Opaque => "opaque",
573 }
574 }
575}
576
577#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
579pub enum Moderation {
580 #[default]
582 Auto,
583 Low,
585}
586
587impl Moderation {
588 pub(crate) const fn as_str(self) -> &'static str {
589 match self {
590 Self::Auto => "auto",
591 Self::Low => "low",
592 }
593 }
594}
595
596#[derive(Clone, Debug, Eq, PartialEq)]
598pub struct ImageGenerationRequest {
599 pub prompt: String,
601 pub size: ImageSize,
603 pub quality: ImageQuality,
605 pub output_format: ImageFormat,
607 pub output_compression: Option<u8>,
609 pub background: ImageBackground,
611 pub moderation: Moderation,
613 pub user: Option<String>,
615}
616
617impl ImageGenerationRequest {
618 pub fn new(prompt: impl Into<String>) -> Self {
620 Self {
621 prompt: prompt.into(),
622 size: ImageSize::Auto,
623 quality: ImageQuality::Auto,
624 output_format: ImageFormat::Png,
625 output_compression: None,
626 background: ImageBackground::Auto,
627 moderation: Moderation::Auto,
628 user: None,
629 }
630 }
631
632 pub(crate) fn validate(&self) -> Result<()> {
633 if self.prompt.trim().is_empty()
634 || self.prompt.chars().count() > MAX_IMAGE_PROMPT_CHARACTERS
635 {
636 return Err(Error::InvalidInput(format!(
637 "image prompt must contain 1 through {MAX_IMAGE_PROMPT_CHARACTERS} characters"
638 )));
639 }
640 self.size.validate()?;
641 if self.output_compression.is_some_and(|value| value > 100) {
642 return Err(Error::InvalidInput(
643 "output compression must be between 0 and 100".into(),
644 ));
645 }
646 if self.output_compression.is_some() && self.output_format == ImageFormat::Png {
647 return Err(Error::InvalidInput(
648 "output compression is supported only for JPEG and WebP images".into(),
649 ));
650 }
651 if let Some(user) = &self.user
652 && (user.trim().is_empty()
653 || user.chars().count() > 512
654 || user.chars().any(char::is_control))
655 {
656 return Err(Error::InvalidInput(
657 "image user identifier must contain 1 through 512 non-control characters when supplied".into(),
658 ));
659 }
660 Ok(())
661 }
662
663 pub(crate) fn payload(&self) -> Value {
664 let mut payload = json!({
665 "model": crate::GPT_IMAGE_2,
666 "prompt": self.prompt,
667 "n": 1,
668 "size": self.size.as_api_value(),
669 "quality": self.quality.as_str(),
670 "output_format": self.output_format.as_str(),
671 "background": self.background.as_str(),
672 "moderation": self.moderation.as_str(),
673 "stream": false
674 });
675 let object = payload.as_object_mut().expect("image payload is an object");
676 if let Some(compression) = self.output_compression {
677 object.insert("output_compression".into(), json!(compression));
678 }
679 if let Some(user) = &self.user {
680 object.insert("user".into(), json!(user));
681 }
682 payload
683 }
684}
685
686#[derive(Clone, Eq, PartialEq)]
688pub struct GeneratedImage {
689 pub data: Vec<u8>,
691 pub format: ImageFormat,
693}
694
695impl fmt::Debug for GeneratedImage {
696 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
697 f.debug_struct("GeneratedImage")
698 .field("format", &self.format)
699 .field("bytes", &self.data.len())
700 .finish()
701 }
702}
703
704#[derive(Clone, Debug, Default, Eq, PartialEq)]
706pub struct ImageTokenDetails {
707 pub text_tokens: u64,
709 pub image_tokens: u64,
711}
712
713#[derive(Clone, Debug, Eq, PartialEq)]
715pub struct ImageUsage {
716 pub input_tokens: u64,
718 pub output_tokens: u64,
720 pub total_tokens: u64,
722 pub input_details: ImageTokenDetails,
724 pub output_details: Option<ImageTokenDetails>,
726}
727
728#[derive(Clone, Debug, PartialEq)]
730pub struct ImageGeneration {
731 pub created: u64,
733 pub image: GeneratedImage,
735 pub size: Option<String>,
737 pub quality: Option<ImageQuality>,
739 pub usage: Option<ImageUsage>,
741 pub request_id: Option<String>,
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748
749 #[test]
750 fn audio_debug_omits_bytes_and_rejects_unsafe_names() {
751 let audio = AudioInput::new("note.webm", "audio/webm", vec![7, 8, 9]).unwrap();
752 let debug = format!("{audio:?}");
753 assert!(debug.contains("bytes: 3"));
754 assert!(!debug.contains("7, 8, 9"));
755 assert!(AudioInput::new("../note.webm", "audio/webm", vec![1]).is_err());
756 }
757
758 #[test]
759 fn image_analysis_input_is_bounded_and_redacted() {
760 let image = ImageInput::new(ImageMediaType::Png, vec![1, 2, 3]).unwrap();
761 let debug = format!("{image:?}");
762 assert!(debug.contains("Png"));
763 assert!(debug.contains("bytes: 3"));
764 assert!(!debug.contains("1, 2, 3"));
765 assert!(ImageInput::new(ImageMediaType::Png, Vec::new()).is_err());
766 }
767
768 #[test]
769 fn image_analysis_payload_preserves_prompt_and_has_no_output_cap() {
770 let image = ImageInput::new(ImageMediaType::Jpeg, vec![1, 2, 3]).unwrap();
771 let mut request = ImageAnalysisRequest::new(image, " Explain this image. ");
772 request.detail = ImageDetail::High;
773 request.validate().unwrap();
774
775 let payload = request.payload();
776 assert_eq!(payload["model"], "gpt-5.6");
777 assert_eq!(payload["store"], false);
778 assert_eq!(
779 payload["input"][0]["content"][0]["text"],
780 " Explain this image. "
781 );
782 assert_eq!(
783 payload["input"][0]["content"][1]["image_url"],
784 "data:image/jpeg;base64,AQID"
785 );
786 assert_eq!(payload["input"][0]["content"][1]["detail"], "high");
787 assert!(payload.get("max_output_tokens").is_none());
788 assert!(payload.get("tools").is_none());
789 }
790
791 #[test]
792 fn image_dimensions_enforce_current_gpt_image_2_constraints() {
793 assert_eq!(
794 ImageSize::dimensions(2048, 2048).unwrap(),
795 ImageSize::Dimensions {
796 width: 2048,
797 height: 2048
798 }
799 );
800 assert!(ImageSize::dimensions(1000, 1000).is_err());
801 assert!(ImageSize::dimensions(3840, 3840).is_err());
802 assert!(ImageSize::dimensions(3072, 1024).is_ok());
803 assert!(ImageSize::dimensions(3088, 1024).is_err());
804 }
805
806 #[test]
807 fn png_rejects_compression_but_jpeg_accepts_it() {
808 let mut request = ImageGenerationRequest::new("draw a lighthouse");
809 request.output_compression = Some(80);
810 assert!(request.validate().is_err());
811 request.output_format = ImageFormat::Jpeg;
812 assert!(request.validate().is_ok());
813 request.output_compression = Some(101);
814 assert!(request.validate().is_err());
815 }
816
817 #[test]
818 fn image_payload_is_single_shot_gpt_image_2() {
819 let request = ImageGenerationRequest::new("draw a lighthouse");
820 let payload = request.payload();
821 assert_eq!(payload["model"], "gpt-image-2");
822 assert_eq!(payload["n"], 1);
823 assert_eq!(payload["stream"], false);
824 assert_eq!(payload["background"], "auto");
825 assert_eq!(payload["output_format"], "png");
826 }
827}