use anyhow::Result;
use async_stream::stream;
use eventsource_stream::Eventsource;
use futures_util::StreamExt;
use tokio_util::sync::CancellationToken;
use super::wire::{self, GenResponse};
use crate::shared::api::contract::{
ChatChunk, ChatRequest, ChatStream, EngineBackend, FinishReason, TokenUsage, ToolCallDelta,
VisionSupport,
};
use crate::shared::api::error::{self, SUBJECT_GEMINI};
use crate::shared::api::http;
pub struct GeminiClient {
http: reqwest::Client,
base_url: String,
api_key: String,
model: String,
}
impl GeminiClient {
pub fn new(
base_url: impl Into<String>,
api_key: impl Into<String>,
model: impl Into<String>,
) -> Self {
let base_url = base_url.into().trim_end_matches('/').to_string();
let model = model
.into()
.trim_start_matches("models/")
.trim()
.to_string();
Self {
http: http::engine_client(),
base_url,
api_key: api_key.into(),
model,
}
}
}
fn map_finish(reason: &str, saw_tool_call: bool) -> FinishReason {
match reason {
"MAX_TOKENS" => FinishReason::Length,
_ if saw_tool_call => FinishReason::ToolCalls,
_ if is_filter_reason(reason) => FinishReason::Filtered,
_ => FinishReason::Stop,
}
}
fn is_filter_reason(reason: &str) -> bool {
matches!(
reason,
"SAFETY" | "RECITATION" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "SPII" | "IMAGE_SAFETY"
)
}
fn is_block_reason(reason: &str) -> bool {
matches!(reason, "MALFORMED_FUNCTION_CALL" | "OTHER")
}
fn block_note(reason: &str) -> String {
format!("\n⚠ Gemini did not produce a response (reason: {reason}).")
}
#[async_trait::async_trait]
impl EngineBackend for GeminiClient {
async fn chat_stream(&self, req: ChatRequest, cancel: CancellationToken) -> Result<ChatStream> {
let body = wire::build_request(&req, &self.model);
let url = format!(
"{}/models/{}:streamGenerateContent?alt=sse",
self.base_url, self.model
);
let request = self
.http
.post(&url)
.header("x-goog-api-key", &self.api_key)
.json(&body);
let Some(response) = http::send_cancellable(request, &cancel).await? else {
return Ok(http::cancelled_stream());
};
let response = error::check_status(SUBJECT_GEMINI, response).await?;
let mut events = response.bytes_stream().eventsource();
let s = stream! {
let mut tool_index = 0usize;
let mut saw_tool_call = false;
loop {
tokio::select! {
biased;
_ = cancel.cancelled() => {
yield ChatChunk::Finished(FinishReason::Cancelled);
break;
}
next = events.next() => {
match next {
None => {
yield ChatChunk::Finished(FinishReason::Stop);
break;
}
Some(Err(err)) => {
let message = error::chain_text(&err);
tracing::warn!(error = %message, "SSE stream error (gemini)");
for chunk in ChatChunk::failure(message, true) { yield chunk; }
break;
}
Some(Ok(event)) => {
if event.data == "[DONE]" {
yield ChatChunk::Finished(FinishReason::Stop);
break;
}
let resp: GenResponse = match serde_json::from_str(&event.data) {
Ok(r) => r,
Err(err) => {
tracing::warn!(error = %err, data = %event.data, "failed to parse gemini SSE chunk");
continue;
}
};
if let Some(e) = resp.error {
let transient = error::stream_error_transient(&e.status, e.code);
tracing::warn!(
status = %e.status,
code = ?e.code,
transient,
message = %e.message,
"gemini reported an error inside the stream"
);
let message = error::stream_error_text(&e.status, &e.message);
for chunk in ChatChunk::failure(message, transient) { yield chunk; }
break;
}
if let Some(reason) = resp
.prompt_feedback
.and_then(|f| f.block_reason)
.filter(|r| !r.is_empty())
{
tracing::warn!(reason = %reason, "gemini blocked the prompt");
yield ChatChunk::Finished(FinishReason::Filtered);
break;
}
let candidate = resp.candidates.into_iter().next();
if let Some(cand) = &candidate
&& let Some(content) = &cand.content
{
for part in &content.parts {
if let Some(fc) = &part.function_call {
saw_tool_call = true;
yield ChatChunk::ToolCall(ToolCallDelta {
index: tool_index,
id: Some(format!("{}-{}", fc.name, tool_index)),
name: Some(fc.name.clone()),
arguments: fc.args.to_string(),
thought_signature: part.thought_signature.clone(),
});
tool_index += 1;
} else if let Some(text) = &part.text {
if text.is_empty() {
continue;
}
if part.thought == Some(true) {
yield ChatChunk::Thoughts(text.clone());
} else {
yield ChatChunk::Text(text.clone());
}
}
}
}
if let Some(u) = resp.usage_metadata {
yield ChatChunk::Usage(TokenUsage {
prompt_tokens: u.prompt_token_count,
completion_tokens: u.candidates_token_count,
reasoning_tokens: u.thoughts_token_count,
prefill: None,
});
}
if let Some(reason) = candidate.and_then(|c| c.finish_reason) {
if is_filter_reason(&reason) {
tracing::warn!(reason = %reason, "gemini stopped the reply with a filter reason");
}
if is_block_reason(&reason) && !saw_tool_call {
tracing::warn!(reason = %reason, "gemini stopped with a block reason");
yield ChatChunk::Text(block_note(&reason));
}
yield ChatChunk::Finished(map_finish(&reason, saw_tool_call));
break;
}
}
}
}
}
}
};
Ok(Box::pin(s))
}
async fn vision(&self) -> VisionSupport {
VisionSupport::Supported
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shared::api::sse_stub::{self, collect, serve};
#[test]
fn finish_mapping() {
assert_eq!(map_finish("STOP", false), FinishReason::Stop);
assert_eq!(map_finish("STOP", true), FinishReason::ToolCalls);
assert_eq!(map_finish("MAX_TOKENS", false), FinishReason::Length);
assert_eq!(map_finish("SAFETY", false), FinishReason::Filtered);
assert_eq!(map_finish("RECITATION", false), FinishReason::Filtered);
assert_eq!(map_finish("SAFETY", true), FinishReason::ToolCalls);
assert_eq!(map_finish("OTHER", false), FinishReason::Stop);
}
#[test]
fn strips_models_prefix_from_model() {
let c = GeminiClient::new("https://x/v1beta", "k", "models/gemini-2.5-flash");
assert_eq!(c.model, "gemini-2.5-flash");
}
#[test]
fn block_reasons_recognized_and_noted() {
assert!(is_block_reason("MALFORMED_FUNCTION_CALL"));
assert!(is_block_reason("OTHER"));
assert!(!is_block_reason("SAFETY"));
assert!(!is_block_reason("STOP"));
assert!(!is_block_reason("MAX_TOKENS"));
assert!(block_note("OTHER").contains("OTHER"));
}
#[test]
fn filter_reasons_are_told_from_the_other_blocks() {
for r in [
"SAFETY",
"RECITATION",
"BLOCKLIST",
"PROHIBITED_CONTENT",
"SPII",
"IMAGE_SAFETY",
] {
assert!(is_filter_reason(r), "{r}");
assert!(!is_block_reason(r), "{r}");
}
for r in ["STOP", "MAX_TOKENS", "MALFORMED_FUNCTION_CALL", "OTHER"] {
assert!(!is_filter_reason(r), "{r}");
}
}
#[tokio::test]
async fn a_safety_stop_ends_the_turn_as_filtered_with_no_text_of_ours() {
let base = serve(&[
r#"{"candidates":[{"content":{"parts":[{"text":"Once upon"}],"role":"model"}}]}"#,
r#"{"candidates":[{"content":{"parts":[],"role":"model"},"finishReason":"SAFETY"}]}"#,
]);
let client = GeminiClient::new(base, "k", "gemini-x");
let chunks = collect(
client
.chat_stream(sse_stub::hello(), Default::default())
.await
.unwrap(),
)
.await;
let texts: Vec<_> = chunks
.iter()
.filter_map(|c| match c {
ChatChunk::Text(t) => Some(t.as_str()),
_ => None,
})
.collect();
assert_eq!(texts, ["Once upon"], "{chunks:?}");
assert_eq!(
chunks.last(),
Some(&ChatChunk::Finished(FinishReason::Filtered))
);
}
#[tokio::test]
async fn a_blocked_prompt_ends_the_turn_as_filtered() {
let base = serve(&[r#"{"promptFeedback":{"blockReason":"PROHIBITED_CONTENT"}}"#]);
let client = GeminiClient::new(base, "k", "gemini-x");
let chunks = collect(
client
.chat_stream(sse_stub::hello(), Default::default())
.await
.unwrap(),
)
.await;
assert_eq!(
chunks,
[ChatChunk::Finished(FinishReason::Filtered)],
"no reply text, only the reason"
);
}
#[tokio::test]
async fn a_malformed_call_still_gets_its_note() {
let base = serve(&[
r#"{"candidates":[{"content":{"parts":[],"role":"model"},"finishReason":"MALFORMED_FUNCTION_CALL"}]}"#,
]);
let client = GeminiClient::new(base, "k", "gemini-x");
let chunks = collect(
client
.chat_stream(sse_stub::hello(), Default::default())
.await
.unwrap(),
)
.await;
assert!(
chunks.contains(&ChatChunk::Text(block_note("MALFORMED_FUNCTION_CALL"))),
"{chunks:?}"
);
assert_eq!(
chunks.last(),
Some(&ChatChunk::Finished(FinishReason::Stop))
);
}
}
#[cfg(test)]
mod ignored_smoke {
use super::*;
use crate::entities::sampling::{ReasoningEffort, SamplingConfig};
use crate::shared::api::ToolCallAccumulator;
use crate::shared::api::contract::{ApiMessage, ToolSchema};
fn client_from_env() -> Option<GeminiClient> {
let key = std::env::var("MINDFORK_GEMINI_KEY").ok()?;
let model =
std::env::var("MINDFORK_GEMINI_MODEL").unwrap_or_else(|_| "gemini-2.5-flash".into());
Some(GeminiClient::new(
"https://generativelanguage.googleapis.com/v1beta",
key,
model,
))
}
#[tokio::test]
#[ignore = "requires MINDFORK_GEMINI_KEY (live Gemini API)"]
async fn simple_generation() {
let Some(client) = client_from_env() else {
eprintln!("skip: MINDFORK_GEMINI_KEY not set");
return;
};
let req = ChatRequest {
continue_final: false,
system: Some("You are a helpful assistant.".into()),
messages: vec![ApiMessage::user("Reply with exactly: pong")],
sampling: SamplingConfig {
max_tokens: Some(2048),
..Default::default()
},
tools: vec![],
};
let mut stream = client.chat_stream(req, Default::default()).await.unwrap();
let mut text = String::new();
let mut finish = None;
while let Some(chunk) = stream.next().await {
match chunk {
ChatChunk::Text(t) => text.push_str(&t),
ChatChunk::Finished(r) => {
finish = Some(r);
break;
}
ChatChunk::Error { message, .. } => {
eprintln!("engine error: {message}");
}
_ => {}
}
}
assert!(!text.is_empty(), "expected non-empty response");
assert!(matches!(
finish,
Some(FinishReason::Stop | FinishReason::Length)
));
}
#[tokio::test]
#[ignore = "requires MINDFORK_GEMINI_KEY (live Gemini API)"]
async fn tool_result_image_takes_the_user_part_fallback() {
let Some(client) = client_from_env() else {
eprintln!("skip: MINDFORK_GEMINI_KEY not set");
return;
};
let turn = |with_image: bool| {
let tool = ApiMessage::tool("take_screenshot-0", "Screenshot taken.");
let tool = if with_image {
tool.with_images(vec![crate::shared::api::ApiImage::new(
"image/png",
&crate::shared::api::green_circle_png_base64(),
None,
)])
} else {
tool
};
ChatRequest {
continue_final: false,
system: None,
messages: vec![
ApiMessage::user(crate::shared::api::TOOL_VISION_PROMPT),
ApiMessage::assistant_tool_calls(
"",
vec![crate::shared::api::ApiToolCall {
id: "take_screenshot-0".into(),
name: "take_screenshot".into(),
arguments: "{}".into(),
thought_signature: None,
}],
),
tool,
],
sampling: SamplingConfig {
max_tokens: Some(2048),
reasoning_budget: Some(0),
..Default::default()
},
tools: vec![crate::shared::api::ToolSchema {
name: "take_screenshot".into(),
description: "Take a screenshot of the screen.".into(),
parameters: serde_json::json!({ "type": "object", "properties": {} }),
}],
}
};
let read = async |req| {
let mut stream: ChatStream = client.chat_stream(req, Default::default()).await.unwrap();
let mut text = String::new();
while let Some(chunk) = stream.next().await {
match chunk {
ChatChunk::Text(t) => text.push_str(&t),
ChatChunk::Error { message, .. } => eprintln!("engine error: {message}"),
ChatChunk::Finished(_) => break,
_ => {}
}
}
text
};
let control = read(turn(false)).await;
eprintln!("gemini control (no image): {control}");
crate::shared::api::assert_sees_green_circle(&control, false, "control");
let answer = read(turn(true)).await;
eprintln!("gemini tool-result image: {answer}");
crate::shared::api::assert_sees_green_circle(&answer, true, "with the image");
}
#[tokio::test]
#[ignore = "requires MINDFORK_GEMINI_KEY (live Gemini API)"]
async fn image_input_is_described() {
let Some(client) = client_from_env() else {
eprintln!("skip: MINDFORK_GEMINI_KEY not set");
return;
};
let req = ChatRequest {
continue_final: false,
system: None,
messages: vec![
ApiMessage::user(crate::shared::api::VISION_PROMPT).with_images(vec![
crate::shared::api::ApiImage::new(
"image/png",
&crate::shared::api::blue_square_png_base64(),
None,
),
]),
],
sampling: SamplingConfig {
max_tokens: Some(2048),
..Default::default()
},
tools: vec![],
};
let mut stream = client.chat_stream(req, Default::default()).await.unwrap();
let mut text = String::new();
while let Some(chunk) = stream.next().await {
match chunk {
ChatChunk::Text(t) => text.push_str(&t),
ChatChunk::Error { message, .. } => eprintln!("engine error: {message}"),
ChatChunk::Finished(_) => break,
_ => {}
}
}
eprintln!("gemini vision reply: {text}");
crate::shared::api::assert_sees_blue_square(&text, "gemini");
}
#[tokio::test]
#[ignore = "requires MINDFORK_GEMINI_KEY (live Gemini API)"]
async fn thinking_streams_thoughts() {
let Some(client) = client_from_env() else {
eprintln!("skip: MINDFORK_GEMINI_KEY not set");
return;
};
let req = ChatRequest {
continue_final: false,
system: None,
messages: vec![ApiMessage::user(
"Think step by step: what is 17 * 23? Show brief reasoning.",
)],
sampling: SamplingConfig {
max_tokens: Some(4096),
thinking: Some(true),
reasoning_effort: Some(ReasoningEffort::Medium),
..Default::default()
},
tools: vec![],
};
let mut stream = client.chat_stream(req, Default::default()).await.unwrap();
let mut thoughts = String::new();
let mut text = String::new();
while let Some(chunk) = stream.next().await {
match chunk {
ChatChunk::Thoughts(t) => thoughts.push_str(&t),
ChatChunk::Text(t) => text.push_str(&t),
ChatChunk::Finished(_) => break,
ChatChunk::Error { message, .. } => {
eprintln!("engine error: {message}");
}
_ => {}
}
}
assert!(
!text.is_empty(),
"expected final answer, thoughts={thoughts:?}"
);
}
#[tokio::test]
#[ignore = "requires MINDFORK_GEMINI_KEY (live Gemini API)"]
async fn single_tool_call() {
let Some(client) = client_from_env() else {
eprintln!("skip: MINDFORK_GEMINI_KEY not set");
return;
};
let tool = ToolSchema {
name: "get_weather".into(),
description: "Get the current weather for a city.".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
}),
};
let req = ChatRequest {
continue_final: false,
system: None,
messages: vec![ApiMessage::user("Call get_weather for Paris.")],
sampling: SamplingConfig {
max_tokens: Some(2048),
..Default::default()
},
tools: vec![tool],
};
let mut stream = client.chat_stream(req, Default::default()).await.unwrap();
let mut acc = ToolCallAccumulator::default();
let mut reason = FinishReason::Stop;
while let Some(chunk) = stream.next().await {
match chunk {
ChatChunk::ToolCall(d) => acc.push(d),
ChatChunk::Finished(r) => {
reason = r;
break;
}
ChatChunk::Error { message, .. } => {
eprintln!("engine error: {message}");
}
_ => {}
}
}
assert_eq!(
reason,
FinishReason::ToolCalls,
"model should call the tool"
);
let calls = acc.finish();
assert!(!calls.is_empty(), "expected a tool call");
assert_eq!(calls[0].name, "get_weather");
}
#[tokio::test]
#[ignore = "requires MINDFORK_GEMINI_KEY (live Gemini API), Gemini 3 for signatures"]
async fn tool_use_round_trips_signature() {
let Some(client) = client_from_env() else {
eprintln!("skip: MINDFORK_GEMINI_KEY not set");
return;
};
let tool = ToolSchema {
name: "get_weather".into(),
description: "Get the current weather for a city.".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
}),
};
let sampling = SamplingConfig {
max_tokens: Some(4096),
thinking: Some(true),
reasoning_effort: Some(ReasoningEffort::High),
..Default::default()
};
let prompt = "Reason briefly which of Paris or Berlin is the capital of France, \
then call get_weather for that city.";
let round1 = ChatRequest {
continue_final: false,
system: None,
messages: vec![ApiMessage::user(prompt)],
sampling: sampling.clone(),
tools: vec![tool.clone()],
};
let mut stream = client
.chat_stream(round1, Default::default())
.await
.unwrap();
let mut acc = ToolCallAccumulator::default();
let mut reason = FinishReason::Stop;
while let Some(chunk) = stream.next().await {
match chunk {
ChatChunk::ToolCall(d) => acc.push(d),
ChatChunk::Finished(r) => {
reason = r;
break;
}
ChatChunk::Error { message, .. } => {
eprintln!("engine error: {message}");
}
_ => {}
}
}
assert_eq!(
reason,
FinishReason::ToolCalls,
"model should call the tool"
);
let calls = acc.finish();
assert!(!calls.is_empty(), "expected a tool call");
let call = calls[0].clone();
eprintln!(
"thought_signature present: {}",
call.thought_signature.is_some()
);
let round2 = ChatRequest {
continue_final: false,
system: None,
messages: vec![
ApiMessage::user(prompt),
ApiMessage::assistant_tool_calls("", vec![call.clone()]),
ApiMessage::tool(&call.id, "18°C, sunny"),
],
sampling,
tools: vec![tool],
};
let mut stream = client
.chat_stream(round2, Default::default())
.await
.unwrap();
let mut text = String::new();
let mut finish = None;
while let Some(chunk) = stream.next().await {
match chunk {
ChatChunk::Text(t) => text.push_str(&t),
ChatChunk::Finished(r) => {
finish = Some(r);
break;
}
ChatChunk::Error { message, .. } => {
eprintln!("engine error: {message}");
}
_ => {}
}
}
assert!(
matches!(finish, Some(FinishReason::Stop | FinishReason::Length)),
"second round must succeed (no 400), got {finish:?}"
);
assert!(
!text.is_empty(),
"expected a final answer after tool result"
);
}
}