Skip to main content

kcode_openai_api/
model.rs

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/// One in-memory audio file accepted by `gpt-4o-transcribe`.
19#[derive(Clone, Eq, PartialEq)]
20pub struct AudioInput {
21    file_name: String,
22    mime_type: String,
23    data: Vec<u8>,
24}
25
26impl AudioInput {
27    /// Constructs and validates an audio input.
28    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    /// Returns the validated upload filename.
43    pub fn file_name(&self) -> &str {
44        &self.file_name
45    }
46
47    /// Returns the validated audio MIME type.
48    pub fn mime_type(&self) -> &str {
49        &self.mime_type
50    }
51
52    /// Returns the raw audio bytes.
53    pub fn data(&self) -> &[u8] {
54        &self.data
55    }
56
57    /// Returns the raw audio byte count.
58    pub fn len(&self) -> usize {
59        self.data.len()
60    }
61
62    /// Returns whether the audio input contains no bytes.
63    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/// One audio transcription request.
137#[derive(Clone, Debug, Eq, PartialEq)]
138pub struct TranscriptionRequest {
139    /// Audio file to transcribe.
140    pub audio: AudioInput,
141    /// Optional text that guides transcription style and context.
142    pub prompt: Option<String>,
143    /// Optional ISO-639-1 input language code, such as `en`.
144    pub language: Option<String>,
145}
146
147impl TranscriptionRequest {
148    /// Constructs a request with Kennedy's current faithful-transcription prompt.
149    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/// Modality detail for token-billed audio transcription input.
179#[derive(Clone, Debug, Default, Eq, PartialEq)]
180pub struct TranscriptionTokenDetails {
181    /// Audio tokens billed for the request, when reported.
182    pub audio_tokens: Option<u64>,
183    /// Prompt text tokens billed for the request, when reported.
184    pub text_tokens: Option<u64>,
185}
186
187/// Token-billed transcription usage.
188#[derive(Clone, Debug, Eq, PartialEq)]
189pub struct TranscriptionTokenUsage {
190    /// Input tokens billed for the request.
191    pub input_tokens: u64,
192    /// Output tokens generated by the request.
193    pub output_tokens: u64,
194    /// Total input and output tokens.
195    pub total_tokens: u64,
196    /// Optional input modality breakdown.
197    pub input_details: Option<TranscriptionTokenDetails>,
198}
199
200/// Usage returned for a transcription request.
201#[derive(Clone, Debug, PartialEq)]
202pub enum TranscriptionUsage {
203    /// Usage billed in tokens.
204    Tokens(TranscriptionTokenUsage),
205    /// Usage billed from audio duration in seconds.
206    DurationSeconds(f64),
207}
208
209/// Normalized `gpt-4o-transcribe` result.
210#[derive(Clone, Debug, PartialEq)]
211pub struct Transcription {
212    /// Complete non-empty transcript text.
213    pub text: String,
214    /// Provider usage, when returned.
215    pub usage: Option<TranscriptionUsage>,
216    /// OpenAI request identifier, when returned.
217    pub request_id: Option<String>,
218}
219
220/// Stored image media accepted for image analysis.
221#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
222pub enum ImageMediaType {
223    /// PNG image data.
224    Png,
225    /// JPEG image data.
226    Jpeg,
227    /// WebP image data.
228    WebP,
229    /// GIF image data. OpenAI accepts non-animated GIF input.
230    Gif,
231}
232
233impl ImageMediaType {
234    /// Returns the MIME type sent to OpenAI.
235    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/// One caller-owned in-memory image accepted for image analysis.
246#[derive(Clone, Eq, PartialEq)]
247pub struct ImageInput {
248    media_type: ImageMediaType,
249    data: Vec<u8>,
250}
251
252impl ImageInput {
253    /// Constructs and validates an image input.
254    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    /// Returns the declared stored-image media type.
261    pub const fn media_type(&self) -> ImageMediaType {
262        self.media_type
263    }
264
265    /// Returns the raw image bytes.
266    pub fn data(&self) -> &[u8] {
267        &self.data
268    }
269
270    /// Returns the raw image byte count.
271    pub fn len(&self) -> usize {
272        self.data.len()
273    }
274
275    /// Returns whether the image contains no bytes.
276    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/// OpenAI image-understanding detail level.
300#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
301pub enum ImageDetail {
302    /// Let the fixed model select the detail level.
303    #[default]
304    Auto,
305    /// Use low-detail image understanding.
306    Low,
307    /// Use high-detail image understanding.
308    High,
309    /// Use original-resolution image understanding where supported.
310    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/// One prompt-driven image-analysis request.
325#[derive(Clone, Debug, Eq, PartialEq)]
326pub struct ImageAnalysisRequest {
327    /// Caller-owned image bytes and declared media type.
328    pub image: ImageInput,
329    /// Exact prompt paired with the image.
330    pub prompt: String,
331    /// Provider image-understanding detail level.
332    pub detail: ImageDetail,
333}
334
335impl ImageAnalysisRequest {
336    /// Constructs a request using automatic image detail.
337    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/// Completion state of a non-streaming image-analysis response.
385#[derive(Clone, Debug, Eq, PartialEq)]
386pub enum ImageAnalysisStatus {
387    /// OpenAI completed the response.
388    Completed,
389    /// OpenAI returned a valid partial response.
390    Incomplete {
391        /// Provider reason for incompleteness, when returned.
392        reason: Option<String>,
393    },
394}
395
396/// Documented Responses API token usage retained for image analysis.
397#[derive(Clone, Debug, Eq, PartialEq)]
398pub struct ImageAnalysisUsage {
399    /// Total input tokens.
400    pub input_tokens: u64,
401    /// Total output tokens.
402    pub output_tokens: u64,
403    /// Total input and output tokens.
404    pub total_tokens: u64,
405    /// Cached input tokens, when reported.
406    pub cached_input_tokens: Option<u64>,
407    /// Cache-write input tokens, when reported.
408    pub cache_write_input_tokens: Option<u64>,
409    /// Reasoning output tokens, when reported.
410    pub reasoning_output_tokens: Option<u64>,
411}
412
413/// Normalized prompt-driven image-analysis result.
414#[derive(Clone, Debug, Eq, PartialEq)]
415pub struct ImageAnalysis {
416    /// Ordered non-empty assistant text.
417    pub text: String,
418    /// OpenAI response identifier.
419    pub response_id: String,
420    /// Model identifier returned by OpenAI.
421    pub model: String,
422    /// Completion or partial-result status.
423    pub status: ImageAnalysisStatus,
424    /// Provider token usage, when returned.
425    pub usage: Option<ImageAnalysisUsage>,
426    /// OpenAI request identifier, when returned.
427    pub request_id: Option<String>,
428}
429
430/// Generated image dimensions.
431#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
432pub enum ImageSize {
433    /// Let GPT Image choose the dimensions.
434    #[default]
435    Auto,
436    /// Explicit width and height in pixels.
437    Dimensions {
438        /// Width in pixels.
439        width: u16,
440        /// Height in pixels.
441        height: u16,
442    },
443}
444
445impl ImageSize {
446    /// Constructs explicit dimensions after validating GPT Image 2 constraints.
447    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/// GPT Image rendering quality.
484#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
485pub enum ImageQuality {
486    /// Let GPT Image select quality.
487    #[default]
488    Auto,
489    /// Fast draft quality.
490    Low,
491    /// Balanced quality.
492    Medium,
493    /// Highest supported quality.
494    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/// GPT Image output file format.
519#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
520pub enum ImageFormat {
521    /// PNG output.
522    #[default]
523    Png,
524    /// JPEG output.
525    Jpeg,
526    /// WebP output.
527    WebP,
528}
529
530impl ImageFormat {
531    /// Returns the output MIME type.
532    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/// Background selection supported by GPT Image 2.
559#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
560pub enum ImageBackground {
561    /// Let GPT Image select the background.
562    #[default]
563    Auto,
564    /// Require an opaque background.
565    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/// Provider content-moderation level.
578#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
579pub enum Moderation {
580    /// Standard provider moderation.
581    #[default]
582    Auto,
583    /// Less restrictive provider moderation.
584    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/// One GPT Image 2 text-to-image request.
597#[derive(Clone, Debug, Eq, PartialEq)]
598pub struct ImageGenerationRequest {
599    /// Text description of the desired image.
600    pub prompt: String,
601    /// Output dimensions.
602    pub size: ImageSize,
603    /// Rendering quality.
604    pub quality: ImageQuality,
605    /// Output file format.
606    pub output_format: ImageFormat,
607    /// JPEG or WebP compression from 0 through 100, when explicitly requested.
608    pub output_compression: Option<u8>,
609    /// Background behavior. GPT Image 2 does not support transparency.
610    pub background: ImageBackground,
611    /// Provider moderation level.
612    pub moderation: Moderation,
613    /// Optional stable end-user identifier supplied to OpenAI abuse monitoring.
614    pub user: Option<String>,
615}
616
617impl ImageGenerationRequest {
618    /// Constructs a single-image request with automatic size and quality and PNG output.
619    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/// One generated image held in memory.
687#[derive(Clone, Eq, PartialEq)]
688pub struct GeneratedImage {
689    /// Decoded image bytes.
690    pub data: Vec<u8>,
691    /// Returned image file format.
692    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/// Text and image token detail for GPT Image usage.
705#[derive(Clone, Debug, Default, Eq, PartialEq)]
706pub struct ImageTokenDetails {
707    /// Text tokens reported for the modality side.
708    pub text_tokens: u64,
709    /// Image tokens reported for the modality side.
710    pub image_tokens: u64,
711}
712
713/// Token usage returned by GPT Image.
714#[derive(Clone, Debug, Eq, PartialEq)]
715pub struct ImageUsage {
716    /// Total input tokens.
717    pub input_tokens: u64,
718    /// Total output tokens.
719    pub output_tokens: u64,
720    /// Total input and output tokens.
721    pub total_tokens: u64,
722    /// Input text and image token breakdown.
723    pub input_details: ImageTokenDetails,
724    /// Output text and image token breakdown, when returned.
725    pub output_details: Option<ImageTokenDetails>,
726}
727
728/// Normalized single-image GPT Image 2 result.
729#[derive(Clone, Debug, PartialEq)]
730pub struct ImageGeneration {
731    /// Provider creation time as Unix seconds.
732    pub created: u64,
733    /// Decoded generated image.
734    pub image: GeneratedImage,
735    /// Actual provider-selected size, when returned.
736    pub size: Option<String>,
737    /// Actual provider-selected quality, when returned.
738    pub quality: Option<ImageQuality>,
739    /// Provider token usage, when returned.
740    pub usage: Option<ImageUsage>,
741    /// OpenAI request identifier, when returned.
742    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}