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#[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 FlashLite,
54 Pro,
56}
57
58impl TextModel {
59 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#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
76pub enum ServiceTier {
77 #[default]
79 Standard,
80 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
95pub enum ThinkingLevel {
96 Minimal,
98 Low,
100 Medium,
102 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#[derive(Clone, Debug, PartialEq)]
119pub struct GenerationOptions {
120 pub max_output_tokens: Option<u32>,
123 pub temperature: Option<f32>,
125 pub thinking_level: Option<ThinkingLevel>,
127 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#[derive(Clone, Debug, PartialEq)]
185pub struct StructuredOutput {
186 schema: Value,
187}
188
189impl StructuredOutput {
190 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 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#[derive(Clone, Debug, PartialEq)]
228pub struct InferenceRequest {
229 pub prompt: String,
231 pub system_instruction: Option<String>,
233 pub structured_output: Option<StructuredOutput>,
235 pub options: GenerationOptions,
237}
238
239impl InferenceRequest {
240 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
253pub enum MediaKind {
254 Image,
256 Audio,
258 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#[derive(Clone, Eq, PartialEq)]
274pub struct MediaInput {
275 kind: MediaKind,
276 mime_type: String,
277 data: Vec<u8>,
278}
279
280impl MediaInput {
281 pub fn image(mime_type: impl Into<String>, data: Vec<u8>) -> Result<Self> {
283 Self::new(MediaKind::Image, mime_type.into(), data)
284 }
285 pub fn audio(mime_type: impl Into<String>, data: Vec<u8>) -> Result<Self> {
287 Self::new(MediaKind::Audio, mime_type.into(), data)
288 }
289 pub fn video(mime_type: impl Into<String>, data: Vec<u8>) -> Result<Self> {
291 Self::new(MediaKind::Video, mime_type.into(), data)
292 }
293 pub const fn kind(&self) -> MediaKind {
295 self.kind
296 }
297 pub fn mime_type(&self) -> &str {
299 &self.mime_type
300 }
301 pub fn len(&self) -> usize {
303 self.data.len()
304 }
305 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#[derive(Clone, Debug, PartialEq)]
389pub struct MultimodalRequest {
390 pub prompt: String,
392 pub media: Vec<MediaInput>,
394 pub system_instruction: Option<String>,
396 pub structured_output: Option<StructuredOutput>,
398 pub options: GenerationOptions,
400}
401
402impl MultimodalRequest {
403 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#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
417pub enum AspectRatio {
418 #[default]
420 Square,
421 ThreeTwo,
423 TwoThree,
425 FourThree,
427 ThreeFour,
429 FiveFour,
431 FourFive,
433 SixteenNine,
435 NineSixteen,
437 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#[derive(Clone, Debug, PartialEq)]
460pub struct NanoBananaProRequest {
461 pub prompt: String,
463 pub images: Vec<MediaInput>,
465 pub aspect_ratio: AspectRatio,
467 pub options: GenerationOptions,
469}
470
471impl NanoBananaProRequest {
472 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#[derive(Clone, Debug, PartialEq)]
485pub struct GroundedSearchRequest {
486 pub question: String,
488 pub options: GenerationOptions,
490}
491
492impl GroundedSearchRequest {
493 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
509pub enum CompletionStatus {
510 Completed,
512 Incomplete,
514}
515
516#[derive(Clone, Eq, PartialEq)]
518pub struct GeneratedImage {
519 pub mime_type: String,
521 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
536pub enum Modality {
537 Text,
539 Image,
541 Audio,
543 Video,
545 Document,
547 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#[derive(Clone, Debug, Eq, PartialEq)]
566pub struct ModalityTokens {
567 pub modality: Modality,
569 pub tokens: u64,
571}
572
573#[derive(Clone, Debug, Default, Eq, PartialEq)]
575pub struct TokenUsage {
576 pub input_tokens: u64,
578 pub cached_tokens: u64,
580 pub output_tokens: u64,
582 pub thought_tokens: u64,
584 pub tool_use_tokens: u64,
586 pub total_tokens: u64,
588 pub input_by_modality: Vec<ModalityTokens>,
590 pub cached_by_modality: Vec<ModalityTokens>,
592 pub output_by_modality: Vec<ModalityTokens>,
594 pub tool_use_by_modality: Vec<ModalityTokens>,
596 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
641pub enum CostAccuracy {
642 Exact,
644 Estimated,
646 Conservative,
648}
649
650#[derive(Clone, Debug, Eq, PartialEq)]
652pub struct CostBreakdown {
653 pub input: Money,
655 pub cached_input: Money,
657 pub text_output_and_thinking: Money,
659 pub image_output: Money,
661 pub grounding: Money,
663 pub total: Money,
665 pub accuracy: CostAccuracy,
667 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#[derive(Clone, Debug, PartialEq)]
707pub struct InferenceResponse {
708 pub id: String,
710 pub model: String,
712 pub status: CompletionStatus,
714 pub text: Option<String>,
716 pub images: Vec<GeneratedImage>,
718 pub usage: TokenUsage,
720 pub cost: CostBreakdown,
722 pub usage_record_id: u64,
724}
725
726#[derive(Clone, Debug, Eq, PartialEq)]
728pub struct WebSource {
729 pub title: String,
731 pub url: String,
733}
734
735#[derive(Clone, Debug, PartialEq)]
737pub struct GroundedSearchResponse {
738 pub interaction: InferenceResponse,
740 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}