use std::collections::HashMap;
use serde_json::{Value, json};
use super::tools::{PreparedTool, decode_tool};
use super::{
MapError, bool_field, drop_dest_chat_sampling_extras, drop_dest_logprobs,
drop_dest_n_and_penalties, drop_dest_output_modalities, drop_dest_top_logprobs,
drop_responses_request_fields, f32_field, messages_raw_passthrough, off_dialect_raw_path,
stop_values, str_field, u32_field, value_as_string,
};
use crate::ir::{
IrCache, IrDocumentSource, IrItem, IrPart, IrRequest, IrSampling, IrTool, IrToolChoice,
LossAction, LossReport,
};
pub(super) fn decode(value: &Value) -> Result<(IrRequest, LossReport), MapError> {
let mut report = LossReport::default();
let mut items = Vec::new();
decode_system(value.get("system"), &mut items);
if let Some(messages) = value.get("messages").and_then(Value::as_array) {
for msg in messages {
decode_message(msg, &mut items);
}
}
let tools = value
.get("tools")
.and_then(Value::as_array)
.map(|arr| arr.iter().map(decode_tool).collect())
.unwrap_or_default();
let mut sampling = decode_sampling(value, &mut report);
sampling.cache = cache_from(value);
let ir = IrRequest {
model: str_field(value, "model").unwrap_or_default(),
items,
tools,
sampling,
};
Ok((ir, report))
}
fn decode_system(system: Option<&Value>, items: &mut Vec<IrItem>) {
let Some(system) = system else {
return;
};
if let Some(text) = system.as_str() {
items.push(IrItem::System {
text: text.to_string(),
prompt_cache_breakpoint: None,
content_parts: vec![],
});
return;
}
let Some(arr) = system.as_array() else {
return;
};
for block in arr {
let text = block
.get("text")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if !text.is_empty() {
items.push(IrItem::System {
text,
prompt_cache_breakpoint: None,
content_parts: vec![],
});
}
}
}
fn decode_message(msg: &Value, items: &mut Vec<IrItem>) {
let role = msg.get("role").and_then(Value::as_str).unwrap_or("user");
let content = msg.get("content");
match role {
"assistant" => decode_assistant(content, items),
_ => decode_user(content, items),
}
}
fn decode_assistant(content: Option<&Value>, items: &mut Vec<IrItem>) {
if let Some(Value::String(text)) = content {
items.push(IrItem::Assistant {
parts: vec![IrPart::Text(text.clone())],
phase: None,
});
return;
}
let mut parts = Vec::new();
for block in content_blocks(content) {
match block.get("type").and_then(Value::as_str).unwrap_or("text") {
"tool_use" => {
flush_assistant(&mut parts, items);
items.push(IrItem::FunctionCall {
call_id: str_field(block, "id").unwrap_or_default(),
name: str_field(block, "name").unwrap_or_default(),
arguments: block
.get("input")
.map(value_as_string)
.unwrap_or_else(|| "{}".into()),
thought_signature: None,
});
}
"thinking" => parts.push(IrPart::Thinking {
text: block
.get("thinking")
.or_else(|| block.get("text"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
signature: str_field(block, "signature"),
}),
"redacted_thinking" => parts.push(IrPart::Raw {
type_name: "redacted_thinking".into(),
raw: block.clone(),
}),
_ => {
if let Some(part) = decode_content_part(block) {
parts.push(part);
}
}
}
}
flush_assistant(&mut parts, items);
}
fn decode_user(content: Option<&Value>, items: &mut Vec<IrItem>) {
if let Some(Value::String(text)) = content {
items.push(IrItem::User {
parts: vec![IrPart::Text(text.clone())],
});
return;
}
let mut parts = Vec::new();
for block in content_blocks(content) {
match block.get("type").and_then(Value::as_str).unwrap_or("text") {
"tool_result" => {
flush_user(&mut parts, items);
let (output, result_parts) = decode_tool_result(block);
items.push(IrItem::FunctionOutput {
call_id: str_field(block, "tool_use_id").unwrap_or_default(),
output,
parts: result_parts,
is_error: block
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false),
});
}
_ => {
if let Some(part) = decode_content_part(block) {
parts.push(part);
}
}
}
}
flush_user(&mut parts, items);
}
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 flush_user(parts: &mut Vec<IrPart>, items: &mut Vec<IrItem>) {
if !parts.is_empty() {
items.push(IrItem::User {
parts: std::mem::take(parts),
});
}
}
fn content_blocks(content: Option<&Value>) -> Vec<&Value> {
match content {
Some(Value::Array(arr)) => arr.iter().collect(),
_ => Vec::new(),
}
}
fn decode_content_part(block: &Value) -> Option<IrPart> {
if let Some(text) = block.as_str() {
return Some(IrPart::Text(text.to_string()));
}
let type_name = block.get("type").and_then(Value::as_str).unwrap_or("text");
match type_name {
"text" => block
.get("text")
.and_then(Value::as_str)
.map(|t| IrPart::Text(t.to_string())),
"image" => decode_image(block).or_else(|| messages_file_image(block)),
"document" => decode_document(block).or_else(|| {
Some(IrPart::Raw {
type_name: "document".into(),
raw: block.clone(),
})
}),
_ => block
.get("text")
.and_then(Value::as_str)
.map(|t| IrPart::Text(t.to_string()))
.or_else(|| {
Some(IrPart::Raw {
type_name: type_name.to_string(),
raw: block.clone(),
})
}),
}
}
fn messages_file_image(block: &Value) -> Option<IrPart> {
let source = block.get("source")?;
if source.get("type").and_then(Value::as_str) != Some("file") {
return None;
}
let id = str_field(source, "file_id")?;
if id.is_empty() {
return None;
}
Some(IrPart::Raw {
type_name: "image".into(),
raw: block.clone(),
})
}
fn decode_image(block: &Value) -> Option<IrPart> {
let source = block.get("source")?;
match source.get("type").and_then(Value::as_str) {
Some("url") => str_field(source, "url").map(|url| IrPart::ImageUrl {
url,
detail: None,
bucket_owner: None,
file_id: None,
}),
Some("base64") => Some(IrPart::ImageBase64 {
media_type: str_field(source, "media_type").unwrap_or_else(|| "image/png".into()),
data: str_field(source, "data").unwrap_or_default(),
}),
_ => str_field(source, "url").map(|url| IrPart::ImageUrl {
url,
detail: None,
bucket_owner: None,
file_id: None,
}),
}
}
fn decode_document(block: &Value) -> Option<IrPart> {
let source = block.get("source")?;
let media_type = str_field(source, "media_type")
.or_else(|| str_field(block, "media_type"))
.unwrap_or_default();
let name = str_field(block, "title")
.or_else(|| str_field(block, "name"))
.filter(|s| !s.is_empty());
let citations_enabled = block.pointer("/citations/enabled").and_then(Value::as_bool);
let context = str_field(block, "context").filter(|s| !s.is_empty());
let src = match source.get("type").and_then(Value::as_str) {
Some("text") => IrDocumentSource::Text(str_field(source, "data")?),
Some("url") => IrDocumentSource::Url(str_field(source, "url")?),
Some("file") => IrDocumentSource::FileId(
str_field(source, "file_id").or_else(|| str_field(source, "id"))?,
),
Some("base64") => IrDocumentSource::Base64(str_field(source, "data")?),
_ => {
if let Some(data) = str_field(source, "data") {
IrDocumentSource::Base64(data)
} else if let Some(url) = str_field(source, "url") {
IrDocumentSource::Url(url)
} else if let Some(id) = str_field(source, "file_id") {
IrDocumentSource::FileId(id)
} else {
return None;
}
}
};
let media_type = if !media_type.is_empty() {
media_type
} else if matches!(src, IrDocumentSource::Text(_)) {
"text/plain".into()
} else {
"application/pdf".into()
};
Some(IrPart::Document {
source: src,
media_type,
name,
citations_enabled,
context,
})
}
fn decode_tool_result(block: &Value) -> (String, Vec<IrPart>) {
let Some(content) = block.get("content").or_else(|| block.get("text")) else {
return (String::new(), Vec::new());
};
if let Some(text) = content.as_str() {
return (text.to_string(), Vec::new());
}
let Some(arr) = content.as_array() else {
return (value_as_string(content), Vec::new());
};
let decoded: Vec<IrPart> = arr
.iter()
.filter_map(decode_content_part)
.filter(|part| !matches!(part, IrPart::Text(text) if text.is_empty()))
.collect();
let keep_text_in_order = decoded.iter().any(|part| !matches!(part, IrPart::Text(_)));
if keep_text_in_order {
return (String::new(), decoded);
}
let text = decoded
.into_iter()
.filter_map(|part| match part {
IrPart::Text(text) => Some(text),
_ => None,
})
.collect::<Vec<_>>()
.join("");
(text, Vec::new())
}
fn decode_sampling(value: &Value, report: &mut LossReport) -> IrSampling {
let (json_schema, json_schema_name) = decode_output_config_schema(value);
if messages_source_has_json_schema(value) && json_schema.is_none() {
report.record("sampling.json_schema", LossAction::Drop, "no slot");
}
let (include_thoughts, max_reasoning_tokens) = decode_thinking(value, report);
IrSampling {
temperature: f32_field(value, "temperature"),
top_p: f32_field(value, "top_p"),
top_k: u32_field(value, "top_k"),
max_tokens: u32_field(value, "max_tokens"),
stop: stop_values(value, &["stop_sequences", "stop"]),
tool_choice: decode_tool_choice(value.get("tool_choice")),
parallel_tool_calls: decode_parallel_tool_calls(value),
store: bool_field(value, "store"),
previous_response_id: str_field(value, "previous_response_id"),
cache: IrCache::default(),
stream: bool_field(value, "stream"),
include_thoughts,
thinking_budget: None,
reasoning_effort: value
.pointer("/output_config/effort")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string),
max_reasoning_tokens,
json_schema,
json_schema_name,
json_schema_description: None,
json_schema_strict: None,
json_object: None,
include: Vec::new(),
prompt_cache_key: None,
prompt_cache_retention: None,
prompt_cache_mode: None,
prompt_cache_ttl: None,
top_logprobs: None,
moderation_model: None,
moderation_input: None,
moderation_output: None,
include_obfuscation: None,
service_tier: str_field(value, "service_tier")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
user: value
.pointer("/metadata/user_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string),
verbosity: None,
safety_identifier: None,
metadata: std::collections::BTreeMap::new(),
frequency_penalty: None,
presence_penalty: None,
seed: None,
n: None,
output_modalities: Vec::new(),
audio_voice: None,
audio_format: None,
logprobs: None,
logit_bias: std::collections::BTreeMap::new(),
prediction: None,
web_search_options: None,
inference_geo: str_field(value, "inference_geo")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
performance_latency: None,
previous_message_id: value
.pointer("/diagnostics/previous_message_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string),
response_field_paths: Vec::new(),
additional_request_fields: None,
prompt_variables: None,
guardrail: None,
container: value
.get("container")
.filter(|container| container.is_string() || container.is_object())
.cloned(),
context_management: value
.get("context_management")
.filter(|mgmt| mgmt.is_object())
.cloned(),
mcp_servers: value
.get("mcp_servers")
.filter(|servers| servers.is_array())
.cloned(),
media_resolution: None,
image_config: None,
audio_transcription_config: None,
translation_config: None,
affective_dialog: None,
enhanced_civic_answers: None,
multi_speaker_speech: None,
cached_content: None,
safety_settings: None,
request_labels: None,
truncation: None,
max_tool_calls: None,
background: None,
conversation: None,
responses_context_management: None,
messages_thinking: messages_native_thinking(value),
}
}
fn messages_native_thinking(value: &Value) -> Option<Value> {
let thinking = value.get("thinking")?;
let kind = thinking.get("type").and_then(Value::as_str)?.trim();
if kind == "adaptive" || kind == "between_tools" {
Some(thinking.clone())
} else {
None
}
}
fn decode_output_config_schema(value: &Value) -> (Option<Value>, Option<String>) {
let Some(format) = value.pointer("/output_config/format") else {
return (None, None);
};
if format.get("type").and_then(Value::as_str) != Some("json_schema") {
return (None, None);
}
match format.get("schema").filter(|v| v.is_object()).cloned() {
Some(schema) => (Some(schema), Some("response".into())),
None => (None, None),
}
}
fn decode_thinking(value: &Value, report: &mut LossReport) -> (Option<bool>, Option<u32>) {
let Some(thinking) = value.get("thinking") else {
return (None, None);
};
match thinking.get("type").and_then(Value::as_str).map(str::trim) {
Some("disabled") => (Some(false), None),
Some("enabled") => (Some(true), u32_field(thinking, "budget_tokens")),
Some("adaptive") | Some("between_tools") => (None, None),
_ => {
report.record("sampling.thinking", LossAction::Drop, "unknown thinking");
(None, None)
}
}
}
fn messages_source_has_json_schema(value: &Value) -> bool {
value.get("output_format").is_some()
|| value.get("response_format").is_some()
|| value.get("json_schema").is_some()
|| value.pointer("/text/format/type").and_then(Value::as_str) == Some("json_schema")
|| value.pointer("/output_config/format").is_some()
}
fn decode_parallel_tool_calls(value: &Value) -> Option<bool> {
if let Some(disable) = value
.get("tool_choice")
.filter(|choice| choice.is_object())
.and_then(|choice| choice.get("disable_parallel_tool_use"))
.and_then(Value::as_bool)
{
return Some(!disable);
}
bool_field(value, "parallel_tool_calls")
}
fn decode_tool_choice(value: Option<&Value>) -> IrToolChoice {
let Some(value) = value else {
return IrToolChoice::Auto;
};
if let Some(s) = value.as_str() {
return match s {
"none" => IrToolChoice::None,
"any" | "required" => IrToolChoice::Required,
_ => IrToolChoice::Auto,
};
}
match value.get("type").and_then(Value::as_str) {
Some("none") => IrToolChoice::None,
Some("any") | Some("required") => IrToolChoice::Required,
Some("tool") => IrToolChoice::Named(str_field(value, "name").unwrap_or_default()),
_ => {
if let Some(name) = str_field(value, "name") {
IrToolChoice::Named(name)
} else {
IrToolChoice::Auto
}
}
}
}
fn cache_from(value: &Value) -> IrCache {
let mut found = None;
visit_cache(value, &mut found);
match found {
Some(ttl) => IrCache {
enabled: true,
retention: ttl,
min_cacheable_tokens: None,
},
None => IrCache::default(),
}
}
fn visit_cache(value: &Value, found: &mut Option<Option<String>>) {
if found.is_some() {
return;
}
match value {
Value::Object(map) => {
if let Some(cc) = map.get("cache_control") {
let ttl = cc.get("ttl").and_then(Value::as_str).map(str::to_string);
*found = Some(ttl);
return;
}
for child in map.values() {
visit_cache(child, found);
}
}
Value::Array(arr) => {
for child in arr {
visit_cache(child, found);
}
}
_ => {}
}
}
pub(super) fn encode(
ir: &IrRequest,
tools: &[PreparedTool],
report: &mut LossReport,
) -> Result<Value, MapError> {
let mut tool_ids = HashMap::new();
let (system, messages) = encode_items(ir, &mut tool_ids, report);
if messages.as_array().is_none_or(|rows| rows.is_empty()) {
if system.is_some() {
return Err(MapError::Invalid(
"messages must not be empty: system text needs a message turn".into(),
));
}
let dropped = super::dropped_details(report);
if !dropped.is_empty() {
return Err(MapError::Invalid(format!(
"messages must not be empty: {}",
dropped.join("; ")
)));
}
}
if ir.sampling.max_tokens == Some(0) {
return Err(MapError::hard(
"sampling.max_tokens",
"max_tokens must be at least 1",
));
}
super::reject_sampling_number(
ir.sampling.temperature,
0.0,
1.0,
"sampling.temperature",
"temperature must be between 0 and 1",
)?;
super::reject_sampling_number(
ir.sampling.top_p,
0.0,
1.0,
"sampling.top_p",
"top_p must be between 0 and 1",
)?;
let mut body = json!({
"model": ir.model,
"messages": messages,
});
if let Some(system) = system {
body["system"] = system;
}
if !tools.is_empty() {
body["tools"] = Value::Array(tools.iter().map(encode_tool).collect());
}
encode_sampling(ir, &mut body, report);
apply_cache_breakpoints(&mut body, ir, report);
Ok(body)
}
fn encode_items(
ir: &IrRequest,
tool_ids: &mut HashMap<String, String>,
report: &mut LossReport,
) -> (Option<Value>, Value) {
let mut system_blocks = Vec::new();
let mut messages = Vec::new();
let mut idx = 0;
while idx < ir.items.len() {
match &ir.items[idx] {
IrItem::System {
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 !messages.is_empty() {
report.record(
format!("items[{idx}]"),
LossAction::Degrade,
"system message moved out of the turn list",
);
}
system_blocks.push(text_block(
&super::system_role_text(text, content_parts, report),
false,
None,
));
idx += 1;
}
IrItem::SystemCache { .. } => {
report.record(
format!("items[{idx}]"),
LossAction::Drop,
"system cache point has no slot",
);
idx += 1;
}
IrItem::SystemGuard { .. } => {
report.record(
format!("items[{idx}]"),
LossAction::Drop,
"system guard content has no slot",
);
idx += 1;
}
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);
}
report.record(
format!("items[{idx}]"),
LossAction::Degrade,
"developer to system",
);
system_blocks.push(text_block(
&super::system_role_text(text, content_parts, report),
false,
None,
));
idx += 1;
}
IrItem::User { parts } => {
let (msg, consumed) = encode_user(ir, idx, parts, tool_ids, report);
push_alternating_message(&mut messages, msg, idx, report);
idx += consumed;
}
IrItem::Assistant { parts, phase } => {
super::drop_assistant_phase(phase, &format!("items[{idx}].phase"), report);
let (msg, consumed) = encode_assistant(ir, idx, parts, tool_ids, report);
messages.push(msg);
idx += consumed;
}
IrItem::FunctionCall {
call_id,
name,
arguments,
thought_signature,
} => {
if thought_signature.is_some() {
report.record(
format!("items[{idx}]"),
LossAction::Drop,
"thoughtSignature has no Messages slot",
);
}
messages.push(json!({
"role": "assistant",
"content": [tool_use_block(
call_id,
name,
arguments,
format!("items[{idx}]"),
tool_ids,
report,
)],
}));
idx += 1;
}
IrItem::CustomToolCall {
call_id,
name,
input,
..
} => {
messages.push(json!({
"role": "assistant",
"content": [tool_use_block(
call_id,
name,
input,
format!("items[{idx}]"),
tool_ids,
report,
)],
}));
idx += 1;
}
IrItem::FunctionOutput {
call_id,
output,
parts,
is_error,
} => {
messages.push(json!({
"role": "user",
"content": [tool_result_block(
call_id,
output,
parts,
*is_error,
format!("items[{idx}]"),
tool_ids,
report,
)],
}));
idx += 1;
}
IrItem::Reasoning {
encrypted,
summary,
raw,
} => {
idx += encode_reasoning(
&mut messages,
encrypted.as_deref(),
summary.as_deref(),
raw.as_ref(),
idx,
report,
);
}
IrItem::HostedToolCall { kind, raw } => {
report.record(
format!("items[{idx}]"),
LossAction::Preserve,
format!("hosted item `{kind}` passthrough"),
);
messages.push(raw.clone());
idx += 1;
}
IrItem::Unknown { type_name, .. }
if matches!(type_name.as_str(), "toolAddition" | "toolRemoval") =>
{
report.record(
format!("items[{idx}]"),
LossAction::Drop,
"tool addition or removal has no slot",
);
idx += 1;
}
IrItem::Unknown { raw, .. } => {
messages.push(raw.clone());
idx += 1;
}
}
}
if messages_need_continue(ir) {
messages.push(json!({
"role": "user",
"content": [text_block("Continue.", false, None)],
}));
}
let system = if system_blocks.is_empty() {
None
} else if system_blocks.len() == 1
&& system_blocks[0].get("cache_control").is_none()
&& let Some(text) = system_blocks[0].get("text").cloned()
{
Some(text)
} else {
Some(Value::Array(system_blocks))
};
(system, Value::Array(messages))
}
fn push_alternating_message(
messages: &mut Vec<Value>,
msg: Value,
idx: usize,
report: &mut LossReport,
) {
if let Some(last) = messages.last_mut()
&& last.get("role") == msg.get("role")
&& matches!(
last.get("role").and_then(Value::as_str),
Some("user" | "assistant")
)
&& let (Some(dst), Some(src)) = (
last.get_mut("content").and_then(Value::as_array_mut),
msg.get("content").and_then(Value::as_array),
)
{
report.record(
format!("items[{idx}]"),
LossAction::Degrade,
"consecutive turns with the same role were joined",
);
dst.extend(src.iter().cloned());
return;
}
messages.push(msg);
}
fn messages_need_continue(ir: &IrRequest) -> bool {
matches!(
ir.items.last(),
Some(
IrItem::Assistant { .. } | IrItem::FunctionCall { .. } | IrItem::CustomToolCall { .. },
)
)
}
fn encode_user(
ir: &IrRequest,
start: usize,
parts: &[IrPart],
tool_ids: &mut HashMap<String, String>,
report: &mut LossReport,
) -> (Value, usize) {
let mut consumed = 1;
let mut content = encode_user_parts(parts, &format!("items[{start}]"), report);
while let Some(IrItem::FunctionOutput {
call_id,
output,
parts,
is_error,
}) = ir.items.get(start + consumed)
{
content.push(tool_result_block(
call_id,
output,
parts,
*is_error,
format!("items[{}]", start + consumed),
tool_ids,
report,
));
consumed += 1;
}
(
json!({
"role": "user",
"content": content,
}),
consumed,
)
}
fn encode_assistant(
ir: &IrRequest,
start: usize,
parts: &[IrPart],
tool_ids: &mut HashMap<String, String>,
report: &mut LossReport,
) -> (Value, usize) {
let mut consumed = 1;
let mut content = encode_assistant_parts(parts, report);
loop {
match ir.items.get(start + consumed) {
Some(IrItem::FunctionCall {
call_id,
name,
arguments,
thought_signature,
}) => {
if thought_signature.is_some() {
report.record(
format!("items[{}]", start + consumed),
LossAction::Drop,
"thoughtSignature has no Messages slot",
);
}
content.push(tool_use_block(
call_id,
name,
arguments,
format!("items[{}]", start + consumed),
tool_ids,
report,
));
consumed += 1;
}
Some(IrItem::CustomToolCall {
call_id,
name,
input,
..
}) => {
content.push(tool_use_block(
call_id,
name,
input,
format!("items[{}]", start + consumed),
tool_ids,
report,
));
consumed += 1;
}
Some(IrItem::Reasoning {
encrypted,
summary,
raw,
}) => {
if let Some(block) =
reasoning_block(encrypted.as_deref(), summary.as_deref(), raw.as_ref())
{
content.push(block);
report.record(
format!("items[{}]", start + consumed),
LossAction::Preserve,
"reasoning as thinking",
);
} else {
report.record(
format!("items[{}]", start + consumed),
LossAction::Degrade,
"reasoning omitted (no thinking)",
);
}
consumed += 1;
}
_ => break,
}
}
if content.is_empty() {
report.record(
format!("items[{start}]"),
LossAction::Degrade,
"empty content became '.'",
);
content.push(json!({"type": "text", "text": "."}));
}
(
json!({
"role": "assistant",
"content": content,
}),
consumed,
)
}
fn encode_reasoning(
messages: &mut Vec<Value>,
encrypted: Option<&str>,
summary: Option<&str>,
raw: Option<&Value>,
idx: usize,
report: &mut LossReport,
) -> usize {
if let Some(block) = reasoning_block(encrypted, summary, raw) {
messages.push(json!({
"role": "assistant",
"content": [block],
}));
report.record(
format!("items[{idx}]"),
LossAction::Preserve,
"reasoning as thinking",
);
} else {
report.record(
format!("items[{idx}]"),
LossAction::Degrade,
"reasoning omitted (no thinking)",
);
}
1
}
fn reasoning_block(
encrypted: Option<&str>,
summary: Option<&str>,
raw: Option<&Value>,
) -> Option<Value> {
if let Some(raw) = raw
&& raw.get("type").and_then(Value::as_str) == Some("thinking")
{
return Some(raw.clone());
}
let text = summary
.map(str::to_string)
.or_else(|| raw.and_then(|v| str_field(v, "summary")));
let text = text?;
let mut block = json!({
"type": "thinking",
"thinking": text,
});
if let Some(sig) = encrypted {
block["signature"] = json!(sig);
}
Some(block)
}
fn encode_user_parts(parts: &[IrPart], path: &str, report: &mut LossReport) -> Vec<Value> {
let out: Vec<Value> = parts
.iter()
.filter_map(|part| encode_part(part, report))
.collect();
if out.is_empty() {
report.record(path, LossAction::Degrade, "empty content became '.'");
vec![json!({"type": "text", "text": "."})]
} else {
out
}
}
fn encode_assistant_parts(parts: &[IrPart], report: &mut LossReport) -> Vec<Value> {
parts
.iter()
.filter_map(|part| encode_part(part, report))
.collect()
}
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!({"type": "text", "text": text})),
IrPart::ImageUrl {
url,
detail,
bucket_owner: _,
file_id,
} => {
if url.trim().is_empty() {
if file_id.as_deref().is_some_and(|id| !id.trim().is_empty()) {
report.record("part.image.file_id", LossAction::Drop, "no slot");
} else {
report.record("part.image", LossAction::Drop, "image url is empty");
}
return None;
}
if super::drop_image_file_id_without_url(url, file_id.as_deref(), report) {
return None;
}
super::note_dropped_image_detail(detail.as_deref(), "messages", report);
if let Some((media_type, data)) = super::split_data_url(url) {
if data.trim().is_empty() {
report.record("part.image", LossAction::Drop, "image data is empty");
return None;
}
return Some(json!({
"type": "image",
"source": {"type": "base64", "media_type": media_type, "data": data}
}));
}
Some(json!({
"type": "image",
"source": {"type": "url", "url": url}
}))
}
IrPart::ImageBase64 { media_type, data } => {
if data.trim().is_empty() {
report.record("part.image", LossAction::Drop, "image data is empty");
None
} else {
Some(json!({
"type": "image",
"source": {"type": "base64", "media_type": media_type, "data": data}
}))
}
}
IrPart::Document {
source: IrDocumentSource::Base64(data),
..
} if data.trim().is_empty() => {
report.record("part.document", LossAction::Drop, "document data is empty");
None
}
IrPart::Document {
source: IrDocumentSource::FileId(id),
..
} if id.trim().is_empty() => {
report.record("part.document", LossAction::Drop, "file id is empty");
None
}
IrPart::Document {
source: IrDocumentSource::S3 { .. },
..
} => {
report.record(
"part.document",
LossAction::Drop,
"S3 document has no Messages slot",
);
None
}
IrPart::Document {
source,
media_type,
name,
citations_enabled,
context,
} => {
let mut block = encode_document(source, media_type, name.as_deref());
if let Some(enabled) = citations_enabled {
block["citations"] = json!({ "enabled": enabled });
}
if let Some(context) = context.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
block["context"] = json!(context);
}
Some(block)
}
IrPart::Audio { .. } => {
report.record("part.audio", LossAction::Drop, "audio has no Messages slot");
None
}
IrPart::Thinking { text, signature } => {
let Some(sig) = signature.as_deref().filter(|s| !s.is_empty()) else {
report.record(
"part.thinking",
LossAction::Drop,
"unsigned thinking is not replayed",
);
return None;
};
Some(json!({
"type": "thinking",
"thinking": text,
"signature": sig,
}))
}
IrPart::Raw { raw, .. } => {
if let Some(plain) = super::plain_media_part(part) {
report.record("part.media_hint", LossAction::Drop, "no slot");
return encode_part(&plain, report);
}
if let Some((media_type, data)) = super::function_response_image(part) {
return Some(json!({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": data
}
}));
}
if messages_raw_passthrough(raw) {
Some(raw.clone())
} else {
report.record(
off_dialect_raw_path(raw),
LossAction::Drop,
"raw part is not Messages-shaped",
);
None
}
}
}
}
fn encode_document(source: &IrDocumentSource, media_type: &str, name: Option<&str>) -> Value {
let mut block = json!({"type": "document"});
if let Some(name) = name.map(str::trim).filter(|s| !s.is_empty()) {
block["title"] = json!(name);
}
block["source"] = match source {
IrDocumentSource::Base64(data) => json!({
"type": "base64",
"media_type": if media_type.is_empty() {
"application/pdf"
} else {
media_type
},
"data": data
}),
IrDocumentSource::Text(data) => json!({
"type": "text",
"media_type": if media_type.is_empty() {
"text/plain"
} else {
media_type
},
"data": data
}),
IrDocumentSource::Url(url) | IrDocumentSource::S3 { uri: url, .. } => json!({
"type": "url",
"url": url
}),
IrDocumentSource::FileId(id) => json!({
"type": "file",
"file_id": id
}),
};
block
}
fn text_block(text: &str, cache: bool, retention: Option<&str>) -> Value {
let mut block = json!({"type": "text", "text": text});
if cache {
let mut cc = json!({"type": "ephemeral"});
if let Some(ttl) = retention {
cc["ttl"] = json!(ttl);
}
block["cache_control"] = cc;
}
block
}
pub(crate) fn cache_floor_tokens(req: &IrRequest) -> u32 {
let mut chars = 0usize;
for item in &req.items {
match item {
IrItem::System { text, .. } | IrItem::Developer { text, .. } => chars += text.len(),
IrItem::SystemCache { .. } | IrItem::SystemGuard { .. } => {}
IrItem::User { parts } | IrItem::Assistant { parts, .. } => {
chars += parts.iter().map(messages_part_chars).sum::<usize>();
}
IrItem::FunctionCall {
name, arguments, ..
} => {
chars += name.len() + arguments.len();
}
IrItem::CustomToolCall { name, input, .. } => {
chars += name.len() + input.len();
}
IrItem::FunctionOutput { output, parts, .. } => {
chars += output.len();
chars += parts.iter().map(messages_part_chars).sum::<usize>();
}
IrItem::Reasoning { summary, raw, .. } => {
chars += reasoning_prompt_chars(summary.as_deref(), raw.as_ref());
}
IrItem::HostedToolCall { .. } | IrItem::Unknown { .. } => {}
}
}
for tool in &req.tools {
if let IrTool::Function {
name,
description,
parameters,
..
} = tool
{
chars += name.len() + description.len() + parameters.to_string().len();
}
}
(chars / 4) as u32
}
fn messages_part_chars(part: &IrPart) -> usize {
if let IrPart::PromptCacheBreakpoint { inner, .. } = part {
return messages_part_chars(inner);
}
match part {
IrPart::PromptCacheBreakpoint { .. } => unreachable!("handled above"),
IrPart::Text(text) if text.trim().is_empty() => 0,
IrPart::Text(text) => text.len(),
IrPart::ImageUrl { url, file_id, .. } => {
if super::image_file_id_without_url(url, file_id.as_deref()) {
0
} else {
url.len().max(MESSAGES_REMOTE_SOURCE_CHARS)
}
}
IrPart::ImageBase64 { data, .. } => data.len(),
IrPart::Document {
source,
name,
context,
..
} => document_written_chars(source, name.as_deref(), context.as_deref()),
IrPart::Audio { .. } => 0,
IrPart::Thinking { text, signature } => {
if signature.as_deref().is_some_and(|sig| !sig.is_empty()) {
text.len()
} else {
0
}
}
IrPart::Raw { type_name, raw } if type_name == "json" => raw.to_string().len(),
IrPart::Raw { .. } => super::raw_prompt_chars(part),
}
}
fn document_written_chars(
source: &IrDocumentSource,
name: Option<&str>,
context: Option<&str>,
) -> usize {
let payload = match source {
IrDocumentSource::Base64(data) | IrDocumentSource::Text(data) => data.len(),
IrDocumentSource::Url(data)
| IrDocumentSource::FileId(data)
| IrDocumentSource::S3 { uri: data, .. } => data.len().max(MESSAGES_REMOTE_SOURCE_CHARS),
};
payload + trimmed_nonempty_len(name) + trimmed_nonempty_len(context)
}
fn trimmed_nonempty_len(value: Option<&str>) -> usize {
value
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::len)
.unwrap_or(0)
}
fn reasoning_prompt_chars(summary: Option<&str>, raw: Option<&Value>) -> usize {
if let Some(raw) = raw
&& raw.get("type").and_then(Value::as_str) == Some("thinking")
{
return raw
.get("thinking")
.and_then(Value::as_str)
.map(str::len)
.unwrap_or(0);
}
if let Some(summary) = summary {
return summary.len();
}
raw.and_then(|value| value.get("summary"))
.and_then(Value::as_str)
.map(str::len)
.unwrap_or(0)
}
pub(super) const MESSAGES_REMOTE_SOURCE_CHARS: usize = 4096;
const ANTHROPIC_MAX_CACHE_CONTROL_BLOCKS: usize = 4;
const ANTHROPIC_PREFERRED_CACHE_CONTROL_BLOCKS: usize = 2;
fn apply_cache_breakpoints(body: &mut Value, ir: &IrRequest, report: &mut LossReport) {
let cache = &ir.sampling.cache;
if !cache.enabled || cache.retention.as_deref() == Some("none") {
return;
}
if let Some(floor) = cache.min_cacheable_tokens
&& floor > 0
{
let estimated = cache_floor_tokens(ir);
if estimated < floor {
report.record(
"sampling.cache",
LossAction::Drop,
format!("below min_cacheable_tokens floor {floor}"),
);
return;
}
}
let ttl = match cache.retention.as_deref() {
Some("1h") | Some("long") => Some("1h"),
Some("5m") | Some("short") | None => None,
Some(other) => Some(other),
};
strip_all_cache_control(body);
apply_preferred_cache_breakpoints(body, ttl);
enforce_cache_control_limit(body, ttl);
if count_cache_control(body) == 0 {
report.record("sampling.cache", LossAction::Drop, "no cacheable block");
return;
}
report.record("sampling.cache", LossAction::Preserve, "messages cache");
}
fn apply_preferred_cache_breakpoints(body: &mut Value, ttl: Option<&str>) {
let has_tools = body
.get("tools")
.and_then(Value::as_array)
.is_some_and(|t| !t.is_empty());
if ttl.is_some() {
if has_tools {
tag_last_tool(body, ttl);
if !tag_first_system(body, ttl) {
tag_first_user_text(body, ttl);
}
return;
}
if !tag_first_system(body, ttl) {
tag_first_user_text(body, ttl);
return;
}
tag_first_user_text(body, ttl);
return;
}
if !tag_last_system(body, ttl) {
tag_first_system(body, ttl);
}
tag_first_user_text(body, ttl);
}
fn strip_all_cache_control(value: &mut Value) {
match value {
Value::Object(map) => {
map.remove("cache_control");
for child in map.values_mut() {
strip_all_cache_control(child);
}
}
Value::Array(arr) => {
for child in arr {
strip_all_cache_control(child);
}
}
_ => {}
}
}
fn enforce_cache_control_limit(body: &mut Value, ttl: Option<&str>) {
let n = count_cache_control(body);
if n <= ANTHROPIC_PREFERRED_CACHE_CONTROL_BLOCKS {
debug_assert!(n <= ANTHROPIC_MAX_CACHE_CONTROL_BLOCKS);
return;
}
strip_all_cache_control(body);
apply_preferred_cache_breakpoints(body, ttl);
debug_assert!(count_cache_control(body) <= ANTHROPIC_MAX_CACHE_CONTROL_BLOCKS);
}
fn count_cache_control(value: &Value) -> usize {
match value {
Value::Object(map) => {
usize::from(map.contains_key("cache_control"))
+ map.values().map(count_cache_control).sum::<usize>()
}
Value::Array(arr) => arr.iter().map(count_cache_control).sum(),
_ => 0,
}
}
fn ephemeral_cache_control(ttl: Option<&str>) -> Value {
let mut cc = json!({"type": "ephemeral"});
if let Some(ttl) = ttl {
cc["ttl"] = json!(ttl);
}
cc
}
fn tag_last_tool(body: &mut Value, ttl: Option<&str>) {
let Some(tools) = body.get_mut("tools").and_then(Value::as_array_mut) else {
return;
};
if let Some(last) = tools.last_mut() {
last["cache_control"] = ephemeral_cache_control(ttl);
}
}
fn tag_first_system(body: &mut Value, ttl: Option<&str>) -> bool {
tag_system_slot(body, ttl, true)
}
fn tag_last_system(body: &mut Value, ttl: Option<&str>) -> bool {
tag_system_slot(body, ttl, false)
}
fn tag_system_slot(body: &mut Value, ttl: Option<&str>, first: bool) -> bool {
match body.get_mut("system") {
Some(Value::Array(blocks)) => {
let slot = if first {
blocks.first_mut()
} else {
blocks.last_mut()
};
if let Some(block) = slot {
block["cache_control"] = ephemeral_cache_control(ttl);
return true;
}
false
}
Some(Value::String(text)) => {
let text = text.clone();
body["system"] = json!([{
"type": "text",
"text": text,
"cache_control": ephemeral_cache_control(ttl),
}]);
true
}
Some(Value::Object(obj)) => {
obj.insert("cache_control".into(), ephemeral_cache_control(ttl));
true
}
_ => false,
}
}
fn tag_first_user_text(body: &mut Value, ttl: Option<&str>) {
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
return;
};
let Some(user) = messages
.iter_mut()
.find(|m| m.get("role").and_then(Value::as_str) == Some("user"))
else {
return;
};
match user.get_mut("content") {
Some(Value::Array(parts)) => tag_user_content_blocks(parts, ttl),
Some(Value::String(text)) if !text.trim().is_empty() => {
let text = text.clone();
user["content"] = json!([{
"type": "text",
"text": text,
"cache_control": ephemeral_cache_control(ttl),
}]);
}
_ => {}
}
}
fn tag_user_content_blocks(parts: &mut [Value], ttl: Option<&str>) {
let text_idx = parts.iter().position(nonempty_text_block);
let payload_idx = parts
.iter()
.enumerate()
.rev()
.find_map(|(idx, part)| cacheable_user_block(part).then_some(idx));
let placeholder =
text_idx.is_some_and(|idx| parts[idx].get("text").and_then(Value::as_str) == Some("."));
let chosen = if let Some(idx) = text_idx
&& !(placeholder && payload_idx.is_some())
{
Some(idx)
} else {
payload_idx.or(text_idx)
};
if let Some(idx) = chosen {
parts[idx]["cache_control"] = ephemeral_cache_control(ttl);
}
}
fn nonempty_text_block(part: &Value) -> bool {
part.get("type").and_then(Value::as_str) == Some("text")
&& part
.get("text")
.and_then(Value::as_str)
.is_some_and(|text| !text.trim().is_empty())
}
fn cacheable_user_block(part: &Value) -> bool {
matches!(
part.get("type").and_then(Value::as_str),
Some("image" | "document" | "tool_result" | "tool_use")
)
}
fn sanitize_messages_tool_use_id(call_id: &str) -> String {
let mut out = String::with_capacity(call_id.len().max(1));
let mut last_underscore = false;
for c in call_id.chars() {
let legal = c.is_ascii_alphanumeric() || c == '_' || c == '-';
if legal {
last_underscore = c == '_';
out.push(c);
} else if !last_underscore {
out.push('_');
last_underscore = true;
}
}
if out.is_empty() {
out.push('_');
}
out
}
fn assign_messages_tool_use_id(call_id: &str, tool_ids: &mut HashMap<String, String>) -> String {
if let Some(existing) = tool_ids.get(call_id) {
return existing.clone();
}
let base = sanitize_messages_tool_use_id(call_id);
let mut candidate = base.clone();
let mut suffix = 2u64;
while tool_ids.values().any(|used| used == &candidate) {
candidate = format!("{base}_{suffix}");
suffix += 1;
}
tool_ids.insert(call_id.to_string(), candidate.clone());
candidate
}
fn rewrite_messages_tool_use_id(
call_id: &str,
path: impl Into<String>,
tool_ids: &mut HashMap<String, String>,
report: &mut LossReport,
) -> String {
let id = assign_messages_tool_use_id(call_id, tool_ids);
if id != call_id {
report.record(
path,
LossAction::Degrade,
"sanitized to Anthropic tool_use.id charset",
);
}
id
}
fn tool_use_block(
call_id: &str,
name: &str,
arguments: &str,
path: impl Into<String>,
tool_ids: &mut HashMap<String, String>,
report: &mut LossReport,
) -> Value {
let path = path.into();
let id = rewrite_messages_tool_use_id(call_id, path.as_str(), tool_ids, report);
let input = super::json_object_or_raw(
arguments,
&path,
report,
"tool arguments are not a JSON object",
"raw",
);
json!({
"type": "tool_use",
"id": id,
"name": name,
"input": input,
})
}
fn tool_result_block(
call_id: &str,
output: &str,
parts: &[IrPart],
is_error: bool,
path: impl Into<String>,
tool_ids: &mut HashMap<String, String>,
report: &mut LossReport,
) -> Value {
let id = rewrite_messages_tool_use_id(call_id, path, tool_ids, report);
let mut block = json!({
"type": "tool_result",
"tool_use_id": id,
});
if is_error {
block["is_error"] = json!(true);
}
let ordered_text = parts
.iter()
.any(|part| matches!(part, IrPart::Text(text) if !text.is_empty()));
let text = if ordered_text {
String::new()
} else {
super::function_output_text(output, parts)
};
if parts.is_empty() {
block["content"] = json!(text);
return block;
}
let mut content = Vec::new();
if !text.is_empty() {
content.push(json!({"type": "text", "text": text}));
}
for part in parts {
match part {
IrPart::Text(text) if ordered_text && !text.is_empty() => {
content.push(json!({"type": "text", "text": text}));
}
IrPart::Text(_) => {}
IrPart::Raw { type_name, raw } if type_name == "json" && ordered_text => {
content.push(json!({"type": "text", "text": raw.to_string()}));
}
IrPart::Raw { type_name, .. } if type_name == "json" => {}
_ => {
if let Some(encoded) = encode_part(part, report) {
content.push(encoded);
}
}
}
}
block["content"] = if content.is_empty() {
json!(output)
} else {
Value::Array(content)
};
block
}
fn encode_tool(tool: &PreparedTool) -> Value {
match tool {
PreparedTool::Function {
name,
description,
parameters,
strict,
} => {
let mut schema = parameters.clone();
normalize_object_schema_required(&mut schema);
if *strict == Some(true) {
strict_object_schema(&mut schema);
}
json!({
"name": name,
"description": description,
"input_schema": schema,
})
}
PreparedTool::Raw(raw) => {
let mut raw = raw.clone();
if let Some(schema) = raw.get_mut("input_schema") {
normalize_object_schema_required(schema);
}
raw
}
}
}
pub(super) fn normalize_object_schema_required(schema: &mut Value) {
fill_object_schema(schema);
}
fn fill_object_schema(schema: &mut Value) {
let Some(obj) = schema.as_object_mut() else {
return;
};
let is_object =
obj.get("type").and_then(Value::as_str) == Some("object") || obj.contains_key("properties");
if is_object {
match obj.get("required") {
None | Some(Value::Null) => {
obj.insert("required".into(), json!([]));
}
Some(Value::Array(_)) => {}
Some(_) => {
obj.insert("required".into(), json!([]));
}
}
if !obj.contains_key("properties") {
obj.insert("properties".into(), json!({}));
}
} else if matches!(obj.get("required"), Some(Value::Null)) {
obj.insert("required".into(), json!([]));
}
if let Some(props) = obj.get_mut("properties").and_then(Value::as_object_mut) {
for child in props.values_mut() {
fill_object_schema(child);
}
}
for key in ["$defs", "definitions"] {
if let Some(defs) = obj.get_mut(key).and_then(Value::as_object_mut) {
for child in defs.values_mut() {
fill_object_schema(child);
}
}
}
if let Some(items) = obj.get_mut("items") {
match items {
Value::Array(list) => {
for item in list.iter_mut() {
fill_object_schema(item);
}
}
other => fill_object_schema(other),
}
}
for key in ["anyOf", "oneOf", "allOf", "prefixItems"] {
if let Some(Value::Array(list)) = obj.get_mut(key) {
for item in list.iter_mut() {
fill_object_schema(item);
}
}
}
for key in ["additionalProperties", "not"] {
if let Some(child) = obj.get_mut(key).filter(|value| value.is_object()) {
fill_object_schema(child);
}
}
}
pub(super) fn strict_object_schema(schema: &mut Value) {
close_objects(schema, true);
}
pub(super) fn require_every_property(schema: &mut Value) {
close_objects(schema, false);
}
fn close_objects(schema: &mut Value, close_additional: bool) {
let Some(obj) = schema.as_object_mut() else {
return;
};
let is_object =
obj.get("type").and_then(Value::as_str) == Some("object") || obj.contains_key("properties");
if is_object {
if close_additional {
obj.insert("additionalProperties".into(), json!(false));
}
let names: Vec<Value> = obj
.get("properties")
.and_then(Value::as_object)
.map(|props| props.keys().cloned().map(Value::String).collect())
.unwrap_or_default();
if !names.is_empty() {
obj.insert("required".into(), Value::Array(names));
}
}
if let Some(props) = obj.get_mut("properties").and_then(Value::as_object_mut) {
for child in props.values_mut() {
close_objects(child, close_additional);
}
}
for key in ["$defs", "definitions"] {
if let Some(defs) = obj.get_mut(key).and_then(Value::as_object_mut) {
for child in defs.values_mut() {
close_objects(child, close_additional);
}
}
}
if let Some(items) = obj.get_mut("items") {
match items {
Value::Array(list) => {
for item in list {
close_objects(item, close_additional);
}
}
other => close_objects(other, close_additional),
}
}
for key in ["anyOf", "oneOf", "allOf", "prefixItems"] {
if let Some(Value::Array(list)) = obj.get_mut(key) {
for item in list {
close_objects(item, close_additional);
}
}
}
for key in ["additionalProperties", "not"] {
if let Some(child) = obj.get_mut(key).filter(|value| value.is_object()) {
close_objects(child, close_additional);
}
}
}
fn encode_sampling(ir: &IrRequest, body: &mut Value, report: &mut LossReport) {
let s = &ir.sampling;
if let Some(t) = s.temperature {
body["temperature"] = super::json_f32(t);
}
if let Some(p) = s.top_p {
body["top_p"] = super::json_f32(p);
}
if let Some(k) = s.top_k {
body["top_k"] = json!(k);
}
if let Some(geo) = s
.inference_geo
.as_deref()
.map(str::trim)
.filter(|geo| !geo.is_empty())
{
body["inference_geo"] = json!(geo);
}
if let Some(id) = s.previous_message_id.as_deref() {
body["diagnostics"]["previous_message_id"] = json!(id);
}
if s.performance_latency.is_some() {
report.record("sampling.performance_latency", 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 let Some(container) = &s.container {
body["container"] = container.clone();
}
if let Some(mgmt) = &s.context_management {
body["context_management"] = mgmt.clone();
}
drop_responses_request_fields(s, report);
if let Some(servers) = &s.mcp_servers {
body["mcp_servers"] = servers.clone();
}
if let Some(max) = s.max_tokens {
body["max_tokens"] = json!(max);
}
let stop = super::kept_stop(&s.stop, report);
if !stop.is_empty() {
body["stop_sequences"] = json!(stop);
}
encode_tool_choice(&s.tool_choice, body);
encode_disable_parallel_tool_use(s.parallel_tool_calls, body, report);
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 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");
}
encode_service_tier(s.service_tier.as_deref(), body, report);
if s.previous_response_id.is_some() {
report.record(
"sampling.previous_response_id",
LossAction::Degrade,
"drop id, rebuild full input",
);
}
if let Some(stream) = s.stream {
body["stream"] = json!(stream);
}
encode_thinking(s, body, report);
omit_sampling_rejected_by_thinking(s, body, report);
omit_forced_tool_choice_rejected_by_model(&ir.model, body, report);
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 body.get("max_tokens").is_none() {
body["max_tokens"] = json!(MESSAGES_DEFAULT_COMPLETION_TOKENS);
report.record(
"sampling.max_tokens",
LossAction::Degrade,
"messages requires max_tokens",
);
}
if !s.include.is_empty() {
report.record("sampling.include", LossAction::Drop, "no slot");
}
encode_output_config(s, body, report);
if s.json_object == Some(true) {
report.record("sampling.json_object", LossAction::Drop, "no slot");
}
if let Some(user) = s.user.as_deref().map(str::trim).filter(|id| !id.is_empty()) {
body["metadata"] = json!({ "user_id": user });
}
}
fn encode_output_config(s: &IrSampling, body: &mut Value, report: &mut LossReport) {
let mut out = serde_json::Map::new();
if s.include_thoughts != Some(false)
&& let Some(effort) = s
.reasoning_effort
.as_deref()
.map(str::trim)
.filter(|effort| !effort.is_empty())
{
out.insert("effort".into(), json!(effort));
}
if let Some(schema) = &s.json_schema {
if schema.is_object() {
out.insert(
"format".into(),
json!({
"type": "json_schema",
"schema": schema,
}),
);
report.record(
"sampling.json_schema",
LossAction::Preserve,
"messages output_config.format",
);
if s.json_schema_name.is_some() {
report.record(
"sampling.json_schema_name",
LossAction::Drop,
"messages output_config.format has no name",
);
}
} else {
report.record(
"sampling.json_schema",
LossAction::Drop,
"json_schema requires object schema",
);
}
}
if !out.is_empty() {
body["output_config"] = Value::Object(out);
}
}
const MESSAGES_DEFAULT_COMPLETION_TOKENS: u32 = 4096;
const MESSAGES_DEFAULT_THINKING_BUDGET: u32 = 10240;
fn encode_thinking(s: &IrSampling, body: &mut Value, report: &mut LossReport) {
if let Some(native) = s.messages_thinking.as_ref().filter(|thinking| {
thinking
.get("type")
.and_then(Value::as_str)
.is_some_and(|kind| {
let kind = kind.trim();
kind == "adaptive" || kind == "between_tools"
})
}) {
body["thinking"] = native.clone();
report.record(
"sampling.messages_thinking",
LossAction::Preserve,
"messages thinking",
);
if s.include_thoughts.is_some() {
report.record(
"sampling.include_thoughts",
LossAction::Drop,
"native thinking object",
);
}
if s.max_reasoning_tokens.is_some() {
report.record(
"sampling.max_reasoning_tokens",
LossAction::Drop,
"native thinking object",
);
}
if s.thinking_budget.is_some() {
report.record(
"sampling.thinking_budget",
LossAction::Drop,
"native thinking object",
);
}
if s.reasoning_effort
.as_deref()
.is_some_and(|effort| !effort.trim().is_empty())
{
if s.include_thoughts == Some(false) {
report.record(
"sampling.reasoning_effort",
LossAction::Drop,
"thinking disabled",
);
} else {
report.record(
"sampling.reasoning_effort",
LossAction::Preserve,
"messages output_config.effort",
);
}
}
return;
}
if s.messages_thinking.is_some() {
report.record(
"sampling.messages_thinking",
LossAction::Drop,
"not adaptive or between_tools",
);
}
let effort = s
.reasoning_effort
.as_deref()
.filter(|effort| !effort.trim().is_empty());
if s.include_thoughts == Some(false) {
body["thinking"] = json!({ "type": "disabled" });
report.record(
"sampling.include_thoughts",
LossAction::Preserve,
"messages thinking",
);
if effort.is_some() {
report.record(
"sampling.reasoning_effort",
LossAction::Drop,
"thinking disabled",
);
}
if s.max_reasoning_tokens.is_some() {
report.record(
"sampling.max_reasoning_tokens",
LossAction::Drop,
"thinking disabled",
);
}
if s.thinking_budget.is_some() {
report.record(
"sampling.thinking_budget",
LossAction::Drop,
"thinking disabled",
);
}
return;
}
let want_enable = s.include_thoughts == Some(true)
|| effort.is_some()
|| s.max_reasoning_tokens.is_some()
|| s.thinking_budget.is_some();
if !want_enable {
return;
}
let budget = s
.max_reasoning_tokens
.or(s.thinking_budget)
.unwrap_or_else(|| {
effort
.map(messages_effort_budget)
.unwrap_or(MESSAGES_DEFAULT_THINKING_BUDGET)
});
body["thinking"] = json!({
"type": "enabled",
"budget_tokens": budget,
});
if s.include_thoughts == Some(true) {
report.record(
"sampling.include_thoughts",
LossAction::Preserve,
"messages thinking",
);
}
if s.max_reasoning_tokens.is_some() {
report.record(
"sampling.max_reasoning_tokens",
LossAction::Preserve,
"messages thinking.budget_tokens",
);
if s.thinking_budget.is_some_and(|n| n != budget) {
report.record(
"sampling.thinking_budget",
LossAction::Degrade,
"max_reasoning_tokens wins budget_tokens",
);
}
} else if s.thinking_budget.is_some() {
report.record(
"sampling.thinking_budget",
LossAction::Preserve,
"messages thinking.budget_tokens",
);
}
if effort.is_some() {
report.record(
"sampling.reasoning_effort",
LossAction::Preserve,
"messages thinking",
);
}
let current = body.get("max_tokens").and_then(Value::as_u64).unwrap_or(0) as u32;
if current <= budget {
let raised = budget.saturating_add(MESSAGES_DEFAULT_COMPLETION_TOKENS);
body["max_tokens"] = json!(raised);
report.record(
"sampling.max_tokens",
LossAction::Degrade,
"raised above thinking.budget_tokens",
);
}
}
fn omit_sampling_rejected_by_thinking(s: &IrSampling, body: &mut Value, report: &mut LossReport) {
let thinking_on = body
.pointer("/thinking/type")
.and_then(Value::as_str)
.is_some_and(|kind| kind == "enabled");
if !thinking_on {
return;
}
if let Some(obj) = body.as_object_mut() {
if s.temperature.is_some() {
obj.remove("temperature");
report.record(
"sampling.temperature",
LossAction::Drop,
"thinking omits temperature",
);
}
if s.top_p.is_some() {
obj.remove("top_p");
report.record("sampling.top_p", LossAction::Drop, "thinking omits top_p");
}
if s.top_k.is_some() {
obj.remove("top_k");
report.record("sampling.top_k", LossAction::Drop, "thinking omits top_k");
}
}
match &s.tool_choice {
IrToolChoice::Required | IrToolChoice::Named(_) => {
match body.get_mut("tool_choice") {
Some(Value::Object(choice)) => {
choice.insert("type".into(), json!("auto"));
choice.remove("name");
}
_ => body["tool_choice"] = json!({"type": "auto"}),
}
report.record(
"sampling.tool_choice",
LossAction::Degrade,
"thinking forces tool_choice auto",
);
}
IrToolChoice::Auto | IrToolChoice::None => {}
}
}
const MODEL_FORCES_TOOL_CHOICE_AUTO: &str = "model forces tool_choice auto";
const FORCED_TOOL_REJECTING_FAMILIES: &[&str] = &[
"claude-opus-5-5",
"claude-sonnet-5-5",
"claude-fable-5-1",
"claude-mythos-5-1",
];
fn messages_model_stem(model: &str) -> String {
let segment = model.rsplit('/').next().unwrap_or(model).trim();
let mut id = segment.to_ascii_lowercase();
if let Some(rest) = anthropic_provider_rest(&id) {
id = rest.to_string();
}
if let Some((stem, _)) = id.split_once('@') {
id = stem.trim().to_string();
}
id
}
fn anthropic_provider_rest(id: &str) -> Option<&str> {
let (prefix, rest) = id.split_once("anthropic.")?;
if prefix.is_empty() {
return Some(rest);
}
let region = prefix.strip_suffix('.')?;
if region.is_empty() || region.contains('.') {
return None;
}
Some(rest)
}
fn rejects_forced_tool_choice(model: &str) -> bool {
let stem = messages_model_stem(model);
FORCED_TOOL_REJECTING_FAMILIES.iter().any(|family| {
stem == *family
|| stem
.strip_prefix(family)
.is_some_and(|rest| rest.starts_with('-'))
})
}
fn omit_forced_tool_choice_rejected_by_model(
model: &str,
body: &mut Value,
report: &mut LossReport,
) {
if !rejects_forced_tool_choice(model) {
return;
}
let kind = body.pointer("/tool_choice/type").and_then(Value::as_str);
if kind != Some("tool") && kind != Some("any") {
return;
}
if let Some(Value::Object(choice)) = body.get_mut("tool_choice") {
choice.insert("type".into(), json!("auto"));
choice.remove("name");
}
report.record(
"sampling.tool_choice",
LossAction::Degrade,
MODEL_FORCES_TOOL_CHOICE_AUTO,
);
}
pub fn restore_same_wire_messages_tool_choice(
from: crate::Wire,
to: crate::Wire,
ir: &IrRequest,
body: &mut Vec<u8>,
report: &mut LossReport,
) {
if from != crate::Wire::Messages || to != crate::Wire::Messages {
return;
}
let Some(pos) = report.events.iter().position(|event| {
event.path == "sampling.tool_choice"
&& event.action == LossAction::Degrade
&& event.detail == MODEL_FORCES_TOOL_CHOICE_AUTO
}) else {
return;
};
let Ok(mut value) = serde_json::from_slice::<Value>(body) else {
return;
};
if !put_back_forced_tool_choice(&ir.sampling.tool_choice, &mut value) {
return;
}
let Ok(bytes) = serde_json::to_vec(&value) else {
return;
};
*body = bytes;
report.events.remove(pos);
}
fn put_back_forced_tool_choice(choice: &IrToolChoice, value: &mut Value) -> bool {
let Some(obj) = value.get_mut("tool_choice").and_then(Value::as_object_mut) else {
return true;
};
if obj.get("type").and_then(Value::as_str) != Some("auto") {
return true;
}
match choice {
IrToolChoice::Named(name) => {
obj.insert("type".into(), json!("tool"));
obj.insert("name".into(), json!(name));
true
}
IrToolChoice::Required => {
obj.insert("type".into(), json!("any"));
obj.remove("name");
true
}
IrToolChoice::Auto | IrToolChoice::None => false,
}
}
fn messages_effort_budget(effort: &str) -> u32 {
match effort.trim().to_ascii_lowercase().as_str() {
"low" => 4096,
"high" => 32768,
"xhigh" | "x-high" => 65536,
_ => MESSAGES_DEFAULT_THINKING_BUDGET,
}
}
fn encode_tool_choice(choice: &IrToolChoice, body: &mut Value) {
match choice {
IrToolChoice::Auto => {}
IrToolChoice::None => body["tool_choice"] = json!({"type": "none"}),
IrToolChoice::Required => body["tool_choice"] = json!({"type": "any"}),
IrToolChoice::Named(name) => {
body["tool_choice"] = json!({"type": "tool", "name": name});
}
}
}
fn encode_disable_parallel_tool_use(
parallel: Option<bool>,
body: &mut Value,
report: &mut LossReport,
) {
let Some(parallel) = parallel else {
return;
};
let has_obj = body.get("tool_choice").is_some_and(Value::is_object);
if parallel {
if !has_obj {
return;
}
body["tool_choice"]["disable_parallel_tool_use"] = json!(false);
} else {
if !has_obj {
body["tool_choice"] = json!({ "type": "auto" });
}
body["tool_choice"]["disable_parallel_tool_use"] = json!(true);
}
report.record(
"sampling.parallel_tool_calls",
LossAction::Preserve,
"messages disable_parallel_tool_use",
);
}
fn encode_service_tier(tier: Option<&str>, body: &mut Value, report: &mut LossReport) {
let Some(tier) = tier else {
return;
};
let trimmed = tier.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unspecified") {
return;
}
match messages_service_tier(trimmed) {
Some((mapped, degrade)) => {
body["service_tier"] = 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",
);
}
}
}
fn messages_service_tier(tier: &str) -> Option<(String, Option<&'static str>)> {
let lower = tier.trim().to_ascii_lowercase();
match lower.as_str() {
"auto" | "standard_only" => Some((lower, None)),
"default" => Some((
"standard_only".into(),
Some("default maps to standard_only"),
)),
"standard" => Some((
"standard_only".into(),
Some("standard maps to standard_only"),
)),
_ => None,
}
}