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
617/// One GPT Image 2 request that modifies or combines reference images.
618#[derive(Clone, Debug, Eq, PartialEq)]
619pub struct ImageEditRequest {
620    /// One or more source or reference images.
621    pub images: Vec<ImageInput>,
622    /// Text description of the requested result.
623    pub prompt: String,
624    /// Output dimensions.
625    pub size: ImageSize,
626    /// Rendering quality.
627    pub quality: ImageQuality,
628    /// Output file format.
629    pub output_format: ImageFormat,
630    /// JPEG or WebP compression from 0 through 100, when explicitly requested.
631    pub output_compression: Option<u8>,
632    /// Background behavior.
633    pub background: ImageBackground,
634    /// Provider moderation level.
635    pub moderation: Moderation,
636    /// Optional stable end-user identifier supplied to abuse monitoring.
637    pub user: Option<String>,
638}
639
640impl ImageEditRequest {
641    /// Constructs a single-reference edit with automatic size and quality.
642    pub fn new(image: ImageInput, prompt: impl Into<String>) -> Self {
643        Self {
644            images: vec![image],
645            prompt: prompt.into(),
646            size: ImageSize::Auto,
647            quality: ImageQuality::Auto,
648            output_format: ImageFormat::Png,
649            output_compression: None,
650            background: ImageBackground::Auto,
651            moderation: Moderation::Auto,
652            user: None,
653        }
654    }
655
656    pub(crate) fn validate(&self) -> Result<()> {
657        if self.images.is_empty() || self.images.len() > 16 {
658            return Err(Error::InvalidInput(
659                "image edit requires between 1 and 16 reference images".into(),
660            ));
661        }
662        for image in &self.images {
663            image.validate()?;
664        }
665        let generation = ImageGenerationRequest {
666            prompt: self.prompt.clone(),
667            size: self.size,
668            quality: self.quality,
669            output_format: self.output_format,
670            output_compression: self.output_compression,
671            background: self.background,
672            moderation: self.moderation,
673            user: self.user.clone(),
674        };
675        generation.validate()
676    }
677}
678
679impl ImageGenerationRequest {
680    /// Constructs a single-image request with automatic size and quality and PNG output.
681    pub fn new(prompt: impl Into<String>) -> Self {
682        Self {
683            prompt: prompt.into(),
684            size: ImageSize::Auto,
685            quality: ImageQuality::Auto,
686            output_format: ImageFormat::Png,
687            output_compression: None,
688            background: ImageBackground::Auto,
689            moderation: Moderation::Auto,
690            user: None,
691        }
692    }
693
694    pub(crate) fn validate(&self) -> Result<()> {
695        if self.prompt.trim().is_empty()
696            || self.prompt.chars().count() > MAX_IMAGE_PROMPT_CHARACTERS
697        {
698            return Err(Error::InvalidInput(format!(
699                "image prompt must contain 1 through {MAX_IMAGE_PROMPT_CHARACTERS} characters"
700            )));
701        }
702        self.size.validate()?;
703        if self.output_compression.is_some_and(|value| value > 100) {
704            return Err(Error::InvalidInput(
705                "output compression must be between 0 and 100".into(),
706            ));
707        }
708        if self.output_compression.is_some() && self.output_format == ImageFormat::Png {
709            return Err(Error::InvalidInput(
710                "output compression is supported only for JPEG and WebP images".into(),
711            ));
712        }
713        if let Some(user) = &self.user
714            && (user.trim().is_empty()
715                || user.chars().count() > 512
716                || user.chars().any(char::is_control))
717        {
718            return Err(Error::InvalidInput(
719                "image user identifier must contain 1 through 512 non-control characters when supplied".into(),
720            ));
721        }
722        Ok(())
723    }
724
725    pub(crate) fn payload(&self) -> Value {
726        let mut payload = json!({
727            "model": crate::GPT_IMAGE_2,
728            "prompt": self.prompt,
729            "n": 1,
730            "size": self.size.as_api_value(),
731            "quality": self.quality.as_str(),
732            "output_format": self.output_format.as_str(),
733            "background": self.background.as_str(),
734            "moderation": self.moderation.as_str(),
735            "stream": false
736        });
737        let object = payload.as_object_mut().expect("image payload is an object");
738        if let Some(compression) = self.output_compression {
739            object.insert("output_compression".into(), json!(compression));
740        }
741        if let Some(user) = &self.user {
742            object.insert("user".into(), json!(user));
743        }
744        payload
745    }
746}
747
748/// One generated image held in memory.
749#[derive(Clone, Eq, PartialEq)]
750pub struct GeneratedImage {
751    /// Decoded image bytes.
752    pub data: Vec<u8>,
753    /// Returned image file format.
754    pub format: ImageFormat,
755}
756
757impl fmt::Debug for GeneratedImage {
758    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
759        f.debug_struct("GeneratedImage")
760            .field("format", &self.format)
761            .field("bytes", &self.data.len())
762            .finish()
763    }
764}
765
766/// Text and image token detail for GPT Image usage.
767#[derive(Clone, Debug, Default, Eq, PartialEq)]
768pub struct ImageTokenDetails {
769    /// Text tokens reported for the modality side.
770    pub text_tokens: u64,
771    /// Image tokens reported for the modality side.
772    pub image_tokens: u64,
773}
774
775/// Token usage returned by GPT Image.
776#[derive(Clone, Debug, Eq, PartialEq)]
777pub struct ImageUsage {
778    /// Total input tokens.
779    pub input_tokens: u64,
780    /// Total output tokens.
781    pub output_tokens: u64,
782    /// Total input and output tokens.
783    pub total_tokens: u64,
784    /// Input text and image token breakdown.
785    pub input_details: ImageTokenDetails,
786    /// Output text and image token breakdown, when returned.
787    pub output_details: Option<ImageTokenDetails>,
788}
789
790/// Normalized single-image GPT Image 2 result.
791#[derive(Clone, Debug, PartialEq)]
792pub struct ImageGeneration {
793    /// Provider creation time as Unix seconds.
794    pub created: u64,
795    /// Decoded generated image.
796    pub image: GeneratedImage,
797    /// Actual provider-selected size, when returned.
798    pub size: Option<String>,
799    /// Actual provider-selected quality, when returned.
800    pub quality: Option<ImageQuality>,
801    /// Provider token usage, when returned.
802    pub usage: Option<ImageUsage>,
803    /// OpenAI request identifier, when returned.
804    pub request_id: Option<String>,
805}
806
807#[cfg(test)]
808mod tests {
809    use super::*;
810
811    #[test]
812    fn audio_debug_omits_bytes_and_rejects_unsafe_names() {
813        let audio = AudioInput::new("note.webm", "audio/webm", vec![7, 8, 9]).unwrap();
814        let debug = format!("{audio:?}");
815        assert!(debug.contains("bytes: 3"));
816        assert!(!debug.contains("7, 8, 9"));
817        assert!(AudioInput::new("../note.webm", "audio/webm", vec![1]).is_err());
818    }
819
820    #[test]
821    fn image_analysis_input_is_bounded_and_redacted() {
822        let image = ImageInput::new(ImageMediaType::Png, vec![1, 2, 3]).unwrap();
823        let debug = format!("{image:?}");
824        assert!(debug.contains("Png"));
825        assert!(debug.contains("bytes: 3"));
826        assert!(!debug.contains("1, 2, 3"));
827        assert!(ImageInput::new(ImageMediaType::Png, Vec::new()).is_err());
828    }
829
830    #[test]
831    fn image_analysis_payload_preserves_prompt_and_has_no_output_cap() {
832        let image = ImageInput::new(ImageMediaType::Jpeg, vec![1, 2, 3]).unwrap();
833        let mut request = ImageAnalysisRequest::new(image, "  Explain this image.  ");
834        request.detail = ImageDetail::High;
835        request.validate().unwrap();
836
837        let payload = request.payload();
838        assert_eq!(payload["model"], "gpt-5.6");
839        assert_eq!(payload["store"], false);
840        assert_eq!(
841            payload["input"][0]["content"][0]["text"],
842            "  Explain this image.  "
843        );
844        assert_eq!(
845            payload["input"][0]["content"][1]["image_url"],
846            "data:image/jpeg;base64,AQID"
847        );
848        assert_eq!(payload["input"][0]["content"][1]["detail"], "high");
849        assert!(payload.get("max_output_tokens").is_none());
850        assert!(payload.get("tools").is_none());
851    }
852
853    #[test]
854    fn image_dimensions_enforce_current_gpt_image_2_constraints() {
855        assert_eq!(
856            ImageSize::dimensions(2048, 2048).unwrap(),
857            ImageSize::Dimensions {
858                width: 2048,
859                height: 2048
860            }
861        );
862        assert!(ImageSize::dimensions(1000, 1000).is_err());
863        assert!(ImageSize::dimensions(3840, 3840).is_err());
864        assert!(ImageSize::dimensions(3072, 1024).is_ok());
865        assert!(ImageSize::dimensions(3088, 1024).is_err());
866    }
867
868    #[test]
869    fn png_rejects_compression_but_jpeg_accepts_it() {
870        let mut request = ImageGenerationRequest::new("draw a lighthouse");
871        request.output_compression = Some(80);
872        assert!(request.validate().is_err());
873        request.output_format = ImageFormat::Jpeg;
874        assert!(request.validate().is_ok());
875        request.output_compression = Some(101);
876        assert!(request.validate().is_err());
877    }
878
879    #[test]
880    fn image_payload_is_single_shot_gpt_image_2() {
881        let request = ImageGenerationRequest::new("draw a lighthouse");
882        let payload = request.payload();
883        assert_eq!(payload["model"], "gpt-image-2");
884        assert_eq!(payload["n"], 1);
885        assert_eq!(payload["stream"], false);
886        assert_eq!(payload["background"], "auto");
887        assert_eq!(payload["output_format"], "png");
888    }
889
890    #[test]
891    fn image_edits_require_a_bounded_ordered_reference_set() {
892        let first = ImageInput::new(ImageMediaType::Png, vec![1]).unwrap();
893        let mut request = ImageEditRequest::new(first, "make the sky darker");
894        request
895            .images
896            .push(ImageInput::new(ImageMediaType::Jpeg, vec![2]).unwrap());
897        assert!(request.validate().is_ok());
898        assert_eq!(request.images[0].media_type(), ImageMediaType::Png);
899        assert_eq!(request.images[1].media_type(), ImageMediaType::Jpeg);
900
901        request.images.clear();
902        assert!(request.validate().is_err());
903    }
904}