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}
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#[derive(Clone, Eq, PartialEq)]
271pub struct MediaInput {
272 kind: MediaKind,
273 mime_type: String,
274 data: Vec<u8>,
275}
276
277impl MediaInput {
278 pub fn image(mime_type: impl Into<String>, data: Vec<u8>) -> Result<Self> {
280 Self::new(MediaKind::Image, mime_type.into(), data)
281 }
282 pub fn audio(mime_type: impl Into<String>, data: Vec<u8>) -> Result<Self> {
284 Self::new(MediaKind::Audio, mime_type.into(), data)
285 }
286 pub const fn kind(&self) -> MediaKind {
288 self.kind
289 }
290 pub fn mime_type(&self) -> &str {
292 &self.mime_type
293 }
294 pub fn len(&self) -> usize {
296 self.data.len()
297 }
298 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#[derive(Clone, Debug, PartialEq)]
369pub struct MultimodalRequest {
370 pub prompt: String,
372 pub media: Vec<MediaInput>,
374 pub system_instruction: Option<String>,
376 pub structured_output: Option<StructuredOutput>,
378 pub options: GenerationOptions,
380}
381
382impl MultimodalRequest {
383 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#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
397pub enum AspectRatio {
398 #[default]
400 Square,
401 ThreeTwo,
403 TwoThree,
405 FourThree,
407 ThreeFour,
409 FiveFour,
411 FourFive,
413 SixteenNine,
415 NineSixteen,
417 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#[derive(Clone, Debug, PartialEq)]
440pub struct NanoBananaProRequest {
441 pub prompt: String,
443 pub images: Vec<MediaInput>,
445 pub aspect_ratio: AspectRatio,
447 pub options: GenerationOptions,
449}
450
451impl NanoBananaProRequest {
452 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#[derive(Clone, Debug, PartialEq)]
465pub struct GroundedSearchRequest {
466 pub question: String,
468 pub options: GenerationOptions,
470}
471
472impl GroundedSearchRequest {
473 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
489pub enum CompletionStatus {
490 Completed,
492 Incomplete,
494}
495
496#[derive(Clone, Eq, PartialEq)]
498pub struct GeneratedImage {
499 pub mime_type: String,
501 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
516pub enum Modality {
517 Text,
519 Image,
521 Audio,
523 Video,
525 Document,
527 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#[derive(Clone, Debug, Eq, PartialEq)]
546pub struct ModalityTokens {
547 pub modality: Modality,
549 pub tokens: u64,
551}
552
553#[derive(Clone, Debug, Default, Eq, PartialEq)]
555pub struct TokenUsage {
556 pub input_tokens: u64,
558 pub cached_tokens: u64,
560 pub output_tokens: u64,
562 pub thought_tokens: u64,
564 pub tool_use_tokens: u64,
566 pub total_tokens: u64,
568 pub input_by_modality: Vec<ModalityTokens>,
570 pub cached_by_modality: Vec<ModalityTokens>,
572 pub output_by_modality: Vec<ModalityTokens>,
574 pub tool_use_by_modality: Vec<ModalityTokens>,
576 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
621pub enum CostAccuracy {
622 Exact,
624 Estimated,
626 Conservative,
628}
629
630#[derive(Clone, Debug, Eq, PartialEq)]
632pub struct CostBreakdown {
633 pub input: Money,
635 pub cached_input: Money,
637 pub text_output_and_thinking: Money,
639 pub image_output: Money,
641 pub grounding: Money,
643 pub total: Money,
645 pub accuracy: CostAccuracy,
647 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#[derive(Clone, Debug, PartialEq)]
687pub struct InferenceResponse {
688 pub id: String,
690 pub model: String,
692 pub status: CompletionStatus,
694 pub text: Option<String>,
696 pub images: Vec<GeneratedImage>,
698 pub usage: TokenUsage,
700 pub cost: CostBreakdown,
702 pub usage_record_id: u64,
704}
705
706#[derive(Clone, Debug, Eq, PartialEq)]
708pub struct WebSource {
709 pub title: String,
711 pub url: String,
713}
714
715#[derive(Clone, Debug, PartialEq)]
717pub struct GroundedSearchResponse {
718 pub interaction: InferenceResponse,
720 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}