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_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/// One in-memory audio file accepted by `gpt-4o-transcribe`.
18#[derive(Clone, Eq, PartialEq)]
19pub struct AudioInput {
20    file_name: String,
21    mime_type: String,
22    data: Vec<u8>,
23}
24
25impl AudioInput {
26    /// Constructs and validates an audio input.
27    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    /// Returns the validated upload filename.
42    pub fn file_name(&self) -> &str {
43        &self.file_name
44    }
45
46    /// Returns the validated audio MIME type.
47    pub fn mime_type(&self) -> &str {
48        &self.mime_type
49    }
50
51    /// Returns the raw audio bytes.
52    pub fn data(&self) -> &[u8] {
53        &self.data
54    }
55
56    /// Returns the raw audio byte count.
57    pub fn len(&self) -> usize {
58        self.data.len()
59    }
60
61    /// Returns whether the audio input contains no bytes.
62    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/// One audio transcription request.
136#[derive(Clone, Debug, Eq, PartialEq)]
137pub struct TranscriptionRequest {
138    /// Audio file to transcribe.
139    pub audio: AudioInput,
140    /// Optional text that guides transcription style and context.
141    pub prompt: Option<String>,
142    /// Optional ISO-639-1 input language code, such as `en`.
143    pub language: Option<String>,
144}
145
146impl TranscriptionRequest {
147    /// Constructs a request with Kennedy's current faithful-transcription prompt.
148    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/// Modality detail for token-billed audio transcription input.
177#[derive(Clone, Debug, Default, Eq, PartialEq)]
178pub struct TranscriptionTokenDetails {
179    /// Audio tokens billed for the request, when reported.
180    pub audio_tokens: Option<u64>,
181    /// Prompt text tokens billed for the request, when reported.
182    pub text_tokens: Option<u64>,
183}
184
185/// Token-billed transcription usage.
186#[derive(Clone, Debug, Eq, PartialEq)]
187pub struct TranscriptionTokenUsage {
188    /// Input tokens billed for the request.
189    pub input_tokens: u64,
190    /// Output tokens generated by the request.
191    pub output_tokens: u64,
192    /// Total input and output tokens.
193    pub total_tokens: u64,
194    /// Optional input modality breakdown.
195    pub input_details: Option<TranscriptionTokenDetails>,
196}
197
198/// Usage returned for a transcription request.
199#[derive(Clone, Debug, PartialEq)]
200pub enum TranscriptionUsage {
201    /// Usage billed in tokens.
202    Tokens(TranscriptionTokenUsage),
203    /// Usage billed from audio duration in seconds.
204    DurationSeconds(f64),
205}
206
207/// Normalized `gpt-4o-transcribe` result.
208#[derive(Clone, Debug, PartialEq)]
209pub struct Transcription {
210    /// Complete non-empty transcript text.
211    pub text: String,
212    /// Provider usage, when returned.
213    pub usage: Option<TranscriptionUsage>,
214    /// OpenAI request identifier, when returned.
215    pub request_id: Option<String>,
216}
217
218/// Stored image media accepted for image analysis.
219#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
220pub enum ImageMediaType {
221    /// PNG image data.
222    Png,
223    /// JPEG image data.
224    Jpeg,
225    /// WebP image data.
226    WebP,
227    /// GIF image data. OpenAI accepts non-animated GIF input.
228    Gif,
229}
230
231impl ImageMediaType {
232    /// Returns the MIME type sent to OpenAI.
233    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/// One caller-owned in-memory image accepted for image analysis.
244#[derive(Clone, Eq, PartialEq)]
245pub struct ImageInput {
246    media_type: ImageMediaType,
247    data: Vec<u8>,
248}
249
250impl ImageInput {
251    /// Constructs and validates an image input.
252    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    /// Returns the declared stored-image media type.
259    pub const fn media_type(&self) -> ImageMediaType {
260        self.media_type
261    }
262
263    /// Returns the raw image bytes.
264    pub fn data(&self) -> &[u8] {
265        &self.data
266    }
267
268    /// Returns the raw image byte count.
269    pub fn len(&self) -> usize {
270        self.data.len()
271    }
272
273    /// Returns whether the image contains no bytes.
274    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/// OpenAI image-understanding detail level.
298#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
299pub enum ImageDetail {
300    /// Let the fixed model select the detail level.
301    #[default]
302    Auto,
303    /// Use low-detail image understanding.
304    Low,
305    /// Use high-detail image understanding.
306    High,
307    /// Use original-resolution image understanding where supported.
308    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/// One prompt-driven image-analysis request.
323#[derive(Clone, Debug, Eq, PartialEq)]
324pub struct ImageAnalysisRequest {
325    /// Caller-owned image bytes and declared media type.
326    pub image: ImageInput,
327    /// Exact prompt paired with the image.
328    pub prompt: String,
329    /// Provider image-understanding detail level.
330    pub detail: ImageDetail,
331}
332
333impl ImageAnalysisRequest {
334    /// Constructs a request using automatic image detail.
335    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/// Completion state of a non-streaming image-analysis response.
383#[derive(Clone, Debug, Eq, PartialEq)]
384pub enum ImageAnalysisStatus {
385    /// OpenAI completed the response.
386    Completed,
387    /// OpenAI returned a valid partial response.
388    Incomplete {
389        /// Provider reason for incompleteness, when returned.
390        reason: Option<String>,
391    },
392}
393
394/// Documented Responses API token usage retained for image analysis.
395#[derive(Clone, Debug, Eq, PartialEq)]
396pub struct ImageAnalysisUsage {
397    /// Total input tokens.
398    pub input_tokens: u64,
399    /// Total output tokens.
400    pub output_tokens: u64,
401    /// Total input and output tokens.
402    pub total_tokens: u64,
403    /// Cached input tokens, when reported.
404    pub cached_input_tokens: Option<u64>,
405    /// Cache-write input tokens, when reported.
406    pub cache_write_input_tokens: Option<u64>,
407    /// Reasoning output tokens, when reported.
408    pub reasoning_output_tokens: Option<u64>,
409}
410
411/// Normalized prompt-driven image-analysis result.
412#[derive(Clone, Debug, Eq, PartialEq)]
413pub struct ImageAnalysis {
414    /// Ordered non-empty assistant text.
415    pub text: String,
416    /// OpenAI response identifier.
417    pub response_id: String,
418    /// Model identifier returned by OpenAI.
419    pub model: String,
420    /// Completion or partial-result status.
421    pub status: ImageAnalysisStatus,
422    /// Provider token usage, when returned.
423    pub usage: Option<ImageAnalysisUsage>,
424    /// OpenAI request identifier, when returned.
425    pub request_id: Option<String>,
426}
427
428/// Generated image dimensions.
429#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
430pub enum ImageSize {
431    /// Let GPT Image choose the dimensions.
432    #[default]
433    Auto,
434    /// Explicit width and height in pixels.
435    Dimensions {
436        /// Width in pixels.
437        width: u16,
438        /// Height in pixels.
439        height: u16,
440    },
441}
442
443impl ImageSize {
444    /// Constructs explicit dimensions after validating GPT Image 2 constraints.
445    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/// GPT Image rendering quality.
482#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
483pub enum ImageQuality {
484    /// Let GPT Image select quality.
485    #[default]
486    Auto,
487    /// Fast draft quality.
488    Low,
489    /// Balanced quality.
490    Medium,
491    /// Highest supported quality.
492    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/// GPT Image output file format.
517#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
518pub enum ImageFormat {
519    /// PNG output.
520    #[default]
521    Png,
522    /// JPEG output.
523    Jpeg,
524    /// WebP output.
525    WebP,
526}
527
528impl ImageFormat {
529    /// Returns the output MIME type.
530    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/// Background selection supported by GPT Image 2.
557#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
558pub enum ImageBackground {
559    /// Let GPT Image select the background.
560    #[default]
561    Auto,
562    /// Require an opaque background.
563    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/// Provider content-moderation level.
576#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
577pub enum Moderation {
578    /// Standard provider moderation.
579    #[default]
580    Auto,
581    /// Less restrictive provider moderation.
582    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/// One GPT Image 2 text-to-image request.
595#[derive(Clone, Debug, Eq, PartialEq)]
596pub struct ImageGenerationRequest {
597    /// Text description of the desired image.
598    pub prompt: String,
599    /// Output dimensions.
600    pub size: ImageSize,
601    /// Rendering quality.
602    pub quality: ImageQuality,
603    /// Output file format.
604    pub output_format: ImageFormat,
605    /// JPEG or WebP compression from 0 through 100, when explicitly requested.
606    pub output_compression: Option<u8>,
607    /// Background behavior. GPT Image 2 does not support transparency.
608    pub background: ImageBackground,
609    /// Provider moderation level.
610    pub moderation: Moderation,
611    /// Optional stable end-user identifier supplied to OpenAI abuse monitoring.
612    pub user: Option<String>,
613}
614
615/// One GPT Image 2 request that modifies or combines reference images.
616#[derive(Clone, Debug, Eq, PartialEq)]
617pub struct ImageEditRequest {
618    /// One or more source or reference images.
619    pub images: Vec<ImageInput>,
620    /// Text description of the requested result.
621    pub prompt: String,
622    /// Output dimensions.
623    pub size: ImageSize,
624    /// Rendering quality.
625    pub quality: ImageQuality,
626    /// Output file format.
627    pub output_format: ImageFormat,
628    /// JPEG or WebP compression from 0 through 100, when explicitly requested.
629    pub output_compression: Option<u8>,
630    /// Background behavior.
631    pub background: ImageBackground,
632    /// Provider moderation level.
633    pub moderation: Moderation,
634    /// Optional stable end-user identifier supplied to abuse monitoring.
635    pub user: Option<String>,
636}
637
638impl ImageEditRequest {
639    /// Constructs a single-reference edit with automatic size and quality.
640    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    /// Constructs a single-image request with automatic size and quality and PNG output.
679    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/// One generated image held in memory.
747#[derive(Clone, Eq, PartialEq)]
748pub struct GeneratedImage {
749    /// Decoded image bytes.
750    pub data: Vec<u8>,
751    /// Returned image file format.
752    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/// Text and image token detail for GPT Image usage.
765#[derive(Clone, Debug, Default, Eq, PartialEq)]
766pub struct ImageTokenDetails {
767    /// Text tokens reported for the modality side.
768    pub text_tokens: u64,
769    /// Image tokens reported for the modality side.
770    pub image_tokens: u64,
771}
772
773/// Token usage returned by GPT Image.
774#[derive(Clone, Debug, Eq, PartialEq)]
775pub struct ImageUsage {
776    /// Total input tokens.
777    pub input_tokens: u64,
778    /// Total output tokens.
779    pub output_tokens: u64,
780    /// Total input and output tokens.
781    pub total_tokens: u64,
782    /// Input text and image token breakdown.
783    pub input_details: ImageTokenDetails,
784    /// Output text and image token breakdown, when returned.
785    pub output_details: Option<ImageTokenDetails>,
786}
787
788/// Normalized single-image GPT Image 2 result.
789#[derive(Clone, Debug, PartialEq)]
790pub struct ImageGeneration {
791    /// Provider creation time as Unix seconds.
792    pub created: u64,
793    /// Decoded generated image.
794    pub image: GeneratedImage,
795    /// Actual provider-selected size, when returned.
796    pub size: Option<String>,
797    /// Actual provider-selected quality, when returned.
798    pub quality: Option<ImageQuality>,
799    /// Provider token usage, when returned.
800    pub usage: Option<ImageUsage>,
801    /// OpenAI request identifier, when returned.
802    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}