use serde_json::{Value, json};
use super::MapError;
use super::drop_dest_chat_sampling_extras;
use super::drop_dest_logprobs;
use super::drop_dest_n_and_penalties;
use super::drop_dest_output_modalities;
use super::drop_dest_top_logprobs;
use super::drop_messages_native_thinking;
use super::drop_responses_request_fields;
use super::str_field;
use super::string_object_field;
use super::tools::PreparedTool;
use crate::ir::{
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(system) = value.get("system") {
decode_system(system, &mut items);
}
if let Some(messages) = value.get("messages").and_then(Value::as_array) {
for msg in messages {
decode_message(msg, &mut items, &mut report)?;
}
}
let model = value
.get("modelId")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let mut sampling = decode_sampling(value, &mut report);
sampling.tool_choice =
decode_tool_choice(value.get("toolConfig").and_then(|c| c.get("toolChoice")));
let tools = value
.get("toolConfig")
.and_then(|c| c.get("tools"))
.and_then(Value::as_array)
.map(|arr| {
let mut tools = Vec::new();
for entry in arr {
if let Some(spec) = entry.get("toolSpec") {
let mut function = json!({
"name": spec.get("name"),
"description": spec.get("description"),
"parameters": spec
.pointer("/inputSchema/json")
.cloned()
.unwrap_or(json!({})),
});
if let Some(strict) = spec.get("strict").and_then(Value::as_bool) {
function["strict"] = json!(strict);
}
tools.push(super::tools::decode_tool(&json!({
"type": "function",
"function": function,
})));
} else if let Some(system_tool) =
entry.get("systemTool").filter(|value| value.is_object())
{
tools.push(crate::ir::IrTool::Unknown {
type_name: "systemTool".into(),
raw: system_tool.clone(),
});
} else if let Some(point) =
entry.get("cachePoint").filter(|value| value.is_object())
{
tools.push(crate::ir::IrTool::CachePoint { raw: point.clone() });
}
}
tools
})
.unwrap_or_default();
Ok((
IrRequest {
model,
items,
tools,
sampling,
},
report,
))
}
fn decode_system(system: &Value, items: &mut Vec<IrItem>) {
match system {
Value::String(text) if !text.is_empty() => {
items.push(IrItem::System {
text: text.clone(),
prompt_cache_breakpoint: None,
content_parts: vec![],
});
}
Value::Array(blocks) => {
for block in blocks {
if let Some(text) = block.get("text").and_then(Value::as_str) {
if !text.is_empty() {
items.push(IrItem::System {
text: text.to_string(),
prompt_cache_breakpoint: None,
content_parts: vec![],
});
}
} else if let Some(point) =
block.get("cachePoint").filter(|value| value.is_object())
{
items.push(IrItem::SystemCache { raw: point.clone() });
} else if let Some(guard) =
block.get("guardContent").filter(|value| value.is_object())
{
items.push(IrItem::SystemGuard { raw: guard.clone() });
}
}
}
_ => {}
}
}
fn decode_message(
msg: &Value,
items: &mut Vec<IrItem>,
report: &mut LossReport,
) -> Result<(), MapError> {
let role = msg.get("role").and_then(Value::as_str).unwrap_or("");
let content = msg.get("content");
match role {
"user" => decode_user(content, items),
"assistant" => decode_assistant(content, items),
"system" => decode_system_role_tool_change(content, items, report),
_ => {
let shown = if role.is_empty() { "missing" } else { role };
report.record(
"message.role",
LossAction::Drop,
format!("{shown} not user or assistant"),
);
Ok(())
}
}
}
fn decode_system_role_tool_change(
content: Option<&Value>,
items: &mut Vec<IrItem>,
report: &mut LossReport,
) -> Result<(), MapError> {
let mut kept = false;
if let Some(blocks) = content.and_then(Value::as_array) {
for block in blocks {
let Some(type_name) = tool_set_change_name(block) else {
continue;
};
items.push(IrItem::Unknown {
type_name: type_name.to_string(),
raw: block.clone(),
});
kept = true;
}
}
if !kept {
report.record(
"message.role",
LossAction::Drop,
"system not user or assistant",
);
}
Ok(())
}
fn tool_set_change_name(block: &Value) -> Option<&'static str> {
let obj = block.as_object()?;
if obj.get("toolAddition").is_some_and(Value::is_object) {
Some("toolAddition")
} else if obj.get("toolRemoval").is_some_and(Value::is_object) {
Some("toolRemoval")
} else {
None
}
}
fn decode_user(content: Option<&Value>, items: &mut Vec<IrItem>) -> Result<(), MapError> {
let Some(arr) = content.and_then(Value::as_array) else {
if let Some(s) = content.and_then(Value::as_str) {
items.push(IrItem::User {
parts: vec![IrPart::Text(s.to_string())],
});
}
return Ok(());
};
let mut parts = Vec::new();
for block in arr {
if let Some(result) = block.get("toolResult") {
flush_user(&mut parts, items);
let id = result
.get("toolUseId")
.and_then(Value::as_str)
.ok_or_else(|| MapError::Invalid("toolResult omitted toolUseId".into()))?;
let (output, parts) = tool_result_output(result);
items.push(IrItem::FunctionOutput {
call_id: id.to_string(),
output,
parts,
is_error: tool_result_status_is_error(result),
});
continue;
}
if let Some(part) = decode_part(block) {
parts.push(part);
}
}
flush_user(&mut parts, items);
Ok(())
}
fn flush_user(parts: &mut Vec<IrPart>, items: &mut Vec<IrItem>) {
if !parts.is_empty() {
items.push(IrItem::User {
parts: std::mem::take(parts),
});
}
}
fn decode_assistant(content: Option<&Value>, items: &mut Vec<IrItem>) -> Result<(), MapError> {
let Some(arr) = content.and_then(Value::as_array) else {
if let Some(s) = content.and_then(Value::as_str) {
items.push(IrItem::Assistant {
parts: vec![IrPart::Text(s.to_string())],
phase: None,
});
}
return Ok(());
};
let mut parts = Vec::new();
for block in arr {
if let Some(tool) = block.get("toolUse") {
flush_assistant(&mut parts, items);
let id = tool
.get("toolUseId")
.and_then(Value::as_str)
.ok_or_else(|| MapError::Invalid("toolUse omitted toolUseId".into()))?;
let name = tool
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let arguments = tool
.get("input")
.map(|v| v.to_string())
.unwrap_or_else(|| "{}".into());
items.push(IrItem::FunctionCall {
call_id: id.to_string(),
name,
arguments,
thought_signature: None,
});
continue;
}
if let Some(part) = decode_part(block) {
parts.push(part);
}
}
flush_assistant(&mut parts, items);
Ok(())
}
fn flush_assistant(parts: &mut Vec<IrPart>, items: &mut Vec<IrItem>) {
if !parts.is_empty() {
items.push(IrItem::Assistant {
parts: std::mem::take(parts),
phase: None,
});
}
}
fn tool_result_status_is_error(result: &Value) -> bool {
result
.get("status")
.and_then(Value::as_str)
.is_some_and(|status| status.trim().eq_ignore_ascii_case("error"))
}
fn tool_result_output(result: &Value) -> (String, Vec<IrPart>) {
let mut parts = Vec::new();
let Some(blocks) = result.get("content").and_then(Value::as_array) else {
return (String::new(), parts);
};
let keep_text_in_order = blocks.iter().any(|block| {
block.get("json").is_some()
|| block.get("image").is_some()
|| block.get("document").is_some()
|| block.get("video").is_some()
|| block.get("searchResult").is_some()
});
let mut texts = Vec::new();
for block in blocks {
if let Some(image) = block.get("image") {
if let Some(part) = decode_image(image) {
parts.push(part);
}
continue;
}
if let Some(doc) = block.get("document") {
if let Some(part) = decode_document(doc) {
parts.push(part);
}
continue;
}
if let Some(video) = block.get("video").filter(|value| value.is_object()) {
parts.push(IrPart::Raw {
type_name: "video".into(),
raw: video.clone(),
});
continue;
}
if let Some(found) = block.get("searchResult").filter(|value| value.is_object()) {
parts.push(IrPart::Raw {
type_name: "searchResult".into(),
raw: found.clone(),
});
continue;
}
if let Some(value) = block.get("json") {
parts.push(IrPart::Raw {
type_name: "json".into(),
raw: value.clone(),
});
continue;
}
if let Some(text) = block.get("text").and_then(Value::as_str) {
if text.is_empty() {
continue;
}
if keep_text_in_order {
parts.push(IrPart::Text(text.to_string()));
} else {
texts.push(text.to_string());
}
}
}
(texts.join(""), parts)
}
fn decode_part(block: &Value) -> Option<IrPart> {
if let Some(text) = block.get("text").and_then(Value::as_str) {
return Some(IrPart::Text(text.to_string()));
}
if let Some(reason) = block
.pointer("/reasoningContent/reasoningText/text")
.and_then(Value::as_str)
{
let signature = block
.pointer("/reasoningContent/reasoningText/signature")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string);
return Some(IrPart::Thinking {
text: reason.to_string(),
signature,
});
}
if let Some(blob) = block
.pointer("/reasoningContent/redactedContent")
.and_then(Value::as_str)
.filter(|blob| !blob.is_empty())
{
return Some(IrPart::Raw {
type_name: "redactedContent".into(),
raw: Value::String(blob.to_string()),
});
}
if let Some(doc) = block.get("document") {
return decode_document(doc);
}
if let Some(image) = block.get("image") {
return decode_image(image);
}
if let Some(audio) = block.get("audio") {
return decode_audio(audio);
}
if let Some(video) = block.get("video").filter(|value| value.is_object()) {
return Some(IrPart::Raw {
type_name: "video".into(),
raw: video.clone(),
});
}
if let Some(point) = block.get("cachePoint").filter(|value| value.is_object()) {
return Some(IrPart::Raw {
type_name: "cachePoint".into(),
raw: point.clone(),
});
}
if let Some(guard) = block.get("guardContent").filter(|value| value.is_object()) {
return Some(IrPart::Raw {
type_name: "guardContent".into(),
raw: guard.clone(),
});
}
if let Some(found) = block.get("searchResult").filter(|value| value.is_object()) {
return Some(IrPart::Raw {
type_name: "searchResult".into(),
raw: found.clone(),
});
}
if let Some(cited) = block
.get("citationsContent")
.filter(|value| value.is_object())
{
return Some(IrPart::Raw {
type_name: "citationsContent".into(),
raw: cited.clone(),
});
}
None
}
fn decode_document(doc: &Value) -> Option<IrPart> {
let format = str_field(doc, "format").unwrap_or_else(|| "pdf".into());
let name = str_field(doc, "name").filter(|s| !s.is_empty());
let source = doc.get("source")?;
let src = if let Some(bytes) = str_field(source, "bytes") {
IrDocumentSource::Base64(bytes)
} else if let Some(uri) = source
.pointer("/s3Location/uri")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
{
let bucket_owner = source
.pointer("/s3Location/bucketOwner")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string);
IrDocumentSource::S3 {
uri: uri.to_string(),
bucket_owner,
}
} else {
return None;
};
Some(IrPart::Document {
source: src,
media_type: super::media_type_from_converse_format(&format),
name,
citations_enabled: doc.pointer("/citations/enabled").and_then(Value::as_bool),
context: str_field(doc, "context").filter(|s| !s.is_empty()),
})
}
fn decode_image(image: &Value) -> Option<IrPart> {
let format = str_field(image, "format").unwrap_or_else(|| "png".into());
let media_type = super::media_type_from_converse_image_format(&format);
let source = image.get("source")?;
if let Some(bytes) = str_field(source, "bytes") {
return Some(IrPart::ImageBase64 {
media_type,
data: bytes,
});
}
if let Some(uri) = source
.pointer("/s3Location/uri")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
{
let bucket_owner = source
.pointer("/s3Location/bucketOwner")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string);
return Some(IrPart::ImageUrl {
url: uri.to_string(),
detail: None,
bucket_owner,
file_id: None,
});
}
None
}
fn decode_audio(audio: &Value) -> Option<IrPart> {
let format = str_field(audio, "format").unwrap_or_else(|| "mp3".into());
let source = audio.get("source")?;
if let Some(bytes) = str_field(source, "bytes") {
return Some(IrPart::Audio {
data: bytes,
format,
});
}
if source
.pointer("/s3Location/uri")
.and_then(Value::as_str)
.is_some_and(|s| !s.is_empty())
{
return Some(IrPart::Raw {
type_name: "audio".into(),
raw: audio.clone(),
});
}
None
}
fn decode_sampling(value: &Value, report: &mut LossReport) -> IrSampling {
let mut sampling = IrSampling::default();
if let Some(cfg) = value.get("inferenceConfig") {
sampling.max_tokens = cfg
.get("maxTokens")
.and_then(Value::as_u64)
.map(|n| n as u32);
sampling.temperature = cfg
.get("temperature")
.and_then(Value::as_f64)
.map(|n| n as f32);
sampling.top_p = cfg.get("topP").and_then(Value::as_f64).map(|n| n as f32);
if let Some(stops) = cfg.get("stopSequences").and_then(Value::as_array) {
sampling.stop = stops
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect();
}
}
sampling.service_tier = value
.pointer("/serviceTier/type")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.map(str::to_string);
if let Some(out) = value.get("outputConfig") {
sampling.reasoning_effort = out
.get("effort")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.map(str::to_string);
let text_format = out.get("textFormat");
let is_json_schema = text_format
.and_then(|tf| tf.get("type"))
.and_then(Value::as_str)
== Some("json_schema");
if is_json_schema
&& let Some(js) = text_format.and_then(|tf| tf.pointer("/structure/jsonSchema"))
{
sampling.json_schema = converse_json_schema(js.get("schema"));
sampling.json_schema_name = js
.get("name")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.map(str::to_string);
sampling.json_schema_description = js
.get("description")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.map(str::to_string);
}
}
sampling.metadata = string_object_field(value, "requestMetadata");
if let Some(raw) = value
.pointer("/performanceConfig/latency")
.and_then(Value::as_str)
{
let latency = raw.trim();
if !latency.is_empty() {
if latency.eq_ignore_ascii_case("standard") || latency.eq_ignore_ascii_case("optimized")
{
sampling.performance_latency = Some(latency.to_ascii_lowercase());
} else {
report.record(
"sampling.performance_latency",
LossAction::Drop,
"unmapped latency",
);
}
}
}
if let Some(paths) = value
.get("additionalModelResponseFieldPaths")
.and_then(Value::as_array)
{
sampling.response_field_paths = paths
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|path| !path.is_empty())
.map(str::to_string)
.collect();
}
if let Some(fields) = value.get("additionalModelRequestFields")
&& !fields.is_null()
{
sampling.additional_request_fields = Some(fields.clone());
}
if let Some(vars) = value.get("promptVariables")
&& vars.is_object()
{
sampling.prompt_variables = Some(vars.clone());
}
if let Some(guard) = value.get("guardrailConfig")
&& guard.is_object()
{
sampling.guardrail = Some(guard.clone());
}
sampling
}
fn converse_json_schema(schema: Option<&Value>) -> Option<Value> {
match schema {
Some(Value::String(raw)) => serde_json::from_str(raw).ok().filter(Value::is_object),
Some(obj) if obj.is_object() => Some(obj.clone()),
_ => None,
}
}
fn decode_tool_choice(value: Option<&Value>) -> IrToolChoice {
let Some(value) = value else {
return IrToolChoice::Auto;
};
if value.get("auto").is_some() {
return IrToolChoice::Auto;
}
if value.get("any").is_some() {
return IrToolChoice::Required;
}
if let Some(name) = value.pointer("/tool/name").and_then(Value::as_str) {
return IrToolChoice::Named(name.to_string());
}
IrToolChoice::Auto
}
pub(super) fn encode(
ir: &IrRequest,
prepared: &[PreparedTool],
report: &mut LossReport,
) -> Result<Value, MapError> {
if !ir.model.is_empty() {
report.record(
"model",
LossAction::Drop,
"modelId is the /model/{modelId}/converse URL, not a JSON field",
);
}
let (system, messages) = encode_items(ir, report);
if messages.as_array().is_none_or(|a| a.is_empty()) {
let dropped: Vec<&str> = report
.events
.iter()
.filter(|event| event.action == LossAction::Drop && event.path != "model")
.map(|event| event.detail.as_str())
.collect();
let message = if dropped.is_empty() {
"converse messages must not be empty".to_string()
} else {
format!(
"converse messages must not be empty: {}",
dropped.join("; ")
)
};
return Err(MapError::Invalid(message));
}
let mut body = json!({ "messages": messages });
if let Some(system) = system {
body["system"] = system;
}
encode_sampling(ir, &mut body, report);
if !prepared.is_empty() {
if matches!(ir.sampling.tool_choice, IrToolChoice::None) {
report.record("sampling.tool_choice", LossAction::Degrade, "no none slot");
} else {
let tools: Vec<Value> = prepared.iter().filter_map(encode_tool).collect();
if !tools.is_empty() {
let mut cfg = json!({ "tools": tools });
encode_tool_choice(&ir.sampling.tool_choice, &mut cfg);
body["toolConfig"] = cfg;
}
}
}
Ok(body)
}
fn encode_items(ir: &IrRequest, report: &mut LossReport) -> (Option<Value>, Value) {
let mut system = Vec::new();
let mut messages = Vec::new();
for (idx, item) in ir.items.iter().enumerate() {
match item {
IrItem::System {
text,
prompt_cache_breakpoint,
content_parts,
}
| IrItem::Developer {
text,
prompt_cache_breakpoint,
content_parts,
} => {
super::note_role_breakpoint(prompt_cache_breakpoint.as_deref(), report);
if !content_parts.is_empty() {
super::note_role_breakpoint(Some("explicit"), report);
}
if text.is_empty() {
continue;
}
if !messages.is_empty() {
report.record(
format!("items[{idx}]"),
LossAction::Degrade,
"system message moved out of the turn list",
);
}
system.push(json!({ "text": text }));
}
IrItem::SystemCache { raw } => {
if !messages.is_empty() {
report.record(
format!("items[{idx}]"),
LossAction::Degrade,
"system message moved out of the turn list",
);
}
system.push(json!({ "cachePoint": raw }));
}
IrItem::SystemGuard { raw } => {
if !messages.is_empty() {
report.record(
format!("items[{idx}]"),
LossAction::Degrade,
"system message moved out of the turn list",
);
}
system.push(json!({ "guardContent": raw }));
}
IrItem::User { parts } => {
let blocks: Vec<Value> = parts
.iter()
.filter_map(|p| encode_part(p, report))
.collect();
if blocks.is_empty() {
continue;
}
let tool_result_tail = last_user_has_tool_result(&messages);
if messages
.last()
.and_then(|message| message.get("role"))
.and_then(Value::as_str)
== Some("user")
&& let Some(last) = messages.last_mut()
&& let Some(arr) = last.get_mut("content").and_then(Value::as_array_mut)
{
if !tool_result_tail {
report.record(
format!("items[{idx}]"),
LossAction::Degrade,
"consecutive turns with the same role were joined",
);
}
arr.extend(blocks);
ensure_document_text(arr, report);
continue;
}
let mut blocks = blocks;
ensure_document_text(&mut blocks, report);
messages.push(json!({ "role": "user", "content": blocks }));
}
IrItem::Assistant { parts, phase } => {
super::drop_assistant_phase(phase, &format!("items[{idx}].phase"), report);
let encoded: Vec<Value> = parts
.iter()
.filter_map(|p| encode_part(p, report))
.collect();
let mut blocks = Vec::new();
let mut media = Vec::new();
for block in encoded {
if block.get("document").is_some() || block.get("image").is_some() {
media.push(block);
} else {
blocks.push(block);
}
}
if !media.is_empty() {
report.record(
format!("items[{idx}]"),
LossAction::Degrade,
"document and image blocks moved to a user turn",
);
}
if !blocks.is_empty() {
if let Some(last) = messages.last_mut()
&& last.get("role").and_then(Value::as_str) == Some("assistant")
&& let Some(arr) = last.get_mut("content").and_then(Value::as_array_mut)
{
arr.extend(blocks);
} else {
messages.push(json!({ "role": "assistant", "content": blocks }));
}
}
if !media.is_empty() {
let tool_result_tail = last_user_has_tool_result(&messages);
let join_user = messages
.last()
.and_then(|message| message.get("role"))
.and_then(Value::as_str)
== Some("user")
&& messages
.last()
.and_then(|message| message.get("content"))
.and_then(Value::as_array)
.is_some();
if join_user {
if !tool_result_tail {
report.record(
format!("items[{idx}]"),
LossAction::Degrade,
"consecutive turns with the same role were joined",
);
}
if let Some(last) = messages.last_mut()
&& let Some(arr) = last.get_mut("content").and_then(Value::as_array_mut)
{
arr.extend(media);
ensure_document_text(arr, report);
}
} else {
ensure_document_text(&mut media, report);
messages.push(json!({ "role": "user", "content": media }));
}
}
}
IrItem::FunctionCall {
call_id,
name,
arguments,
..
} => {
let input = converse_tool_input(arguments, report);
let block = json!({
"toolUse": {
"toolUseId": call_id,
"name": name,
"input": input
}
});
if let Some(last) = messages.last_mut()
&& last.get("role").and_then(Value::as_str) == Some("assistant")
&& let Some(arr) = last.get_mut("content").and_then(Value::as_array_mut)
{
arr.push(block);
continue;
}
messages.push(json!({ "role": "assistant", "content": [block] }));
}
IrItem::CustomToolCall {
call_id,
name,
input: tool_input,
..
} => {
let input = converse_tool_input(tool_input, report);
let block = json!({
"toolUse": {
"toolUseId": call_id,
"name": name,
"input": input
}
});
if let Some(last) = messages.last_mut()
&& last.get("role").and_then(Value::as_str) == Some("assistant")
&& let Some(arr) = last.get_mut("content").and_then(Value::as_array_mut)
{
arr.push(block);
continue;
}
messages.push(json!({ "role": "assistant", "content": [block] }));
}
IrItem::FunctionOutput {
call_id,
output,
parts,
is_error,
} => {
let has_media = parts.iter().any(|part| {
matches!(
part,
IrPart::ImageUrl { .. }
| IrPart::ImageBase64 { .. }
| IrPart::Document { .. }
) || matches!(
part,
IrPart::Raw { type_name, .. }
if type_name == "video"
|| type_name == "searchResult"
|| type_name == "json"
) || super::function_response_image(part).is_some()
});
let ordered_text = parts
.iter()
.any(|part| matches!(part, IrPart::Text(text) if !text.is_empty()));
let text = if output.trim().is_empty() && !has_media && !ordered_text {
report.record(
format!("items[{idx}]"),
LossAction::Degrade,
"empty content became '.'",
);
"."
} else if ordered_text {
""
} else {
output.as_str()
};
let mut content = Vec::new();
if !text.is_empty() {
content.push(json!({ "text": text }));
}
for part in parts {
match part {
IrPart::Text(text) if !text.is_empty() => {
content.push(json!({ "text": text }));
}
IrPart::Text(_) => {}
IrPart::ImageUrl {
url,
detail,
bucket_owner,
file_id,
} => {
if super::drop_image_file_id_without_url(
url,
file_id.as_deref(),
report,
) {
continue;
}
super::note_dropped_image_detail(detail.as_deref(), "converse", report);
if let Some(block) =
encode_image_url(url, bucket_owner.as_deref(), report)
{
content.push(block);
}
}
IrPart::ImageBase64 { media_type, data } => {
if let Some(block) = encode_image_bytes(media_type, data, report) {
content.push(block);
}
}
IrPart::Document {
source,
media_type,
name,
citations_enabled,
context,
} => {
if let Some(block) = encode_document(
source,
media_type,
name.as_deref(),
*citations_enabled,
context.as_deref(),
report,
) {
content.push(block);
}
}
IrPart::Raw { type_name, raw }
if type_name == "video" && raw.is_object() =>
{
content.push(json!({ "video": raw }));
}
IrPart::Raw { type_name, raw }
if type_name == "searchResult" && raw.is_object() =>
{
content.push(json!({ "searchResult": raw }));
}
IrPart::Raw { type_name, raw } if type_name == "json" => {
content.push(json!({ "json": raw }));
}
IrPart::Raw { .. } => {
if let Some((media_type, data)) = super::function_response_image(part) {
if let Some(block) = encode_image_bytes(&media_type, &data, report)
{
content.push(block);
}
} else {
report.record(
format!("items[{idx}].part"),
LossAction::Drop,
"no slot",
);
}
}
IrPart::PromptCacheBreakpoint { .. } => {
if let Some(block) = encode_part(part, report) {
content.push(block);
}
}
_ => {
report.record(format!("items[{idx}].part"), LossAction::Drop, "no slot")
}
}
}
if content.is_empty() {
content.push(json!({ "text": "." }));
}
let mut tool_result = json!({
"toolUseId": call_id,
"content": content
});
if *is_error {
tool_result["status"] = json!("error");
}
let block = json!({ "toolResult": tool_result });
if last_is_user(&messages)
&& let Some(last) = messages.last_mut()
&& let Some(arr) = last.get_mut("content").and_then(Value::as_array_mut)
{
arr.push(block);
continue;
}
messages.push(json!({ "role": "user", "content": [block] }));
}
IrItem::Reasoning { summary, .. } => {
if let Some(text) = summary {
if let Some(last) = messages.last_mut()
&& last.get("role").and_then(Value::as_str) == Some("assistant")
&& let Some(arr) = last.get_mut("content").and_then(Value::as_array_mut)
{
arr.push(json!({
"reasoningContent": { "reasoningText": { "text": text } }
}));
continue;
}
messages.push(json!({
"role": "assistant",
"content": [{
"reasoningContent": { "reasoningText": { "text": text } }
}]
}));
}
}
IrItem::Unknown { type_name, raw }
if matches!(type_name.as_str(), "toolAddition" | "toolRemoval")
&& raw.is_object() =>
{
messages.push(json!({
"role": "system",
"content": [raw.clone()],
}));
}
IrItem::HostedToolCall { kind, .. }
| IrItem::Unknown {
type_name: kind, ..
} => {
report.record(
"messages",
LossAction::Drop,
format!("converse has no slot for {kind}"),
);
}
}
}
let system = if system.is_empty() {
None
} else {
Some(Value::Array(system))
};
(system, Value::Array(messages))
}
fn last_is_user(messages: &[Value]) -> bool {
messages
.last()
.and_then(|m| m.get("role"))
.and_then(Value::as_str)
== Some("user")
}
fn last_user_has_tool_result(messages: &[Value]) -> bool {
if !last_is_user(messages) {
return false;
}
messages
.last()
.and_then(|m| m.get("content"))
.and_then(Value::as_array)
.is_some_and(|arr| arr.iter().any(|b| b.get("toolResult").is_some()))
}
fn encode_part(part: &IrPart, report: &mut LossReport) -> Option<Value> {
if let IrPart::PromptCacheBreakpoint { inner, mode } = part {
let encoded = encode_part(inner, report);
return match encoded {
Some(mut value) => {
super::apply_prompt_cache(&mut value, Some(mode), false, report);
Some(value)
}
None => {
super::apply_prompt_cache(&mut serde_json::Value::Null, Some(mode), false, report);
None
}
};
}
match part {
IrPart::PromptCacheBreakpoint { .. } => unreachable!("handled above"),
IrPart::Text(text) if text.trim().is_empty() => None,
IrPart::Text(text) => Some(json!({ "text": text })),
IrPart::Thinking { text, signature } => {
let mut reasoning_text = json!({ "text": text });
if let Some(sig) = signature.as_deref().filter(|s| !s.is_empty()) {
reasoning_text["signature"] = json!(sig);
}
Some(json!({
"reasoningContent": { "reasoningText": reasoning_text }
}))
}
IrPart::Document {
source,
media_type,
name,
citations_enabled,
context,
} => encode_document(
source,
media_type,
name.as_deref(),
*citations_enabled,
context.as_deref(),
report,
),
IrPart::Audio { data, format } => encode_audio(data, format, report),
IrPart::ImageUrl {
url,
detail,
bucket_owner,
file_id,
} => {
if super::drop_image_file_id_without_url(url, file_id.as_deref(), report) {
return None;
}
super::note_dropped_image_detail(detail.as_deref(), "converse", report);
if let Some((media_type, data)) = super::split_data_url(url) {
return encode_image_bytes(media_type, data, report);
}
encode_image_url(url, bucket_owner.as_deref(), report)
}
IrPart::ImageBase64 { media_type, data } => encode_image_bytes(media_type, data, report),
IrPart::Raw { type_name, raw } if type_name == "video" && raw.is_object() => {
Some(json!({ "video": raw }))
}
IrPart::Raw { type_name, raw } if type_name == "audio" && raw.is_object() => {
Some(json!({ "audio": raw }))
}
IrPart::Raw { type_name, raw } if type_name == "cachePoint" && raw.is_object() => {
Some(json!({ "cachePoint": raw }))
}
IrPart::Raw { type_name, raw } if type_name == "guardContent" && raw.is_object() => {
Some(json!({ "guardContent": raw }))
}
IrPart::Raw { type_name, raw } if type_name == "searchResult" && raw.is_object() => {
Some(json!({ "searchResult": raw }))
}
IrPart::Raw { type_name, raw } if type_name == "citationsContent" && raw.is_object() => {
Some(json!({ "citationsContent": raw }))
}
IrPart::Raw { type_name, raw }
if type_name == "redactedContent"
&& raw.as_str().is_some_and(|blob| !blob.is_empty()) =>
{
Some(json!({
"reasoningContent": { "redactedContent": raw }
}))
}
IrPart::Raw { .. } => {
if let Some(plain) = super::plain_media_part(part) {
report.record("part.media_hint", LossAction::Drop, "no slot");
return encode_part(&plain, report);
}
report.record("content", LossAction::Drop, "converse image/raw dropped");
None
}
}
}
fn encode_image_bytes(media_type: &str, data: &str, report: &mut LossReport) -> Option<Value> {
let Some(format) = super::converse_image_format(media_type) else {
report.record(
"part.image",
LossAction::Drop,
"image format has no converse slot",
);
return None;
};
Some(json!({
"image": {
"format": format,
"source": { "bytes": data }
}
}))
}
fn encode_image_url(
url: &str,
bucket_owner: Option<&str>,
report: &mut LossReport,
) -> Option<Value> {
if !url.to_ascii_lowercase().starts_with("s3://") {
report.record(
"part.image",
LossAction::Drop,
"image url has no converse slot",
);
return None;
}
let format = super::converse_image_format_from_url(url);
let mut location = json!({ "uri": url });
if let Some(owner) = bucket_owner.filter(|s| !s.is_empty()) {
location["bucketOwner"] = json!(owner);
}
Some(json!({
"image": {
"format": format,
"source": { "s3Location": location }
}
}))
}
fn encode_audio(data: &str, format: &str, report: &mut LossReport) -> Option<Value> {
let Some(format) = super::converse_audio_format(format) else {
report.record(
"part.audio",
LossAction::Drop,
"audio format has no converse slot",
);
return None;
};
Some(json!({
"audio": {
"format": format,
"source": { "bytes": data }
}
}))
}
fn converse_document_name(name: Option<&str>, report: &mut LossReport) -> String {
let Some(original) = name.filter(|s| !s.is_empty()) else {
return "document".to_string();
};
let raw = original.trim();
if raw.is_empty() {
report.record(
"part.document.name",
LossAction::Degrade,
"document name had characters Converse rejects",
);
return "document".to_string();
}
let mut out = String::new();
let mut spaced = false;
for ch in raw.chars() {
let keep = ch.is_ascii_alphanumeric() || matches!(ch, '-' | '(' | ')' | '[' | ']');
if keep {
out.push(ch);
spaced = false;
continue;
}
if !spaced && !out.is_empty() {
out.push(' ');
spaced = true;
}
}
let mut cleaned = out.trim().to_string();
if cleaned.chars().count() > 200 {
cleaned = cleaned.chars().take(200).collect();
cleaned = cleaned.trim().to_string();
}
if cleaned.is_empty() {
cleaned = "document".to_string();
}
if cleaned != original {
report.record(
"part.document.name",
LossAction::Degrade,
"document name had characters Converse rejects",
);
}
cleaned
}
fn ensure_document_text(blocks: &mut Vec<Value>, report: &mut LossReport) {
let has_document = blocks.iter().any(|block| block.get("document").is_some());
let has_text = blocks.iter().any(|block| block.get("text").is_some());
if has_document && !has_text {
report.record(
"part.document",
LossAction::Degrade,
"document needs a text prompt",
);
blocks.push(json!({ "text": "See the attached document." }));
}
}
fn encode_document(
source: &IrDocumentSource,
media_type: &str,
name: Option<&str>,
citations_enabled: Option<bool>,
context: Option<&str>,
report: &mut LossReport,
) -> Option<Value> {
if matches!(source, IrDocumentSource::FileId(_)) {
report.record(
"part.document",
LossAction::Drop,
"document file_id has no converse slot",
);
return None;
}
if let IrDocumentSource::Text(data) = source {
if data.trim().is_empty() {
return None;
}
report.record(
"part.document",
LossAction::Degrade,
"plain text document becomes text",
);
return Some(json!({ "text": data }));
}
let Some(format) = super::converse_document_format(media_type) else {
report.record(
"part.document",
LossAction::Drop,
"document format has no converse slot",
);
return None;
};
let name = converse_document_name(name, report);
let mut block = match source {
IrDocumentSource::Text(_) => unreachable!("recorded above"),
IrDocumentSource::Base64(data) => json!({
"document": {
"format": format,
"name": name,
"source": { "bytes": data }
}
}),
IrDocumentSource::Url(url) if url.starts_with("s3://") => json!({
"document": {
"format": format,
"name": name,
"source": { "s3Location": { "uri": url } }
}
}),
IrDocumentSource::S3 { uri, bucket_owner } => {
let mut location = json!({ "uri": uri });
if let Some(owner) = bucket_owner.as_deref().filter(|s| !s.is_empty()) {
location["bucketOwner"] = json!(owner);
}
json!({
"document": {
"format": format,
"name": name,
"source": { "s3Location": location }
}
})
}
IrDocumentSource::Url(_) => {
report.record(
"part.document",
LossAction::Drop,
"document url has no converse slot",
);
return None;
}
IrDocumentSource::FileId(_) => unreachable!("recorded above"),
};
if let Some(doc) = block.get_mut("document") {
if let Some(enabled) = citations_enabled {
doc["citations"] = json!({ "enabled": enabled });
}
if let Some(context) = context.map(str::trim).filter(|s| !s.is_empty()) {
doc["context"] = json!(context);
}
}
Some(block)
}
fn converse_tool_input(arguments: &str, report: &mut LossReport) -> Value {
match serde_json::from_str::<Value>(arguments) {
Ok(value) => value,
Err(_) => {
report.record(
"toolUse.input",
LossAction::Degrade,
"tool arguments are not JSON",
);
json!({ "raw": arguments })
}
}
}
fn encode_tool(tool: &PreparedTool) -> Option<Value> {
match tool {
PreparedTool::Function {
name,
description,
parameters,
strict,
} => {
let mut schema = parameters.clone();
super::messages::normalize_object_schema_required(&mut schema);
if *strict == Some(true) {
super::messages::strict_object_schema(&mut schema);
}
let mut tool_spec = json!({
"name": name,
"description": description,
"inputSchema": { "json": schema }
});
if let Some(strict) = *strict {
tool_spec["strict"] = json!(strict);
}
Some(json!({ "toolSpec": tool_spec }))
}
PreparedTool::Raw(raw) => Some(raw.clone()),
}
}
fn encode_sampling(ir: &IrRequest, body: &mut Value, report: &mut LossReport) {
let s = &ir.sampling;
let mut cfg = serde_json::Map::new();
if let Some(n) = s.max_tokens {
cfg.insert("maxTokens".into(), json!(n));
}
if let Some(t) = s.temperature {
cfg.insert("temperature".into(), super::json_f32(t));
}
if let Some(p) = s.top_p {
cfg.insert("topP".into(), super::json_f32(p));
}
if s.top_k.is_some() {
report.record("sampling.top_k", LossAction::Drop, "no slot");
}
if s.inference_geo.is_some() {
report.record("sampling.inference_geo", 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() {
body["additionalModelResponseFieldPaths"] = json!(s.response_field_paths);
}
if let Some(fields) = &s.additional_request_fields {
body["additionalModelRequestFields"] = fields.clone();
}
if let Some(vars) = &s.prompt_variables {
body["promptVariables"] = vars.clone();
}
if let Some(guard) = &s.guardrail {
body["guardrailConfig"] = guard.clone();
}
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 let Some(latency) = s.performance_latency.as_deref()
&& let Some(root) = body.as_object_mut()
{
let slot = root
.entry("performanceConfig")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if let Some(obj) = slot.as_object_mut() {
obj.insert("latency".into(), json!(latency));
}
}
if s.json_schema_strict.is_some() {
report.record("sampling.json_schema_strict", LossAction::Drop, "no slot");
}
if !s.stop.is_empty() {
cfg.insert("stopSequences".into(), json!(s.stop));
}
if !cfg.is_empty() {
body["inferenceConfig"] = Value::Object(cfg);
}
if s.store.is_some() {
report.record("sampling.store", LossAction::Drop, "no slot");
}
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);
drop_dest_top_logprobs(s, report);
drop_dest_logprobs(s, report);
drop_dest_n_and_penalties(s, report);
drop_dest_output_modalities(s, report);
if let Some(tier) = s.service_tier.as_deref() {
match converse_service_tier(tier) {
Some((mapped, degrade)) => {
body["serviceTier"] = json!({ "type": 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.stream.is_some() {
report.record("sampling.stream", LossAction::Drop, "no slot");
}
if s.cache.enabled {
report.record("sampling.cache", LossAction::Drop, "no slot");
}
let mut output = serde_json::Map::new();
match &s.json_schema {
Some(schema) if schema.is_object() => match serde_json::to_string(schema) {
Ok(schema_str) => {
let mut json_schema = serde_json::Map::new();
json_schema.insert("schema".into(), json!(schema_str));
if let Some(name) = &s.json_schema_name {
json_schema.insert("name".into(), json!(name));
}
if let Some(description) = s
.json_schema_description
.as_deref()
.filter(|text| !text.trim().is_empty())
{
json_schema.insert("description".into(), json!(description));
}
output.insert(
"textFormat".into(),
json!({
"type": "json_schema",
"structure": { "jsonSchema": json_schema },
}),
);
}
Err(_) => {
report.record(
"sampling.json_schema",
LossAction::Drop,
"json_schema requires object schema",
);
if s.json_schema_name.is_some() {
report.record("sampling.json_schema_name", LossAction::Drop, "no slot");
}
}
},
Some(_) => {
report.record(
"sampling.json_schema",
LossAction::Drop,
"json_schema requires object schema",
);
if s.json_schema_name.is_some() {
report.record("sampling.json_schema_name", LossAction::Drop, "no slot");
}
}
None => {
if s.json_schema_name.is_some() {
report.record("sampling.json_schema_name", LossAction::Drop, "no slot");
}
}
}
if let Some(effort) = s.reasoning_effort.as_deref() {
match converse_effort(effort) {
Some((mapped, degrade)) => {
output.insert("effort".into(), json!(mapped));
if let Some(detail) = degrade {
report.record("sampling.reasoning_effort", LossAction::Degrade, detail);
}
}
None => {
report.record(
"sampling.reasoning_effort",
LossAction::Drop,
"unmapped effort",
);
}
}
}
if !output.is_empty() {
body["outputConfig"] = Value::Object(output);
}
if s.json_schema_description
.as_deref()
.is_some_and(|text| !text.trim().is_empty())
&& body
.pointer("/outputConfig/textFormat/structure/jsonSchema/description")
.is_none()
{
report.record(
"sampling.json_schema_description",
LossAction::Drop,
"description needs a json schema",
);
}
if s.max_reasoning_tokens.is_some() {
report.record("sampling.max_reasoning_tokens", LossAction::Drop, "no slot");
}
if s.include_thoughts.is_some() {
report.record("sampling.include_thoughts", LossAction::Drop, "no slot");
}
if s.thinking_budget.is_some() {
report.record("sampling.thinking_budget", LossAction::Drop, "no slot");
}
if s.json_object == Some(true) && s.json_schema.is_none() {
report.record("sampling.json_object", LossAction::Drop, "no slot");
}
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() {
body["requestMetadata"] = json!(s.metadata);
report.record(
"sampling.metadata",
LossAction::Preserve,
"converse requestMetadata",
);
}
if !s.include.is_empty() {
report.record("sampling.include", LossAction::Drop, "no slot");
}
if s.parallel_tool_calls.is_some() {
report.record("sampling.parallel_tool_calls", LossAction::Drop, "no slot");
}
}
pub(crate) fn converse_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" | "reserved" | "default" => Some((lower, None)),
"auto" => Some(("default".into(), Some("auto maps to default"))),
_ => None,
}
}
fn converse_effort(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() {
"low" | "medium" | "high" | "xhigh" | "max" => Some((lower, None)),
"x-high" => Some(("xhigh".into(), Some("x-high maps to xhigh"))),
_ => None,
}
}
fn encode_tool_choice(choice: &IrToolChoice, cfg: &mut Value) {
match choice {
IrToolChoice::Auto => {
cfg["toolChoice"] = json!({ "auto": {} });
}
IrToolChoice::None => {}
IrToolChoice::Required => {
cfg["toolChoice"] = json!({ "any": {} });
}
IrToolChoice::Named(name) => {
cfg["toolChoice"] = json!({ "tool": { "name": name } });
}
}
}