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