use crate::llm::api::{
DeltaSender, LlmRequestPayload, LlmResult, OutputFormat, ProviderTelemetry,
RawProviderToolCall, ReasoningEffort, ThinkingConfig,
};
use crate::llm::providers::common::{
apply_provider_overrides, maybe_emit_delta, output_text_block, reasoning_block,
tool_call_block, vm_err,
};
use crate::llm::providers::gemini::interactions_stream::{
InteractionStream, StreamAction, DONE_SENTINEL,
};
use crate::llm::providers::schema_compat::{
sanitize_schema_for_provider, SchemaCompatProfile, SchemaSurface,
};
use crate::value::VmError;
use serde_json::{json, Map, Value};
pub(crate) const STEP_USER_INPUT: &str = "user_input";
pub(crate) const STEP_MODEL_OUTPUT: &str = "model_output";
pub(crate) const STEP_THOUGHT: &str = "thought";
pub(crate) const STEP_FUNCTION_CALL: &str = "function_call";
pub(crate) const STEP_FUNCTION_RESULT: &str = "function_result";
pub(crate) struct GeminiInteractions;
impl GeminiInteractions {
pub(crate) fn build_request_body(opts: &LlmRequestPayload) -> Value {
let caps = crate::llm::capabilities::lookup(&opts.provider, &opts.model);
let wire_model = crate::llm_config::wire_model_id(&opts.model);
let model = wire_model.strip_prefix("models/").unwrap_or(&wire_model);
let InputSteps {
steps,
system_instruction,
} = build_input_steps(opts);
let mut body = json!({
"model": model,
"input": steps,
});
let mut system = system_instruction;
if let Some(leading) = opts.system.as_deref().filter(|value| !value.is_empty()) {
system.insert(0, leading.to_string());
}
if !system.is_empty() {
body["system_instruction"] = json!(system.join("\n\n"));
}
if let Some(tools) = interactions_tools(opts) {
body["tools"] = tools;
}
if let Some(generation_config) = generation_config(opts, &caps) {
body["generation_config"] = Value::Object(generation_config);
}
if let Some(response_format) = response_format(opts) {
body["response_format"] = response_format;
}
if let Some(previous) = opts
.previous_response_id
.as_deref()
.filter(|value| !value.is_empty())
{
body["previous_interaction_id"] = json!(previous);
}
body["store"] = json!(opts
.store
.unwrap_or_else(|| opts.previous_response_id.is_some()));
if let Some(background) = opts.background {
body["background"] = json!(background);
}
apply_provider_overrides(&mut body, opts.provider_overrides.as_ref());
body
}
}
impl GeminiInteractions {
pub(crate) async fn chat(
request: &LlmRequestPayload,
delta_tx: Option<DeltaSender>,
) -> Result<LlmResult, VmError> {
let mut body = Self::build_request_body(request);
if request.stream {
body["stream"] = json!(true);
}
let pdef = crate::llm_config::provider_config(&request.provider);
let base_url = pdef
.as_ref()
.map(crate::llm_config::resolve_base_url)
.unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_string());
let url = format!("{base_url}/v1beta/interactions");
let client = crate::llm::blocking_client_for_base_url(&base_url);
let http = client
.post(url)
.header("Content-Type", "application/json")
.timeout(std::time::Duration::from_secs(request.resolve_timeout()))
.json(&body);
let http = crate::llm::api::apply_auth_headers(http, &request.api_key, pdef.as_ref());
let response = http.send().await.map_err(|error| {
vm_err(format!(
"gemini API error: {}",
crate::egress::redact_reqwest_error(&error)
))
})?;
if !response.status().is_success() {
return Err(crate::llm::api::err_for_non_success("gemini", response).await);
}
if request.stream {
let envelope = read_interaction_stream(response, delta_tx).await?;
return parse_response(&envelope, request);
}
let json: Value = response
.json()
.await
.map_err(|error| vm_err(format!("gemini response parse error: {error}")))?;
let result = parse_response(&json, request)?;
maybe_emit_delta(delta_tx, &result.text);
Ok(result)
}
}
async fn read_interaction_stream(
response: reqwest::Response,
delta_tx: Option<DeltaSender>,
) -> Result<Value, VmError> {
use tokio_stream::StreamExt;
let stream = response
.bytes_stream()
.map(|result| result.map_err(std::io::Error::other));
let reader = tokio::io::BufReader::new(tokio_util::io::StreamReader::new(stream));
consume_interaction_sse(reader, delta_tx).await
}
pub(crate) async fn consume_interaction_sse<R: tokio::io::AsyncBufRead + Unpin>(
reader: R,
delta_tx: Option<DeltaSender>,
) -> Result<Value, VmError> {
use tokio::io::AsyncBufReadExt;
let mut lines = reader.lines();
let mut stream = InteractionStream::new();
while let Some(line) = lines
.next_line()
.await
.map_err(|error| vm_err(format!("gemini stream read error: {error}")))?
{
let Some(payload) = line.strip_prefix("data:").map(str::trim) else {
continue;
};
if payload.is_empty() || payload == DONE_SENTINEL {
continue;
}
let Ok(event) = serde_json::from_str::<Value>(payload) else {
continue;
};
match stream.push(&event) {
StreamAction::Text(text) => maybe_emit_delta(delta_tx.clone(), &text),
StreamAction::Done => break,
StreamAction::None => {}
}
}
Ok(stream.finish())
}
struct InputSteps {
steps: Vec<Value>,
system_instruction: Vec<String>,
}
fn build_input_steps(opts: &LlmRequestPayload) -> InputSteps {
let mut system_instruction = Vec::new();
let mut steps = Vec::new();
let chained = opts
.previous_response_id
.as_deref()
.is_some_and(|value| !value.is_empty());
let start = if chained {
opts.messages
.iter()
.rposition(|message| matches!(message_role(message), "assistant" | "model"))
.map(|index| index + 1)
.unwrap_or(0)
} else {
0
};
for message in &opts.messages[start.min(opts.messages.len())..] {
match message_role(message) {
"system" => {
let text = crate::llm::providers::common::request_text_content(message);
if !text.is_empty() {
system_instruction.push(text);
}
}
"tool" | "tool_result" => {
if let Some(step) = function_result_step(message) {
steps.push(step);
}
}
"assistant" | "model" => push_assistant_steps(message, &mut steps),
_ => {
let content = interactions_content(&message["content"]);
if !content.is_empty() {
steps.push(json!({"type": STEP_USER_INPUT, "content": content}));
}
}
}
}
InputSteps {
steps,
system_instruction,
}
}
fn message_role(message: &Value) -> &str {
message
.get("role")
.and_then(Value::as_str)
.unwrap_or("user")
}
fn push_assistant_steps(message: &Value, steps: &mut Vec<Value>) {
let parts = message
.get("content")
.map(crate::llm::content::gemini_parts)
.unwrap_or_default();
let mut calls: Vec<Value> = Vec::new();
let mut content = Vec::new();
let mut signature: Option<String> = None;
let mut remember_signature = |value: &Value| {
if signature.is_none() {
signature = super::gemini_tool_call_thought_signature(value).map(str::to_string);
}
};
for part in &parts {
remember_signature(part);
if let Some(call) = part.get("functionCall") {
if let Some(step) = function_call_step(call, part) {
calls.push(step);
}
continue;
}
if let Some(value) = content_from_gemini_part(part) {
content.push(value);
}
}
for call in message
.get("tool_calls")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
{
remember_signature(call);
if let Some(step) = function_call_step(call.get("function").unwrap_or(call), call) {
calls.push(step);
}
}
if let Some(signature) = signature {
steps.push(json!({"type": STEP_THOUGHT, "signature": signature}));
}
if !content.is_empty() {
steps.push(json!({"type": STEP_MODEL_OUTPUT, "content": content}));
}
steps.extend(calls);
}
fn function_call_step(call: &Value, owner: &Value) -> Option<Value> {
let name = call
.get("name")
.and_then(Value::as_str)
.filter(|name| !name.is_empty())?;
let arguments = call
.get("args")
.or_else(|| call.get("arguments"))
.and_then(|value| {
value
.as_str()
.and_then(|text| serde_json::from_str::<Value>(text).ok())
.or_else(|| (!value.is_string()).then(|| value.clone()))
})
.unwrap_or_else(|| json!({}));
let mut step = json!({
"type": STEP_FUNCTION_CALL,
"name": name,
"arguments": arguments,
});
if let Some(id) = call
.get("id")
.or_else(|| owner.get("id"))
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
{
step["id"] = json!(id);
}
Some(step)
}
fn function_result_step(message: &Value) -> Option<Value> {
let name = message
.get("name")
.or_else(|| message.get("tool_name"))
.and_then(Value::as_str)
.filter(|name| !name.is_empty())?;
let payload = message
.get("content")
.map(super::gemini_function_response_payload)
.unwrap_or_else(|| json!({}));
let text = match &payload {
Value::String(text) => text.clone(),
other => other.to_string(),
};
let mut step = json!({
"type": STEP_FUNCTION_RESULT,
"name": name,
"result": [{"type": "text", "text": text}],
});
if let Some(id) = message
.get("tool_call_id")
.or_else(|| message.get("tool_use_id"))
.or_else(|| message.get("call_id"))
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
{
step["call_id"] = json!(id);
}
Some(step)
}
fn interactions_content(content: &Value) -> Vec<Value> {
crate::llm::content::gemini_parts(content)
.iter()
.filter_map(content_from_gemini_part)
.collect()
}
fn content_from_gemini_part(part: &Value) -> Option<Value> {
if let Some(text) = part.get("text").and_then(Value::as_str) {
return Some(json!({"type": "text", "text": text}));
}
if let Some(inline) = part.get("inline_data").or_else(|| part.get("inlineData")) {
let mime = media_field(inline, "mime_type", "mimeType")?;
let data = inline.get("data").and_then(Value::as_str)?;
return Some(json!({
"type": media_kind_for_mime(mime),
"data": data,
"mime_type": mime,
}));
}
if let Some(file) = part.get("file_data").or_else(|| part.get("fileData")) {
let mime = media_field(file, "mime_type", "mimeType")?;
let uri = media_field(file, "file_uri", "fileUri")?;
return Some(json!({
"type": media_kind_for_mime(mime),
"uri": uri,
"mime_type": mime,
}));
}
None
}
fn media_field<'a>(value: &'a Value, snake: &str, camel: &str) -> Option<&'a str> {
value
.get(snake)
.or_else(|| value.get(camel))
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
}
fn media_kind_for_mime(mime: &str) -> &'static str {
match mime.split('/').next().unwrap_or_default() {
"image" => "image",
"audio" => "audio",
"video" => "video",
_ => "document",
}
}
fn interactions_tools(opts: &LlmRequestPayload) -> Option<Value> {
let declarations = crate::llm::providers::common::google_function_declaration_tools(
&opts.provider,
&opts.model,
opts.native_tools.as_deref(),
)?;
let tools: Vec<Value> = declarations
.get(0)?
.get("functionDeclarations")?
.as_array()?
.iter()
.map(|declaration| {
let mut tool = declaration.clone();
tool["type"] = json!("function");
tool
})
.collect();
(!tools.is_empty()).then_some(Value::Array(tools))
}
fn generation_config(
opts: &LlmRequestPayload,
caps: &crate::llm::capabilities::Capabilities,
) -> Option<Map<String, Value>> {
let mut config = Map::new();
if opts.max_tokens > 0 {
config.insert("max_output_tokens".to_string(), json!(opts.max_tokens));
}
if let Some(temperature) = opts.temperature {
config.insert("temperature".to_string(), json!(temperature));
}
if let Some(top_p) = opts.top_p {
config.insert("top_p".to_string(), json!(top_p));
}
if let Some(top_k) = opts.top_k {
config.insert("top_k".to_string(), json!(top_k));
}
if let Some(stop) = &opts.stop {
config.insert("stop_sequences".to_string(), json!(stop));
}
if let Some(seed) = opts.seed.filter(|_| caps.seed_supported) {
config.insert("seed".to_string(), json!(seed));
}
if opts.logprobs {
config.insert("response_logprobs".to_string(), json!(true));
}
if let Some(level) = thinking_level(&opts.thinking) {
config.insert("thinking_level".to_string(), json!(level));
}
if opts.thinking.is_enabled() {
config.insert("thinking_summaries".to_string(), json!("auto"));
}
if let Some(tool_choice) = tool_choice_mode(opts.tool_choice.as_ref()) {
config.insert("tool_choice".to_string(), json!(tool_choice));
}
(!config.is_empty()).then_some(config)
}
pub(crate) fn thinking_level(thinking: &ThinkingConfig) -> Option<&'static str> {
match thinking {
ThinkingConfig::Disabled => Some("minimal"),
ThinkingConfig::Enabled { .. } | ThinkingConfig::Adaptive => None,
ThinkingConfig::Effort { level } => Some(match level {
ReasoningEffort::None | ReasoningEffort::Minimal => "minimal",
ReasoningEffort::Low => "low",
ReasoningEffort::Medium => "medium",
ReasoningEffort::High | ReasoningEffort::XHigh | ReasoningEffort::Max => "high",
}),
}
}
pub(crate) fn tool_choice_mode(tool_choice: Option<&Value>) -> Option<&'static str> {
let choice = tool_choice?;
Some(match choice {
Value::String(value) => match value.as_str() {
"none" => "none",
"required" | "any" => "any",
_ => "auto",
},
Value::Object(object) => match object.get("type").and_then(Value::as_str) {
Some("none") => "none",
Some("function" | "tool" | "any" | "required") => "any",
_ => "auto",
},
_ => "auto",
})
}
fn response_format(opts: &LlmRequestPayload) -> Option<Value> {
match &opts.output_format {
OutputFormat::Text => None,
OutputFormat::JsonObject => Some(json!({"type": "object"})),
OutputFormat::JsonSchema { schema, .. } => Some(sanitize_schema_for_provider(
&opts.provider,
&opts.model,
SchemaCompatProfile::Google,
SchemaSurface::StructuredOutput,
schema,
)),
}
}
pub(crate) fn parse_response(
json: &Value,
request: &LlmRequestPayload,
) -> Result<LlmResult, VmError> {
if let Some(message) = json
.get("error")
.and_then(|error| error.get("message"))
.and_then(Value::as_str)
{
return Err(vm_err(format!("{} API error: {message}", request.provider)));
}
let mut text = String::new();
let mut thinking = String::new();
let mut blocks = Vec::new();
let mut tool_calls = Vec::new();
let mut raw_tool_calls = Vec::new();
let mut signature: Option<String> = None;
for (index, step) in json
.get("steps")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.enumerate()
{
match step.get("type").and_then(Value::as_str).unwrap_or_default() {
STEP_THOUGHT => {
if let Some(value) = step.get("signature").and_then(Value::as_str) {
if !value.is_empty() {
signature = Some(value.to_string());
}
}
for fragment in step_text(step, "summary") {
thinking.push_str(&fragment);
blocks.push(reasoning_block(&fragment));
}
}
STEP_MODEL_OUTPUT => {
for fragment in step_text(step, "content") {
text.push_str(&fragment);
let mut block = output_text_block(&fragment);
if let Some(signature) = &signature {
block["provider_metadata"] = json!({
"gemini": {"thought_signature": signature}
});
}
blocks.push(block);
}
}
STEP_FUNCTION_CALL => {
let Some(name) = step
.get("name")
.and_then(Value::as_str)
.filter(|name| !name.is_empty())
else {
continue;
};
raw_tool_calls.push(RawProviderToolCall::new(step.clone()).map_err(|message| {
vm_err(format!("gemini raw tool call parse error: {message}"))
})?);
let arguments = step.get("arguments").cloned().unwrap_or_else(|| json!({}));
let id = step
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("gemini_tool_{}", tool_calls.len()));
let mut tool_call = json!({
"id": id,
"name": name,
"arguments": arguments.clone(),
});
if let Some(signature) = &signature {
tool_call["thought_signature"] = json!(signature);
}
tool_calls.push(tool_call.clone());
let mut block = tool_call_block(tool_call["id"].clone(), name, arguments);
if let Some(signature) = &signature {
block["thought_signature"] = json!(signature);
}
block["part_index"] = json!(index);
blocks.push(block);
}
_ => {}
}
}
let usage = &json["usage"];
let input_tokens = usage["total_input_tokens"].as_i64().unwrap_or(0);
let output_tokens = usage["total_output_tokens"].as_i64().unwrap_or(0)
+ usage["total_thought_tokens"].as_i64().unwrap_or(0);
let cache_read_tokens = usage["total_cached_tokens"].as_i64().unwrap_or(0);
let request_id = json["id"].as_str().filter(|value| !value.is_empty());
let telemetry = ProviderTelemetry::from_gemini_interactions_usage(usage, request_id);
Ok(LlmResult {
text_projection: None,
served_fast: false,
text,
raw_tool_calls,
tool_calls,
input_tokens,
output_tokens,
cache_read_tokens,
cache_write_tokens: 0,
cache_supported: true,
model: request.model.clone(),
provider: request.provider.clone(),
thinking: (!thinking.is_empty()).then_some(thinking),
thinking_summary: None,
stop_reason: interaction_stop_reason(json["status"].as_str()),
blocks,
logprobs: Vec::new(),
telemetry,
})
}
fn interaction_stop_reason(status: Option<&str>) -> Option<String> {
Some(match status? {
"incomplete" => "max_tokens".to_string(),
other => other.to_string(),
})
}
fn step_text(step: &Value, field: &str) -> Vec<String> {
step.get(field)
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.filter_map(|entry| {
entry
.get("text")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(str::to_string)
})
.collect()
}