use std::env;
use std::fmt::{Debug, Formatter, Result as FmtResult};
use std::time::Duration;
use async_trait::async_trait;
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use tracing::{debug, error, instrument, warn};
use super::client::{
self, map_send_error, with_retries, AttemptError, HttpRetryConfig, DEFAULT_TIMEOUT_SECS,
};
use super::sse::create_sse_stream;
use crate::types::{
ChatMessage, ChatRequest, ChatResponse, ChatStream, ErrorKind, LlmCapabilities, LlmProvider,
MessageRole, RunnerError, StreamChunk, TokenUsage, ToolCallRequest, ToolDefinition,
};
const PROVIDER_NAME: &str = "gemini";
const GEMINI_API_KEY_ENV: &str = "GEMINI_API_KEY";
const GEMINI_DEFAULT_MODEL_ENV: &str = "GEMINI_DEFAULT_MODEL";
const GEMINI_ENV_PREFIX: &str = "GEMINI";
const DEFAULT_MODEL: &str = "gemini-flash-lite-latest";
const AVAILABLE_MODELS: &[&str] = &[
"gemini-flash-lite-latest",
"gemini-2.5-flash",
"gemini-2.0-flash",
"gemini-1.5-pro",
"gemini-1.5-flash",
];
const API_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
#[derive(Debug, Serialize)]
struct GeminiRequest {
contents: Vec<GeminiContent>,
#[serde(skip_serializing_if = "Option::is_none")]
system_instruction: Option<GeminiContent>,
#[serde(skip_serializing_if = "Option::is_none")]
generation_config: Option<GenerationConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<GeminiTool>>,
}
#[derive(Debug, Serialize, Deserialize)]
struct GeminiContent {
#[serde(skip_serializing_if = "Option::is_none")]
role: Option<String>,
#[serde(default)]
parts: Vec<ContentPart>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
enum ContentPart {
Text { text: String },
FunctionCall {
#[serde(rename = "functionCall")]
function_call: GeminiFunctionCall,
},
FunctionResponse {
#[serde(rename = "functionResponse")]
function_response: GeminiFunctionResponse,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct GeminiFunctionCall {
name: String,
args: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct GeminiFunctionResponse {
name: String,
response: serde_json::Value,
}
#[derive(Debug, Clone, Serialize)]
struct FunctionDeclaration {
name: String,
description: String,
#[serde(skip_serializing_if = "Option::is_none")]
parameters: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize)]
struct GeminiTool {
function_declarations: Vec<FunctionDeclaration>,
}
#[derive(Debug, Serialize)]
struct GenerationConfig {
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
max_output_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
candidate_count: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct GeminiResponse {
candidates: Option<Vec<Candidate>>,
#[serde(rename = "usageMetadata")]
usage_metadata: Option<UsageMetadata>,
error: Option<GeminiError>,
}
#[derive(Debug, Deserialize)]
struct Candidate {
content: Option<GeminiContent>,
#[serde(rename = "finishReason")]
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct UsageMetadata {
#[serde(rename = "promptTokenCount")]
prompt: Option<u32>,
#[serde(rename = "candidatesTokenCount")]
candidates: Option<u32>,
#[serde(rename = "totalTokenCount")]
total: Option<u32>,
#[serde(rename = "cachedContentTokenCount")]
cached: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct GeminiError {
message: String,
}
#[derive(Debug, Deserialize)]
struct StreamingResponse {
candidates: Option<Vec<Candidate>>,
}
#[derive(Clone)]
pub struct GeminiConfig {
pub api_key: String,
pub model: String,
pub timeout: Duration,
pub retry: HttpRetryConfig,
}
impl GeminiConfig {
#[must_use]
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
model: DEFAULT_MODEL.to_owned(),
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
retry: HttpRetryConfig::default(),
}
}
pub fn from_env() -> Result<Self, RunnerError> {
let api_key = env::var(GEMINI_API_KEY_ENV).map_err(|_| {
RunnerError::config(format!("{GEMINI_API_KEY_ENV} environment variable not set"))
})?;
let model = env::var(GEMINI_DEFAULT_MODEL_ENV).unwrap_or_else(|_| DEFAULT_MODEL.to_owned());
Ok(Self {
model,
retry: HttpRetryConfig::from_env(GEMINI_ENV_PREFIX),
..Self::new(api_key)
})
}
#[must_use]
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}
#[must_use]
pub const fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn with_retry(mut self, retry: HttpRetryConfig) -> Self {
self.retry = retry;
self
}
}
impl Debug for GeminiConfig {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
f.debug_struct("GeminiConfig")
.field("api_key", &"[REDACTED]")
.field("model", &self.model)
.field("timeout", &self.timeout)
.field("retry", &self.retry)
.finish()
}
}
pub struct GeminiProvider {
config: GeminiConfig,
client: reqwest::Client,
available_models: Vec<String>,
}
impl GeminiProvider {
pub fn new(config: GeminiConfig) -> Result<Self, RunnerError> {
let client = client::build_client(config.timeout)?;
Ok(Self::with_client(config, client))
}
#[must_use]
pub fn with_client(config: GeminiConfig, client: reqwest::Client) -> Self {
debug!(
model = %config.model,
max_retries = config.retry.max_retries,
initial_delay_ms = config.retry.initial_delay_ms,
max_delay_ms = config.retry.max_delay_ms,
"Gemini provider initialized"
);
Self {
config,
client,
available_models: AVAILABLE_MODELS.iter().map(|s| (*s).to_owned()).collect(),
}
}
#[must_use]
pub fn with_default_model(mut self, model: impl Into<String>) -> Self {
self.config.model = model.into();
self
}
const fn convert_role(role: MessageRole) -> &'static str {
match role {
MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
MessageRole::Assistant => "model",
}
}
fn build_url(&self, model: &str, method: &str) -> String {
format!(
"{API_BASE_URL}/models/{model}:{method}?key={}",
self.config.api_key
)
}
fn convert_messages(messages: &[ChatMessage]) -> (Vec<GeminiContent>, Option<GeminiContent>) {
let mut contents = Vec::new();
let mut system_texts: Vec<String> = Vec::new();
for message in messages {
if message.role == MessageRole::System {
system_texts.push(message.content.clone());
} else {
contents.push(GeminiContent {
role: Some(Self::convert_role(message.role).to_owned()),
parts: vec![ContentPart::Text {
text: message.content.clone(),
}],
});
}
}
let system_instruction = (!system_texts.is_empty()).then(|| GeminiContent {
role: None,
parts: vec![ContentPart::Text {
text: system_texts.join("\n\n"),
}],
});
(contents, system_instruction)
}
fn convert_tools(tools: &[ToolDefinition]) -> Vec<GeminiTool> {
vec![GeminiTool {
function_declarations: tools
.iter()
.map(|t| FunctionDeclaration {
name: t.name.clone(),
description: t.description.clone(),
parameters: t.parameters.clone(),
})
.collect(),
}]
}
fn build_gemini_request(request: &ChatRequest) -> GeminiRequest {
let (contents, system_instruction) = Self::convert_messages(&request.messages);
let generation_config = if request.temperature.is_some() || request.max_tokens.is_some() {
Some(GenerationConfig {
temperature: request.temperature,
max_output_tokens: request.max_tokens,
candidate_count: Some(1),
})
} else {
None
};
let tools = request
.tools
.as_deref()
.filter(|t| !t.is_empty())
.map(Self::convert_tools);
GeminiRequest {
contents,
system_instruction,
generation_config,
tools,
}
}
fn extract_content(response: &GeminiResponse) -> Result<String, RunnerError> {
let first = response.candidates.as_ref().and_then(|c| c.first());
let content = first.and_then(|c| c.content.as_ref());
let Some(content) = content else {
if let Some(reason) = first.and_then(|c| c.finish_reason.as_deref()) {
if reason == "SAFETY" || reason == "RECITATION" || reason == "OTHER" {
return Err(RunnerError::internal(format!(
"Response blocked by Gemini safety filter: {reason}"
)));
}
}
return Err(RunnerError::internal(
"No content in Gemini response - model may still be thinking",
));
};
let Some(part) = content.parts.first() else {
return Err(RunnerError::internal(
"Gemini response has no content parts - model may have returned thinking-only output",
));
};
match part {
ContentPart::Text { text } => Ok(text.clone()),
ContentPart::FunctionCall { function_call } => Ok(format!(
"{{\"function_call\": {{\"name\": \"{}\", \"args\": {}}}}}",
function_call.name, function_call.args
)),
ContentPart::FunctionResponse { .. } => Err(RunnerError::internal(
"Unexpected function response in model output",
)),
}
}
fn extract_tool_calls(response: &GeminiResponse) -> Vec<ToolCallRequest> {
response
.candidates
.as_ref()
.and_then(|c| c.first())
.and_then(|c| c.content.as_ref())
.map(|c| {
c.parts
.iter()
.filter_map(|p| match p {
ContentPart::FunctionCall { function_call } => Some(function_call),
_ => None,
})
.enumerate()
.map(|(index, call)| ToolCallRequest {
id: format!("{}-{index}", call.name),
function_name: call.name.clone(),
arguments: call.args.clone(),
})
.collect()
})
.unwrap_or_default()
}
fn convert_usage(metadata: &UsageMetadata) -> TokenUsage {
TokenUsage::new(
metadata.prompt.unwrap_or(0),
metadata.candidates.unwrap_or(0),
metadata.total.unwrap_or(0),
)
.with_cache(metadata.cached, None)
}
fn map_api_error(status: StatusCode, response_text: &str) -> RunnerError {
let parsed_message = serde_json::from_str::<GeminiResponse>(response_text)
.ok()
.and_then(|r| r.error)
.map(|e| e.message);
if parsed_message.is_none() {
debug!(
status = status.as_u16(),
body_preview = %response_text.chars().take(200).collect::<String>(),
"Gemini API returned non-JSON error response"
);
}
let message = parsed_message.unwrap_or_else(|| format!("HTTP {status}"));
client::map_http_error(PROVIDER_NAME, status, &message)
}
fn is_retryable_error(error: &RunnerError) -> bool {
if error.kind == ErrorKind::RateLimit {
return true;
}
let message = &error.message;
message.contains("429")
|| message.contains("quota exceeded")
|| message.contains("rate limit")
|| message.contains("503")
|| message.contains("overloaded")
|| message.contains("no content parts")
|| message.contains("thinking-only")
|| message.contains("still be thinking")
}
fn classify(error: RunnerError) -> AttemptError {
let retryable = Self::is_retryable_error(&error);
AttemptError { error, retryable }
}
fn parse_stream_data(json_str: &str) -> Option<Result<StreamChunk, RunnerError>> {
match serde_json::from_str::<StreamingResponse>(json_str) {
Ok(response) => {
let candidate = response.candidates?.into_iter().next()?;
let content = candidate.content?;
let part = content.parts.first()?;
let is_final = candidate
.finish_reason
.as_ref()
.is_some_and(|r| r == "STOP");
let delta = match part {
ContentPart::Text { text } => text.clone(),
ContentPart::FunctionCall { function_call } => {
format!(
"{{\"function_call\": {{\"name\": \"{}\", \"args\": {}}}}}",
function_call.name, function_call.args
)
}
ContentPart::FunctionResponse { .. } => return None,
};
Some(Ok(StreamChunk {
delta,
is_final,
finish_reason: candidate.finish_reason,
}))
}
Err(e) => {
warn!(error = %e, "Failed to parse Gemini streaming chunk");
None
}
}
}
async fn attempt_complete(
&self,
url: &str,
gemini_request: &GeminiRequest,
model: &str,
) -> Result<ChatResponse, RunnerError> {
let response = self
.client
.post(url)
.json(gemini_request)
.send()
.await
.map_err(|e| map_send_error(PROVIDER_NAME, e))?;
let status = response.status();
let response_text = response.text().await.map_err(|e| {
RunnerError::external_service(PROVIDER_NAME, format!("Failed to read response: {e}"))
})?;
if !status.is_success() {
return Err(Self::map_api_error(status, &response_text));
}
let gemini_response: GeminiResponse =
serde_json::from_str(&response_text).map_err(|e| {
error!(error = %e, response_len = response_text.len(), "Failed to parse Gemini response (body redacted)");
RunnerError::internal(format!("Failed to parse Gemini response: {e}"))
})?;
if let Some(error) = gemini_response.error {
return Err(RunnerError::internal(format!(
"Gemini API error: {}",
error.message
)));
}
let usage = gemini_response
.usage_metadata
.as_ref()
.map(Self::convert_usage);
let finish_reason = gemini_response
.candidates
.as_ref()
.and_then(|c| c.first())
.and_then(|c| c.finish_reason.clone());
let tool_calls = Self::extract_tool_calls(&gemini_response);
if !tool_calls.is_empty() {
debug!(count = tool_calls.len(), "Extracted function calls");
return Ok(ChatResponse {
content: String::new(),
model: model.to_owned(),
usage,
finish_reason,
warnings: None,
tool_calls: Some(tool_calls),
});
}
let content = Self::extract_content(&gemini_response)?;
Ok(ChatResponse {
content,
model: model.to_owned(),
usage,
finish_reason,
warnings: None,
tool_calls: None,
})
}
async fn attempt_stream(
&self,
url: &str,
gemini_request: &GeminiRequest,
) -> Result<ChatStream, RunnerError> {
let response = self
.client
.post(url)
.query(&[("alt", "sse")])
.json(gemini_request)
.send()
.await
.map_err(|e| map_send_error(PROVIDER_NAME, e))?;
let status = response.status();
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_owned());
return Err(Self::map_api_error(status, &error_text));
}
Ok(create_sse_stream(
response.bytes_stream(),
Self::parse_stream_data,
PROVIDER_NAME,
))
}
}
#[async_trait]
impl LlmProvider for GeminiProvider {
fn name(&self) -> &'static str {
PROVIDER_NAME
}
fn display_name(&self) -> &str {
"Google Gemini"
}
fn capabilities(&self) -> LlmCapabilities {
LlmCapabilities::full_featured()
}
fn default_model(&self) -> &str {
&self.config.model
}
fn available_models(&self) -> &[String] {
&self.available_models
}
#[instrument(skip(self, request), fields(model = %request.model.as_deref().unwrap_or(&self.config.model)))]
async fn complete(&self, request: &ChatRequest) -> Result<ChatResponse, RunnerError> {
let model = request.model.as_deref().unwrap_or(&self.config.model);
let url = self.build_url(model, "generateContent");
let gemini_request = Self::build_gemini_request(request);
let (url, gemini_request) = (&url, &gemini_request);
with_retries(
&self.config.retry,
PROVIDER_NAME,
"Gemini request",
|attempt| async move {
debug!(attempt, "Sending request to Gemini API");
self.attempt_complete(url, gemini_request, model)
.await
.map_err(Self::classify)
},
)
.await
}
#[instrument(skip(self, request), fields(model = %request.model.as_deref().unwrap_or(&self.config.model)))]
async fn complete_stream(&self, request: &ChatRequest) -> Result<ChatStream, RunnerError> {
let model = request.model.as_deref().unwrap_or(&self.config.model);
let url = self.build_url(model, "streamGenerateContent");
let gemini_request = Self::build_gemini_request(request);
let (url, gemini_request) = (&url, &gemini_request);
with_retries(
&self.config.retry,
PROVIDER_NAME,
"Gemini streaming request",
|attempt| async move {
debug!(attempt, "Starting streaming request to Gemini API");
self.attempt_stream(url, gemini_request)
.await
.map_err(Self::classify)
},
)
.await
}
#[instrument(skip(self))]
async fn health_check(&self) -> Result<bool, RunnerError> {
let url = format!("{API_BASE_URL}/models?key={}", self.config.api_key);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| map_send_error(PROVIDER_NAME, e))?;
Ok(response.status().is_success())
}
}
impl Debug for GeminiProvider {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
f.debug_struct("GeminiProvider")
.field("default_model", &self.config.model)
.field("api_key", &"[REDACTED]")
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn provider() -> GeminiProvider {
GeminiProvider::with_client(
GeminiConfig::new("super-secret-key"),
reqwest::Client::new(),
)
}
#[test]
fn debug_redacts_the_api_key() {
let output = format!("{:?}", provider());
assert!(!output.contains("super-secret-key"));
assert!(output.contains("[REDACTED]"));
let config = format!("{:?}", GeminiConfig::new("super-secret-key"));
assert!(!config.contains("super-secret-key"));
}
#[test]
fn system_messages_are_hoisted_and_concatenated() {
let messages = vec![
ChatMessage::system("persona"),
ChatMessage::user("hi"),
ChatMessage::system("tools"),
ChatMessage::assistant("hello"),
ChatMessage::tool("get_x", "call_1", "result"),
];
let (contents, system) = GeminiProvider::convert_messages(&messages);
let roles: Vec<Option<&str>> = contents.iter().map(|c| c.role.as_deref()).collect();
assert_eq!(roles, vec![Some("user"), Some("model"), Some("user")]);
let system = system.expect("system instruction"); assert!(
matches!(&system.parts[0], ContentPart::Text { text } if text == "persona\n\ntools"),
"unexpected part {:?}",
system.parts[0]
);
}
#[test]
fn tools_ride_as_function_declarations() {
let request =
ChatRequest::new(vec![ChatMessage::user("hi")]).with_tools(vec![ToolDefinition {
name: "get_weather".to_owned(),
description: "Weather".to_owned(),
parameters: None,
}]);
let body = GeminiProvider::build_gemini_request(&request);
let json = serde_json::to_value(&body).expect("serialises"); assert_eq!(
json["tools"][0]["function_declarations"][0]["name"],
"get_weather"
);
let bare =
GeminiProvider::build_gemini_request(&ChatRequest::new(vec![]).with_tools(vec![]));
assert!(
bare.tools.is_none(),
"an empty tool list sends no tools field"
);
}
#[test]
fn function_calls_become_tool_calls_with_derived_ids() {
let body = r#"{"candidates":[{"content":{"role":"model","parts":[
{"functionCall":{"name":"get_activities","args":{"limit":5}}},
{"functionCall":{"name":"get_activities","args":{"limit":9}}}
]},"finishReason":"STOP"}]}"#;
let response: GeminiResponse = serde_json::from_str(body).expect("parses"); let calls = GeminiProvider::extract_tool_calls(&response);
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].id, "get_activities-0");
assert_eq!(calls[1].id, "get_activities-1");
assert_eq!(calls[0].arguments["limit"], 5);
}
#[test]
fn a_part_less_candidate_is_a_retryable_internal_error() {
let body =
r#"{"candidates":[{"content":{"role":"model","parts":[]},"finishReason":"STOP"}]}"#;
let response: GeminiResponse = serde_json::from_str(body).expect("parses"); let err = GeminiProvider::extract_content(&response).expect_err("no parts");
assert_eq!(err.kind, ErrorKind::Internal);
assert!(GeminiProvider::is_retryable_error(&err));
}
#[test]
fn a_429_is_a_rate_limit_with_the_wait_and_is_retried_in_place() {
let body = r#"{"error":{"message":"Quota exceeded for quota metric. Please retry in 6.406453963s."}}"#;
let err = GeminiProvider::map_api_error(StatusCode::TOO_MANY_REQUESTS, body);
assert_eq!(err.kind, ErrorKind::RateLimit);
assert!(err.message.contains("7 seconds"), "{}", err.message);
assert!(GeminiProvider::is_retryable_error(&err));
}
#[test]
fn a_503_is_external_service_and_retried_a_401_is_not() {
let err = GeminiProvider::map_api_error(StatusCode::SERVICE_UNAVAILABLE, "overloaded");
assert_eq!(err.kind, ErrorKind::ExternalService);
assert!(GeminiProvider::is_retryable_error(&err));
let err = GeminiProvider::map_api_error(StatusCode::UNAUTHORIZED, "<html>");
assert_eq!(err.kind, ErrorKind::AuthFailure);
assert!(!GeminiProvider::is_retryable_error(&err));
}
#[test]
fn usage_carries_the_cache_read_count() {
let usage = GeminiProvider::convert_usage(&UsageMetadata {
prompt: Some(100),
candidates: Some(10),
total: Some(110),
cached: Some(60),
});
assert_eq!(usage.prompt_tokens, 100);
assert_eq!(usage.cached_read_tokens, Some(60));
assert_eq!(usage.cached_write_tokens, None);
}
#[test]
fn stream_chunk_parses_text_and_final_stop() {
let frame =
r#"{"candidates":[{"content":{"parts":[{"text":"Hi"}]},"finishReason":"STOP"}]}"#;
let chunk = GeminiProvider::parse_stream_data(frame)
.expect("a chunk") .expect("ok"); assert_eq!(chunk.delta, "Hi");
assert!(chunk.is_final);
}
}