use std::fmt;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use serde_json::{Value, json};
use crate::{Error, GEMINI_31_FLASH_LITE, GEMINI_31_PRO, Result};
pub(crate) const MAX_PROMPT_BYTES: usize = 4 * 1024 * 1024;
pub(crate) const MAX_INLINE_MEDIA_BYTES: usize = 64 * 1024 * 1024;
pub(crate) const MAX_NANO_BANANA_IMAGES: usize = 14;
pub(crate) const MAX_TEXT_OUTPUT_TOKENS: u32 = 65_536;
pub(crate) const MAX_IMAGE_OUTPUT_TOKENS: u32 = 32_768;
const MAX_STRUCTURED_OUTPUT_SCHEMA_BYTES: usize = 1024 * 1024;
#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
pub struct Money(u64);
impl Money {
pub const ZERO: Self = Self(0);
pub const fn from_usd_nanos(value: u64) -> Self {
Self(value)
}
pub const fn from_usd_micros(value: u64) -> Self {
Self(value.saturating_mul(1_000))
}
pub const fn usd_nanos(self) -> u64 {
self.0
}
pub fn as_usd(self) -> f64 {
self.0 as f64 / 1_000_000_000.0
}
pub(crate) fn saturating_add(self, other: Self) -> Self {
Self(self.0.saturating_add(other.0))
}
}
impl fmt::Display for Money {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "${:.9}", self.as_usd())
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum TextModel {
FlashLite,
Pro,
}
impl TextModel {
pub const fn as_str(self) -> &'static str {
match self {
Self::FlashLite => GEMINI_31_FLASH_LITE,
Self::Pro => GEMINI_31_PRO,
}
}
}
impl fmt::Display for TextModel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
pub enum ServiceTier {
#[default]
Standard,
Priority,
}
impl ServiceTier {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Standard => "standard",
Self::Priority => "priority",
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum ThinkingLevel {
Minimal,
Low,
Medium,
High,
}
impl ThinkingLevel {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct GenerationOptions {
pub max_output_tokens: Option<u32>,
pub temperature: Option<f32>,
pub thinking_level: Option<ThinkingLevel>,
pub service_tier: ServiceTier,
}
impl Default for GenerationOptions {
fn default() -> Self {
Self {
max_output_tokens: Some(8_192),
temperature: None,
thinking_level: None,
service_tier: ServiceTier::Standard,
}
}
}
impl GenerationOptions {
pub(crate) fn validate(&self, model: TextModel) -> Result<()> {
if let Some(maximum) = self.max_output_tokens {
validate_output_tokens(maximum, MAX_TEXT_OUTPUT_TOKENS)?;
}
if self
.temperature
.is_some_and(|value| !value.is_finite() || !(0.0..=2.0).contains(&value))
{
return Err(Error::InvalidInput(
"temperature must be finite and between 0 and 2".into(),
));
}
if model == TextModel::Pro && self.thinking_level == Some(ThinkingLevel::Minimal) {
return Err(Error::InvalidInput(
"Gemini 3.1 Pro does not support minimal thinking".into(),
));
}
Ok(())
}
pub(crate) fn generation_config(&self) -> Value {
let mut value = json!({});
let object = value.as_object_mut().expect("configuration is an object");
if let Some(maximum) = self.max_output_tokens {
object.insert("max_output_tokens".into(), json!(maximum));
}
if let Some(temperature) = self.temperature {
object.insert("temperature".into(), json!(temperature));
}
if let Some(level) = self.thinking_level {
object.insert("thinking_level".into(), json!(level.as_str()));
}
value
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct StructuredOutput {
schema: Value,
}
impl StructuredOutput {
pub fn new(schema: Value) -> Result<Self> {
let object = schema.as_object().ok_or_else(|| {
Error::InvalidInput("structured-output schema must be a JSON object".into())
})?;
if object.is_empty() {
return Err(Error::InvalidInput(
"structured-output schema must not be empty".into(),
));
}
if serde_json::to_vec(&schema)
.map_err(|error| Error::InvalidInput(format!("invalid JSON Schema: {error}")))?
.len()
> MAX_STRUCTURED_OUTPUT_SCHEMA_BYTES
{
return Err(Error::InvalidInput(format!(
"structured-output schema exceeds the {MAX_STRUCTURED_OUTPUT_SCHEMA_BYTES}-byte safety limit"
)));
}
Ok(Self { schema })
}
pub fn schema(&self) -> &Value {
&self.schema
}
pub(crate) fn response_format(&self) -> Value {
json!({
"type":"text",
"mime_type":"application/json",
"schema":self.schema,
})
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct InferenceRequest {
pub prompt: String,
pub system_instruction: Option<String>,
pub structured_output: Option<StructuredOutput>,
pub options: GenerationOptions,
}
impl InferenceRequest {
pub fn new(prompt: impl Into<String>) -> Self {
Self {
prompt: prompt.into(),
system_instruction: None,
structured_output: None,
options: GenerationOptions::default(),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum MediaKind {
Image,
Audio,
Video,
}
impl MediaKind {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Image => "image",
Self::Audio => "audio",
Self::Video => "video",
}
}
}
#[derive(Clone, Eq, PartialEq)]
pub struct MediaInput {
kind: MediaKind,
mime_type: String,
data: Vec<u8>,
}
impl MediaInput {
pub fn image(mime_type: impl Into<String>, data: Vec<u8>) -> Result<Self> {
Self::new(MediaKind::Image, mime_type.into(), data)
}
pub fn audio(mime_type: impl Into<String>, data: Vec<u8>) -> Result<Self> {
Self::new(MediaKind::Audio, mime_type.into(), data)
}
pub fn video(mime_type: impl Into<String>, data: Vec<u8>) -> Result<Self> {
Self::new(MediaKind::Video, mime_type.into(), data)
}
pub const fn kind(&self) -> MediaKind {
self.kind
}
pub fn mime_type(&self) -> &str {
&self.mime_type
}
pub fn len(&self) -> usize {
self.data.len()
}
pub fn is_empty(&self) -> bool {
self.data.is_empty()
}
fn new(kind: MediaKind, mime_type: String, data: Vec<u8>) -> Result<Self> {
if data.is_empty() {
return Err(Error::InvalidInput("media data must not be empty".into()));
}
let valid = match kind {
MediaKind::Image => matches!(
mime_type.as_str(),
"image/png"
| "image/jpeg"
| "image/webp"
| "image/heic"
| "image/heif"
| "image/gif"
| "image/bmp"
| "image/tiff"
),
MediaKind::Audio => matches!(
mime_type.as_str(),
"audio/wav"
| "audio/mp3"
| "audio/aiff"
| "audio/aac"
| "audio/ogg"
| "audio/flac"
| "audio/mpeg"
| "audio/m4a"
| "audio/l16"
| "audio/opus"
| "audio/alaw"
| "audio/mulaw"
),
MediaKind::Video => matches!(
mime_type.as_str(),
"video/mp4"
| "video/mpeg"
| "video/mov"
| "video/quicktime"
| "video/avi"
| "video/x-flv"
| "video/mpg"
| "video/webm"
| "video/wmv"
| "video/3gpp"
),
};
if !valid {
return Err(Error::InvalidInput(format!(
"unsupported {} MIME type {mime_type:?}",
kind.as_str()
)));
}
Ok(Self {
kind,
mime_type,
data,
})
}
pub(crate) fn interaction_value(&self) -> Value {
json!({
"type": self.kind.as_str(),
"mime_type": self.mime_type,
"data": STANDARD.encode(&self.data),
})
}
}
impl fmt::Debug for MediaInput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MediaInput")
.field("kind", &self.kind)
.field("mime_type", &self.mime_type)
.field("bytes", &self.data.len())
.finish()
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct MultimodalRequest {
pub prompt: String,
pub media: Vec<MediaInput>,
pub system_instruction: Option<String>,
pub structured_output: Option<StructuredOutput>,
pub options: GenerationOptions,
}
impl MultimodalRequest {
pub fn new(prompt: impl Into<String>, media: Vec<MediaInput>) -> Self {
Self {
prompt: prompt.into(),
media,
system_instruction: None,
structured_output: None,
options: GenerationOptions::default(),
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
pub enum AspectRatio {
#[default]
Square,
ThreeTwo,
TwoThree,
FourThree,
ThreeFour,
FiveFour,
FourFive,
SixteenNine,
NineSixteen,
TwentyOneNine,
}
impl AspectRatio {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Square => "1:1",
Self::ThreeTwo => "3:2",
Self::TwoThree => "2:3",
Self::FourThree => "4:3",
Self::ThreeFour => "3:4",
Self::FiveFour => "5:4",
Self::FourFive => "4:5",
Self::SixteenNine => "16:9",
Self::NineSixteen => "9:16",
Self::TwentyOneNine => "21:9",
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct NanoBananaProRequest {
pub prompt: String,
pub images: Vec<MediaInput>,
pub aspect_ratio: AspectRatio,
pub options: GenerationOptions,
}
impl NanoBananaProRequest {
pub fn new(prompt: impl Into<String>) -> Self {
Self {
prompt: prompt.into(),
images: Vec::new(),
aspect_ratio: AspectRatio::default(),
options: GenerationOptions::default(),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct GroundedSearchRequest {
pub question: String,
pub options: GenerationOptions,
}
impl GroundedSearchRequest {
pub fn new(question: impl Into<String>) -> Self {
Self {
question: question.into(),
options: GenerationOptions {
max_output_tokens: None,
temperature: None,
thinking_level: Some(ThinkingLevel::Low),
service_tier: ServiceTier::Priority,
},
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum CompletionStatus {
Completed,
Incomplete,
}
#[derive(Clone, Eq, PartialEq)]
pub struct GeneratedImage {
pub mime_type: String,
pub data: Vec<u8>,
}
impl fmt::Debug for GeneratedImage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("GeneratedImage")
.field("mime_type", &self.mime_type)
.field("bytes", &self.data.len())
.finish()
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum Modality {
Text,
Image,
Audio,
Video,
Document,
Unknown,
}
impl Modality {
pub(crate) fn parse(value: &str) -> Self {
match value {
"text" => Self::Text,
"image" => Self::Image,
"audio" => Self::Audio,
"video" => Self::Video,
"document" => Self::Document,
_ => Self::Unknown,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ModalityTokens {
pub modality: Modality,
pub tokens: u64,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct TokenUsage {
pub input_tokens: u64,
pub cached_tokens: u64,
pub output_tokens: u64,
pub thought_tokens: u64,
pub tool_use_tokens: u64,
pub total_tokens: u64,
pub input_by_modality: Vec<ModalityTokens>,
pub cached_by_modality: Vec<ModalityTokens>,
pub output_by_modality: Vec<ModalityTokens>,
pub tool_use_by_modality: Vec<ModalityTokens>,
pub grounding_search_queries: u64,
}
impl TokenUsage {
pub(crate) fn modality_total(values: &[ModalityTokens], modality: Modality) -> u64 {
values
.iter()
.filter(|entry| entry.modality == modality)
.map(|entry| entry.tokens)
.sum()
}
pub(crate) fn saturating_add(&mut self, other: &Self) {
self.input_tokens = self.input_tokens.saturating_add(other.input_tokens);
self.cached_tokens = self.cached_tokens.saturating_add(other.cached_tokens);
self.output_tokens = self.output_tokens.saturating_add(other.output_tokens);
self.thought_tokens = self.thought_tokens.saturating_add(other.thought_tokens);
self.tool_use_tokens = self.tool_use_tokens.saturating_add(other.tool_use_tokens);
self.total_tokens = self.total_tokens.saturating_add(other.total_tokens);
self.grounding_search_queries = self
.grounding_search_queries
.saturating_add(other.grounding_search_queries);
add_modalities(&mut self.input_by_modality, &other.input_by_modality);
add_modalities(&mut self.cached_by_modality, &other.cached_by_modality);
add_modalities(&mut self.output_by_modality, &other.output_by_modality);
add_modalities(&mut self.tool_use_by_modality, &other.tool_use_by_modality);
}
}
fn add_modalities(target: &mut Vec<ModalityTokens>, source: &[ModalityTokens]) {
for entry in source {
if let Some(existing) = target
.iter_mut()
.find(|value| value.modality == entry.modality)
{
existing.tokens = existing.tokens.saturating_add(entry.tokens);
} else {
target.push(entry.clone());
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum CostAccuracy {
Exact,
Estimated,
Conservative,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CostBreakdown {
pub input: Money,
pub cached_input: Money,
pub text_output_and_thinking: Money,
pub image_output: Money,
pub grounding: Money,
pub total: Money,
pub accuracy: CostAccuracy,
pub pricing_version: String,
}
impl Default for CostBreakdown {
fn default() -> Self {
Self {
input: Money::ZERO,
cached_input: Money::ZERO,
text_output_and_thinking: Money::ZERO,
image_output: Money::ZERO,
grounding: Money::ZERO,
total: Money::ZERO,
accuracy: CostAccuracy::Exact,
pricing_version: "google-gemini-2026-07-20".into(),
}
}
}
impl CostBreakdown {
pub(crate) fn saturating_add(&mut self, other: &Self) {
self.input = self.input.saturating_add(other.input);
self.cached_input = self.cached_input.saturating_add(other.cached_input);
self.text_output_and_thinking = self
.text_output_and_thinking
.saturating_add(other.text_output_and_thinking);
self.image_output = self.image_output.saturating_add(other.image_output);
self.grounding = self.grounding.saturating_add(other.grounding);
self.total = self.total.saturating_add(other.total);
if other.accuracy == CostAccuracy::Conservative {
self.accuracy = CostAccuracy::Conservative;
} else if other.accuracy == CostAccuracy::Estimated && self.accuracy == CostAccuracy::Exact
{
self.accuracy = CostAccuracy::Estimated;
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct InferenceResponse {
pub id: String,
pub model: String,
pub status: CompletionStatus,
pub text: Option<String>,
pub images: Vec<GeneratedImage>,
pub usage: TokenUsage,
pub cost: CostBreakdown,
pub usage_record_id: u64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WebSource {
pub title: String,
pub url: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct GroundedSearchResponse {
pub interaction: InferenceResponse,
pub sources: Vec<WebSource>,
}
pub(crate) fn validate_prompt(prompt: &str) -> Result<()> {
if prompt.trim().is_empty() {
return Err(Error::InvalidInput("prompt must not be empty".into()));
}
if prompt.len() > MAX_PROMPT_BYTES {
return Err(Error::InvalidInput(format!(
"prompt exceeds the {MAX_PROMPT_BYTES}-byte limit"
)));
}
Ok(())
}
pub(crate) fn validate_system_instruction(value: Option<&str>) -> Result<()> {
if let Some(value) = value {
if value.trim().is_empty() {
return Err(Error::InvalidInput(
"system_instruction must be omitted instead of empty".into(),
));
}
if value.len() > MAX_PROMPT_BYTES {
return Err(Error::InvalidInput(format!(
"system_instruction exceeds the {MAX_PROMPT_BYTES}-byte limit"
)));
}
}
Ok(())
}
pub(crate) fn validate_media(media: &[MediaInput], required: bool) -> Result<()> {
if required && media.is_empty() {
return Err(Error::InvalidInput(
"multimodal inference requires at least one media input".into(),
));
}
let total = media.iter().try_fold(0_usize, |total, input| {
total
.checked_add(input.len())
.ok_or_else(|| Error::InvalidInput("aggregate media size overflowed".into()))
})?;
if total > MAX_INLINE_MEDIA_BYTES {
return Err(Error::InvalidInput(format!(
"aggregate inline media exceeds the {MAX_INLINE_MEDIA_BYTES}-byte safety limit"
)));
}
Ok(())
}
pub(crate) fn validate_output_tokens(value: u32, maximum: u32) -> Result<()> {
if value == 0 || value > maximum {
return Err(Error::InvalidInput(format!(
"max_output_tokens must be between 1 and {maximum}"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const VIDEO_MIME_TYPES: &[&str] = &[
"video/mp4",
"video/mpeg",
"video/mov",
"video/quicktime",
"video/avi",
"video/x-flv",
"video/mpg",
"video/webm",
"video/wmv",
"video/3gpp",
];
#[test]
fn media_debug_redacts_bytes() {
let media = MediaInput::video("video/mp4", b"sensitive bytes".to_vec()).unwrap();
let debug = format!("{media:?}");
assert!(debug.contains("Video"));
assert!(debug.contains("video/mp4"));
assert!(debug.contains("bytes: 15"));
assert!(!debug.contains("sensitive"));
}
#[test]
fn every_supported_video_mime_type_is_accepted() {
for mime_type in VIDEO_MIME_TYPES {
let media = MediaInput::video(*mime_type, vec![1]).unwrap();
assert_eq!(media.kind(), MediaKind::Video);
assert_eq!(media.mime_type(), *mime_type);
}
}
#[test]
fn invalid_or_empty_video_is_rejected() {
assert!(MediaInput::video("application/octet-stream", vec![1]).is_err());
assert!(MediaInput::video("Video/MP4", vec![1]).is_err());
assert!(MediaInput::video("video/mp4", Vec::new()).is_err());
}
#[test]
fn pro_rejects_minimal_thinking() {
let options = GenerationOptions {
thinking_level: Some(ThinkingLevel::Minimal),
..GenerationOptions::default()
};
assert!(options.validate(TextModel::Pro).is_err());
assert!(options.validate(TextModel::FlashLite).is_ok());
}
#[test]
fn video_uses_current_interactions_schema() {
let value = MediaInput::video("video/mp4", vec![1, 2, 3])
.unwrap()
.interaction_value();
assert_eq!(value["type"], "video");
assert_eq!(value["mime_type"], "video/mp4");
assert_eq!(value["data"], "AQID");
}
#[test]
fn nano_banana_aspect_ratios_include_five_four_pair() {
assert_eq!(AspectRatio::FiveFour.as_str(), "5:4");
assert_eq!(AspectRatio::FourFive.as_str(), "4:5");
}
#[test]
fn structured_output_requires_a_nonempty_schema_object() {
assert!(StructuredOutput::new(json!({"type":"object"})).is_ok());
assert!(StructuredOutput::new(json!({})).is_err());
assert!(StructuredOutput::new(json!(["not", "a", "schema"])).is_err());
}
}