Skip to main content

kcode_gemini_api/
model.rs

1use std::fmt;
2
3use base64::{Engine as _, engine::general_purpose::STANDARD};
4use serde_json::{Value, json};
5
6use crate::{Error, GEMINI_31_FLASH_LITE, GEMINI_31_PRO, Result};
7
8pub(crate) const MAX_PROMPT_BYTES: usize = 4 * 1024 * 1024;
9pub(crate) const MAX_INLINE_MEDIA_BYTES: usize = 64 * 1024 * 1024;
10pub(crate) const MAX_NANO_BANANA_IMAGES: usize = 14;
11pub(crate) const MAX_TEXT_OUTPUT_TOKENS: u32 = 65_536;
12pub(crate) const MAX_IMAGE_OUTPUT_TOKENS: u32 = 32_768;
13const MAX_STRUCTURED_OUTPUT_SCHEMA_BYTES: usize = 1024 * 1024;
14
15/// A non-negative USD amount represented exactly in billionths of one dollar.
16#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
17pub struct Money(u64);
18
19impl Money {
20    /// Zero dollars.
21    pub const ZERO: Self = Self(0);
22    /// Constructs an amount from billionths of one US dollar.
23    pub const fn from_usd_nanos(value: u64) -> Self {
24        Self(value)
25    }
26    /// Constructs an amount from millionths of one US dollar.
27    pub const fn from_usd_micros(value: u64) -> Self {
28        Self(value.saturating_mul(1_000))
29    }
30    /// Returns the amount in billionths of one US dollar.
31    pub const fn usd_nanos(self) -> u64 {
32        self.0
33    }
34    /// Returns a display-oriented floating-point dollar value.
35    pub fn as_usd(self) -> f64 {
36        self.0 as f64 / 1_000_000_000.0
37    }
38    pub(crate) fn saturating_add(self, other: Self) -> Self {
39        Self(self.0.saturating_add(other.0))
40    }
41}
42
43impl fmt::Display for Money {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        write!(f, "${:.9}", self.as_usd())
46    }
47}
48
49/// Text inference models intentionally supported by this crate.
50#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
51pub enum TextModel {
52    /// Gemini 3.1 Flash-Lite.
53    FlashLite,
54    /// Gemini 3.1 Pro, currently exposed as a preview model.
55    Pro,
56}
57
58impl TextModel {
59    /// Returns the exact API model identifier.
60    pub const fn as_str(self) -> &'static str {
61        match self {
62            Self::FlashLite => GEMINI_31_FLASH_LITE,
63            Self::Pro => GEMINI_31_PRO,
64        }
65    }
66}
67
68impl fmt::Display for TextModel {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        f.write_str(self.as_str())
71    }
72}
73
74/// Gemini inference service tier.
75#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
76pub enum ServiceTier {
77    /// Standard synchronous pricing and scheduling.
78    #[default]
79    Standard,
80    /// Priority scheduling at Google's higher published prices.
81    Priority,
82}
83
84impl ServiceTier {
85    pub(crate) const fn as_str(self) -> &'static str {
86        match self {
87            Self::Standard => "standard",
88            Self::Priority => "priority",
89        }
90    }
91}
92
93/// Maximum model reasoning depth.
94#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
95pub enum ThinkingLevel {
96    /// Little to no reasoning; supported by Flash-Lite but not Pro.
97    Minimal,
98    /// Low reasoning depth.
99    Low,
100    /// Medium reasoning depth.
101    Medium,
102    /// High reasoning depth.
103    High,
104}
105
106impl ThinkingLevel {
107    pub(crate) const fn as_str(self) -> &'static str {
108        match self {
109            Self::Minimal => "minimal",
110            Self::Low => "low",
111            Self::Medium => "medium",
112            Self::High => "high",
113        }
114    }
115}
116
117/// Shared generation controls.
118#[derive(Clone, Debug, PartialEq)]
119pub struct GenerationOptions {
120    /// Optional maximum generated token budget, including thinking tokens.
121    /// Omission lets Gemini select its model default.
122    pub max_output_tokens: Option<u32>,
123    /// Optional sampling temperature in the inclusive range 0 through 2.
124    pub temperature: Option<f32>,
125    /// Optional maximum thinking depth; omission uses the model default.
126    pub thinking_level: Option<ThinkingLevel>,
127    /// Gemini scheduling and pricing tier.
128    pub service_tier: ServiceTier,
129}
130
131impl Default for GenerationOptions {
132    fn default() -> Self {
133        Self {
134            max_output_tokens: Some(8_192),
135            temperature: None,
136            thinking_level: None,
137            service_tier: ServiceTier::Standard,
138        }
139    }
140}
141
142impl GenerationOptions {
143    pub(crate) fn validate(&self, model: TextModel) -> Result<()> {
144        if let Some(maximum) = self.max_output_tokens {
145            validate_output_tokens(maximum, MAX_TEXT_OUTPUT_TOKENS)?;
146        }
147        if self
148            .temperature
149            .is_some_and(|value| !value.is_finite() || !(0.0..=2.0).contains(&value))
150        {
151            return Err(Error::InvalidInput(
152                "temperature must be finite and between 0 and 2".into(),
153            ));
154        }
155        if model == TextModel::Pro && self.thinking_level == Some(ThinkingLevel::Minimal) {
156            return Err(Error::InvalidInput(
157                "Gemini 3.1 Pro does not support minimal thinking".into(),
158            ));
159        }
160        Ok(())
161    }
162
163    pub(crate) fn generation_config(&self) -> Value {
164        let mut value = json!({});
165        let object = value.as_object_mut().expect("configuration is an object");
166        if let Some(maximum) = self.max_output_tokens {
167            object.insert("max_output_tokens".into(), json!(maximum));
168        }
169        if let Some(temperature) = self.temperature {
170            object.insert("temperature".into(), json!(temperature));
171        }
172        if let Some(level) = self.thinking_level {
173            object.insert("thinking_level".into(), json!(level.as_str()));
174        }
175        value
176    }
177}
178
179/// A validated JSON Schema requesting JSON structured output.
180///
181/// Gemini receives this as an `application/json` text response format. The
182/// provider remains responsible for validating which JSON Schema features the
183/// selected model supports.
184#[derive(Clone, Debug, PartialEq)]
185pub struct StructuredOutput {
186    schema: Value,
187}
188
189impl StructuredOutput {
190    /// Constructs a structured-output request from a JSON Schema object.
191    pub fn new(schema: Value) -> Result<Self> {
192        let object = schema.as_object().ok_or_else(|| {
193            Error::InvalidInput("structured-output schema must be a JSON object".into())
194        })?;
195        if object.is_empty() {
196            return Err(Error::InvalidInput(
197                "structured-output schema must not be empty".into(),
198            ));
199        }
200        if serde_json::to_vec(&schema)
201            .map_err(|error| Error::InvalidInput(format!("invalid JSON Schema: {error}")))?
202            .len()
203            > MAX_STRUCTURED_OUTPUT_SCHEMA_BYTES
204        {
205            return Err(Error::InvalidInput(format!(
206                "structured-output schema exceeds the {MAX_STRUCTURED_OUTPUT_SCHEMA_BYTES}-byte safety limit"
207            )));
208        }
209        Ok(Self { schema })
210    }
211
212    /// Returns the JSON Schema sent to Gemini.
213    pub fn schema(&self) -> &Value {
214        &self.schema
215    }
216
217    pub(crate) fn response_format(&self) -> Value {
218        json!({
219            "type":"text",
220            "mime_type":"application/json",
221            "schema":self.schema,
222        })
223    }
224}
225
226/// One text-only inference request.
227#[derive(Clone, Debug, PartialEq)]
228pub struct InferenceRequest {
229    /// User prompt.
230    pub prompt: String,
231    /// Optional system instruction.
232    pub system_instruction: Option<String>,
233    /// Optional JSON structured-output contract.
234    pub structured_output: Option<StructuredOutput>,
235    /// Generation controls.
236    pub options: GenerationOptions,
237}
238
239impl InferenceRequest {
240    /// Constructs a request with default generation controls.
241    pub fn new(prompt: impl Into<String>) -> Self {
242        Self {
243            prompt: prompt.into(),
244            system_instruction: None,
245            structured_output: None,
246            options: GenerationOptions::default(),
247        }
248    }
249}
250
251/// Inline media category accepted by Gemini 3.1 Pro.
252#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
253pub enum MediaKind {
254    /// Image input.
255    Image,
256    /// Audio input.
257    Audio,
258    /// Video input.
259    Video,
260}
261
262impl MediaKind {
263    pub(crate) const fn as_str(self) -> &'static str {
264        match self {
265            Self::Image => "image",
266            Self::Audio => "audio",
267            Self::Video => "video",
268        }
269    }
270}
271
272/// One image, audio, or video input held in memory for an inference request.
273#[derive(Clone, Eq, PartialEq)]
274pub struct MediaInput {
275    kind: MediaKind,
276    mime_type: String,
277    data: Vec<u8>,
278}
279
280impl MediaInput {
281    /// Constructs an image input after validation.
282    pub fn image(mime_type: impl Into<String>, data: Vec<u8>) -> Result<Self> {
283        Self::new(MediaKind::Image, mime_type.into(), data)
284    }
285    /// Constructs an audio input after validation.
286    pub fn audio(mime_type: impl Into<String>, data: Vec<u8>) -> Result<Self> {
287        Self::new(MediaKind::Audio, mime_type.into(), data)
288    }
289    /// Constructs a video input after validation.
290    pub fn video(mime_type: impl Into<String>, data: Vec<u8>) -> Result<Self> {
291        Self::new(MediaKind::Video, mime_type.into(), data)
292    }
293    /// Returns whether this is image, audio, or video data.
294    pub const fn kind(&self) -> MediaKind {
295        self.kind
296    }
297    /// Returns the validated MIME type.
298    pub fn mime_type(&self) -> &str {
299        &self.mime_type
300    }
301    /// Returns the raw byte count.
302    pub fn len(&self) -> usize {
303        self.data.len()
304    }
305    /// Returns true when the input has no bytes.
306    pub fn is_empty(&self) -> bool {
307        self.data.is_empty()
308    }
309
310    fn new(kind: MediaKind, mime_type: String, data: Vec<u8>) -> Result<Self> {
311        if data.is_empty() {
312            return Err(Error::InvalidInput("media data must not be empty".into()));
313        }
314        let valid = match kind {
315            MediaKind::Image => matches!(
316                mime_type.as_str(),
317                "image/png"
318                    | "image/jpeg"
319                    | "image/webp"
320                    | "image/heic"
321                    | "image/heif"
322                    | "image/gif"
323                    | "image/bmp"
324                    | "image/tiff"
325            ),
326            MediaKind::Audio => matches!(
327                mime_type.as_str(),
328                "audio/wav"
329                    | "audio/mp3"
330                    | "audio/aiff"
331                    | "audio/aac"
332                    | "audio/ogg"
333                    | "audio/flac"
334                    | "audio/mpeg"
335                    | "audio/m4a"
336                    | "audio/l16"
337                    | "audio/opus"
338                    | "audio/alaw"
339                    | "audio/mulaw"
340            ),
341            MediaKind::Video => matches!(
342                mime_type.as_str(),
343                "video/mp4"
344                    | "video/mpeg"
345                    | "video/mov"
346                    | "video/quicktime"
347                    | "video/avi"
348                    | "video/x-flv"
349                    | "video/mpg"
350                    | "video/webm"
351                    | "video/wmv"
352                    | "video/3gpp"
353            ),
354        };
355        if !valid {
356            return Err(Error::InvalidInput(format!(
357                "unsupported {} MIME type {mime_type:?}",
358                kind.as_str()
359            )));
360        }
361        Ok(Self {
362            kind,
363            mime_type,
364            data,
365        })
366    }
367
368    pub(crate) fn interaction_value(&self) -> Value {
369        json!({
370            "type": self.kind.as_str(),
371            "mime_type": self.mime_type,
372            "data": STANDARD.encode(&self.data),
373        })
374    }
375}
376
377impl fmt::Debug for MediaInput {
378    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
379        f.debug_struct("MediaInput")
380            .field("kind", &self.kind)
381            .field("mime_type", &self.mime_type)
382            .field("bytes", &self.data.len())
383            .finish()
384    }
385}
386
387/// Image, audio, and/or video inference request for Gemini 3.1 Pro.
388#[derive(Clone, Debug, PartialEq)]
389pub struct MultimodalRequest {
390    /// User prompt accompanying the media.
391    pub prompt: String,
392    /// Image, audio, and video inputs; at least one is required.
393    pub media: Vec<MediaInput>,
394    /// Optional system instruction.
395    pub system_instruction: Option<String>,
396    /// Optional JSON structured-output contract.
397    pub structured_output: Option<StructuredOutput>,
398    /// Generation controls.
399    pub options: GenerationOptions,
400}
401
402impl MultimodalRequest {
403    /// Constructs a request with default generation controls.
404    pub fn new(prompt: impl Into<String>, media: Vec<MediaInput>) -> Self {
405        Self {
406            prompt: prompt.into(),
407            media,
408            system_instruction: None,
409            structured_output: None,
410            options: GenerationOptions::default(),
411        }
412    }
413}
414
415/// Native-image aspect ratio supported by Nano Banana Pro at 2K.
416#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
417pub enum AspectRatio {
418    /// 1:1 square output.
419    #[default]
420    Square,
421    /// 3:2 landscape output.
422    ThreeTwo,
423    /// 2:3 portrait output.
424    TwoThree,
425    /// 4:3 landscape output.
426    FourThree,
427    /// 3:4 portrait output.
428    ThreeFour,
429    /// 5:4 landscape output.
430    FiveFour,
431    /// 4:5 portrait output.
432    FourFive,
433    /// 16:9 landscape output.
434    SixteenNine,
435    /// 9:16 portrait output.
436    NineSixteen,
437    /// 21:9 ultrawide output.
438    TwentyOneNine,
439}
440
441impl AspectRatio {
442    pub(crate) const fn as_str(self) -> &'static str {
443        match self {
444            Self::Square => "1:1",
445            Self::ThreeTwo => "3:2",
446            Self::TwoThree => "2:3",
447            Self::FourThree => "4:3",
448            Self::ThreeFour => "3:4",
449            Self::FiveFour => "5:4",
450            Self::FourFive => "4:5",
451            Self::SixteenNine => "16:9",
452            Self::NineSixteen => "9:16",
453            Self::TwentyOneNine => "21:9",
454        }
455    }
456}
457
458/// Nano Banana Pro generation or editing request with fixed 2K output.
459#[derive(Clone, Debug, PartialEq)]
460pub struct NanoBananaProRequest {
461    /// Text generation or editing prompt.
462    pub prompt: String,
463    /// Optional image inputs for editing or visual reference.
464    pub images: Vec<MediaInput>,
465    /// Requested aspect ratio.
466    pub aspect_ratio: AspectRatio,
467    /// Generation controls; Nano Banana Pro supports Standard service only here.
468    pub options: GenerationOptions,
469}
470
471impl NanoBananaProRequest {
472    /// Constructs a 2K text-to-image request with defaults.
473    pub fn new(prompt: impl Into<String>) -> Self {
474        Self {
475            prompt: prompt.into(),
476            images: Vec::new(),
477            aspect_ratio: AspectRatio::default(),
478            options: GenerationOptions::default(),
479        }
480    }
481}
482
483/// Grounded Flash-Lite search request matching Kennedy fast search.
484#[derive(Clone, Debug, PartialEq)]
485pub struct GroundedSearchRequest {
486    /// Research question.
487    pub question: String,
488    /// Generation controls.
489    pub options: GenerationOptions,
490}
491
492impl GroundedSearchRequest {
493    /// Constructs a low-thinking Priority request with Gemini's output limit.
494    pub fn new(question: impl Into<String>) -> Self {
495        Self {
496            question: question.into(),
497            options: GenerationOptions {
498                max_output_tokens: None,
499                temperature: None,
500                thinking_level: Some(ThinkingLevel::Low),
501                service_tier: ServiceTier::Priority,
502            },
503        }
504    }
505}
506
507/// Final interaction state returned by Gemini.
508#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
509pub enum CompletionStatus {
510    /// The turn completed normally.
511    Completed,
512    /// The turn returned usable partial output.
513    Incomplete,
514}
515
516/// A generated image returned by Nano Banana Pro.
517#[derive(Clone, Eq, PartialEq)]
518pub struct GeneratedImage {
519    /// Returned image MIME type.
520    pub mime_type: String,
521    /// Decoded image bytes.
522    pub data: Vec<u8>,
523}
524
525impl fmt::Debug for GeneratedImage {
526    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
527        f.debug_struct("GeneratedImage")
528            .field("mime_type", &self.mime_type)
529            .field("bytes", &self.data.len())
530            .finish()
531    }
532}
533
534/// Provider modality used for token accounting.
535#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
536pub enum Modality {
537    /// Text tokens.
538    Text,
539    /// Image tokens.
540    Image,
541    /// Audio tokens.
542    Audio,
543    /// Video tokens.
544    Video,
545    /// Document tokens, billed at image rates.
546    Document,
547    /// A provider modality unknown to this version.
548    Unknown,
549}
550
551impl Modality {
552    pub(crate) fn parse(value: &str) -> Self {
553        match value {
554            "text" => Self::Text,
555            "image" => Self::Image,
556            "audio" => Self::Audio,
557            "video" => Self::Video,
558            "document" => Self::Document,
559            _ => Self::Unknown,
560        }
561    }
562}
563
564/// One modality-specific token count.
565#[derive(Clone, Debug, Eq, PartialEq)]
566pub struct ModalityTokens {
567    /// Token modality.
568    pub modality: Modality,
569    /// Token count.
570    pub tokens: u64,
571}
572
573/// Token usage reported for one interaction.
574#[derive(Clone, Debug, Default, Eq, PartialEq)]
575pub struct TokenUsage {
576    /// Total effective input tokens.
577    pub input_tokens: u64,
578    /// Cached input tokens.
579    pub cached_tokens: u64,
580    /// Visible output tokens.
581    pub output_tokens: u64,
582    /// Internal thinking tokens billed at output rates.
583    pub thought_tokens: u64,
584    /// Tool-use prompt tokens reported separately.
585    pub tool_use_tokens: u64,
586    /// Total tokens reported by Gemini.
587    pub total_tokens: u64,
588    /// Input counts by modality.
589    pub input_by_modality: Vec<ModalityTokens>,
590    /// Cached input counts by modality.
591    pub cached_by_modality: Vec<ModalityTokens>,
592    /// Output counts by modality.
593    pub output_by_modality: Vec<ModalityTokens>,
594    /// Tool-use prompt counts by modality.
595    pub tool_use_by_modality: Vec<ModalityTokens>,
596    /// Reported Google Search query count.
597    pub grounding_search_queries: u64,
598}
599
600impl TokenUsage {
601    pub(crate) fn modality_total(values: &[ModalityTokens], modality: Modality) -> u64 {
602        values
603            .iter()
604            .filter(|entry| entry.modality == modality)
605            .map(|entry| entry.tokens)
606            .sum()
607    }
608
609    pub(crate) fn saturating_add(&mut self, other: &Self) {
610        self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
611        self.cached_tokens = self.cached_tokens.saturating_add(other.cached_tokens);
612        self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
613        self.thought_tokens = self.thought_tokens.saturating_add(other.thought_tokens);
614        self.tool_use_tokens = self.tool_use_tokens.saturating_add(other.tool_use_tokens);
615        self.total_tokens = self.total_tokens.saturating_add(other.total_tokens);
616        self.grounding_search_queries = self
617            .grounding_search_queries
618            .saturating_add(other.grounding_search_queries);
619        add_modalities(&mut self.input_by_modality, &other.input_by_modality);
620        add_modalities(&mut self.cached_by_modality, &other.cached_by_modality);
621        add_modalities(&mut self.output_by_modality, &other.output_by_modality);
622        add_modalities(&mut self.tool_use_by_modality, &other.tool_use_by_modality);
623    }
624}
625
626fn add_modalities(target: &mut Vec<ModalityTokens>, source: &[ModalityTokens]) {
627    for entry in source {
628        if let Some(existing) = target
629            .iter_mut()
630            .find(|value| value.modality == entry.modality)
631        {
632            existing.tokens = existing.tokens.saturating_add(entry.tokens);
633        } else {
634            target.push(entry.clone());
635        }
636    }
637}
638
639/// Quality of a locally calculated cost.
640#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
641pub enum CostAccuracy {
642    /// Complete provider usage mapped directly to published rates.
643    Exact,
644    /// Missing modality detail required a documented fallback.
645    Estimated,
646    /// Grounding cost assumes every reported query is chargeable.
647    Conservative,
648}
649
650/// Locally calculated cost for one request or aggregate.
651#[derive(Clone, Debug, Eq, PartialEq)]
652pub struct CostBreakdown {
653    /// Non-cached input cost.
654    pub input: Money,
655    /// Cached input cost.
656    pub cached_input: Money,
657    /// Visible text output plus thinking cost.
658    pub text_output_and_thinking: Money,
659    /// Native image output cost.
660    pub image_output: Money,
661    /// Conservative Search grounding cost.
662    pub grounding: Money,
663    /// Sum of cost components.
664    pub total: Money,
665    /// Cost calculation quality.
666    pub accuracy: CostAccuracy,
667    /// Compiled pricing-table identifier.
668    pub pricing_version: String,
669}
670
671impl Default for CostBreakdown {
672    fn default() -> Self {
673        Self {
674            input: Money::ZERO,
675            cached_input: Money::ZERO,
676            text_output_and_thinking: Money::ZERO,
677            image_output: Money::ZERO,
678            grounding: Money::ZERO,
679            total: Money::ZERO,
680            accuracy: CostAccuracy::Exact,
681            pricing_version: "google-gemini-2026-07-20".into(),
682        }
683    }
684}
685
686impl CostBreakdown {
687    pub(crate) fn saturating_add(&mut self, other: &Self) {
688        self.input = self.input.saturating_add(other.input);
689        self.cached_input = self.cached_input.saturating_add(other.cached_input);
690        self.text_output_and_thinking = self
691            .text_output_and_thinking
692            .saturating_add(other.text_output_and_thinking);
693        self.image_output = self.image_output.saturating_add(other.image_output);
694        self.grounding = self.grounding.saturating_add(other.grounding);
695        self.total = self.total.saturating_add(other.total);
696        if other.accuracy == CostAccuracy::Conservative {
697            self.accuracy = CostAccuracy::Conservative;
698        } else if other.accuracy == CostAccuracy::Estimated && self.accuracy == CostAccuracy::Exact
699        {
700            self.accuracy = CostAccuracy::Estimated;
701        }
702    }
703}
704
705/// A successful text or image interaction.
706#[derive(Clone, Debug, PartialEq)]
707pub struct InferenceResponse {
708    /// Gemini interaction identifier.
709    pub id: String,
710    /// Actual model identifier returned by Gemini.
711    pub model: String,
712    /// Completion state.
713    pub status: CompletionStatus,
714    /// Concatenated text output, when present.
715    pub text: Option<String>,
716    /// Decoded native image output.
717    pub images: Vec<GeneratedImage>,
718    /// Provider-reported usage.
719    pub usage: TokenUsage,
720    /// Locally calculated cost.
721    pub cost: CostBreakdown,
722    /// In-memory session usage-record identifier.
723    pub usage_record_id: u64,
724}
725
726/// One public source returned by grounded search.
727#[derive(Clone, Debug, Eq, PartialEq)]
728pub struct WebSource {
729    /// Sanitized source title.
730    pub title: String,
731    /// Public HTTP(S) URL without a fragment.
732    pub url: String,
733}
734
735/// Successful grounded-search output.
736#[derive(Clone, Debug, PartialEq)]
737pub struct GroundedSearchResponse {
738    /// Normalized interaction response.
739    pub interaction: InferenceResponse,
740    /// Deduplicated citations and search results.
741    pub sources: Vec<WebSource>,
742}
743
744pub(crate) fn validate_prompt(prompt: &str) -> Result<()> {
745    if prompt.trim().is_empty() {
746        return Err(Error::InvalidInput("prompt must not be empty".into()));
747    }
748    if prompt.len() > MAX_PROMPT_BYTES {
749        return Err(Error::InvalidInput(format!(
750            "prompt exceeds the {MAX_PROMPT_BYTES}-byte limit"
751        )));
752    }
753    Ok(())
754}
755
756pub(crate) fn validate_system_instruction(value: Option<&str>) -> Result<()> {
757    if let Some(value) = value {
758        if value.trim().is_empty() {
759            return Err(Error::InvalidInput(
760                "system_instruction must be omitted instead of empty".into(),
761            ));
762        }
763        if value.len() > MAX_PROMPT_BYTES {
764            return Err(Error::InvalidInput(format!(
765                "system_instruction exceeds the {MAX_PROMPT_BYTES}-byte limit"
766            )));
767        }
768    }
769    Ok(())
770}
771
772pub(crate) fn validate_media(media: &[MediaInput], required: bool) -> Result<()> {
773    if required && media.is_empty() {
774        return Err(Error::InvalidInput(
775            "multimodal inference requires at least one media input".into(),
776        ));
777    }
778    let total = media.iter().try_fold(0_usize, |total, input| {
779        total
780            .checked_add(input.len())
781            .ok_or_else(|| Error::InvalidInput("aggregate media size overflowed".into()))
782    })?;
783    if total > MAX_INLINE_MEDIA_BYTES {
784        return Err(Error::InvalidInput(format!(
785            "aggregate inline media exceeds the {MAX_INLINE_MEDIA_BYTES}-byte safety limit"
786        )));
787    }
788    Ok(())
789}
790
791pub(crate) fn validate_output_tokens(value: u32, maximum: u32) -> Result<()> {
792    if value == 0 || value > maximum {
793        return Err(Error::InvalidInput(format!(
794            "max_output_tokens must be between 1 and {maximum}"
795        )));
796    }
797    Ok(())
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803
804    const VIDEO_MIME_TYPES: &[&str] = &[
805        "video/mp4",
806        "video/mpeg",
807        "video/mov",
808        "video/quicktime",
809        "video/avi",
810        "video/x-flv",
811        "video/mpg",
812        "video/webm",
813        "video/wmv",
814        "video/3gpp",
815    ];
816
817    #[test]
818    fn media_debug_redacts_bytes() {
819        let media = MediaInput::video("video/mp4", b"sensitive bytes".to_vec()).unwrap();
820        let debug = format!("{media:?}");
821        assert!(debug.contains("Video"));
822        assert!(debug.contains("video/mp4"));
823        assert!(debug.contains("bytes: 15"));
824        assert!(!debug.contains("sensitive"));
825    }
826
827    #[test]
828    fn every_supported_video_mime_type_is_accepted() {
829        for mime_type in VIDEO_MIME_TYPES {
830            let media = MediaInput::video(*mime_type, vec![1]).unwrap();
831            assert_eq!(media.kind(), MediaKind::Video);
832            assert_eq!(media.mime_type(), *mime_type);
833        }
834    }
835
836    #[test]
837    fn invalid_or_empty_video_is_rejected() {
838        assert!(MediaInput::video("application/octet-stream", vec![1]).is_err());
839        assert!(MediaInput::video("Video/MP4", vec![1]).is_err());
840        assert!(MediaInput::video("video/mp4", Vec::new()).is_err());
841    }
842
843    #[test]
844    fn pro_rejects_minimal_thinking() {
845        let options = GenerationOptions {
846            thinking_level: Some(ThinkingLevel::Minimal),
847            ..GenerationOptions::default()
848        };
849        assert!(options.validate(TextModel::Pro).is_err());
850        assert!(options.validate(TextModel::FlashLite).is_ok());
851    }
852
853    #[test]
854    fn video_uses_current_interactions_schema() {
855        let value = MediaInput::video("video/mp4", vec![1, 2, 3])
856            .unwrap()
857            .interaction_value();
858        assert_eq!(value["type"], "video");
859        assert_eq!(value["mime_type"], "video/mp4");
860        assert_eq!(value["data"], "AQID");
861    }
862
863    #[test]
864    fn nano_banana_aspect_ratios_include_five_four_pair() {
865        assert_eq!(AspectRatio::FiveFour.as_str(), "5:4");
866        assert_eq!(AspectRatio::FourFive.as_str(), "4:5");
867    }
868
869    #[test]
870    fn structured_output_requires_a_nonempty_schema_object() {
871        assert!(StructuredOutput::new(json!({"type":"object"})).is_ok());
872        assert!(StructuredOutput::new(json!({})).is_err());
873        assert!(StructuredOutput::new(json!(["not", "a", "schema"])).is_err());
874    }
875}