use base64::Engine;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::BTreeSet;
use std::fmt;
use crate::content::{
Annotation, CodeExecutionLanguage, Content, FileSearchResultItem, GoogleSearchResultItem,
};
use crate::errors::GenaiError;
use crate::request::{InteractionInput, ServiceTier};
use crate::steps::{FunctionResultPayload, Step};
use crate::tools::Tool;
fn deserialize_token_count<'de, D>(deserializer: D) -> Result<u32, D::Error>
where
D: Deserializer<'de>,
{
let value = i64::deserialize(deserializer)?;
if value < 0 {
tracing::warn!(
"Received negative token count from API: {}. Clamping to 0.",
value
);
Ok(0)
} else if value > u32::MAX as i64 {
tracing::warn!(
"Token count exceeds u32::MAX: {}. Clamping to u32::MAX.",
value
);
Ok(u32::MAX)
} else {
Ok(value as u32)
}
}
fn deserialize_optional_token_count<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
where
D: Deserializer<'de>,
{
let value: Option<i64> = Option::deserialize(deserializer)?;
match value {
None => Ok(None),
Some(v) if v < 0 => {
tracing::warn!(
"Received negative token count from API: {}. Clamping to 0.",
v
);
Ok(Some(0))
}
Some(v) if v > u32::MAX as i64 => {
tracing::warn!("Token count exceeds u32::MAX: {}. Clamping to u32::MAX.", v);
Ok(Some(u32::MAX))
}
Some(v) => Ok(Some(v as u32)),
}
}
#[derive(Clone, Debug, Default, PartialEq)]
#[non_exhaustive]
pub enum InteractionStatus {
Completed,
#[default]
InProgress,
RequiresAction,
Failed,
Cancelled,
Incomplete,
BudgetExceeded,
Unknown {
status_type: String,
data: serde_json::Value,
},
}
impl InteractionStatus {
#[must_use]
pub const fn is_unknown(&self) -> bool {
matches!(self, Self::Unknown { .. })
}
#[must_use]
pub fn unknown_status_type(&self) -> Option<&str> {
match self {
Self::Unknown { status_type, .. } => Some(status_type),
_ => None,
}
}
#[must_use]
pub fn unknown_data(&self) -> Option<&serde_json::Value> {
match self {
Self::Unknown { data, .. } => Some(data),
_ => None,
}
}
}
impl Serialize for InteractionStatus {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Self::Completed => serializer.serialize_str("completed"),
Self::InProgress => serializer.serialize_str("in_progress"),
Self::RequiresAction => serializer.serialize_str("requires_action"),
Self::Failed => serializer.serialize_str("failed"),
Self::Cancelled => serializer.serialize_str("cancelled"),
Self::Incomplete => serializer.serialize_str("incomplete"),
Self::BudgetExceeded => serializer.serialize_str("budget_exceeded"),
Self::Unknown { status_type, .. } => serializer.serialize_str(status_type),
}
}
}
impl<'de> Deserialize<'de> for InteractionStatus {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match value.as_str() {
Some("completed") => Ok(Self::Completed),
Some("in_progress") => Ok(Self::InProgress),
Some("requires_action") => Ok(Self::RequiresAction),
Some("failed") => Ok(Self::Failed),
Some("cancelled") => Ok(Self::Cancelled),
Some("incomplete") => Ok(Self::Incomplete),
Some("budget_exceeded") => Ok(Self::BudgetExceeded),
Some(other) => {
tracing::warn!(
"Encountered unknown InteractionStatus '{}'. \
This may indicate a new API feature. \
The status will be preserved in the Unknown variant.",
other
);
Ok(Self::Unknown {
status_type: other.to_string(),
data: value,
})
}
None => {
let status_type = format!("<non-string: {}>", value);
tracing::warn!(
"InteractionStatus received non-string value: {}. \
Preserving in Unknown variant.",
value
);
Ok(Self::Unknown {
status_type,
data: value,
})
}
}
}
}
#[derive(Clone, Deserialize, Serialize, Debug, PartialEq)]
pub struct ModalityTokens {
pub modality: String,
#[serde(deserialize_with = "deserialize_token_count")]
pub tokens: u32,
}
#[derive(Clone, Deserialize, Serialize, Debug, Default, PartialEq, Eq)]
#[serde(default)]
pub struct GroundingToolCount {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub tool_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub count: Option<u32>,
}
#[derive(Clone, Deserialize, Serialize, Debug, Default, PartialEq)]
#[serde(default)]
pub struct UsageMetadata {
#[serde(
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_optional_token_count"
)]
pub total_input_tokens: Option<u32>,
#[serde(
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_optional_token_count"
)]
pub total_output_tokens: Option<u32>,
#[serde(
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_optional_token_count"
)]
pub total_tokens: Option<u32>,
#[serde(
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_optional_token_count"
)]
pub total_cached_tokens: Option<u32>,
#[serde(
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_optional_token_count"
)]
pub total_thought_tokens: Option<u32>,
#[serde(
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_optional_token_count"
)]
pub total_tool_use_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_tokens_by_modality: Option<Vec<ModalityTokens>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_tokens_by_modality: Option<Vec<ModalityTokens>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cached_tokens_by_modality: Option<Vec<ModalityTokens>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_use_tokens_by_modality: Option<Vec<ModalityTokens>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub grounding_tool_count: Option<Vec<GroundingToolCount>>,
}
impl UsageMetadata {
#[must_use]
pub fn has_data(&self) -> bool {
self.total_tokens.is_some()
|| self.total_input_tokens.is_some()
|| self.total_output_tokens.is_some()
|| self.total_cached_tokens.is_some()
|| self.total_thought_tokens.is_some()
|| self.total_tool_use_tokens.is_some()
|| self.input_tokens_by_modality.is_some()
|| self.output_tokens_by_modality.is_some()
|| self.cached_tokens_by_modality.is_some()
|| self.tool_use_tokens_by_modality.is_some()
|| self.grounding_tool_count.is_some()
}
#[must_use]
pub fn thought_tokens(&self) -> Option<u32> {
self.total_thought_tokens
}
#[must_use]
pub fn input_tokens_for_modality(&self, modality: &str) -> Option<u32> {
self.input_tokens_by_modality
.as_ref()?
.iter()
.find(|m| m.modality == modality)
.map(|m| m.tokens)
}
#[must_use]
pub fn grounding_count_for_tool(&self, tool_type: &str) -> Option<u32> {
self.grounding_tool_count
.as_ref()?
.iter()
.find(|g| g.tool_type.as_deref() == Some(tool_type))
.and_then(|g| g.count)
}
#[must_use]
pub fn cache_hit_rate(&self) -> Option<f32> {
let cached = self.total_cached_tokens? as f32;
let total = self.total_input_tokens? as f32;
if total > 0.0 {
Some(cached / total)
} else {
None
}
}
pub(crate) fn accumulate(&mut self, other: &UsageMetadata) {
fn add_option(a: &mut Option<u32>, b: Option<u32>) {
if let Some(b_val) = b {
*a = Some(a.unwrap_or(0).saturating_add(b_val));
}
}
add_option(&mut self.total_input_tokens, other.total_input_tokens);
add_option(&mut self.total_output_tokens, other.total_output_tokens);
add_option(&mut self.total_tokens, other.total_tokens);
add_option(&mut self.total_cached_tokens, other.total_cached_tokens);
add_option(&mut self.total_thought_tokens, other.total_thought_tokens);
add_option(&mut self.total_tool_use_tokens, other.total_tool_use_tokens);
}
}
#[derive(Debug, Clone)]
pub struct ImageInfo<'a> {
data: &'a str,
mime_type: Option<&'a str>,
}
impl ImageInfo<'_> {
#[must_use = "this `Result` should be used to handle potential decode errors"]
pub fn bytes(&self) -> Result<Vec<u8>, GenaiError> {
base64::engine::general_purpose::STANDARD
.decode(self.data)
.map_err(|e| GenaiError::InvalidInput(format!("Invalid base64 image data: {}", e)))
}
#[must_use]
pub fn mime_type(&self) -> Option<&str> {
self.mime_type
}
#[must_use]
pub fn extension(&self) -> &str {
match self.mime_type {
Some("image/jpeg") | Some("image/jpg") => "jpg",
Some("image/png") => "png",
Some("image/webp") => "webp",
Some("image/gif") => "gif",
Some(unknown) => {
tracing::warn!(
"Unknown image MIME type '{}', defaulting to 'png' extension. \
Consider updating genai-rs to handle this type.",
unknown
);
"png"
}
None => "png", }
}
}
#[derive(Debug, Clone)]
pub struct AudioInfo<'a> {
data: &'a str,
mime_type: Option<&'a str>,
sample_rate: Option<u32>,
channels: Option<u32>,
}
impl AudioInfo<'_> {
#[must_use = "this `Result` should be used to handle potential decode errors"]
pub fn bytes(&self) -> Result<Vec<u8>, GenaiError> {
base64::engine::general_purpose::STANDARD
.decode(self.data)
.map_err(|e| GenaiError::InvalidInput(format!("Invalid base64 audio data: {}", e)))
}
#[must_use]
pub fn mime_type(&self) -> Option<&str> {
self.mime_type
}
#[must_use]
pub fn sample_rate(&self) -> Option<u32> {
self.sample_rate
}
#[must_use]
pub fn channels(&self) -> Option<u32> {
self.channels
}
#[must_use]
pub fn extension(&self) -> &str {
match self.mime_type {
Some("audio/wav") | Some("audio/x-wav") => "wav",
Some("audio/mp3") | Some("audio/mpeg") => "mp3",
Some("audio/ogg") => "ogg",
Some("audio/flac") => "flac",
Some("audio/aac") => "aac",
Some("audio/webm") => "webm",
Some(mime) if mime.starts_with("audio/L16") || mime.starts_with("audio/l16") => "pcm",
Some(unknown) => {
tracing::warn!(
"Unknown audio MIME type '{}', defaulting to 'wav' extension. \
Consider updating genai-rs to handle this type.",
unknown
);
"wav"
}
None => "wav", }
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct FunctionCallInfo<'a> {
pub id: &'a str,
pub name: &'a str,
pub args: &'a serde_json::Value,
}
impl FunctionCallInfo<'_> {
#[must_use]
pub fn to_owned(&self) -> OwnedFunctionCallInfo {
OwnedFunctionCallInfo {
id: self.id.to_string(),
name: self.name.to_string(),
args: self.args.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OwnedFunctionCallInfo {
pub id: String,
pub name: String,
pub args: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct FunctionResultInfo<'a> {
pub name: Option<&'a str>,
pub call_id: &'a str,
pub result: &'a FunctionResultPayload,
pub is_error: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[non_exhaustive]
pub struct CodeExecutionCallInfo<'a> {
pub id: &'a str,
pub language: CodeExecutionLanguage,
pub code: &'a str,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[non_exhaustive]
pub struct CodeExecutionResultInfo<'a> {
pub call_id: &'a str,
pub is_error: bool,
pub result: &'a str,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[non_exhaustive]
pub struct UrlContextResultInfo<'a> {
pub call_id: &'a str,
pub items: &'a [crate::UrlContextResultItem],
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[non_exhaustive]
pub struct GoogleMapsResultInfo<'a> {
pub call_id: &'a str,
pub items: &'a [crate::GoogleMapsResultItem],
}
#[derive(Clone, Deserialize, Serialize, Debug, Default)]
#[serde(default)]
pub struct InteractionResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input: Option<InteractionInput>,
pub steps: Vec<Step>,
pub status: InteractionStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<UsageMetadata>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<Tool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_interaction_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub environment_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub object: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<ServiceTier>,
#[serde(skip_serializing_if = "Option::is_none")]
pub webhook_config: Option<crate::webhooks::WebhookConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated: Option<DateTime<Utc>>,
}
impl InteractionResponse {
pub fn output_contents(&self) -> impl Iterator<Item = &Content> {
self.steps.iter().flat_map(|step| match step {
Step::ModelOutput { content, .. } => content.as_slice(),
_ => &[],
})
}
#[must_use]
pub fn output_steps(&self) -> Vec<Step> {
self.steps.clone()
}
#[must_use]
pub fn as_text(&self) -> Option<&str> {
self.output_contents()
.find_map(Content::as_text)
.or(self.output_text.as_deref())
}
#[must_use]
pub fn all_text(&self) -> String {
let text: String = self
.output_contents()
.filter_map(Content::as_text)
.collect::<Vec<_>>()
.join("");
if text.is_empty() {
self.output_text.clone().unwrap_or_default()
} else {
text
}
}
#[must_use]
pub fn has_text(&self) -> bool {
self.output_contents().any(|c| c.as_text().is_some()) || self.output_text.is_some()
}
#[must_use]
pub fn has_annotations(&self) -> bool {
self.output_contents().any(|c| c.annotations().is_some())
}
pub fn all_annotations(&self) -> impl Iterator<Item = &Annotation> {
self.output_contents()
.filter_map(|c| c.annotations())
.flatten()
}
pub fn first_image_bytes(&self) -> Result<Option<Vec<u8>>, GenaiError> {
for content in self.output_contents() {
if let Content::Image {
data: Some(base64_data),
..
} = content
{
let bytes = base64::engine::general_purpose::STANDARD
.decode(base64_data)
.map_err(|e| {
GenaiError::MalformedResponse(format!("Invalid base64 image data: {}", e))
})?;
return Ok(Some(bytes));
}
}
Ok(None)
}
pub fn images(&self) -> impl Iterator<Item = ImageInfo<'_>> {
self.output_contents().filter_map(|content| {
if let Content::Image {
data: Some(base64_data),
mime_type,
..
} = content
{
Some(ImageInfo {
data: base64_data.as_str(),
mime_type: mime_type.as_deref(),
})
} else {
None
}
})
}
#[must_use]
pub fn has_images(&self) -> bool {
self.output_contents()
.any(|c| matches!(c, Content::Image { data: Some(_), .. }))
}
#[must_use]
pub fn first_audio(&self) -> Option<AudioInfo<'_>> {
self.audios().next()
}
pub fn audios(&self) -> impl Iterator<Item = AudioInfo<'_>> {
self.output_contents().filter_map(|content| {
if let Content::Audio {
data: Some(base64_data),
mime_type,
sample_rate,
channels,
..
} = content
{
Some(AudioInfo {
data: base64_data.as_str(),
mime_type: mime_type.as_deref(),
sample_rate: *sample_rate,
channels: *channels,
})
} else {
None
}
})
}
#[must_use]
pub fn has_audio(&self) -> bool {
self.output_contents()
.any(|c| matches!(c, Content::Audio { data: Some(_), .. }))
}
#[must_use]
pub fn function_calls(&self) -> Vec<FunctionCallInfo<'_>> {
self.steps
.iter()
.filter_map(|step| {
if let Step::FunctionCall {
id,
name,
arguments,
..
} = step
{
Some(FunctionCallInfo {
id: id.as_str(),
name: name.as_str(),
args: arguments,
})
} else {
None
}
})
.collect()
}
#[must_use]
pub fn has_function_calls(&self) -> bool {
self.steps
.iter()
.any(|s| matches!(s, Step::FunctionCall { .. }))
}
#[must_use]
pub fn has_function_results(&self) -> bool {
self.steps
.iter()
.any(|s| matches!(s, Step::FunctionResult { .. }))
}
#[must_use]
pub fn function_results(&self) -> Vec<FunctionResultInfo<'_>> {
self.steps
.iter()
.filter_map(|step| {
if let Step::FunctionResult {
name,
call_id,
result,
is_error,
..
} = step
{
Some(FunctionResultInfo {
name: name.as_deref(),
call_id: call_id.as_str(),
result,
is_error: *is_error,
})
} else {
None
}
})
.collect()
}
#[must_use]
pub fn has_thoughts(&self) -> bool {
self.steps.iter().any(|s| {
matches!(
s,
Step::Thought {
signature: Some(_),
..
}
)
})
}
pub fn thought_signatures(&self) -> impl Iterator<Item = &str> {
self.steps.iter().filter_map(|s| match s {
Step::Thought {
signature: Some(sig),
..
} => Some(sig.as_str()),
_ => None,
})
}
pub fn thought_summaries(&self) -> impl Iterator<Item = &Content> {
self.steps.iter().flat_map(|s| match s {
Step::Thought { summary, .. } => summary.as_slice(),
_ => &[],
})
}
#[must_use]
pub fn has_unknown(&self) -> bool {
self.steps.iter().any(|s| matches!(s, Step::Unknown { .. }))
}
#[must_use]
pub fn unknown_steps(&self) -> Vec<(&str, &serde_json::Value)> {
self.steps
.iter()
.filter_map(|step| {
if let Step::Unknown { step_type, data } = step {
Some((step_type.as_str(), data))
} else {
None
}
})
.collect()
}
#[must_use]
pub fn has_code_execution_calls(&self) -> bool {
self.steps
.iter()
.any(|s| matches!(s, Step::CodeExecutionCall { .. }))
}
#[must_use]
pub fn code_execution_call(&self) -> Option<CodeExecutionCallInfo<'_>> {
self.code_execution_calls().into_iter().next()
}
#[must_use]
pub fn code_execution_calls(&self) -> Vec<CodeExecutionCallInfo<'_>> {
self.steps
.iter()
.filter_map(|step| {
if let Step::CodeExecutionCall {
id, language, code, ..
} = step
{
Some(CodeExecutionCallInfo {
id: id.as_str(),
language: language.clone(),
code: code.as_str(),
})
} else {
None
}
})
.collect()
}
#[must_use]
pub fn has_code_execution_results(&self) -> bool {
self.steps
.iter()
.any(|s| matches!(s, Step::CodeExecutionResult { .. }))
}
#[must_use]
pub fn code_execution_results(&self) -> Vec<CodeExecutionResultInfo<'_>> {
self.steps
.iter()
.filter_map(|step| {
if let Step::CodeExecutionResult {
call_id,
is_error,
result,
..
} = step
{
Some(CodeExecutionResultInfo {
call_id: call_id.as_str(),
is_error: *is_error,
result: result.as_str(),
})
} else {
None
}
})
.collect()
}
#[must_use]
pub fn successful_code_output(&self) -> Option<&str> {
self.steps.iter().find_map(|step| {
if let Step::CodeExecutionResult {
is_error: false,
result,
..
} = step
{
Some(result.as_str())
} else {
None
}
})
}
#[must_use]
pub fn has_google_search_calls(&self) -> bool {
self.steps
.iter()
.any(|s| matches!(s, Step::GoogleSearchCall { .. }))
}
#[must_use]
pub fn google_search_call(&self) -> Option<&str> {
self.steps.iter().find_map(|step| {
if let Step::GoogleSearchCall { queries, .. } = step {
queries.iter().find(|q| !q.is_empty()).map(|q| q.as_str())
} else {
None
}
})
}
#[must_use]
pub fn google_search_calls(&self) -> Vec<&str> {
self.steps
.iter()
.filter_map(|step| {
if let Step::GoogleSearchCall { queries, .. } = step {
Some(queries.iter().map(|q| q.as_str()))
} else {
None
}
})
.flatten()
.collect()
}
#[must_use]
pub fn has_google_search_results(&self) -> bool {
self.steps
.iter()
.any(|s| matches!(s, Step::GoogleSearchResult { .. }))
}
#[must_use]
pub fn google_search_results(&self) -> Vec<&GoogleSearchResultItem> {
self.steps
.iter()
.filter_map(|step| {
if let Step::GoogleSearchResult { result, .. } = step {
Some(result.iter())
} else {
None
}
})
.flatten()
.collect()
}
#[must_use]
pub fn has_url_context_calls(&self) -> bool {
self.steps
.iter()
.any(|s| matches!(s, Step::UrlContextCall { .. }))
}
#[must_use]
pub fn url_context_call_id(&self) -> Option<&str> {
self.steps.iter().find_map(|step| {
if let Step::UrlContextCall { id, .. } = step {
Some(id.as_str())
} else {
None
}
})
}
#[must_use]
pub fn url_context_call_urls(&self) -> Vec<&str> {
self.steps
.iter()
.filter_map(|step| {
if let Step::UrlContextCall { urls, .. } = step {
Some(urls.iter().map(String::as_str))
} else {
None
}
})
.flatten()
.collect()
}
#[must_use]
pub fn has_url_context_results(&self) -> bool {
self.steps
.iter()
.any(|s| matches!(s, Step::UrlContextResult { .. }))
}
#[must_use]
pub fn url_context_results(&self) -> Vec<UrlContextResultInfo<'_>> {
self.steps
.iter()
.filter_map(|step| {
if let Step::UrlContextResult {
call_id, result, ..
} = step
{
Some(UrlContextResultInfo {
call_id: call_id.as_str(),
items: result,
})
} else {
None
}
})
.collect()
}
#[must_use]
pub fn has_file_search_results(&self) -> bool {
self.steps
.iter()
.any(|s| matches!(s, Step::FileSearchResult { .. }))
}
#[must_use]
pub fn file_search_results(&self) -> Vec<&FileSearchResultItem> {
self.steps
.iter()
.filter_map(|step| {
if let Step::FileSearchResult { result, .. } = step {
Some(result.iter())
} else {
None
}
})
.flatten()
.collect()
}
#[must_use]
pub fn has_google_maps_results(&self) -> bool {
self.steps
.iter()
.any(|s| matches!(s, Step::GoogleMapsResult { .. }))
}
#[must_use]
pub fn google_maps_results(&self) -> Vec<GoogleMapsResultInfo<'_>> {
self.steps
.iter()
.filter_map(|step| {
if let Step::GoogleMapsResult {
call_id, result, ..
} = step
{
Some(GoogleMapsResultInfo {
call_id: call_id.as_str(),
items: result,
})
} else {
None
}
})
.collect()
}
#[must_use]
pub fn step_summary(&self) -> StepSummary {
let mut summary = StepSummary::default();
let mut unknown_types_set = BTreeSet::new();
for step in &self.steps {
match step {
Step::UserInput { .. } => summary.user_input_count += 1,
Step::ModelOutput { content, .. } => {
summary.model_output_count += 1;
for c in content {
match c {
Content::Text { .. } => summary.text_count += 1,
Content::Image { .. } => summary.image_count += 1,
Content::Audio { .. } => summary.audio_count += 1,
Content::Video { .. } => summary.video_count += 1,
Content::Document { .. } => summary.document_count += 1,
Content::Unknown { content_type, .. } => {
summary.unknown_count += 1;
unknown_types_set.insert(content_type.clone());
}
#[allow(unreachable_patterns)]
_ => {}
}
}
}
Step::Thought { .. } => summary.thought_count += 1,
Step::FunctionCall { .. } => summary.function_call_count += 1,
Step::FunctionResult { .. } => summary.function_result_count += 1,
Step::CodeExecutionCall { .. } => summary.code_execution_call_count += 1,
Step::CodeExecutionResult { .. } => summary.code_execution_result_count += 1,
Step::GoogleSearchCall { .. } => summary.google_search_call_count += 1,
Step::GoogleSearchResult { .. } => summary.google_search_result_count += 1,
Step::UrlContextCall { .. } => summary.url_context_call_count += 1,
Step::UrlContextResult { .. } => summary.url_context_result_count += 1,
Step::McpServerToolCall { .. } => summary.mcp_server_tool_call_count += 1,
Step::McpServerToolResult { .. } => summary.mcp_server_tool_result_count += 1,
Step::FileSearchCall { .. } => summary.file_search_call_count += 1,
Step::FileSearchResult { .. } => summary.file_search_result_count += 1,
Step::GoogleMapsCall { .. } => summary.google_maps_call_count += 1,
Step::GoogleMapsResult { .. } => summary.google_maps_result_count += 1,
Step::Unknown { step_type, .. } => {
summary.unknown_count += 1;
unknown_types_set.insert(step_type.clone());
}
}
}
summary.unknown_types = unknown_types_set.into_iter().collect();
summary
}
#[must_use]
pub fn input_tokens(&self) -> Option<u32> {
self.usage.as_ref().and_then(|u| u.total_input_tokens)
}
#[must_use]
pub fn output_tokens(&self) -> Option<u32> {
self.usage.as_ref().and_then(|u| u.total_output_tokens)
}
#[must_use]
pub fn total_tokens(&self) -> Option<u32> {
self.usage.as_ref().and_then(|u| u.total_tokens)
}
#[must_use]
pub fn thought_tokens(&self) -> Option<u32> {
self.usage.as_ref().and_then(|u| u.total_thought_tokens)
}
#[must_use]
pub fn cached_tokens(&self) -> Option<u32> {
self.usage.as_ref().and_then(|u| u.total_cached_tokens)
}
#[must_use]
pub fn tool_use_tokens(&self) -> Option<u32> {
self.usage.as_ref().and_then(|u| u.total_tool_use_tokens)
}
#[must_use]
pub fn created(&self) -> Option<DateTime<Utc>> {
self.created
}
#[must_use]
pub fn updated(&self) -> Option<DateTime<Utc>> {
self.updated
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct StepSummary {
pub user_input_count: usize,
pub model_output_count: usize,
pub text_count: usize,
pub image_count: usize,
pub audio_count: usize,
pub video_count: usize,
pub document_count: usize,
pub thought_count: usize,
pub function_call_count: usize,
pub function_result_count: usize,
pub code_execution_call_count: usize,
pub code_execution_result_count: usize,
pub google_search_call_count: usize,
pub google_search_result_count: usize,
pub url_context_call_count: usize,
pub url_context_result_count: usize,
pub mcp_server_tool_call_count: usize,
pub mcp_server_tool_result_count: usize,
pub file_search_call_count: usize,
pub file_search_result_count: usize,
pub google_maps_call_count: usize,
pub google_maps_result_count: usize,
pub unknown_count: usize,
pub unknown_types: Vec<String>,
}
impl fmt::Display for StepSummary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut parts = Vec::new();
let fields: [(&str, usize); 22] = [
("user_input", self.user_input_count),
("model_output", self.model_output_count),
("text", self.text_count),
("image", self.image_count),
("audio", self.audio_count),
("video", self.video_count),
("document", self.document_count),
("thought", self.thought_count),
("function_call", self.function_call_count),
("function_result", self.function_result_count),
("code_execution_call", self.code_execution_call_count),
("code_execution_result", self.code_execution_result_count),
("google_search_call", self.google_search_call_count),
("google_search_result", self.google_search_result_count),
("url_context_call", self.url_context_call_count),
("url_context_result", self.url_context_result_count),
("mcp_server_tool_call", self.mcp_server_tool_call_count),
("mcp_server_tool_result", self.mcp_server_tool_result_count),
("file_search_call", self.file_search_call_count),
("file_search_result", self.file_search_result_count),
("google_maps_call", self.google_maps_call_count),
("google_maps_result", self.google_maps_result_count),
];
for (name, count) in fields {
if count > 0 {
parts.push(format!("{count} {name}"));
}
}
if self.unknown_count > 0 {
parts.push(format!(
"{} unknown ({:?})",
self.unknown_count, self.unknown_types
));
}
if parts.is_empty() {
write!(f, "empty")
} else {
write!(f, "{}", parts.join(", "))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_response(usage: Option<UsageMetadata>) -> InteractionResponse {
InteractionResponse {
status: InteractionStatus::Completed,
usage,
..Default::default()
}
}
fn text_response(text: &str) -> InteractionResponse {
InteractionResponse {
status: InteractionStatus::Completed,
steps: vec![Step::model_text(text)],
..Default::default()
}
}
#[test]
fn test_token_helpers_with_usage() {
let response = minimal_response(Some(UsageMetadata {
total_input_tokens: Some(100),
total_output_tokens: Some(50),
total_tokens: Some(150),
total_cached_tokens: Some(25),
total_thought_tokens: Some(10),
total_tool_use_tokens: Some(5),
..Default::default()
}));
assert_eq!(response.input_tokens(), Some(100));
assert_eq!(response.output_tokens(), Some(50));
assert_eq!(response.total_tokens(), Some(150));
assert_eq!(response.cached_tokens(), Some(25));
assert_eq!(response.thought_tokens(), Some(10));
assert_eq!(response.tool_use_tokens(), Some(5));
}
#[test]
fn test_token_helpers_without_usage() {
let response = minimal_response(None);
assert_eq!(response.input_tokens(), None);
assert_eq!(response.output_tokens(), None);
assert_eq!(response.total_tokens(), None);
assert_eq!(response.cached_tokens(), None);
assert_eq!(response.thought_tokens(), None);
assert_eq!(response.tool_use_tokens(), None);
}
#[test]
fn test_response_deserializes_steps_wire_fixture() {
let json = r#"{
"id": "interactions/abc123",
"model": "gemini-3-flash-preview",
"status": "completed",
"steps": [
{"type": "thought", "signature": "sig-1"},
{"type": "model_output", "content": [
{"type": "text", "text": "The answer is 4."}
]}
],
"usage": {
"total_input_tokens": 10,
"total_output_tokens": 8,
"total_tokens": 18,
"grounding_tool_count": [{"type": "google_search", "count": 2}]
},
"previous_interaction_id": "interactions/prev",
"environment_id": "environments/env1",
"created": "2026-05-21T10:00:00Z"
}"#;
let response: InteractionResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.id.as_deref(), Some("interactions/abc123"));
assert_eq!(response.status, InteractionStatus::Completed);
assert_eq!(response.steps.len(), 2);
assert_eq!(response.as_text(), Some("The answer is 4."));
assert_eq!(
response.thought_signatures().collect::<Vec<_>>(),
vec!["sig-1"]
);
assert_eq!(
response.environment_id.as_deref(),
Some("environments/env1")
);
let usage = response.usage.as_ref().unwrap();
assert_eq!(usage.grounding_count_for_tool("google_search"), Some(2));
assert!(response.created().is_some());
}
#[test]
fn test_response_serializes_snake_case() {
let response = InteractionResponse {
previous_interaction_id: Some("interactions/prev".into()),
status: InteractionStatus::Completed,
..Default::default()
};
let json = serde_json::to_value(&response).unwrap();
assert_eq!(json["previous_interaction_id"], "interactions/prev");
assert!(json.get("previousInteractionId").is_none());
}
#[test]
fn test_budget_exceeded_status_roundtrip() {
let status: InteractionStatus = serde_json::from_str("\"budget_exceeded\"").unwrap();
assert_eq!(status, InteractionStatus::BudgetExceeded);
assert_eq!(
serde_json::to_string(&status).unwrap(),
"\"budget_exceeded\""
);
}
#[test]
fn test_function_calls_over_steps() {
let response = InteractionResponse {
status: InteractionStatus::RequiresAction,
steps: vec![
Step::thought("sig"),
Step::function_call(
"call_1",
"get_weather",
serde_json::json!({"city": "Tokyo"}),
),
],
..Default::default()
};
let calls = response.function_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "call_1");
assert_eq!(calls[0].name, "get_weather");
assert_eq!(calls[0].args["city"], "Tokyo");
assert!(response.has_function_calls());
assert!(response.has_thoughts());
}
#[test]
fn test_as_text_falls_back_to_output_text() {
let response = InteractionResponse {
status: InteractionStatus::Completed,
output_text: Some("fallback".into()),
..Default::default()
};
assert_eq!(response.as_text(), Some("fallback"));
assert_eq!(response.all_text(), "fallback");
assert!(response.has_text());
}
#[test]
fn test_step_summary_counts() {
let response = InteractionResponse {
status: InteractionStatus::Completed,
steps: vec![
Step::user_text("hi"),
Step::thought("sig"),
Step::model_output(vec![Content::text("a"), Content::text("b")]),
Step::Unknown {
step_type: "future".into(),
data: serde_json::Value::Null,
},
],
..Default::default()
};
let summary = response.step_summary();
assert_eq!(summary.user_input_count, 1);
assert_eq!(summary.model_output_count, 1);
assert_eq!(summary.text_count, 2);
assert_eq!(summary.thought_count, 1);
assert_eq!(summary.unknown_count, 1);
assert_eq!(summary.unknown_types, vec!["future".to_string()]);
let display = summary.to_string();
assert!(display.contains("2 text"));
assert!(display.contains("1 unknown"));
}
#[test]
fn test_unknown_steps_helper() {
let response = InteractionResponse {
status: InteractionStatus::Completed,
steps: vec![Step::Unknown {
step_type: "quantum".into(),
data: serde_json::json!({"type": "quantum", "x": 1}),
}],
..Default::default()
};
assert!(response.has_unknown());
let unknown = response.unknown_steps();
assert_eq!(unknown.len(), 1);
assert_eq!(unknown[0].0, "quantum");
}
#[test]
fn test_modality_tokens_serialization() {
let tokens = ModalityTokens {
modality: "text".to_string(),
tokens: 100,
};
let json = serde_json::to_string(&tokens).unwrap();
assert!(json.contains("\"modality\":\"text\""));
assert!(json.contains("\"tokens\":100"));
let deserialized: ModalityTokens = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.modality, "text");
assert_eq!(deserialized.tokens, 100);
}
#[test]
fn test_grounding_tool_count_wire_format() {
let json = r#"{"type": "google_maps", "count": 3}"#;
let count: GroundingToolCount = serde_json::from_str(json).unwrap();
assert_eq!(count.tool_type.as_deref(), Some("google_maps"));
assert_eq!(count.count, Some(3));
let out = serde_json::to_value(&count).unwrap();
assert_eq!(out["type"], "google_maps");
assert_eq!(out["count"], 3);
}
#[test]
fn test_input_tokens_for_modality() {
let usage = UsageMetadata {
input_tokens_by_modality: Some(vec![
ModalityTokens {
modality: "text".to_string(),
tokens: 100,
},
ModalityTokens {
modality: "image".to_string(),
tokens: 500,
},
]),
..Default::default()
};
assert_eq!(usage.input_tokens_for_modality("text"), Some(100));
assert_eq!(usage.input_tokens_for_modality("image"), Some(500));
assert_eq!(usage.input_tokens_for_modality("video"), None);
}
#[test]
fn test_cache_hit_rate() {
let usage = UsageMetadata {
total_input_tokens: Some(100),
total_cached_tokens: Some(25),
..Default::default()
};
let rate = usage.cache_hit_rate().unwrap();
assert!((rate - 0.25).abs() < f32::EPSILON);
let usage = UsageMetadata {
total_input_tokens: Some(0),
total_cached_tokens: Some(0),
..Default::default()
};
assert!(usage.cache_hit_rate().is_none());
}
#[test]
fn test_has_data_with_grounding_tool_count() {
let usage = UsageMetadata {
grounding_tool_count: Some(vec![GroundingToolCount {
tool_type: Some("retrieval".into()),
count: Some(1),
}]),
..Default::default()
};
assert!(usage.has_data());
assert!(!UsageMetadata::default().has_data());
}
#[test]
fn test_negative_token_count_clamped_to_zero() {
let json = r#"{"total_input_tokens": -100, "total_output_tokens": 50}"#;
let usage: UsageMetadata = serde_json::from_str(json).unwrap();
assert_eq!(usage.total_input_tokens, Some(0));
assert_eq!(usage.total_output_tokens, Some(50));
}
#[test]
fn test_large_token_count_clamped_to_u32_max() {
let json = r#"{"total_input_tokens": 5000000000}"#;
let usage: UsageMetadata = serde_json::from_str(json).unwrap();
assert_eq!(usage.total_input_tokens, Some(u32::MAX));
}
fn make_response_with_image(base64_data: &str, mime_type: Option<&str>) -> InteractionResponse {
InteractionResponse {
id: Some("test-id".to_string()),
model: Some("test-model".to_string()),
steps: vec![Step::model_output(vec![Content::Image {
data: Some(base64_data.to_string()),
mime_type: mime_type.map(String::from),
uri: None,
resolution: None,
}])],
status: InteractionStatus::Completed,
..Default::default()
}
}
#[test]
fn test_first_image_bytes_success() {
let response = make_response_with_image("dGVzdA==", Some("image/png"));
let bytes = response.first_image_bytes().unwrap();
assert_eq!(bytes.unwrap(), b"test");
}
#[test]
fn test_first_image_bytes_no_images() {
let response = text_response("Hello");
assert!(response.first_image_bytes().unwrap().is_none());
}
#[test]
fn test_first_image_bytes_invalid_base64() {
let response = make_response_with_image("not-valid-base64!!!", Some("image/png"));
let err = response.first_image_bytes().unwrap_err().to_string();
assert!(err.contains("Invalid base64"));
}
#[test]
fn test_images_iterator() {
let response = InteractionResponse {
status: InteractionStatus::Completed,
steps: vec![Step::model_output(vec![
Content::image_data("dGVzdDE=", "image/png"),
Content::text("text between"),
Content::image_data("dGVzdDI=", "image/jpeg"),
])],
..Default::default()
};
let images: Vec<_> = response.images().collect();
assert_eq!(images.len(), 2);
assert_eq!(images[0].bytes().unwrap(), b"test1");
assert_eq!(images[0].mime_type(), Some("image/png"));
assert_eq!(images[0].extension(), "png");
assert_eq!(images[1].bytes().unwrap(), b"test2");
assert_eq!(images[1].extension(), "jpg");
}
#[test]
fn test_has_images() {
assert!(make_response_with_image("dGVzdA==", Some("image/png")).has_images());
assert!(!text_response("no images").has_images());
}
#[test]
fn test_image_info_extension() {
let check = |mime: Option<&str>, expected: &str| {
let info = ImageInfo {
data: "",
mime_type: mime,
};
assert_eq!(info.extension(), expected);
};
check(Some("image/jpeg"), "jpg");
check(Some("image/jpg"), "jpg");
check(Some("image/png"), "png");
check(Some("image/webp"), "webp");
check(Some("image/gif"), "gif");
check(Some("image/unknown"), "png"); check(None, "png"); }
#[test]
fn test_audio_info_extension() {
let check = |mime: Option<&str>, expected: &str| {
let info = AudioInfo {
data: "",
mime_type: mime,
sample_rate: None,
channels: None,
};
assert_eq!(info.extension(), expected);
};
check(Some("audio/wav"), "wav");
check(Some("audio/x-wav"), "wav");
check(Some("audio/mp3"), "mp3");
check(Some("audio/mpeg"), "mp3");
check(Some("audio/ogg"), "ogg");
check(Some("audio/flac"), "flac");
check(Some("audio/aac"), "aac");
check(Some("audio/webm"), "webm");
check(Some("audio/L16;codec=pcm;rate=24000"), "pcm");
check(Some("audio/l16"), "pcm");
check(Some("audio/unknown"), "wav"); check(None, "wav"); }
#[test]
fn test_audio_channels_and_sample_rate_exposed() {
let response = InteractionResponse {
status: InteractionStatus::Completed,
steps: vec![Step::model_output(vec![Content::Audio {
data: Some("dGVzdA==".into()),
uri: None,
mime_type: Some("audio/l16".into()),
sample_rate: Some(24000),
channels: Some(1),
}])],
..Default::default()
};
let audio = response.first_audio().unwrap();
assert_eq!(audio.sample_rate(), Some(24000));
assert_eq!(audio.channels(), Some(1));
assert!(response.has_audio());
}
#[test]
fn test_usage_metadata_accumulate_all_fields() {
let mut usage1 = UsageMetadata {
total_input_tokens: Some(100),
total_output_tokens: Some(50),
total_tokens: Some(150),
total_cached_tokens: Some(20),
total_thought_tokens: Some(5),
total_tool_use_tokens: Some(15),
..Default::default()
};
let usage2 = UsageMetadata {
total_input_tokens: Some(200),
total_output_tokens: Some(100),
total_tokens: Some(300),
total_cached_tokens: Some(40),
total_thought_tokens: Some(10),
total_tool_use_tokens: Some(30),
..Default::default()
};
usage1.accumulate(&usage2);
assert_eq!(usage1.total_input_tokens, Some(300));
assert_eq!(usage1.total_output_tokens, Some(150));
assert_eq!(usage1.total_tokens, Some(450));
assert_eq!(usage1.total_cached_tokens, Some(60));
assert_eq!(usage1.total_thought_tokens, Some(15));
assert_eq!(usage1.total_tool_use_tokens, Some(45));
}
#[test]
fn test_usage_metadata_accumulate_saturating() {
let mut usage1 = UsageMetadata {
total_input_tokens: Some(u32::MAX - 10),
..Default::default()
};
let usage2 = UsageMetadata {
total_input_tokens: Some(100),
..Default::default()
};
usage1.accumulate(&usage2);
assert_eq!(usage1.total_input_tokens, Some(u32::MAX));
}
#[test]
fn test_interaction_status_incomplete_roundtrip() {
let json = r#""incomplete""#;
let status: InteractionStatus = serde_json::from_str(json).unwrap();
assert_eq!(status, InteractionStatus::Incomplete);
let serialized = serde_json::to_string(&status).unwrap();
assert_eq!(serialized, r#""incomplete""#);
}
#[test]
fn test_interaction_status_unknown_preserved() {
let status: InteractionStatus = serde_json::from_str("\"hibernating\"").unwrap();
assert!(status.is_unknown());
assert_eq!(status.unknown_status_type(), Some("hibernating"));
assert!(status.unknown_data().is_some());
assert_eq!(serde_json::to_string(&status).unwrap(), "\"hibernating\"");
}
}