use crate::chat::stream::Event;
use crate::chat::{
ChatRequest, ChatResponse, ContentBlock, Effort, ImageSource, Message, Role, StopReason,
Thinking,
};
use crate::cost::Usage;
use crate::error::{Error, Result};
use crate::model::{ModelId, Reach};
use crate::providers::api::{ApiProvider, Protocol, SseFrame};
use crate::registry::Registry;
use crate::secret::Secret;
use crate::transport::HttpTransport;
use serde_json::{json, Value};
use std::sync::Arc;
pub const DEFAULT_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
#[derive(Debug, Clone, Copy, Default)]
pub struct GenerateContent;
pub type Gemini = ApiProvider<GenerateContent>;
pub fn with(transport: Arc<dyn HttpTransport>, key: Secret, registry: Arc<Registry>) -> Gemini {
ApiProvider::new(
GenerateContent,
DEFAULT_BASE_URL,
transport,
key,
Reach::FirstPartyApi,
registry,
)
}
#[cfg(feature = "reqwest")]
#[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))]
pub fn from_env(timeout: std::time::Duration) -> Result<Gemini> {
Ok(with(
Arc::new(crate::transport::Reqwest::new(timeout)?),
Secret::from_env("gemini-api-key", "GEMINI_API_KEY")?,
Arc::new(shipped_registry()),
))
}
pub fn shipped_registry() -> Registry {
Registry::parse(include_str!("../../../models/gemini.toml"))
.unwrap_or_else(|_| Registry::empty("gemini", Reach::FirstPartyApi))
}
pub fn shipped_prices() -> crate::cost::PriceBook {
crate::cost::PriceBook::parse(include_str!("../../../models/gemini-prices.toml"))
.unwrap_or_else(|_| crate::cost::PriceBook {
id: "unavailable".into(),
provider: "gemini".into(),
effective_from: String::new(),
source: String::new(),
verified_at: String::new(),
expires_on: None,
currency: "USD".into(),
rates: Default::default(),
})
}
impl Protocol for GenerateContent {
fn id(&self) -> &str {
"gemini"
}
fn chat_url(&self, base_url: &str, model: &ModelId) -> String {
format!("{base_url}/models/{}:generateContent", model.as_str())
}
fn catalogue_url(&self, base_url: &str) -> Option<String> {
Some(format!("{base_url}/models"))
}
fn headers(&self, key: &Secret) -> Result<Vec<(String, String)>> {
let key = key
.expose_str()
.map_err(|_| Error::Auth("the API key is not valid UTF-8".into()))?;
Ok(vec![
("x-goog-api-key".into(), key.to_string()),
("content-type".into(), "application/json".into()),
])
}
fn body(&self, request: &ChatRequest) -> Result<Value> {
let mut contents = Vec::with_capacity(request.messages.len());
for message in &request.messages {
contents.push(json!({
"role": match message.role {
Role::User => "user",
Role::Assistant => "model",
},
"parts": message.content.iter().filter_map(wire_part).collect::<Vec<_>>(),
}));
}
let mut body = json!({ "contents": contents });
if let Some(system) = &request.system {
body["systemInstruction"] = json!({ "parts": [{ "text": system }] });
}
let mut generation = json!({});
if let Some(max) = request.generation.max_tokens {
generation["maxOutputTokens"] = json!(max);
}
if let Some(temperature) = request.generation.temperature {
generation["temperature"] = json!(temperature);
}
if let Some(top_p) = request.generation.top_p {
generation["topP"] = json!(top_p);
}
if let Some(schema) = &request.response_schema {
generation["responseMimeType"] = json!("application/json");
generation["responseSchema"] = schema.clone();
}
match request.thinking {
Thinking::On(effort) => {
generation["thinkingConfig"] = json!({
"thinkingBudget": budget(effort),
"includeThoughts": true,
});
}
Thinking::Off => {
generation["thinkingConfig"] = json!({ "thinkingBudget": 0 });
}
Thinking::Unset => {}
}
if generation != json!({}) {
body["generationConfig"] = generation;
}
if !request.tools.is_empty() {
body["tools"] = json!([{
"functionDeclarations": request
.tools
.iter()
.map(|tool| json!({
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
}))
.collect::<Vec<_>>(),
}]);
}
Ok(body)
}
fn read(&self, body: &Value, asked_for: &ModelId) -> Result<ChatResponse> {
let candidate = body
.get("candidates")
.and_then(Value::as_array)
.and_then(|c| c.first())
.ok_or_else(|| Error::Unreadable("the reply carried no candidates".into()))?;
let blocks: Vec<ContentBlock> = candidate
.get("content")
.and_then(|c| c.get("parts"))
.and_then(Value::as_array)
.map(|parts| parts.iter().filter_map(read_part).collect())
.unwrap_or_default();
if blocks.is_empty() {
return Err(Error::Unreadable(
"the reply carried no content this crate could read".into(),
));
}
Ok(ChatResponse::new(
Message {
role: Role::Assistant,
content: blocks,
},
read_stop(candidate.get("finishReason").and_then(Value::as_str)),
read_usage(body.get("usageMetadata")),
body.get("modelVersion")
.and_then(Value::as_str)
.map(ModelId::from)
.unwrap_or_else(|| asked_for.clone()),
))
}
fn read_catalogue(&self, body: &Value) -> Result<Vec<ModelId>> {
let mut ids: Vec<ModelId> = body
.get("models")
.and_then(Value::as_array)
.ok_or_else(|| Error::Unreadable("the model list had no models array".into()))?
.iter()
.filter_map(|m| m.get("name").and_then(Value::as_str))
.map(|name| ModelId::from(name.strip_prefix("models/").unwrap_or(name)))
.collect();
ids.sort();
Ok(ids)
}
fn stream_url(&self, base_url: &str, model: &ModelId) -> String {
format!(
"{base_url}/models/{}:streamGenerateContent?alt=sse",
model.as_str()
)
}
fn stream_body(&self, request: &ChatRequest) -> Result<Option<Value>> {
Ok(Some(self.body(request)?))
}
fn read_event(&self, frame: &SseFrame, asked_for: &ModelId) -> Result<Vec<Event>> {
let Some(body) = frame.json() else {
return Err(Error::Unreadable(format!(
"a frame that was not JSON: {}",
frame.data.chars().take(120).collect::<String>()
)));
};
let mut events = Vec::new();
if let Some(model) = body.get("modelVersion").and_then(Value::as_str) {
events.push(Event::Started {
model: ModelId::from(model),
});
}
if let Some(candidate) = body
.get("candidates")
.and_then(Value::as_array)
.and_then(|c| c.first())
{
for part in candidate
.get("content")
.and_then(|c| c.get("parts"))
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if let Some(text) = part.get("text").and_then(Value::as_str) {
if part
.get("thought")
.and_then(Value::as_bool)
.unwrap_or(false)
{
events.push(Event::ThinkingDelta(text.to_string()));
} else if !text.is_empty() {
events.push(Event::TextDelta(text.to_string()));
}
} else if let Some(call) = part.get("functionCall") {
events.push(Event::ToolUseStarted {
id: call
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
name: call
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
});
events.push(Event::ToolArgumentsDelta(
call.get("args").cloned().unwrap_or(json!({})).to_string(),
));
}
}
if let Some(reason) = candidate.get("finishReason").and_then(Value::as_str) {
events.push(Event::Stopped {
reason: read_stop(Some(reason)),
details: None,
});
}
}
let usage = read_usage(body.get("usageMetadata"));
if usage.coverage() != crate::cost::UsageCoverage::Absent {
events.push(Event::Metered(usage));
}
let _ = asked_for;
Ok(events)
}
}
fn budget(effort: Effort) -> u32 {
match effort {
Effort::Low => 2_048,
Effort::Medium => 8_192,
Effort::High => 24_576,
Effort::XHigh => 32_768,
Effort::Max => 32_768,
}
}
fn wire_part(block: &ContentBlock) -> Option<Value> {
match block {
ContentBlock::Text(text) => Some(json!({ "text": text })),
ContentBlock::Thinking { text, .. } => Some(json!({ "text": text, "thought": true })),
ContentBlock::ToolUse { name, input, .. } => Some(json!({
"functionCall": { "name": name, "args": input },
})),
ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
} => Some(json!({
"functionResponse": {
"name": tool_use_id,
"response": if *is_error {
json!({ "error": content })
} else {
json!({ "result": content })
},
},
})),
ContentBlock::Image { media_type, source } => Some(match source {
ImageSource::Bytes(bytes) => {
use base64::Engine as _;
json!({
"inlineData": {
"mimeType": media_type,
"data": base64::engine::general_purpose::STANDARD.encode(bytes),
},
})
}
ImageSource::Url(url) => json!({
"fileData": { "mimeType": media_type, "fileUri": url },
}),
}),
ContentBlock::Opaque { .. } => None,
}
}
fn read_part(part: &Value) -> Option<ContentBlock> {
if let Some(call) = part.get("functionCall") {
return Some(ContentBlock::ToolUse {
id: call
.get("id")
.and_then(Value::as_str)
.or_else(|| call.get("name").and_then(Value::as_str))
.unwrap_or_default()
.to_string(),
name: call.get("name")?.as_str()?.to_string(),
input: call.get("args").cloned().unwrap_or(json!({})),
});
}
let text = part.get("text")?.as_str()?.to_string();
if part
.get("thought")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return Some(ContentBlock::Thinking {
text,
signature: None,
});
}
Some(ContentBlock::Text(text))
}
fn read_stop(reason: Option<&str>) -> StopReason {
match reason {
Some("STOP") => StopReason::EndTurn,
Some("MAX_TOKENS") => StopReason::MaxTokens,
Some("SAFETY") | Some("PROHIBITED_CONTENT") | Some("BLOCKLIST") | Some("RECITATION") => {
StopReason::Refusal
}
Some("MALFORMED_FUNCTION_CALL") => StopReason::Other,
_ => StopReason::Other,
}
}
fn read_usage(value: Option<&Value>) -> Usage {
let Some(usage) = value else {
return Usage::absent();
};
let field = |name: &str| usage.get(name).and_then(Value::as_u64);
let cached = field("cachedContentTokenCount");
let visible = field("candidatesTokenCount");
let thoughts = field("thoughtsTokenCount");
Usage {
input_tokens: field("promptTokenCount")
.map(|total| total.saturating_sub(cached.unwrap_or(0))),
cache_read_tokens: cached,
cache_write_tokens: None,
output_tokens: match (visible, thoughts) {
(None, None) => None,
(visible, thoughts) => Some(visible.unwrap_or(0) + thoughts.unwrap_or(0)),
},
estimated: false,
}
}