use crate::adapter::adapters::gemini::GeminiStreamer;
use crate::adapter::adapters::support::get_api_key;
use crate::adapter::{Adapter, AdapterKind, ServiceType, WebRequestData};
use crate::chat::{
Binary, BinarySource, ChatOptionsSet, ChatRequest, ChatResponse, ChatResponseFormat, ChatRole, ChatStream,
ChatStreamResponse, CompletionTokensDetails, ContentPart, MessageContent, PromptTokensDetails, ReasoningEffort,
StopReason, Tool, ToolCall, ToolChoice, ToolConfig, ToolName, ToolResponse, Usage,
};
use crate::resolver::{AuthData, Endpoint};
use crate::webc::StreamRequest as RequestBuilder;
use crate::webc::{EventSourceStream, WebClient, WebResponse};
use crate::{Error, Headers, ModelIden, Result, ServiceTarget};
use serde::Serialize;
use serde_json::{Value, json};
use std::collections::HashSet;
use value_ext::JsonValueExt;
const SKIP_THOUGHT_SIGNATURE_VALIDATOR: &str = "skip_thought_signature_validator";
pub struct GeminiAdapter;
pub(in crate::adapter) const REASONING_LOW: u32 = 1000;
pub(in crate::adapter) const REASONING_MEDIUM: u32 = 8000;
pub(in crate::adapter) const REASONING_HIGH: u32 = 24000;
fn insert_gemini_thinking_budget_value(payload: &mut Value, effort: &ReasoningEffort) -> Result<()> {
let budget = match effort {
ReasoningEffort::Zero => None,
ReasoningEffort::Low | ReasoningEffort::Minimal => Some(REASONING_LOW),
ReasoningEffort::Medium => Some(REASONING_MEDIUM),
ReasoningEffort::High | ReasoningEffort::Max | ReasoningEffort::XHigh => Some(REASONING_HIGH),
ReasoningEffort::Budget(budget) => Some(*budget),
};
if let Some(budget) = budget {
payload.x_insert("/generationConfig/thinkingConfig/thinkingBudget", budget)?;
}
Ok(())
}
const GEMINI_BUILTIN_TOOL_NAMES: &[&str] = &["googleSearch", "googleSearchRetrieval", "codeExecution", "urlContext"];
fn needs_server_side_tool_invocations(tools: &[Value]) -> bool {
let has_builtin = tools.iter().any(|tool| {
tool.as_object()
.is_some_and(|obj| obj.keys().any(|key| GEMINI_BUILTIN_TOOL_NAMES.contains(&key.as_str())))
});
let has_functions = tools.iter().any(|tool| tool.get("functionDeclarations").is_some());
has_builtin && has_functions
}
fn gemini_tool_config(tool_choice: Option<&ToolChoice>) -> Option<Value> {
let tool_choice = tool_choice?;
let mut config = json!({
"functionCallingConfig": {
"mode": match tool_choice {
ToolChoice::Auto => "AUTO",
ToolChoice::None => "NONE",
ToolChoice::Required | ToolChoice::Tool { .. } => "ANY",
}
}
});
if let Some(name) = tool_choice.tool_name() {
config["functionCallingConfig"]["allowedFunctionNames"] = json!([name]);
}
Some(config)
}
impl GeminiAdapter {
pub const API_KEY_DEFAULT_ENV_NAME: &str = "GEMINI_API_KEY";
}
impl Adapter for GeminiAdapter {
const DEFAULT_API_KEY_ENV_NAME: Option<&'static str> = Some(Self::API_KEY_DEFAULT_ENV_NAME);
fn default_endpoint(_kind: AdapterKind) -> Endpoint {
const BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta/";
Endpoint::from_static(BASE_URL)
}
fn default_auth(_kind: AdapterKind) -> AuthData {
match Self::DEFAULT_API_KEY_ENV_NAME {
Some(env_name) => AuthData::from_env(env_name),
None => AuthData::None,
}
}
async fn all_model_names(
kind: AdapterKind,
endpoint: Endpoint,
auth: AuthData,
web_client: &WebClient,
) -> Result<Vec<String>> {
let base_url = endpoint.base_url();
let url = format!("{base_url}models");
let api_key = auth.single_key_value().ok();
let headers = api_key
.map(|api_key| Headers::from(("x-goog-api-key".to_string(), api_key)))
.unwrap_or_default();
let mut res = web_client
.do_get(&url, &headers)
.await
.map_err(|webc_error| Error::WebAdapterCall {
adapter_kind: kind,
webc_error,
})?;
let mut models: Vec<String> = Vec::new();
if let Value::Array(models_value) = res.body.x_take("models")? {
for mut model in models_value {
let model_name: String = model.x_take("name")?;
let model_name = model_name.strip_prefix("models/").unwrap_or(&model_name).to_string();
models.push(model_name);
}
}
Ok(models)
}
fn get_service_url(model: &ModelIden, service_type: ServiceType, endpoint: Endpoint) -> Result<String> {
let base_url = endpoint.base_url();
let (_, model_name) = model.model_name.namespace_and_name();
let url = match service_type {
ServiceType::Chat => format!("{base_url}models/{model_name}:generateContent"),
ServiceType::ChatStream => format!("{base_url}models/{model_name}:streamGenerateContent?alt=sse"),
ServiceType::Embed => format!("{base_url}models/{model_name}:embedContent"), };
Ok(url)
}
fn to_web_request_data(
target: ServiceTarget,
service_type: ServiceType,
chat_req: ChatRequest,
options_set: ChatOptionsSet<'_, '_>,
) -> Result<WebRequestData> {
let ServiceTarget { endpoint, auth, model } = target;
let (_, model_name) = model.model_name.namespace_and_name();
let api_key = get_api_key(auth, &model)?;
let headers = Headers::from(("x-goog-api-key".to_string(), api_key.to_string()));
let (payload, provider_model_name) =
Self::build_gemini_request_payload(&model, model_name, chat_req, options_set)?;
let provider_model = model.from_name(&provider_model_name);
let url = Self::get_service_url(&provider_model, service_type, endpoint)?;
Ok(WebRequestData { url, headers, payload })
}
fn to_chat_response(
model_iden: ModelIden,
web_response: WebResponse,
_options_set: ChatOptionsSet<'_, '_>,
) -> Result<ChatResponse> {
let WebResponse { mut body, .. } = web_response;
let provider_model_name: Option<String> = body.x_remove("modelVersion").ok();
let provider_model_iden = model_iden.from_optional_name(provider_model_name);
let gemini_response = Self::body_to_gemini_chat_response(&model_iden.clone(), body)?;
let GeminiChatResponse {
content: gemini_content,
usage,
stop_reason,
} = gemini_response;
let stop_reason = stop_reason.map(StopReason::from);
let mut parts: Vec<ContentPart> = Vec::new();
let mut reasoning_text = String::new();
let mut text_buffer = String::new();
fn flush_text(text_buffer: &mut String, parts: &mut Vec<ContentPart>) {
if !text_buffer.is_empty() {
parts.push(ContentPart::Text(std::mem::take(text_buffer)));
}
}
for GeminiPart {
data,
thought_signature,
} in gemini_content
{
if let GeminiPartData::Reasoning(reasoning) = &data {
reasoning_text.push_str(reasoning);
}
if let Some(signature) = thought_signature {
flush_text(&mut text_buffer, &mut parts);
parts.push(ContentPart::ThoughtSignature(signature));
}
match data {
GeminiPartData::Text(text) => text_buffer.push_str(&text),
GeminiPartData::ToolCall(tool_call) => {
flush_text(&mut text_buffer, &mut parts);
parts.push(ContentPart::ToolCall(tool_call));
}
GeminiPartData::Binary(binary) => {
flush_text(&mut text_buffer, &mut parts);
parts.push(ContentPart::Binary(binary));
}
GeminiPartData::Reasoning(_) => {}
}
}
flush_text(&mut text_buffer, &mut parts);
let content = MessageContent::from_parts(parts);
Ok(ChatResponse {
content,
reasoning_content: Some(reasoning_text),
model_iden,
provider_model_iden,
stop_reason,
usage,
captured_raw_body: None, response_id: None,
})
}
fn to_chat_stream(
model_iden: ModelIden,
reqwest_builder: RequestBuilder,
options_set: ChatOptionsSet<'_, '_>,
) -> Result<ChatStreamResponse> {
let event_source = EventSourceStream::new(reqwest_builder);
let gemini_stream = GeminiStreamer::new(event_source, model_iden.clone(), options_set);
let frame_tap = gemini_stream.frame_tap();
let chat_stream = ChatStream::from_inter_stream(gemini_stream).with_frame_tap(frame_tap);
Ok(ChatStreamResponse {
model_iden,
stream: chat_stream,
})
}
fn to_embed_request_data(
service_target: crate::ServiceTarget,
embed_req: crate::embed::EmbedRequest,
options_set: crate::embed::EmbedOptionsSet<'_, '_>,
) -> Result<crate::adapter::WebRequestData> {
super::embed::to_embed_request_data(service_target, embed_req, options_set)
}
fn to_embed_response(
model_iden: crate::ModelIden,
web_response: crate::webc::WebResponse,
options_set: crate::embed::EmbedOptionsSet<'_, '_>,
) -> Result<crate::embed::EmbedResponse> {
super::embed::to_embed_response(model_iden, web_response, options_set)
}
}
impl GeminiAdapter {
pub(in crate::adapter) fn body_to_gemini_chat_response(
model_iden: &ModelIden,
mut body: Value,
) -> Result<GeminiChatResponse> {
if body.get("error").is_some() {
return Err(Error::ChatResponse {
model_iden: model_iden.clone(),
body,
});
}
let mut content: Vec<GeminiPart> = Vec::new();
let usage = body.x_take::<Value>("usageMetadata").map(Self::into_usage).unwrap_or_default();
let parts = match body.x_take::<Vec<Value>>("/candidates/0/content/parts") {
Ok(parts) => parts,
Err(_) => {
let finish_reason = body
.x_remove::<String>("/candidates/0/finishReason")
.ok()
.or_else(|| body.x_remove::<String>("/candidates/finishReason").ok());
let saw_usage_only_tail = body.get("candidates").is_some() || finish_reason.is_some();
if saw_usage_only_tail {
return Ok(GeminiChatResponse {
content,
usage,
stop_reason: finish_reason,
});
}
let body = json!({
"finishReason": finish_reason,
"usageMetadata": Value::Null,
});
return Err(Error::ChatResponse {
model_iden: model_iden.clone(),
body,
});
}
};
let mut tool_call_counter: usize = 0;
for mut part in parts {
let thought_signature = take_string(&mut part, "thoughtSignature");
let is_thought = take_bool(&mut part, "thought");
let data = if let Ok(fc) = part.x_take::<Value>("functionCall") {
let fn_name: String = fc.x_get("name").unwrap_or_default();
let call_id: String = fc.x_get("id").unwrap_or(format!("call#{}#{}", fn_name, tool_call_counter));
tool_call_counter += 1;
GeminiPartData::ToolCall(ToolCall {
call_id,
fn_name,
fn_arguments: fc.x_get("args").unwrap_or(Value::Null),
thought_signatures: thought_signature.clone().map(|signature| vec![signature]),
})
} else if let Some(text) = take_string(&mut part, "text") {
if is_thought {
GeminiPartData::Reasoning(text)
} else {
GeminiPartData::Text(text)
}
} else if let Ok(inline_data) = part.x_take::<Value>("inlineData")
&& let Ok(mime_type) = inline_data.x_get::<String>("mimeType")
&& let Ok(data) = inline_data.x_get::<String>("data")
{
GeminiPartData::Binary(Binary::from_base64(mime_type, data, None))
} else if thought_signature.is_some() {
GeminiPartData::Text(String::new())
} else {
continue;
};
content.push(GeminiPart {
data,
thought_signature,
});
}
let stop_reason: Option<String> = body.x_take("/candidates/0/finishReason").ok();
Ok(GeminiChatResponse {
content,
usage,
stop_reason,
})
}
pub(in crate::adapter) fn build_gemini_request_payload(
model: &ModelIden,
model_name: &str,
chat_req: ChatRequest,
options_set: ChatOptionsSet<'_, '_>,
) -> Result<(Value, String)> {
let (provider_model_name, computed_reasoning_effort) = match (model_name, options_set.reasoning_effort()) {
(model, None) => {
if let Some((prefix, last)) = model_name.rsplit_once('-') {
let reasoning = match last {
"none" | "zero" => Some(ReasoningEffort::Budget(0)),
"low" | "minimal" => Some(ReasoningEffort::Low),
"medium" => Some(ReasoningEffort::Medium),
"high" => Some(ReasoningEffort::High),
"xhigh" => Some(ReasoningEffort::XHigh),
"max" => Some(ReasoningEffort::Max),
_ => None,
};
let model = if reasoning.is_some() { prefix } else { model };
(model, reasoning)
} else {
(model, None)
}
}
(model, Some(effort)) => (model, Some(effort.clone())),
};
let GeminiChatRequestParts {
system,
contents,
tools,
} = Self::into_gemini_request_parts(model, chat_req)?;
let mut payload = json!({});
if let Some(computed_reasoning_effort) = computed_reasoning_effort {
let models = ["gemini-3", "gemma-4"];
if models.iter().any(|m| provider_model_name.contains(m)) {
let thinking_level = match computed_reasoning_effort {
ReasoningEffort::Zero => None,
ReasoningEffort::Budget(_) => None,
ReasoningEffort::Minimal => Some("MINIMAL"),
ReasoningEffort::Low => Some("LOW"),
ReasoningEffort::Medium => Some("MEDIUM"),
ReasoningEffort::High | ReasoningEffort::Max | ReasoningEffort::XHigh => Some("HIGH"),
};
if let Some(thinking_level) = thinking_level {
payload.x_insert("/generationConfig/thinkingConfig/thinkingLevel", thinking_level)?;
}
}
else {
insert_gemini_thinking_budget_value(&mut payload, &computed_reasoning_effort)?;
}
}
if options_set.capture_reasoning_content() == Some(true) {
payload.x_insert("/generationConfig/thinkingConfig/includeThoughts", true)?;
}
if let Some(system) = system {
payload.x_insert("systemInstruction", json!({ "parts": [{ "text": system }] }))?;
}
let server_side_invocations = tools.as_deref().is_some_and(needs_server_side_tool_invocations);
if let Some(tools) = tools {
payload.x_insert("tools", tools)?;
}
let tool_config = gemini_tool_config(options_set.tool_choice());
if tool_config.is_some() || server_side_invocations {
let mut tool_config = tool_config.unwrap_or_else(|| json!({}));
if server_side_invocations {
tool_config.x_insert("includeServerSideToolInvocations", true)?;
}
payload.x_insert("toolConfig", tool_config)?;
}
payload.x_insert("contents", contents)?;
if let Some(ChatResponseFormat::JsonSpec(st_json)) = options_set.response_format() {
payload.x_insert("/generationConfig/responseMimeType", "application/json")?;
payload.x_insert("/generationConfig/responseJsonSchema", st_json.schema.clone())?;
}
if let Some(temperature) = options_set.temperature() {
payload.x_insert("/generationConfig/temperature", temperature)?;
}
if !options_set.stop_sequences().is_empty() {
payload.x_insert("/generationConfig/stopSequences", options_set.stop_sequences())?;
}
if let Some(max_tokens) = options_set.max_tokens() {
payload.x_insert("/generationConfig/maxOutputTokens", max_tokens)?;
}
if let Some(top_p) = options_set.top_p() {
payload.x_insert("/generationConfig/topP", top_p)?;
}
Ok((payload, provider_model_name.to_string()))
}
pub(in crate::adapter) fn into_usage(mut usage_value: Value) -> Usage {
let total_tokens: Option<i32> = usage_value.x_take("totalTokenCount").ok();
let prompt_tokens: Option<i32> = usage_value.x_take("promptTokenCount").ok();
let g_cached_tokens: Option<i32> = usage_value.x_take("cachedContentTokenCount").ok();
let g_tool_use_tokens: Option<i32> = usage_value.x_take("toolUsePromptTokenCount").ok();
let prompt_tokens = match (prompt_tokens, g_tool_use_tokens) {
(Some(prompt), Some(tool_use)) => Some(prompt + tool_use),
(None, Some(tool_use)) => Some(tool_use),
(prompt, None) => prompt,
};
let prompt_tokens_details = g_cached_tokens.map(|g_cached_tokens| PromptTokensDetails {
cache_creation_tokens: None,
cache_creation_details: None,
cached_tokens: Some(g_cached_tokens),
audio_tokens: None,
});
let g_candidate_tokens: Option<i32> = usage_value.x_take("candidatesTokenCount").ok();
let g_thoughts_tokens: Option<i32> = usage_value.x_take("thoughtsTokenCount").ok();
let (completion_tokens, completion_tokens_details) = match (g_candidate_tokens, g_thoughts_tokens) {
(Some(c_tokens), Some(t_tokens)) => (
Some(c_tokens + t_tokens),
Some(CompletionTokensDetails {
accepted_prediction_tokens: None,
rejected_prediction_tokens: None,
reasoning_tokens: Some(t_tokens),
audio_tokens: None,
}),
),
(None, Some(t_tokens)) => {
(
None,
Some(CompletionTokensDetails {
accepted_prediction_tokens: None,
rejected_prediction_tokens: None,
reasoning_tokens: Some(t_tokens), audio_tokens: None,
}),
)
}
(c_tokens, None) => (c_tokens, None),
};
Usage {
prompt_tokens,
prompt_tokens_details,
completion_tokens,
completion_tokens_details,
total_tokens,
}
}
pub(in crate::adapter) fn into_gemini_request_parts(
model_iden: &ModelIden, chat_req: ChatRequest,
) -> Result<GeminiChatRequestParts> {
let mut contents: Vec<Value> = Vec::new();
let mut systems: Vec<String> = Vec::new();
if let Some(system) = chat_req.system {
systems.push(system);
}
for msg in chat_req.messages {
match msg.role {
ChatRole::System => {
if let Some(content) = msg.content.into_joined_texts() {
systems.push(content);
}
}
ChatRole::User => {
let mut parts_values: Vec<Value> = Vec::new();
for part in msg.content {
match part {
ContentPart::Text(text) => parts_values.push(json!({"text": text})),
ContentPart::Binary(binary) => {
let Binary {
content_type, source, ..
} = binary;
match &source {
BinarySource::Url(url) => parts_values.push(json!({
"file_data": {
"mime_type": content_type,
"file_uri": url
}
})),
BinarySource::Base64(content) => parts_values.push(json!({
"inline_data": {
"mime_type": content_type,
"data": content
}
})),
}
}
ContentPart::ToolCall(tool_call) => {
parts_values.push(json!({
"functionCall": {
"name": tool_call.fn_name,
"args": tool_call.fn_arguments,
}
}));
}
ContentPart::ToolResponse(tool_response) => {
let fn_name = gemini_function_response_name(&tool_response);
parts_values.push(json!({
"functionResponse": {
"name": &fn_name,
"response": {
"name": &fn_name,
"content": tool_response.content,
}
}
}));
}
ContentPart::ThoughtSignature(thought) => {
parts_values.push(json!({
"thoughtSignature": thought
}));
}
ContentPart::ReasoningContent(_) => {}
ContentPart::Custom(_) => {}
}
}
contents.push(json!({"role": "user", "parts": parts_values}));
}
ChatRole::Assistant => {
let is_gemini_3 = model_iden.model_name.contains("gemini-3");
let parts = GeminiModelPart::from_assistant_content(msg.content, is_gemini_3);
if !parts.is_empty() {
contents.push(json!({"role": "model", "parts": serde_json::to_value(parts)?}));
}
}
ChatRole::Tool => {
let mut parts_values: Vec<Value> = Vec::new();
for part in msg.content {
match part {
ContentPart::ToolCall(tool_call) => {
parts_values.push(json!({
"functionCall": {
"name": tool_call.fn_name,
"args": tool_call.fn_arguments,
}
}));
}
ContentPart::ToolResponse(tool_response) => {
let fn_name = gemini_function_response_name(&tool_response);
parts_values.push(json!({
"functionResponse": {
"name": &fn_name,
"response": {
"name": &fn_name,
"content": tool_response.content,
}
}
}));
}
ContentPart::ThoughtSignature(thought) => {
parts_values.push(json!({
"thoughtSignature": thought
}));
}
ContentPart::ReasoningContent(_) => {}
_ => {
return Err(Error::MessageContentTypeNotSupported {
model_iden: model_iden.clone(),
cause: "ChatRole::Tool can only contain ToolCall, ToolResponse, or Thought content parts",
});
}
}
}
contents.push(json!({"role": "user", "parts": parts_values}));
}
}
}
let system = if !systems.is_empty() {
Some(systems.join("\n"))
} else {
None
};
let contents = Self::merge_consecutive_tool_response_entries(contents);
let tools = if let Some(req_tools) = chat_req.tools {
let mut tools: Vec<Value> = Vec::new();
let mut function_declarations: Vec<Value> = Vec::new();
for req_tool in req_tools {
match Self::tool_to_gemini_tool(req_tool)? {
GeminiTool::Builtin(value) => tools.push(value),
GeminiTool::User(value) => function_declarations.push(value),
}
}
if !function_declarations.is_empty() {
tools.push(json!({"functionDeclarations": function_declarations}));
}
Some(tools)
} else {
None
};
Ok(GeminiChatRequestParts {
system,
contents,
tools,
})
}
fn tool_to_gemini_tool(tool: Tool) -> Result<GeminiTool> {
let Tool {
name,
description,
schema,
config,
..
} = tool;
let name_str = match &name {
ToolName::WebSearch => "googleSearch",
ToolName::Custom(name) => name.as_str(),
};
if GEMINI_BUILTIN_TOOL_NAMES.contains(&name_str) {
let config = match config {
Some(ToolConfig::WebSearch(_config)) => Some(json!({})),
Some(ToolConfig::Custom(config)) => Some(config),
None => None,
};
Ok(GeminiTool::Builtin(json!({ name_str: config })))
}
else {
let parameters = schema.filter(|s| !s.is_null());
Ok(GeminiTool::User(json!({
"name": name_str,
"description": description,
"parametersJsonSchema": parameters,
})))
}
}
}
impl GeminiAdapter {
fn merge_consecutive_tool_response_entries(contents: Vec<Value>) -> Vec<Value> {
fn is_tool_response_entry(entry: &Value) -> bool {
if entry.get("role").and_then(|r| r.as_str()) != Some("user") {
return false;
}
if let Some(parts) = entry.get("parts").and_then(|p| p.as_array()) {
!parts.is_empty() && parts.iter().all(|p| p.get("functionResponse").is_some())
} else {
false
}
}
let mut result: Vec<Value> = Vec::with_capacity(contents.len());
for entry in contents {
if is_tool_response_entry(&entry) {
if let Some(prev) = result.last_mut()
&& is_tool_response_entry(prev)
&& let (Some(prev_parts), Some(new_parts)) = (
prev.get_mut("parts").and_then(|p| p.as_array_mut()),
entry.get("parts").and_then(|p| p.as_array()),
) {
prev_parts.extend(new_parts.iter().cloned());
continue;
}
}
result.push(entry);
}
result
}
}
pub enum GeminiTool {
Builtin(Value),
User(Value),
}
pub(in crate::adapter) struct GeminiChatResponse {
pub content: Vec<GeminiPart>,
pub usage: Usage,
pub stop_reason: Option<String>,
}
pub(in crate::adapter) struct GeminiPart {
pub data: GeminiPartData,
pub thought_signature: Option<String>,
}
pub(in crate::adapter) enum GeminiPartData {
Text(String),
Binary(Binary),
ToolCall(ToolCall),
Reasoning(String),
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GeminiModelPart {
#[serde(flatten)]
data: GeminiModelPartData,
#[serde(skip_serializing_if = "Option::is_none")]
thought_signature: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
enum GeminiModelPartData {
Text(String),
FunctionCall { name: String, args: Value },
}
impl GeminiModelPart {
fn from_assistant_content(content: MessageContent, is_gemini_3: bool) -> Vec<Self> {
let call_signatures: HashSet<String> = content
.tool_calls()
.into_iter()
.filter_map(|call| call.thought_signatures.clone())
.flatten()
.collect();
let mut parts: Vec<Self> = Vec::new();
let mut pending_signature: Option<String> = None;
let mut is_first_call = true;
for part in content {
match part {
ContentPart::ThoughtSignature(signature) if call_signatures.contains(&signature) => {}
ContentPart::ThoughtSignature(signature) => pending_signature = Some(signature),
ContentPart::Text(text) => parts.push(Self {
data: GeminiModelPartData::Text(text),
thought_signature: pending_signature.take(),
}),
ContentPart::ToolCall(call) => {
let thought_signature = call
.thought_signatures
.and_then(|signatures| signatures.into_iter().next())
.or_else(|| pending_signature.take())
.or_else(|| {
(is_gemini_3 && is_first_call).then(|| SKIP_THOUGHT_SIGNATURE_VALIDATOR.to_string())
});
parts.push(Self {
data: GeminiModelPartData::FunctionCall {
name: call.fn_name,
args: call.fn_arguments,
},
thought_signature,
});
is_first_call = false;
}
ContentPart::Binary(_)
| ContentPart::ToolResponse(_)
| ContentPart::ReasoningContent(_)
| ContentPart::Custom(_) => {}
}
}
parts
}
}
pub(in crate::adapter) struct GeminiChatRequestParts {
pub system: Option<String>,
pub contents: Vec<Value>,
pub tools: Option<Vec<Value>>,
}
fn take_string(v: &mut Value, key: &str) -> Option<String> {
v.as_object_mut()
.and_then(|m| m.remove(key))
.and_then(|v| if let Value::String(s) = v { Some(s) } else { None })
}
fn take_bool(v: &mut Value, key: &str) -> bool {
v.as_object_mut()
.and_then(|m| m.remove(key))
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
fn gemini_function_response_name(tool_response: &ToolResponse) -> String {
tool_response
.fn_name
.clone()
.or_else(|| infer_gemini_synthetic_call_fn_name(&tool_response.call_id))
.unwrap_or_else(|| tool_response.call_id.clone())
}
fn infer_gemini_synthetic_call_fn_name(call_id: &str) -> Option<String> {
let rest = call_id.strip_prefix("call#")?;
let (name, counter) = rest.rsplit_once('#')?;
if name.is_empty() || counter.parse::<usize>().is_err() {
return None;
}
Some(name.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chat::{ChatMessage, ChatOptions, JsonSpec, WebSearchConfig};
#[test]
fn tool_use_prompt_tokens_are_folded_into_prompt_tokens() {
let usage = GeminiAdapter::into_usage(json!({
"promptTokenCount": 68,
"candidatesTokenCount": 50,
"thoughtsTokenCount": 112,
"toolUsePromptTokenCount": 5858,
"totalTokenCount": 6088
}));
assert_eq!(usage.prompt_tokens, Some(68 + 5858));
assert_eq!(usage.completion_tokens, Some(50 + 112));
assert_eq!(usage.total_tokens, Some(6088));
assert_eq!(
usage.prompt_tokens.unwrap() + usage.completion_tokens.unwrap(),
usage.total_tokens.unwrap()
);
}
#[test]
fn usage_without_tool_use_is_unchanged() {
let usage = GeminiAdapter::into_usage(json!({
"promptTokenCount": 10,
"candidatesTokenCount": 4,
"totalTokenCount": 14
}));
assert_eq!(usage.prompt_tokens, Some(10));
assert_eq!(usage.completion_tokens, Some(4));
assert_eq!(usage.prompt_tokens_details, None);
}
#[test]
fn cached_tokens_are_not_added_but_tool_use_tokens_are() {
let usage = GeminiAdapter::into_usage(json!({
"promptTokenCount": 100,
"cachedContentTokenCount": 80,
"candidatesTokenCount": 10,
"toolUsePromptTokenCount": 500,
"totalTokenCount": 610
}));
assert_eq!(usage.prompt_tokens, Some(600), "only tool-use is added, not cached");
assert_eq!(
usage.prompt_tokens_details.and_then(|d| d.cached_tokens),
Some(80),
"cached tokens still reported as a subset"
);
}
#[test]
fn tool_use_tokens_alone_become_the_prompt_count() {
let usage = GeminiAdapter::into_usage(json!({
"candidatesTokenCount": 10,
"toolUsePromptTokenCount": 500,
"totalTokenCount": 510
}));
assert_eq!(usage.prompt_tokens, Some(500));
}
#[test]
fn merge_consecutive_tool_responses() {
let contents = vec![
json!({"role": "model", "parts": [{"functionCall": {"name": "read", "args": {}}}]}),
json!({"role": "user", "parts": [{"functionResponse": {"name": "call1", "response": {"content": "a"}}}]}),
json!({"role": "user", "parts": [{"functionResponse": {"name": "call2", "response": {"content": "b"}}}]}),
];
let merged = GeminiAdapter::merge_consecutive_tool_response_entries(contents);
assert_eq!(merged.len(), 2); let parts = merged[1].get("parts").unwrap().as_array().unwrap();
assert_eq!(parts.len(), 2); }
#[test]
fn merge_does_not_merge_non_tool_user_entries() {
let contents = vec![
json!({"role": "user", "parts": [{"text": "hello"}]}),
json!({"role": "user", "parts": [{"functionResponse": {"name": "c1", "response": {"content": "x"}}}]}),
];
let merged = GeminiAdapter::merge_consecutive_tool_response_entries(contents);
assert_eq!(merged.len(), 2); }
#[test]
fn merge_three_consecutive_tool_responses() {
let contents = vec![
json!({"role": "user", "parts": [{"functionResponse": {"name": "c1", "response": {"content": "a"}}}]}),
json!({"role": "user", "parts": [{"functionResponse": {"name": "c2", "response": {"content": "b"}}}]}),
json!({"role": "user", "parts": [{"functionResponse": {"name": "c3", "response": {"content": "c"}}}]}),
];
let merged = GeminiAdapter::merge_consecutive_tool_response_entries(contents);
assert_eq!(merged.len(), 1);
let parts = merged[0].get("parts").unwrap().as_array().unwrap();
assert_eq!(parts.len(), 3);
}
#[test]
fn function_response_uses_function_name_when_available() {
let model_iden = ModelIden::new(AdapterKind::Gemini, "gemini-2.5-flash");
let chat_req = ChatRequest::new(vec![ChatMessage::from(
ToolResponse::new("call_123", "{}").with_fn_name("get_weather"),
)]);
let parts = GeminiAdapter::into_gemini_request_parts(&model_iden, chat_req).unwrap();
let function_response = &parts.contents[0]["parts"][0]["functionResponse"];
assert_eq!(function_response["name"], "get_weather");
assert_eq!(function_response["response"]["name"], "get_weather");
}
#[test]
fn function_response_infers_name_from_gemini_synthetic_call_id() {
let model_iden = ModelIden::new(AdapterKind::Gemini, "gemini-2.5-flash");
let chat_req = ChatRequest::new(vec![ChatMessage::from(ToolResponse::new("call#get_weather#0", "{}"))]);
let parts = GeminiAdapter::into_gemini_request_parts(&model_iden, chat_req).unwrap();
let function_response = &parts.contents[0]["parts"][0]["functionResponse"];
assert_eq!(function_response["name"], "get_weather");
assert_eq!(function_response["response"]["name"], "get_weather");
}
fn build_payload(chat_req: ChatRequest, options: &ChatOptions) -> Value {
let model_iden = ModelIden::new(AdapterKind::Gemini, "gemini-3.7-flash");
let options_set = ChatOptionsSet::default().with_chat_options(Some(options));
GeminiAdapter::build_gemini_request_payload(&model_iden, "gemini-3.7-flash", chat_req, options_set)
.unwrap()
.0
}
fn web_search_tool() -> Tool {
Tool::new_web_search().with_config(WebSearchConfig::default())
}
#[test]
fn mixing_builtin_and_function_tools_opts_into_server_side_invocations() {
let chat_req = ChatRequest::from_user("What is the weather in Cairo?")
.with_tools(vec![web_search_tool(), Tool::new("get_weather")]);
let payload = build_payload(chat_req, &ChatOptions::default());
assert_eq!(payload["toolConfig"]["includeServerSideToolInvocations"], true);
}
#[test]
fn every_builtin_triggers_the_opt_in_when_mixed() {
for builtin in GEMINI_BUILTIN_TOOL_NAMES {
let chat_req = ChatRequest::from_user("Do the thing.").with_tools(vec![
Tool::new(*builtin).with_config(json!({})),
Tool::new("get_weather"),
]);
let payload = build_payload(chat_req, &ChatOptions::default());
assert_eq!(
payload["toolConfig"]["includeServerSideToolInvocations"], true,
"builtin {builtin} should opt in when mixed with function declarations"
);
}
}
#[test]
fn unmixed_requests_do_not_get_the_opt_in() {
let builtin_only = build_payload(
ChatRequest::from_user("Search for something.").with_tools(vec![web_search_tool()]),
&ChatOptions::default(),
);
assert!(
builtin_only.get("toolConfig").is_none(),
"a builtin-only request needs no toolConfig"
);
let functions_only = build_payload(
ChatRequest::from_user("What is the weather?").with_tools(vec![Tool::new("get_weather")]),
&ChatOptions::default(),
);
assert!(
functions_only.get("toolConfig").is_none(),
"a function-only request needs no toolConfig"
);
let no_tools = build_payload(ChatRequest::from_user("Hello."), &ChatOptions::default());
assert!(no_tools.get("toolConfig").is_none());
}
#[test]
fn the_opt_in_merges_with_tool_choice_rather_than_replacing_it() {
let options = ChatOptions::default().with_tool_choice(ToolChoice::tool("get_weather"));
let chat_req = ChatRequest::from_user("What is the weather in Cairo?")
.with_tools(vec![web_search_tool(), Tool::new("get_weather")]);
let payload = build_payload(chat_req, &options);
assert_eq!(payload["toolConfig"]["includeServerSideToolInvocations"], true);
assert_eq!(payload["toolConfig"]["functionCallingConfig"]["mode"], "ANY");
assert_eq!(
payload["toolConfig"]["functionCallingConfig"]["allowedFunctionNames"],
json!(["get_weather"])
);
}
#[test]
fn tool_choice_required_maps_to_gemini_any_mode() {
let model_iden = ModelIden::new(AdapterKind::Gemini, "gemini-2.5-flash");
let options = ChatOptions::default().with_tool_choice(ToolChoice::Required);
let options_set = ChatOptionsSet::default().with_chat_options(Some(&options));
let chat_req = ChatRequest::from_user("Use the weather tool.").with_tools(vec![Tool::new("get_weather")]);
let (payload, _) =
GeminiAdapter::build_gemini_request_payload(&model_iden, "gemini-2.5-flash", chat_req, options_set)
.unwrap();
assert_eq!(payload["toolConfig"]["functionCallingConfig"]["mode"], "ANY");
assert!(
payload["toolConfig"]["functionCallingConfig"]
.get("allowedFunctionNames")
.is_none()
);
}
#[test]
fn tool_choice_specific_tool_sets_gemini_allowed_function() {
let model_iden = ModelIden::new(AdapterKind::Gemini, "gemini-2.5-flash");
let options = ChatOptions::default().with_tool_choice(ToolChoice::tool("get_weather"));
let options_set = ChatOptionsSet::default().with_chat_options(Some(&options));
let chat_req = ChatRequest::from_user("Use the weather tool.").with_tools(vec![Tool::new("get_weather")]);
let (payload, _) =
GeminiAdapter::build_gemini_request_payload(&model_iden, "gemini-2.5-flash", chat_req, options_set)
.unwrap();
assert_eq!(payload["toolConfig"]["functionCallingConfig"]["mode"], "ANY");
assert_eq!(
payload["toolConfig"]["functionCallingConfig"]["allowedFunctionNames"],
json!(["get_weather"])
);
}
#[test]
fn tool_call_id_uses_counter() {
let body = json!({
"candidates": [{
"content": {
"role": "model",
"parts": [
{"functionCall": {"name": "read_file", "args": {"path": "a.rs"}}},
{"functionCall": {"name": "read_file", "args": {"path": "b.rs"}}}
]
}
}],
"usageMetadata": {"totalTokenCount": 100}
});
let model_iden = ModelIden::new(AdapterKind::Gemini, "gemini-test");
let response = GeminiAdapter::body_to_gemini_chat_response(&model_iden, body).unwrap();
let tool_calls: Vec<_> = response
.content
.into_iter()
.filter_map(|part| match part.data {
GeminiPartData::ToolCall(tc) => Some(tc),
_ => None,
})
.collect();
assert_eq!(tool_calls.len(), 2);
assert_ne!(tool_calls[0].call_id, tool_calls[1].call_id);
assert!(tool_calls[0].call_id.contains("read_file"));
assert!(tool_calls[1].call_id.contains("read_file"));
}
#[test]
fn body_to_gemini_chat_response_accepts_usage_only_stream_tail() {
let model_iden = ModelIden::new(AdapterKind::Gemini, "gemini-2.5-flash");
let response = GeminiAdapter::body_to_gemini_chat_response(
&model_iden,
json!({
"candidates": [
{
"finishReason": "STOP"
}
],
"usageMetadata": {
"promptTokenCount": 10,
"candidatesTokenCount": 4,
"totalTokenCount": 14
}
}),
)
.expect("usage-only stream tail should not be treated as an error");
assert!(response.content.is_empty());
assert_eq!(response.usage.total_tokens, Some(14));
assert_eq!(response.usage.prompt_tokens, Some(10));
assert_eq!(response.usage.completion_tokens, Some(4));
assert_eq!(response.stop_reason.as_deref(), Some("STOP"));
}
#[test]
fn body_to_gemini_chat_response_accepts_tail_with_null_finish_reason() {
let model_iden = ModelIden::new(AdapterKind::Gemini, "gemini-2.5-flash");
let response = GeminiAdapter::body_to_gemini_chat_response(
&model_iden,
json!({
"candidates": [
{
"finishReason": null
}
],
"usageMetadata": {
"promptTokenCount": 10,
"candidatesTokenCount": 4,
"totalTokenCount": 14
}
}),
)
.expect("usage-only stream tail with null finishReason should not be an error");
assert!(response.content.is_empty());
assert_eq!(response.usage.total_tokens, Some(14));
}
#[test]
fn body_to_gemini_chat_response_still_rejects_missing_candidates() {
let model_iden = ModelIden::new(AdapterKind::Gemini, "gemini-2.5-flash");
let result = GeminiAdapter::body_to_gemini_chat_response(
&model_iden,
json!({
"usageMetadata": {
"promptTokenCount": 10,
"candidatesTokenCount": 4,
"totalTokenCount": 14
}
}),
);
let err = match result {
Err(e) => e,
Ok(_) => panic!("missing candidates should still be rejected"),
};
assert!(matches!(err, Error::ChatResponse { .. }));
}
#[test]
fn response_json_schema_is_forwarded_raw() {
let model_iden = ModelIden::new(AdapterKind::Gemini, "gemini-2.5-flash");
let schema = json!({
"type": "object",
"properties": { "person": { "$ref": "#/$defs/Person" } },
"required": ["person"],
"$defs": {
"Person": { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] }
}
});
let options = ChatOptions::default().with_response_format(JsonSpec::new("person", schema.clone()));
let options_set = ChatOptionsSet::default().with_chat_options(Some(&options));
let chat_req = ChatRequest::from_user("Return a person.");
let (payload, _) =
GeminiAdapter::build_gemini_request_payload(&model_iden, "gemini-2.5-flash", chat_req, options_set)
.unwrap();
assert_eq!(payload["generationConfig"]["responseMimeType"], "application/json");
assert_eq!(payload["generationConfig"]["responseJsonSchema"], schema);
}
#[test]
fn tool_parameters_use_parameters_json_schema_raw() {
let model_iden = ModelIden::new(AdapterKind::Gemini, "gemini-2.5-flash");
let schema = json!({
"type": "object",
"properties": { "loc": { "$ref": "#/$defs/Loc" } },
"required": ["loc"],
"$defs": {
"Loc": { "type": "object", "properties": { "city": { "type": "string" } }, "required": ["city"] }
}
});
let chat_req =
ChatRequest::from_user("Use the tool.").with_tools(vec![Tool::new("record").with_schema(schema.clone())]);
let options_set = ChatOptionsSet::default();
let (payload, _) =
GeminiAdapter::build_gemini_request_payload(&model_iden, "gemini-2.5-flash", chat_req, options_set)
.unwrap();
let decl = &payload["tools"][0]["functionDeclarations"][0];
assert_eq!(decl["parametersJsonSchema"], schema);
assert!(
decl.get("parameters").is_none(),
"must not use the restricted `parameters` field"
);
}
fn gemini_3() -> ModelIden {
ModelIden::new(AdapterKind::Gemini, "gemini-3-pro")
}
fn response_with_parts(parts: Value) -> WebResponse {
WebResponse {
status: reqwest::StatusCode::OK,
body: json!({
"candidates": [{"content": {"parts": parts, "role": "model"}, "finishReason": "STOP"}],
"usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 5, "thoughtsTokenCount": 3},
"modelVersion": "gemini-3-pro"
}),
}
}
fn call(path: &str) -> ToolCall {
ToolCall {
call_id: format!("call#read_file#{path}"),
fn_name: "read_file".to_string(),
fn_arguments: json!({"path": path}),
thought_signatures: None,
}
}
fn shape(parts: &[ContentPart]) -> Vec<String> {
parts
.iter()
.map(|part| match part {
ContentPart::Text(text) => format!("text({text})"),
ContentPart::ThoughtSignature(signature) => format!("sig({signature})"),
ContentPart::ToolCall(call) => {
let mirrored = call.thought_signatures.as_ref().map(|s| s.join("+")).unwrap_or_default();
format!("call({}:{mirrored})", call.fn_arguments["path"].as_str().unwrap_or(""))
}
other => format!("{other:?}"),
})
.collect()
}
#[test]
fn signatures_stay_next_to_the_part_they_arrived_on() {
let response = GeminiAdapter::to_chat_response(
gemini_3(),
response_with_parts(json!([
{"text": "Reading both."},
{"functionCall": {"name": "read_file", "args": {"path": "/a"}}, "thoughtSignature": "sig-a"},
{"functionCall": {"name": "read_file", "args": {"path": "/b"}}, "thoughtSignature": "sig-b"}
])),
ChatOptionsSet::default(),
)
.expect("chat response");
assert_eq!(
shape(response.content.parts()),
[
"text(Reading both.)",
"sig(sig-a)",
"call(/a:sig-a)",
"sig(sig-b)",
"call(/b:sig-b)"
]
);
}
#[test]
fn each_call_goes_back_with_its_own_signature() {
let content = MessageContent::from_parts(vec![
ContentPart::Text("Reading both.".to_string()),
ContentPart::ThoughtSignature("sig-a".to_string()),
ContentPart::ToolCall(call("/a")),
ContentPart::ThoughtSignature("sig-b".to_string()),
ContentPart::ToolCall(call("/b")),
]);
let chat_req = ChatRequest::new(vec![ChatMessage::user("Read both."), ChatMessage::assistant(content)]);
let parts = GeminiAdapter::into_gemini_request_parts(&gemini_3(), chat_req).expect("request parts");
let model_turn = &parts.contents[1]["parts"];
assert_eq!(model_turn[0], json!({"text": "Reading both."}));
assert_eq!(model_turn[1]["thoughtSignature"], "sig-a");
assert_eq!(model_turn[1]["functionCall"]["args"]["path"], "/a");
assert_eq!(model_turn[2]["thoughtSignature"], "sig-b");
assert_eq!(model_turn[2]["functionCall"]["args"]["path"], "/b");
}
#[test]
fn a_signature_on_a_text_part_rides_the_text_part_back() {
let content = MessageContent::from_parts(vec![
ContentPart::ThoughtSignature("sig-t".to_string()),
ContentPart::Text("Done.".to_string()),
]);
let chat_req = ChatRequest::new(vec![ChatMessage::user("Hi"), ChatMessage::assistant(content)]);
let parts = GeminiAdapter::into_gemini_request_parts(&gemini_3(), chat_req).expect("request parts");
assert_eq!(
parts.contents[1]["parts"],
json!([{"text": "Done.", "thoughtSignature": "sig-t"}])
);
}
#[test]
fn a_mirrored_signature_on_the_call_is_honoured() {
let mut mirrored = call("/a");
mirrored.thought_signatures = Some(vec!["sig-m".to_string()]);
let content = MessageContent::from_parts(vec![ContentPart::ToolCall(mirrored)]);
let chat_req = ChatRequest::new(vec![ChatMessage::user("Read."), ChatMessage::assistant(content)]);
let parts = GeminiAdapter::into_gemini_request_parts(&gemini_3(), chat_req).expect("request parts");
assert_eq!(parts.contents[1]["parts"][0]["thoughtSignature"], "sig-m");
}
#[test]
fn a_two_call_turn_round_trips_with_both_signatures() {
let response = GeminiAdapter::to_chat_response(
gemini_3(),
response_with_parts(json!([
{"functionCall": {"name": "read_file", "args": {"path": "/a"}}, "thoughtSignature": "sig-a"},
{"functionCall": {"name": "read_file", "args": {"path": "/b"}}, "thoughtSignature": "sig-b"}
])),
ChatOptionsSet::default(),
)
.expect("chat response");
let chat_req = ChatRequest::new(vec![
ChatMessage::user("Read both."),
ChatMessage::assistant(response.content),
]);
let parts = GeminiAdapter::into_gemini_request_parts(&gemini_3(), chat_req).expect("request parts");
let model_turn = parts.contents[1]["parts"].as_array().expect("parts");
let signatures: Vec<&str> = model_turn
.iter()
.map(|part| part["thoughtSignature"].as_str().unwrap_or("-"))
.collect();
assert_eq!(signatures, ["sig-a", "sig-b"]);
assert!(model_turn.iter().all(|part| part.get("functionCall").is_some()));
}
fn signed_call(name: &str, signature: Option<&str>) -> ToolCall {
ToolCall {
call_id: format!("call#{name}#0"),
fn_name: name.to_string(),
fn_arguments: json!({}),
thought_signatures: signature.map(|s| vec![s.to_string()]),
}
}
fn model_parts(parts: Vec<ContentPart>) -> Vec<Value> {
let chat_req =
ChatRequest::from_user("hi").append_message(ChatMessage::assistant(MessageContent::from_parts(parts)));
let payload = build_payload(chat_req, &ChatOptions::default());
let contents = payload["contents"].as_array().unwrap();
let model = contents.iter().find(|c| c["role"] == "model").expect("model turn");
model["parts"].as_array().unwrap().clone()
}
fn assert_no_bare_signatures(parts: &[Value]) {
for part in parts {
assert!(
part.get("text").is_some() || part.get("functionCall").is_some(),
"part without data sent: {part}"
);
}
}
#[test]
fn free_signatures_before_a_call_are_not_sent_bare() {
let parts = model_parts(vec![
ContentPart::ThoughtSignature("sig-1".to_string()),
ContentPart::ThoughtSignature("sig-2".to_string()),
ContentPart::ToolCall(signed_call("get_weather", None)),
]);
assert_no_bare_signatures(&parts);
assert_eq!(parts.len(), 1);
assert_eq!(parts[0]["thoughtSignature"], "sig-2");
}
#[test]
fn streamed_shape_puts_each_signature_on_its_part() {
let parts = model_parts(vec![
ContentPart::ThoughtSignature("sig-t".to_string()),
ContentPart::ThoughtSignature("sig-a".to_string()),
ContentPart::ThoughtSignature("sig-b".to_string()),
ContentPart::Text("Reading both.".to_string()),
ContentPart::ToolCall(signed_call("read_a", Some("sig-a"))),
ContentPart::ToolCall(signed_call("read_b", Some("sig-b"))),
]);
assert_no_bare_signatures(&parts);
let signatures: Vec<&str> = parts.iter().map(|p| p["thoughtSignature"].as_str().unwrap_or("-")).collect();
assert_eq!(signatures, ["sig-t", "sig-a", "sig-b"]);
}
#[test]
fn trailing_signature_is_dropped() {
let parts = model_parts(vec![
ContentPart::Text("Hmm.".to_string()),
ContentPart::ThoughtSignature("sig-1".to_string()),
]);
assert_eq!(parts, [json!({"text": "Hmm."})]);
}
#[test]
fn unsigned_first_call_gets_the_validator_placeholder() {
let parts = model_parts(vec![
ContentPart::ToolCall(signed_call("a", None)),
ContentPart::ToolCall(signed_call("b", None)),
]);
assert_eq!(parts[0]["thoughtSignature"], SKIP_THOUGHT_SIGNATURE_VALIDATOR);
assert!(parts[1].get("thoughtSignature").is_none());
}
#[test]
fn parser_keeps_a_signature_only_part_as_empty_text() {
let body = json!({"candidates": [{"content": {"parts": [
{"thoughtSignature": "sig-t"},
{"functionCall": {"name": "read_file", "args": {}}, "thoughtSignature": "sig-a"}
]}}]});
let response = GeminiAdapter::body_to_gemini_chat_response(&gemini_3(), body).unwrap();
let [first, second] = response.content.as_slice() else {
panic!("expected two parts");
};
assert!(matches!(&first.data, GeminiPartData::Text(text) if text.is_empty()));
assert_eq!(first.thought_signature.as_deref(), Some("sig-t"));
let GeminiPartData::ToolCall(call) = &second.data else {
panic!("expected a call");
};
assert_eq!(call.thought_signatures.as_deref(), Some(&["sig-a".to_string()][..]));
}
}