use serde_json::{Value, json};
use super::tools::PreparedTool;
use super::{
MapError, audio_format_from_mime, audio_mime_from_format, bool_field, document_ref_source,
drop_dest_chat_sampling_extras, drop_messages_native_thinking, drop_responses_request_fields,
f32_field, i64_field, is_audio_media_type, is_pdf_media_type, stop_values, str_field,
u32_field,
};
use crate::ir::{
IrCache, IrDocumentSource, IrItem, IrPart, IrRequest, IrSampling, IrToolChoice, LossAction,
LossReport,
};
pub(super) fn decode(value: &Value) -> Result<(IrRequest, LossReport), MapError> {
let mut report = LossReport::default();
let mut items = Vec::new();
if let Some(sys) = system_text(value.get("systemInstruction")) {
items.push(IrItem::System { text: sys });
}
if let Some(contents) = value.get("contents").and_then(Value::as_array) {
for content in contents {
decode_content(content, &mut items);
}
}
let tools = decode_tools(value);
let ir = IrRequest {
model: str_field(value, "model").unwrap_or_default(),
items,
tools,
sampling: decode_sampling(value, &mut report),
};
Ok((ir, report))
}
fn system_text(value: Option<&Value>) -> Option<String> {
let parts = value?.get("parts").and_then(Value::as_array)?;
let text: String = parts
.iter()
.filter_map(|p| p.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("");
(!text.is_empty()).then_some(text)
}
fn unmatched_call_id(items: &[IrItem], name: &str, skip_trailing_user: bool) -> Option<String> {
let used: Vec<&str> = items
.iter()
.filter_map(|item| match item {
IrItem::FunctionOutput { call_id, .. } => Some(call_id.as_str()),
_ => None,
})
.collect();
fn unmatched_id<'a>(item: &'a IrItem, name: &str, used: &[&str]) -> Option<&'a str> {
match item {
IrItem::FunctionCall {
call_id,
name: call_name,
..
} if call_name == name && !used.iter().any(|id| *id == call_id) => {
Some(call_id.as_str())
}
_ => None,
}
}
let mut end = items.len();
if skip_trailing_user && end > 0 && matches!(items[end - 1], IrItem::User { .. }) {
end -= 1;
}
let mut skipped_outputs = 0;
while end > 0 && matches!(items[end - 1], IrItem::FunctionOutput { .. }) {
end -= 1;
skipped_outputs += 1;
}
if skipped_outputs > 0
&& end >= 2
&& matches!(items[end - 1], IrItem::User { .. })
&& !matches!(
items[end - 2],
IrItem::User { .. }
| IrItem::Developer { .. }
| IrItem::FunctionOutput { .. }
| IrItem::System { .. }
)
{
end -= 1;
}
let mut start = end;
while start > 0 {
match &items[start - 1] {
IrItem::User { .. }
| IrItem::Developer { .. }
| IrItem::FunctionOutput { .. }
| IrItem::System { .. } => break,
_ => start -= 1,
}
}
items[start..end]
.iter()
.find_map(|item| unmatched_id(item, name, &used).map(str::to_string))
}
fn decode_content(content: &Value, items: &mut Vec<IrItem>) {
let role = content
.get("role")
.and_then(Value::as_str)
.unwrap_or("user");
let parts = content.get("parts").and_then(Value::as_array);
let Some(parts) = parts else {
return;
};
let mut text_parts = Vec::new();
for part in parts {
if let Some(fc) = part.get("functionCall") {
flush_parts(role, &mut text_parts, items);
let name = str_field(fc, "name").unwrap_or_default();
let args = fc.get("args").cloned().unwrap_or_else(|| json!({}));
items.push(IrItem::FunctionCall {
call_id: crate::stream::gemini_call_id(fc, &name, items.len()),
name,
arguments: args.to_string(),
thought_signature: part
.get("thoughtSignature")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string),
});
continue;
}
if let Some(fr) = part.get("functionResponse") {
let skip_trailing_user = role != "model" && !text_parts.is_empty();
flush_parts(role, &mut text_parts, items);
let name = str_field(fr, "name").unwrap_or_default();
let output = fr
.get("response")
.map(ToString::to_string)
.unwrap_or_else(|| "{}".into());
let call_id = fr
.get("id")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| unmatched_call_id(items, &name, skip_trailing_user))
.unwrap_or(name);
items.push(IrItem::FunctionOutput {
call_id,
output,
parts: decode_function_response_parts(fr),
is_error: false,
});
continue;
}
if part.get("thought").and_then(Value::as_bool) == Some(true) {
if let Some(text) = part.get("text").and_then(Value::as_str) {
text_parts.push(IrPart::Thinking {
text: text.to_string(),
signature: part
.get("thoughtSignature")
.and_then(Value::as_str)
.map(str::to_string),
});
}
continue;
}
if let Some(text) = part.get("text").and_then(Value::as_str) {
if super::part_has_media_hint(part) {
text_parts.push(IrPart::Raw {
type_name: "text".into(),
raw: part.clone(),
});
} else {
text_parts.push(IrPart::Text(text.to_string()));
}
}
if let Some(inline) = part.get("inlineData") {
if super::part_has_media_hint(part) {
text_parts.push(IrPart::Raw {
type_name: "inlineData".into(),
raw: part.clone(),
});
continue;
}
let media = str_field(inline, "mimeType").unwrap_or_default();
let data = str_field(inline, "data").unwrap_or_default();
if is_audio_media_type(&media) {
text_parts.push(IrPart::Audio {
data,
format: audio_format_from_mime(&media),
});
} else if is_pdf_media_type(&media) {
text_parts.push(IrPart::Document {
source: IrDocumentSource::Base64(data),
media_type: media,
name: None,
citations_enabled: None,
context: None,
});
} else {
text_parts.push(IrPart::ImageBase64 {
media_type: media,
data,
});
}
}
if let Some(file) = part.get("fileData") {
let media = str_field(file, "mimeType").unwrap_or_default();
let uri = str_field(file, "fileUri").unwrap_or_default();
if is_pdf_media_type(&media) && !uri.is_empty() && !super::part_has_media_hint(part) {
text_parts.push(IrPart::Document {
source: document_ref_source(uri),
media_type: media,
name: None,
citations_enabled: None,
context: None,
});
} else {
text_parts.push(IrPart::Raw {
type_name: "fileData".into(),
raw: part.clone(),
});
}
} else if part.get("fileUri").is_some() {
text_parts.push(IrPart::Raw {
type_name: "fileUri".into(),
raw: part.clone(),
});
}
if part.get("executableCode").is_some() || part.get("codeExecutionResult").is_some() {
let type_name = if part.get("executableCode").is_some() {
"executableCode"
} else {
"codeExecutionResult"
};
text_parts.push(IrPart::Raw {
type_name: type_name.into(),
raw: part.clone(),
});
}
if part.get("toolCall").is_some() || part.get("toolResponse").is_some() {
let type_name = if part.get("toolCall").is_some() {
"toolCall"
} else {
"toolResponse"
};
text_parts.push(IrPart::Raw {
type_name: type_name.into(),
raw: part.clone(),
});
}
}
flush_parts(role, &mut text_parts, items);
}
fn decode_function_response_parts(fr: &Value) -> Vec<IrPart> {
let Some(parts) = fr.get("parts").and_then(Value::as_array) else {
return Vec::new();
};
let mut out = Vec::new();
for part in parts {
let Some(inline) = part.get("inlineData") else {
continue;
};
let media = str_field(inline, "mimeType").unwrap_or_default();
let data = str_field(inline, "data").unwrap_or_default();
if data.is_empty() {
continue;
}
let name = str_field(inline, "displayName").filter(|item| !item.is_empty());
if is_pdf_media_type(&media) || media.eq_ignore_ascii_case("text/plain") {
out.push(IrPart::Document {
source: IrDocumentSource::Base64(data),
media_type: media,
name,
citations_enabled: None,
context: None,
});
continue;
}
if media.to_ascii_lowercase().starts_with("image/") && name.is_none() {
out.push(IrPart::ImageBase64 {
media_type: media,
data,
});
continue;
}
out.push(IrPart::Raw {
type_name: "functionResponse.inlineData".into(),
raw: json!({ "inlineData": inline }),
});
}
out
}
fn flush_parts(role: &str, parts: &mut Vec<IrPart>, items: &mut Vec<IrItem>) {
if parts.is_empty() {
return;
}
let taken = std::mem::take(parts);
match role {
"model" => items.push(IrItem::Assistant { parts: taken }),
_ => items.push(IrItem::User { parts: taken }),
}
}
fn decode_tools(value: &Value) -> Vec<crate::ir::IrTool> {
let Some(tools) = value.get("tools").and_then(Value::as_array) else {
return Vec::new();
};
let mut out = Vec::new();
for tool in tools {
let Some(obj) = tool.as_object() else {
continue;
};
if let Some(decls) = obj.get("functionDeclarations").and_then(Value::as_array) {
for decl in decls {
out.push(crate::ir::IrTool::Function {
name: str_field(decl, "name").unwrap_or_default(),
description: str_field(decl, "description").unwrap_or_default(),
parameters: decl
.get("parametersJsonSchema")
.or_else(|| decl.get("parameters_json_schema"))
.filter(|v| v.is_object())
.or_else(|| decl.get("parameters"))
.cloned()
.unwrap_or_else(|| json!({"type": "object", "properties": {}})),
strict: None,
});
}
for (key, val) in obj {
if key == "functionDeclarations" {
continue;
}
out.push(crate::ir::IrTool::Unknown {
type_name: key.clone(),
raw: json!({ key: val.clone() }),
});
}
continue;
}
let Some(type_name) = obj.keys().next() else {
continue;
};
out.push(crate::ir::IrTool::Unknown {
type_name: type_name.clone(),
raw: tool.clone(),
});
}
out
}
fn decode_sampling(value: &Value, report: &mut LossReport) -> IrSampling {
let cfg = value.get("generationConfig").unwrap_or(value);
let thinking = thinking_config_obj(value);
let tool_choice = decode_tool_choice(value);
if gemini_source_has_unmapped_tool_choice(value) {
report.record("sampling.tool_choice", LossAction::Drop, "no slot");
}
let json_schema = gemini_json_schema(cfg);
if gemini_source_has_json_schema(value) && json_schema.is_none() {
report.record(
"sampling.json_schema",
LossAction::Drop,
"json_schema requires object schema",
);
}
let json_schema_name = json_schema
.as_ref()
.is_some()
.then(|| "response".to_string());
let json_object = gemini_json_object(cfg, json_schema.is_some());
gemini_drop_speech_language(cfg, report);
let top_logprobs = u32_field(cfg, "logprobs").or_else(|| u32_field(cfg, "top_logprobs"));
let logprobs = bool_field(cfg, "responseLogprobs")
.or_else(|| bool_field(cfg, "response_logprobs"))
.or_else(|| bool_field(cfg, "logprobs"))
.or(top_logprobs.is_some().then_some(true));
IrSampling {
temperature: f32_field(cfg, "temperature"),
top_p: f32_field(cfg, "topP").or_else(|| f32_field(cfg, "top_p")),
top_k: u32_field(cfg, "topK").or_else(|| u32_field(cfg, "top_k")),
max_tokens: u32_field(cfg, "maxOutputTokens").or_else(|| u32_field(cfg, "max_tokens")),
stop: stop_values(cfg, &["stopSequences", "stop"]),
tool_choice,
parallel_tool_calls: bool_field(value, "parallel_tool_calls"),
store: bool_field(value, "store"),
previous_response_id: None,
cache: IrCache::default(),
stream: bool_field(value, "stream"),
include_thoughts: bool_field(thinking, "includeThoughts")
.or_else(|| bool_field(thinking, "include_thoughts")),
thinking_budget: u32_field(thinking, "thinkingBudget")
.or_else(|| u32_field(thinking, "thinking_budget")),
reasoning_effort: str_field(thinking, "thinkingLevel")
.or_else(|| str_field(thinking, "thinking_level"))
.filter(|s| !s.trim().is_empty()),
max_reasoning_tokens: None,
json_schema,
json_schema_name,
json_schema_description: None,
json_schema_strict: None,
json_object,
include: Vec::new(),
prompt_cache_key: None,
prompt_cache_retention: None,
prompt_cache_mode: None,
prompt_cache_ttl: None,
top_logprobs,
moderation_model: None,
moderation_input: None,
moderation_output: None,
include_obfuscation: None,
service_tier: gemini_decode_service_tier(value),
user: None,
verbosity: None,
safety_identifier: None,
metadata: std::collections::BTreeMap::new(),
frequency_penalty: f32_field(cfg, "frequencyPenalty")
.or_else(|| f32_field(cfg, "frequency_penalty")),
presence_penalty: f32_field(cfg, "presencePenalty")
.or_else(|| f32_field(cfg, "presence_penalty")),
seed: i64_field(cfg, "seed"),
n: u32_field(cfg, "candidateCount").or_else(|| u32_field(cfg, "candidate_count")),
output_modalities: gemini_output_modalities(cfg),
audio_voice: if gemini_multi_speaker_speech(cfg).is_some() {
None
} else {
gemini_speech_voice(cfg)
},
multi_speaker_speech: gemini_multi_speaker_speech(cfg),
audio_format: None,
logprobs,
logit_bias: std::collections::BTreeMap::new(),
prediction: None,
web_search_options: None,
inference_geo: None,
performance_latency: None,
previous_message_id: None,
response_field_paths: Vec::new(),
additional_request_fields: None,
prompt_variables: None,
guardrail: None,
container: None,
context_management: None,
mcp_servers: None,
media_resolution: str_field(cfg, "mediaResolution")
.map(|level| level.trim().to_string())
.filter(|level| !level.is_empty()),
image_config: cfg
.get("imageConfig")
.filter(|value| value.is_object())
.cloned(),
audio_transcription_config: cfg
.get("audioTranscriptionConfig")
.filter(|value| value.is_object())
.cloned(),
translation_config: cfg
.get("translationConfig")
.filter(|value| value.is_object())
.cloned(),
affective_dialog: bool_field(cfg, "enableAffectiveDialog"),
enhanced_civic_answers: bool_field(cfg, "enableEnhancedCivicAnswers"),
cached_content: str_field(value, "cachedContent")
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty()),
safety_settings: value
.get("safetySettings")
.filter(|settings| settings.is_array())
.cloned(),
request_labels: value
.get("labels")
.filter(|labels| labels.is_object())
.cloned(),
truncation: None,
max_tool_calls: None,
background: None,
conversation: None,
responses_context_management: None,
messages_thinking: None,
}
}
fn gemini_output_modalities(cfg: &Value) -> Vec<String> {
let Some(arr) = cfg
.get("responseModalities")
.or_else(|| cfg.get("response_modalities"))
.and_then(Value::as_array)
else {
return Vec::new();
};
let mut out = Vec::new();
for item in arr {
let Some(raw) = item.as_str() else {
continue;
};
if raw.eq_ignore_ascii_case("text") {
out.push("text".to_string());
} else if raw.eq_ignore_ascii_case("audio") {
out.push("audio".to_string());
} else if raw.eq_ignore_ascii_case("image") {
out.push("image".to_string());
}
}
out
}
fn gemini_speech_obj(cfg: &Value) -> Option<&Value> {
cfg.get("speechConfig").or_else(|| cfg.get("speech_config"))
}
fn gemini_multi_speaker_speech(cfg: &Value) -> Option<Value> {
let speech = gemini_speech_obj(cfg)?;
speech
.get("multiSpeakerVoiceConfig")
.filter(|value| value.is_object())?;
Some(speech.clone())
}
fn gemini_speech_voice(cfg: &Value) -> Option<String> {
let speech = gemini_speech_obj(cfg)?;
let voice_cfg = speech
.get("voiceConfig")
.or_else(|| speech.get("voice_config"))?;
let prebuilt = voice_cfg
.get("prebuiltVoiceConfig")
.or_else(|| voice_cfg.get("prebuilt_voice_config"))?;
str_field(prebuilt, "voiceName")
.or_else(|| str_field(prebuilt, "voice_name"))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn gemini_drop_speech_language(cfg: &Value, report: &mut LossReport) {
let Some(speech) = gemini_speech_obj(cfg) else {
return;
};
if speech.get("multiSpeakerVoiceConfig").is_some() {
return;
}
let lang = str_field(speech, "languageCode").or_else(|| str_field(speech, "language_code"));
if lang.as_deref().is_some_and(|s| !s.trim().is_empty()) {
report.record("sampling.audio_language", LossAction::Drop, "no slot");
}
}
fn gemini_json_object(cfg: &Value, has_schema: bool) -> Option<bool> {
if has_schema {
return None;
}
let mime = cfg
.get("responseMimeType")
.or_else(|| cfg.get("response_mime_type"))
.and_then(Value::as_str);
if mime == Some("application/json") {
return Some(true);
}
let format_mime = cfg
.pointer("/responseFormat/text/mimeType")
.or_else(|| cfg.pointer("/response_format/text/mimeType"))
.and_then(Value::as_str)?;
matches!(format_mime, "APPLICATION_JSON" | "application/json").then_some(true)
}
fn gemini_json_schema(cfg: &Value) -> Option<Value> {
cfg.get("responseSchema")
.or_else(|| cfg.get("responseJsonSchema"))
.or_else(|| cfg.get("response_schema"))
.or_else(|| cfg.get("response_json_schema"))
.or_else(|| cfg.pointer("/responseFormat/text/schema"))
.or_else(|| cfg.pointer("/response_format/text/schema"))
.filter(|v| v.is_object())
.cloned()
}
fn thinking_config_obj(value: &Value) -> &Value {
value
.get("thinkingConfig")
.or_else(|| value.pointer("/generationConfig/thinkingConfig"))
.unwrap_or(&Value::Null)
}
pub(crate) fn gemini_decode_service_tier(value: &Value) -> Option<String> {
str_field(value, "serviceTier")
.or_else(|| str_field(value, "service_tier"))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.filter(|s| !s.eq_ignore_ascii_case("unspecified"))
}
pub(crate) fn gemini_service_tier(tier: &str) -> Option<(String, Option<&'static str>)> {
let trimmed = tier.trim();
if trimmed.is_empty() {
return None;
}
let lower = trimmed.to_ascii_lowercase();
match lower.as_str() {
"flex" | "priority" | "standard" => Some((lower, None)),
"default" => Some(("standard".into(), Some("default maps to standard"))),
"auto" => Some(("standard".into(), Some("auto maps to standard"))),
_ => None,
}
}
fn gemini_thinking_level(effort: &str) -> Option<(String, Option<&'static str>)> {
let trimmed = effort.trim();
if trimmed.is_empty() {
return None;
}
let lower = trimmed.to_ascii_lowercase();
match lower.as_str() {
"xhigh" | "x-high" => Some(("high".into(), Some("xhigh maps to high"))),
_ => Some((lower, None)),
}
}
fn decode_tool_choice(value: &Value) -> IrToolChoice {
let Some(fcc) = gemini_function_calling_config(value) else {
return IrToolChoice::Auto;
};
let named = fcc
.get("allowedFunctionNames")
.or_else(|| fcc.get("allowed_function_names"))
.and_then(Value::as_array)
.and_then(|names| names.iter().find_map(|n| n.as_str()))
.filter(|n| !n.is_empty())
.map(str::to_string);
match str_field(fcc, "mode").as_deref() {
Some("NONE") | Some("none") => IrToolChoice::None,
Some("ANY") | Some("any") => match named {
Some(name) => IrToolChoice::Named(name),
None => IrToolChoice::Required,
},
_ => IrToolChoice::Auto,
}
}
fn gemini_tool_config(value: &Value) -> Option<&Value> {
value.get("toolConfig").or_else(|| value.get("tool_config"))
}
fn gemini_function_calling_config(value: &Value) -> Option<&Value> {
let cfg = gemini_tool_config(value)?;
cfg.get("functionCallingConfig")
.or_else(|| cfg.get("function_calling_config"))
}
fn gemini_source_has_unmapped_tool_choice(value: &Value) -> bool {
value.get("tool_choice").is_some() && gemini_tool_config(value).is_none()
}
fn gemini_source_has_json_schema(value: &Value) -> bool {
let cfg = value.get("generationConfig").unwrap_or(value);
cfg.get("responseSchema").is_some()
|| cfg.get("responseJsonSchema").is_some()
|| cfg.get("response_schema").is_some()
|| cfg.get("response_json_schema").is_some()
}
pub(super) fn encode(
ir: &IrRequest,
prepared: &[PreparedTool],
report: &mut LossReport,
) -> Result<Value, MapError> {
let mut system_parts = Vec::new();
let mut contents = Vec::new();
let mut call_names = Vec::new();
for (idx, item) in ir.items.iter().enumerate() {
match item {
IrItem::System { text } | IrItem::Developer { text } => {
if !contents.is_empty() {
report.record(
format!("items[{idx}]"),
LossAction::Degrade,
"system message moved out of the turn list",
);
}
if matches!(item, IrItem::Developer { .. }) {
report.record(
"item.developer",
LossAction::Degrade,
"developer to systemInstruction",
);
}
system_parts.push(json!({ "text": text }));
}
IrItem::User { parts } => {
if last_role(&contents) == Some("user") {
report.record(
format!("items[{idx}]"),
LossAction::Degrade,
"consecutive turns with the same role were joined",
);
}
for part in encode_parts(parts, report) {
push_role_part(&mut contents, "user", part);
}
}
IrItem::Assistant { parts } => {
if last_role(&contents) == Some("model") {
report.record(
format!("items[{idx}]"),
LossAction::Degrade,
"consecutive turns with the same role were joined",
);
}
for part in encode_parts(parts, report) {
push_role_part(&mut contents, "model", part);
}
}
IrItem::FunctionCall {
call_id,
name,
arguments,
thought_signature,
} => {
call_names.push((call_id.as_str(), name.as_str()));
let args = super::json_object_or_raw(
arguments,
"functionCall.args",
report,
"tool arguments are not a JSON object",
"raw",
);
let mut part = json!({
"functionCall": { "id": call_id, "name": name, "args": args }
});
if let Some(sig) = thought_signature.as_deref().filter(|s| !s.is_empty()) {
part["thoughtSignature"] = json!(sig);
}
push_role_part(&mut contents, "model", part);
}
IrItem::CustomToolCall {
call_id,
name,
input,
..
} => {
call_names.push((call_id.as_str(), name.as_str()));
let args = super::json_object_or_raw(
input,
"functionCall.args",
report,
"tool arguments are not a JSON object",
"raw",
);
push_role_part(
&mut contents,
"model",
json!({
"functionCall": { "id": call_id, "name": name, "args": args }
}),
);
}
IrItem::FunctionOutput {
call_id,
output,
parts,
is_error,
} => {
if *is_error {
report.record(
format!("items[{idx}].is_error"),
LossAction::Drop,
"no slot",
);
}
let mut wire_parts = Vec::new();
for part in parts {
if let Some(inline) = super::function_response_inline(part) {
let mut blob = json!({
"mimeType": inline.mime,
"data": inline.data,
});
if let Some(name) = inline.display_name {
blob["displayName"] = json!(name);
}
wire_parts.push(json!({ "inlineData": blob }));
} else if matches!(
part,
IrPart::ImageUrl { .. } | IrPart::ImageBase64 { .. } | IrPart::Audio { .. }
) || matches!(part, IrPart::Raw { raw, .. } if raw.get("inlineData").is_some())
{
report.record(format!("items[{idx}].part"), LossAction::Drop, "no slot");
}
}
let name = call_names
.iter()
.rev()
.find_map(|(id, name)| (*id == call_id).then_some(*name))
.unwrap_or(call_id.as_str());
let response = super::json_object_or_raw(
output,
"functionResponse.response",
report,
"tool output is not a JSON object",
"result",
);
let mut body = json!({
"id": call_id,
"name": name,
"response": response
});
if !wire_parts.is_empty() {
body["parts"] = serde_json::Value::Array(wire_parts);
}
push_role_part(&mut contents, "user", json!({ "functionResponse": body }));
}
IrItem::Reasoning {
encrypted: _,
summary,
raw,
} => {
let text = summary
.as_deref()
.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| raw.as_ref().and_then(|v| str_field(v, "summary")));
if let Some(text) = text.filter(|t| !t.is_empty()) {
push_role_part(
&mut contents,
"model",
json!({ "text": text, "thought": true }),
);
} else {
report.record(
"item.reasoning",
LossAction::Drop,
"reasoning omitted on generateContent",
);
}
}
IrItem::HostedToolCall { kind, .. }
| IrItem::Unknown {
type_name: kind, ..
} => {
report.record(
"item.unknown",
LossAction::Drop,
format!("gemini cannot express {kind}"),
);
}
}
}
let mut body = json!({ "contents": contents });
if !ir.model.is_empty() {
body["model"] = json!(ir.model);
}
if !system_parts.is_empty() {
body["systemInstruction"] = json!({ "parts": system_parts });
}
let tools = encode_prepared_tools(prepared, report);
if !tools.is_empty() {
body["tools"] = json!(tools);
}
encode_sampling(ir, &mut body, report);
Ok(body)
}
fn relax_gemini_schema(schema: &mut Value, report: &mut LossReport, path: &str) {
let defs = schema_defs(schema);
let mut stack = Vec::new();
relax_gemini_node(schema, report, path, &defs, &mut stack);
}
fn schema_defs(schema: &Value) -> serde_json::Map<String, Value> {
let mut defs = serde_json::Map::new();
let Some(obj) = schema.as_object() else {
return defs;
};
for key in ["$defs", "definitions"] {
if let Some(map) = obj.get(key).and_then(Value::as_object) {
for (name, value) in map {
defs.entry(name.clone()).or_insert_with(|| value.clone());
}
}
}
defs
}
fn local_ref_name(reference: &str) -> Option<&str> {
let name = reference
.strip_prefix("#/$defs/")
.or_else(|| reference.strip_prefix("#/definitions/"))?;
if name.is_empty() || name.contains('/') {
None
} else {
Some(name)
}
}
fn relax_gemini_node(
schema: &mut Value,
report: &mut LossReport,
path: &str,
defs: &serde_json::Map<String, Value>,
stack: &mut Vec<String>,
) {
let mut inline: Option<String> = None;
if let Some(obj) = schema.as_object_mut()
&& let Some(reference) = obj.get("$ref").and_then(Value::as_str).map(str::to_owned)
{
obj.remove("$ref");
match local_ref_name(&reference) {
Some(name) if stack.iter().any(|seen| seen == name) => {
report.record(
format!("{path}.$ref"),
LossAction::Drop,
"recursive schema ref has no Gemini slot",
);
}
Some(name) => match defs.get(name).cloned() {
Some(Value::Object(map)) => {
for (key, value) in map {
obj.entry(key).or_insert(value);
}
inline = Some(name.to_string());
}
Some(_) | None => {
report.record(
format!("{path}.$ref"),
LossAction::Drop,
"unresolved schema ref has no Gemini slot",
);
}
},
None => {
report.record(
format!("{path}.$ref"),
LossAction::Drop,
"schema ref has no Gemini slot",
);
}
}
}
if let Some(name) = inline {
stack.push(name);
relax_gemini_node(schema, report, path, defs, stack);
stack.pop();
return;
}
let Some(obj) = schema.as_object_mut() else {
return;
};
if obj.remove("prefixItems").is_some() {
report.record(
format!("{path}.prefixItems"),
LossAction::Drop,
"prefixItems has no Gemini slot",
);
if !obj.contains_key("items") {
obj.insert("items".into(), json!({}));
}
}
if obj.remove("$schema").is_some() {
report.record(
format!("{path}.$schema"),
LossAction::Drop,
"$schema has no Gemini slot",
);
}
for key in ["exclusiveMinimum", "exclusiveMaximum"] {
if obj.remove(key).is_some() {
report.record(
format!("{path}.{key}"),
LossAction::Drop,
"exclusive bound has no Gemini slot",
);
}
}
if let Some(props) = obj.get_mut("properties").and_then(Value::as_object_mut) {
for (name, child) in props.iter_mut() {
relax_gemini_node(
child,
report,
&format!("{path}.properties.{name}"),
defs,
stack,
);
}
}
if let Some(items) = obj.get_mut("items") {
match items {
Value::Array(list) => {
for (index, item) in list.iter_mut().enumerate() {
relax_gemini_node(item, report, &format!("{path}.items[{index}]"), defs, stack);
}
}
other => relax_gemini_node(other, report, &format!("{path}.items"), defs, stack),
}
}
for key in ["anyOf", "oneOf", "allOf"] {
if let Some(Value::Array(list)) = obj.get_mut(key) {
for (index, item) in list.iter_mut().enumerate() {
relax_gemini_node(item, report, &format!("{path}.{key}[{index}]"), defs, stack);
}
}
}
if obj.remove("additionalProperties").is_some() {
report.record(
format!("{path}.additionalProperties"),
LossAction::Drop,
"additionalProperties has no Gemini slot",
);
}
if let Some(child) = obj.get_mut("not").filter(|value| value.is_object()) {
relax_gemini_node(child, report, &format!("{path}.not"), defs, stack);
}
obj.remove("$defs");
obj.remove("definitions");
}
fn encode_prepared_tools(prepared: &[PreparedTool], report: &mut LossReport) -> Vec<Value> {
let mut decls = Vec::new();
let mut hosted = Vec::new();
for (i, tool) in prepared.iter().enumerate() {
match tool {
PreparedTool::Function {
name,
description,
parameters,
strict,
} => {
let mut parameters = parameters.clone();
let path = format!("tools[{i}].parameters");
relax_gemini_schema(&mut parameters, report, &path);
if *strict == Some(true) {
super::messages::require_every_property(&mut parameters);
report.record(
path,
LossAction::Drop,
"strict additionalProperties has no Gemini slot",
);
}
decls.push(json!({
"name": name,
"description": description,
"parameters": parameters,
}));
}
PreparedTool::Raw(raw) if is_gemini_hosted_raw(raw) => hosted.push(raw.clone()),
PreparedTool::Raw(_) => {
report.record(
format!("tools[{i}]"),
LossAction::Drop,
"raw tool has no generateContent slot",
);
}
}
}
let mut tools = Vec::new();
if !decls.is_empty() {
tools.push(json!({ "functionDeclarations": decls }));
}
tools.extend(hosted);
tools
}
fn is_gemini_hosted_raw(raw: &Value) -> bool {
let Some(obj) = raw.as_object() else {
return false;
};
obj.contains_key("googleSearch")
|| obj.contains_key("codeExecution")
|| obj.contains_key("googleSearchRetrieval")
}
fn last_role(contents: &[Value]) -> Option<&str> {
contents.last()?.get("role")?.as_str()
}
fn push_role_part(contents: &mut Vec<Value>, role: &str, part: Value) {
if let Some(last) = contents.last_mut()
&& last.get("role").and_then(Value::as_str) == Some(role)
&& let Some(parts) = last.get_mut("parts").and_then(Value::as_array_mut)
{
parts.push(part);
return;
}
contents.push(json!({ "role": role, "parts": [part] }));
}
fn encode_parts(parts: &[IrPart], report: &mut LossReport) -> Vec<Value> {
let mut out = Vec::new();
for part in parts {
match part {
IrPart::Text(text) => out.push(json!({ "text": text })),
IrPart::Thinking { text, signature } => {
let mut obj = json!({ "text": text, "thought": true });
if let Some(sig) = signature {
obj["thoughtSignature"] = json!(sig);
}
out.push(obj);
}
IrPart::ImageBase64 { media_type, data } => {
out.push(json!({
"inlineData": { "mimeType": media_type, "data": data }
}));
}
IrPart::Document {
source: IrDocumentSource::Text(data),
..
} => {
report.record(
"part.document",
LossAction::Degrade,
"plain text document becomes text",
);
out.push(json!({ "text": data }));
}
IrPart::Document {
source, media_type, ..
} => out.push(encode_document(source, media_type)),
IrPart::Audio { data, format } => {
out.push(json!({
"inlineData": {
"mimeType": audio_mime_from_format(format),
"data": data
}
}));
}
IrPart::Raw { raw, .. } => {
if raw.get("fileData").is_some()
|| raw.get("fileUri").is_some()
|| raw.get("inlineData").is_some()
|| raw.get("executableCode").is_some()
|| raw.get("codeExecutionResult").is_some()
|| raw.get("toolCall").is_some()
|| raw.get("toolResponse").is_some()
|| raw.get("audioTranscription").is_some()
|| raw.get("speechMetadata").is_some()
|| raw.get("partMetadata").is_some()
|| raw.get("thoughtSignature").is_some()
{
out.push(raw.clone());
} else {
report.record(
"part.raw",
LossAction::Drop,
"raw part has no generateContent slot",
);
}
}
IrPart::ImageUrl { url, .. } => {
if let Some(rest) = url.strip_prefix("data:")
&& let Some((mime, b64)) = rest.split_once(";base64,")
{
out.push(json!({
"inlineData": { "mimeType": mime, "data": b64 }
}));
} else {
report.record(
"part.image_url",
LossAction::Degrade,
"url to text placeholder",
);
out.push(json!({ "text": format!("[image: {url}]") }));
}
}
}
}
out
}
fn encode_document(source: &IrDocumentSource, media_type: &str) -> Value {
match source {
IrDocumentSource::Text(data) => json!({ "text": data }),
IrDocumentSource::Base64(data) => json!({
"inlineData": { "mimeType": media_type, "data": data }
}),
IrDocumentSource::Url(uri) | IrDocumentSource::FileId(uri) => {
let mut file = json!({ "fileUri": uri });
if !media_type.is_empty() {
file["mimeType"] = json!(media_type);
}
json!({ "fileData": file })
}
}
}
fn encode_sampling(ir: &IrRequest, body: &mut Value, report: &mut LossReport) {
let s = &ir.sampling;
let mut cfg = json!({});
if let Some(t) = s.temperature {
cfg["temperature"] = super::json_f32(t);
}
if let Some(p) = s.top_p {
cfg["topP"] = super::json_f32(p);
}
if let Some(k) = s.top_k {
cfg["topK"] = json!(k);
}
if let Some(level) = s
.media_resolution
.as_deref()
.map(str::trim)
.filter(|level| !level.is_empty())
{
cfg["mediaResolution"] = json!(level);
}
if let Some(config) = s.image_config.as_ref().filter(|value| value.is_object()) {
cfg["imageConfig"] = config.clone();
}
if let Some(config) = s
.audio_transcription_config
.as_ref()
.filter(|value| value.is_object())
{
cfg["audioTranscriptionConfig"] = config.clone();
}
if let Some(config) = s
.translation_config
.as_ref()
.filter(|value| value.is_object())
{
cfg["translationConfig"] = config.clone();
}
if let Some(enabled) = s.affective_dialog {
cfg["enableAffectiveDialog"] = json!(enabled);
}
if let Some(enabled) = s.enhanced_civic_answers {
cfg["enableEnhancedCivicAnswers"] = json!(enabled);
}
if s.inference_geo.is_some() {
report.record("sampling.inference_geo", LossAction::Drop, "no slot");
}
if s.performance_latency.is_some() {
report.record("sampling.performance_latency", LossAction::Drop, "no slot");
}
if s.previous_message_id.is_some() {
report.record("sampling.previous_message_id", LossAction::Drop, "no slot");
}
if !s.response_field_paths.is_empty() {
report.record("sampling.response_field_paths", LossAction::Drop, "no slot");
}
if s.additional_request_fields.is_some() {
report.record(
"sampling.additional_request_fields",
LossAction::Drop,
"no slot",
);
}
if s.prompt_variables.is_some() {
report.record("sampling.prompt_variables", LossAction::Drop, "no slot");
}
if s.guardrail.is_some() {
report.record("sampling.guardrail", LossAction::Drop, "no slot");
}
if s.container.is_some() {
report.record("sampling.container", LossAction::Drop, "no slot");
}
if s.context_management.is_some() {
report.record("sampling.context_management", LossAction::Drop, "no slot");
}
drop_responses_request_fields(s, report);
drop_messages_native_thinking(s, report);
if s.mcp_servers.is_some() {
report.record("sampling.mcp_servers", LossAction::Drop, "no slot");
}
if s.json_schema_strict.is_some() {
report.record("sampling.json_schema_strict", LossAction::Drop, "no slot");
}
if s.json_schema_description.is_some() {
report.record(
"sampling.json_schema_description",
LossAction::Drop,
"no slot",
);
}
if let Some(max) = s.max_tokens {
cfg["maxOutputTokens"] = json!(max);
}
if !s.stop.is_empty() {
cfg["stopSequences"] = json!(s.stop);
}
if let Some(fp) = s.frequency_penalty {
cfg["frequencyPenalty"] = super::json_f32(fp);
}
if let Some(pp) = s.presence_penalty {
cfg["presencePenalty"] = super::json_f32(pp);
}
if let Some(seed) = s.seed {
cfg["seed"] = json!(seed);
}
if let Some(n) = s.n {
cfg["candidateCount"] = json!(n);
}
if s.logprobs == Some(true) || s.top_logprobs.is_some() {
cfg["responseLogprobs"] = json!(true);
}
if s.logprobs == Some(true) {
report.record(
"sampling.logprobs",
LossAction::Preserve,
"gemini generationConfig.responseLogprobs",
);
}
if let Some(n) = s.top_logprobs {
cfg["logprobs"] = json!(n);
report.record(
"sampling.top_logprobs",
LossAction::Preserve,
"gemini generationConfig.logprobs",
);
}
let thinking_budget = s.thinking_budget.or(s.max_reasoning_tokens);
let thinking_level = s
.reasoning_effort
.as_deref()
.and_then(gemini_thinking_level);
if s.include_thoughts.is_some() || thinking_budget.is_some() || thinking_level.is_some() {
let mut tc = json!({});
if let Some(include) = s.include_thoughts {
tc["includeThoughts"] = json!(include);
}
if let Some(budget) = thinking_budget {
tc["thinkingBudget"] = json!(budget);
}
if let Some((level, degrade)) = thinking_level {
tc["thinkingLevel"] = json!(level);
if let Some(detail) = degrade {
report.record("sampling.reasoning_effort", LossAction::Degrade, detail);
}
}
cfg["thinkingConfig"] = tc;
}
if let Some(schema) = &s.json_schema {
if schema.is_object() {
let mut schema = schema.clone();
relax_gemini_schema(&mut schema, report, "sampling.json_schema");
cfg["responseMimeType"] = json!("application/json");
cfg["responseSchema"] = schema;
} else {
report.record(
"sampling.json_schema",
LossAction::Drop,
"json_schema requires object schema",
);
}
} else if s.json_object == Some(true) {
cfg["responseMimeType"] = json!("application/json");
}
let modalities: Vec<String> = s
.output_modalities
.iter()
.filter_map(|raw| {
if raw.eq_ignore_ascii_case("text") {
Some("TEXT".to_string())
} else if raw.eq_ignore_ascii_case("audio") {
Some("AUDIO".to_string())
} else if raw.eq_ignore_ascii_case("image") {
Some("IMAGE".to_string())
} else {
None
}
})
.collect();
if !s.output_modalities.is_empty() && !modalities.is_empty() {
cfg["responseModalities"] = json!(modalities);
report.record(
"sampling.output_modalities",
LossAction::Preserve,
"gemini generationConfig.responseModalities",
);
}
if let Some(speech) = s
.multi_speaker_speech
.as_ref()
.filter(|value| value.is_object())
{
cfg["speechConfig"] = speech.clone();
} else if let Some(voice) = s
.audio_voice
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
{
cfg["speechConfig"] = json!({
"voiceConfig": {
"prebuiltVoiceConfig": {
"voiceName": voice
}
}
});
report.record(
"sampling.audio_voice",
LossAction::Preserve,
"gemini generationConfig.speechConfig",
);
}
if s.audio_format.is_some() {
report.record("sampling.audio_format", LossAction::Drop, "no slot");
}
if cfg.as_object().is_some_and(|o| !o.is_empty()) {
body["generationConfig"] = cfg;
}
if let Some(name) = s
.cached_content
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
{
body["cachedContent"] = json!(name);
}
if let Some(settings) = s.safety_settings.as_ref().filter(|value| value.is_array()) {
body["safetySettings"] = settings.clone();
}
if let Some(labels) = s.request_labels.as_ref().filter(|value| value.is_object()) {
body["labels"] = labels.clone();
}
if let Some(store) = s.store {
body["store"] = json!(store);
report.record("sampling.store", LossAction::Preserve, "gemini store");
}
if s.prompt_cache_key.is_some() {
report.record("sampling.prompt_cache_key", LossAction::Drop, "no slot");
}
if s.prompt_cache_retention.is_some() {
report.record(
"sampling.prompt_cache_retention",
LossAction::Drop,
"no slot",
);
}
drop_dest_chat_sampling_extras(s, report);
if s.user.is_some() {
report.record("sampling.user", LossAction::Drop, "no slot");
}
if s.verbosity.is_some() {
report.record("sampling.verbosity", LossAction::Drop, "no slot");
}
if s.safety_identifier.is_some() {
report.record("sampling.safety_identifier", LossAction::Drop, "no slot");
}
if !s.metadata.is_empty() {
report.record("sampling.metadata", LossAction::Drop, "no slot");
}
if let Some(tier) = s.service_tier.as_deref() {
let trimmed = tier.trim();
if !trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("unspecified") {
match gemini_service_tier(trimmed) {
Some((mapped, degrade)) => {
body["serviceTier"] = json!(mapped);
if let Some(detail) = degrade {
report.record("sampling.service_tier", LossAction::Degrade, detail);
}
}
None => {
report.record(
"sampling.service_tier",
LossAction::Drop,
"unmapped service_tier",
);
}
}
}
}
if s.previous_response_id.is_some() {
report.record("sampling.previous_response_id", LossAction::Drop, "no slot");
}
if s.cache.enabled {
report.record("sampling.cache", LossAction::Drop, "no slot");
}
if s.stream.is_some() {
report.record("sampling.stream", LossAction::Drop, "no slot");
}
let used_max_as_budget = s.thinking_budget.is_none() && s.max_reasoning_tokens.is_some();
if s.max_reasoning_tokens.is_some() && !used_max_as_budget {
let detail = if s.thinking_budget.is_some() {
"thinking_budget sibling won"
} else {
"no slot"
};
report.record("sampling.max_reasoning_tokens", LossAction::Drop, detail);
}
encode_tool_choice(&s.tool_choice, body);
if s.parallel_tool_calls.is_some() {
report.record("sampling.parallel_tool_calls", LossAction::Drop, "no slot");
}
if !s.include.is_empty() {
report.record("sampling.include", LossAction::Drop, "no slot");
}
}
fn encode_tool_choice(choice: &IrToolChoice, body: &mut Value) {
let fcc = match choice {
IrToolChoice::Auto => return,
IrToolChoice::None => json!({ "mode": "NONE" }),
IrToolChoice::Required => json!({ "mode": "ANY" }),
IrToolChoice::Named(name) => json!({
"mode": "ANY",
"allowedFunctionNames": [name],
}),
};
body["toolConfig"] = json!({ "functionCallingConfig": fcc });
}