use std::collections::HashMap;
use async_trait::async_trait;
use serde_json::{json, Value};
use crate::{
send_json, ChatRequest, ChatResponse, LlmError, LlmProvider, Message, Part, Result, Role,
StopReason, Usage,
};
const LOCAL_ID_PREFIX: &str = "llm-universal-call-";
#[derive(Clone)]
pub struct GeminiProvider {
client: reqwest::Client,
api_key: String,
base_url: String,
}
impl std::fmt::Debug for GeminiProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GeminiProvider")
.field("api_key", &"<redacted>")
.field("base_url", &self.base_url)
.finish()
}
}
impl GeminiProvider {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
client: crate::http_client(),
api_key: api_key.into(),
base_url: "https://generativelanguage.googleapis.com/v1beta".to_string(),
}
}
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into().trim_end_matches('/').to_string();
self
}
pub fn with_client(mut self, client: reqwest::Client) -> Self {
self.client = client;
self
}
fn build_body(req: &ChatRequest) -> Value {
let names: HashMap<&str, &str> = req
.messages
.iter()
.flat_map(|m| &m.parts)
.filter_map(|p| match p {
Part::ToolCall { id, name, .. } => Some((id.as_str(), name.as_str())),
_ => None,
})
.collect();
let mut system_parts: Vec<Value> =
req.system.iter().map(|s| json!({ "text": s })).collect();
let mut contents = Vec::new();
for message in &req.messages {
if message.role == Role::System {
system_parts.push(json!({ "text": message.text_content() }));
continue;
}
let role = if message.role == Role::Assistant {
"model"
} else {
"user"
};
let parts: Vec<Value> = message
.parts
.iter()
.filter_map(|p| to_gemini_part(p, &names))
.collect();
contents.push(json!({ "role": role, "parts": parts }));
}
let mut body = json!({ "contents": contents });
if !system_parts.is_empty() {
body["systemInstruction"] = json!({ "parts": system_parts });
}
if !req.tools.is_empty() {
let declarations: Vec<Value> = req
.tools
.iter()
.map(|t| {
json!({
"name": t.name,
"description": t.description,
"parameters": t.parameters,
})
})
.collect();
body["tools"] = json!([{ "functionDeclarations": declarations }]);
}
let mut config = serde_json::Map::new();
if let Some(max_tokens) = req.max_tokens {
config.insert("maxOutputTokens".into(), json!(max_tokens));
}
if let Some(temperature) = req.temperature {
config.insert("temperature".into(), json!(temperature));
}
if !config.is_empty() {
body["generationConfig"] = Value::Object(config);
}
body
}
}
fn to_gemini_part(part: &Part, names: &HashMap<&str, &str>) -> Option<Value> {
let sendable_id =
|id: &str| (!id.is_empty() && !id.starts_with(LOCAL_ID_PREFIX)).then(|| id.to_string());
match part {
Part::Text { text } if text.is_empty() => None,
Part::Text { text } => Some(json!({ "text": text })),
Part::ToolCall {
id,
name,
arguments,
} => {
let mut call = json!({ "name": name, "args": arguments });
if let Some(id) = sendable_id(id) {
call["id"] = json!(id);
}
Some(json!({ "functionCall": call }))
}
Part::ToolResult {
call_id,
content,
is_error,
} => {
let response = match serde_json::from_str::<Value>(content) {
Ok(v @ Value::Object(_)) if !is_error => v,
_ if *is_error => json!({ "error": content }),
_ => json!({ "result": content }),
};
let mut result = json!({
"name": names.get(call_id.as_str()).copied().unwrap_or_default(),
"response": response,
});
if let Some(id) = sendable_id(call_id) {
result["id"] = json!(id);
}
Some(json!({ "functionResponse": result }))
}
}
}
fn parse_response(v: &Value, requested_model: &str) -> Result<ChatResponse> {
let tokens = |field: &str| {
u32::try_from(v["usageMetadata"][field].as_u64().unwrap_or(0)).unwrap_or(u32::MAX)
};
let usage = Usage {
input_tokens: tokens("promptTokenCount"),
output_tokens: tokens("candidatesTokenCount"),
};
let model = v["modelVersion"]
.as_str()
.unwrap_or(requested_model)
.to_string();
let Some(candidate) = v["candidates"].get(0) else {
if let Some(reason) = v["promptFeedback"]["blockReason"].as_str() {
let text = format!("prompt blocked: {reason}");
return Ok(ChatResponse {
model,
message: Message::new(Role::Assistant, vec![Part::Text { text }]),
stop_reason: StopReason::Refusal,
usage,
});
}
return Err(LlmError::InvalidResponse(
"missing field `candidates[0]`".into(),
));
};
let mut parts = Vec::new();
let mut calls = 0usize;
for part in candidate["content"]["parts"]
.as_array()
.into_iter()
.flatten()
{
if part["thought"].as_bool() == Some(true) {
continue;
}
if let Some(text) = part["text"].as_str() {
if !text.is_empty() {
parts.push(Part::Text {
text: text.to_string(),
});
}
} else if part["functionCall"].is_object() {
let call = &part["functionCall"];
let id = match call["id"].as_str() {
Some(id) if !id.is_empty() => id.to_string(),
_ => format!("{LOCAL_ID_PREFIX}{calls}"),
};
calls += 1;
parts.push(Part::ToolCall {
id,
name: call["name"].as_str().unwrap_or_default().to_string(),
arguments: if call["args"].is_null() {
json!({})
} else {
call["args"].clone()
},
});
}
}
let stop_reason = match candidate["finishReason"].as_str() {
Some("STOP") if calls > 0 => StopReason::ToolUse,
Some("STOP") => StopReason::EndTurn,
Some("MAX_TOKENS") => StopReason::MaxTokens,
Some(
"SAFETY"
| "RECITATION"
| "BLOCKLIST"
| "PROHIBITED_CONTENT"
| "SPII"
| "IMAGE_SAFETY"
| "IMAGE_PROHIBITED_CONTENT"
| "IMAGE_RECITATION",
) => StopReason::Refusal,
None if calls > 0 => StopReason::ToolUse,
other => StopReason::Other(other.unwrap_or("unknown").to_string()),
};
Ok(ChatResponse {
model,
message: Message::new(Role::Assistant, parts),
stop_reason,
usage,
})
}
fn checked_model(model: &str) -> Result<&str> {
let model = model.strip_prefix("models/").unwrap_or(model);
if model
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
{
Ok(model)
} else {
Err(LlmError::InvalidArgument(format!(
"GeminiProvider: model `{model}` may only contain a-z, A-Z, 0-9, '-', '_' and '.'"
)))
}
}
#[async_trait]
impl LlmProvider for GeminiProvider {
async fn chat(&self, req: &ChatRequest) -> Result<ChatResponse> {
crate::validate(
"GeminiProvider",
&self.api_key,
true,
&self.base_url,
64,
req,
)?;
let model = checked_model(&req.model)?;
let url = format!("{}/models/{model}:generateContent", self.base_url);
let builder = self
.client
.post(url)
.header(
"x-goog-api-key",
crate::secret_header("GeminiProvider", &self.api_key)?,
)
.json(&Self::build_body(req));
let body = send_json(builder).await?;
parse_response(&body, model)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Tool;
#[test]
fn builds_request_with_tools_and_results() {
let req = ChatRequest::new("gemini-2.5-flash")
.system("sys")
.max_tokens(100)
.tool(Tool::new("get_weather", "d", json!({"type": "object"})))
.message(Message::user("hi"))
.message(Message::new(
Role::Assistant,
vec![Part::ToolCall {
id: format!("{LOCAL_ID_PREFIX}0"),
name: "get_weather".into(),
arguments: json!({"city": "Berlin"}),
}],
))
.message(Message::tool_result(
format!("{LOCAL_ID_PREFIX}0"),
"18C",
false,
));
let body = GeminiProvider::build_body(&req);
assert_eq!(body["systemInstruction"]["parts"][0]["text"], "sys");
assert_eq!(body["generationConfig"]["maxOutputTokens"], 100);
assert_eq!(
body["tools"][0]["functionDeclarations"][0]["name"],
"get_weather"
);
assert_eq!(body["contents"][1]["role"], "model");
let call = &body["contents"][1]["parts"][0]["functionCall"];
assert_eq!(call["args"]["city"], "Berlin");
assert!(call.get("id").is_none(), "local ids are not sent");
let result = &body["contents"][2]["parts"][0]["functionResponse"];
assert_eq!(result["name"], "get_weather");
assert_eq!(result["response"]["result"], "18C");
}
#[test]
fn parses_function_call_as_tool_use() {
let v = json!({
"candidates": [{
"content": { "role": "model", "parts": [
{ "text": "thinking...", "thought": true },
{ "functionCall": { "name": "get_weather", "args": { "city": "Berlin" } } }
]},
"finishReason": "STOP"
}],
"usageMetadata": { "promptTokenCount": 4, "candidatesTokenCount": 6 },
"modelVersion": "gemini-2.5-flash"
});
let resp = parse_response(&v, "m").unwrap();
assert_eq!(resp.stop_reason, StopReason::ToolUse);
assert_eq!(resp.text(), "");
let calls = resp.tool_calls();
assert_eq!(calls[0].1, "get_weather");
assert!(calls[0].0.starts_with(LOCAL_ID_PREFIX));
assert_eq!(resp.usage.output_tokens, 6);
assert_eq!(resp.model, "gemini-2.5-flash");
}
#[test]
fn blocked_prompt_is_a_refusal() {
let v = json!({ "promptFeedback": { "blockReason": "SAFETY" } });
let resp = parse_response(&v, "m").unwrap();
assert_eq!(resp.stop_reason, StopReason::Refusal);
assert_eq!(resp.text(), "prompt blocked: SAFETY");
}
#[test]
fn rejects_model_that_would_change_the_url() {
for model in ["../files", "gemini?key=x", "a/b", "gemini pro"] {
assert!(checked_model(model).is_err(), "{model}");
}
assert_eq!(
checked_model("models/gemini-2.5-flash").unwrap(),
"gemini-2.5-flash"
);
}
}