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#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
17pub struct Money(u64);
18
19impl Money {
20 pub const ZERO: Self = Self(0);
22 pub const fn from_usd_nanos(value: u64) -> Self {
24 Self(value)
25 }
26 pub const fn from_usd_micros(value: u64) -> Self {
28 Self(value.saturating_mul(1_000))
29 }
30 pub const fn usd_nanos(self) -> u64 {
32 self.0
33 }
34 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
51pub enum TextModel {
52 Flash25,
54 FlashLite,
56 Pro,
58}
59
60impl TextModel {
61 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#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
79pub enum ServiceTier {
80 #[default]
82 Standard,
83 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
98pub enum ThinkingLevel {
99 Minimal,
101 Low,
103 Medium,
105 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#[derive(Clone, Debug, PartialEq)]
122pub struct GenerationOptions {
123 pub max_output_tokens: Option<u32>,
126 pub temperature: Option<f32>,
128 pub thinking_level: Option<ThinkingLevel>,
130 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#[derive(Clone, Debug, PartialEq)]
188pub struct StructuredOutput {
189 schema: Value,
190}
191
192impl StructuredOutput {
193 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 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#[derive(Clone, Debug, PartialEq)]
231pub struct InferenceRequest {
232 pub prompt: String,
234 pub system_instruction: Option<String>,
236 pub structured_output: Option<StructuredOutput>,
238 pub options: GenerationOptions,
240}
241
242impl InferenceRequest {
243 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
256pub enum MediaKind {
257 Image,
259 Audio,
261 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#[derive(Clone, Eq, PartialEq)]
277pub struct MediaInput {
278 kind: MediaKind,
279 mime_type: String,
280 data: Vec<u8>,
281}
282
283impl MediaInput {
284 pub fn image(mime_type: impl Into<String>, data: Vec<u8>) -> Result<Self> {
286 Self::new(MediaKind::Image, mime_type.into(), data)
287 }
288 pub fn audio(mime_type: impl Into<String>, data: Vec<u8>) -> Result<Self> {
290 Self::new(MediaKind::Audio, mime_type.into(), data)
291 }
292 pub fn video(mime_type: impl Into<String>, data: Vec<u8>) -> Result<Self> {
294 Self::new(MediaKind::Video, mime_type.into(), data)
295 }
296 pub const fn kind(&self) -> MediaKind {
298 self.kind
299 }
300 pub fn mime_type(&self) -> &str {
302 &self.mime_type
303 }
304 pub fn len(&self) -> usize {
306 self.data.len()
307 }
308 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#[derive(Clone, Debug, PartialEq)]
392pub struct MultimodalRequest {
393 pub prompt: String,
395 pub media: Vec<MediaInput>,
397 pub system_instruction: Option<String>,
399 pub structured_output: Option<StructuredOutput>,
401 pub options: GenerationOptions,
403}
404
405impl MultimodalRequest {
406 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#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
420pub enum AspectRatio {
421 #[default]
423 Square,
424 ThreeTwo,
426 TwoThree,
428 FourThree,
430 ThreeFour,
432 FiveFour,
434 FourFive,
436 SixteenNine,
438 NineSixteen,
440 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#[derive(Clone, Debug, PartialEq)]
463pub struct NanoBananaProRequest {
464 pub prompt: String,
466 pub images: Vec<MediaInput>,
468 pub aspect_ratio: AspectRatio,
470 pub options: GenerationOptions,
472}
473
474impl NanoBananaProRequest {
475 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#[derive(Clone, Debug, PartialEq)]
488pub struct GroundedSearchRequest {
489 pub question: String,
491 pub options: GenerationOptions,
493}
494
495impl GroundedSearchRequest {
496 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
512pub enum CompletionStatus {
513 Completed,
515 Incomplete,
517}
518
519#[derive(Clone, Eq, PartialEq)]
521pub struct GeneratedImage {
522 pub mime_type: String,
524 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
539pub enum Modality {
540 Text,
542 Image,
544 Audio,
546 Video,
548 Document,
550 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#[derive(Clone, Debug, Eq, PartialEq)]
569pub struct ModalityTokens {
570 pub modality: Modality,
572 pub tokens: u64,
574}
575
576#[derive(Clone, Debug, Default, Eq, PartialEq)]
578pub struct TokenUsage {
579 pub input_tokens: u64,
581 pub cached_tokens: u64,
583 pub output_tokens: u64,
585 pub thought_tokens: u64,
587 pub tool_use_tokens: u64,
589 pub total_tokens: u64,
591 pub input_by_modality: Vec<ModalityTokens>,
593 pub cached_by_modality: Vec<ModalityTokens>,
595 pub output_by_modality: Vec<ModalityTokens>,
597 pub tool_use_by_modality: Vec<ModalityTokens>,
599 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
644pub enum CostAccuracy {
645 Exact,
647 Estimated,
649 Conservative,
651}
652
653#[derive(Clone, Debug, Eq, PartialEq)]
655pub struct CostBreakdown {
656 pub input: Money,
658 pub cached_input: Money,
660 pub text_output_and_thinking: Money,
662 pub image_output: Money,
664 pub grounding: Money,
666 pub total: Money,
668 pub accuracy: CostAccuracy,
670 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#[derive(Clone, Debug, PartialEq)]
710pub struct InferenceResponse {
711 pub id: String,
713 pub model: String,
715 pub status: CompletionStatus,
717 pub text: Option<String>,
719 pub images: Vec<GeneratedImage>,
721 pub usage: TokenUsage,
723 pub cost: CostBreakdown,
725 pub usage_record_id: u64,
727}
728
729#[derive(Clone, Debug, Eq, PartialEq)]
731pub struct WebSource {
732 pub title: String,
734 pub url: String,
736}
737
738#[derive(Clone, Debug, PartialEq)]
740pub struct GroundedSearchResponse {
741 pub interaction: InferenceResponse,
743 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}