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}
259
260impl MediaKind {
261    pub(crate) const fn as_str(self) -> &'static str {
262        match self {
263            Self::Image => "image",
264            Self::Audio => "audio",
265        }
266    }
267}
268
269/// One image or audio input held in memory for an inference request.
270#[derive(Clone, Eq, PartialEq)]
271pub struct MediaInput {
272    kind: MediaKind,
273    mime_type: String,
274    data: Vec<u8>,
275}
276
277impl MediaInput {
278    /// Constructs an image input after validation.
279    pub fn image(mime_type: impl Into<String>, data: Vec<u8>) -> Result<Self> {
280        Self::new(MediaKind::Image, mime_type.into(), data)
281    }
282    /// Constructs an audio input after validation.
283    pub fn audio(mime_type: impl Into<String>, data: Vec<u8>) -> Result<Self> {
284        Self::new(MediaKind::Audio, mime_type.into(), data)
285    }
286    /// Returns whether this is image or audio data.
287    pub const fn kind(&self) -> MediaKind {
288        self.kind
289    }
290    /// Returns the validated MIME type.
291    pub fn mime_type(&self) -> &str {
292        &self.mime_type
293    }
294    /// Returns the raw byte count.
295    pub fn len(&self) -> usize {
296        self.data.len()
297    }
298    /// Returns true when the input has no bytes.
299    pub fn is_empty(&self) -> bool {
300        self.data.is_empty()
301    }
302
303    fn new(kind: MediaKind, mime_type: String, data: Vec<u8>) -> Result<Self> {
304        if data.is_empty() {
305            return Err(Error::InvalidInput("media data must not be empty".into()));
306        }
307        let valid = match kind {
308            MediaKind::Image => matches!(
309                mime_type.as_str(),
310                "image/png"
311                    | "image/jpeg"
312                    | "image/webp"
313                    | "image/heic"
314                    | "image/heif"
315                    | "image/gif"
316                    | "image/bmp"
317                    | "image/tiff"
318            ),
319            MediaKind::Audio => matches!(
320                mime_type.as_str(),
321                "audio/wav"
322                    | "audio/mp3"
323                    | "audio/aiff"
324                    | "audio/aac"
325                    | "audio/ogg"
326                    | "audio/flac"
327                    | "audio/mpeg"
328                    | "audio/m4a"
329                    | "audio/l16"
330                    | "audio/opus"
331                    | "audio/alaw"
332                    | "audio/mulaw"
333            ),
334        };
335        if !valid {
336            return Err(Error::InvalidInput(format!(
337                "unsupported {} MIME type {mime_type:?}",
338                kind.as_str()
339            )));
340        }
341        Ok(Self {
342            kind,
343            mime_type,
344            data,
345        })
346    }
347
348    pub(crate) fn interaction_value(&self) -> Value {
349        json!({
350            "type": self.kind.as_str(),
351            "mime_type": self.mime_type,
352            "data": STANDARD.encode(&self.data),
353        })
354    }
355}
356
357impl fmt::Debug for MediaInput {
358    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
359        f.debug_struct("MediaInput")
360            .field("kind", &self.kind)
361            .field("mime_type", &self.mime_type)
362            .field("bytes", &self.data.len())
363            .finish()
364    }
365}
366
367/// Image and/or audio inference request for Gemini 3.1 Pro.
368#[derive(Clone, Debug, PartialEq)]
369pub struct MultimodalRequest {
370    /// User prompt accompanying the media.
371    pub prompt: String,
372    /// Image and audio inputs; at least one is required.
373    pub media: Vec<MediaInput>,
374    /// Optional system instruction.
375    pub system_instruction: Option<String>,
376    /// Optional JSON structured-output contract.
377    pub structured_output: Option<StructuredOutput>,
378    /// Generation controls.
379    pub options: GenerationOptions,
380}
381
382impl MultimodalRequest {
383    /// Constructs a request with default generation controls.
384    pub fn new(prompt: impl Into<String>, media: Vec<MediaInput>) -> Self {
385        Self {
386            prompt: prompt.into(),
387            media,
388            system_instruction: None,
389            structured_output: None,
390            options: GenerationOptions::default(),
391        }
392    }
393}
394
395/// Native-image aspect ratio supported by Nano Banana Pro at 2K.
396#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
397pub enum AspectRatio {
398    /// 1:1 square output.
399    #[default]
400    Square,
401    /// 3:2 landscape output.
402    ThreeTwo,
403    /// 2:3 portrait output.
404    TwoThree,
405    /// 4:3 landscape output.
406    FourThree,
407    /// 3:4 portrait output.
408    ThreeFour,
409    /// 5:4 landscape output.
410    FiveFour,
411    /// 4:5 portrait output.
412    FourFive,
413    /// 16:9 landscape output.
414    SixteenNine,
415    /// 9:16 portrait output.
416    NineSixteen,
417    /// 21:9 ultrawide output.
418    TwentyOneNine,
419}
420
421impl AspectRatio {
422    pub(crate) const fn as_str(self) -> &'static str {
423        match self {
424            Self::Square => "1:1",
425            Self::ThreeTwo => "3:2",
426            Self::TwoThree => "2:3",
427            Self::FourThree => "4:3",
428            Self::ThreeFour => "3:4",
429            Self::FiveFour => "5:4",
430            Self::FourFive => "4:5",
431            Self::SixteenNine => "16:9",
432            Self::NineSixteen => "9:16",
433            Self::TwentyOneNine => "21:9",
434        }
435    }
436}
437
438/// Nano Banana Pro generation or editing request with fixed 2K output.
439#[derive(Clone, Debug, PartialEq)]
440pub struct NanoBananaProRequest {
441    /// Text generation or editing prompt.
442    pub prompt: String,
443    /// Optional image inputs for editing or visual reference.
444    pub images: Vec<MediaInput>,
445    /// Requested aspect ratio.
446    pub aspect_ratio: AspectRatio,
447    /// Generation controls; Nano Banana Pro supports Standard service only here.
448    pub options: GenerationOptions,
449}
450
451impl NanoBananaProRequest {
452    /// Constructs a 2K text-to-image request with defaults.
453    pub fn new(prompt: impl Into<String>) -> Self {
454        Self {
455            prompt: prompt.into(),
456            images: Vec::new(),
457            aspect_ratio: AspectRatio::default(),
458            options: GenerationOptions::default(),
459        }
460    }
461}
462
463/// Grounded Flash-Lite search request matching Kennedy fast search.
464#[derive(Clone, Debug, PartialEq)]
465pub struct GroundedSearchRequest {
466    /// Research question.
467    pub question: String,
468    /// Generation controls.
469    pub options: GenerationOptions,
470}
471
472impl GroundedSearchRequest {
473    /// Constructs a low-thinking Priority request with Gemini's output limit.
474    pub fn new(question: impl Into<String>) -> Self {
475        Self {
476            question: question.into(),
477            options: GenerationOptions {
478                max_output_tokens: None,
479                temperature: None,
480                thinking_level: Some(ThinkingLevel::Low),
481                service_tier: ServiceTier::Priority,
482            },
483        }
484    }
485}
486
487/// Final interaction state returned by Gemini.
488#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
489pub enum CompletionStatus {
490    /// The turn completed normally.
491    Completed,
492    /// The turn returned usable partial output.
493    Incomplete,
494}
495
496/// A generated image returned by Nano Banana Pro.
497#[derive(Clone, Eq, PartialEq)]
498pub struct GeneratedImage {
499    /// Returned image MIME type.
500    pub mime_type: String,
501    /// Decoded image bytes.
502    pub data: Vec<u8>,
503}
504
505impl fmt::Debug for GeneratedImage {
506    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
507        f.debug_struct("GeneratedImage")
508            .field("mime_type", &self.mime_type)
509            .field("bytes", &self.data.len())
510            .finish()
511    }
512}
513
514/// Provider modality used for token accounting.
515#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
516pub enum Modality {
517    /// Text tokens.
518    Text,
519    /// Image tokens.
520    Image,
521    /// Audio tokens.
522    Audio,
523    /// Video tokens.
524    Video,
525    /// Document tokens, billed at image rates.
526    Document,
527    /// A provider modality unknown to this version.
528    Unknown,
529}
530
531impl Modality {
532    pub(crate) fn parse(value: &str) -> Self {
533        match value {
534            "text" => Self::Text,
535            "image" => Self::Image,
536            "audio" => Self::Audio,
537            "video" => Self::Video,
538            "document" => Self::Document,
539            _ => Self::Unknown,
540        }
541    }
542}
543
544/// One modality-specific token count.
545#[derive(Clone, Debug, Eq, PartialEq)]
546pub struct ModalityTokens {
547    /// Token modality.
548    pub modality: Modality,
549    /// Token count.
550    pub tokens: u64,
551}
552
553/// Token usage reported for one interaction.
554#[derive(Clone, Debug, Default, Eq, PartialEq)]
555pub struct TokenUsage {
556    /// Total effective input tokens.
557    pub input_tokens: u64,
558    /// Cached input tokens.
559    pub cached_tokens: u64,
560    /// Visible output tokens.
561    pub output_tokens: u64,
562    /// Internal thinking tokens billed at output rates.
563    pub thought_tokens: u64,
564    /// Tool-use prompt tokens reported separately.
565    pub tool_use_tokens: u64,
566    /// Total tokens reported by Gemini.
567    pub total_tokens: u64,
568    /// Input counts by modality.
569    pub input_by_modality: Vec<ModalityTokens>,
570    /// Cached input counts by modality.
571    pub cached_by_modality: Vec<ModalityTokens>,
572    /// Output counts by modality.
573    pub output_by_modality: Vec<ModalityTokens>,
574    /// Tool-use prompt counts by modality.
575    pub tool_use_by_modality: Vec<ModalityTokens>,
576    /// Reported Google Search query count.
577    pub grounding_search_queries: u64,
578}
579
580impl TokenUsage {
581    pub(crate) fn modality_total(values: &[ModalityTokens], modality: Modality) -> u64 {
582        values
583            .iter()
584            .filter(|entry| entry.modality == modality)
585            .map(|entry| entry.tokens)
586            .sum()
587    }
588
589    pub(crate) fn saturating_add(&mut self, other: &Self) {
590        self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
591        self.cached_tokens = self.cached_tokens.saturating_add(other.cached_tokens);
592        self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
593        self.thought_tokens = self.thought_tokens.saturating_add(other.thought_tokens);
594        self.tool_use_tokens = self.tool_use_tokens.saturating_add(other.tool_use_tokens);
595        self.total_tokens = self.total_tokens.saturating_add(other.total_tokens);
596        self.grounding_search_queries = self
597            .grounding_search_queries
598            .saturating_add(other.grounding_search_queries);
599        add_modalities(&mut self.input_by_modality, &other.input_by_modality);
600        add_modalities(&mut self.cached_by_modality, &other.cached_by_modality);
601        add_modalities(&mut self.output_by_modality, &other.output_by_modality);
602        add_modalities(&mut self.tool_use_by_modality, &other.tool_use_by_modality);
603    }
604}
605
606fn add_modalities(target: &mut Vec<ModalityTokens>, source: &[ModalityTokens]) {
607    for entry in source {
608        if let Some(existing) = target
609            .iter_mut()
610            .find(|value| value.modality == entry.modality)
611        {
612            existing.tokens = existing.tokens.saturating_add(entry.tokens);
613        } else {
614            target.push(entry.clone());
615        }
616    }
617}
618
619/// Quality of a locally calculated cost.
620#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
621pub enum CostAccuracy {
622    /// Complete provider usage mapped directly to published rates.
623    Exact,
624    /// Missing modality detail required a documented fallback.
625    Estimated,
626    /// Grounding cost assumes every reported query is chargeable.
627    Conservative,
628}
629
630/// Locally calculated cost for one request or aggregate.
631#[derive(Clone, Debug, Eq, PartialEq)]
632pub struct CostBreakdown {
633    /// Non-cached input cost.
634    pub input: Money,
635    /// Cached input cost.
636    pub cached_input: Money,
637    /// Visible text output plus thinking cost.
638    pub text_output_and_thinking: Money,
639    /// Native image output cost.
640    pub image_output: Money,
641    /// Conservative Search grounding cost.
642    pub grounding: Money,
643    /// Sum of cost components.
644    pub total: Money,
645    /// Cost calculation quality.
646    pub accuracy: CostAccuracy,
647    /// Compiled pricing-table identifier.
648    pub pricing_version: String,
649}
650
651impl Default for CostBreakdown {
652    fn default() -> Self {
653        Self {
654            input: Money::ZERO,
655            cached_input: Money::ZERO,
656            text_output_and_thinking: Money::ZERO,
657            image_output: Money::ZERO,
658            grounding: Money::ZERO,
659            total: Money::ZERO,
660            accuracy: CostAccuracy::Exact,
661            pricing_version: "google-gemini-2026-07-20".into(),
662        }
663    }
664}
665
666impl CostBreakdown {
667    pub(crate) fn saturating_add(&mut self, other: &Self) {
668        self.input = self.input.saturating_add(other.input);
669        self.cached_input = self.cached_input.saturating_add(other.cached_input);
670        self.text_output_and_thinking = self
671            .text_output_and_thinking
672            .saturating_add(other.text_output_and_thinking);
673        self.image_output = self.image_output.saturating_add(other.image_output);
674        self.grounding = self.grounding.saturating_add(other.grounding);
675        self.total = self.total.saturating_add(other.total);
676        if other.accuracy == CostAccuracy::Conservative {
677            self.accuracy = CostAccuracy::Conservative;
678        } else if other.accuracy == CostAccuracy::Estimated && self.accuracy == CostAccuracy::Exact
679        {
680            self.accuracy = CostAccuracy::Estimated;
681        }
682    }
683}
684
685/// A successful text or image interaction.
686#[derive(Clone, Debug, PartialEq)]
687pub struct InferenceResponse {
688    /// Gemini interaction identifier.
689    pub id: String,
690    /// Actual model identifier returned by Gemini.
691    pub model: String,
692    /// Completion state.
693    pub status: CompletionStatus,
694    /// Concatenated text output, when present.
695    pub text: Option<String>,
696    /// Decoded native image output.
697    pub images: Vec<GeneratedImage>,
698    /// Provider-reported usage.
699    pub usage: TokenUsage,
700    /// Locally calculated cost.
701    pub cost: CostBreakdown,
702    /// In-memory session usage-record identifier.
703    pub usage_record_id: u64,
704}
705
706/// One public source returned by grounded search.
707#[derive(Clone, Debug, Eq, PartialEq)]
708pub struct WebSource {
709    /// Sanitized source title.
710    pub title: String,
711    /// Public HTTP(S) URL without a fragment.
712    pub url: String,
713}
714
715/// Successful grounded-search output.
716#[derive(Clone, Debug, PartialEq)]
717pub struct GroundedSearchResponse {
718    /// Normalized interaction response.
719    pub interaction: InferenceResponse,
720    /// Deduplicated citations and search results.
721    pub sources: Vec<WebSource>,
722}
723
724pub(crate) fn validate_prompt(prompt: &str) -> Result<()> {
725    if prompt.trim().is_empty() {
726        return Err(Error::InvalidInput("prompt must not be empty".into()));
727    }
728    if prompt.len() > MAX_PROMPT_BYTES {
729        return Err(Error::InvalidInput(format!(
730            "prompt exceeds the {MAX_PROMPT_BYTES}-byte limit"
731        )));
732    }
733    Ok(())
734}
735
736pub(crate) fn validate_system_instruction(value: Option<&str>) -> Result<()> {
737    if let Some(value) = value {
738        if value.trim().is_empty() {
739            return Err(Error::InvalidInput(
740                "system_instruction must be omitted instead of empty".into(),
741            ));
742        }
743        if value.len() > MAX_PROMPT_BYTES {
744            return Err(Error::InvalidInput(format!(
745                "system_instruction exceeds the {MAX_PROMPT_BYTES}-byte limit"
746            )));
747        }
748    }
749    Ok(())
750}
751
752pub(crate) fn validate_media(media: &[MediaInput], required: bool) -> Result<()> {
753    if required && media.is_empty() {
754        return Err(Error::InvalidInput(
755            "multimodal inference requires at least one media input".into(),
756        ));
757    }
758    let total = media.iter().try_fold(0_usize, |total, input| {
759        total
760            .checked_add(input.len())
761            .ok_or_else(|| Error::InvalidInput("aggregate media size overflowed".into()))
762    })?;
763    if total > MAX_INLINE_MEDIA_BYTES {
764        return Err(Error::InvalidInput(format!(
765            "aggregate inline media exceeds the {MAX_INLINE_MEDIA_BYTES}-byte safety limit"
766        )));
767    }
768    Ok(())
769}
770
771pub(crate) fn validate_output_tokens(value: u32, maximum: u32) -> Result<()> {
772    if value == 0 || value > maximum {
773        return Err(Error::InvalidInput(format!(
774            "max_output_tokens must be between 1 and {maximum}"
775        )));
776    }
777    Ok(())
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783
784    #[test]
785    fn media_debug_redacts_bytes() {
786        let media = MediaInput::image("image/png", b"sensitive bytes".to_vec()).unwrap();
787        let debug = format!("{media:?}");
788        assert!(debug.contains("bytes: 15"));
789        assert!(!debug.contains("sensitive"));
790    }
791
792    #[test]
793    fn pro_rejects_minimal_thinking() {
794        let options = GenerationOptions {
795            thinking_level: Some(ThinkingLevel::Minimal),
796            ..GenerationOptions::default()
797        };
798        assert!(options.validate(TextModel::Pro).is_err());
799        assert!(options.validate(TextModel::FlashLite).is_ok());
800    }
801
802    #[test]
803    fn media_uses_current_interactions_schema() {
804        let value = MediaInput::audio("audio/wav", vec![1, 2, 3])
805            .unwrap()
806            .interaction_value();
807        assert_eq!(value["type"], "audio");
808        assert_eq!(value["mime_type"], "audio/wav");
809        assert_eq!(value["data"], "AQID");
810    }
811
812    #[test]
813    fn nano_banana_aspect_ratios_include_five_four_pair() {
814        assert_eq!(AspectRatio::FiveFour.as_str(), "5:4");
815        assert_eq!(AspectRatio::FourFive.as_str(), "4:5");
816    }
817
818    #[test]
819    fn structured_output_requires_a_nonempty_schema_object() {
820        assert!(StructuredOutput::new(json!({"type":"object"})).is_ok());
821        assert!(StructuredOutput::new(json!({})).is_err());
822        assert!(StructuredOutput::new(json!(["not", "a", "schema"])).is_err());
823    }
824}