use crate::error::LlmError;
use crate::types::{LlmClient, LlmDoneOutcome, LlmEvent, LlmRequest, LlmStream};
use async_trait::async_trait;
use futures::StreamExt;
use meerkat_core::schema::{CompiledSchema, SchemaCompat, SchemaError, SchemaWarning};
use meerkat_core::{ContentBlock, ImageData, Message, OutputSchema, Provider, StopReason, Usage};
use serde::Deserialize;
use serde_json::{Map, Value, json};
use std::collections::HashMap;
pub struct GeminiClient {
api_key: String,
base_url: String,
http: reqwest::Client,
}
impl GeminiClient {
pub fn new(api_key: String) -> Self {
Self::new_with_base_url(
api_key,
"https://generativelanguage.googleapis.com".to_string(),
)
}
pub fn new_with_base_url(api_key: String, base_url: String) -> Self {
let http =
crate::http::build_http_client_for_base_url(reqwest::Client::builder(), &base_url)
.unwrap_or_else(|_| reqwest::Client::new());
Self {
api_key,
base_url,
http,
}
}
pub fn with_base_url(mut self, url: String) -> Self {
if let Ok(http) =
crate::http::build_http_client_for_base_url(reqwest::Client::builder(), &url)
{
self.http = http;
}
self.base_url = url;
self
}
pub fn from_env() -> Result<Self, LlmError> {
let api_key = std::env::var("RKAT_GEMINI_API_KEY")
.or_else(|_| {
std::env::var("GEMINI_API_KEY").or_else(|_| std::env::var("GOOGLE_API_KEY"))
})
.map_err(|_| LlmError::InvalidApiKey)?;
Ok(Self::new(api_key))
}
fn build_request_body(&self, request: &LlmRequest) -> Result<Value, LlmError> {
let mut contents = Vec::new();
let mut system_instruction = None;
let mut tool_name_by_id: HashMap<String, String> = HashMap::new();
for msg in &request.messages {
match msg {
Message::System(s) => {
system_instruction = Some(serde_json::json!({
"parts": [{"text": s.content}]
}));
}
Message::User(u) => {
if meerkat_core::has_images(&u.content) {
let parts: Vec<Value> = u
.content
.iter()
.map(|block| match block {
ContentBlock::Text { text } => serde_json::json!({
"text": text
}),
ContentBlock::Image {
media_type,
data: ImageData::Inline { data },
} => serde_json::json!({
"inlineData": {
"mimeType": media_type,
"data": data
}
}),
_ => serde_json::json!({
"text": block.text_projection()
}),
})
.collect();
contents.push(serde_json::json!({
"role": "user",
"parts": parts
}));
} else {
contents.push(serde_json::json!({
"role": "user",
"parts": [{"text": u.text_content()}]
}));
}
}
Message::Assistant(_) => {
return Err(LlmError::InvalidRequest {
message: "Legacy Message::Assistant is not supported by Gemini adapter; use BlockAssistant".to_string(),
});
}
Message::BlockAssistant(a) => {
let mut parts = Vec::new();
for block in &a.blocks {
match block {
meerkat_core::AssistantBlock::Text { text, meta } => {
if !text.is_empty() {
let mut part = serde_json::json!({"text": text});
if let Some(meerkat_core::ProviderMeta::Gemini {
thought_signature,
}) = meta.as_deref()
{
part["thoughtSignature"] =
serde_json::json!(thought_signature);
}
parts.push(part);
}
}
meerkat_core::AssistantBlock::Reasoning { text, .. } => {
if !text.is_empty() {
parts.push(serde_json::json!({"text": format!("[Reasoning: {}]", text)}));
}
}
meerkat_core::AssistantBlock::ToolUse {
id,
name,
args,
meta,
} => {
tool_name_by_id.insert(id.clone(), name.clone());
let args_value: Value = serde_json::from_str(args.get())
.unwrap_or_else(|_| serde_json::json!({}));
let mut part = serde_json::json!({"functionCall": {"name": name, "args": args_value}});
if let Some(meerkat_core::ProviderMeta::Gemini {
thought_signature,
}) = meta.as_deref()
{
part["thoughtSignature"] = serde_json::json!(thought_signature);
}
parts.push(part);
}
_ => {} }
}
contents.push(serde_json::json!({
"role": "model",
"parts": parts
}));
}
Message::ToolResults { results } => {
let mut parts: Vec<Value> = Vec::new();
for r in results {
let function_name = tool_name_by_id
.get(&r.tool_use_id)
.cloned()
.unwrap_or_else(|| r.tool_use_id.clone());
parts.push(serde_json::json!({
"functionResponse": {
"name": function_name,
"response": {
"content": r.text_content(),
"error": r.is_error
}
}
}));
if r.has_images() {
for block in &r.content {
if let ContentBlock::Image { media_type, data } = block {
match data {
ImageData::Inline { data } => {
parts.push(serde_json::json!({
"inlineData": {
"mimeType": media_type,
"data": data
}
}));
}
ImageData::Blob { .. } => parts.push(serde_json::json!({
"text": block.text_projection()
})),
}
}
}
}
}
contents.push(serde_json::json!({
"role": "user",
"parts": parts
}));
}
}
}
let mut body = serde_json::json!({
"contents": contents,
"generationConfig": {
"maxOutputTokens": request.max_tokens,
}
});
if let Some(system) = system_instruction {
body["systemInstruction"] = system;
}
if let Some(temp) = request.temperature
&& let Some(num) = serde_json::Number::from_f64(temp as f64)
{
body["generationConfig"]["temperature"] = Value::Number(num);
}
if let Some(ref params) = request.provider_params {
let thinking_budget = params.get("thinking_budget").or_else(|| {
params
.get("thinking")
.and_then(|t| t.get("thinking_budget"))
});
if let Some(budget) = thinking_budget {
body["generationConfig"]["thinkingConfig"] = serde_json::json!({
"thinkingBudget": budget
});
}
if let Some(top_k) = params.get("top_k") {
body["generationConfig"]["topK"] = top_k.clone();
}
if let Some(top_p) = params.get("top_p") {
body["generationConfig"]["topP"] = top_p.clone();
}
if let Some(structured) = params.get("structured_output") {
let output_schema: OutputSchema = serde_json::from_value(structured.clone())
.map_err(|e| LlmError::InvalidRequest {
message: format!("Invalid structured_output schema: {e}"),
})?;
let compiled = Self::compile_schema_for_gemini(&output_schema).map_err(|e| {
LlmError::InvalidRequest {
message: e.to_string(),
}
})?;
body["generationConfig"]["responseMimeType"] =
Value::String("application/json".to_string());
body["generationConfig"]["responseJsonSchema"] = compiled.schema;
}
}
if !request.tools.is_empty() {
let function_declarations: Vec<Value> = request
.tools
.iter()
.map(|t| {
let parameters = normalize_gemini_function_parameters_schema(&t.input_schema)?;
Ok(serde_json::json!({
"name": t.name,
"description": t.description,
"parameters": parameters
}))
})
.collect::<Result<Vec<_>, LlmError>>()?;
body["tools"] = serde_json::json!([{
"functionDeclarations": function_declarations
}]);
}
Ok(body)
}
fn parse_stream_line(line: &str) -> Option<GenerateContentResponse> {
serde_json::from_str(line).ok()
}
fn compile_schema_for_gemini(
output_schema: &OutputSchema,
) -> Result<CompiledSchema, SchemaError> {
let schema = output_schema.schema.as_value().clone();
let warnings = validate_gemini_response_json_schema(&schema, Provider::Gemini);
if output_schema.compat == SchemaCompat::Strict && !warnings.is_empty() {
return Err(SchemaError::UnsupportedFeatures {
provider: Provider::Gemini,
warnings,
});
}
Ok(CompiledSchema { schema, warnings })
}
}
fn normalize_gemini_function_parameters_schema(schema: &Value) -> Result<Value, LlmError> {
let mut unresolved_refs = Vec::new();
let mut normalized =
inline_local_schema_refs(schema, schema, &mut Vec::new(), 0, &mut unresolved_refs);
if !unresolved_refs.is_empty() {
unresolved_refs.sort();
unresolved_refs.dedup();
return Err(LlmError::InvalidRequest {
message: format!(
"Gemini function parameters schema contains unresolved $ref values: {}. \
Only local refs (e.g. '#/$defs/...') are supported for inlining.",
unresolved_refs.join(", ")
),
});
}
lower_gemini_function_parameters_schema(&mut normalized);
strip_gemini_function_parameters_unsupported_keywords(&mut normalized, 0);
Ok(normalized)
}
fn lower_gemini_function_parameters_schema(value: &mut Value) {
match value {
Value::Object(obj) => {
collapse_single_literal_composition(obj, "oneOf");
collapse_single_literal_composition(obj, "anyOf");
normalize_const_keyword(obj);
normalize_type_array_keyword(obj);
for child in obj.values_mut() {
lower_gemini_function_parameters_schema(child);
}
normalize_nullable_composition(obj, "oneOf");
normalize_nullable_composition(obj, "anyOf");
}
Value::Array(items) => {
for item in items {
lower_gemini_function_parameters_schema(item);
}
}
_ => {}
}
}
fn collapse_single_literal_composition(obj: &mut Map<String, Value>, key: &str) {
let Some(variants) = obj.get(key).and_then(Value::as_array) else {
return;
};
let mut literals = Vec::new();
for variant in variants {
let Some(variant_obj) = variant.as_object() else {
return;
};
let Some(literal) = variant_obj.get("const").cloned() else {
return;
};
if variant_obj
.keys()
.any(|k| k != "const" && k != "title" && k != "description")
{
return;
}
literals.push(literal);
}
if literals.is_empty() {
return;
}
obj.remove(key);
obj.insert("enum".to_string(), Value::Array(literals.clone()));
if !obj.contains_key("type")
&& let Some(common_type) = infer_common_literal_type(&literals)
{
obj.insert("type".to_string(), Value::String(common_type.to_string()));
}
}
fn infer_common_literal_type(values: &[Value]) -> Option<&'static str> {
let mut common: Option<&'static str> = None;
for value in values {
let value_type = infer_schema_type_from_literal(value)?;
match common {
Some(existing) if existing != value_type => return None,
Some(_) => {}
None => common = Some(value_type),
}
}
common
}
fn infer_schema_type_from_literal(value: &Value) -> Option<&'static str> {
match value {
Value::String(_) => Some("string"),
Value::Number(n) if n.is_i64() || n.is_u64() => Some("integer"),
Value::Number(_) => Some("number"),
Value::Bool(_) => Some("boolean"),
Value::Array(_) => Some("array"),
Value::Object(_) => Some("object"),
Value::Null => None,
}
}
fn normalize_const_keyword(obj: &mut Map<String, Value>) {
let Some(const_value) = obj.remove("const") else {
return;
};
if !obj.contains_key("enum") {
obj.insert("enum".to_string(), Value::Array(vec![const_value.clone()]));
}
if !obj.contains_key("type")
&& let Some(value_type) = infer_schema_type_from_literal(&const_value)
{
obj.insert("type".to_string(), Value::String(value_type.to_string()));
}
if const_value.is_null() {
obj.insert("nullable".to_string(), Value::Bool(true));
}
}
fn normalize_type_array_keyword(obj: &mut Map<String, Value>) {
let Some(Value::Array(type_values)) = obj.get("type").cloned() else {
return;
};
let mut has_null = false;
let mut non_null_types: Vec<String> = Vec::new();
for value in type_values {
let Value::String(type_name) = value else {
return;
};
if type_name == "null" {
has_null = true;
} else if !non_null_types.iter().any(|t| t == &type_name) {
non_null_types.push(type_name);
}
}
if non_null_types.is_empty() {
obj.remove("type");
obj.insert("nullable".to_string(), Value::Bool(true));
return;
}
if non_null_types.len() == 1 {
obj.insert("type".to_string(), Value::String(non_null_types[0].clone()));
if has_null {
obj.insert("nullable".to_string(), Value::Bool(true));
}
return;
}
let mut variants = Vec::new();
for type_name in non_null_types {
let mut variant = Map::new();
variant.insert("type".to_string(), Value::String(type_name));
variants.push(Value::Object(variant));
}
obj.remove("type");
match obj.get_mut("anyOf") {
Some(Value::Array(existing)) => existing.extend(variants),
_ => {
obj.insert("anyOf".to_string(), Value::Array(variants));
}
}
if has_null {
obj.insert("nullable".to_string(), Value::Bool(true));
}
}
fn normalize_nullable_composition(obj: &mut Map<String, Value>, key: &str) {
let Some(Value::Array(variants)) = obj.get(key).cloned() else {
return;
};
let mut saw_null_branch = false;
let mut retained = Vec::new();
for variant in variants {
if is_null_schema(&variant) {
saw_null_branch = true;
} else {
retained.push(variant);
}
}
if !saw_null_branch {
return;
}
obj.insert("nullable".to_string(), Value::Bool(true));
if retained.is_empty() {
obj.remove(key);
} else {
obj.insert(key.to_string(), Value::Array(retained));
}
}
fn is_null_schema(value: &Value) -> bool {
let Value::Object(obj) = value else {
return false;
};
if matches!(obj.get("type"), Some(Value::String(t)) if t == "null") {
return true;
}
matches!(
obj.get("enum"),
Some(Value::Array(values)) if values.len() == 1 && values[0].is_null()
)
}
fn inline_local_schema_refs(
node: &Value,
root: &Value,
active_refs: &mut Vec<String>,
depth: usize,
unresolved_refs: &mut Vec<String>,
) -> Value {
const MAX_REF_DEPTH: usize = 64;
if depth > MAX_REF_DEPTH {
return node.clone();
}
match node {
Value::Object(obj) => {
if let Some(reference) = obj.get("$ref").and_then(Value::as_str)
&& let Some(resolved) = resolve_local_schema_ref(root, reference)
&& !active_refs.iter().any(|r| r == reference)
{
active_refs.push(reference.to_string());
let mut inlined = inline_local_schema_refs(
resolved,
root,
active_refs,
depth + 1,
unresolved_refs,
);
active_refs.pop();
if let Value::Object(ref mut inlined_obj) = inlined {
for (key, value) in obj {
if key == "$ref" {
continue;
}
inlined_obj.insert(
key.clone(),
inline_local_schema_refs(
value,
root,
active_refs,
depth + 1,
unresolved_refs,
),
);
}
return inlined;
}
return inlined;
}
if let Some(reference) = obj.get("$ref").and_then(Value::as_str) {
unresolved_refs.push(reference.to_string());
}
let mut mapped = Map::new();
for (key, value) in obj {
mapped.insert(
key.clone(),
inline_local_schema_refs(value, root, active_refs, depth + 1, unresolved_refs),
);
}
Value::Object(mapped)
}
Value::Array(items) => Value::Array(
items
.iter()
.map(|item| {
inline_local_schema_refs(item, root, active_refs, depth + 1, unresolved_refs)
})
.collect(),
),
_ => node.clone(),
}
}
fn resolve_local_schema_ref<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> {
if !reference.starts_with("#/") {
return None;
}
let mut cursor = root;
for segment in reference.trim_start_matches("#/").split('/') {
let key = segment.replace("~1", "/").replace("~0", "~");
cursor = cursor.get(&key)?;
}
Some(cursor)
}
fn strip_gemini_function_parameters_unsupported_keywords(value: &mut Value, depth: usize) {
match value {
Value::Object(obj) => {
obj.remove("$schema");
obj.remove("$defs");
obj.remove("defs");
obj.remove("definitions");
obj.remove("$ref");
obj.remove("$id");
obj.remove("$anchor");
obj.remove("const");
obj.remove("title");
if depth > 0 {
obj.remove("additionalProperties");
}
for (key, child) in obj.iter_mut() {
if key == "properties" {
if let Value::Object(props) = child {
for prop_schema in props.values_mut() {
strip_gemini_function_parameters_unsupported_keywords(
prop_schema,
depth + 1,
);
}
}
} else {
strip_gemini_function_parameters_unsupported_keywords(child, depth + 1);
}
}
}
Value::Array(items) => {
for item in items {
strip_gemini_function_parameters_unsupported_keywords(item, depth + 1);
}
}
_ => {}
}
}
fn text_event_for_part(
text: String,
is_thought: bool,
meta: Option<Box<meerkat_core::ProviderMeta>>,
) -> LlmEvent {
if is_thought {
LlmEvent::ReasoningDelta { delta: text }
} else {
LlmEvent::TextDelta { delta: text, meta }
}
}
fn validate_gemini_response_json_schema(schema: &Value, provider: Provider) -> Vec<SchemaWarning> {
let mut warnings = Vec::new();
inspect_gemini_json_schema_node(schema, "", provider, &mut warnings);
warnings
}
fn inspect_gemini_json_schema_node(
value: &Value,
path: &str,
provider: Provider,
warnings: &mut Vec<SchemaWarning>,
) {
match value {
Value::Object(obj) => {
for key in obj.keys() {
if !is_gemini_supported_schema_keyword(key) {
warnings.push(SchemaWarning {
provider,
path: join_path(path, key),
message: format!(
"Keyword '{key}' may be ignored by Gemini responseJsonSchema"
),
});
}
}
for (key, child) in obj {
match key.as_str() {
"properties" | "$defs" => {
inspect_schema_map(child, &join_path(path, key), provider, warnings);
}
_ => inspect_gemini_json_schema_node(
child,
&join_path(path, key),
provider,
warnings,
),
}
}
}
Value::Array(items) => {
for (index, item) in items.iter().enumerate() {
inspect_gemini_json_schema_node(item, &join_index(path, index), provider, warnings);
}
}
_ => {}
}
}
fn inspect_schema_map(
value: &Value,
path: &str,
provider: Provider,
warnings: &mut Vec<SchemaWarning>,
) {
match value {
Value::Object(map) => {
for (name, child) in map {
inspect_gemini_json_schema_node(child, &join_path(path, name), provider, warnings);
}
}
other => inspect_gemini_json_schema_node(other, path, provider, warnings),
}
}
fn is_gemini_supported_schema_keyword(key: &str) -> bool {
matches!(
key,
"$id"
| "$defs"
| "$ref"
| "$anchor"
| "type"
| "format"
| "title"
| "description"
| "const"
| "default"
| "examples"
| "enum"
| "items"
| "prefixItems"
| "minItems"
| "maxItems"
| "minimum"
| "maximum"
| "anyOf"
| "oneOf"
| "properties"
| "additionalProperties"
| "required"
| "propertyOrdering"
| "nullable"
)
}
fn join_path(prefix: &str, key: &str) -> String {
if prefix.is_empty() {
format!("/{key}")
} else {
format!("{prefix}/{key}")
}
}
fn join_index(prefix: &str, index: usize) -> String {
if prefix.is_empty() {
format!("/{index}")
} else {
format!("{prefix}/{index}")
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl LlmClient for GeminiClient {
fn stream<'a>(&'a self, request: &'a LlmRequest) -> LlmStream<'a> {
let inner: LlmStream<'a> = Box::pin(async_stream::try_stream! {
let body = self.build_request_body(request)?;
let url = format!(
"{}/v1beta/models/{}:streamGenerateContent?alt=sse",
self.base_url, request.model
);
let response = self.http
.post(url)
.header("Content-Type", "application/json")
.header("x-goog-api-key", &self.api_key)
.json(&body)
.send()
.await
.map_err(|_| LlmError::NetworkTimeout {
duration_ms: 30000,
})?;
let status_code = response.status().as_u16();
let stream_result = if (200..=299).contains(&status_code) {
Ok(response.bytes_stream())
} else {
let headers = response.headers().clone();
let text = response.text().await.unwrap_or_default();
Err(LlmError::from_http_response(status_code, text, &headers))
};
let mut stream = stream_result?;
let mut buffer = String::with_capacity(512);
let mut tool_call_index: u32 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|_| LlmError::ConnectionReset)?;
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(newline_pos) = buffer.find('\n') {
let line = buffer[..newline_pos].trim();
let data = line.strip_prefix("data: ");
let parsed_response = if let Some(d) = data {
Self::parse_stream_line(d)
} else {
None
};
buffer.drain(..=newline_pos);
if let Some(resp) = parsed_response {
if let Some(usage) = resp.usage_metadata {
yield LlmEvent::UsageUpdate {
usage: Usage {
input_tokens: usage.prompt_token_count.unwrap_or(0),
output_tokens: usage.candidates_token_count.unwrap_or(0),
cache_creation_tokens: None,
cache_read_tokens: None,
}
};
}
if let Some(candidates) = resp.candidates {
for cand in candidates {
if let Some(content) = cand.content {
#[allow(clippy::collapsible_if)]
if let Some(parts) = content.parts {
for part in parts {
let meta = part.thought_signature.as_ref().map(|sig| {
Box::new(meerkat_core::ProviderMeta::Gemini {
thought_signature: sig.clone(),
})
});
if let Some(text) = part.text {
yield text_event_for_part(
text,
part.thought.unwrap_or(false),
meta.clone(),
);
}
if let Some(fc) = part.function_call {
let id = format!("fc_{tool_call_index}");
tool_call_index += 1;
yield LlmEvent::ToolCallComplete {
id,
name: fc.name,
args: fc.args.unwrap_or(json!({})),
meta,
};
}
}
}
}
if let Some(reason) = cand.finish_reason {
let stop = match reason.as_str() {
"MAX_TOKENS" => StopReason::MaxTokens,
"SAFETY" | "RECITATION" => StopReason::ContentFilter,
"TOOL_CALL" | "FUNCTION_CALL" => StopReason::ToolUse,
_ => StopReason::EndTurn,
};
yield LlmEvent::Done {
outcome: LlmDoneOutcome::Success { stop_reason: stop },
};
}
}
}
}
}
}
});
crate::streaming::ensure_terminal_done(inner)
}
fn provider(&self) -> &'static str {
"gemini"
}
async fn health_check(&self) -> Result<(), LlmError> {
Ok(())
}
fn compile_schema(&self, output_schema: &OutputSchema) -> Result<CompiledSchema, SchemaError> {
GeminiClient::compile_schema_for_gemini(output_schema)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GenerateContentResponse {
candidates: Option<Vec<Candidate>>,
usage_metadata: Option<GeminiUsage>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Candidate {
content: Option<CandidateContent>,
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CandidateContent {
parts: Option<Vec<Part>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Part {
text: Option<String>,
function_call: Option<FunctionCall>,
thought: Option<bool>,
thought_signature: Option<String>,
}
#[derive(Debug, Deserialize)]
struct FunctionCall {
name: String,
args: Option<Value>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GeminiUsage {
prompt_token_count: Option<u64>,
candidates_token_count: Option<u64>,
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::explicit_counter_loop
)]
mod tests {
use super::*;
use meerkat_core::{
AssistantBlock, BlockAssistantMessage, ContentBlock, ProviderMeta, UserMessage,
};
fn assert_no_const_or_type_arrays(value: &Value) {
match value {
Value::Object(obj) => {
assert!(
obj.get("const").is_none(),
"const must be lowered/removed for Gemini function parameters: {value:?}"
);
if let Some(schema_type) = obj.get("type") {
assert!(
!schema_type.is_array(),
"type must be scalar in Gemini function parameters: {value:?}"
);
}
for child in obj.values() {
assert_no_const_or_type_arrays(child);
}
}
Value::Array(items) => {
for item in items {
assert_no_const_or_type_arrays(item);
}
}
_ => {}
}
}
#[test]
fn test_build_request_body_with_thinking_budget() -> Result<(), Box<dyn std::error::Error>> {
let client = GeminiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gemini-1.5-pro",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("thinking_budget", 10000);
let body = client.build_request_body(&request)?;
let generation_config = body.get("generationConfig").ok_or("missing config")?;
let thinking_config = generation_config
.get("thinkingConfig")
.ok_or("missing thinking")?;
let thinking_budget = thinking_config
.get("thinkingBudget")
.ok_or("missing budget")?;
assert_eq!(thinking_budget.as_i64(), Some(10000));
Ok(())
}
#[test]
fn test_build_request_body_with_top_k() -> Result<(), Box<dyn std::error::Error>> {
let client = GeminiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gemini-1.5-pro",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("top_k", 40);
let body = client.build_request_body(&request)?;
let generation_config = body.get("generationConfig").ok_or("missing config")?;
let top_k = generation_config.get("topK").ok_or("missing top_k")?;
assert_eq!(top_k.as_i64(), Some(40));
Ok(())
}
#[test]
fn test_build_request_body_with_multiple_provider_params()
-> Result<(), Box<dyn std::error::Error>> {
let client = GeminiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gemini-1.5-pro",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("top_k", 50)
.with_provider_param("thinking_budget", 5000);
let body = client.build_request_body(&request)?;
let generation_config = body.get("generationConfig").ok_or("missing config")?;
let top_k = generation_config.get("topK").ok_or("missing top_k")?;
assert_eq!(top_k.as_i64(), Some(50));
let thinking_config = generation_config
.get("thinkingConfig")
.ok_or("missing thinking")?;
let thinking_budget = thinking_config
.get("thinkingBudget")
.ok_or("missing budget")?;
assert_eq!(thinking_budget.as_i64(), Some(5000));
Ok(())
}
#[test]
fn test_build_request_body_no_provider_params() -> Result<(), Box<dyn std::error::Error>> {
let client = GeminiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gemini-1.5-pro",
vec![Message::User(UserMessage::text("test".to_string()))],
);
let body = client.build_request_body(&request)?;
let generation_config = body.get("generationConfig").ok_or("missing config")?;
assert!(generation_config.get("thinkingConfig").is_none());
assert!(generation_config.get("topK").is_none());
Ok(())
}
#[test]
fn test_tool_response_uses_function_name_no_signature() -> Result<(), Box<dyn std::error::Error>>
{
use serde_json::value::RawValue;
let client = GeminiClient::new("test-key".to_string());
let args_raw = RawValue::from_string(json!({"city": "Tokyo"}).to_string()).unwrap();
let request = LlmRequest::new(
"gemini-1.5-pro",
vec![
Message::User(UserMessage::text("test".to_string())),
Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![AssistantBlock::ToolUse {
id: "call_1".to_string(),
name: "get_weather".to_string(),
args: args_raw,
meta: Some(Box::new(ProviderMeta::Gemini {
thought_signature: "sig_123".to_string(),
})),
}],
stop_reason: StopReason::ToolUse,
}),
Message::ToolResults {
results: vec![meerkat_core::ToolResult::new(
"call_1".to_string(),
"Sunny".to_string(),
false,
)],
},
],
);
let body = client.build_request_body(&request)?;
let contents = body
.get("contents")
.and_then(|c| c.as_array())
.ok_or("missing contents")?;
let model_content = contents
.iter()
.find(|c| c.get("role").and_then(|r| r.as_str()) == Some("model"))
.ok_or("missing model content")?;
let model_parts = model_content
.get("parts")
.and_then(|p| p.as_array())
.ok_or("missing model parts")?;
let fc_part = model_parts
.iter()
.find(|p| p.get("functionCall").is_some())
.ok_or("missing functionCall part")?;
assert_eq!(
fc_part["thoughtSignature"], "sig_123",
"functionCall SHOULD have signature"
);
let tool_result_parts = contents
.last()
.and_then(|c| c.get("parts"))
.and_then(|p| p.as_array())
.ok_or("missing parts")?;
let function_response = &tool_result_parts[0]["functionResponse"];
assert_eq!(function_response["name"], "get_weather");
assert!(
tool_result_parts[0].get("thoughtSignature").is_none(),
"functionResponse MUST NOT have thoughtSignature"
);
Ok(())
}
#[test]
fn test_parse_stream_line_valid_response() -> Result<(), Box<dyn std::error::Error>> {
let line =
r#"{"candidates":[{"content":{"parts":[{"text":"Hello"}]},"finishReason":"STOP"}]}"#;
let response = GeminiClient::parse_stream_line(line);
assert!(response.is_some());
let response = response.ok_or("missing response")?;
assert!(response.candidates.is_some());
let candidates = response.candidates.ok_or("missing candidates")?;
assert_eq!(candidates.len(), 1);
Ok(())
}
#[test]
fn test_parse_stream_line_with_usage() -> Result<(), Box<dyn std::error::Error>> {
let line = r#"{"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}"#;
let response = GeminiClient::parse_stream_line(line);
assert!(response.is_some());
let response = response.ok_or("missing response")?;
assert!(response.usage_metadata.is_some());
let usage = response.usage_metadata.ok_or("missing usage")?;
assert_eq!(usage.prompt_token_count, Some(10));
Ok(())
}
#[test]
fn test_parse_stream_line_function_call() -> Result<(), Box<dyn std::error::Error>> {
let line = r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather","args":{"city":"Tokyo"}}}]}}]}"#;
let response = GeminiClient::parse_stream_line(line);
assert!(response.is_some());
let response = response.ok_or("missing response")?;
let candidates = response.candidates.as_ref().ok_or("missing candidates")?;
let parts = candidates[0]
.content
.as_ref()
.ok_or("missing content")?
.parts
.as_ref()
.ok_or("missing parts")?;
let fc = parts[0].function_call.as_ref().ok_or("missing fc")?;
assert_eq!(fc.name, "get_weather");
assert_eq!(fc.args.as_ref().ok_or("missing args")?["city"], "Tokyo");
Ok(())
}
#[test]
fn test_parse_stream_line_empty() {
let line = "";
let response = GeminiClient::parse_stream_line(line);
assert!(response.is_none());
}
#[test]
fn test_parse_stream_line_invalid_json() {
let line = "{invalid}";
let response = GeminiClient::parse_stream_line(line);
assert!(response.is_none());
}
#[test]
fn test_regression_gemini_finish_reason_tool_call_maps_to_tool_use() {
let finish_reasons = ["TOOL_CALL", "FUNCTION_CALL"];
for reason in finish_reasons {
let stop = match reason {
"MAX_TOKENS" => StopReason::MaxTokens,
"SAFETY" | "RECITATION" => StopReason::ContentFilter,
"TOOL_CALL" | "FUNCTION_CALL" => StopReason::ToolUse,
_ => StopReason::EndTurn,
};
assert_eq!(
stop,
StopReason::ToolUse,
"finish_reason '{reason}' should map to ToolUse"
);
}
}
#[test]
fn test_regression_gemini_finish_reason_recitation_maps_to_content_filter() {
let reason = "RECITATION";
let stop = match reason {
"MAX_TOKENS" => StopReason::MaxTokens,
"SAFETY" | "RECITATION" => StopReason::ContentFilter,
"TOOL_CALL" | "FUNCTION_CALL" => StopReason::ToolUse,
_ => StopReason::EndTurn,
};
assert_eq!(stop, StopReason::ContentFilter);
}
#[test]
fn test_regression_gemini_tool_call_ids_must_be_unique() {
let mut tool_call_index: u32 = 0;
let tool_names = ["search", "search", "search"];
let mut generated_ids = Vec::new();
for _name in tool_names {
let id = format!("fc_{tool_call_index}");
tool_call_index += 1;
generated_ids.push(id);
}
assert_eq!(generated_ids[0], "fc_0");
assert_eq!(generated_ids[1], "fc_1");
assert_eq!(generated_ids[2], "fc_2");
let mut seen = std::collections::HashSet::new();
for id in &generated_ids {
assert!(
seen.insert(id.clone()),
"Duplicate tool call ID found: {id}"
);
}
}
#[test]
fn test_regression_gemini_tool_call_ids_unique_across_different_tools() {
let mut tool_call_index: u32 = 0;
let tool_names = ["search", "write_file", "search", "read_file"];
let mut id_to_name = Vec::new();
for name in tool_names {
let id = format!("fc_{tool_call_index}");
tool_call_index += 1;
id_to_name.push((id, name));
}
assert_eq!(id_to_name[0], ("fc_0".to_string(), "search"));
assert_eq!(id_to_name[1], ("fc_1".to_string(), "write_file"));
assert_eq!(id_to_name[2], ("fc_2".to_string(), "search")); assert_eq!(id_to_name[3], ("fc_3".to_string(), "read_file"));
}
#[test]
fn test_build_request_body_with_structured_output() -> Result<(), Box<dyn std::error::Error>> {
let client = GeminiClient::new("test-key".to_string());
let schema = serde_json::json!({
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"}
},
"required": ["name", "age"]
});
let request = LlmRequest::new(
"gemini-3-pro-preview",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param(
"structured_output",
serde_json::json!({
"schema": schema,
"name": "person",
"strict": true
}),
);
let body = client.build_request_body(&request)?;
let gen_config = body
.get("generationConfig")
.ok_or("missing generationConfig")?;
assert_eq!(gen_config["responseMimeType"], "application/json");
assert!(gen_config.get("responseJsonSchema").is_some());
let response_schema = &gen_config["responseJsonSchema"];
assert_eq!(response_schema["type"], "object");
assert!(response_schema.get("properties").is_some());
Ok(())
}
#[test]
fn test_build_request_body_with_structured_output_preserves_schema_keywords()
-> Result<(), Box<dyn std::error::Error>> {
let client = GeminiClient::new("test-key".to_string());
let schema = serde_json::json!({
"type": "object",
"$defs": {
"Address": {"type": "object"}
},
"$ref": "#/$defs/Address",
"anyOf": [
{"type": "object"},
{"type": "null"}
],
"properties": {
"name": {"type": "string"}
},
"additionalProperties": false
});
let request = LlmRequest::new(
"gemini-3-pro-preview",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param("structured_output", serde_json::json!({"schema": schema}));
let body = client.build_request_body(&request)?;
let gen_config = body
.get("generationConfig")
.ok_or("missing generationConfig")?;
let response_schema = &gen_config["responseJsonSchema"];
assert!(response_schema.get("$defs").is_some());
assert_eq!(response_schema["$ref"], "#/$defs/Address");
assert!(response_schema.get("anyOf").is_some());
assert_eq!(response_schema["additionalProperties"], false);
assert_eq!(response_schema["type"], "object");
assert!(response_schema.get("properties").is_some());
Ok(())
}
#[test]
fn test_compile_schema_for_gemini_strict_errors_on_unsupported_keywords() {
let schema = serde_json::json!({
"type": "object",
"allOf": [
{"type": "object"}
]
});
let output_schema = OutputSchema::new(schema)
.expect("valid schema")
.with_compat(SchemaCompat::Strict);
let err = GeminiClient::compile_schema_for_gemini(&output_schema)
.expect_err("strict mode should reject unsupported keywords");
match err {
SchemaError::UnsupportedFeatures { provider, warnings } => {
assert_eq!(provider, Provider::Gemini);
assert!(!warnings.is_empty());
assert!(
warnings.iter().any(|w| w.path.contains("/allOf")),
"expected warning path to include /allOf, got: {warnings:?}"
);
}
other => panic!("expected UnsupportedFeatures, got: {other:?}"),
}
}
#[test]
fn test_compile_schema_for_gemini_lossy_keeps_schema_and_emits_warnings() {
let schema = serde_json::json!({
"type": "object",
"allOf": [{"type": "object"}],
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z]+$"
}
}
});
let output_schema = OutputSchema::new(schema).expect("valid schema");
let expected = output_schema.schema.as_value().clone();
let compiled = GeminiClient::compile_schema_for_gemini(&output_schema)
.expect("lossy mode should not error");
assert_eq!(compiled.schema, expected);
assert!(!compiled.warnings.is_empty());
assert!(
compiled.warnings.iter().any(|w| w.path.contains("/allOf")),
"expected /allOf warning"
);
assert!(
compiled
.warnings
.iter()
.any(|w| w.path.contains("/properties/name/pattern")),
"expected /properties/name/pattern warning"
);
}
#[test]
fn test_compile_schema_for_gemini_strict_accepts_supported_keywords() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "string"},
"score": {"type": "number", "minimum": 0.0, "maximum": 1.0}
},
"required": ["id", "score"],
"additionalProperties": false
}
},
"choice": {
"oneOf": [
{"type": "string"},
{"type": "null"}
]
}
},
"required": ["items", "choice"],
"additionalProperties": false
});
let output_schema = OutputSchema::new(schema)
.expect("valid schema")
.with_compat(SchemaCompat::Strict);
let compiled = GeminiClient::compile_schema_for_gemini(&output_schema)
.expect("strict mode should accept supported keywords");
assert!(compiled.warnings.is_empty());
assert_eq!(compiled.schema["type"], "object");
}
#[test]
fn test_compile_schema_for_gemini_warns_nested_unsupported_paths() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"nested": {
"type": "object",
"allOf": [
{"type": "object", "properties": {"x": {"type": "string"}}}
],
"properties": {
"payload": {
"type": "array",
"items": {
"type": "object",
"patternProperties": {
"^k_": {"type": "integer"}
}
}
}
}
}
}
});
let output_schema = OutputSchema::new(schema).expect("valid schema");
let compiled = GeminiClient::compile_schema_for_gemini(&output_schema)
.expect("lossy mode should still compile");
let paths: Vec<String> = compiled.warnings.iter().map(|w| w.path.clone()).collect();
assert!(
paths.iter().any(|p| p.contains("/properties/nested/allOf")),
"expected warning at /properties/nested/allOf, got: {paths:?}"
);
assert!(
paths.iter().any(|p| {
p.contains("/properties/nested/properties/payload/items/patternProperties")
}),
"expected warning at nested patternProperties path, got: {paths:?}"
);
}
#[test]
fn test_build_request_body_strict_compat_rejects_unsupported_schema() {
let client = GeminiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gemini-3-pro-preview",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_provider_param(
"structured_output",
serde_json::json!({
"schema": {
"type": "object",
"allOf": [{"type": "object"}]
},
"compat": "strict"
}),
);
let err = client
.build_request_body(&request)
.expect_err("strict compat should reject unsupported schema keywords");
match err {
LlmError::InvalidRequest { message } => {
assert!(
message.contains("unsupported"),
"unexpected message: {message}"
);
assert!(message.contains("Gemini"), "unexpected message: {message}");
}
other => panic!("expected InvalidRequest, got {other:?}"),
}
}
#[test]
fn test_build_request_body_without_structured_output() -> Result<(), Box<dyn std::error::Error>>
{
let client = GeminiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gemini-3-pro-preview",
vec![Message::User(UserMessage::text("test".to_string()))],
);
let body = client.build_request_body(&request)?;
let gen_config = body
.get("generationConfig")
.ok_or("missing generationConfig")?;
assert!(
gen_config.get("responseMimeType").is_none(),
"responseMimeType should not be present"
);
assert!(
gen_config.get("responseJsonSchema").is_none(),
"responseJsonSchema should not be present"
);
Ok(())
}
#[test]
fn test_tool_schema_lowers_type_arrays() -> Result<(), Box<dyn std::error::Error>> {
use meerkat_core::ToolDef;
use std::sync::Arc;
let schema = serde_json::json!({
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": ["integer", "null"]},
"email": {"type": ["string", "null"]},
"score": {"type": ["string", "number"]}
}
});
let client = GeminiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gemini-3-pro-preview",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_tools(vec![Arc::new(ToolDef {
name: "test_tool".to_string(),
description: "test".to_string(),
input_schema: schema,
})]);
let body = client.build_request_body(&request)?;
let lowered = &body["tools"][0]["functionDeclarations"][0]["parameters"];
assert_eq!(
lowered["properties"]["age"]["type"], "integer",
"['integer', 'null'] should lower to scalar type"
);
assert_eq!(
lowered["properties"]["age"]["nullable"],
serde_json::json!(true),
"null in type array should map to nullable=true"
);
assert_eq!(
lowered["properties"]["email"]["type"], "string",
"['string', 'null'] should lower to scalar type"
);
assert_eq!(
lowered["properties"]["email"]["nullable"],
serde_json::json!(true),
"null in type array should map to nullable=true"
);
assert!(
lowered["properties"]["score"].get("type").is_none(),
"multi-type union should move from type array to anyOf variants"
);
assert!(
lowered["properties"]["score"].get("anyOf").is_some(),
"multi-type union should become anyOf"
);
assert_eq!(
lowered["properties"]["name"]["type"], "string",
"'string' should remain 'string'"
);
assert_no_const_or_type_arrays(lowered);
Ok(())
}
#[test]
fn test_tool_schema_lowers_const_compositions() -> Result<(), Box<dyn std::error::Error>> {
use meerkat_core::ToolDef;
use std::sync::Arc;
let schema = serde_json::json!({
"type": "object",
"properties": {
"status": {
"oneOf": [
{"const": "active"},
{"const": "inactive"}
]
},
"category": {
"anyOf": [
{
"oneOf": [
{"const": "alpha"},
{"const": "beta"},
{"const": "gamma"}
]
},
{"type": "null"}
]
},
"value": {
"anyOf": [
{"type": "string"},
{"type": "number"}
]
}
},
"allOf": [
{"required": ["status"]}
]
});
let client = GeminiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gemini-3-pro-preview",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_tools(vec![Arc::new(ToolDef {
name: "test_tool".to_string(),
description: "test".to_string(),
input_schema: schema,
})]);
let body = client.build_request_body(&request)?;
let lowered = &body["tools"][0]["functionDeclarations"][0]["parameters"];
assert!(
lowered["properties"]["status"].get("enum").is_some(),
"const oneOf branches should collapse into enum"
);
assert_eq!(
lowered["properties"]["status"]["enum"],
serde_json::json!(["active", "inactive"])
);
assert_eq!(
lowered["properties"]["status"]["type"], "string",
"collapsed const enum should infer string type"
);
assert!(
lowered["properties"]["category"]["nullable"] == serde_json::json!(true),
"null composition branch should set nullable=true"
);
assert!(
lowered["properties"]["value"].get("anyOf").is_some(),
"anyOf should be preserved"
);
assert!(lowered.get("allOf").is_some(), "allOf should be preserved");
assert_no_const_or_type_arrays(lowered);
Ok(())
}
#[test]
fn test_tool_schema_parameters_inlines_ref_and_strips_unsupported_keywords()
-> Result<(), Box<dyn std::error::Error>> {
use meerkat_core::ToolDef;
use std::sync::Arc;
let schema = serde_json::json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "RootParameters",
"type": "object",
"properties": {
"payload": {
"$ref": "#/$defs/Payload"
}
},
"required": ["payload"],
"$defs": {
"Payload": {
"title": "Payload",
"type": "object",
"properties": {
"message": {
"title": "Message",
"type": "string"
}
},
"required": ["message"],
"additionalProperties": false
}
},
"additionalProperties": false
});
let client = GeminiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gemini-3-pro-preview",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_tools(vec![Arc::new(ToolDef {
name: "test_tool".to_string(),
description: "test".to_string(),
input_schema: schema,
})]);
let body = client.build_request_body(&request)?;
let parameters = &body["tools"][0]["functionDeclarations"][0]["parameters"];
assert!(
parameters.get("$schema").is_none(),
"tool parameters should not include $schema"
);
assert!(
parameters.get("$defs").is_none(),
"tool parameters should not include $defs"
);
assert!(
parameters.get("title").is_none(),
"tool parameters should not include title"
);
assert_eq!(
parameters.get("additionalProperties"),
Some(&serde_json::json!(false)),
"tool parameters should preserve top-level additionalProperties"
);
assert!(
parameters["properties"]["payload"].get("$ref").is_none(),
"tool parameters should inline $ref targets"
);
assert!(
parameters["properties"]["payload"].get("title").is_none(),
"inlined payload should strip title"
);
assert_eq!(
parameters["properties"]["payload"]["type"], "object",
"inlined payload should preserve referenced type"
);
assert_eq!(
parameters["properties"]["payload"]["properties"]["message"]["type"], "string",
"inlined payload should preserve nested properties"
);
assert!(
parameters["properties"]["payload"]["properties"]["message"]
.get("title")
.is_none(),
"nested title should be stripped"
);
Ok(())
}
#[test]
fn test_tool_schema_parameters_inline_ref_inside_anyof()
-> Result<(), Box<dyn std::error::Error>> {
use meerkat_core::ToolDef;
use std::sync::Arc;
let schema = serde_json::json!({
"type": "object",
"properties": {
"context": {
"anyOf": [
{"$ref": "#/$defs/Context"},
{"type": "null"}
]
}
},
"$defs": {
"Context": {
"type": "object",
"properties": {
"ticket": {"type": "string"}
},
"required": ["ticket"],
"additionalProperties": false
}
}
});
let client = GeminiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gemini-3-pro-preview",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_tools(vec![Arc::new(ToolDef {
name: "test_tool".to_string(),
description: "test".to_string(),
input_schema: schema,
})]);
let body = client.build_request_body(&request)?;
let parameters = &body["tools"][0]["functionDeclarations"][0]["parameters"];
let context_schema = ¶meters["properties"]["context"];
let any_of = context_schema["anyOf"]
.as_array()
.ok_or("missing anyOf array")?;
let object_branch = any_of
.iter()
.find(|item| item["type"] == "object")
.ok_or("missing inlined object branch")?;
assert!(
object_branch.get("$ref").is_none(),
"inlined anyOf branch should not keep $ref"
);
assert!(
object_branch.get("additionalProperties").is_none(),
"additionalProperties should be stripped from inlined branch"
);
assert_eq!(object_branch["properties"]["ticket"]["type"], "string");
Ok(())
}
#[test]
fn test_tool_schema_parameters_rejects_unresolved_external_ref() {
use meerkat_core::ToolDef;
use std::sync::Arc;
let schema = serde_json::json!({
"type": "object",
"properties": {
"payload": {
"$ref": "https://example.com/schemas/Payload.json"
}
}
});
let client = GeminiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gemini-3-pro-preview",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_tools(vec![Arc::new(ToolDef {
name: "test_tool".to_string(),
description: "test".to_string(),
input_schema: schema,
})]);
let err = client
.build_request_body(&request)
.expect_err("external refs should fail fast for function parameters");
match err {
LlmError::InvalidRequest { message } => {
assert!(
message.contains("unresolved $ref"),
"unexpected message: {message}"
);
assert!(
message.contains("https://example.com/schemas/Payload.json"),
"unexpected message: {message}"
);
}
other => panic!("expected InvalidRequest, got {other:?}"),
}
}
#[test]
fn test_tool_schema_preserves_property_named_title() -> Result<(), Box<dyn std::error::Error>> {
use meerkat_core::ToolDef;
use std::sync::Arc;
let schema = serde_json::json!({
"type": "object",
"properties": {
"id": { "type": "string" },
"title": { "type": "string", "description": "Human readable title" },
"summary": { "type": "string" }
},
"required": ["id", "title", "summary"]
});
let client = GeminiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gemini-3-pro-preview",
vec![Message::User(UserMessage::text("test".to_string()))],
)
.with_tools(vec![Arc::new(ToolDef {
name: "upsert_record".to_string(),
description: "test".to_string(),
input_schema: schema,
})]);
let body = client.build_request_body(&request)?;
let parameters = &body["tools"][0]["functionDeclarations"][0]["parameters"];
let props = parameters["properties"]
.as_object()
.ok_or("missing properties")?;
assert!(
props.contains_key("title"),
"property named 'title' must not be stripped — it's a user field, not a schema keyword"
);
assert!(
props.contains_key("id"),
"property 'id' should be preserved"
);
assert!(
props.contains_key("summary"),
"property 'summary' should be preserved"
);
assert!(
parameters.get("title").is_none(),
"root-level title keyword should be stripped"
);
Ok(())
}
#[test]
fn test_parse_function_call_with_thought_signature() -> Result<(), Box<dyn std::error::Error>> {
let line = r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather","args":{"city":"Tokyo"}},"thoughtSignature":"sig_abc123"}]}}]}"#;
let response = GeminiClient::parse_stream_line(line).ok_or("missing response")?;
let candidates = response.candidates.as_ref().ok_or("missing candidates")?;
let parts = candidates[0]
.content
.as_ref()
.ok_or("missing content")?
.parts
.as_ref()
.ok_or("missing parts")?;
assert!(
parts[0].function_call.is_some(),
"should have function_call"
);
assert_eq!(
parts[0].thought_signature.as_deref(),
Some("sig_abc123"),
"should have thoughtSignature"
);
Ok(())
}
#[test]
fn test_parse_text_with_thought_signature() -> Result<(), Box<dyn std::error::Error>> {
let line = r#"{"candidates":[{"content":{"parts":[{"text":"Hello world","thoughtSignature":"sig_text_456"}]}}]}"#;
let response = GeminiClient::parse_stream_line(line).ok_or("missing response")?;
let candidates = response.candidates.as_ref().ok_or("missing candidates")?;
let parts = candidates[0]
.content
.as_ref()
.ok_or("missing content")?
.parts
.as_ref()
.ok_or("missing parts")?;
assert_eq!(parts[0].text.as_deref(), Some("Hello world"));
assert_eq!(
parts[0].thought_signature.as_deref(),
Some("sig_text_456"),
"text parts can have thoughtSignature for continuity"
);
Ok(())
}
#[test]
fn test_parse_text_with_thought_flag() -> Result<(), Box<dyn std::error::Error>> {
let line =
r#"{"candidates":[{"content":{"parts":[{"text":"thinking...","thought":true}]}}]}"#;
let response = GeminiClient::parse_stream_line(line).ok_or("missing response")?;
let candidates = response.candidates.as_ref().ok_or("missing candidates")?;
let parts = candidates[0]
.content
.as_ref()
.ok_or("missing content")?
.parts
.as_ref()
.ok_or("missing parts")?;
assert_eq!(parts[0].text.as_deref(), Some("thinking..."));
assert_eq!(parts[0].thought, Some(true));
Ok(())
}
#[test]
fn test_parallel_calls_only_first_has_signature() -> Result<(), Box<dyn std::error::Error>> {
let line = r#"{"candidates":[{"content":{"parts":[
{"functionCall":{"name":"get_weather","args":{"city":"Tokyo"}},"thoughtSignature":"sig_first"},
{"functionCall":{"name":"get_time","args":{"tz":"JST"}}},
{"functionCall":{"name":"get_population","args":{"city":"Tokyo"}}}
]}}]}"#;
let response = GeminiClient::parse_stream_line(line).ok_or("missing response")?;
let candidates = response.candidates.ok_or("missing candidates")?;
let parts = candidates[0]
.content
.as_ref()
.ok_or("missing content")?
.parts
.as_ref()
.ok_or("missing parts")?;
assert_eq!(parts.len(), 3);
assert_eq!(
parts[0].thought_signature.as_deref(),
Some("sig_first"),
"first parallel call MUST have signature"
);
assert!(
parts[1].thought_signature.is_none(),
"second parallel call must NOT have signature"
);
assert!(
parts[2].thought_signature.is_none(),
"third parallel call must NOT have signature"
);
Ok(())
}
#[test]
fn test_request_building_no_signature_on_function_response()
-> Result<(), Box<dyn std::error::Error>> {
use serde_json::value::RawValue;
let client = GeminiClient::new("test-key".to_string());
let args_raw = RawValue::from_string(json!({"city": "Tokyo"}).to_string()).unwrap();
let request = LlmRequest::new(
"gemini-3-pro-preview",
vec![
Message::User(UserMessage::text("What's the weather?".to_string())),
Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![AssistantBlock::ToolUse {
id: "call_1".to_string(),
name: "get_weather".to_string(),
args: args_raw,
meta: Some(Box::new(ProviderMeta::Gemini {
thought_signature: "sig_123".to_string(),
})),
}],
stop_reason: StopReason::ToolUse,
}),
Message::ToolResults {
results: vec![meerkat_core::ToolResult::new(
"call_1".to_string(),
"Sunny, 25C".to_string(),
false,
)],
},
],
);
let body = client.build_request_body(&request)?;
let contents = body
.get("contents")
.and_then(|c| c.as_array())
.ok_or("missing contents")?;
let assistant_content = contents
.iter()
.find(|c| c.get("role").and_then(|r| r.as_str()) == Some("model"))
.ok_or("missing model content")?;
let assistant_parts = assistant_content
.get("parts")
.and_then(|p| p.as_array())
.ok_or("missing parts")?;
let fc_part = assistant_parts
.iter()
.find(|p| p.get("functionCall").is_some())
.ok_or("missing functionCall part")?;
assert!(
fc_part.get("thoughtSignature").is_some(),
"functionCall part SHOULD have thoughtSignature"
);
let tool_results_content = contents.last().ok_or("missing last content")?;
let tool_result_parts = tool_results_content
.get("parts")
.and_then(|p| p.as_array())
.ok_or("missing tool result parts")?;
let fr_part = tool_result_parts
.iter()
.find(|p| p.get("functionResponse").is_some())
.ok_or("missing functionResponse part")?;
assert!(
fr_part.get("thoughtSignature").is_none(),
"functionResponse MUST NOT have thoughtSignature"
);
Ok(())
}
#[test]
fn test_tool_call_complete_uses_provider_meta() {
use meerkat_core::ProviderMeta;
let meta = Some(Box::new(ProviderMeta::Gemini {
thought_signature: "sig_test".to_string(),
}));
let event = LlmEvent::ToolCallComplete {
id: "fc_0".to_string(),
name: "test_tool".to_string(),
args: json!({}),
meta, };
if let LlmEvent::ToolCallComplete { meta: m, .. } = event {
assert!(m.is_some(), "meta should be Some");
match *m.unwrap() {
ProviderMeta::Gemini { thought_signature } => {
assert_eq!(thought_signature, "sig_test");
}
_ => panic!("expected Gemini variant"),
}
}
}
#[test]
fn test_text_delta_uses_provider_meta() {
use meerkat_core::ProviderMeta;
let meta = Some(Box::new(ProviderMeta::Gemini {
thought_signature: "sig_text".to_string(),
}));
let event = LlmEvent::TextDelta {
delta: "Hello".to_string(),
meta,
};
if let LlmEvent::TextDelta { meta: m, .. } = event {
assert!(m.is_some());
match *m.unwrap() {
ProviderMeta::Gemini { thought_signature } => {
assert_eq!(thought_signature, "sig_text");
}
_ => panic!("expected Gemini variant"),
}
}
}
#[test]
fn test_text_event_for_part_emits_reasoning_delta_when_thought() {
let event = text_event_for_part("plan step".to_string(), true, None);
match event {
LlmEvent::ReasoningDelta { delta } => assert_eq!(delta, "plan step"),
_ => panic!("expected ReasoningDelta"),
}
}
#[test]
fn test_text_event_for_part_emits_text_delta_when_not_thought() {
use meerkat_core::ProviderMeta;
let event = text_event_for_part(
"final answer".to_string(),
false,
Some(Box::new(ProviderMeta::Gemini {
thought_signature: "sig_text".to_string(),
})),
);
match event {
LlmEvent::TextDelta { delta, meta } => {
assert_eq!(delta, "final answer");
let meta = meta.expect("meta");
match meta.as_ref() {
ProviderMeta::Gemini { thought_signature } => {
assert_eq!(thought_signature, "sig_text");
}
_ => panic!("expected Gemini meta"),
}
}
_ => panic!("expected TextDelta"),
}
}
#[test]
fn gemini_user_message_with_image_inline_data() -> Result<(), Box<dyn std::error::Error>> {
let client = GeminiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gemini-3.1-pro-preview",
vec![Message::User(UserMessage::with_blocks(vec![
ContentBlock::Text {
text: "describe this".to_string(),
},
ContentBlock::Image {
media_type: "image/png".to_string(),
data: "iVBOR...".into(),
},
]))],
);
let body = client.build_request_body(&request)?;
let contents = body["contents"].as_array().ok_or("missing contents")?;
let user_content = &contents[0];
assert_eq!(user_content["role"], "user");
let parts = user_content["parts"].as_array().ok_or("missing parts")?;
assert_eq!(parts.len(), 2);
assert_eq!(parts[0]["text"], "describe this");
assert_eq!(parts[1]["inlineData"]["mimeType"], "image/png");
assert_eq!(parts[1]["inlineData"]["data"], "iVBOR...");
let body_str = serde_json::to_string(&body)?;
assert!(
!body_str.contains("source_path"),
"source_path must never appear in provider payload"
);
assert!(
!body_str.contains("/tmp/img.png"),
"source_path value must never appear in provider payload"
);
Ok(())
}
#[test]
fn gemini_text_only_user_message_stays_simple() -> Result<(), Box<dyn std::error::Error>> {
let client = GeminiClient::new("test-key".to_string());
let request = LlmRequest::new(
"gemini-3.1-pro-preview",
vec![Message::User(UserMessage::text("just text"))],
);
let body = client.build_request_body(&request)?;
let contents = body["contents"].as_array().ok_or("missing contents")?;
let parts = contents[0]["parts"].as_array().ok_or("missing parts")?;
assert_eq!(parts.len(), 1);
assert_eq!(parts[0]["text"], "just text");
assert!(
parts[0].get("inlineData").is_none(),
"text-only should not have inlineData"
);
Ok(())
}
#[test]
fn gemini_tool_result_with_image_preserves_inline_data()
-> Result<(), Box<dyn std::error::Error>> {
use serde_json::value::RawValue;
let client = GeminiClient::new("test-key".to_string());
let args_raw = RawValue::from_string(json!({"url": "http://example.com"}).to_string())?;
let request = LlmRequest::new(
"gemini-3.1-pro-preview",
vec![
Message::User(UserMessage::text("take a screenshot")),
Message::BlockAssistant(BlockAssistantMessage {
blocks: vec![AssistantBlock::ToolUse {
id: "call_1".to_string(),
name: "screenshot".to_string(),
args: args_raw,
meta: None,
}],
stop_reason: StopReason::ToolUse,
}),
Message::ToolResults {
results: vec![meerkat_core::ToolResult::with_blocks(
"call_1".to_string(),
vec![
ContentBlock::Text {
text: "captured".to_string(),
},
ContentBlock::Image {
media_type: "image/png".to_string(),
data: "iVBOR...".into(),
},
],
false,
)],
},
],
);
let body = client.build_request_body(&request)?;
let contents = body["contents"].as_array().ok_or("missing contents")?;
let tool_result_content = contents.last().ok_or("no last content")?;
let parts = tool_result_content["parts"]
.as_array()
.ok_or("missing parts")?;
let response = &parts[0]["functionResponse"]["response"];
let content_str = response["content"].as_str().ok_or("content not string")?;
assert!(
content_str.contains("captured"),
"text content should be preserved in functionResponse"
);
assert!(
parts.len() >= 2,
"should have functionResponse + inlineData parts, got {} parts",
parts.len()
);
let inline_data = &parts[1]["inlineData"];
assert_eq!(
inline_data["mimeType"].as_str(),
Some("image/png"),
"image mimeType should be preserved"
);
assert_eq!(
inline_data["data"].as_str(),
Some("iVBOR..."),
"image base64 data should be preserved"
);
Ok(())
}
}