use std::fs;
use std::path::PathBuf;
use serde_json::Value;
use wiremux::{
IrCache, IrDocumentSource, IrItem, IrPart, IrRequest, IrSampling, IrTool, IrToolChoice,
LossAction, LossReport, MapError, ResolvedProfile, Wire, decode, encode, parse_profile_str,
restore_same_wire_messages_tool_choice,
};
fn golden(name: &str) -> Vec<u8> {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/goldens/requests")
.join(name);
fs::read(&path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()))
}
fn profile(toml: &str) -> ResolvedProfile {
parse_profile_str(toml).expect("test profile parses")
}
fn hard_error_profile() -> ResolvedProfile {
profile(
r#"
schema_version = 1
id = "test-hard-error"
wire = "responses"
tool_type_policy = "hard-error"
"#,
)
}
fn flatten_profile() -> ResolvedProfile {
profile(
r#"
schema_version = 1
id = "test-flatten"
wire = "responses"
tool_type_policy = "flatten-namespace"
"#,
)
}
fn openrouter_forbid_store() -> ResolvedProfile {
profile(
r#"
schema_version = 1
id = "test-openrouter-store"
wire = "responses"
tool_type_policy = "flatten-namespace"
[fingerprint]
forbidden_body_fields = ["store"]
forbidden_field_policy = "hard-error"
"#,
)
}
fn messages_profile() -> ResolvedProfile {
profile(
r#"
schema_version = 1
id = "test-messages"
wire = "messages"
tool_type_policy = "hard-error"
"#,
)
}
fn chat_profile() -> ResolvedProfile {
profile(
r#"
schema_version = 1
id = "test-chat"
wire = "chat-completions"
tool_type_policy = "hard-error"
"#,
)
}
fn namespace_tool(tools: &[IrTool]) -> &IrTool {
tools
.iter()
.find(|tool| matches!(tool, IrTool::Namespace { .. }))
.expect("namespace tool must not be silent-stripped")
}
#[test]
fn codex_namespace_is_not_silent_stripped() {
let bytes = golden("codex_namespace.json");
let (ir, _loss) = decode(Wire::Responses, &bytes).expect("decode Responses");
let IrTool::Namespace { name, raw } = namespace_tool(&ir.tools) else {
panic!("expected IrTool::Namespace, got {:?}", ir.tools);
};
assert_eq!(name, "mcp__fs");
assert_eq!(raw["type"], "namespace");
assert_eq!(raw["tools"][0]["name"], "list_dir");
assert_eq!(raw["tools"][0]["description"], "List a directory");
assert!(
raw["tools"][0]["parameters"]["properties"]
.get("path")
.is_some()
);
let err = encode(Wire::Messages, &ir, &hard_error_profile())
.expect_err("default hard-error must fail closed on type=namespace, not strip tools");
match err {
MapError::HardError { path, detail } => {
assert!(path.contains("tools"), "path={path}");
assert!(
detail.contains("namespace"),
"hard-error should name namespace, got {detail}"
);
}
other => panic!("expected HardError, got {other}"),
}
let err = encode(Wire::ChatCompletions, &ir, &hard_error_profile())
.expect_err("Chat hard-error must not silent-strip namespace");
assert!(matches!(err, MapError::HardError { .. }), "{err}");
let (preserved, loss) =
encode(Wire::Responses, &ir, &hard_error_profile()).expect("Responses keeps namespace");
let body: Value = serde_json::from_slice(&preserved).expect("json");
assert_eq!(body["tools"][0]["type"], "namespace");
assert_eq!(body["tools"][0]["name"], "mcp__fs");
assert_eq!(body["tools"][0]["tools"][0]["name"], "list_dir");
assert!(
loss.events
.iter()
.any(|event| event.action == LossAction::Preserve && event.path.contains("tools")),
"expected Preserve on namespace tools, got {:?}",
loss.events
);
let (flat, loss) =
encode(Wire::ChatCompletions, &ir, &flatten_profile()).expect("flatten-namespace");
let body: Value = serde_json::from_slice(&flat).expect("json");
let tool = &body["tools"][0];
assert_eq!(tool["type"], "function");
assert_eq!(tool["function"]["name"], "mcp__fs.list_dir");
assert_eq!(tool["function"]["description"], "List a directory");
assert!(
tool["function"]["parameters"]["properties"]
.get("path")
.is_some(),
"flatten must keep parameters, not a name-only stub: {tool}"
);
assert!(
loss.events
.iter()
.any(|event| event.action == LossAction::Degrade && event.detail.contains("flatten")),
"expected flatten Degrade, got {:?}",
loss.events
);
let (flat_resp, _) =
encode(Wire::Responses, &ir, &flatten_profile()).expect("flatten on Responses");
let body: Value = serde_json::from_slice(&flat_resp).expect("json");
assert_eq!(body["tools"][0]["type"], "function");
assert_eq!(body["tools"][0]["name"], "mcp__fs.list_dir");
assert_ne!(
body["tools"][0]["type"], "namespace",
"openrouter-style flatten-namespace must not emit type=namespace"
);
}
#[test]
fn store_true_forbidden_is_hard_error() {
let bytes = golden("store_true.json");
let (ir, _loss) = decode(Wire::Responses, &bytes).expect("decode");
assert_eq!(ir.sampling.store, Some(true));
let err = encode(Wire::Responses, &ir, &openrouter_forbid_store())
.expect_err("forbidden store must hard-error");
match err {
MapError::HardError { path, detail } => {
assert!(path.contains("store"), "path={path}");
assert!(detail.contains("store"), "detail={detail}");
}
other => panic!("expected HardError, got {other}"),
}
let (ok, loss) = encode(Wire::Responses, &ir, &hard_error_profile()).expect("store allowed");
let body: Value = serde_json::from_slice(&ok).expect("json");
assert_eq!(body["store"], true);
assert!(
loss.events
.iter()
.any(|event| event.path.contains("store") && event.action == LossAction::Preserve),
"expected Preserve store, got {:?}",
loss.events
);
}
#[test]
fn chat_encode_emits_store_true() {
let ir = user_ir(IrSampling::patch(|s| {
s.store = Some(true);
}));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("store"),
Some(&Value::Bool(true)),
"Chat must emit store=true, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.store"),
"Chat store must not Drop as no slot, got {report:?}"
);
assert!(
report.events.iter().any(|event| {
event.path == "sampling.store" && event.action == LossAction::Preserve
}),
"expected Preserve store, got {report:?}"
);
}
#[test]
fn chat_encode_emits_store_false() {
let ir = user_ir(IrSampling::patch(|s| {
s.store = Some(false);
}));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("store"),
Some(&Value::Bool(false)),
"Chat must emit store=false, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.store"),
"Chat store=false must not Drop, got {report:?}"
);
}
#[test]
fn chat_decode_reads_store_true() {
let req = br#"{
"model": "gpt-5",
"store": true,
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(ir.sampling.store, Some(true));
}
#[test]
fn chat_store_forbidden_is_hard_error() {
let ir = user_ir(IrSampling::patch(|s| {
s.store = Some(true);
}));
let err = encode(Wire::ChatCompletions, &ir, &openrouter_forbid_store())
.expect_err("OpenRouter forbidden store must hard-error on Chat");
match err {
MapError::HardError { path, detail } => {
assert!(path.contains("store"), "path={path}");
assert!(detail.contains("store"), "detail={detail}");
}
other => panic!("expected HardError, got {other}"),
}
}
#[test]
fn messages_drops_store_gemini_emits_store() {
let ir = user_ir(IrSampling::patch(|s| {
s.store = Some(true);
}));
let (msg_bytes, msg_report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let msg: Value = serde_json::from_slice(&msg_bytes).expect("json");
assert!(
msg.get("store").is_none(),
"Messages must not invent store, got {msg}"
);
assert!(
loss_dropped(&msg_report, "sampling.store"),
"Messages store drop missing, got {msg_report:?}"
);
let (gem_bytes, gem_report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let gem: Value = serde_json::from_slice(&gem_bytes).expect("json");
assert_eq!(
gem.get("store").and_then(Value::as_bool),
Some(true),
"Gemini must emit store, got {gem}"
);
assert!(
!loss_dropped(&gem_report, "sampling.store"),
"Gemini has store and must not Drop, got {gem_report:?}"
);
}
#[test]
fn responses_and_chat_round_trip_codex_cache_key_and_tier() {
let req = br#"{
"model": "gpt-5",
"prompt_cache_key": "sess-1",
"service_tier": "flex",
"input": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode responses");
assert_eq!(ir.sampling.prompt_cache_key.as_deref(), Some("sess-1"));
assert_eq!(ir.sampling.service_tier.as_deref(), Some("flex"));
let (bytes, report) = encode(Wire::Responses, &ir, &hard_error_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(body["prompt_cache_key"], "sess-1");
assert_eq!(body["service_tier"], "flex");
assert!(
!loss_dropped(&report, "sampling.prompt_cache_key"),
"Responses prompt_cache_key must not Drop, got {report:?}"
);
assert!(
!loss_dropped(&report, "sampling.service_tier"),
"Responses service_tier must not Drop, got {report:?}"
);
let chat_req = br#"{
"model": "gpt-5",
"prompt_cache_key": "sess-1",
"service_tier": "priority",
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (chat_ir, _) = decode(Wire::ChatCompletions, chat_req).expect("decode chat");
assert_eq!(chat_ir.sampling.prompt_cache_key.as_deref(), Some("sess-1"));
assert_eq!(chat_ir.sampling.service_tier.as_deref(), Some("priority"));
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &chat_ir, &chat_profile()).expect("encode chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("json");
assert_eq!(chat["prompt_cache_key"], "sess-1");
assert_eq!(chat["service_tier"], "priority");
assert!(
!loss_dropped(&chat_report, "sampling.prompt_cache_key"),
"Chat prompt_cache_key must not Drop, got {chat_report:?}"
);
}
#[test]
fn messages_drops_codex_cache_and_tier_gemini_emits_service_tier() {
let ir = user_ir(IrSampling::patch(|s| {
s.prompt_cache_key = Some("sess-1".into());
s.service_tier = Some("flex".into());
}));
let (msg_bytes, msg_report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let msg: Value = serde_json::from_slice(&msg_bytes).expect("json");
assert!(
msg.get("prompt_cache_key").is_none() && msg.get("service_tier").is_none(),
"Messages must not invent Codex cache/tier, got {msg}"
);
assert!(
loss_dropped(&msg_report, "sampling.prompt_cache_key"),
"Messages prompt_cache_key drop missing, got {msg_report:?}"
);
assert!(
loss_dropped(&msg_report, "sampling.service_tier"),
"Messages service_tier drop missing, got {msg_report:?}"
);
let (gem_bytes, gem_report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let gem: Value = serde_json::from_slice(&gem_bytes).expect("json");
assert!(
gem.get("prompt_cache_key").is_none(),
"Gemini must still Drop prompt_cache_key, got {gem}"
);
assert!(
gem.get("service_tier").is_none(),
"Gemini must not emit Chat service_tier key, got {gem}"
);
assert_eq!(
gem.get("serviceTier").and_then(Value::as_str),
Some("flex"),
"Gemini flex must emit serviceTier, got {gem}"
);
assert!(
loss_dropped(&gem_report, "sampling.prompt_cache_key"),
"Gemini prompt_cache_key drop missing, got {gem_report:?}"
);
assert!(
!loss_dropped(&gem_report, "sampling.service_tier"),
"Gemini has serviceTier; flex must not Drop, got {gem_report:?}"
);
let (cv_bytes, cv_report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let cv: Value = serde_json::from_slice(&cv_bytes).expect("json");
assert!(
cv.get("prompt_cache_key").is_none() && cv.get("service_tier").is_none(),
"Converse must not invent Codex cache/tier keys, got {cv}"
);
assert!(
loss_dropped(&cv_report, "sampling.prompt_cache_key"),
"Converse prompt_cache_key drop missing, got {cv_report:?}"
);
assert_eq!(
cv.pointer("/serviceTier/type").and_then(Value::as_str),
Some("flex"),
"Converse flex must emit serviceTier.type, got {cv}"
);
assert!(
!loss_dropped(&cv_report, "sampling.service_tier"),
"Converse has serviceTier; flex must not Drop, got {cv_report:?}"
);
}
#[test]
fn responses_request_keeps_assistant_phase_for_the_next_turn() {
let req = br#"{
"model": "gpt-5.3-codex",
"input": [
{"role": "user", "content": "hi"},
{
"type": "message",
"role": "assistant",
"phase": "commentary",
"content": [{"type": "output_text", "text": "thinking out loud"}]
},
{"role": "assistant", "phase": "final_answer", "content": "done"},
{"role": "user", "phase": "commentary", "content": "go on"},
{"role": "assistant", "phase": " ", "content": "blank"}
]
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
let (bytes, report) = encode(Wire::Responses, &ir, &hard_error_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let input = body["input"].as_array().expect("input array");
assert!(
input[0].get("phase").is_none(),
"user messages must not gain phase, got {body}"
);
assert_eq!(
input[1]["phase"].as_str(),
Some("commentary"),
"commentary must be resent on the next Responses turn, got {body}"
);
assert_eq!(
input[2]["phase"].as_str(),
Some("final_answer"),
"final_answer must be resent, got {body}"
);
assert!(
input[3].get("phase").is_none(),
"a phase on a user item must not be resent, got {body}"
);
assert!(
input[4].get("phase").is_none(),
"a blank phase must stay absent, got {body}"
);
assert!(
!loss_dropped(&report, "items[1].phase"),
"same-wire phase must not Drop, got {report:?}"
);
for (wire, profile) in [
(Wire::ChatCompletions, chat_profile()),
(Wire::Messages, messages_profile()),
(Wire::Gemini, gemini_profile()),
(Wire::Converse, converse_profile()),
] {
let (dest_bytes, dest_report) = encode(wire, &ir, &profile).expect("encode dest");
let dest: Value = serde_json::from_slice(&dest_bytes).expect("json");
assert!(
!dest.to_string().contains("commentary") && !dest.to_string().contains("final_answer"),
"{wire:?} must not invent a phase field, got {dest}"
);
assert!(
loss_dropped(&dest_report, "items[1].phase"),
"{wire:?} must Drop assistant phase, got {dest_report:?}"
);
assert!(
loss_dropped(&dest_report, "items[2].phase"),
"{wire:?} must Drop the second assistant phase, got {dest_report:?}"
);
}
}
#[test]
fn responses_round_trips_truncation_tool_cap_background_and_conversation() {
let req = br#"{
"model": "gpt-5",
"truncation": "auto",
"max_tool_calls": 3,
"background": false,
"conversation": "conv_1",
"context_management": [{"type": "compaction", "compact_threshold": 1000}],
"input": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode responses");
assert_eq!(ir.sampling.truncation.as_deref(), Some("auto"));
assert_eq!(ir.sampling.max_tool_calls, Some(3));
assert_eq!(ir.sampling.background, Some(false));
assert_eq!(
ir.sampling.conversation.as_ref().and_then(Value::as_str),
Some("conv_1")
);
let compaction = ir
.sampling
.responses_context_management
.as_ref()
.expect("responses context_management array");
assert_eq!(compaction[0]["type"], "compaction");
assert_eq!(compaction[0]["compact_threshold"].as_u64(), Some(1000));
assert!(
ir.sampling.context_management.is_none(),
"Responses array must not land on the Messages object field"
);
let (bytes, report) = encode(Wire::Responses, &ir, &hard_error_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(body["truncation"], "auto");
assert_eq!(body["max_tool_calls"].as_u64(), Some(3));
assert_eq!(body["background"], false);
assert_eq!(body["conversation"], "conv_1");
assert_eq!(body["context_management"][0]["type"], "compaction");
assert_eq!(
body["context_management"][0]["compact_threshold"].as_u64(),
Some(1000)
);
for path in [
"sampling.truncation",
"sampling.max_tool_calls",
"sampling.background",
"sampling.conversation",
"sampling.responses_context_management",
] {
assert!(
!loss_dropped(&report, path),
"{path} must not Drop, got {report:?}"
);
}
let object_req = br#"{
"model": "gpt-5",
"conversation": {"id": "conv_1"},
"input": [{"role": "user", "content": "hi"}]
}"#;
let (object_ir, _) = decode(Wire::Responses, object_req).expect("decode object conversation");
assert_eq!(
object_ir
.sampling
.conversation
.as_ref()
.and_then(|value| value.get("id"))
.and_then(Value::as_str),
Some("conv_1")
);
let (object_bytes, _) =
encode(Wire::Responses, &object_ir, &hard_error_profile()).expect("encode object");
let object_body: Value = serde_json::from_slice(&object_bytes).expect("json");
assert_eq!(object_body["conversation"]["id"], "conv_1");
let echoed = br#"{
"model": "gpt-5",
"truncation": "FUTURE_TRUNCATION",
"max_tool_calls": 0,
"background": true,
"context_management": [],
"input": [{"role": "user", "content": "hi"}]
}"#;
let (echoed_ir, _) = decode(Wire::Responses, echoed).expect("decode echoed");
assert_eq!(
echoed_ir.sampling.truncation.as_deref(),
Some("FUTURE_TRUNCATION")
);
assert_eq!(echoed_ir.sampling.max_tool_calls, Some(0));
assert_eq!(echoed_ir.sampling.background, Some(true));
assert_eq!(
echoed_ir
.sampling
.responses_context_management
.as_ref()
.and_then(Value::as_array)
.map(Vec::len),
Some(0)
);
let (echoed_bytes, _) =
encode(Wire::Responses, &echoed_ir, &hard_error_profile()).expect("encode echoed");
let echoed_body: Value = serde_json::from_slice(&echoed_bytes).expect("json");
assert_eq!(echoed_body["truncation"], "FUTURE_TRUNCATION");
assert_eq!(echoed_body["max_tool_calls"].as_u64(), Some(0));
assert_eq!(echoed_body["background"], true);
assert_eq!(
echoed_body["context_management"].as_array().map(Vec::len),
Some(0)
);
let ignored = br#"{
"model": "gpt-5",
"truncation": " ",
"max_tool_calls": -1,
"conversation": 4,
"context_management": {"edits": []},
"input": [{"role": "user", "content": "hi"}]
}"#;
let (ignored_ir, _) = decode(Wire::Responses, ignored).expect("decode ignored");
assert!(ignored_ir.sampling.truncation.is_none());
assert!(ignored_ir.sampling.max_tool_calls.is_none());
assert!(ignored_ir.sampling.conversation.is_none());
assert!(ignored_ir.sampling.responses_context_management.is_none());
assert!(ignored_ir.sampling.context_management.is_none());
}
#[test]
fn other_wires_drop_responses_request_fields() {
let ir = user_ir(IrSampling::patch(|sampling| {
sampling.truncation = Some("auto".into());
sampling.max_tool_calls = Some(3);
sampling.background = Some(false);
sampling.conversation = Some(serde_json::json!("conv_1"));
sampling.responses_context_management =
Some(serde_json::json!([{"type": "compaction", "compact_threshold": 1000}]));
}));
let wires = [
(Wire::ChatCompletions, chat_profile()),
(Wire::Messages, messages_profile()),
(Wire::Gemini, gemini_profile()),
(Wire::Converse, converse_profile()),
];
for (wire, profile) in wires {
let (bytes, report) = encode(wire, &ir, &profile).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
for key in [
"truncation",
"max_tool_calls",
"background",
"conversation",
"context_management",
] {
assert!(
!json_has_key(&body, key),
"{wire:?} must not emit {key}, got {body}"
);
}
for path in [
"sampling.truncation",
"sampling.max_tool_calls",
"sampling.background",
"sampling.conversation",
"sampling.responses_context_management",
] {
assert!(
loss_dropped(&report, path),
"{wire:?} {path} drop missing, got {report:?}"
);
}
}
let split = user_ir(IrSampling::patch(|sampling| {
sampling.context_management =
Some(serde_json::json!({"edits": [{"type": "clear_tool_uses"}]}));
sampling.responses_context_management =
Some(serde_json::json!([{"type": "compaction", "compact_threshold": 1000}]));
}));
let (msg_bytes, msg_report) =
encode(Wire::Messages, &split, &messages_profile()).expect("encode messages");
let msg: Value = serde_json::from_slice(&msg_bytes).expect("json");
assert_eq!(
msg["context_management"]["edits"][0]["type"],
"clear_tool_uses"
);
assert!(
!loss_dropped(&msg_report, "sampling.context_management"),
"Messages object must stay, got {msg_report:?}"
);
assert!(
loss_dropped(&msg_report, "sampling.responses_context_management"),
"Messages must Drop the Responses array, got {msg_report:?}"
);
let (resp_bytes, resp_report) =
encode(Wire::Responses, &split, &hard_error_profile()).expect("encode responses");
let resp: Value = serde_json::from_slice(&resp_bytes).expect("json");
assert_eq!(resp["context_management"][0]["type"], "compaction");
assert!(resp["context_management"].get("edits").is_none());
assert!(
loss_dropped(&resp_report, "sampling.context_management"),
"Responses must Drop the Messages object, got {resp_report:?}"
);
assert!(
!loss_dropped(&resp_report, "sampling.responses_context_management"),
"Responses array must not Drop, got {resp_report:?}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &split, &chat_profile()).expect("encode chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("json");
assert!(!json_has_key(&chat, "context_management"));
assert!(loss_dropped(&chat_report, "sampling.context_management"));
assert!(loss_dropped(
&chat_report,
"sampling.responses_context_management"
));
let plain = user_ir(IrSampling::default());
let (plain_bytes, plain_report) =
encode(Wire::Responses, &plain, &hard_error_profile()).expect("encode plain");
let plain_body: Value = serde_json::from_slice(&plain_bytes).expect("json");
for key in [
"truncation",
"max_tool_calls",
"background",
"conversation",
"context_management",
] {
assert!(
plain_body.get(key).is_none(),
"absent Responses field must not be invented: {key}"
);
}
for path in [
"sampling.truncation",
"sampling.max_tool_calls",
"sampling.background",
"sampling.conversation",
"sampling.responses_context_management",
] {
assert!(
!loss_dropped(&plain_report, path),
"absent {path} must not Drop, got {plain_report:?}"
);
}
}
fn json_has_key(value: &Value, key: &str) -> bool {
match value {
Value::Object(map) => {
map.contains_key(key) || map.values().any(|child| json_has_key(child, key))
}
Value::Array(items) => items.iter().any(|child| json_has_key(child, key)),
_ => false,
}
}
#[test]
fn developer_degrades_to_system_on_messages() {
let bytes = golden("developer_chat.json");
let (ir, _loss) = decode(Wire::ChatCompletions, &bytes).expect("decode Chat");
assert!(
ir.items.iter().any(
|item| matches!(item, IrItem::Developer { text, .. } if text == "Follow house style.")
),
"developer item missing: {:?}",
ir.items
);
let (chat_bytes, chat_loss) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("Chat preserves developer");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("json");
assert_eq!(chat["messages"][0]["role"], "developer");
assert_eq!(chat["messages"][0]["content"], "Follow house style.");
assert!(
chat_loss
.events
.iter()
.any(|event| event.action == LossAction::Preserve && event.detail.contains("developer")),
"expected Preserve developer, got {:?}",
chat_loss.events
);
let (msg_bytes, loss) =
encode(Wire::Messages, &ir, &messages_profile()).expect("Messages encode");
let body: Value = serde_json::from_slice(&msg_bytes).expect("json");
assert!(
loss.events.iter().any(|event| {
event.action == LossAction::Degrade && event.detail.contains("developer to system")
}),
"expected developer → system Degrade, got {:?}",
loss.events
);
let system = &body["system"];
let system_text = if let Some(s) = system.as_str() {
s.to_string()
} else {
system
.as_array()
.into_iter()
.flatten()
.filter_map(|block| block.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n")
};
assert!(
system_text.contains("Follow house style."),
"system should carry developer text, got {system}"
);
if let Some(messages) = body["messages"].as_array() {
assert!(
messages
.iter()
.all(|msg| msg.get("role").and_then(Value::as_str) != Some("developer")),
"Messages must not emit role=developer: {messages:?}"
);
}
}
#[test]
fn responses_messages_responses_round_trip_keeps_tool_names() {
let bytes = golden("roundtrip_responses.json");
let flatten = flatten_profile();
let (ir1, _l1) = decode(Wire::Responses, &bytes).expect("decode Responses");
let IrTool::Namespace { name, .. } = namespace_tool(&ir1.tools) else {
panic!("round-trip source must keep namespace");
};
assert_eq!(name, "crm");
let (msg_bytes, msg_loss) = encode(Wire::Messages, &ir1, &flatten).expect("encode Messages");
assert!(
msg_loss.events.iter().any(|event| {
event.action == LossAction::Degrade && event.path.contains("previous_response_id")
}),
"previous_response_id should degrade off Responses, got {:?}",
msg_loss.events
);
assert!(
msg_loss
.events
.iter()
.any(|event| event.action == LossAction::Degrade && event.detail.contains("flatten")),
"namespace should flatten toward Messages, got {:?}",
msg_loss.events
);
let messages: Value = serde_json::from_slice(&msg_bytes).expect("json");
let msg_tools = messages["tools"]
.as_array()
.expect("Messages tools")
.iter()
.filter_map(|tool| tool.get("name").and_then(Value::as_str))
.collect::<Vec<_>>();
assert!(
msg_tools.contains(&"crm.lookup"),
"flattened dotted name missing: {msg_tools:?}"
);
assert!(messages.get("previous_response_id").is_none());
let (ir2, _l2) = decode(Wire::Messages, &msg_bytes).expect("decode Messages");
let function_names: Vec<_> = ir2
.tools
.iter()
.filter_map(|tool| match tool {
IrTool::Function { name, .. } => Some(name.as_str()),
IrTool::Namespace { name, .. } => Some(name.as_str()),
_ => None,
})
.collect();
assert!(
function_names.iter().any(|name| name.contains("lookup")),
"tool name lookup lost after Messages decode: {function_names:?}"
);
let (resp_bytes, resp_loss) =
encode(Wire::Responses, &ir2, &flatten).expect("encode Responses");
let resp: Value = serde_json::from_slice(&resp_bytes).expect("json");
let tools = resp["tools"].as_array().expect("Responses tools");
let names = collect_tool_names(tools);
assert!(
names.iter().any(|name| name == "crm.lookup"),
"round-trip must keep CRM tool names as dotted functions, got {names:?}"
);
assert!(
tools
.iter()
.any(|tool| tool.get("type").and_then(Value::as_str) == Some("function")),
"flatten-namespace encode to Responses must emit function tools, got {tools:?}"
);
assert!(
!resp_loss.events.is_empty() || !msg_loss.events.is_empty(),
"round-trip must report loss"
);
}
#[test]
fn unknown_type_with_name_is_not_relabeled_function() {
let bytes = br#"{
"model": "gpt-5",
"input": "hi",
"tools": [{
"type": "weird",
"name": "do_thing",
"description": "A thing",
"parameters": {"type": "object", "properties": {}}
}]
}"#;
let (ir, _) = decode(Wire::Responses, bytes).expect("decode");
assert!(
matches!(&ir.tools[0], IrTool::Unknown { type_name, .. } if type_name == "weird"),
"type=weird with name must stay Unknown, got {:?}",
ir.tools
);
encode(Wire::Responses, &ir, &hard_error_profile())
.expect_err("hard-error must fail closed on unknown type");
encode(Wire::Responses, &ir, &flatten_profile())
.expect_err("flatten-namespace must not coerce unknown type to function");
let passthrough = profile(
r#"
schema_version = 1
id = "test-passthrough"
wire = "responses"
tool_type_policy = "passthrough"
"#,
);
let (out, _) = encode(Wire::Responses, &ir, &passthrough).expect("passthrough");
let body: Value = serde_json::from_slice(&out).expect("json");
assert_eq!(body["tools"][0]["type"], "weird");
assert_eq!(body["tools"][0]["name"], "do_thing");
}
fn collect_tool_names(tools: &[Value]) -> Vec<String> {
let mut names = Vec::new();
for tool in tools {
if let Some(name) = tool.get("name").and_then(Value::as_str) {
names.push(name.to_string());
}
if let Some(name) = tool
.get("function")
.and_then(|func| func.get("name"))
.and_then(Value::as_str)
{
names.push(name.to_string());
}
if let Some(inner) = tool.get("tools").and_then(Value::as_array) {
for child in inner {
if let Some(name) = child.get("name").and_then(Value::as_str) {
names.push(name.to_string());
}
}
}
}
names
}
#[test]
fn stream_true_survives_chat_messages_responses() {
let chat_req = br#"{
"model": "grok-4",
"stream": true,
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, chat_req).expect("decode chat stream");
assert_eq!(
ir.sampling.stream,
Some(true),
"Grok always-SSE must not drop stream:true"
);
for (wire, profile) in [
(Wire::ChatCompletions, chat_profile()),
(Wire::Messages, messages_profile()),
(Wire::Responses, flatten_profile()),
] {
let (bytes, _) = encode(wire, &ir, &profile).expect("encode keeps stream");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("stream"),
Some(&Value::Bool(true)),
"{wire:?} must send stream:true, got {body}"
);
}
}
#[test]
fn gemini_drops_chat_stream_flag() {
let chat_req = br#"{
"model": "gemini-2.0-flash",
"stream": false,
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, chat_req).expect("decode");
assert_eq!(ir.sampling.stream, Some(false));
let (bytes, loss) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("stream").is_none(),
"Gemini has no JSON stream field, got {body}"
);
assert!(
loss.events
.iter()
.any(|event| { event.path == "sampling.stream" && event.action == LossAction::Drop }),
"dropped stream must be on the loss report, got {loss:?}"
);
let (ir, _) = decode(
Wire::ChatCompletions,
br#"{"model":"gemini-2.0-flash","stream":true,"messages":[{"role":"user","content":"hi"}]}"#,
)
.expect("decode stream true");
let (bytes, _) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode stream true");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("stream").is_none(),
"stream true must not become a Gemini JSON field, got {body}"
);
}
#[test]
fn converse_drops_chat_stream_flag() {
let chat_req = br#"{
"model": "amazon.nova-lite-v1:0",
"stream": false,
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, chat_req).expect("decode");
assert_eq!(ir.sampling.stream, Some(false));
let (bytes, loss) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("stream").is_none(),
"Converse has no JSON stream field, got {body}"
);
assert!(
loss.events
.iter()
.any(|event| { event.path == "sampling.stream" && event.action == LossAction::Drop }),
"dropped stream must be on the loss report, got {loss:?}"
);
let (ir, _) = decode(
Wire::ChatCompletions,
br#"{"model":"amazon.nova-lite-v1:0","stream":true,"messages":[{"role":"user","content":"hi"}]}"#,
)
.expect("decode stream true");
let (bytes, loss) =
encode(Wire::Converse, &ir, &converse_profile()).expect("encode stream true");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(body.get("stream").is_none(), "got {body}");
assert!(
loss.events
.iter()
.any(|event| { event.path == "sampling.stream" && event.action == LossAction::Drop }),
"stream true must be a recorded drop, got {loss:?}"
);
}
#[test]
fn gemini_request_round_trip_text_and_function() {
let req = br#"{
"model": "gemini-2.5-flash",
"systemInstruction": { "parts": [{ "text": "Be brief." }] },
"contents": [
{ "role": "user", "parts": [{ "text": "hi" }] },
{ "role": "model", "parts": [{ "functionCall": { "name": "lookup", "args": { "q": "x" } } }] },
{ "role": "user", "parts": [{ "functionResponse": { "name": "lookup", "response": { "ok": true } } }] }
],
"tools": [{
"functionDeclarations": [{
"name": "lookup",
"description": "Look up",
"parameters": { "type": "object", "properties": { "q": { "type": "string" } } }
}]
}],
"generationConfig": { "temperature": 0.2, "maxOutputTokens": 64 }
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode gemini");
assert_eq!(ir.model, "gemini-2.5-flash");
assert!(
ir.items
.iter()
.any(|item| matches!(item, IrItem::System { text, .. } if text == "Be brief.")),
"systemInstruction: {:?}",
ir.items
);
assert!(
ir.items
.iter()
.any(|item| matches!(item, IrItem::FunctionCall { name, .. } if name == "lookup")),
"functionCall: {:?}",
ir.items
);
assert_eq!(ir.sampling.temperature, Some(0.2));
assert_eq!(ir.sampling.max_tokens, Some(64));
let profile = profile(
r#"
schema_version = 1
id = "test-gemini"
wire = "gemini"
"#,
);
let (bytes, _) = encode(Wire::Gemini, &ir, &profile).expect("encode gemini");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/systemInstruction/parts/0/text")
.and_then(Value::as_str),
Some("Be brief.")
);
assert_eq!(
body.pointer("/tools/0/functionDeclarations/0/name")
.and_then(Value::as_str),
Some("lookup")
);
assert_eq!(
body.pointer("/contents/1/parts/0/functionCall/name")
.and_then(Value::as_str),
Some("lookup"),
"encode must keep functionCall.name, got {body}"
);
assert_eq!(
body.pointer("/contents/1/parts/0/functionCall/args/q")
.and_then(Value::as_str),
Some("x"),
"encode must keep functionCall.args.q, got {body}"
);
assert_eq!(
body.pointer("/contents/2/parts/0/functionResponse/name")
.and_then(Value::as_str),
Some("lookup"),
"encode must keep functionResponse, got {body}"
);
assert_eq!(
body.pointer("/contents/2/parts/0/functionResponse/response/ok"),
Some(&Value::Bool(true)),
"encode must keep functionResponse.response, got {body}"
);
let temp = body
.pointer("/generationConfig/temperature")
.and_then(Value::as_f64)
.expect("temperature");
assert!((temp - 0.2).abs() < 1e-6, "temperature={temp}");
}
#[test]
fn gemini_function_output_keeps_text_inside_cache_breakpoint() {
let ir = IrRequest::new(
"gemini-2.5-flash",
vec![IrItem::FunctionOutput {
call_id: "call-1".into(),
output: String::new(),
is_error: false,
parts: vec![IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(IrPart::Text("from-parts".into())),
}],
}],
);
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/contents/0/parts/0/functionResponse/response/result")
.and_then(Value::as_str),
Some("from-parts"),
"wrapped tool text must be the response, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "part.prompt_cache_breakpoint" && event.action == LossAction::Drop
}),
"breakpoint has no Gemini function-response slot, got {report:?}"
);
}
#[test]
fn gemini_function_output_keeps_image_inside_cache_breakpoint() {
let ir = IrRequest::new(
"gemini-2.5-flash",
vec![IrItem::FunctionOutput {
call_id: "call-1".into(),
output: String::new(),
is_error: false,
parts: vec![IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(IrPart::ImageBase64 {
media_type: "image/png".into(),
data: "aGVsbG8".into(),
}),
}],
}],
);
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/contents/0/parts/0/functionResponse/parts/0/inlineData/data")
.and_then(Value::as_str),
Some("aGVsbG8"),
"wrapped tool image must be inline data, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "part.prompt_cache_breakpoint" && event.action == LossAction::Drop
}),
"breakpoint has no Gemini function-response slot, got {report:?}"
);
}
#[test]
fn gemini_function_output_keeps_text_inside_nested_cache_breakpoints() {
let ir = IrRequest::new(
"gemini-2.5-flash",
vec![IrItem::FunctionOutput {
call_id: "call-1".into(),
output: String::new(),
is_error: false,
parts: vec![IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(IrPart::PromptCacheBreakpoint {
mode: "nested".into(),
inner: Box::new(IrPart::Text("kept-tool-text".into())),
}),
}],
}],
);
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/contents/0/parts/0/functionResponse/response/result")
.and_then(Value::as_str),
Some("kept-tool-text"),
"two breakpoints must keep the tool text, got {body}"
);
assert_eq!(
breakpoint_drops(&report),
2,
"each breakpoint layer has no Gemini slot, got {report:?}"
);
}
#[test]
fn gemini_function_output_keeps_image_inside_nested_cache_breakpoints() {
let ir = IrRequest::new(
"gemini-2.5-flash",
vec![IrItem::FunctionOutput {
call_id: "call-1".into(),
output: String::new(),
is_error: false,
parts: vec![IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(IrPart::PromptCacheBreakpoint {
mode: "nested".into(),
inner: Box::new(IrPart::ImageBase64 {
media_type: "image/png".into(),
data: "aGVsbG8".into(),
}),
}),
}],
}],
);
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/contents/0/parts/0/functionResponse/parts/0/inlineData/data")
.and_then(Value::as_str),
Some("aGVsbG8"),
"two breakpoints must keep the tool image, got {body}"
);
assert_eq!(
breakpoint_drops(&report),
2,
"each breakpoint layer has no Gemini slot, got {report:?}"
);
}
fn gemini_profile() -> ResolvedProfile {
profile(
r#"
schema_version = 1
id = "test-gemini"
wire = "gemini"
"#,
)
}
#[test]
fn gemini_tool_schema_drops_prefix_items() {
let ir = IrRequest::new(
"gemini-2.5-flash",
vec![IrItem::User {
parts: vec![IrPart::Text("hi".into())],
}],
)
.with_tools(vec![IrTool::Function {
name: "coords".into(),
description: "coords".into(),
parameters: serde_json::json!({
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"coords": {
"type": "array",
"prefixItems": [{"type": "number"}, {"type": "number"}]
},
"n": {"type": "number", "exclusiveMinimum": 0},
"hi": {"type": "number", "exclusiveMaximum": 1}
}
}),
strict: None,
}]);
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let coords = body
.pointer("/tools/0/functionDeclarations/0/parameters/properties/coords")
.expect("coords");
assert!(coords.get("prefixItems").is_none(), "{coords}");
assert_eq!(
coords.get("items"),
Some(&serde_json::json!({})),
"dropped prefixItems must leave an empty items schema, got {coords}"
);
let root = body
.pointer("/tools/0/functionDeclarations/0/parameters")
.expect("parameters");
assert!(root.get("$schema").is_none(), "{root}");
let bound = body
.pointer("/tools/0/functionDeclarations/0/parameters/properties/n")
.expect("n");
assert!(bound.get("exclusiveMinimum").is_none(), "{bound}");
assert!(
bound.get("minimum").is_none(),
"exclusiveMinimum must not become minimum, got {bound}"
);
let upper = body
.pointer("/tools/0/functionDeclarations/0/parameters/properties/hi")
.expect("hi");
assert!(upper.get("exclusiveMaximum").is_none(), "{upper}");
assert!(
upper.get("maximum").is_none(),
"exclusiveMaximum must not become maximum, got {upper}"
);
assert!(
report.events.iter().any(|event| {
event.action == LossAction::Drop && event.detail.contains("exclusive bound")
}),
"{report:?}"
);
assert!(
report.events.iter().any(|event| {
event.action == LossAction::Drop && event.detail.contains("prefixItems")
}),
"{report:?}"
);
let nested = IrRequest::new(
"gemini-2.5-flash",
vec![IrItem::User {
parts: vec![IrPart::Text("hi".into())],
}],
)
.with_tools(vec![IrTool::Function {
name: "coords".into(),
description: "coords".into(),
parameters: serde_json::json!({
"type": "object",
"$defs": {
"pair": {
"type": "array",
"prefixItems": [{"type": "number"}, {"type": "number"}]
}
},
"properties": {
"coords": {"$ref": "#/$defs/pair"}
}
}),
strict: None,
}]);
let (nested_bytes, _) = encode(Wire::Gemini, &nested, &gemini_profile()).expect("encode");
let nested_body: Value = serde_json::from_slice(&nested_bytes).expect("json");
let parameters = nested_body
.pointer("/tools/0/functionDeclarations/0/parameters")
.expect("parameters");
assert!(parameters.get("$defs").is_none(), "{parameters}");
let inlined = parameters.pointer("/properties/coords").expect("coords");
assert!(inlined.get("$ref").is_none(), "{inlined}");
assert!(inlined.get("prefixItems").is_none(), "{inlined}");
assert_eq!(
inlined.get("type").and_then(Value::as_str),
Some("array"),
"{inlined}"
);
assert_eq!(
inlined.get("items"),
Some(&serde_json::json!({})),
"inlined prefixItems must leave an empty items schema, got {inlined}"
);
}
#[test]
fn gemini_response_schema_drops_prefix_items() {
let req = br#"{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "coords",
"schema": {
"type": "object",
"properties": {
"coords": {
"type": "array",
"prefixItems": [{"type": "number"}, {"type": "number"}]
}
}
}
}
}
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let coords = body
.pointer("/generationConfig/responseSchema/properties/coords")
.expect("coords");
assert!(coords.get("prefixItems").is_none(), "{coords}");
assert_eq!(
coords.get("items"),
Some(&serde_json::json!({})),
"dropped prefixItems must leave an empty items schema, got {coords}"
);
assert!(
report
.events
.iter()
.any(|event| event.action == LossAction::Drop && event.detail.contains("prefixItems")),
"{report:?}"
);
}
#[test]
fn dest_gemini_function_response_reuses_function_call_id() {
let req = br#"{
"contents": [
{ "role": "user", "parts": [{ "text": "weather Paris" }] },
{ "role": "model", "parts": [{ "functionCall": { "name": "get_weather", "args": { "city": "Paris" } } }] },
{ "role": "user", "parts": [{ "functionResponse": { "name": "get_weather", "response": { "content": "sunny 22C" } } }] }
]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode dest Gemini");
let (bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let call_id = body
.pointer("/messages/1/tool_calls/0/id")
.and_then(Value::as_str);
let result_id = body
.pointer("/messages/2/tool_call_id")
.and_then(Value::as_str);
assert_eq!(
call_id, result_id,
"dest Gemini functionResponse must reuse the functionCall id, got {body}"
);
assert!(
call_id.is_some_and(|id| !id.is_empty()),
"expected a Chat tool call id, got {body}"
);
}
#[test]
fn function_call_thought_signature_round_trips_on_next_request() {
let req = br#"{
"contents": [{
"role": "model",
"parts": [{
"functionCall": { "name": "lookup", "args": { "q": "x" } },
"thoughtSignature": "sig_thought_abc"
}]
}]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, _) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let parts = body
.pointer("/contents/0/parts")
.and_then(Value::as_array)
.expect("parts");
let signed = parts.iter().find(|p| {
p.pointer("/functionCall/name").and_then(Value::as_str) == Some("lookup")
&& p.get("thoughtSignature").and_then(Value::as_str) == Some("sig_thought_abc")
});
assert!(
signed.is_some(),
"function-call thoughtSignature must be sent back on the next request, got {body}"
);
}
#[test]
fn thought_part_signature_is_not_stolen_by_later_function_call() {
let req = br#"{
"contents": [{
"role": "model",
"parts": [
{ "text": "plan", "thought": true, "thoughtSignature": "sig_thought_part" },
{ "functionCall": { "name": "lookup", "args": {} }, "thoughtSignature": "sig_function_call" }
]
}]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, _) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let parts: Vec<&Value> = body
.get("contents")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|c| c.get("parts").and_then(Value::as_array))
.flatten()
.collect();
let thought_sig = parts.iter().find_map(|p| {
(p.get("thought").and_then(Value::as_bool) == Some(true))
.then(|| p.get("thoughtSignature").and_then(Value::as_str))
.flatten()
});
let call_sig = parts.iter().find_map(|p| {
p.get("functionCall")
.and_then(|_| p.get("thoughtSignature").and_then(Value::as_str))
});
assert_eq!(thought_sig, Some("sig_thought_part"));
assert_eq!(
call_sig,
Some("sig_function_call"),
"function call must keep its own thoughtSignature, got {body}"
);
}
#[test]
fn gemini_signature_only_thought_round_trips() {
let req = br#"{
"contents": [{
"role": "model",
"parts": [
{ "text": "answer", "thoughtSignature": "sig_answer" },
{ "thought": true, "thoughtSignature": "sig_thought" },
{ "thoughtSignature": "sig_only" }
]
}]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, _) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let parts = body
.pointer("/contents/0/parts")
.and_then(Value::as_array)
.expect("parts");
let sigs: Vec<&str> = parts
.iter()
.filter_map(|part| part.get("thoughtSignature").and_then(Value::as_str))
.collect();
assert_eq!(
sigs,
["sig_answer", "sig_thought", "sig_only"],
"each thoughtSignature must be sent back, got {body}"
);
}
#[test]
fn gemini_reasoning_summary_encodes_as_thought_part() {
let ir = IrRequest::new(
"gemini-2.5-flash",
vec![
IrItem::User {
parts: vec![IrPart::Text("hi".into())],
},
IrItem::Reasoning {
encrypted: Some("enc-openai-not-gemini".into()),
summary: Some("I should greet them".into()),
raw: None,
},
],
);
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let parts: Vec<&Value> = body
.get("contents")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|c| c.get("parts").and_then(Value::as_array))
.flatten()
.collect();
let thought = parts.iter().find(|p| {
p.get("thought").and_then(Value::as_bool) == Some(true)
&& p.get("text").and_then(Value::as_str) == Some("I should greet them")
});
assert!(
thought.is_some(),
"Reasoning summary must encode as a thought part, got {body}"
);
assert!(
thought
.and_then(|p| p.get("thoughtSignature"))
.and_then(Value::as_str)
!= Some("enc-openai-not-gemini"),
"encrypted_content must not become thoughtSignature, got {body}"
);
assert!(
!report.events.iter().any(|event| {
event.action == LossAction::Drop && event.detail.contains("no generateContent slot")
}),
"summary remaps to a thought part, must not Drop as no slot, got {report:?}"
);
}
#[test]
fn gemini_reasoning_without_summary_omits_thought() {
let ir = IrRequest::new(
"gemini-2.5-flash",
vec![IrItem::Reasoning {
encrypted: Some("enc-only".into()),
summary: None,
raw: None,
}],
);
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let has_thought = body
.get("contents")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|c| c.get("parts").and_then(Value::as_array))
.flatten()
.any(|p| p.get("thought").and_then(Value::as_bool) == Some(true));
assert!(
!has_thought,
"encrypted-only Reasoning must not invent a thought part, got {body}"
);
assert!(
!body.to_string().contains("enc-only"),
"encrypted_content must not become thoughtSignature, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.action == LossAction::Drop
&& event
.detail
.contains("reasoning omitted on generateContent")
}),
"empty Reasoning must Drop naming generateContent, got {report:?}"
);
assert!(
!report.events.iter().any(|event| {
event.action == LossAction::Drop && event.detail.contains("no generateContent slot")
}),
"must not claim no slot after remapping Reasoning, got {report:?}"
);
}
#[test]
fn parallel_function_calls_only_first_signed() {
let req = br#"{
"contents": [{
"role": "model",
"parts": [
{ "functionCall": { "name": "one", "args": {} }, "thoughtSignature": "sig_first_only" },
{ "functionCall": { "name": "two", "args": {} } }
]
}]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, _) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let parts: Vec<&Value> = body
.get("contents")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|c| c.get("parts").and_then(Value::as_array))
.flatten()
.collect();
let one = parts
.iter()
.find(|p| p.pointer("/functionCall/name").and_then(Value::as_str) == Some("one"))
.expect("one");
let two = parts
.iter()
.find(|p| p.pointer("/functionCall/name").and_then(Value::as_str) == Some("two"))
.expect("two");
assert_eq!(
one.get("thoughtSignature").and_then(Value::as_str),
Some("sig_first_only"),
"first parallel call must keep its thoughtSignature, got {body}"
);
assert!(
two.get("thoughtSignature").is_none(),
"unsigned parallel call must not inherit a leftover thoughtSignature, got {body}"
);
}
#[test]
fn gemini_thinking_config_round_trips() {
let req = br#"{
"contents": [{ "role": "user", "parts": [{ "text": "hi" }] }],
"thinkingConfig": { "includeThoughts": true, "thinkingBudget": 24576 }
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, _) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let tc = body
.pointer("/generationConfig/thinkingConfig")
.expect("thinkingConfig should be nested under generationConfig");
assert_eq!(tc["includeThoughts"], true);
assert_eq!(tc["thinkingBudget"], 24576);
assert!(
body.get("thinkingConfig").is_none(),
"must not emit top-level thinkingConfig: {body}"
);
}
#[test]
fn gemini_thinking_config_absent_is_not_invented() {
let req = br#"{
"contents": [{ "role": "user", "parts": [{ "text": "hi" }] }]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, _) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("thinkingConfig").is_none(),
"must not invent thinkingConfig: {body}"
);
assert!(
body.pointer("/generationConfig/thinkingConfig").is_none(),
"must not invent nested thinkingConfig: {body}"
);
}
#[test]
fn gemini_user_text_then_function_response_stays_user() {
let req = br#"{
"contents": [{
"role": "user",
"parts": [
{ "text": "here is the result" },
{ "functionResponse": { "name": "lookup", "response": { "ok": true } } }
]
}]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
assert!(
matches!(
ir.items.as_slice(),
[
IrItem::User { parts },
IrItem::FunctionOutput { call_id, .. }
] if parts.iter().any(|p| matches!(p, IrPart::Text(t) if t == "here is the result"))
&& call_id == "lookup"
),
"mixed user text+functionResponse must be User then FunctionOutput, got {:?}",
ir.items
);
assert!(
!ir.items
.iter()
.any(|item| matches!(item, IrItem::Assistant { .. })),
"user-role pending text must not flush as Assistant, got {:?}",
ir.items
);
}
#[test]
fn gemini_consecutive_function_responses_share_user_turn() {
let req = br#"{
"contents": [
{ "role": "user", "parts": [{ "text": "hi" }] },
{ "role": "model", "parts": [
{ "functionCall": { "name": "one", "args": {} }, "thoughtSignature": "s1" },
{ "functionCall": { "name": "two", "args": {} } }
]},
{ "role": "user", "parts": [
{ "functionResponse": { "name": "one", "response": { "ok": 1 } } },
{ "functionResponse": { "name": "two", "response": { "ok": 2 } } }
]}
]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, _) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let contents = body
.get("contents")
.and_then(Value::as_array)
.expect("contents");
let model_turns = contents
.iter()
.filter(|c| c.get("role").and_then(Value::as_str) == Some("model"))
.count();
let user_tool = contents.iter().find(|c| {
c.get("role").and_then(Value::as_str) == Some("user")
&& c.pointer("/parts/0/functionResponse").is_some()
});
assert_eq!(
model_turns, 1,
"text + calls must share one model content, got {body}"
);
let parts = user_tool
.and_then(|c| c.get("parts").and_then(Value::as_array))
.expect("merged user tool parts");
assert_eq!(
parts.len(),
2,
"consecutive functionResponse must share one user turn, got {body}"
);
}
#[test]
fn gemini_same_name_function_responses_pair_in_order() {
let req = br#"{
"contents": [
{ "role": "user", "parts": [{ "text": "hi" }] },
{ "role": "model", "parts": [
{ "functionCall": { "name": "read_file", "args": { "path": "a.rs" } } },
{ "functionCall": { "name": "read_file", "args": { "path": "b.rs" } } }
]},
{ "role": "user", "parts": [
{ "functionResponse": { "name": "read_file", "response": { "text": "aaa" } } },
{ "functionResponse": { "name": "read_file", "response": { "text": "bbb" } } }
]}
]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let calls: Vec<&str> = ir
.items
.iter()
.filter_map(|item| match item {
IrItem::FunctionCall { call_id, name, .. } if name == "read_file" => {
Some(call_id.as_str())
}
_ => None,
})
.collect();
let outs: Vec<&str> = ir
.items
.iter()
.filter_map(|item| match item {
IrItem::FunctionOutput { call_id, .. } => Some(call_id.as_str()),
_ => None,
})
.collect();
assert_eq!(calls.len(), 2, "two calls, got {:?}", ir.items);
assert_ne!(
calls[0], calls[1],
"same-name calls need distinct ids, got {calls:?}"
);
assert_eq!(
outs, calls,
"each functionResponse must bind the next unmatched call, got outs={outs:?} calls={calls:?}"
);
}
#[test]
fn gemini_stale_same_name_call_does_not_steal_later_response() {
let req = br#"{
"contents": [
{ "role": "user", "parts": [{ "text": "weather" }] },
{ "role": "model", "parts": [
{ "functionCall": { "name": "get_weather", "args": { "city": "Paris" } } }
]},
{ "role": "user", "parts": [{ "text": "try again" }] },
{ "role": "model", "parts": [
{ "functionCall": { "name": "get_weather", "args": { "city": "Lyon" } } }
]},
{ "role": "user", "parts": [
{ "functionResponse": { "name": "get_weather", "response": { "content": "sunny" } } }
]}
]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let calls: Vec<&str> = ir
.items
.iter()
.filter_map(|item| match item {
IrItem::FunctionCall { call_id, name, .. } if name == "get_weather" => {
Some(call_id.as_str())
}
_ => None,
})
.collect();
let outs: Vec<&str> = ir
.items
.iter()
.filter_map(|item| match item {
IrItem::FunctionOutput { call_id, .. } => Some(call_id.as_str()),
_ => None,
})
.collect();
assert_eq!(calls.len(), 2, "two calls, got {:?}", ir.items);
assert_eq!(
outs,
vec![calls[1]],
"the response follows the later call, got outs={outs:?} calls={calls:?}"
);
}
#[test]
fn gemini_later_turn_without_same_name_does_not_reuse_stale_id() {
let req = br#"{
"contents": [
{ "role": "user", "parts": [{ "text": "weather" }] },
{ "role": "model", "parts": [
{ "functionCall": { "name": "get_weather", "args": { "city": "Paris" } } }
]},
{ "role": "user", "parts": [{ "text": "try again" }] },
{ "role": "model", "parts": [
{ "text": "checking" },
{ "functionCall": { "name": "get_time", "args": {} } }
]},
{ "role": "user", "parts": [
{ "text": "result" },
{ "functionResponse": { "name": "get_weather", "response": { "content": "sunny" } } }
]}
]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let weather_ids: Vec<&str> = ir
.items
.iter()
.filter_map(|item| match item {
IrItem::FunctionCall { call_id, name, .. } if name == "get_weather" => {
Some(call_id.as_str())
}
_ => None,
})
.collect();
let weather_out = ir.items.iter().find_map(|item| match item {
IrItem::FunctionOutput { call_id, .. } => Some(call_id.as_str()),
_ => None,
});
assert_eq!(weather_ids.len(), 1, "one stale call, got {:?}", ir.items);
assert_eq!(
weather_out,
Some("get_weather"),
"a response after a turn with no get_weather must not reuse {}, got {:?}",
weather_ids[0],
ir.items
);
assert_ne!(weather_out, Some(weather_ids[0]));
}
#[test]
fn gemini_sibling_text_and_two_responses_pair_in_order() {
let req = br#"{
"contents": [
{ "role": "model", "parts": [
{ "functionCall": { "name": "read_file", "args": { "path": "a.rs" } } },
{ "functionCall": { "name": "read_file", "args": { "path": "b.rs" } } }
]},
{ "role": "user", "parts": [
{ "text": "files" },
{ "functionResponse": { "name": "read_file", "response": { "text": "aaa" } } },
{ "functionResponse": { "name": "read_file", "response": { "text": "bbb" } } }
]}
]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let calls: Vec<&str> = ir
.items
.iter()
.filter_map(|item| match item {
IrItem::FunctionCall { call_id, .. } => Some(call_id.as_str()),
_ => None,
})
.collect();
let outs: Vec<&str> = ir
.items
.iter()
.filter_map(|item| match item {
IrItem::FunctionOutput { call_id, .. } => Some(call_id.as_str()),
_ => None,
})
.collect();
assert_eq!(
outs, calls,
"leading text plus two responses must still pair in order, got {:?}",
ir.items
);
}
#[test]
fn gemini_sibling_user_text_does_not_hide_the_call() {
let req = br#"{
"contents": [
{ "role": "model", "parts": [
{ "functionCall": { "name": "read_file", "args": { "path": "a.rs" } } }
]},
{ "role": "user", "parts": [
{ "text": "file" },
{ "functionResponse": { "name": "read_file", "response": { "text": "aaa" } } }
]}
]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let call = ir.items.iter().find_map(|item| match item {
IrItem::FunctionCall { call_id, .. } => Some(call_id.as_str()),
_ => None,
});
let out = ir.items.iter().find_map(|item| match item {
IrItem::FunctionOutput { call_id, .. } => Some(call_id.as_str()),
_ => None,
});
assert_eq!(
out, call,
"text before the response must still bind that call, got {:?}",
ir.items
);
}
#[test]
fn gemini_same_turn_text_between_calls_still_pairs_in_order() {
let req = br#"{
"contents": [
{ "role": "user", "parts": [{ "text": "hi" }] },
{ "role": "model", "parts": [
{ "text": "checking" },
{ "functionCall": { "name": "read_file", "args": { "path": "a.rs" } } },
{ "text": "and" },
{ "functionCall": { "name": "read_file", "args": { "path": "b.rs" } } }
]},
{ "role": "user", "parts": [
{ "functionResponse": { "name": "read_file", "response": { "text": "aaa" } } },
{ "functionResponse": { "name": "read_file", "response": { "text": "bbb" } } }
]}
]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let calls: Vec<&str> = ir
.items
.iter()
.filter_map(|item| match item {
IrItem::FunctionCall { call_id, name, .. } if name == "read_file" => {
Some(call_id.as_str())
}
_ => None,
})
.collect();
let outs: Vec<&str> = ir
.items
.iter()
.filter_map(|item| match item {
IrItem::FunctionOutput { call_id, .. } => Some(call_id.as_str()),
_ => None,
})
.collect();
assert_eq!(
outs, calls,
"text between calls in one turn still pairs oldest-first, got outs={outs:?} calls={calls:?}"
);
}
#[test]
fn gemini_function_response_uses_function_name_not_call_id() {
let ir = wiremux::IrRequest::new(
"gemini-2.5-flash",
vec![
IrItem::FunctionCall {
call_id: "call_abc".into(),
name: "lookup".into(),
arguments: "{}".into(),
thought_signature: None,
},
IrItem::FunctionOutput {
call_id: "call_abc".into(),
output: "plain text".into(),
parts: Vec::new(),
is_error: false,
},
],
)
.with_sampling(wiremux::IrSampling::default());
let (bytes, _) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/contents/1/parts/0/functionResponse/name")
.and_then(Value::as_str),
Some("lookup"),
"functionResponse.name must be the function name, got {body}"
);
assert_eq!(
body.pointer("/contents/1/parts/0/functionResponse/response/result")
.and_then(Value::as_str),
Some("plain text"),
"non-JSON output must be {{result: text}}, got {body}"
);
}
#[test]
fn stream_absent_is_not_invented() {
let chat_req = br#"{
"model": "grok-4",
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, chat_req).expect("decode");
assert_eq!(ir.sampling.stream, None);
let (bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("stream").is_none(),
"must not invent stream when the source omitted it: {body}"
);
}
#[test]
fn replay_thinking_and_signature_in_assistant_json() {
let req = br#"{
"model": "claude-opus-4-6",
"messages": [{
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "I should greet them", "signature": "sig_abc" },
{ "type": "text", "text": "Hello" }
]
}]
}"#;
let (ir, _) = decode(Wire::Messages, req).expect("decode");
assert!(
ir.items.iter().any(|item| matches!(
item,
IrItem::Assistant { parts, ..} if parts.iter().any(|part| matches!(
part,
IrPart::Thinking { text, signature }
if text == "I should greet them"
&& signature.as_deref() == Some("sig_abc")
))
)),
"decode must produce IrPart::Thinking {{ text: \"I should greet them\", signature: Some(\"sig_abc\") }}, got {:?}",
ir.items
);
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let content = body
.pointer("/messages/0/content")
.and_then(Value::as_array)
.expect("content");
assert!(
content.iter().any(|block| {
block.get("type").and_then(Value::as_str) == Some("thinking")
&& block.get("thinking").and_then(Value::as_str) == Some("I should greet them")
&& block.get("signature").and_then(Value::as_str) == Some("sig_abc")
}),
"replay JSON must include thinking + signature, got {body}"
);
assert!(
!loss_dropped(&report, "part.thinking"),
"signed thinking must not be recorded as dropped, got {report:?}"
);
}
#[test]
fn unsigned_thinking_is_not_replayed_on_messages() {
let ir = IrRequest::new(
"claude-opus-4-6",
vec![IrItem::Assistant {
phase: None,
parts: vec![
IrPart::Thinking {
text: "scratch".into(),
signature: None,
},
IrPart::Text("Hello".into()),
],
}],
);
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let content = body
.pointer("/messages/0/content")
.and_then(Value::as_array)
.expect("content");
assert!(
content
.iter()
.all(|block| block.get("type").and_then(Value::as_str) != Some("thinking")),
"unsigned thinking must not be replayed, got {body}"
);
assert!(
!body.to_string().contains("scratch"),
"unsigned thinking text must not leak onto the wire, got {body}"
);
assert!(
content
.iter()
.all(|block| block.get("text").and_then(Value::as_str) != Some("scratch")),
"unsigned thinking must not leak as a text block, got {body}"
);
assert!(
content
.iter()
.any(|block| block.get("text").and_then(Value::as_str) == Some("Hello")),
"visible text must stay, got {body}"
);
assert!(
loss_dropped(&report, "part.thinking"),
"unsigned thinking drop missing, got {report:?}"
);
}
#[test]
fn replay_redacted_thinking_in_assistant_json() {
let req = br#"{
"model": "claude-opus-4-6",
"messages": [{
"role": "assistant",
"content": [
{ "type": "redacted_thinking", "data": "enc_abc" },
{ "type": "text", "text": "Hello" }
]
}]
}"#;
let (ir, _) = decode(Wire::Messages, req).expect("decode");
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let content = body
.pointer("/messages/0/content")
.and_then(Value::as_array)
.expect("content");
assert!(
content.iter().any(|block| {
block.get("type").and_then(Value::as_str) == Some("redacted_thinking")
&& block.get("data").and_then(Value::as_str) == Some("enc_abc")
}),
"replay JSON must include redacted_thinking, got {body}"
);
}
#[test]
fn chat_skips_thinking_and_protocol_parts() {
let ir = IrRequest::new(
"grok-4",
vec![IrItem::Assistant {
phase: None,
parts: vec![
IrPart::Thinking {
text: "plan".into(),
signature: Some("sig".into()),
},
IrPart::Raw {
type_name: "redacted_thinking".into(),
raw: serde_json::json!({"type": "redacted_thinking", "data": "enc"}),
},
IrPart::Text("Hello".into()),
],
}],
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let content = &body["messages"][0]["content"];
assert_eq!(
content, "Hello",
"Chat must send only visible text, got {body}"
);
assert!(
loss_dropped(&report, "part.thinking"),
"thinking drop missing, got {report:?}"
);
assert!(
loss_dropped(&report, "part.raw"),
"raw drop missing, got {report:?}"
);
}
#[test]
fn responses_drops_unsigned_thinking_without_adding_include() {
let ir = IrRequest::new(
"gpt-5",
vec![IrItem::Assistant {
phase: None,
parts: vec![
IrPart::Thinking {
text: "secret plan".into(),
signature: None,
},
IrPart::Raw {
type_name: "redacted_thinking".into(),
raw: serde_json::json!({"type": "redacted_thinking", "data": "enc"}),
},
IrPart::Text("Hello".into()),
],
}],
);
let (bytes, report) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("include").is_none(),
"unset include must stay off the wire, got {body}"
);
let dumped = body.to_string();
assert!(
!dumped.contains("secret plan"),
"unsigned thinking must not become output_text, got {body}"
);
assert!(
dumped.contains("redacted_thinking"),
"Raw part must be replayed as a content part, got {body}"
);
assert!(
loss_dropped(&report, "part.thinking"),
"thinking drop missing, got {report:?}"
);
assert!(
!loss_dropped(&report, "part.raw"),
"Raw must not be dropped, got {report:?}"
);
assert!(
report
.events
.iter()
.all(|event| event.path != "sampling.include"),
"unset include must not record sampling.include, got {report:?}"
);
}
#[test]
fn replay_thinking_signed_encodes_as_responses_reasoning() {
let req = br#"{
"model": "claude-opus-4-6",
"messages": [{
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "I should greet them", "signature": "sig_abc" },
{ "type": "text", "text": "Hello" }
]
}]
}"#;
let (ir, _) = decode(Wire::Messages, req).expect("decode");
let (bytes, report) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let input = body
.get("input")
.and_then(Value::as_array)
.expect("Responses input");
assert!(
input.iter().any(|item| {
item.get("type").and_then(Value::as_str) == Some("reasoning")
&& item.get("encrypted_content").and_then(Value::as_str) == Some("sig_abc")
}),
"signed thinking must encode as a sibling reasoning item, got {body}"
);
assert!(
!loss_dropped(&report, "part.thinking"),
"signed thinking must not Drop, got {report:?}"
);
assert!(
report
.events
.iter()
.any(|event| { event.path == "part.thinking" && event.action == LossAction::Preserve }),
"signed thinking remapped to reasoning must Preserve, got {report:?}"
);
}
#[test]
fn chat_stream_true_requests_include_usage() {
let req = br#"{
"model": "grok-4",
"stream": true,
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/stream_options/include_usage"),
Some(&Value::Bool(true)),
"stream:true must request usage, got {body}"
);
}
#[test]
fn gemini_non_object_tool_output_is_wrapped_and_degraded() {
let ir = IrRequest::new(
"gemini-2.5-flash",
vec![
IrItem::FunctionCall {
call_id: "call_1".into(),
name: "lookup".into(),
arguments: r#"{"q":"x"}"#.into(),
thought_signature: None,
},
IrItem::FunctionOutput {
call_id: "call_1".into(),
output: "not-json".into(),
parts: Vec::new(),
is_error: false,
},
],
);
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/contents/1/parts/0/functionResponse/response"),
Some(&serde_json::json!({ "result": "not-json" })),
"non-JSON tool output must stay visible in an object, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "functionResponse.response"
&& event.action == LossAction::Degrade
&& event.detail.contains("tool output is not a JSON object")
}),
"non-object tool output must Degrade, got {report:?}"
);
let ir = IrRequest::new(
"gemini-2.5-flash",
vec![IrItem::FunctionOutput {
call_id: "call_2".into(),
output: "[1]".into(),
parts: Vec::new(),
is_error: false,
}],
);
let (bytes, _) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/contents/0/parts/0/functionResponse/response"),
Some(&serde_json::json!({ "result": [1] })),
"a JSON array is not a functionResponse object, got {body}"
);
}
#[test]
fn responses_system_between_users_moves_and_keeps_turns() {
let req = br#"{
"model": "gpt-4.1-mini",
"messages": [
{"role": "user", "content": "first"},
{"role": "system", "content": "late rule"},
{"role": "user", "content": "second"}
]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, report) = encode(Wire::Responses, &ir, &hard_error_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(body["instructions"], "late rule", "got {body}");
let input = body["input"].as_array().expect("input");
assert_eq!(
input.len(),
2,
"Responses keeps separate user items, got {body}"
);
assert_eq!(input[0]["role"], "user");
assert_eq!(input[1]["role"], "user");
assert!(
report.events.iter().any(|event| {
event.path == "items[1]"
&& event.action == LossAction::Degrade
&& event
.detail
.contains("system message moved out of the turn list")
}),
"moving a mid-list system must Degrade, got {report:?}"
);
}
#[test]
fn converse_system_between_users_is_moved_and_users_join() {
let req = br#"{
"model": "amazon.nova-lite-v1:0",
"messages": [
{"role": "user", "content": "first"},
{"role": "system", "content": "late rule"},
{"role": "user", "content": "second"}
]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/system/0/text").and_then(Value::as_str),
Some("late rule"),
"got {body}"
);
let messages = body["messages"].as_array().expect("messages");
assert_eq!(
messages.len(),
1,
"adjacent user turns must be one message, got {body}"
);
assert_eq!(
messages[0]["content"],
serde_json::json!([{"text": "first"}, {"text": "second"}]),
"got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "items[1]"
&& event.action == LossAction::Degrade
&& event
.detail
.contains("system message moved out of the turn list")
}),
"moving a mid-list system must Degrade, got {report:?}"
);
assert!(
loss_degraded(&report, "items[2]"),
"the second user turn must Degrade when joined, got {report:?}"
);
}
#[test]
fn gemini_system_between_users_is_moved_and_users_join() {
let req = br#"{
"model": "gemini-2.5-flash",
"messages": [
{"role": "user", "content": "first"},
{"role": "system", "content": "late rule"},
{"role": "user", "content": "second"}
]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/systemInstruction/parts/0/text")
.and_then(Value::as_str),
Some("late rule"),
"got {body}"
);
assert_eq!(
body.pointer("/contents/0/parts"),
Some(&serde_json::json!([{"text": "first"}, {"text": "second"}])),
"adjacent user turns must share one content, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "items[1]"
&& event.action == LossAction::Degrade
&& event
.detail
.contains("system message moved out of the turn list")
}),
"moving a mid-list system must Degrade, got {report:?}"
);
assert!(
loss_degraded(&report, "items[2]"),
"the second user turn must Degrade when joined, got {report:?}"
);
}
#[test]
fn messages_system_between_users_is_moved_and_users_join() {
let req = br#"{
"model": "claude-haiku-4-5",
"messages": [
{"role": "user", "content": "first"},
{"role": "system", "content": "late rule"},
{"role": "user", "content": "second"}
]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(body["system"], "late rule", "got {body}");
let messages = body["messages"].as_array().expect("messages");
assert_eq!(
messages.len(),
1,
"adjacent user turns must be one message, got {body}"
);
assert_eq!(
messages[0]["content"],
serde_json::json!([
{"type": "text", "text": "first"},
{"type": "text", "text": "second"}
]),
"got {body}"
);
assert!(
report.events.iter().any(|event| {
event.action == LossAction::Degrade
&& event
.detail
.contains("system message moved out of the turn list")
}),
"moving a mid-list system must Degrade, got {report:?}"
);
assert!(
loss_degraded(&report, "items[2]"),
"the second user turn must Degrade when joined, got {report:?}"
);
}
#[test]
fn messages_empty_function_tool_name_is_a_hard_error() {
let req = br#"{
"model": "claude-haiku-4-5",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"type": "function", "function": {"description": "no name"}}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let err = encode(Wire::Messages, &ir, &messages_profile())
.expect_err("empty tool name must not be sent upstream");
match err {
MapError::HardError { path, detail } => {
assert_eq!(path, "tools[0]", "{path}");
assert!(detail.contains("function tool name is empty"), "{detail}");
}
other => panic!("expected HardError, got {other}"),
}
}
#[test]
fn chat_zero_n_is_a_hard_error() {
let mut ir = IrRequest::new(
"gpt-4o",
vec![IrItem::User {
parts: vec![IrPart::Text("hello".into())],
}],
);
ir.sampling.n = Some(0);
let err = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect_err("n");
match err {
MapError::HardError { path, detail } => {
assert_eq!(path, "sampling.n");
assert!(detail.contains("at least 1"), "{detail}");
}
other => panic!("expected HardError, got {other}"),
}
ir.sampling.n = Some(1);
let (bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("\"n\":1"),
"an n of 1 must still be sent, got {body}"
);
}
#[test]
fn chat_penalty_outside_vendor_range_is_a_hard_error() {
let mut ir = IrRequest::new(
"gpt-4o",
vec![IrItem::User {
parts: vec![IrPart::Text("hello".into())],
}],
);
ir.sampling.frequency_penalty = Some(2.5);
let err = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect_err("frequency");
match err {
MapError::HardError { path, detail } => {
assert_eq!(path, "sampling.frequency_penalty");
assert!(detail.contains("between -2 and 2"), "{detail}");
}
other => panic!("expected HardError, got {other}"),
}
ir.sampling.frequency_penalty = Some(-2.0);
ir.sampling.presence_penalty = Some(-2.5);
let err = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect_err("presence");
match err {
MapError::HardError { path, detail } => {
assert_eq!(path, "sampling.presence_penalty");
assert!(detail.contains("between -2 and 2"), "{detail}");
}
other => panic!("expected HardError, got {other}"),
}
ir.sampling.presence_penalty = Some(2.0);
let (bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("\"frequency_penalty\":-2") && body.contains("\"presence_penalty\":2"),
"the penalty bounds must still be sent, got {body}"
);
}
#[test]
fn messages_keeps_temperature_when_top_p_is_also_set() {
let mut ir = IrRequest::new(
"claude",
vec![IrItem::User {
parts: vec![IrPart::Text("hello".into())],
}],
);
ir.sampling.temperature = Some(0.2);
ir.sampling.top_p = Some(0.9);
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("\"temperature\":0.2"),
"temperature must stay, got {body}"
);
assert!(
!body.contains("top_p"),
"Messages must not send top_p with temperature, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "sampling.top_p" && event.detail.contains("cannot both be specified")
}),
"the dropped top_p must be recorded, got {report:?}"
);
ir.sampling.temperature = None;
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("top_p only");
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("\"top_p\":0.9"),
"top_p alone must still be sent, got {body}"
);
}
#[test]
fn sampling_outside_vendor_range_is_a_hard_error() {
let mut messages = IrRequest::new(
"claude",
vec![IrItem::User {
parts: vec![IrPart::Text("hello".into())],
}],
);
messages.sampling.temperature = Some(1.5);
let err = encode(Wire::Messages, &messages, &messages_profile()).expect_err("messages");
match err {
MapError::HardError { path, detail } => {
assert_eq!(path, "sampling.temperature");
assert!(detail.contains("between 0 and 1"), "{detail}");
}
other => panic!("expected HardError, got {other}"),
}
messages.sampling.temperature = Some(1.0);
let (bytes, _) = encode(Wire::Messages, &messages, &messages_profile()).expect("encode");
assert!(
String::from_utf8_lossy(&bytes).contains("\"temperature\":1"),
"messages temperature 1 must be sent"
);
let mut chat = IrRequest::new(
"gpt-4o",
vec![IrItem::User {
parts: vec![IrPart::Text("hello".into())],
}],
);
chat.sampling.temperature = Some(2.5);
let err = encode(Wire::ChatCompletions, &chat, &chat_profile()).expect_err("chat");
match err {
MapError::HardError { path, detail } => {
assert_eq!(path, "sampling.temperature");
assert!(detail.contains("between 0 and 2"), "{detail}");
}
other => panic!("expected HardError, got {other}"),
}
chat.sampling.temperature = Some(2.0);
chat.sampling.top_p = Some(1.5);
let err = encode(Wire::ChatCompletions, &chat, &chat_profile()).expect_err("chat top_p");
match err {
MapError::HardError { path, .. } => assert_eq!(path, "sampling.top_p"),
other => panic!("expected HardError, got {other}"),
}
chat.sampling.top_p = Some(1.0);
let (bytes, _) = encode(Wire::ChatCompletions, &chat, &chat_profile()).expect("encode");
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("\"temperature\":2") && body.contains("\"top_p\":1"),
"chat bounds must be sent, got {body}"
);
let mut gpt5 = IrRequest::new(
"gpt-5",
vec![IrItem::User {
parts: vec![IrPart::Text("hello".into())],
}],
);
gpt5.sampling.temperature = Some(2.5);
let (bytes, report) = encode(Wire::ChatCompletions, &gpt5, &chat_profile()).expect("encode");
let body = String::from_utf8_lossy(&bytes);
assert!(
!body.contains("temperature"),
"gpt-5 still drops temperature instead of failing the request, got {body}"
);
assert!(
report
.events
.iter()
.any(|event| event.path == "sampling.temperature"),
"gpt-5 must still record the temperature drop"
);
let mut responses = IrRequest::new(
"gpt-4o",
vec![IrItem::User {
parts: vec![IrPart::Text("hello".into())],
}],
);
responses.sampling.temperature = Some(2.5);
let err = encode(Wire::Responses, &responses, &flatten_profile()).expect_err("responses");
match err {
MapError::HardError { path, detail } => {
assert_eq!(path, "sampling.temperature");
assert!(detail.contains("between 0 and 2"), "{detail}");
}
other => panic!("expected HardError, got {other}"),
}
}
#[test]
fn converse_sampling_outside_unit_interval_is_a_hard_error() {
let mut ir = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![IrItem::User {
parts: vec![IrPart::Text("hello".into())],
}],
);
ir.sampling.temperature = Some(1.5);
let err = encode(Wire::Converse, &ir, &converse_profile())
.expect_err("temperature above 1 must not be sent");
match err {
MapError::HardError { path, detail } => {
assert_eq!(path, "sampling.temperature");
assert!(detail.contains("between 0 and 1"), "{detail}");
}
other => panic!("expected HardError, got {other}"),
}
ir.sampling.temperature = Some(1.0);
ir.sampling.top_p = Some(1.5);
let err = encode(Wire::Converse, &ir, &converse_profile())
.expect_err("top_p above 1 must not be sent");
match err {
MapError::HardError { path, detail } => {
assert_eq!(path, "sampling.top_p");
assert!(detail.contains("between 0 and 1"), "{detail}");
}
other => panic!("expected HardError, got {other}"),
}
ir.sampling.top_p = Some(0.0);
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("\"temperature\":1") && body.contains("\"topP\":0"),
"the unit-interval bounds must still be sent, got {body}"
);
}
#[test]
fn converse_zero_max_tokens_is_a_hard_error() {
let mut ir = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![IrItem::User {
parts: vec![IrPart::Text("hello".into())],
}],
);
ir.sampling.max_tokens = Some(0);
let err = encode(Wire::Converse, &ir, &converse_profile())
.expect_err("zero max_tokens must not be sent");
match err {
MapError::HardError { path, detail } => {
assert_eq!(path, "sampling.max_tokens");
assert!(detail.contains("at least 1"), "{detail}");
}
other => panic!("expected HardError, got {other}"),
}
ir.sampling.max_tokens = Some(1);
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("\"maxTokens\":1"),
"a max_tokens of 1 must still be sent, got {body}"
);
}
#[test]
fn messages_zero_max_tokens_is_a_hard_error() {
let mut ir = IrRequest::new(
"claude",
vec![IrItem::User {
parts: vec![IrPart::Text("hello".into())],
}],
);
ir.sampling.max_tokens = Some(0);
let err = encode(Wire::Messages, &ir, &messages_profile())
.expect_err("zero max_tokens must not be sent");
match err {
MapError::HardError { path, detail } => {
assert_eq!(path, "sampling.max_tokens");
assert!(detail.contains("at least 1"), "{detail}");
}
other => panic!("expected HardError, got {other}"),
}
ir.sampling.max_tokens = Some(1);
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("\"max_tokens\":1"),
"a max_tokens of 1 must still be sent, got {body}"
);
}
fn text_turn(model: &str) -> IrRequest {
IrRequest::new(
model,
vec![IrItem::User {
parts: vec![IrPart::Text("hello".into())],
}],
)
}
fn assert_zero_max_tokens_rejected(wire: Wire, ir: &IrRequest, profile: &ResolvedProfile) {
let err = encode(wire, ir, profile).expect_err("zero max_tokens must not be sent");
match err {
MapError::HardError { path, detail } => {
assert_eq!(path, "sampling.max_tokens");
assert!(detail.contains("at least 1"), "{detail}");
}
other => panic!("expected HardError, got {other}"),
}
}
fn encoded_body(wire: Wire, ir: &IrRequest, profile: &ResolvedProfile) -> Value {
let (bytes, _) = encode(wire, ir, profile).expect("encode");
serde_json::from_slice(&bytes).expect("json")
}
#[test]
fn chat_zero_max_tokens_is_a_hard_error() {
for (model, field, other) in [
("gpt-4o", "max_tokens", "max_completion_tokens"),
("o3-mini", "max_completion_tokens", "max_tokens"),
] {
let mut ir = text_turn(model);
ir.sampling.max_tokens = Some(0);
assert_zero_max_tokens_rejected(Wire::ChatCompletions, &ir, &chat_profile());
ir.sampling.max_tokens = None;
let omitted = encoded_body(Wire::ChatCompletions, &ir, &chat_profile());
assert!(
omitted.get("max_tokens").is_none(),
"{model} must omit max_tokens when the cap is unset, got {omitted}"
);
assert!(
omitted.get("max_completion_tokens").is_none(),
"{model} must omit max_completion_tokens when the cap is unset, got {omitted}"
);
ir.sampling.max_tokens = Some(1);
let body = encoded_body(Wire::ChatCompletions, &ir, &chat_profile());
assert_eq!(
body.get(field).and_then(Value::as_u64),
Some(1),
"{model} must send {field}=1, got {body}"
);
assert!(
body.get(other).is_none(),
"{model} must omit {other}, got {body}"
);
}
}
#[test]
fn responses_zero_max_tokens_is_a_hard_error() {
let mut ir = text_turn("gpt-4o");
ir.sampling.max_tokens = Some(0);
assert_zero_max_tokens_rejected(Wire::Responses, &ir, &flatten_profile());
ir.sampling.max_tokens = None;
let omitted = encoded_body(Wire::Responses, &ir, &flatten_profile());
assert!(
omitted.get("max_output_tokens").is_none(),
"an omitted cap must stay omitted, got {omitted}"
);
assert!(
omitted.get("max_tokens").is_none(),
"Responses must not invent max_tokens, got {omitted}"
);
ir.sampling.max_tokens = Some(1);
let body = encoded_body(Wire::Responses, &ir, &flatten_profile());
assert_eq!(
body.get("max_output_tokens").and_then(Value::as_u64),
Some(1),
"a max_tokens of 1 must still be sent, got {body}"
);
}
#[test]
fn gemini_zero_max_tokens_is_a_hard_error() {
let mut ir = text_turn("gemini-2.5-flash");
ir.sampling.max_tokens = Some(0);
assert_zero_max_tokens_rejected(Wire::Gemini, &ir, &gemini_profile());
ir.sampling.max_tokens = None;
let omitted = encoded_body(Wire::Gemini, &ir, &gemini_profile());
assert!(
omitted
.pointer("/generationConfig/maxOutputTokens")
.is_none(),
"an omitted cap must stay omitted, got {omitted}"
);
ir.sampling.max_tokens = Some(1);
let body = encoded_body(Wire::Gemini, &ir, &gemini_profile());
assert_eq!(
body.pointer("/generationConfig/maxOutputTokens")
.and_then(Value::as_u64),
Some(1),
"a max_tokens of 1 must still be sent, got {body}"
);
}
#[test]
fn messages_whitespace_only_assistant_becomes_dot() {
let ir = IrRequest::new(
"claude-opus-4-6",
vec![IrItem::Assistant {
phase: None,
parts: vec![IrPart::Text("\n".into())],
}],
);
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/text")
.and_then(Value::as_str),
Some("."),
"whitespace-only text must become '.', got {body}"
);
assert!(
loss_degraded(&report, "items[0]"),
"placeholder '.' must Degrade, got {report:?}"
);
}
#[test]
fn messages_null_user_content_becomes_dot_with_loss() {
let req = br#"{
"model": "claude-haiku-4-5",
"messages": [{"role": "user", "content": null}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/text")
.and_then(Value::as_str),
Some("."),
"null user content must not be an empty Messages text block, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "items[0]"
&& event.action == LossAction::Degrade
&& event.detail.contains("empty content became '.'")
}),
"null user content must Degrade, got {report:?}"
);
}
#[test]
fn chat_data_url_becomes_image_base64_for_gemini() {
let req = br#"{
"model": "gpt-4o",
"messages": [{
"role": "user",
"content": [
{ "type": "text", "text": "see" },
{ "type": "image_url", "image_url": { "url": "data:image/png;base64,iVBORw0KGgo=" } }
]
}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert!(
ir.items.iter().any(|item| matches!(
item,
IrItem::User { parts } if parts.iter().any(|p| matches!(
p,
IrPart::ImageBase64 { media_type, data }
if media_type == "image/png" && data == "iVBORw0KGgo="
))
)),
"data URL must become ImageBase64 with payload, got {:?}",
ir.items
);
let (bytes, _) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/contents/0/parts/1/inlineData/mimeType")
.and_then(Value::as_str),
Some("image/png"),
"Gemini must get inlineData, got {body}"
);
assert_eq!(
body.pointer("/contents/0/parts/1/inlineData/data")
.and_then(Value::as_str),
Some("iVBORw0KGgo="),
"Gemini must encode inlineData/data, got {body}"
);
}
#[test]
fn responses_data_url_becomes_image_base64_for_gemini() {
let req = br#"{
"model": "gpt-4o",
"input": [{
"role": "user",
"content": [
{ "type": "input_text", "text": "see" },
{ "type": "input_image", "image_url": "data:image/png;base64,iVBORw0KGgo=" }
]
}]
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
assert!(
ir.items.iter().any(|item| matches!(
item,
IrItem::User { parts } if parts.iter().any(|p| matches!(
p,
IrPart::ImageBase64 { media_type, data }
if media_type == "image/png" && data == "iVBORw0KGgo="
))
)),
"Responses data URL must become ImageBase64 with payload, got {:?}",
ir.items
);
let (bytes, _) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/contents/0/parts/1/inlineData/mimeType")
.and_then(Value::as_str),
Some("image/png"),
"Gemini must get inlineData, got {body}"
);
assert_eq!(
body.pointer("/contents/0/parts/1/inlineData/data")
.and_then(Value::as_str),
Some("iVBORw0KGgo="),
"Gemini must encode inlineData/data, got {body}"
);
}
#[test]
fn chat_tool_audio_is_recorded() {
let req = br#"{
"model": "gpt-4o",
"messages": [{
"role": "tool",
"tool_call_id": "call_a",
"content": [{
"type": "input_audio",
"input_audio": {"data": "AAAA", "format": "wav"}
}]
}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, report) =
encode(Wire::ChatCompletions, &ir, &hard_error_profile()).expect("encode");
let body = String::from_utf8_lossy(&bytes);
assert!(
!body.contains("AAAA"),
"tool audio must not vanish into the tool text, got {body}"
);
assert!(
report
.events
.iter()
.any(|event| { event.path.contains("audio") && event.detail.contains("no slot") }),
"tool audio must be recorded, got {report:?}"
);
}
#[test]
fn responses_assistant_image_is_not_input_image() {
let ir = IrRequest::new(
"gpt-4o",
vec![IrItem::Assistant {
parts: vec![
IrPart::Text("see".into()),
IrPart::ImageBase64 {
media_type: "image/png".into(),
data: "AAAA".into(),
},
IrPart::Audio {
data: "AAAA".into(),
format: "wav".into(),
},
],
phase: None,
}],
);
let (bytes, report) = encode(Wire::Responses, &ir, &hard_error_profile()).expect("encode");
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("\"type\":\"output_text\""),
"assistant text must stay output_text, got {body}"
);
assert!(
!body.contains("input_image") && !body.contains("input_audio"),
"an assistant message must not carry input media, got {body}"
);
assert!(
report
.events
.iter()
.any(|event| event.detail.contains("cannot carry an image")),
"the image drop must be recorded, got {report:?}"
);
assert!(
report
.events
.iter()
.any(|event| event.detail.contains("cannot carry audio")),
"the audio drop must be recorded, got {report:?}"
);
}
#[test]
fn responses_input_image_file_id_round_trips() {
let req = br#"{
"model": "gpt-5",
"input": [{
"role": "user",
"content": [{
"type": "input_image",
"file_id": "file_abc"
}]
}]
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
let (bytes, _) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/input/0/content/0/type")
.and_then(serde_json::Value::as_str),
Some("input_image"),
"file id image must stay an input_image, got {body}"
);
assert_eq!(
body.pointer("/input/0/content/0/file_id")
.and_then(serde_json::Value::as_str),
Some("file_abc"),
"file id must round-trip, got {body}"
);
}
#[test]
fn responses_input_image_detail_round_trips() {
let req = br#"{
"model": "gpt-5",
"input": [{
"role": "user",
"content": [{
"type": "input_image",
"image_url": "https://example.com/cat.png",
"detail": "original"
}]
}]
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
let (bytes, report) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/input/0/content/0/detail")
.and_then(serde_json::Value::as_str),
Some("original"),
"image detail must stay on the next request, got {body}"
);
assert!(
report
.events
.iter()
.all(|event| event.path != "part.image.detail"),
"same-wire detail must not be recorded as dropped, got {report:?}"
);
let auto = br#"{
"model": "gpt-5",
"input": [{
"role": "user",
"content": [{
"type": "input_image",
"image_url": "https://example.com/cat.png",
"detail": "auto"
}]
}]
}"#;
let (ir, _) = decode(Wire::Responses, auto).expect("decode auto");
let (bytes, _) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode auto");
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.pointer("/input/0/content/0/detail").is_none(),
"default auto detail must stay omitted, got {body}"
);
}
#[test]
fn responses_prompt_cache_breakpoint_round_trips() {
let req = br#"{
"model": "gpt-5",
"input": [{
"role": "user",
"content": [{
"type": "input_text",
"text": "prefix",
"prompt_cache_breakpoint": {"mode": "explicit"}
}]
}]
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
let (bytes, _) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/input/0/content/0/text")
.and_then(Value::as_str),
Some("prefix"),
"breakpoint text must stay, got {body}"
);
assert_eq!(
body.pointer("/input/0/content/0/prompt_cache_breakpoint/mode")
.and_then(Value::as_str),
Some("explicit"),
"cache breakpoint must stay on the next request, got {body}"
);
}
#[test]
fn chat_prompt_cache_breakpoint_round_trips_and_messages_keep_text() {
let req = br#"{
"model": "gpt-4o",
"messages": [{
"role": "user",
"content": [{
"type": "text",
"text": "prefix",
"prompt_cache_breakpoint": {"mode": "explicit"}
}]
}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/prompt_cache_breakpoint/mode")
.and_then(Value::as_str),
Some("explicit"),
"cache breakpoint must stay on the next request, got {body}"
);
let (msg_bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("messages");
let msg: Value = serde_json::from_slice(&msg_bytes).expect("json");
let msg_text = msg.to_string();
assert!(
msg_text.contains("prefix"),
"messages must still show the text, got {msg}"
);
assert!(
report.events.iter().any(|event| {
event.action == LossAction::Drop && event.detail.contains("prompt cache breakpoint")
}),
"messages must record that the breakpoint has no slot, got {report:?}"
);
}
#[test]
fn responses_nested_cache_thinking_inside_breakpoint_drops_secret() {
let ir = IrRequest::new(
"gpt-5",
vec![IrItem::User {
parts: vec![
IrPart::Text("hello".into()),
IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(IrPart::Thinking {
text: "secret".into(),
signature: None,
}),
}),
},
],
}],
);
let (bytes, report) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body = String::from_utf8_lossy(&bytes);
assert!(body.contains("hello"), "visible text must stay, got {body}");
assert!(
!body.contains("secret"),
"wrapped thinking must not be sent, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "part.thinking"
&& event.action == LossAction::Drop
&& event.detail.contains("thinking has no Responses slot")
}),
"wrapped thinking must record part.thinking, got {report:?}"
);
}
#[test]
fn responses_nested_cache_breakpoint_keeps_hello() {
let ir = IrRequest::new(
"gpt-5",
vec![IrItem::User {
parts: vec![IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(IrPart::PromptCacheBreakpoint {
mode: "nested".into(),
inner: Box::new(IrPart::Text("hello".into())),
}),
}],
}],
);
let (bytes, _) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/input/0/content/0/text")
.and_then(Value::as_str),
Some("hello"),
"nested breakpoints must still send the text, got {body}"
);
assert_eq!(
body.pointer("/input/0/content/0/prompt_cache_breakpoint/mode")
.and_then(Value::as_str),
Some("explicit"),
"outermost cache breakpoint must stay, got {body}"
);
assert!(
!body.to_string().contains("nested"),
"inner breakpoint mode must not replace the outer mode, got {body}"
);
}
#[test]
fn chat_nested_cache_thinking_inside_breakpoint_drops_secret() {
let ir = IrRequest::new(
"gpt-4o",
vec![IrItem::User {
parts: vec![
IrPart::Text("hello".into()),
IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(IrPart::Thinking {
text: "secret".into(),
signature: None,
}),
}),
},
],
}],
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body = String::from_utf8_lossy(&bytes);
assert!(body.contains("hello"), "visible text must stay, got {body}");
assert!(
!body.contains("secret"),
"wrapped thinking must not be sent, got {body}"
);
assert!(
loss_dropped(&report, "part.thinking"),
"wrapped thinking must record part.thinking, got {report:?}"
);
}
#[test]
fn chat_nested_cache_breakpoint_keeps_hello() {
let ir = IrRequest::new(
"gpt-4o",
vec![IrItem::User {
parts: vec![IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(IrPart::PromptCacheBreakpoint {
mode: "nested".into(),
inner: Box::new(IrPart::Text("hello".into())),
}),
}],
}],
);
let (bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/text")
.and_then(Value::as_str),
Some("hello"),
"nested breakpoints must still send the text, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/prompt_cache_breakpoint/mode")
.and_then(Value::as_str),
Some("explicit"),
"outermost cache breakpoint must stay, got {body}"
);
assert!(
!body.to_string().contains("nested"),
"inner breakpoint mode must not replace the outer mode, got {body}"
);
}
#[test]
fn responses_system_prompt_cache_breakpoint_round_trips() {
let req = br#"{
"model": "gpt-5",
"input": [{
"role": "system",
"content": [{
"type": "input_text",
"text": "rules",
"prompt_cache_breakpoint": {"mode": "explicit"}
}]
}]
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
let (bytes, _) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/input/0/content/0/prompt_cache_breakpoint/mode")
.and_then(Value::as_str),
Some("explicit"),
"system cache breakpoint must stay on the Responses turn, got {body}"
);
assert!(
body.get("instructions").is_none(),
"a breakpoint cannot live on the instructions string, got {body}"
);
}
#[test]
fn responses_developer_prompt_cache_breakpoint_round_trips() {
let req = br#"{
"model": "gpt-5",
"input": [{
"role": "developer",
"content": [{
"type": "input_text",
"text": "rules",
"prompt_cache_breakpoint": {"mode": "explicit"}
}]
}]
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
let (bytes, _) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/input/0/role").and_then(Value::as_str),
Some("developer"),
"developer breakpoint must stay a developer turn, got {body}"
);
assert_eq!(
body.pointer("/input/0/content/0/prompt_cache_breakpoint/mode")
.and_then(Value::as_str),
Some("explicit"),
"developer cache breakpoint must stay, got {body}"
);
}
#[test]
fn chat_system_prompt_cache_breakpoint_round_trips() {
let req = br#"{
"model": "gpt-4o",
"messages": [{
"role": "system",
"content": [{
"type": "text",
"text": "rules",
"prompt_cache_breakpoint": {"mode": "explicit"}
}]
}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/text")
.and_then(Value::as_str),
Some("rules"),
"system text must stay, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/prompt_cache_breakpoint/mode")
.and_then(Value::as_str),
Some("explicit"),
"system cache breakpoint must stay, got {body}"
);
}
#[test]
fn chat_system_second_part_prompt_cache_breakpoint_round_trips() {
let req = br#"{
"model": "gpt-4o",
"messages": [{
"role": "system",
"content": [
{"type": "text", "text": "stable"},
{
"type": "text",
"text": "today",
"prompt_cache_breakpoint": {"mode": "explicit"}
}
]
}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/text")
.and_then(Value::as_str),
Some("stable"),
"first system part must stay first, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/1/text")
.and_then(Value::as_str),
Some("today"),
"second system part must stay second, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/1/prompt_cache_breakpoint/mode")
.and_then(Value::as_str),
Some("explicit"),
"breakpoint must stay on the second part, got {body}"
);
}
#[test]
fn chat_developer_prompt_cache_breakpoint_round_trips() {
let req = br#"{
"model": "gpt-4o",
"messages": [{
"role": "developer",
"content": [{
"type": "text",
"text": "rules",
"prompt_cache_breakpoint": {"mode": "explicit"}
}]
}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/prompt_cache_breakpoint/mode")
.and_then(Value::as_str),
Some("explicit"),
"developer cache breakpoint must stay, got {body}"
);
}
#[test]
fn chat_tool_prompt_cache_breakpoint_round_trips() {
let req = br#"{
"model": "gpt-4o",
"messages": [{
"role": "tool",
"tool_call_id": "call_1",
"content": [{
"type": "text",
"text": "prefix",
"prompt_cache_breakpoint": {"mode": "explicit"}
}]
}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/text")
.and_then(Value::as_str),
Some("prefix"),
"tool text must stay, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/prompt_cache_breakpoint/mode")
.and_then(Value::as_str),
Some("explicit"),
"tool cache breakpoint must stay, got {body}"
);
}
#[test]
fn chat_tool_nested_cache_breakpoint_keeps_text() {
let ir = IrRequest::new(
"probe-model",
vec![IrItem::FunctionOutput {
call_id: "call-1".into(),
output: String::new(),
is_error: false,
parts: vec![IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(IrPart::PromptCacheBreakpoint {
mode: "nested".into(),
inner: Box::new(IrPart::Text("kept-tool-text".into())),
}),
}],
}],
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.to_string().contains("kept-tool-text"),
"two breakpoints must keep the tool text, got {body}"
);
assert_ne!(
body.pointer("/messages/0/content").and_then(Value::as_str),
Some(""),
"tool text must not become an empty string, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/prompt_cache_breakpoint/mode")
.and_then(Value::as_str),
Some("explicit"),
"outer cache breakpoint must stay, got {body}"
);
assert!(
!body.to_string().contains("nested"),
"inner breakpoint mode must not replace the outer mode, got {body}"
);
assert_eq!(
breakpoint_drops(&report),
1,
"the inner mode cannot be written and must be a drop, got {report:?}"
);
}
#[test]
fn chat_tool_image_inside_cache_breakpoint_records_the_drop() {
let ir = IrRequest::new(
"probe-model",
vec![IrItem::FunctionOutput {
call_id: "call-1".into(),
output: String::new(),
is_error: false,
parts: vec![IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(IrPart::ImageUrl {
url: "https://example.com/cat.png".into(),
detail: None,
bucket_owner: None,
file_id: None,
}),
}],
}],
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
!body.to_string().contains("cat.png"),
"Chat tool messages have no image slot, got {body}"
);
assert!(
loss_dropped(&report, "items[0].image"),
"a breakpoint around a tool image must still record the drop, got {report:?}"
);
assert_eq!(
breakpoint_drops(&report),
1,
"the breakpoint mode cannot be written, got {report:?}"
);
}
#[test]
fn chat_tool_output_survives_breakpoint_around_image() {
let image = IrPart::ImageUrl {
url: "https://example.com/cat.png".into(),
detail: None,
bucket_owner: None,
file_id: None,
};
let ir = IrRequest::new(
"probe-model",
vec![IrItem::FunctionOutput {
call_id: "call-1".into(),
output: "hello-from-output".into(),
is_error: false,
parts: vec![IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(image),
}],
}],
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content").and_then(Value::as_str),
Some("hello-from-output"),
"a breakpoint around an image must not erase tool output, got {body}"
);
assert!(
!body.to_string().contains("cat.png"),
"Chat tool messages have no image slot, got {body}"
);
assert!(
loss_dropped(&report, "items[0].image"),
"the image drop stays, got {report:?}"
);
assert_eq!(
breakpoint_drops(&report),
1,
"the breakpoint mode stays a drop, got {report:?}"
);
assert!(
!report
.events
.iter()
.any(|event| event.path.contains("output")),
"sent output must not also be a drop, got {report:?}"
);
}
#[test]
fn chat_tool_output_survives_breakpoint_around_document() {
let ir = IrRequest::new(
"probe-model",
vec![IrItem::FunctionOutput {
call_id: "call-1".into(),
output: "hello-from-output".into(),
is_error: false,
parts: vec![IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(IrPart::Document {
source: IrDocumentSource::Url("https://example.com/doc.pdf".into()),
media_type: "application/pdf".into(),
name: None,
citations_enabled: None,
context: None,
}),
}],
}],
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content").and_then(Value::as_str),
Some("hello-from-output"),
"a breakpoint around a document must not erase tool output, got {body}"
);
assert!(
loss_dropped(&report, "items[0].part"),
"the document drop stays, got {report:?}"
);
}
#[test]
fn chat_tool_output_survives_breakpoint_around_empty_text() {
let ir = IrRequest::new(
"probe-model",
vec![IrItem::FunctionOutput {
call_id: "call-1".into(),
output: "hello-from-output".into(),
is_error: false,
parts: vec![IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(IrPart::Text(String::new())),
}],
}],
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content").and_then(Value::as_str),
Some("hello-from-output"),
"a breakpoint around empty text must not erase tool output, got {body}"
);
assert_eq!(
breakpoint_drops(&report),
1,
"the unused breakpoint mode must stay a drop, got {report:?}"
);
assert!(
!report
.events
.iter()
.any(|event| event.path.contains("output")),
"sent output must not also be a drop, got {report:?}"
);
}
#[test]
fn other_wires_keep_tool_output_around_breakpoint_image() {
let ir = IrRequest::new(
"probe-model",
vec![IrItem::FunctionOutput {
call_id: "call-1".into(),
output: "hello-from-output".into(),
is_error: false,
parts: vec![IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(IrPart::ImageUrl {
url: "https://example.com/cat.png".into(),
detail: None,
bucket_owner: None,
file_id: None,
}),
}],
}],
);
for (wire, wire_name) in [
(Wire::Messages, "messages"),
(Wire::Gemini, "gemini"),
(Wire::Responses, "responses"),
(Wire::Converse, "converse"),
] {
let profile = profile(&format!(
"schema_version = 1\nid = \"probe\"\nwire = \"{wire_name}\"\n"
));
let (bytes, _) = encode(wire, &ir, &profile).expect("encode");
let encoded = String::from_utf8(bytes).expect("utf8");
assert!(
encoded.contains("hello-from-output"),
"{wire:?} must keep tool output beside a breakpoint image, got {encoded}"
);
}
}
#[test]
fn chat_tool_breakpoint_keeps_the_image_for_messages() {
let req = br#"{
"model": "gpt-4o",
"messages": [{
"role": "tool",
"tool_call_id": "call_1",
"content": [
{
"type": "text",
"text": "prefix",
"prompt_cache_breakpoint": {"mode": "explicit"}
},
{
"type": "image_url",
"image_url": {"url": "https://example.com/cat.png"}
}
]
}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("chat json");
assert_eq!(
chat.pointer("/messages/0/content/0/text")
.and_then(Value::as_str),
Some("prefix"),
"tool text must stay, got {chat}"
);
assert_eq!(
chat.pointer("/messages/0/content/0/prompt_cache_breakpoint/mode")
.and_then(Value::as_str),
Some("explicit"),
"tool cache breakpoint must stay, got {chat}"
);
assert!(
!chat.to_string().contains("cat.png"),
"Chat tool messages have no image slot, got {chat}"
);
assert!(
loss_dropped(&chat_report, "items[0].image"),
"Chat must record the image drop, got {chat_report:?}"
);
let (msg_bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("messages");
let msg: Value = serde_json::from_slice(&msg_bytes).expect("messages json");
let msg_text = msg.to_string();
assert!(
msg_text.contains("prefix") && msg_text.contains("https://example.com/cat.png"),
"Messages must keep the tool text and the image, got {msg}"
);
let (resp_bytes, _) = encode(Wire::Responses, &ir, &hard_error_profile()).expect("responses");
let resp: Value = serde_json::from_slice(&resp_bytes).expect("responses json");
let resp_text = resp.to_string();
assert!(
resp_text.contains("prefix") && resp_text.contains("https://example.com/cat.png"),
"Responses must keep the tool text and the image, got {resp}"
);
assert_eq!(
resp.pointer("/input/0/output/0/prompt_cache_breakpoint/mode")
.or_else(|| resp.pointer("/input/0/content/0/prompt_cache_breakpoint/mode"))
.and_then(Value::as_str),
Some("explicit"),
"Responses must keep the breakpoint on the tool text, got {resp}"
);
let (conv_bytes, conv_report) =
encode(Wire::Converse, &ir, &converse_profile()).expect("converse");
let conv: Value = serde_json::from_slice(&conv_bytes).expect("converse json");
let conv_text = conv.to_string();
assert!(
conv_text.contains("prefix"),
"Converse must keep the tool text, got {conv}"
);
assert!(
conv_report.events.iter().any(|event| {
event.action == LossAction::Drop
&& event.detail.contains("image url has no converse slot")
}),
"Converse must record that an https image has no slot, got {conv_report:?} body={conv}"
);
}
#[test]
fn gemini_https_image_url_degrades_to_text_placeholder() {
let ir = IrRequest::new(
"gemini-2.5-flash",
vec![IrItem::User {
parts: vec![
IrPart::Text("see".into()),
IrPart::ImageUrl {
url: "https://example.com/cat.png".into(),
detail: None,
bucket_owner: None,
file_id: None,
},
],
}],
);
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/contents/0/parts/1/text")
.and_then(Value::as_str),
Some("[image: https://example.com/cat.png]"),
"https ImageUrl must become a text placeholder, got {body}"
);
assert!(
body.pointer("/contents/0/parts/1/inlineData").is_none(),
"https ImageUrl must not become inlineData, got {body}"
);
assert!(
body.pointer("/contents/0/parts/1/fileData").is_none(),
"this pass must not invent fileData.fileUri, got {body}"
);
assert!(
loss_degraded(&report, "part.image_url"),
"ImageUrl degrade missing, got {report:?}"
);
}
#[test]
fn gemini_raw_part_is_dropped_with_loss_report() {
let ir = IrRequest::new(
"gemini-2.5-flash",
vec![IrItem::User {
parts: vec![
IrPart::Text("hi".into()),
IrPart::Raw {
type_name: "redacted_thinking".into(),
raw: serde_json::json!({"type": "redacted_thinking", "data": "enc"}),
},
],
}],
);
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let parts = body
.pointer("/contents/0/parts")
.and_then(Value::as_array)
.expect("parts");
assert_eq!(parts.len(), 1, "Raw must be omitted, got {body}");
assert_eq!(
parts[0].get("text").and_then(Value::as_str),
Some("hi"),
"visible text must stay, got {body}"
);
assert!(
!body.to_string().contains("redacted_thinking"),
"Raw must not be replayed, got {body}"
);
assert!(
loss_dropped(&report, "part.raw"),
"Raw drop missing, got {report:?}"
);
}
#[test]
fn gemini_raw_tool_is_dropped_with_loss_report() {
let ir = IrRequest::new(
"gemini-2.5-flash",
vec![IrItem::User {
parts: vec![IrPart::Text("hi".into())],
}],
)
.with_tools(vec![
IrTool::Function {
name: "lookup".into(),
description: "Look up".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
strict: None,
},
IrTool::Unknown {
type_name: "weird".into(),
raw: serde_json::json!({"type": "weird", "name": "do_thing"}),
},
]);
let passthrough = profile(
r#"
schema_version = 1
id = "test-gemini-passthrough"
wire = "gemini"
tool_type_policy = "passthrough"
"#,
);
let (bytes, report) = encode(Wire::Gemini, &ir, &passthrough).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let decls = body
.pointer("/tools/0/functionDeclarations")
.and_then(Value::as_array)
.expect("functionDeclarations");
assert_eq!(decls.len(), 1, "Raw must be omitted from decls, got {body}");
assert_eq!(
decls[0].get("name").and_then(Value::as_str),
Some("lookup"),
"function tool must stay, got {body}"
);
assert!(
!body.to_string().contains("weird") && !body.to_string().contains("do_thing"),
"Raw tool must not appear in generateContent JSON, got {body}"
);
assert!(
body.pointer("/tools/0/fileData").is_none(),
"this pass must not invent fileData, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.action == LossAction::Drop
&& (event.path == "tools[1]" || event.path == "tool.raw")
&& event
.detail
.contains("raw tool has no generateContent slot")
}),
"Raw tool drop missing, got {report:?}"
);
}
#[test]
fn gemini_hosted_google_search_tool_is_not_dropped() {
let bytes = br#"{
"model": "gemini-2.5-flash",
"contents": [{"role": "user", "parts": [{"text": "search"}]}],
"tools": [{"googleSearch": {}}]
}"#;
let (ir, report) = decode(Wire::Gemini, bytes).expect("decode");
assert!(
!ir.tools.is_empty(),
"googleSearch must not vanish, tools={:?} report={report:?}",
ir.tools
);
assert!(
ir.tools.iter().any(|tool| match tool {
IrTool::Unknown { type_name, .. } => type_name == "googleSearch",
IrTool::Hosted { kind, .. } => kind == "googleSearch",
_ => false,
}),
"googleSearch must be Unknown or Hosted, got {:?}",
ir.tools
);
}
#[test]
fn gemini_mixed_function_declarations_and_google_search() {
let bytes = br#"{
"model": "gemini-2.5-flash",
"contents": [{"role": "user", "parts": [{"text": "search"}]}],
"tools": [{
"functionDeclarations": [{"name": "lookup"}],
"googleSearch": {}
}]
}"#;
let (ir, _) = decode(Wire::Gemini, bytes).expect("decode");
assert!(
ir.tools
.iter()
.any(|tool| matches!(tool, IrTool::Function { name, .. } if name == "lookup")),
"lookup Function missing, got {:?}",
ir.tools
);
assert!(
ir.tools.iter().any(|tool| match tool {
IrTool::Unknown { type_name, .. } => type_name == "googleSearch",
IrTool::Hosted { kind, .. } => kind == "googleSearch",
_ => false,
}),
"googleSearch must decode beside functionDeclarations, got {:?}",
ir.tools
);
}
#[test]
fn gemini_hosted_google_search_passthrough_encodes() {
let bytes = br#"{
"model": "gemini-2.5-flash",
"contents": [{"role": "user", "parts": [{"text": "search"}]}],
"tools": [{"googleSearch": {}}]
}"#;
let (ir, _) = decode(Wire::Gemini, bytes).expect("decode");
let passthrough = profile(
r#"
schema_version = 1
id = "test-gemini-hosted-passthrough"
wire = "gemini"
tool_type_policy = "passthrough"
"#,
);
let (out, report) = encode(Wire::Gemini, &ir, &passthrough).expect("encode");
let body: Value = serde_json::from_slice(&out).expect("json");
let tools = body.get("tools").and_then(Value::as_array);
assert!(
tools.is_some_and(|tools| tools.iter().any(|tool| tool.get("googleSearch").is_some())),
"passthrough encode must emit googleSearch, got {body} report={report:?}"
);
assert!(
body.pointer("/toolConfig/includeServerSideToolInvocations")
.is_none(),
"googleSearch alone must not set the mixed-tool flag, got {body}"
);
}
#[test]
fn gemini_mixed_function_and_google_search_keeps_server_side_tool_flag() {
let bytes = br#"{
"model": "gemini-2.5-flash",
"contents": [{"role": "user", "parts": [{"text": "search"}]}],
"tools": [
{"functionDeclarations": [{"name": "lookup", "parameters": {"type": "object"}}]},
{"googleSearch": {}}
],
"toolConfig": {"includeServerSideToolInvocations": true}
}"#;
let (ir, _) = decode(Wire::Gemini, bytes).expect("decode");
let passthrough = profile(
r#"
schema_version = 1
id = "test-gemini-mixed-tools"
wire = "gemini"
tool_type_policy = "passthrough"
"#,
);
let (out, report) = encode(Wire::Gemini, &ir, &passthrough).expect("encode");
let body: Value = serde_json::from_slice(&out).expect("json");
let tools = body.get("tools").and_then(Value::as_array).expect("tools");
assert!(
tools
.iter()
.any(|tool| tool.get("functionDeclarations").is_some()),
"function tool must stay, got {body}"
);
assert!(
tools.iter().any(|tool| tool.get("googleSearch").is_some()),
"googleSearch must stay, got {body}"
);
assert_eq!(
body.pointer("/toolConfig/includeServerSideToolInvocations"),
Some(&Value::Bool(true)),
"mixed function and Google Search must keep includeServerSideToolInvocations or Gemini rejects the request, got {body} report={report:?}"
);
let mut forced = ir;
forced.sampling.tool_choice = IrToolChoice::Required;
let (forced_out, _) = encode(Wire::Gemini, &forced, &passthrough).expect("encode required");
let forced_body: Value = serde_json::from_slice(&forced_out).expect("json");
assert_eq!(
forced_body.pointer("/toolConfig/functionCallingConfig/mode"),
Some(&Value::String("ANY".into())),
"tool choice must stay beside the builtin flag, got {forced_body}"
);
assert_eq!(
forced_body.pointer("/toolConfig/includeServerSideToolInvocations"),
Some(&Value::Bool(true)),
"required tool choice must not drop the builtin flag, got {forced_body}"
);
}
#[test]
fn gemini_url_context_tool_round_trips() {
let bytes = br#"{
"model": "gemini-2.5-flash",
"contents": [{"role": "user", "parts": [{"text": "Read https://example.com/a"}]}],
"tools": [{"urlContext": {}}]
}"#;
let (ir, _) = decode(Wire::Gemini, bytes).expect("decode");
let passthrough = profile(
r#"
schema_version = 1
id = "test-gemini-url-context"
wire = "gemini"
tool_type_policy = "passthrough"
"#,
);
let (out, report) = encode(Wire::Gemini, &ir, &passthrough).expect("encode");
let body: Value = serde_json::from_slice(&out).expect("json");
assert!(
body.get("tools")
.and_then(Value::as_array)
.is_some_and(|tools| { tools.iter().any(|tool| tool.get("urlContext").is_some()) }),
"urlContext must stay so the model can read the page, got {body} report={report:?}"
);
assert!(
body.pointer("/toolConfig/includeServerSideToolInvocations")
.is_none(),
"urlContext alone must not set the mixed-tool flag, got {body}"
);
let mixed = br#"{
"model": "gemini-2.5-flash",
"contents": [{"role": "user", "parts": [{"text": "Read https://example.com/a"}]}],
"tools": [
{"functionDeclarations": [{"name": "lookup", "parameters": {"type": "object"}}]},
{"urlContext": {}}
]
}"#;
let (mixed_ir, _) = decode(Wire::Gemini, mixed).expect("decode mixed");
let (mixed_out, mixed_report) =
encode(Wire::Gemini, &mixed_ir, &passthrough).expect("encode mixed");
let mixed_body: Value = serde_json::from_slice(&mixed_out).expect("json mixed");
assert!(
mixed_body
.get("tools")
.and_then(Value::as_array)
.is_some_and(|tools| tools.iter().any(|tool| tool.get("urlContext").is_some())),
"urlContext must stay beside the function, got {mixed_body} report={mixed_report:?}"
);
assert_eq!(
mixed_body.pointer("/toolConfig/includeServerSideToolInvocations"),
Some(&Value::Bool(true)),
"urlContext beside a function must set the builtin flag, got {mixed_body}"
);
}
#[test]
fn gemini_builtin_tools_round_trip() {
let passthrough = profile(
r#"
schema_version = 1
id = "test-gemini-builtin-tools"
wire = "gemini"
tool_type_policy = "passthrough"
"#,
);
for key in ["googleMaps", "enterpriseWebSearch", "computerUse"] {
let bytes = format!(
r#"{{
"model": "gemini-2.5-flash",
"contents": [{{"role": "user", "parts": [{{"text": "go"}}]}}],
"tools": [{{"{key}": {{}}}}]
}}"#
);
let (ir, _) = decode(Wire::Gemini, bytes.as_bytes()).expect("decode");
let (out, report) = encode(Wire::Gemini, &ir, &passthrough).expect("encode");
let body: Value = serde_json::from_slice(&out).expect("json");
assert!(
body.get("tools")
.and_then(Value::as_array)
.is_some_and(|tools| { tools.iter().any(|tool| tool.get(key).is_some()) }),
"{key} must stay on the Gemini request, got {body} report={report:?}"
);
}
let mixed = br#"{
"model": "gemini-2.5-flash",
"contents": [{"role": "user", "parts": [{"text": "go"}]}],
"tools": [
{"functionDeclarations": [{"name": "lookup", "parameters": {"type": "object"}}]},
{"googleMaps": {}}
]
}"#;
let (ir, _) = decode(Wire::Gemini, mixed).expect("decode mixed");
let (out, _) = encode(Wire::Gemini, &ir, &passthrough).expect("encode mixed");
let body: Value = serde_json::from_slice(&out).expect("json mixed");
assert_eq!(
body.pointer("/toolConfig/includeServerSideToolInvocations"),
Some(&Value::Bool(true)),
"googleMaps beside a function must set the builtin flag, got {body}"
);
}
fn count_cache_control(value: &Value) -> usize {
match value {
Value::Object(map) => {
let here = usize::from(map.contains_key("cache_control"));
here + map.values().map(count_cache_control).sum::<usize>()
}
Value::Array(arr) => arr.iter().map(count_cache_control).sum(),
_ => 0,
}
}
#[test]
fn prompt_caching_on_system_and_first_user() {
let ir = IrRequest::new(
"claude-opus-4-6",
vec![
IrItem::System {
text: "rules".into(),
prompt_cache_breakpoint: None,
content_parts: vec![],
},
IrItem::User {
parts: vec![IrPart::Text("hello world".into())],
},
],
)
.with_sampling(IrSampling::patch(|s| {
s.cache = IrCache::enabled();
}));
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.pointer("/system/cache_control").is_some()
|| body.pointer("/system/0/cache_control").is_some(),
"system must have cache_control, got {body}"
);
assert!(
body.pointer("/messages/0/content/0/cache_control")
.is_some(),
"first user text must have cache_control, got {body}"
);
assert!(
count_cache_control(&body) <= 4,
"must stay under Anthropic cap, got {body}"
);
}
#[test]
fn long_ttl_with_tools_tags_last_tool_before_system() {
let ir = IrRequest::new(
"claude-opus-4-6",
vec![
IrItem::System {
text: "rules".into(),
prompt_cache_breakpoint: None,
content_parts: vec![],
},
IrItem::User {
parts: vec![IrPart::Text("hello".into())],
},
],
)
.with_tools(vec![
IrTool::Function {
name: "one".into(),
description: "a".into(),
parameters: serde_json::json!({"type": "object"}),
strict: None,
},
IrTool::Function {
name: "two".into(),
description: "b".into(),
parameters: serde_json::json!({"type": "object"}),
strict: None,
},
])
.with_sampling(IrSampling::patch(|s| {
s.cache = IrCache::enabled().with_retention("1h");
}));
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let tools = body.get("tools").and_then(Value::as_array).expect("tools");
assert!(
tools[0].get("cache_control").is_none(),
"only last tool is tagged, got {body}"
);
assert_eq!(
tools[1]
.pointer("/cache_control/ttl")
.and_then(Value::as_str),
Some("1h"),
"last tool must carry 1h, got {body}"
);
let sys_ttl = body
.pointer("/system/cache_control/ttl")
.or_else(|| body.pointer("/system/0/cache_control/ttl"))
.and_then(Value::as_str);
assert_eq!(
sys_ttl,
Some("1h"),
"first system must carry 1h, got {body}"
);
assert!(
count_cache_control(&body) <= 2,
"preferred pair is two markers, got {body}"
);
}
#[test]
fn cache_disabled_no_cache_control_blocks() {
let ir = IrRequest::new(
"claude-opus-4-6",
vec![
IrItem::System {
text: "rules".into(),
prompt_cache_breakpoint: None,
content_parts: vec![],
},
IrItem::User {
parts: vec![IrPart::Text("hello".into())],
},
],
)
.with_tools(vec![IrTool::Function {
name: "one".into(),
description: "a".into(),
parameters: serde_json::json!({"type": "object"}),
strict: None,
}]);
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(count_cache_control(&body), 0, "got {body}");
}
#[test]
fn cache_retention_none_skips_cache_control() {
let ir = IrRequest::new(
"claude-opus-4-6",
vec![
IrItem::System {
text: "rules".into(),
prompt_cache_breakpoint: None,
content_parts: vec![],
},
IrItem::User {
parts: vec![IrPart::Text("hello".into())],
},
],
)
.with_sampling(IrSampling::patch(|s| {
s.cache = IrCache::enabled().with_retention("none");
}));
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(count_cache_control(&body), 0, "retention=none, got {body}");
}
#[test]
fn cache_without_system_does_not_preserve_before_a_marker() {
let none = IrRequest::new(
"claude-opus-4-6",
vec![IrItem::User {
parts: vec![IrPart::Text("hello".into())],
}],
)
.with_sampling(IrSampling::patch(|s| {
s.cache = IrCache::enabled().with_retention("none");
}));
let (bytes, report) = encode(Wire::Messages, &none, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(count_cache_control(&body), 0, "{body}");
assert!(
!report.events.iter().any(|event| {
event.path == "sampling.cache" && event.action == LossAction::Preserve
}),
"retention none must not say Preserve, got {report:?}"
);
let kept = IrRequest::new(
"claude-opus-4-6",
vec![IrItem::User {
parts: vec![IrPart::Text("hello".into())],
}],
)
.with_sampling(IrSampling::patch(|s| {
s.cache = IrCache::enabled();
}));
let (bytes, report) = encode(Wire::Messages, &kept, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
count_cache_control(&body) > 0,
"a user turn must still be tagged, got {body}"
);
assert_eq!(
report
.events
.iter()
.filter(|event| event.path == "sampling.cache" && event.action == LossAction::Preserve)
.count(),
1,
"one Preserve after the marker, got {report:?}"
);
}
const ANTHROPIC_MAX_CACHE_CONTROL: usize = 4;
const ANTHROPIC_PREFERRED_CACHE_CONTROL: usize = 2;
fn system_ttl(body: &Value, index: usize) -> Option<&str> {
body.pointer(&format!("/system/{index}/cache_control/ttl"))
.and_then(Value::as_str)
}
fn system_has_cache_control(body: &Value, index: usize) -> bool {
body.pointer(&format!("/system/{index}/cache_control"))
.is_some()
}
#[test]
fn multi_fragment_system_stays_at_or_under_cache_control_limit() {
let mut items: Vec<IrItem> = (0..6)
.map(|i| IrItem::System {
text: format!("system fragment {i}"),
prompt_cache_breakpoint: None,
content_parts: vec![],
})
.collect();
items.push(IrItem::User {
parts: vec![IrPart::Text("do the work".into())],
});
let ir = IrRequest::new("claude-opus-4-6", items).with_sampling(IrSampling::patch(|s| {
s.cache = IrCache::enabled().with_retention("1h");
}));
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let system = body
.get("system")
.and_then(Value::as_array)
.expect("system");
assert_eq!(
system.len(),
6,
"each system item is its own block, got {body}"
);
assert_eq!(
system_ttl(&body, 0),
Some("1h"),
"first system must carry 1h, got {body}"
);
for i in 1..6 {
assert!(
!system_has_cache_control(&body, i),
"system fragment {i} must not get cache_control, got {body}"
);
}
let n = count_cache_control(&body);
assert!(
n <= ANTHROPIC_MAX_CACHE_CONTROL,
"must not exceed Anthropic max of {ANTHROPIC_MAX_CACHE_CONTROL}, got {n} in {body}"
);
assert!(
n <= ANTHROPIC_PREFERRED_CACHE_CONTROL,
"prefer at most {ANTHROPIC_PREFERRED_CACHE_CONTROL} breakpoints, got {n} in {body}"
);
assert_eq!(n, 2, "first system + first user, got {body}");
assert!(
body.pointer("/messages/0/content/0/cache_control")
.is_some(),
"first user must be tagged, got {body}"
);
}
#[test]
fn multi_fragment_long_ttl_with_tools_tags_first_system_not_last() {
let ir = IrRequest::new(
"claude-opus-4-6",
vec![
IrItem::System {
text: "You are a coding agent.".into(),
prompt_cache_breakpoint: None,
content_parts: vec![],
},
IrItem::System {
text: "Restored goal state from previous session:\nGoal: current task".into(),
prompt_cache_breakpoint: None,
content_parts: vec![],
},
IrItem::User {
parts: vec![IrPart::Text("continue the work".into())],
},
],
)
.with_tools(vec![
IrTool::Function {
name: "one".into(),
description: "a".into(),
parameters: serde_json::json!({"type": "object"}),
strict: None,
},
IrTool::Function {
name: "two".into(),
description: "b".into(),
parameters: serde_json::json!({"type": "object"}),
strict: None,
},
])
.with_sampling(IrSampling::patch(|s| {
s.cache = IrCache::enabled().with_retention("1h");
}));
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let tools = body.get("tools").and_then(Value::as_array).expect("tools");
assert!(tools[0].get("cache_control").is_none());
assert_eq!(
tools[1]
.pointer("/cache_control/ttl")
.and_then(Value::as_str),
Some("1h")
);
assert_eq!(
system_ttl(&body, 0),
Some("1h"),
"first (static) system must hold long TTL, got {body}"
);
assert!(
!system_has_cache_control(&body, 1),
"resume goal fragment must not hold 1h after main system, got {body}"
);
assert_eq!(count_cache_control(&body), 2, "got {body}");
}
#[test]
fn short_ttl_multi_system_tags_last_system_and_first_user() {
let ir = IrRequest::new(
"claude-opus-4-6",
vec![
IrItem::System {
text: "static".into(),
prompt_cache_breakpoint: None,
content_parts: vec![],
},
IrItem::System {
text: "resume".into(),
prompt_cache_breakpoint: None,
content_parts: vec![],
},
IrItem::User {
parts: vec![IrPart::Text("hello".into())],
},
],
)
.with_sampling(IrSampling::patch(|s| {
s.cache = IrCache::enabled();
}));
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
!system_has_cache_control(&body, 0),
"short TTL must not tag first system, got {body}"
);
assert!(
system_has_cache_control(&body, 1),
"short TTL tags last system, got {body}"
);
assert!(
body.pointer("/messages/0/content/0/cache_control")
.is_some(),
"first user tagged, got {body}"
);
assert_eq!(count_cache_control(&body), 2, "got {body}");
}
#[test]
fn encode_after_decode_strips_six_cache_markers_to_preferred_pair() {
let req = br#"{
"model": "claude-opus-4-6",
"system": [
{ "type": "text", "text": "a", "cache_control": { "type": "ephemeral" } },
{ "type": "text", "text": "b", "cache_control": { "type": "ephemeral" } },
{ "type": "text", "text": "c", "cache_control": { "type": "ephemeral" } }
],
"messages": [
{ "role": "user", "content": [{ "type": "text", "text": "u1", "cache_control": { "type": "ephemeral" } }] },
{ "role": "assistant", "content": [{ "type": "text", "text": "a1", "cache_control": { "type": "ephemeral" } }] },
{ "role": "user", "content": [{ "type": "text", "text": "u2", "cache_control": { "type": "ephemeral" } }] }
]
}"#;
let incoming: Value = serde_json::from_slice(req).expect("fixture");
assert_eq!(
count_cache_control(&incoming),
6,
"fixture starts with 6 markers"
);
let (ir, _) = decode(Wire::Messages, req).expect("decode");
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let n = count_cache_control(&body);
assert!(
n <= ANTHROPIC_MAX_CACHE_CONTROL,
"hard cap 4, got {n} in {body}"
);
assert_eq!(n, 2, "preferred pair after remap, got {body}");
assert!(
system_has_cache_control(&body, 2),
"short TTL keeps last system, got {body}"
);
assert!(
!system_has_cache_control(&body, 0),
"first system must be stripped on short TTL, got {body}"
);
assert!(
body.pointer("/messages/0/content/0/cache_control")
.is_some(),
"first user kept, got {body}"
);
}
fn cached_messages_ir(text: &str, floor: Option<u32>) -> IrRequest {
IrRequest::new(
"claude-opus-4-6",
vec![
IrItem::System {
text: text.into(),
prompt_cache_breakpoint: None,
content_parts: vec![],
},
IrItem::User {
parts: vec![IrPart::Text("hello".into())],
},
],
)
.with_sampling(IrSampling::patch(|s| {
s.cache = IrCache::enabled().with_min_cacheable_tokens(floor.unwrap_or(0));
}))
}
#[test]
fn short_prompt_below_min_cacheable_tokens_drops_cache_control() {
let ir = cached_messages_ir("rules", Some(1024));
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(count_cache_control(&body), 0, "short prompt, got {body}");
assert!(
report.events.iter().any(|event| {
event.path == "sampling.cache"
&& event.action == LossAction::Drop
&& event.detail.contains("1024")
}),
"must Drop sampling.cache naming the floor, got {report:?}"
);
}
#[test]
fn long_prompt_at_min_cacheable_tokens_still_tags() {
let ir = cached_messages_ir(&"x".repeat(5000), Some(1024));
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
count_cache_control(&body) > 0,
"long prompt must still tag, got {body}"
);
}
#[test]
fn min_cacheable_tokens_zero_does_not_skip() {
let ir = cached_messages_ir("rules", Some(0));
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
count_cache_control(&body) > 0,
"floor 0 must still tag, got {body}"
);
}
#[test]
fn long_developer_at_min_cacheable_tokens_still_tags() {
let ir = IrRequest::new(
"claude-opus-4-6",
vec![
IrItem::Developer {
text: "x".repeat(5000),
prompt_cache_breakpoint: None,
content_parts: vec![],
},
IrItem::User {
parts: vec![IrPart::Text("hello".into())],
},
],
)
.with_sampling(IrSampling::patch(|s| {
s.cache = IrCache::enabled().with_min_cacheable_tokens(1024);
}));
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
count_cache_control(&body) > 0,
"long developer must still tag, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "sampling.cache" && event.action == LossAction::Preserve
}),
"must Preserve sampling.cache, got {report:?}"
);
assert!(
!report
.events
.iter()
.any(|event| { event.path == "sampling.cache" && event.action == LossAction::Drop }),
"must not Drop sampling.cache, got {report:?}"
);
let (decoded, _) = decode(Wire::Messages, &bytes).expect("decode");
assert_eq!(
decoded.sampling.cache.min_cacheable_tokens, None,
"floor is host policy, not a wire field"
);
}
#[test]
fn large_function_output_at_min_cacheable_tokens_still_tags() {
let ir = IrRequest::new(
"claude-opus-4-6",
vec![
IrItem::System {
text: "rules".into(),
prompt_cache_breakpoint: None,
content_parts: vec![],
},
IrItem::FunctionOutput {
call_id: "c1".into(),
output: "x".repeat(5000),
parts: Vec::new(),
is_error: false,
},
],
)
.with_sampling(IrSampling::patch(|s| {
s.cache = IrCache::enabled().with_min_cacheable_tokens(1024);
}));
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
count_cache_control(&body) > 0,
"large tool result must still tag, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "sampling.cache" && event.action == LossAction::Preserve
}),
"must Preserve sampling.cache, got {report:?}"
);
let (decoded, _) = decode(Wire::Messages, &bytes).expect("decode");
assert_eq!(
decoded.sampling.cache.min_cacheable_tokens, None,
"floor is host policy, not a wire field"
);
}
fn messages_floor_ir(items: Vec<IrItem>, floor: u32) -> IrRequest {
IrRequest::new("claude-opus-4-6", items).with_sampling(IrSampling::patch(|s| {
s.cache = IrCache::enabled().with_min_cacheable_tokens(floor);
}))
}
fn assert_cache_preserved(bytes: &[u8], report: &LossReport) {
let body: Value = serde_json::from_slice(bytes).expect("json");
assert!(
count_cache_control(&body) > 0,
"prompt at the floor must still tag, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "sampling.cache" && event.action == LossAction::Preserve
}),
"must Preserve sampling.cache, got {report:?}"
);
}
fn assert_cache_dropped(bytes: &[u8], report: &LossReport) {
let body: Value = serde_json::from_slice(bytes).expect("json");
assert_eq!(count_cache_control(&body), 0, "short prompt, got {body}");
assert!(
report.events.iter().any(|event| {
event.path == "sampling.cache"
&& event.action == LossAction::Drop
&& event.detail.contains("1024")
}),
"must Drop sampling.cache naming the floor, got {report:?}"
);
}
#[test]
fn large_custom_tool_input_at_min_cacheable_tokens_still_tags() {
let input = "x".repeat(5000);
let ir = messages_floor_ir(
vec![
IrItem::System {
text: "rules".into(),
prompt_cache_breakpoint: None,
content_parts: vec![],
},
IrItem::CustomToolCall {
call_id: "call_custom".into(),
name: "lookup".into(),
input: input.clone(),
responses_item: None,
},
],
1024,
);
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
assert!(
String::from_utf8_lossy(&bytes).contains(&input),
"custom-tool input must be in the Messages body"
);
assert_cache_preserved(&bytes, &report);
}
#[test]
fn short_custom_tool_input_below_min_cacheable_tokens_drops_cache_control() {
let ir = messages_floor_ir(
vec![
IrItem::System {
text: "rules".into(),
prompt_cache_breakpoint: None,
content_parts: vec![],
},
IrItem::CustomToolCall {
call_id: "call_custom".into(),
name: "lookup".into(),
input: "{}".into(),
responses_item: Some(serde_json::json!({ "input": "x".repeat(5000) })),
},
],
1024,
);
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
assert_cache_dropped(&bytes, &report);
}
#[test]
fn large_reasoning_summary_at_min_cacheable_tokens_still_tags() {
let summary = "x".repeat(5000);
let ir = messages_floor_ir(
vec![
IrItem::System {
text: "rules".into(),
prompt_cache_breakpoint: None,
content_parts: vec![],
},
IrItem::Reasoning {
encrypted: Some("sig".into()),
summary: Some(summary.clone()),
raw: None,
},
],
1024,
);
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
assert!(
String::from_utf8_lossy(&bytes).contains(&summary),
"reasoning summary must be in the Messages body"
);
assert_cache_preserved(&bytes, &report);
}
#[test]
fn reasoning_signature_alone_stays_below_min_cacheable_tokens() {
let ir = messages_floor_ir(
vec![
IrItem::System {
text: "rules".into(),
prompt_cache_breakpoint: None,
content_parts: vec![],
},
IrItem::User {
parts: vec![IrPart::Text("hello".into())],
},
IrItem::Reasoning {
encrypted: Some("y".repeat(5000)),
summary: None,
raw: None,
},
],
1024,
);
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
assert_cache_dropped(&bytes, &report);
}
fn encode_floor(items: Vec<IrItem>) -> (Vec<u8>, LossReport) {
let ir = messages_floor_ir(items, 1024);
encode(Wire::Messages, &ir, &messages_profile()).expect("encode")
}
#[test]
fn large_image_base64_at_min_cacheable_tokens_still_tags() {
let data = "x".repeat(5000);
let (bytes, report) = encode_floor(vec![IrItem::User {
parts: vec![IrPart::ImageBase64 {
media_type: "image/png".into(),
data: data.clone(),
}],
}]);
assert!(
String::from_utf8_lossy(&bytes).contains(&data),
"image bytes must be in the Messages body"
);
assert_cache_preserved(&bytes, &report);
}
#[test]
fn blank_image_url_is_not_sent() {
let item = IrItem::User {
parts: vec![
IrPart::Text("hello".into()),
IrPart::ImageUrl {
url: " ".into(),
detail: None,
bucket_owner: None,
file_id: None,
},
],
};
for (wire, profile) in [
(Wire::ChatCompletions, chat_profile()),
(Wire::Responses, flatten_profile()),
(Wire::Messages, messages_profile()),
(Wire::Gemini, gemini_profile()),
] {
let ir = IrRequest::new("m", vec![item.clone()]);
let (bytes, report) = encode(wire, &ir, &profile).expect("encode");
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("hello")
&& !body.contains("image_url")
&& !body.contains("\"url\":\" \"")
&& !body.contains("[image:"),
"{wire:?} must keep the text and omit the blank image url, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "part.image"
&& event.action == LossAction::Drop
&& event.detail.contains("image url is empty")
}),
"{wire:?} must record the blank image url, got {report:?}"
);
}
}
#[test]
fn blank_audio_data_is_not_sent() {
let item = IrItem::User {
parts: vec![
IrPart::Text("hello".into()),
IrPart::Audio {
data: " ".into(),
format: "mp3".into(),
},
],
};
let wrapped = IrItem::User {
parts: vec![
IrPart::Text("hello".into()),
IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(IrPart::Audio {
data: " ".into(),
format: "mp3".into(),
}),
},
],
};
let hinted = IrItem::User {
parts: vec![
IrPart::Text("hello".into()),
IrPart::Raw {
type_name: "inline".into(),
raw: serde_json::json!({
"inlineData": {"mimeType": "audio/mp3", "data": " "},
"thoughtSignature": "sig"
}),
},
],
};
for (wire, profile) in [
(Wire::ChatCompletions, chat_profile()),
(Wire::Responses, flatten_profile()),
(Wire::Gemini, gemini_profile()),
(Wire::Converse, converse_profile()),
] {
for item in [&item, &wrapped, &hinted] {
let ir = IrRequest::new("m", vec![item.clone()]);
let (bytes, report) = encode(wire, &ir, &profile).expect("encode");
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("hello")
&& !body.contains("input_audio")
&& !body.contains("\"data\":\" \"")
&& !body.contains("\"bytes\":\" \""),
"{wire:?} must keep the text and omit the blank audio, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "part.audio"
&& event.action == LossAction::Drop
&& event.detail.contains("audio data is empty")
}),
"{wire:?} must record the blank audio, got {report:?}"
);
}
}
}
#[test]
fn blank_image_data_is_not_sent() {
let item = IrItem::User {
parts: vec![
IrPart::Text("hello".into()),
IrPart::ImageBase64 {
media_type: "image/png".into(),
data: " ".into(),
},
],
};
let data_url = IrItem::User {
parts: vec![
IrPart::Text("hello".into()),
IrPart::ImageUrl {
url: "data:image/png;base64, ".into(),
detail: None,
bucket_owner: None,
file_id: None,
},
],
};
for (wire, profile) in [
(Wire::ChatCompletions, chat_profile()),
(Wire::Responses, flatten_profile()),
(Wire::Messages, messages_profile()),
(Wire::Gemini, gemini_profile()),
(Wire::Converse, converse_profile()),
] {
for item in [&item, &data_url] {
let ir = IrRequest::new("m", vec![item.clone()]);
let (bytes, report) = encode(wire, &ir, &profile).expect("encode");
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("hello")
&& !body.contains("base64, ")
&& !body.contains("\"data\":\" \"")
&& !body.contains("\"bytes\":\" \""),
"{wire:?} must keep the text and omit the blank image data, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "part.image"
&& event.action == LossAction::Drop
&& event.detail.contains("image data is empty")
}),
"{wire:?} must record the blank image data, got {report:?}"
);
}
}
}
#[test]
fn empty_stop_sequence_is_not_sent() {
for (wire, profile, model) in [
(Wire::ChatCompletions, chat_profile(), "gpt-4o"),
(Wire::Messages, messages_profile(), "claude"),
(Wire::Gemini, gemini_profile(), "gemini-2.5-flash"),
(Wire::Converse, converse_profile(), "amazon.nova-lite-v1:0"),
] {
let mut mixed = IrRequest::new(
model,
vec![IrItem::User {
parts: vec![IrPart::Text("hello".into())],
}],
);
mixed.sampling.stop = vec!["".into(), "END".into()];
let (bytes, report) = encode(wire, &mixed, &profile).expect("encode");
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("END") && !body.contains("\"\""),
"{wire:?} must keep END and omit the empty stop, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "sampling.stop"
&& event.action == LossAction::Drop
&& event.detail.contains("stop sequence is empty")
}),
"{wire:?} must record the empty stop, got {report:?}"
);
let mut only_empty = IrRequest::new(
model,
vec![IrItem::User {
parts: vec![IrPart::Text("hello".into())],
}],
);
only_empty.sampling.stop = vec!["".into()];
let (bytes, _) = encode(wire, &only_empty, &profile).expect("encode");
let body = String::from_utf8_lossy(&bytes);
assert!(
!body.contains("stop_sequences")
&& !body.contains("stopSequences")
&& !body.contains("\"stop\":"),
"{wire:?} must omit a stop list that is only empty, got {body}"
);
}
}
#[test]
fn blank_document_data_is_not_sent() {
let item = IrItem::User {
parts: vec![
IrPart::Text("hello".into()),
IrPart::Document {
source: IrDocumentSource::Base64(" ".into()),
media_type: "application/pdf".into(),
name: None,
citations_enabled: None,
context: None,
},
],
};
for (wire, profile) in [
(Wire::ChatCompletions, chat_profile()),
(Wire::Responses, flatten_profile()),
(Wire::Messages, messages_profile()),
(Wire::Gemini, gemini_profile()),
(Wire::Converse, converse_profile()),
] {
let ir = IrRequest::new("m", vec![item.clone()]);
let (bytes, report) = encode(wire, &ir, &profile).expect("encode");
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("hello")
&& !body.contains("base64, ")
&& !body.contains("\"data\":\" \"")
&& !body.contains("\"bytes\":\" \""),
"{wire:?} must keep the text and omit the blank document, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "part.document"
&& event.action == LossAction::Drop
&& event.detail.contains("document data is empty")
}),
"{wire:?} must record the blank document, got {report:?}"
);
}
}
#[test]
fn gemini_empty_file_id_is_not_sent() {
let ir = IrRequest::new(
"gemini-2.5-flash",
vec![IrItem::User {
parts: vec![
IrPart::Text("hello".into()),
IrPart::Document {
source: IrDocumentSource::FileId(" ".into()),
media_type: String::new(),
name: None,
citations_enabled: None,
context: None,
},
],
}],
);
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body = String::from_utf8_lossy(&bytes);
assert!(
body.contains("hello") && !body.contains("fileUri"),
"Gemini must keep the text and omit the blank file id, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "part.document"
&& event.action == LossAction::Drop
&& event.detail.contains("file id is empty")
}),
"Gemini must record the empty file id, got {report:?}"
);
}
#[test]
fn empty_file_id_is_not_sent() {
let item = IrItem::User {
parts: vec![IrPart::Document {
source: IrDocumentSource::FileId(" ".into()),
media_type: String::new(),
name: None,
citations_enabled: None,
context: None,
}],
};
for (wire, profile) in [
(Wire::ChatCompletions, chat_profile()),
(Wire::Responses, flatten_profile()),
(Wire::Messages, messages_profile()),
] {
let ir = IrRequest::new("m", vec![item.clone()]);
let (bytes, report) = encode(wire, &ir, &profile).expect("encode");
let body = String::from_utf8_lossy(&bytes);
assert!(
!body.contains("\"file_id\":\" \"") && !body.contains("\"file_id\":\"\""),
"{wire:?} must not send an empty file id, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "part.document"
&& event.action == LossAction::Drop
&& event.detail.contains("file id is empty")
}),
"{wire:?} must record the empty file id, got {report:?}"
);
}
}
#[test]
fn large_document_text_at_min_cacheable_tokens_still_tags() {
let data = "x".repeat(5000);
let (bytes, report) = encode_floor(vec![IrItem::User {
parts: vec![IrPart::Document {
source: IrDocumentSource::Text(data.clone()),
media_type: "text/plain".into(),
name: None,
citations_enabled: None,
context: None,
}],
}]);
assert!(
String::from_utf8_lossy(&bytes).contains(&data),
"document text must be in the Messages body"
);
assert_cache_preserved(&bytes, &report);
}
#[test]
fn large_document_base64_at_min_cacheable_tokens_still_tags() {
let data = "x".repeat(5000);
let (bytes, report) = encode_floor(vec![IrItem::User {
parts: vec![IrPart::Document {
source: IrDocumentSource::Base64(data.clone()),
media_type: "application/pdf".into(),
name: None,
citations_enabled: None,
context: None,
}],
}]);
assert!(
String::from_utf8_lossy(&bytes).contains(&data),
"document base64 must be in the Messages body"
);
assert_cache_preserved(&bytes, &report);
}
#[test]
fn large_tool_result_image_at_min_cacheable_tokens_still_tags() {
let data = "x".repeat(5000);
let (bytes, report) = encode_floor(vec![IrItem::FunctionOutput {
call_id: "c1".into(),
output: String::new(),
parts: vec![IrPart::ImageBase64 {
media_type: "image/png".into(),
data: data.clone(),
}],
is_error: false,
}]);
assert!(
String::from_utf8_lossy(&bytes).contains(&data),
"tool-result image bytes must be in the Messages body"
);
assert_cache_preserved(&bytes, &report);
}
#[test]
fn large_tool_result_text_at_min_cacheable_tokens_still_tags() {
let data = "x".repeat(5000);
let (bytes, report) = encode_floor(vec![IrItem::FunctionOutput {
call_id: "c1".into(),
output: String::new(),
parts: vec![IrPart::Text(data.clone())],
is_error: false,
}]);
assert!(
String::from_utf8_lossy(&bytes).contains(&data),
"tool-result text must be in the Messages body"
);
assert_cache_preserved(&bytes, &report);
}
#[test]
fn short_image_and_document_urls_meet_min_cacheable_tokens() {
let (bytes, report) = encode_floor(vec![IrItem::User {
parts: vec![IrPart::ImageUrl {
url: "https://cdn.example/a.png".into(),
detail: None,
bucket_owner: None,
file_id: None,
}],
}]);
assert!(
String::from_utf8_lossy(&bytes).contains("https://cdn.example/a.png"),
"image URL must be in the Messages body"
);
assert_cache_preserved(&bytes, &report);
let (bytes, report) = encode_floor(vec![IrItem::User {
parts: vec![IrPart::Document {
source: IrDocumentSource::Url("https://cdn.example/a.pdf".into()),
media_type: "application/pdf".into(),
name: None,
citations_enabled: None,
context: None,
}],
}]);
assert!(
String::from_utf8_lossy(&bytes).contains("https://cdn.example/a.pdf"),
"document URL must be in the Messages body"
);
assert_cache_preserved(&bytes, &report);
let (bytes, report) = encode_floor(vec![IrItem::User {
parts: vec![IrPart::Document {
source: IrDocumentSource::FileId("file_abc".into()),
media_type: "application/pdf".into(),
name: None,
citations_enabled: None,
context: None,
}],
}]);
assert!(
String::from_utf8_lossy(&bytes).contains("file_abc"),
"file id must be in the Messages body"
);
assert_cache_preserved(&bytes, &report);
let (bytes, report) = encode_floor(vec![IrItem::User {
parts: vec![IrPart::Document {
source: IrDocumentSource::Base64("0123456789".into()),
media_type: "application/pdf".into(),
name: None,
citations_enabled: None,
context: None,
}],
}]);
assert_cache_dropped(&bytes, &report);
}
#[test]
fn large_image_url_at_min_cacheable_tokens_still_tags() {
let url = format!("https://cdn.example/{}", "a".repeat(5000));
let (bytes, report) = encode_floor(vec![IrItem::User {
parts: vec![IrPart::ImageUrl {
url: url.clone(),
detail: Some("high".into()),
bucket_owner: None,
file_id: None,
}],
}]);
assert!(
String::from_utf8_lossy(&bytes).contains(&url),
"image URL must be in the Messages body"
);
assert!(
!String::from_utf8_lossy(&bytes).contains("high"),
"image detail must not be sent"
);
assert_cache_preserved(&bytes, &report);
}
#[test]
fn unsigned_thinking_stays_below_min_cacheable_tokens() {
let thinking = "x".repeat(5000);
let (bytes, report) = encode_floor(vec![IrItem::User {
parts: vec![
IrPart::Text("hi".into()),
IrPart::Thinking {
text: thinking.clone(),
signature: None,
},
],
}]);
let body = String::from_utf8_lossy(&bytes);
assert!(body.contains("hi"), "user text must stay, got {body}");
assert!(
!body.contains(&thinking),
"unsigned thinking must not be sent, got {body}"
);
assert_cache_dropped(&bytes, &report);
}
#[test]
fn long_user_text_at_min_cacheable_tokens_still_tags() {
let text = "x".repeat(5000);
let (bytes, report) = encode_floor(vec![IrItem::User {
parts: vec![IrPart::Text(text.clone())],
}]);
assert!(
String::from_utf8_lossy(&bytes).contains(&text),
"user text must be in the Messages body"
);
assert_cache_preserved(&bytes, &report);
}
#[test]
fn whitespace_user_text_stays_below_min_cacheable_tokens() {
let (bytes, report) = encode_floor(vec![IrItem::User {
parts: vec![IrPart::Text(" \n".repeat(2500))],
}]);
assert_cache_dropped(&bytes, &report);
}
#[test]
fn signed_thinking_is_not_a_cache_breakpoint() {
let thinking = "x".repeat(5000);
let (bytes, _) = encode_floor(vec![IrItem::User {
parts: vec![
IrPart::ImageBase64 {
media_type: "image/png".into(),
data: "abcd".into(),
},
IrPart::Thinking {
text: thinking.clone(),
signature: Some("sig".into()),
},
],
}]);
let body: Value = serde_json::from_slice(&bytes).expect("json");
let content = body
.pointer("/messages/0/content")
.and_then(Value::as_array)
.expect("content");
assert!(
content.iter().any(|block| {
block.get("type").and_then(Value::as_str) == Some("thinking")
&& block.get("thinking").and_then(Value::as_str) == Some(thinking.as_str())
&& block.get("cache_control").is_none()
}),
"thinking must be sent without cache_control, got {body}"
);
assert!(
content.iter().any(|block| {
block.get("type").and_then(Value::as_str) == Some("image")
&& block.get("cache_control").is_some()
}),
"the image before thinking must carry the breakpoint, got {body}"
);
}
#[test]
fn placeholder_dot_does_not_hide_a_tool_result_breakpoint() {
let data = "x".repeat(5000);
let (bytes, report) = encode_floor(vec![
IrItem::User {
parts: vec![IrPart::Text(" \n".into())],
},
IrItem::FunctionOutput {
call_id: "c1".into(),
output: String::new(),
parts: vec![IrPart::ImageBase64 {
media_type: "image/png".into(),
data: data.clone(),
}],
is_error: false,
},
]);
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.to_string().contains(&data),
"tool-result image must be in the body, got {body}"
);
let content = body
.pointer("/messages/0/content")
.and_then(Value::as_array)
.expect("content");
assert!(
content
.iter()
.all(|block| { block.get("text").and_then(Value::as_str) != Some(".") }),
"a blank user turn must not insert '.' beside the tool result, got {body}"
);
assert!(
content.iter().any(|block| {
block.get("type").and_then(Value::as_str) == Some("tool_result")
&& block.get("cache_control").is_some()
}),
"tool result must carry the breakpoint, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "sampling.cache" && event.action == LossAction::Preserve
}),
"must Preserve sampling.cache, got {report:?}"
);
}
#[test]
fn audio_stays_below_min_cacheable_tokens() {
let (bytes, report) = encode_floor(vec![IrItem::User {
parts: vec![IrPart::Audio {
data: "x".repeat(5000),
format: "mp3".into(),
}],
}]);
assert_cache_dropped(&bytes, &report);
}
#[test]
fn unknown_raw_json_stays_below_min_cacheable_tokens() {
let (bytes, report) = encode_floor(vec![IrItem::Unknown {
type_name: "redacted_thinking".into(),
raw: serde_json::json!({
"type": "redacted_thinking",
"data": "x".repeat(5000),
}),
}]);
assert_cache_dropped(&bytes, &report);
}
fn user_ir(sampling: IrSampling) -> IrRequest {
IrRequest::new(
"gpt-4",
vec![IrItem::User {
parts: vec![IrPart::Text("hi".into())],
}],
)
.with_sampling(sampling)
}
fn loss_dropped(report: &LossReport, path: &str) -> bool {
report
.events
.iter()
.any(|event| event.path == path && event.action == LossAction::Drop)
}
fn breakpoint_drops(report: &LossReport) -> usize {
report
.events
.iter()
.filter(|event| {
event.path == "part.prompt_cache_breakpoint" && event.action == LossAction::Drop
})
.count()
}
fn hex_bytes(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn assert_chat_file_data_prefix(file_data: &str, media_type: &str, b64: &str) {
let got = file_data.as_bytes();
let mut expected = Vec::with_capacity(5 + media_type.len() + 8 + b64.len());
expected.extend_from_slice(b"data:");
expected.extend_from_slice(media_type.as_bytes());
expected.extend_from_slice(b";base64,");
expected.extend_from_slice(b64.as_bytes());
assert!(
got.starts_with(&expected),
"Chat file_data prefix mismatch: got hex {} want hex {}",
hex_bytes(&got[..got.len().min(expected.len() + 8)]),
hex_bytes(&expected)
);
}
fn loss_degraded(report: &LossReport, path: &str) -> bool {
report
.events
.iter()
.any(|event| event.path == path && event.action == LossAction::Degrade)
}
#[test]
fn chat_encode_emits_reasoning_effort_when_set() {
let ir = user_ir(IrSampling::patch(|s| {
s.reasoning_effort = Some("high".into());
}));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("reasoning_effort").and_then(Value::as_str),
Some("high"),
"Chat must emit reasoning_effort, got {body}"
);
assert!(
body.get("reasoning").is_none(),
"Chat must not invent a reasoning object, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.reasoning_effort"),
"effort has a Chat slot, got {report:?}"
);
}
#[test]
fn chat_encode_does_not_invent_reasoning_when_unset() {
let ir = user_ir(IrSampling::default());
let (bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("reasoning_effort").is_none(),
"unset effort must omit the field, got {body}"
);
assert!(
body.get("reasoning").is_none(),
"unset effort must not invent reasoning, got {body}"
);
}
#[test]
fn chat_decode_reads_reasoning_effort() {
let req = br#"{
"model": "o4-mini",
"reasoning_effort": "low",
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(ir.sampling.reasoning_effort.as_deref(), Some("low"));
}
#[test]
fn responses_decode_reads_reasoning_effort() {
let req = br#"{
"model": "gpt-5",
"input": "hi",
"reasoning": { "effort": "high" }
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
assert_eq!(ir.sampling.reasoning_effort.as_deref(), Some("high"));
}
#[test]
fn chat_decode_reads_max_reasoning_tokens() {
let req = br#"{
"model": "o4-mini",
"max_reasoning_tokens": 2048,
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(ir.sampling.max_reasoning_tokens, Some(2048));
}
#[test]
fn chat_decode_empty_reasoning_effort_is_unset() {
for effort in ["", " \t"] {
let req = format!(
r#"{{
"model": "o4-mini",
"reasoning_effort": {effort},
"messages": [{{"role": "user", "content": "hi"}}]
}}"#,
effort = serde_json::to_string(effort).expect("json")
);
let (ir, _) = decode(Wire::ChatCompletions, req.as_bytes()).expect("decode");
assert_eq!(
ir.sampling.reasoning_effort, None,
"empty or whitespace-only effort must be unset, got {:?} for {effort:?}",
ir.sampling.reasoning_effort
);
}
}
#[test]
fn chat_drops_max_reasoning_tokens() {
let ir = user_ir(IrSampling::patch(|s| {
s.max_reasoning_tokens = Some(2048);
}));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("max_reasoning_tokens").is_none(),
"Chat has no max_reasoning_tokens slot, got {body}"
);
assert!(
loss_dropped(&report, "sampling.max_reasoning_tokens"),
"must record drop, got {report:?}"
);
}
#[test]
fn responses_encode_drops_max_reasoning_tokens() {
let ir = user_ir(IrSampling::patch(|s| {
s.max_reasoning_tokens = Some(2048);
}));
let (bytes, report) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("reasoning").is_none(),
"max alone must not invent reasoning, got {body}"
);
assert!(
loss_dropped(&report, "sampling.max_reasoning_tokens"),
"must record drop, got {report:?}"
);
}
#[test]
fn responses_encode_folds_effort_into_reasoning() {
let ir = user_ir(IrSampling::patch(|s| {
s.reasoning_effort = Some("high".into());
}));
let (bytes, _) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/reasoning/effort").and_then(Value::as_str),
Some("high"),
"Responses must fold effort into reasoning, got {body}"
);
assert!(
body.get("include").is_none(),
"effort alone must not add include, got {body}"
);
}
#[test]
fn responses_encode_does_not_invent_reasoning_object_when_unset() {
let ir = user_ir(IrSampling::default());
let (bytes, _) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("reasoning").is_none(),
"unset effort must not invent reasoning, got {body}"
);
}
#[test]
fn responses_encode_does_not_invent_reasoning_for_empty_effort() {
for effort in ["", " \t"] {
let ir = user_ir(IrSampling::patch(|s| {
s.reasoning_effort = Some(effort.into());
}));
let (bytes, _) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("reasoning").is_none(),
"empty or whitespace-only effort must not invent reasoning, got {body}"
);
}
}
#[test]
fn responses_encode_include_thoughts_as_reasoning_summary_auto() {
let ir = user_ir(IrSampling::patch(|s| {
s.include_thoughts = Some(true);
}));
let (bytes, report) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/reasoning/summary").and_then(Value::as_str),
Some("auto"),
"include_thoughts=true must emit reasoning.summary=auto, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.include_thoughts"),
"include_thoughts has a Responses summary slot, got {report:?}"
);
assert!(
report.events.iter().any(|event| {
event.path == "sampling.include_thoughts" && event.action == LossAction::Preserve
}),
"expected Preserve include_thoughts, got {report:?}"
);
}
#[test]
fn responses_encode_merges_summary_into_existing_reasoning() {
let ir = user_ir(IrSampling::patch(|s| {
s.include_thoughts = Some(true);
s.reasoning_effort = Some("high".into());
}));
let (bytes, report) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/reasoning/effort").and_then(Value::as_str),
Some("high"),
"must keep effort when merging summary, got {body}"
);
assert_eq!(
body.pointer("/reasoning/summary").and_then(Value::as_str),
Some("auto"),
"must merge summary=auto into reasoning, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.include_thoughts"),
"include_thoughts must Preserve, got {report:?}"
);
}
#[test]
fn responses_encode_false_include_thoughts_does_not_invent_summary() {
let ir = user_ir(IrSampling::patch(|s| {
s.include_thoughts = Some(false);
s.reasoning_effort = Some("high".into());
}));
let (bytes, report) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/reasoning/effort").and_then(Value::as_str),
Some("high"),
"effort must still emit, got {body}"
);
assert!(
body.pointer("/reasoning/summary").is_none(),
"include_thoughts=false must not invent summary, got {body}"
);
assert!(
loss_dropped(&report, "sampling.include_thoughts"),
"include_thoughts=false has no summary dest, got {report:?}"
);
}
#[test]
fn responses_decode_reasoning_summary_sets_include_thoughts() {
let req = br#"{
"model": "gpt-5",
"input": "hi",
"reasoning": { "summary": "auto" }
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
assert_eq!(ir.sampling.include_thoughts, Some(true));
}
#[test]
fn responses_decode_nonempty_reasoning_summary_sets_include_thoughts() {
let req = br#"{
"model": "gpt-5",
"input": "hi",
"reasoning": { "effort": "low", "summary": "detailed" }
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
assert_eq!(ir.sampling.include_thoughts, Some(true));
assert_eq!(ir.sampling.reasoning_effort.as_deref(), Some("low"));
}
#[test]
fn responses_decode_empty_reasoning_summary_leaves_include_thoughts_unset() {
for summary in ["", " \t"] {
let req = format!(
r#"{{
"model": "gpt-5",
"input": "hi",
"reasoning": {{ "summary": {summary} }}
}}"#,
summary = serde_json::to_string(summary).expect("json")
);
let (ir, _) = decode(Wire::Responses, req.as_bytes()).expect("decode");
assert_eq!(
ir.sampling.include_thoughts, None,
"empty summary must not invent include_thoughts, got {:?} for {summary:?}",
ir.sampling.include_thoughts
);
}
}
#[test]
fn messages_encode_emits_thinking_from_include_thoughts_and_budget() {
let ir = user_ir(IrSampling::patch(|s| {
s.include_thoughts = Some(true);
s.max_reasoning_tokens = Some(2048);
s.reasoning_effort = Some("high".into());
}));
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/thinking/type").and_then(Value::as_str),
Some("enabled"),
"Messages must emit thinking, got {body}"
);
assert_eq!(
body.pointer("/thinking/budget_tokens"),
Some(&serde_json::json!(2048)),
"max_reasoning_tokens wins budget_tokens, got {body}"
);
assert!(
body.get("reasoning_effort").is_none(),
"Messages must not emit Chat-only effort, got {body}"
);
assert!(
!report.events.iter().any(|event| {
event.path.starts_with("sampling.")
&& event.path.contains("reason")
&& event.action == LossAction::Drop
}),
"reasoning fields have a Messages slot, got {report:?}"
);
}
#[test]
fn messages_encode_effort_high_maps_to_thinking_budget() {
let ir = user_ir(IrSampling::patch(|s| {
s.reasoning_effort = Some("high".into());
}));
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/thinking/type").and_then(Value::as_str),
Some("enabled"),
"effort must enable thinking, got {body}"
);
assert_eq!(
body.pointer("/thinking/budget_tokens"),
Some(&serde_json::json!(32768)),
"high effort default budget, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.reasoning_effort"),
"effort has a Messages slot, got {report:?}"
);
}
#[test]
fn messages_encode_disables_thinking_when_include_thoughts_false() {
let ir = user_ir(IrSampling::patch(|s| {
s.include_thoughts = Some(false);
s.reasoning_effort = Some("high".into());
s.max_reasoning_tokens = Some(2048);
}));
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/thinking/type").and_then(Value::as_str),
Some("disabled"),
"explicit include_thoughts=false must disable, got {body}"
);
assert!(
body.pointer("/thinking/budget_tokens").is_none(),
"disabled thinking must not carry budget_tokens, got {body}"
);
assert!(
body.pointer("/output_config/effort").is_none(),
"disabled thinking must not emit output_config.effort, got {body}"
);
assert!(
loss_dropped(&report, "sampling.reasoning_effort"),
"disabled thinking must Drop reasoning_effort, got {report:?}"
);
}
#[test]
fn messages_encode_raises_max_tokens_above_budget() {
let ir = user_ir(IrSampling::patch(|s| {
s.include_thoughts = Some(true);
s.max_reasoning_tokens = Some(2048);
s.max_tokens = Some(100);
}));
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("max_tokens"),
Some(&serde_json::json!(6144)),
"raise max_tokens by 4096 completion room above budget, got {body}"
);
}
#[test]
fn messages_encode_defaults_max_tokens_when_unset() {
let ir = user_ir(IrSampling::default());
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("max_tokens"),
Some(&serde_json::json!(4096)),
"Messages requires max_tokens; chat clients often omit it, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "sampling.max_tokens" && event.action == LossAction::Degrade
}),
"default must be recorded, got {report:?}"
);
}
#[test]
fn messages_encode_does_not_invent_thinking_when_unset() {
let ir = user_ir(IrSampling::default());
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("thinking").is_none(),
"unset sampling must omit thinking, got {body}"
);
}
#[test]
fn messages_encode_empty_effort_does_not_invent_thinking() {
for effort in ["", " \t"] {
let ir = user_ir(IrSampling::patch(|s| {
s.reasoning_effort = Some(effort.into());
}));
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("thinking").is_none(),
"empty or whitespace-only effort must not invent thinking, got {body}"
);
}
}
#[test]
fn messages_encode_thinking_budget_wins_when_max_reasoning_unset() {
let ir = user_ir(IrSampling::patch(|s| {
s.thinking_budget = Some(24576);
}));
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/thinking/type").and_then(Value::as_str),
Some("enabled"),
"thinking_budget-only encode must write type enabled, got {body}"
);
assert_eq!(
body.pointer("/thinking/budget_tokens"),
Some(&serde_json::json!(24576)),
"thinking_budget is the Messages slot when max_reasoning_tokens is unset, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.thinking_budget"),
"thinking_budget has a Messages slot, got {report:?}"
);
}
#[test]
fn messages_encode_keeps_explicit_thinking_budget_zero() {
let ir = user_ir(IrSampling::patch(|s| {
s.include_thoughts = Some(true);
s.max_reasoning_tokens = Some(0);
}));
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/thinking/type").and_then(Value::as_str),
Some("enabled"),
"include_thoughts=true with budget 0 stays enabled, got {body}"
);
assert_eq!(
body.pointer("/thinking/budget_tokens"),
Some(&serde_json::json!(0)),
"explicit max_reasoning_tokens 0 must not become 10240, got {body}"
);
}
#[test]
fn messages_encode_thinking_budget_zero_wins_when_max_reasoning_unset() {
let ir = user_ir(IrSampling::patch(|s| {
s.include_thoughts = Some(true);
s.thinking_budget = Some(0);
}));
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/thinking/budget_tokens"),
Some(&serde_json::json!(0)),
"explicit thinking_budget 0 must not become 10240, got {body}"
);
}
#[test]
fn messages_decode_reads_thinking_enabled() {
let req = br#"{
"model": "claude-haiku-4-5-20251001",
"thinking": { "type": "enabled", "budget_tokens": 2048 },
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::Messages, req).expect("decode");
assert_eq!(ir.sampling.include_thoughts, Some(true));
assert_eq!(ir.sampling.max_reasoning_tokens, Some(2048));
}
#[test]
fn messages_decode_reads_thinking_disabled() {
let req = br#"{
"model": "claude-haiku-4-5-20251001",
"thinking": { "type": "disabled" },
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::Messages, req).expect("decode");
assert_eq!(ir.sampling.include_thoughts, Some(false));
assert_eq!(ir.sampling.max_reasoning_tokens, None);
}
#[test]
fn messages_decode_unknown_thinking_missing_type_drops() {
let req = br#"{
"model": "claude-haiku-4-5-20251001",
"thinking": { "budget_tokens": 2048 },
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, report) = decode(Wire::Messages, req).expect("decode");
assert_eq!(ir.sampling.include_thoughts, None);
assert_eq!(ir.sampling.max_reasoning_tokens, None);
assert!(
loss_dropped(&report, "sampling.thinking"),
"unknown thinking object must Drop, got {report:?}"
);
}
#[test]
fn messages_decode_unknown_thinking_array_drops() {
let req = br#"{
"model": "claude-haiku-4-5-20251001",
"thinking": [],
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, report) = decode(Wire::Messages, req).expect("decode");
assert_eq!(ir.sampling.include_thoughts, None);
assert_eq!(ir.sampling.max_reasoning_tokens, None);
assert!(
loss_dropped(&report, "sampling.thinking"),
"array thinking must Drop, got {report:?}"
);
}
#[test]
fn messages_round_trips_adaptive_and_between_tools_thinking() {
let adaptive = br#"{
"model": "claude-haiku-4-5-20251001",
"thinking": { "type": "adaptive", "display": "summarized" },
"output_config": { "effort": "high" },
"max_tokens": 1024,
"temperature": 0.2,
"tool_choice": { "type": "any" },
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, report) = decode(Wire::Messages, adaptive).expect("decode");
assert_eq!(ir.sampling.include_thoughts, None);
assert_eq!(ir.sampling.max_reasoning_tokens, None);
assert_eq!(
ir.sampling.messages_thinking,
Some(serde_json::json!({"type": "adaptive", "display": "summarized"}))
);
assert!(
!loss_dropped(&report, "sampling.thinking"),
"adaptive thinking must stay, got {report:?}"
);
let (bytes, encode_report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("thinking"),
Some(&serde_json::json!({"type": "adaptive", "display": "summarized"}))
);
assert_eq!(body.get("temperature"), Some(&serde_json::json!(0.2)));
assert_eq!(
body.pointer("/output_config/effort")
.and_then(Value::as_str),
Some("high")
);
assert!(
!loss_dropped(&encode_report, "sampling.reasoning_effort"),
"effort must stay beside adaptive thinking, got {encode_report:?}"
);
assert_eq!(
body.pointer("/tool_choice/type").and_then(Value::as_str),
Some("any")
);
assert!(
!loss_dropped(&encode_report, "sampling.messages_thinking"),
"encode dropped adaptive thinking, got {encode_report:?}"
);
let between = br#"{
"model": "claude-haiku-4-5-20251001",
"thinking": { "type": "between_tools" },
"max_tokens": 1024,
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, report) = decode(Wire::Messages, between).expect("decode");
assert_eq!(
ir.sampling.messages_thinking,
Some(serde_json::json!({"type": "between_tools"}))
);
assert!(!loss_dropped(&report, "sampling.thinking"), "{report:?}");
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("thinking"),
Some(&serde_json::json!({"type": "between_tools"}))
);
let omitted = br#"{
"model": "claude-haiku-4-5-20251001",
"thinking": { "type": "adaptive", "display": "omitted" },
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::Messages, omitted).expect("decode");
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/thinking/display").and_then(Value::as_str),
Some("omitted")
);
let future = br#"{
"model": "claude-haiku-4-5-20251001",
"thinking": { "type": "FUTURE_THINKING" },
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, report) = decode(Wire::Messages, future).expect("decode");
assert_eq!(ir.sampling.messages_thinking, None);
assert!(
loss_dropped(&report, "sampling.thinking"),
"unknown thinking type must Drop, got {report:?}"
);
}
#[test]
fn other_wires_drop_messages_native_thinking() {
let ir = user_ir(IrSampling::patch(|sampling| {
sampling.messages_thinking =
Some(serde_json::json!({"type": "adaptive", "display": "summarized"}));
}));
let wires = [
(Wire::ChatCompletions, chat_profile()),
(Wire::Responses, hard_error_profile()),
(Wire::Gemini, gemini_profile()),
(Wire::Converse, converse_profile()),
];
for (wire, profile) in wires {
let (bytes, report) = encode(wire, &ir, &profile).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let text = String::from_utf8(bytes).expect("utf8");
assert!(
!text.contains("adaptive") && !text.contains("between_tools"),
"{wire:?} emitted native thinking, got {body}"
);
assert!(
loss_dropped(&report, "sampling.messages_thinking"),
"{wire:?} drop missing, got {report:?}"
);
}
let (bytes, report) = encode(
Wire::Messages,
&user_ir(IrSampling::default()),
&messages_profile(),
)
.expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(body.get("thinking").is_none(), "invented thinking: {body}");
assert!(
!loss_dropped(&report, "sampling.messages_thinking"),
"default must not Drop messages thinking, got {report:?}"
);
}
#[test]
fn messages_thinking_round_trips() {
let req = br#"{
"model": "claude-haiku-4-5-20251001",
"thinking": { "type": "enabled", "budget_tokens": 2048 },
"max_tokens": 8192,
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::Messages, req).expect("decode");
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/thinking/type").and_then(Value::as_str),
Some("enabled")
);
assert_eq!(
body.pointer("/thinking/budget_tokens"),
Some(&serde_json::json!(2048))
);
assert_eq!(body.get("max_tokens"), Some(&serde_json::json!(8192)));
}
#[test]
fn gemini_thinking_config_survives_reasoning_sampling_fields() {
let ir = user_ir(IrSampling::patch(|s| {
s.include_thoughts = Some(true);
s.thinking_budget = Some(24576);
s.reasoning_effort = Some("high".into());
s.max_reasoning_tokens = Some(1024);
}));
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let tc = body
.pointer("/generationConfig/thinkingConfig")
.expect("thinkingConfig should be nested under generationConfig");
assert_eq!(tc.get("includeThoughts"), Some(&Value::Bool(true)));
assert_eq!(tc.get("thinkingBudget"), Some(&serde_json::json!(24576)));
assert_eq!(
tc.get("thinkingLevel").and_then(Value::as_str),
Some("high"),
"Gemini effort must emit thinkingLevel, got {body}"
);
assert!(
body.get("thinkingConfig").is_none(),
"must not emit top-level thinkingConfig: {body}"
);
assert!(
!loss_dropped(&report, "sampling.reasoning_effort"),
"Gemini effort has thinkingLevel, got {report:?}"
);
assert!(
report.events.iter().any(|event| {
event.path == "sampling.max_reasoning_tokens"
&& event.action == LossAction::Drop
&& event.detail.contains("sibling")
&& !event.detail.contains("no slot")
}),
"thinking_budget sibling win must not say no slot, got {report:?}"
);
}
#[test]
fn gemini_encode_effort_as_thinking_level() {
let ir = user_ir(IrSampling::patch(|s| {
s.reasoning_effort = Some("high".into());
}));
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/generationConfig/thinkingConfig/thinkingLevel")
.and_then(Value::as_str),
Some("high"),
"Gemini must emit thinkingLevel from effort, got {body}"
);
assert!(
body.pointer("/generationConfig/thinkingConfig/thinkingBudget")
.is_none(),
"effort must not map to thinkingBudget, got {body}"
);
assert!(
body.get("thinkingConfig").is_none(),
"must not emit top-level thinkingConfig: {body}"
);
assert!(
!loss_dropped(&report, "sampling.reasoning_effort"),
"effort has a Gemini thinkingLevel slot, got {report:?}"
);
}
#[test]
fn gemini_encode_thinking_level_is_lowercase() {
let ir = user_ir(IrSampling::patch(|s| {
s.reasoning_effort = Some("MEDIUM".into());
}));
let (bytes, _) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/generationConfig/thinkingConfig/thinkingLevel")
.and_then(Value::as_str),
Some("medium"),
"thinkingLevel must be lowercase, got {body}"
);
}
#[test]
fn gemini_encode_xhigh_effort_degrades_to_high() {
for effort in ["xhigh", "x-high", "XHIGH", "X-High"] {
let ir = user_ir(IrSampling::patch(|s| {
s.reasoning_effort = Some(effort.into());
}));
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/generationConfig/thinkingConfig/thinkingLevel")
.and_then(Value::as_str),
Some("high"),
"{effort} must map to thinkingLevel high, got {body}"
);
assert!(
loss_degraded(&report, "sampling.reasoning_effort"),
"{effort} must Degrade to high, got {report:?}"
);
assert!(
body.pointer("/generationConfig/thinkingConfig/thinkingBudget")
.is_none(),
"{effort} must not invent thinkingBudget, got {body}"
);
}
}
#[test]
fn gemini_encode_empty_effort_does_not_invent_thinking_level() {
for effort in ["", " \t"] {
let ir = user_ir(IrSampling::patch(|s| {
s.reasoning_effort = Some(effort.into());
}));
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.pointer("/generationConfig/thinkingConfig").is_none(),
"empty effort must not invent thinkingConfig, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.reasoning_effort"),
"empty effort is unset, not a Drop, got {report:?}"
);
}
}
#[test]
fn gemini_decode_reads_thinking_level() {
let req = br#"{
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"generationConfig": {
"thinkingConfig": { "thinkingLevel": "low" }
}
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
assert_eq!(ir.sampling.reasoning_effort.as_deref(), Some("low"));
}
#[test]
fn gemini_decode_reads_snake_thinking_level() {
let req = br#"{
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"generationConfig": {
"thinkingConfig": { "thinking_level": "minimal" }
}
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
assert_eq!(ir.sampling.reasoning_effort.as_deref(), Some("minimal"));
}
#[test]
fn gemini_thinking_budget_from_messages_max_reasoning_tokens() {
let req = br#"{
"thinking": {"type": "enabled", "budget_tokens": 2048},
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::Messages, req).expect("decode");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/generationConfig/thinkingConfig/thinkingBudget"),
Some(&serde_json::json!(2048)),
"Messages budget_tokens must encode as Gemini thinkingBudget, got {body}"
);
assert!(
!report.events.iter().any(|event| {
event.path == "sampling.max_reasoning_tokens"
&& event.action == LossAction::Drop
&& event.detail.contains("no slot")
}),
"max_reasoning_tokens used as thinkingBudget must not Drop as no slot, got {report:?}"
);
}
#[test]
fn gemini_thinking_budget_zero_from_max_reasoning_tokens() {
let ir = user_ir(IrSampling::patch(|s| {
s.include_thoughts = Some(true);
s.max_reasoning_tokens = Some(0);
}));
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/generationConfig/thinkingConfig/thinkingBudget"),
Some(&serde_json::json!(0)),
"max_reasoning_tokens 0 is official Gemini disable, got {body}"
);
assert_eq!(
body.pointer("/generationConfig/thinkingConfig/includeThoughts"),
Some(&Value::Bool(true)),
"include_thoughts must still emit, got {body}"
);
assert!(
!report.events.iter().any(|event| {
event.path == "sampling.max_reasoning_tokens"
&& event.action == LossAction::Drop
&& event.detail.contains("no slot")
}),
"max_reasoning_tokens 0 has a Gemini slot, got {report:?}"
);
}
#[test]
fn gemini_thinking_ir_drops_on_chat() {
let ir = user_ir(IrSampling::patch(|s| {
s.include_thoughts = Some(true);
s.thinking_budget = Some(24576);
}));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("thinkingConfig").is_none(),
"Chat must not invent thinkingConfig, got {body}"
);
assert!(
body.get("include_thoughts").is_none() && body.get("includeThoughts").is_none(),
"Chat must not invent include_thoughts, got {body}"
);
assert!(
body.get("thinking_budget").is_none() && body.get("thinkingBudget").is_none(),
"Chat must not invent thinking_budget, got {body}"
);
assert!(
body.get("thinking").is_none(),
"Chat must not invent Messages thinking, got {body}"
);
assert!(
loss_dropped(&report, "sampling.include_thoughts"),
"Chat include_thoughts drop missing, got {report:?}"
);
assert!(
loss_dropped(&report, "sampling.thinking_budget"),
"Chat thinking_budget drop missing, got {report:?}"
);
}
#[test]
fn chat_required_tool_choice_maps_on_gemini() {
let ir = user_ir(IrSampling::patch(|s| {
s.tool_choice = IrToolChoice::Required;
}));
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("tool_choice").is_none(),
"Gemini must not invent tool_choice, got {body}"
);
assert_eq!(
body.pointer("/toolConfig/functionCallingConfig/mode")
.and_then(Value::as_str),
Some("ANY"),
"Required must encode as functionCallingConfig.mode ANY, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.tool_choice"),
"Gemini has a slot and must not Drop tool_choice, got {report:?}"
);
}
#[test]
fn gemini_none_tool_choice_encodes() {
let ir = user_ir(IrSampling::patch(|s| {
s.tool_choice = IrToolChoice::None;
}));
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/toolConfig/functionCallingConfig/mode")
.and_then(Value::as_str),
Some("NONE"),
"None must encode as functionCallingConfig.mode NONE, got {body}"
);
assert!(
body.pointer("/toolConfig/functionCallingConfig/allowedFunctionNames")
.is_none(),
"None must not invent allowedFunctionNames, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.tool_choice"),
"Gemini has a slot and must not Drop tool_choice, got {report:?}"
);
}
#[test]
fn gemini_named_tool_choice_encodes() {
let ir = user_ir(IrSampling::patch(|s| {
s.tool_choice = IrToolChoice::Named("lookup".into());
}));
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/toolConfig/functionCallingConfig/mode")
.and_then(Value::as_str),
Some("ANY"),
"Named must encode as functionCallingConfig.mode ANY, got {body}"
);
assert_eq!(
body.pointer("/toolConfig/functionCallingConfig/allowedFunctionNames"),
Some(&serde_json::json!(["lookup"])),
"Named must encode allowedFunctionNames, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.tool_choice"),
"Gemini has a slot and must not Drop tool_choice, got {report:?}"
);
}
#[test]
fn gemini_tool_choice_round_trips() {
let cases: &[(&str, IrToolChoice, Value)] = &[
(
r#"{
"model": "gemini-2.5-pro",
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"toolConfig": {"functionCallingConfig": {"mode": "ANY"}}
}"#,
IrToolChoice::Required,
serde_json::json!({"mode": "ANY"}),
),
(
r#"{
"model": "gemini-2.5-pro",
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"toolConfig": {"functionCallingConfig": {"mode": "NONE"}}
}"#,
IrToolChoice::None,
serde_json::json!({"mode": "NONE"}),
),
(
r#"{
"model": "gemini-2.5-pro",
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"toolConfig": {
"functionCallingConfig": {
"mode": "ANY",
"allowedFunctionNames": ["lookup"]
}
}
}"#,
IrToolChoice::Named("lookup".into()),
serde_json::json!({"mode": "ANY", "allowedFunctionNames": ["lookup"]}),
),
];
for (req, expected, fcc) in cases {
let (ir, decode_report) = decode(Wire::Gemini, req.as_bytes()).expect("decode");
assert_eq!(
ir.sampling.tool_choice, *expected,
"decode tool_choice from {req}"
);
assert!(
!loss_dropped(&decode_report, "sampling.tool_choice"),
"Gemini has toolConfig and must not Drop on decode, got {decode_report:?}"
);
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("toolConfig")
.and_then(|v| v.get("functionCallingConfig")),
Some(fcc),
"Gemini must emit functionCallingConfig {fcc}, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.tool_choice"),
"Gemini has a slot and must not Drop tool_choice, got {report:?}"
);
}
}
#[test]
fn chat_grouped_function_call_records_thought_signature_drop() {
let ir = IrRequest::new(
"gpt-4",
vec![
IrItem::Assistant {
phase: None,
parts: vec![IrPart::Text("calling".into())],
},
IrItem::FunctionCall {
call_id: "call_1".into(),
name: "lookup".into(),
arguments: "{}".into(),
thought_signature: Some("sig_grouped".into()),
},
],
);
let (_, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
assert!(
report.events.iter().any(|event| {
event.action == LossAction::Drop
&& event.detail == "thoughtSignature has no Chat Completions slot"
}),
"grouped FunctionCall must record the same thought_signature Drop as standalone, got {report:?}"
);
}
#[test]
fn chat_standalone_function_calls_encode_one_tool_calls_message() {
let ir = IrRequest::new(
"gpt-4",
vec![
IrItem::FunctionCall {
call_id: "call_1".into(),
name: "lookup".into(),
arguments: r#"{"q":"x"}"#.into(),
thought_signature: None,
},
IrItem::FunctionCall {
call_id: "call_2".into(),
name: "search".into(),
arguments: r#"{"q":"y"}"#.into(),
thought_signature: None,
},
],
);
let (bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let messages = body
.get("messages")
.and_then(Value::as_array)
.expect("messages");
assert_eq!(
messages.len(),
1,
"standalone FunctionCalls must be one assistant turn, got {body}"
);
assert_eq!(
messages[0].get("content"),
Some(&Value::Null),
"tool-only assistant content must stay null, got {body}"
);
let calls = messages[0]
.get("tool_calls")
.and_then(Value::as_array)
.expect("tool_calls");
assert_eq!(
calls.len(),
2,
"one tool_calls array with both calls, got {body}"
);
assert_eq!(
calls[0].pointer("/function/name").and_then(Value::as_str),
Some("lookup"),
"first tool_call name, got {body}"
);
assert_eq!(
calls[1].pointer("/function/name").and_then(Value::as_str),
Some("search"),
"second tool_call name, got {body}"
);
}
#[test]
fn messages_parallel_function_calls_are_one_assistant_message() {
let ir = IrRequest::new(
"claude-haiku-4-5",
vec![
IrItem::FunctionCall {
call_id: "call_1".into(),
name: "lookup".into(),
arguments: r#"{"q":"x"}"#.into(),
thought_signature: None,
},
IrItem::FunctionCall {
call_id: "call_2".into(),
name: "search".into(),
arguments: r#"{"q":"y"}"#.into(),
thought_signature: None,
},
],
);
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let messages = body
.get("messages")
.and_then(Value::as_array)
.expect("messages");
let assistants: Vec<_> = messages
.iter()
.filter(|msg| msg.get("role").and_then(Value::as_str) == Some("assistant"))
.collect();
assert_eq!(
assistants.len(),
1,
"two FunctionCalls must be one assistant message, got {body}"
);
let content = assistants[0]
.get("content")
.and_then(Value::as_array)
.expect("assistant content");
assert_eq!(
content.len(),
2,
"one assistant message must hold both tool_use blocks, got {body}"
);
assert_eq!(content[0]["type"], "tool_use");
assert_eq!(content[0]["id"], "call_1");
assert_eq!(content[0]["name"], "lookup");
assert_eq!(content[0]["input"], serde_json::json!({"q": "x"}));
assert_eq!(content[1]["type"], "tool_use");
assert_eq!(content[1]["id"], "call_2");
assert_eq!(content[1]["name"], "search");
assert_eq!(content[1]["input"], serde_json::json!({"q": "y"}));
}
#[test]
fn messages_parallel_function_outputs_are_one_user_message() {
let ir = IrRequest::new(
"claude-haiku-4-5",
vec![
IrItem::FunctionOutput {
call_id: "call_1".into(),
output: "ok".into(),
parts: Vec::new(),
is_error: false,
},
IrItem::FunctionOutput {
call_id: "call_2".into(),
output: "failed".into(),
parts: Vec::new(),
is_error: true,
},
],
);
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let messages = body
.get("messages")
.and_then(Value::as_array)
.expect("messages");
assert_eq!(
messages.len(),
1,
"two FunctionOutputs must be one user message, got {body}"
);
assert_eq!(messages[0]["role"], "user");
let content = messages[0]
.get("content")
.and_then(Value::as_array)
.expect("user content");
assert_eq!(
content.len(),
2,
"one user message must hold both tool_result blocks, got {body}"
);
assert_eq!(content[0]["type"], "tool_result");
assert_eq!(content[0]["tool_use_id"], "call_1");
assert_eq!(content[0]["content"], "ok");
assert!(
content[0].get("is_error").is_none(),
"a successful tool result must omit is_error, got {body}"
);
assert_eq!(content[1]["type"], "tool_result");
assert_eq!(content[1]["tool_use_id"], "call_2");
assert_eq!(content[1]["content"], "failed");
assert_eq!(
content[1]["is_error"], true,
"only the failed tool result sets is_error, got {body}"
);
}
#[test]
fn messages_user_text_before_tool_result_puts_tool_result_first() {
let ir = IrRequest::new(
"claude-haiku-4-5",
vec![
IrItem::User {
parts: vec![IrPart::Text("Use metric next time.".into())],
},
IrItem::FunctionOutput {
call_id: "call_1".into(),
output: "sunny".into(),
parts: Vec::new(),
is_error: false,
},
],
);
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let content = body
.pointer("/messages/0/content")
.and_then(Value::as_array)
.expect("user content");
assert_eq!(
content.len(),
2,
"text and the tool result must share one user message, got {body}"
);
assert_eq!(
content[0]["type"], "tool_result",
"tool_result must come before text, got {body}"
);
assert_eq!(content[0]["tool_use_id"], "call_1");
assert_eq!(content[0]["content"], "sunny");
assert_eq!(content[1]["type"], "text");
assert_eq!(content[1]["text"], "Use metric next time.");
}
#[test]
fn messages_blank_user_before_tool_result_does_not_lead_with_a_dot() {
let ir = IrRequest::new(
"claude-haiku-4-5",
vec![
IrItem::User {
parts: vec![IrPart::Text("\n".into())],
},
IrItem::FunctionOutput {
call_id: "call_1".into(),
output: "sunny".into(),
parts: Vec::new(),
is_error: true,
},
],
);
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let content = body
.pointer("/messages/0/content")
.and_then(Value::as_array)
.expect("user content");
assert_eq!(
content.len(),
1,
"a blank user turn must not add text beside the tool result, got {body}"
);
assert_eq!(content[0]["type"], "tool_result");
assert_eq!(content[0]["tool_use_id"], "call_1");
assert_eq!(content[0]["is_error"], true);
}
#[test]
fn messages_empty_error_tool_result_is_not_blank() {
let ir = IrRequest::new(
"claude-haiku-4-5",
vec![IrItem::FunctionOutput {
call_id: "call_1".into(),
output: String::new(),
parts: Vec::new(),
is_error: true,
}],
);
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/content")
.and_then(Value::as_str),
Some("."),
"an error tool result with no text must not be empty, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/is_error")
.and_then(Value::as_bool),
Some(true),
"is_error must stay set, got {body}"
);
assert!(
loss_degraded(&report, "items[0]"),
"the placeholder must Degrade, got {report:?}"
);
}
#[test]
fn messages_joined_users_keep_tool_result_before_earlier_text() {
let ir = IrRequest::new(
"claude-haiku-4-5",
vec![
IrItem::User {
parts: vec![IrPart::Text("first".into())],
},
IrItem::System {
text: "late rule".into(),
prompt_cache_breakpoint: None,
content_parts: Vec::new(),
},
IrItem::User {
parts: vec![IrPart::Text("second".into())],
},
IrItem::FunctionOutput {
call_id: "call_1".into(),
output: "sunny".into(),
parts: Vec::new(),
is_error: false,
},
],
);
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content"),
Some(&serde_json::json!([
{"type": "tool_result", "tool_use_id": "call_1", "content": "sunny"},
{"type": "text", "text": "first"},
{"type": "text", "text": "second"}
])),
"joining a later tool result must not leave earlier text in front, got {body}"
);
}
#[test]
fn chat_tool_turn_encodes_one_messages_pair() {
let req = br#"{
"model": "gpt-4",
"messages": [
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "lookup", "arguments": "{\"q\":\"x\"}"}
},
{
"id": "call_2",
"type": "function",
"function": {"name": "search", "arguments": "{\"q\":\"y\"}"}
}
]
},
{"role": "tool", "tool_call_id": "call_1", "content": "one"},
{"role": "tool", "tool_call_id": "call_2", "content": "two"}
]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let messages = body
.get("messages")
.and_then(Value::as_array)
.expect("messages");
assert_eq!(
messages.len(),
2,
"Chat parallel tool turn must be one assistant message and one user message, got {body}"
);
assert_eq!(messages[0]["role"], "assistant");
assert_eq!(messages[1]["role"], "user");
let calls = messages[0]
.get("content")
.and_then(Value::as_array)
.expect("assistant content");
assert_eq!(
calls.len(),
2,
"both Chat tool_calls must be tool_use blocks on one assistant message, got {body}"
);
assert_eq!(calls[0]["type"], "tool_use");
assert_eq!(calls[0]["id"], "call_1");
assert_eq!(calls[0]["name"], "lookup");
assert_eq!(calls[1]["type"], "tool_use");
assert_eq!(calls[1]["id"], "call_2");
assert_eq!(calls[1]["name"], "search");
let results = messages[1]
.get("content")
.and_then(Value::as_array)
.expect("user content");
assert_eq!(
results.len(),
2,
"the second tool result must not be a separate user turn, got {body}"
);
assert_eq!(results[0]["type"], "tool_result");
assert_eq!(results[0]["tool_use_id"], "call_1");
assert_eq!(results[0]["content"], "one");
assert_eq!(results[1]["type"], "tool_result");
assert_eq!(results[1]["tool_use_id"], "call_2");
assert_eq!(results[1]["content"], "two");
}
#[test]
fn messages_reasoning_then_call_is_one_assistant_message() {
let ir = IrRequest::new(
"claude-haiku-4-5",
vec![
IrItem::Reasoning {
encrypted: None,
summary: Some("check the index".into()),
raw: None,
},
IrItem::FunctionCall {
call_id: "call_1".into(),
name: "lookup".into(),
arguments: r#"{"q":"x"}"#.into(),
thought_signature: None,
},
],
);
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let messages = body
.get("messages")
.and_then(Value::as_array)
.expect("messages");
let assistants: Vec<_> = messages
.iter()
.filter(|msg| msg.get("role").and_then(Value::as_str) == Some("assistant"))
.collect();
assert_eq!(
assistants.len(),
1,
"reasoning then FunctionCall must be one assistant message, got {body}"
);
let content = assistants[0]
.get("content")
.and_then(Value::as_array)
.expect("assistant content");
assert_eq!(
content.len(),
2,
"thinking and tool_use must share one assistant message, got {body}"
);
assert_eq!(content[0]["type"], "thinking");
assert_eq!(content[0]["thinking"], "check the index");
assert_eq!(content[1]["type"], "tool_use");
assert_eq!(content[1]["id"], "call_1");
assert_eq!(content[1]["name"], "lookup");
assert_eq!(content[1]["input"], serde_json::json!({"q": "x"}));
}
#[test]
fn messages_reasoning_then_assistant_text_is_one_assistant_message() {
let ir = IrRequest::new(
"claude-haiku-4-5",
vec![
IrItem::Reasoning {
encrypted: None,
summary: Some("check the index".into()),
raw: None,
},
IrItem::Assistant {
phase: None,
parts: vec![IrPart::Text("done".into())],
},
],
);
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let messages = body
.get("messages")
.and_then(Value::as_array)
.expect("messages");
let assistants: Vec<_> = messages
.iter()
.filter(|msg| msg.get("role").and_then(Value::as_str) == Some("assistant"))
.collect();
assert_eq!(
assistants.len(),
1,
"reasoning then assistant text must be one assistant message, got {body}"
);
let content = assistants[0]
.get("content")
.and_then(Value::as_array)
.expect("assistant content");
assert_eq!(
content.len(),
2,
"thinking and text must share one assistant message, got {body}"
);
assert_eq!(content[0]["type"], "thinking");
assert_eq!(content[0]["thinking"], "check the index");
assert_eq!(content[1]["type"], "text");
assert_eq!(content[1]["text"], "done");
}
#[test]
fn messages_tool_use_then_text_round_trips_one_assistant_message() {
let req = br#"{"model":"claude-haiku-4-5","max_tokens":16,"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"lookup","input":{"q":"x"}},{"type":"text","text":"done"}]}]}"#;
let (ir, _) = decode(Wire::Messages, req).expect("decode");
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let messages = body
.get("messages")
.and_then(Value::as_array)
.expect("messages");
let assistants: Vec<_> = messages
.iter()
.filter(|msg| msg.get("role").and_then(Value::as_str) == Some("assistant"))
.collect();
assert_eq!(
assistants.len(),
1,
"tool_use then text must round-trip as one assistant message, got {body}"
);
let content = assistants[0]
.get("content")
.and_then(Value::as_array)
.expect("assistant content");
assert_eq!(
content.len(),
2,
"tool_use and text must share one content array, got {body}"
);
assert_eq!(content[0]["type"], "tool_use");
assert_eq!(content[0]["id"], "call_1");
assert_eq!(content[0]["name"], "lookup");
assert_eq!(content[0]["input"], serde_json::json!({"q": "x"}));
assert_eq!(content[1]["type"], "text");
assert_eq!(content[1]["text"], "done");
}
#[test]
fn gemini_encode_does_not_invent_empty_function_call_args() {
let ir = IrRequest::new(
"gemini-2.5-flash",
vec![IrItem::FunctionCall {
call_id: "lookup".into(),
name: "lookup".into(),
arguments: "not-json".into(),
thought_signature: None,
}],
);
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let args = body.pointer("/contents/0/parts/0/functionCall/args");
assert_ne!(
args,
Some(&serde_json::json!({})),
"invalid JSON must not become empty object, got {body}"
);
assert_eq!(
args,
Some(&serde_json::json!({ "raw": "not-json" })),
"invalid JSON must stay visible inside an object, got {body}"
);
assert!(
loss_degraded(&report, "functionCall.args"),
"non-object tool arguments must Degrade, got {report:?}"
);
}
#[test]
fn messages_non_object_tool_arguments_stay_visible() {
let ir = IrRequest::new(
"claude-haiku-4-5",
vec![IrItem::FunctionCall {
call_id: "call_1".into(),
name: "echo".into(),
arguments: "not-json".into(),
thought_signature: None,
}],
);
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/input"),
Some(&serde_json::json!({ "raw": "not-json" })),
"Anthropic tool_use.input must be an object, got {body}"
);
assert!(
loss_degraded(&report, "items[0]"),
"non-object tool arguments must Degrade, got {report:?}"
);
let ir = IrRequest::new(
"claude-haiku-4-5",
vec![IrItem::FunctionCall {
call_id: "call_2".into(),
name: "echo".into(),
arguments: "[1]".into(),
thought_signature: None,
}],
);
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/input"),
Some(&serde_json::json!({ "raw": [1] })),
"a JSON array is not a tool_use.input object, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "items[0]"
&& event.action == LossAction::Degrade
&& event.detail.contains("not a JSON object")
}),
"parsed non-object must Degrade, got {report:?}"
);
}
#[test]
fn messages_sanitizes_gemini_shaped_tool_use_id() {
let ir = IrRequest::new(
"claude-opus-4-6",
vec![
IrItem::FunctionCall {
call_id: "lookup.v2".into(),
name: "lookup.v2".into(),
arguments: "{}".into(),
thought_signature: None,
},
IrItem::FunctionOutput {
call_id: "lookup.v2".into(),
output: "ok".into(),
parts: Vec::new(),
is_error: false,
},
],
);
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/id")
.and_then(Value::as_str),
Some("lookup_v2"),
"tool_use.id must drop the Gemini dot, got {body}"
);
assert_eq!(
body.pointer("/messages/1/content/0/tool_use_id")
.and_then(Value::as_str),
Some("lookup_v2"),
"tool_result.tool_use_id must match the rewritten tool_use.id, got {body}"
);
assert!(
loss_degraded(&report, "items[0]"),
"rewritten tool_use.id must record Degrade on the original path, got {report:?}"
);
assert!(
loss_degraded(&report, "items[1]"),
"rewritten tool_result.tool_use_id must record Degrade on the original path, got {report:?}"
);
}
#[test]
fn messages_keeps_consecutive_underscores_in_tool_use_id() {
let ir = IrRequest::new(
"claude-opus-4-6",
vec![
IrItem::FunctionCall {
call_id: "a__b".into(),
name: "lookup".into(),
arguments: "{}".into(),
thought_signature: None,
},
IrItem::FunctionOutput {
call_id: "a__b".into(),
output: "ok".into(),
parts: Vec::new(),
is_error: false,
},
],
);
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/id")
.and_then(Value::as_str),
Some("a__b"),
"tool_use.id must keep consecutive underscores, got {body}"
);
assert_eq!(
body.pointer("/messages/1/content/0/tool_use_id")
.and_then(Value::as_str),
Some("a__b"),
"tool_result.tool_use_id must keep the same id, got {body}"
);
assert!(
!loss_degraded(&report, "items[0]"),
"a legal id must not record Degrade, got {report:?}"
);
assert!(
!loss_degraded(&report, "items[1]"),
"a legal tool result id must not record Degrade, got {report:?}"
);
}
#[test]
fn messages_keeps_distinct_tool_ids_that_share_a_sanitized_form() {
let ir = IrRequest::new(
"claude-opus-4-6",
vec![
IrItem::FunctionCall {
call_id: "a__b".into(),
name: "lookup".into(),
arguments: "{}".into(),
thought_signature: None,
},
IrItem::FunctionOutput {
call_id: "a__b".into(),
output: "ok".into(),
parts: Vec::new(),
is_error: false,
},
IrItem::FunctionCall {
call_id: "a_b".into(),
name: "lookup".into(),
arguments: "{}".into(),
thought_signature: None,
},
IrItem::FunctionOutput {
call_id: "a_b".into(),
output: "ok".into(),
parts: Vec::new(),
is_error: false,
},
],
);
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let first = body
.pointer("/messages/0/content/0/id")
.and_then(Value::as_str);
let second = body
.pointer("/messages/2/content/0/id")
.and_then(Value::as_str);
assert_eq!(first, Some("a__b"), "first call id, got {body}");
assert_eq!(second, Some("a_b"), "second call id, got {body}");
assert_ne!(
first, second,
"distinct legal ids must stay distinct, got {body}"
);
assert_eq!(
body.pointer("/messages/1/content/0/tool_use_id")
.and_then(Value::as_str),
Some("a__b"),
"first tool result, got {body}"
);
assert_eq!(
body.pointer("/messages/3/content/0/tool_use_id")
.and_then(Value::as_str),
Some("a_b"),
"second tool result, got {body}"
);
}
#[test]
fn messages_disambiguates_tool_ids_that_sanitize_to_the_same_string() {
let ir = IrRequest::new(
"claude-opus-4-6",
vec![
IrItem::FunctionCall {
call_id: "run_1".into(),
name: "lookup".into(),
arguments: "{}".into(),
thought_signature: None,
},
IrItem::FunctionOutput {
call_id: "run_1".into(),
output: "ok".into(),
parts: Vec::new(),
is_error: false,
},
IrItem::FunctionCall {
call_id: "run#1".into(),
name: "lookup".into(),
arguments: "{}".into(),
thought_signature: None,
},
IrItem::FunctionOutput {
call_id: "run#1".into(),
output: "ok".into(),
parts: Vec::new(),
is_error: false,
},
],
);
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let text = std::str::from_utf8(&bytes).expect("utf8");
assert!(
text.contains("\"run_1\""),
"body must contain run_1, got {text}"
);
assert!(
text.contains("\"run_1_2\""),
"body must contain run_1_2, got {text}"
);
let rewritten_call = body
.pointer("/messages/2/content/0/id")
.and_then(Value::as_str);
let rewritten_result = body
.pointer("/messages/3/content/0/tool_use_id")
.and_then(Value::as_str);
assert_eq!(
body.pointer("/messages/0/content/0/id")
.and_then(Value::as_str),
Some("run_1"),
"legal call id stays run_1, got {body}"
);
assert_eq!(
body.pointer("/messages/1/content/0/tool_use_id")
.and_then(Value::as_str),
Some("run_1"),
"legal tool result stays run_1, got {body}"
);
assert_eq!(
rewritten_call,
Some("run_1_2"),
"run#1 must take the free suffix, got {body}"
);
assert_eq!(
rewritten_result, rewritten_call,
"tool result for run#1 must reuse the id assigned to its call, got {body}"
);
assert!(
!loss_degraded(&report, "items[0]"),
"run_1 must not record Degrade, got {report:?}"
);
assert!(
!loss_degraded(&report, "items[1]"),
"run_1 tool result must not record Degrade, got {report:?}"
);
assert!(
loss_degraded(&report, "items[2]"),
"rewritten run#1 call must record Degrade, got {report:?}"
);
assert!(
loss_degraded(&report, "items[3]"),
"rewritten run#1 tool result must record Degrade, got {report:?}"
);
}
#[test]
fn chat_json_schema_round_trips() {
let req = br#"{
"model": "gpt-4",
"messages": [{"role": "user", "content": "hi"}],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "answer",
"schema": {"type": "object", "properties": {"ok": {"type": "boolean"}}}
}
}
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(ir.sampling.json_schema_name.as_deref(), Some("answer"));
assert_eq!(
ir.sampling.json_schema,
Some(serde_json::json!({"type": "object", "properties": {"ok": {"type": "boolean"}}}))
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/response_format/type")
.and_then(Value::as_str),
Some("json_schema"),
"Chat must emit response_format.type, got {body}"
);
assert_eq!(
body.pointer("/response_format/json_schema/name")
.and_then(Value::as_str),
Some("answer"),
"Chat must emit json_schema.name, got {body}"
);
assert_eq!(
body.pointer("/response_format/json_schema/schema"),
Some(&serde_json::json!({"type": "object", "properties": {"ok": {"type": "boolean"}}})),
"Chat must emit json_schema.schema, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.json_schema"),
"Chat has a slot and must not Drop json_schema, got {report:?}"
);
}
#[test]
fn strict_json_schema_response_format_closes_the_object() {
let req = br#"{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "answer",
"strict": true,
"schema": {"type": "object", "properties": {"ok": {"type": "boolean"}}}
}
}
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(ir.sampling.json_schema_strict, Some(true));
let (bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let schema = body
.pointer("/response_format/json_schema/schema")
.expect("schema");
assert_eq!(
schema.get("additionalProperties"),
Some(&serde_json::json!(false)),
"{schema}"
);
assert_eq!(
schema.get("required"),
Some(&serde_json::json!(["ok"])),
"{schema}"
);
let (resp_bytes, _) = encode(Wire::Responses, &ir, &hard_error_profile()).expect("responses");
let resp: Value = serde_json::from_slice(&resp_bytes).expect("json");
assert_eq!(
resp.pointer("/text/format/schema/additionalProperties"),
Some(&serde_json::json!(false)),
"{resp}"
);
assert_eq!(
resp.pointer("/text/format/schema/required"),
Some(&serde_json::json!(["ok"])),
"{resp}"
);
}
#[test]
fn chat_json_schema_keeps_description() {
let req = br#"{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "Hi"}],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "note",
"description": "A short note",
"strict": true,
"schema": {"type": "object", "properties": {"q": {"type": "string"}}}
}
}
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(
ir.sampling.json_schema_description.as_deref(),
Some("A short note"),
"Chat must store json_schema.description"
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/response_format/json_schema/description")
.and_then(Value::as_str),
Some("A short note"),
"Chat must emit json_schema.description, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.json_schema_description"),
"Chat has a description slot, got {report:?}"
);
}
#[test]
fn responses_json_schema_keeps_description() {
let req = br#"{
"model": "gpt-5",
"input": "Hi",
"text": {
"format": {
"type": "json_schema",
"name": "note",
"description": "A short note",
"schema": {"type": "object", "properties": {"q": {"type": "string"}}}
}
}
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
assert_eq!(
ir.sampling.json_schema_description.as_deref(),
Some("A short note")
);
let (bytes, _) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/text/format/description")
.and_then(Value::as_str),
Some("A short note"),
"Responses must emit text.format.description, got {body}"
);
}
#[test]
fn responses_json_schema_round_trips() {
let req = br#"{
"model": "gpt-4",
"input": "hi",
"text": {
"format": {
"type": "json_schema",
"name": "answer",
"schema": {"type": "object", "properties": {"ok": {"type": "boolean"}}}
}
}
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
assert_eq!(ir.sampling.json_schema_name.as_deref(), Some("answer"));
assert_eq!(
ir.sampling.json_schema,
Some(serde_json::json!({"type": "object", "properties": {"ok": {"type": "boolean"}}}))
);
let (bytes, report) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/text/format/type").and_then(Value::as_str),
Some("json_schema"),
"Responses must emit text.format.type, got {body}"
);
assert_eq!(
body.pointer("/text/format/name").and_then(Value::as_str),
Some("answer"),
"Responses must emit text.format.name, got {body}"
);
assert_eq!(
body.pointer("/text/format/schema"),
Some(&serde_json::json!({"type": "object", "properties": {"ok": {"type": "boolean"}}})),
"Responses must emit text.format.schema, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.json_schema"),
"Responses has a slot and must not Drop json_schema, got {report:?}"
);
}
#[test]
fn responses_json_schema_keeps_strict() {
for strict in [true, false] {
let req = format!(
r#"{{
"model": "gpt-5",
"input": "hi",
"text": {{
"format": {{
"type": "json_schema",
"name": "note",
"strict": {strict},
"schema": {{"type": "object", "properties": {{"q": {{"type": "string"}}}}}}
}}
}}
}}"#
);
let (ir, _) = decode(Wire::Responses, req.as_bytes()).expect("decode");
assert_eq!(
ir.sampling.json_schema_strict,
Some(strict),
"Responses must store text.format.strict"
);
let (bytes, report) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/text/format/strict").and_then(Value::as_bool),
Some(strict),
"Responses must emit text.format.strict, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.json_schema_strict"),
"Responses has a strict slot, got {report:?}"
);
}
}
#[test]
fn dest_responses_json_object_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"input": "hi",
"text": { "format": { "type": "json_object" } }
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
assert_eq!(
ir.sampling.json_object,
Some(true),
"dest Responses json_object must land on IR"
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/response_format/type")
.and_then(Value::as_str),
Some("json_object"),
"dest Responses json_object must reach Chat response_format, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.json_object"),
"Chat has json_object and must not Drop, got {report:?}"
);
}
#[test]
fn dest_responses_user_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"user": "dest-user-42",
"input": "hi"
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
assert_eq!(ir.sampling.user.as_deref(), Some("dest-user-42"));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("user").and_then(Value::as_str),
Some("dest-user-42"),
"dest Responses user must reach Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.user"),
"Chat has user and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_user_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"user": "dest-user-42",
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(ir.sampling.user.as_deref(), Some("dest-user-42"));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("user").and_then(Value::as_str),
Some("dest-user-42"),
"dest Chat user must reach Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.user"),
"Chat has user and must not Drop, got {report:?}"
);
}
#[test]
fn dest_responses_verbosity_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"text": {"verbosity": "low"},
"input": "hi"
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
assert_eq!(ir.sampling.verbosity.as_deref(), Some("low"));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("verbosity").and_then(Value::as_str),
Some("low"),
"dest Responses verbosity must reach Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.verbosity"),
"Chat has verbosity and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_verbosity_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"verbosity": "high",
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(ir.sampling.verbosity.as_deref(), Some("high"));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("verbosity").and_then(Value::as_str),
Some("high"),
"dest Chat verbosity must reach Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.verbosity"),
"Chat has verbosity and must not Drop, got {report:?}"
);
}
#[test]
fn dest_responses_prompt_cache_retention_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"prompt_cache_retention": "24h",
"input": "hi"
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
assert_eq!(ir.sampling.prompt_cache_retention.as_deref(), Some("24h"));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("prompt_cache_retention").and_then(Value::as_str),
Some("24h"),
"dest Responses prompt_cache_retention must reach Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.prompt_cache_retention"),
"Chat has prompt_cache_retention and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_prompt_cache_retention_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"prompt_cache_retention": "24h",
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(ir.sampling.prompt_cache_retention.as_deref(), Some("24h"));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("prompt_cache_retention").and_then(Value::as_str),
Some("24h"),
"dest Chat prompt_cache_retention must reach Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.prompt_cache_retention"),
"Chat has prompt_cache_retention and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_prompt_cache_retention_reaches_responses() {
let req = br#"{
"model": "gpt-4o",
"prompt_cache_retention": "24h",
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(ir.sampling.prompt_cache_retention.as_deref(), Some("24h"));
let (bytes, report) = encode(Wire::Responses, &ir, &hard_error_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("prompt_cache_retention").and_then(Value::as_str),
Some("24h"),
"dest Chat prompt_cache_retention must reach Responses, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.prompt_cache_retention"),
"Responses has prompt_cache_retention and must not Drop, got {report:?}"
);
}
#[test]
fn dest_responses_prompt_cache_options_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"prompt_cache_options": {"mode": "explicit", "ttl": "30m"},
"input": "hi"
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
assert_eq!(ir.sampling.prompt_cache_mode.as_deref(), Some("explicit"));
assert_eq!(ir.sampling.prompt_cache_ttl.as_deref(), Some("30m"));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/prompt_cache_options/mode")
.and_then(Value::as_str),
Some("explicit"),
"dest Responses prompt_cache_options.mode must reach Chat, got {body}"
);
assert_eq!(
body.pointer("/prompt_cache_options/ttl")
.and_then(Value::as_str),
Some("30m"),
"dest Responses prompt_cache_options.ttl must reach Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.prompt_cache_mode"),
"Chat has prompt_cache_options and must not Drop mode, got {report:?}"
);
assert!(
!loss_dropped(&report, "sampling.prompt_cache_ttl"),
"Chat has prompt_cache_options and must not Drop ttl, got {report:?}"
);
}
#[test]
fn dest_chat_prompt_cache_options_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"prompt_cache_options": {"mode": "explicit", "ttl": "30m"},
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(ir.sampling.prompt_cache_mode.as_deref(), Some("explicit"));
assert_eq!(ir.sampling.prompt_cache_ttl.as_deref(), Some("30m"));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/prompt_cache_options/mode")
.and_then(Value::as_str),
Some("explicit"),
"dest Chat prompt_cache_options.mode must reach Chat, got {body}"
);
assert_eq!(
body.pointer("/prompt_cache_options/ttl")
.and_then(Value::as_str),
Some("30m"),
"dest Chat prompt_cache_options.ttl must reach Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.prompt_cache_mode"),
"Chat has prompt_cache_options and must not Drop mode, got {report:?}"
);
assert!(
!loss_dropped(&report, "sampling.prompt_cache_ttl"),
"Chat has prompt_cache_options and must not Drop ttl, got {report:?}"
);
}
#[test]
fn dest_chat_prompt_cache_options_reaches_responses() {
let req = br#"{
"model": "gpt-4o",
"prompt_cache_options": {"mode": "explicit", "ttl": "30m"},
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(ir.sampling.prompt_cache_mode.as_deref(), Some("explicit"));
assert_eq!(ir.sampling.prompt_cache_ttl.as_deref(), Some("30m"));
let (bytes, report) = encode(Wire::Responses, &ir, &hard_error_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/prompt_cache_options/mode")
.and_then(Value::as_str),
Some("explicit"),
"dest Chat prompt_cache_options.mode must reach Responses, got {body}"
);
assert_eq!(
body.pointer("/prompt_cache_options/ttl")
.and_then(Value::as_str),
Some("30m"),
"dest Chat prompt_cache_options.ttl must reach Responses, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.prompt_cache_mode"),
"Responses has prompt_cache_options and must not Drop mode, got {report:?}"
);
assert!(
!loss_dropped(&report, "sampling.prompt_cache_ttl"),
"Responses has prompt_cache_options and must not Drop ttl, got {report:?}"
);
}
#[test]
fn dest_responses_top_logprobs_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"top_logprobs": 5,
"input": "hi"
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
assert_eq!(ir.sampling.top_logprobs, Some(5));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("top_logprobs").and_then(Value::as_u64),
Some(5),
"dest Responses top_logprobs must reach Chat, got {body}"
);
assert_eq!(
body.get("logprobs").and_then(Value::as_bool),
Some(true),
"dest Chat encode must set logprobs true with top_logprobs, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.top_logprobs"),
"Chat has top_logprobs and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_top_logprobs_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"top_logprobs": 5,
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(ir.sampling.top_logprobs, Some(5));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("top_logprobs").and_then(Value::as_u64),
Some(5),
"dest Chat top_logprobs must reach Chat, got {body}"
);
assert_eq!(
body.get("logprobs").and_then(Value::as_bool),
Some(true),
"dest Chat encode must set logprobs true with top_logprobs, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.top_logprobs"),
"Chat has top_logprobs and must not Drop, got {report:?}"
);
}
#[test]
fn dest_responses_moderation_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"moderation": {
"model": "omni-moderation-latest",
"policy": {"input": {"mode": "block"}}
},
"input": "hi"
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
assert_eq!(
ir.sampling.moderation_model.as_deref(),
Some("omni-moderation-latest")
);
assert_eq!(ir.sampling.moderation_input.as_deref(), Some("block"));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/moderation/model").and_then(Value::as_str),
Some("omni-moderation-latest"),
"dest Responses moderation.model must reach Chat, got {body}"
);
assert_eq!(
body.pointer("/moderation/policy/input/mode")
.and_then(Value::as_str),
Some("block"),
"dest Responses moderation.policy.input.mode must reach Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.moderation_model"),
"Chat has moderation and must not Drop model, got {report:?}"
);
assert!(
!loss_dropped(&report, "sampling.moderation_input"),
"Chat has moderation and must not Drop input, got {report:?}"
);
}
#[test]
fn dest_chat_moderation_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"moderation": {
"model": "omni-moderation-latest",
"policy": {"input": {"mode": "block"}}
},
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(
ir.sampling.moderation_model.as_deref(),
Some("omni-moderation-latest")
);
assert_eq!(ir.sampling.moderation_input.as_deref(), Some("block"));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/moderation/model").and_then(Value::as_str),
Some("omni-moderation-latest"),
"dest Chat moderation.model must reach Chat, got {body}"
);
assert_eq!(
body.pointer("/moderation/policy/input/mode")
.and_then(Value::as_str),
Some("block"),
"dest Chat moderation.policy.input.mode must reach Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.moderation_model"),
"Chat has moderation and must not Drop model, got {report:?}"
);
assert!(
!loss_dropped(&report, "sampling.moderation_input"),
"Chat has moderation and must not Drop input, got {report:?}"
);
}
#[test]
fn dest_chat_include_obfuscation_keeps_include_usage() {
let req = br#"{
"model": "gpt-4o",
"stream": true,
"stream_options": {"include_obfuscation": false},
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(ir.sampling.include_obfuscation, Some(false));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/stream_options/include_usage")
.and_then(Value::as_bool),
Some(true),
"dest Chat stream_options.include_usage must stay true, got {body}"
);
assert_eq!(
body.pointer("/stream_options/include_obfuscation")
.and_then(Value::as_bool),
Some(false),
"dest Chat include_obfuscation must reach Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.include_obfuscation"),
"Chat has include_obfuscation and must not Drop, got {report:?}"
);
}
#[test]
fn dest_responses_include_obfuscation_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"stream": true,
"stream_options": {"include_obfuscation": false},
"input": "hi"
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
assert_eq!(ir.sampling.include_obfuscation, Some(false));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/stream_options/include_obfuscation")
.and_then(Value::as_bool),
Some(false),
"dest Responses include_obfuscation must reach Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.include_obfuscation"),
"Chat has include_obfuscation and must not Drop, got {report:?}"
);
}
#[test]
fn dest_responses_safety_identifier_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"safety_identifier": "dest-safety-9",
"input": "hi"
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
assert_eq!(
ir.sampling.safety_identifier.as_deref(),
Some("dest-safety-9")
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("safety_identifier").and_then(Value::as_str),
Some("dest-safety-9"),
"dest Responses safety_identifier must reach Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.safety_identifier"),
"Chat has safety_identifier and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_safety_identifier_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"safety_identifier": "dest-safety-9",
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(
ir.sampling.safety_identifier.as_deref(),
Some("dest-safety-9")
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("safety_identifier").and_then(Value::as_str),
Some("dest-safety-9"),
"dest Chat safety_identifier must reach Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.safety_identifier"),
"Chat has safety_identifier and must not Drop, got {report:?}"
);
}
#[test]
fn dest_responses_metadata_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"metadata": {"ticket": "42"},
"input": "hi"
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
assert_eq!(
ir.sampling.metadata.get("ticket").map(String::as_str),
Some("42")
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/metadata/ticket").and_then(Value::as_str),
Some("42"),
"dest Responses metadata must reach Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.metadata"),
"Chat has metadata and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_metadata_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"metadata": {"ticket": "42"},
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(
ir.sampling.metadata.get("ticket").map(String::as_str),
Some("42")
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/metadata/ticket").and_then(Value::as_str),
Some("42"),
"dest Chat metadata must reach Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.metadata"),
"Chat has metadata and must not Drop, got {report:?}"
);
}
#[test]
fn dest_converse_request_metadata_reaches_chat() {
let req = br#"{
"messages": [{"role": "user", "content": [{"text": "hi"}]}],
"inferenceConfig": {"maxTokens": 16},
"requestMetadata": {"ticket": "42"}
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
assert_eq!(
ir.sampling.metadata.get("ticket").map(String::as_str),
Some("42")
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/metadata/ticket").and_then(Value::as_str),
Some("42"),
"dest Converse requestMetadata must reach Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.metadata"),
"Chat has metadata and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_metadata_reaches_converse() {
let req = br#"{
"model": "gpt-4o",
"metadata": {"ticket": "42"},
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(
ir.sampling.metadata.get("ticket").map(String::as_str),
Some("42")
);
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/requestMetadata/ticket")
.and_then(Value::as_str),
Some("42"),
"dest Chat metadata must reach Converse requestMetadata, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.metadata"),
"Converse has requestMetadata and must not Drop, got {report:?}"
);
}
#[test]
fn dest_responses_verbosity_keeps_json_schema() {
let req = br#"{
"model": "gpt-4o",
"input": "hi",
"text": {
"verbosity": "low",
"format": {
"type": "json_schema",
"name": "answer",
"schema": {"type": "object", "properties": {"ok": {"type": "boolean"}}}
}
}
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
assert_eq!(ir.sampling.verbosity.as_deref(), Some("low"));
assert!(
ir.sampling.json_schema.is_some(),
"dest Responses json_schema must land on IR"
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/response_format/type")
.and_then(Value::as_str),
Some("json_schema"),
"dest Responses json_schema must reach Chat, got {body}"
);
assert_eq!(
body.pointer("/response_format/json_schema/name")
.and_then(Value::as_str),
Some("answer"),
"dest Responses json_schema name must reach Chat, got {body}"
);
assert_eq!(
body.pointer("/response_format/json_schema/schema/properties/ok/type")
.and_then(Value::as_str),
Some("boolean"),
"dest Responses json_schema property must reach Chat, got {body}"
);
assert_eq!(
body.get("verbosity").and_then(Value::as_str),
Some("low"),
"dest Responses verbosity must keep json_schema on Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.verbosity"),
"Chat has verbosity and must not Drop, got {report:?}"
);
assert!(
!loss_dropped(&report, "sampling.json_schema"),
"Chat has json_schema and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_verbosity_and_schema_reach_responses() {
let req = br#"{
"model": "gpt-4o",
"verbosity": "low",
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "answer",
"schema": {"type": "object", "properties": {"ok": {"type": "boolean"}}}
}
},
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(ir.sampling.verbosity.as_deref(), Some("low"));
assert!(
ir.sampling.json_schema.is_some(),
"dest Chat json_schema must land on IR"
);
let (bytes, report) = encode(Wire::Responses, &ir, &hard_error_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/text/verbosity").and_then(Value::as_str),
Some("low"),
"dest Chat verbosity must reach Responses text.verbosity, got {body}"
);
assert_eq!(
body.pointer("/text/format/type").and_then(Value::as_str),
Some("json_schema"),
"dest Responses encode must keep text.format next to verbosity, got {body}"
);
assert_eq!(
body.pointer("/text/format/name").and_then(Value::as_str),
Some("answer"),
"dest Chat json_schema name must reach Responses, got {body}"
);
assert_eq!(
body.pointer("/text/format/schema/properties/ok/type")
.and_then(Value::as_str),
Some("boolean"),
"dest Chat json_schema property must reach Responses, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.verbosity"),
"Responses has text.verbosity and must not Drop, got {report:?}"
);
assert!(
!loss_dropped(&report, "sampling.json_schema"),
"Responses has text.format json_schema and must not Drop, got {report:?}"
);
}
#[test]
fn dest_messages_user_id_reaches_chat() {
let req = br#"{
"model": "claude-sonnet-4",
"metadata": { "user_id": "dest-user-42" },
"max_tokens": 16,
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, decode_report) = decode(Wire::Messages, req).expect("decode");
assert_eq!(
ir.sampling.user.as_deref(),
Some("dest-user-42"),
"dest Messages metadata.user_id must land on IR"
);
assert!(
!loss_dropped(&decode_report, "sampling.user"),
"dest Messages metadata.user_id decode must not Drop, got {decode_report:?}"
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("user").and_then(Value::as_str),
Some("dest-user-42"),
"dest Messages metadata.user_id must reach Chat user, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.user"),
"Chat has user and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_user_reaches_messages_user_id() {
let req = br#"{
"model": "gpt-4o",
"user": "dest-user-42",
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/metadata/user_id").and_then(Value::as_str),
Some("dest-user-42"),
"dest Chat user must reach Messages metadata.user_id, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.user"),
"Messages has metadata.user_id and must not Drop, got {report:?}"
);
}
#[test]
fn dest_messages_output_config_effort_reaches_chat() {
let req = br#"{
"model": "claude-sonnet-4",
"max_tokens": 16,
"output_config": { "effort": "high" },
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, decode_report) = decode(Wire::Messages, req).expect("decode");
assert_eq!(
ir.sampling.reasoning_effort.as_deref(),
Some("high"),
"dest Messages output_config.effort must land on IR"
);
assert!(
!loss_dropped(&decode_report, "sampling.reasoning_effort"),
"dest Messages output_config.effort decode must not Drop, got {decode_report:?}"
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("reasoning_effort").and_then(Value::as_str),
Some("high"),
"dest Messages output_config.effort must reach Chat reasoning_effort, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.reasoning_effort"),
"Chat has reasoning_effort and must not Drop, got {report:?}"
);
}
#[test]
fn dest_messages_output_config_format_reaches_chat() {
let req = br#"{
"model": "claude-sonnet-4",
"max_tokens": 16,
"output_config": {
"format": {
"type": "json_schema",
"schema": {"type": "object", "properties": {"city": {"type": "string"}}}
}
},
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, decode_report) = decode(Wire::Messages, req).expect("decode");
assert_eq!(
ir.sampling.json_schema,
Some(serde_json::json!({"type": "object", "properties": {"city": {"type": "string"}}}))
);
assert_eq!(
ir.sampling.json_schema_name.as_deref(),
Some("response"),
"dest Messages format has no name; default must be response"
);
assert!(
!loss_dropped(&decode_report, "sampling.json_schema"),
"dest Messages output_config.format decode must not Drop, got {decode_report:?}"
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/response_format/type")
.and_then(Value::as_str),
Some("json_schema"),
"dest Messages output_config.format must reach Chat json_schema, got {body}"
);
assert_eq!(
body.pointer("/response_format/json_schema/schema/properties/city/type")
.and_then(Value::as_str),
Some("string"),
"dest Messages schema properties must reach Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.json_schema"),
"dest Messages schema must not Drop on Chat encode, got {report:?}"
);
}
#[test]
fn dest_chat_reasoning_effort_reaches_messages_output_config() {
let req = br#"{
"model": "gpt-4o",
"reasoning_effort": "high",
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/output_config/effort")
.and_then(Value::as_str),
Some("high"),
"dest Chat reasoning_effort must reach Messages output_config.effort, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.reasoning_effort"),
"Messages has output_config.effort and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_json_schema_reaches_messages_output_config() {
let req = br#"{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "answer",
"schema": {"type": "object", "properties": {"city": {"type": "string"}}}
}
}
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/output_config/format/type")
.and_then(Value::as_str),
Some("json_schema"),
"dest Chat json_schema must reach Messages output_config.format, got {body}"
);
assert_eq!(
body.pointer("/output_config/format/schema/properties/city/type")
.and_then(Value::as_str),
Some("string"),
"dest Chat schema properties must reach Messages, got {body}"
);
assert!(
body.pointer("/output_config/format/name").is_none()
&& body.pointer("/output_config/format/schema/name").is_none(),
"Messages output_config.format has no name slot, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.json_schema"),
"Messages has output_config.format and must not Drop json_schema, got {report:?}"
);
assert!(
loss_dropped(&report, "sampling.json_schema_name"),
"Messages output_config.format has no name and must Drop json_schema_name, got {report:?}"
);
}
#[test]
fn dest_messages_service_tier_reaches_chat() {
let req = br#"{
"model": "claude-sonnet-4",
"max_tokens": 16,
"service_tier": "auto",
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, decode_report) = decode(Wire::Messages, req).expect("decode dest Messages");
assert_eq!(
ir.sampling.service_tier.as_deref(),
Some("auto"),
"dest Messages service_tier must land on IR"
);
assert!(
!loss_dropped(&decode_report, "sampling.service_tier"),
"dest Messages service_tier decode must not Drop, got {decode_report:?}"
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("service_tier").and_then(Value::as_str),
Some("auto"),
"dest Messages service_tier must reach Chat service_tier, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.service_tier"),
"Chat has service_tier and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_auto_service_tier_reaches_messages() {
let req = br#"{
"model": "gpt-4o",
"service_tier": "auto",
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("service_tier").and_then(Value::as_str),
Some("auto"),
"dest Chat service_tier auto must reach Messages service_tier, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.service_tier"),
"Messages has service_tier and must not Drop auto, got {report:?}"
);
assert!(
!loss_degraded(&report, "sampling.service_tier"),
"auto is official dest Messages and must not Degrade, got {report:?}"
);
}
#[test]
fn dest_chat_default_service_tier_degrades_to_messages_standard_only() {
let req = br#"{
"model": "gpt-4o",
"service_tier": "default",
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("service_tier").and_then(Value::as_str),
Some("standard_only"),
"dest Chat default must degrade to Messages standard_only, got {body}"
);
assert!(
loss_degraded(&report, "sampling.service_tier"),
"default must Degrade to standard_only, got {report:?}"
);
assert!(
!loss_dropped(&report, "sampling.service_tier"),
"default must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_flex_service_tier_drops_on_messages() {
let req = br#"{
"model": "gpt-4o",
"service_tier": "flex",
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("service_tier").is_none(),
"dest Chat flex must not invent Messages service_tier, got {body}"
);
assert!(
loss_dropped(&report, "sampling.service_tier"),
"dest Chat flex must Drop on Messages, got {report:?}"
);
assert!(
!report.events.iter().any(|event| {
event.path == "sampling.service_tier"
&& event.action == LossAction::Drop
&& event.detail == "no slot"
}),
"Messages has a slot; Drop detail must not be no slot, got {report:?}"
);
}
#[test]
fn dest_messages_disable_parallel_tool_use_reaches_chat() {
let req = br#"{
"model": "claude-sonnet-4",
"max_tokens": 16,
"tool_choice": {"type": "auto", "disable_parallel_tool_use": true},
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, decode_report) = decode(Wire::Messages, req).expect("decode dest Messages");
assert_eq!(
ir.sampling.parallel_tool_calls,
Some(false),
"dest Messages disable_parallel_tool_use must invert onto IR parallel_tool_calls"
);
assert!(
!loss_dropped(&decode_report, "sampling.parallel_tool_calls"),
"dest Messages disable_parallel_tool_use decode must not Drop, got {decode_report:?}"
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("parallel_tool_calls").and_then(Value::as_bool),
Some(false),
"dest Messages disable_parallel_tool_use must reach Chat parallel_tool_calls false, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.parallel_tool_calls"),
"Chat has parallel_tool_calls and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_parallel_tool_calls_false_reaches_messages() {
let req = br#"{
"model": "gpt-4o",
"parallel_tool_calls": false,
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/tool_choice/disable_parallel_tool_use")
.and_then(Value::as_bool),
Some(true),
"dest Chat parallel_tool_calls false must reach Messages disable_parallel_tool_use, got {body}"
);
assert_eq!(
body.pointer("/tool_choice/type").and_then(Value::as_str),
Some("auto"),
"absent Messages tool_choice must become type auto to carry disable_parallel_tool_use, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.parallel_tool_calls"),
"Messages has disable_parallel_tool_use and must not Drop, got {report:?}"
);
}
fn messages_tool_choice(model: &str, choice: IrToolChoice) -> (Value, LossReport) {
let ir = IrRequest::new(
model,
vec![IrItem::User {
parts: vec![IrPart::Text("hi".into())],
}],
)
.with_sampling(IrSampling::patch(|s| {
s.max_tokens = Some(16);
s.tool_choice = choice;
}));
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
(body, report)
}
fn model_forces_tool_auto(report: &LossReport) -> bool {
report.events.iter().any(|event| {
event.path == "sampling.tool_choice"
&& event.action == LossAction::Degrade
&& event.detail == "model forces tool_choice auto"
})
}
#[test]
fn messages_forced_tool_choice_follows_claude_family() {
let cases = [
("claude-opus-5-5", true),
("CLAUDE-OPUS-5-5", true),
("claude-opus-5-5-20260101", true),
("claude-opus-5-5@20260101", true),
("anthropic.claude-opus-5-5", true),
("us.anthropic.claude-sonnet-5-5-v1:0", true),
(
"publishers/anthropic/models/claude-fable-5-1@20260101",
true,
),
("claude-mythos-5-1-20260101", true),
("claude-haiku-4-5", false),
("claude-haiku-5-5", false),
("claude-opus-5", false),
("claude-opus-5-50", false),
("claude-opus-4-6", false),
("claude-sonnet-4-5", false),
("claude-sonnet-5", false),
("claude-fable-5", false),
("claude-mythos-5", false),
("gpt-4o", false),
];
for (model, relax) in cases {
let (body, report) = messages_tool_choice(model, IrToolChoice::Named("get_weather".into()));
let encoded = serde_json::to_string(&body).expect("json");
assert!(
!encoded.contains("model forces tool_choice auto"),
"loss stays off the HTTP body for {model}, got {encoded}"
);
if relax {
assert_eq!(
body.pointer("/tool_choice/type").and_then(Value::as_str),
Some("auto"),
"{model} must send auto, got {body}"
);
assert!(
body.pointer("/tool_choice/name").is_none(),
"{model} must drop the forced name, got {body}"
);
assert!(
model_forces_tool_auto(&report),
"{model} must degrade tool_choice, got {report:?}"
);
} else {
assert_eq!(
body.pointer("/tool_choice/type").and_then(Value::as_str),
Some("tool"),
"{model} must keep type tool, got {body}"
);
assert_eq!(
body.pointer("/tool_choice/name").and_then(Value::as_str),
Some("get_weather"),
"{model} must keep the tool name, got {body}"
);
assert!(
!model_forces_tool_auto(&report),
"{model} must keep the forced tool, got {report:?}"
);
}
}
}
#[test]
fn messages_required_tool_on_opus_55_becomes_auto() {
let (body, report) = messages_tool_choice("claude-opus-5-5", IrToolChoice::Required);
assert_eq!(
body.get("tool_choice"),
Some(&serde_json::json!({"type": "auto"})),
"required must become auto with no name, got {body}"
);
assert!(model_forces_tool_auto(&report), "{report:?}");
}
#[test]
fn messages_auto_and_none_stay_on_opus_55() {
let (auto_body, auto_report) = messages_tool_choice("claude-opus-5-5", IrToolChoice::Auto);
assert!(
auto_body.get("tool_choice").is_none(),
"auto stays omitted, got {auto_body}"
);
assert!(!model_forces_tool_auto(&auto_report), "{auto_report:?}");
let (none_body, none_report) = messages_tool_choice("claude-opus-5-5", IrToolChoice::None);
assert_eq!(
none_body
.pointer("/tool_choice/type")
.and_then(Value::as_str),
Some("none"),
"none stays none, got {none_body}"
);
assert!(!model_forces_tool_auto(&none_report), "{none_report:?}");
}
#[test]
fn messages_opus_55_keeps_disable_parallel_on_auto() {
let ir = IrRequest::new(
"claude-opus-5-5",
vec![IrItem::User {
parts: vec![IrPart::Text("hi".into())],
}],
)
.with_sampling(IrSampling::patch(|s| {
s.max_tokens = Some(16);
s.tool_choice = IrToolChoice::Named("get_weather".into());
s.parallel_tool_calls = Some(false);
}));
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("tool_choice"),
Some(&serde_json::json!({
"disable_parallel_tool_use": true,
"type": "auto"
})),
"parallel flag stays and the name is removed, got {body}"
);
assert!(model_forces_tool_auto(&report), "{report:?}");
}
#[test]
fn messages_thinking_on_opus_55_records_one_tool_choice_degrade() {
let ir = IrRequest::new(
"claude-opus-5-5",
vec![IrItem::User {
parts: vec![IrPart::Text("hi".into())],
}],
)
.with_sampling(IrSampling::patch(|s| {
s.max_tokens = Some(20_000);
s.include_thoughts = Some(true);
s.max_reasoning_tokens = Some(1024);
s.tool_choice = IrToolChoice::Named("get_weather".into());
}));
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/tool_choice/type").and_then(Value::as_str),
Some("auto"),
"{body}"
);
assert!(body.pointer("/tool_choice/name").is_none(), "{body}");
let details: Vec<_> = report
.events
.iter()
.filter(|event| event.path == "sampling.tool_choice")
.map(|event| event.detail.as_str())
.collect();
assert_eq!(
details,
vec!["thinking forces tool_choice auto"],
"thinking already rewrote the choice, got {report:?}"
);
}
#[test]
fn messages_adaptive_thinking_on_opus_55_still_relaxes_forced_tool() {
let ir = IrRequest::new(
"claude-opus-5-5",
vec![IrItem::User {
parts: vec![IrPart::Text("hi".into())],
}],
)
.with_sampling(IrSampling::patch(|s| {
s.max_tokens = Some(16);
s.messages_thinking = Some(serde_json::json!({"type": "adaptive"}));
s.tool_choice = IrToolChoice::Named("get_weather".into());
}));
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/thinking/type").and_then(Value::as_str),
Some("adaptive"),
"{body}"
);
assert_eq!(
body.pointer("/tool_choice/type").and_then(Value::as_str),
Some("auto"),
"{body}"
);
assert!(model_forces_tool_auto(&report), "{report:?}");
assert!(
!report
.events
.iter()
.any(|event| event.detail == "thinking forces tool_choice auto"),
"adaptive thinking must not take the enabled-thinking rewrite, got {report:?}"
);
}
#[test]
fn every_source_wire_relaxes_a_forced_tool_for_a_listed_family() {
let cases: &[(&str, Wire, &str)] = &[
(
r#"{"model":"claude-opus-5-5","messages":[{"role":"user","content":"hi"}],"tool_choice":{"type":"function","function":{"name":"get_weather"}}}"#,
Wire::ChatCompletions,
"tool",
),
(
r#"{"model":"claude-sonnet-5-5","input":"hi","tool_choice":"required"}"#,
Wire::Responses,
"any",
),
(
r#"{"model":"claude-fable-5-1","contents":[{"role":"user","parts":[{"text":"hi"}]}],"toolConfig":{"functionCallingConfig":{"mode":"ANY"}}}"#,
Wire::Gemini,
"any",
),
(
r#"{"modelId":"claude-mythos-5-1","messages":[{"role":"user","content":[{"text":"hi"}]}],"toolConfig":{"toolChoice":{"tool":{"name":"get_weather"}}}}"#,
Wire::Converse,
"tool",
),
];
for (raw, wire, forced) in cases {
let (ir, _) = decode(*wire, raw.as_bytes()).expect(raw);
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect(raw);
let body: Value = serde_json::from_slice(&bytes).expect(raw);
assert_eq!(
body.pointer("/tool_choice/type").and_then(Value::as_str),
Some("auto"),
"{wire:?} forced {forced} must encode as auto, got {body}"
);
assert!(
body.pointer("/tool_choice/name").is_none(),
"{wire:?} must drop the name, got {body}"
);
assert!(model_forces_tool_auto(&report), "{wire:?} {report:?}");
}
}
#[test]
fn same_wire_messages_keeps_a_forced_tool_on_opus_55() {
let raw = br#"{"model":"claude-opus-5-5","max_tokens":16,"tool_choice":{"type":"tool","name":"get_weather","disable_parallel_tool_use":true},"messages":[{"role":"user","content":"hi"}]}"#;
let (ir, _) = decode(Wire::Messages, raw).expect("decode");
let (mut bytes, mut report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let encoded: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
encoded.pointer("/tool_choice/type").and_then(Value::as_str),
Some("auto"),
"encode alone relaxes the family, got {encoded}"
);
assert!(model_forces_tool_auto(&report), "{report:?}");
restore_same_wire_messages_tool_choice(
Wire::ChatCompletions,
Wire::Messages,
&ir,
&mut bytes,
&mut report,
);
let still: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
still.pointer("/tool_choice/type").and_then(Value::as_str),
Some("auto"),
"a Chat source must keep the relax, got {still}"
);
assert!(model_forces_tool_auto(&report), "{report:?}");
restore_same_wire_messages_tool_choice(
Wire::Messages,
Wire::Messages,
&ir,
&mut bytes,
&mut report,
);
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("tool_choice"),
Some(&serde_json::json!({
"disable_parallel_tool_use": true,
"name": "get_weather",
"type": "tool"
})),
"same-wire Messages must send the forced tool again, got {body}"
);
assert!(
!model_forces_tool_auto(&report),
"same-wire must drop the model degrade, got {report:?}"
);
}
#[test]
fn same_wire_messages_required_stays_any_on_opus_55() {
let raw = br#"{"model":"claude-opus-5-5","max_tokens":16,"tool_choice":{"type":"any"},"messages":[{"role":"user","content":"hi"}]}"#;
let (ir, _) = decode(Wire::Messages, raw).expect("decode");
let (mut bytes, mut report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
restore_same_wire_messages_tool_choice(
Wire::Messages,
Wire::Messages,
&ir,
&mut bytes,
&mut report,
);
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("tool_choice"),
Some(&serde_json::json!({"type": "any"})),
"{body}"
);
assert!(!model_forces_tool_auto(&report), "{report:?}");
}
#[test]
fn dest_converse_document_reaches_chat() {
let req = br#"{
"messages": [{
"role": "user",
"content": [
{"text": "summarize"},
{
"document": {
"format": "pdf",
"name": "note",
"source": {"bytes": "JVBERi0x"}
}
}
]
}]
}"#;
let (ir, decode_report) = decode(Wire::Converse, req).expect("decode");
assert!(
ir.items.iter().any(|item| matches!(
item,
IrItem::User { parts } if parts.iter().any(|p| matches!(
p,
IrPart::Document {
source: IrDocumentSource::Base64(data),
media_type,
name: Some(name),
..
} if data == "JVBERi0x"
&& media_type == "application/pdf"
&& name == "note"
))
)),
"dest Converse document must land on IR, got {:?}",
ir.items
);
assert!(
!loss_dropped(&decode_report, "part.document"),
"dest Converse document decode must not Drop, got {decode_report:?}"
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let content = body
.pointer("/messages/0/content")
.and_then(Value::as_array)
.expect("content");
let file = content
.iter()
.find(|part| part.get("type").and_then(Value::as_str) == Some("file"))
.expect("file part");
assert_eq!(
file.pointer("/file/filename").and_then(Value::as_str),
Some("note"),
"Chat filename must be note, got {body}"
);
let file_data = file
.pointer("/file/file_data")
.and_then(Value::as_str)
.unwrap_or("");
assert_chat_file_data_prefix(file_data, "application/pdf", "JVBERi0x");
assert!(
!loss_dropped(&report, "part.document"),
"Chat has file_data and must not Drop, got {report:?}"
);
}
#[test]
fn dest_converse_image_reaches_chat() {
let req = br#"{
"messages": [{
"role": "user",
"content": [
{"text": "see"},
{
"image": {
"format": "png",
"source": {"bytes": "iVBORw0KGgo"}
}
}
]
}]
}"#;
let (ir, decode_report) = decode(Wire::Converse, req).expect("decode");
assert!(
ir.items.iter().any(|item| matches!(
item,
IrItem::User { parts } if parts.iter().any(|p| matches!(
p,
IrPart::ImageBase64 { media_type, data }
if media_type == "image/png" && data == "iVBORw0KGgo"
))
)),
"dest Converse image must land on IR, got {:?}",
ir.items
);
assert!(
!loss_dropped(&decode_report, "part.image") && !loss_dropped(&decode_report, "content"),
"dest Converse image decode must not Drop, got {decode_report:?}"
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let content = body
.pointer("/messages/0/content")
.and_then(Value::as_array)
.expect("content");
let image = content
.iter()
.find(|part| part.get("type").and_then(Value::as_str) == Some("image_url"))
.expect("image_url part");
let url = image
.pointer("/image_url/url")
.and_then(Value::as_str)
.unwrap_or("");
assert_chat_file_data_prefix(url, "image/png", "iVBORw0KGgo");
assert!(
!loss_dropped(&report, "part.image") && !loss_dropped(&report, "content"),
"Chat has image_url and must not Drop, got {report:?}"
);
}
#[test]
fn dest_converse_audio_reaches_chat() {
let req = br#"{
"messages": [{
"role": "user",
"content": [
{"text": "hear"},
{
"audio": {
"format": "mp3",
"source": {"bytes": "SUQz"}
}
}
]
}]
}"#;
let (ir, decode_report) = decode(Wire::Converse, req).expect("decode");
assert!(
ir.items.iter().any(|item| matches!(
item,
IrItem::User { parts } if parts.iter().any(|p| matches!(
p,
IrPart::Audio { data, format } if data == "SUQz" && format == "mp3"
))
)),
"dest Converse audio must land on IR, got {:?}",
ir.items
);
assert!(
!loss_dropped(&decode_report, "part.audio"),
"dest Converse audio decode must not Drop, got {decode_report:?}"
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let content = body
.pointer("/messages/0/content")
.and_then(Value::as_array)
.expect("content");
let audio = content
.iter()
.find(|part| part.get("type").and_then(Value::as_str) == Some("input_audio"))
.expect("input_audio part");
assert_eq!(
audio.pointer("/input_audio/data").and_then(Value::as_str),
Some("SUQz"),
"Chat input_audio data must be SUQz, got {body}"
);
assert_eq!(
audio.pointer("/input_audio/format").and_then(Value::as_str),
Some("mp3"),
"Chat input_audio format must be mp3, got {body}"
);
assert!(
!loss_dropped(&report, "part.audio"),
"Chat has input_audio and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_image_reaches_converse() {
let req = br#"{
"model": "gpt-4o",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "see"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,iVBORw0KGgo"}
}
]
}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/1/image/format")
.and_then(Value::as_str),
Some("png"),
"dest Chat image_url must reach Converse image.format, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/1/image/source/bytes")
.and_then(Value::as_str),
Some("iVBORw0KGgo"),
"dest Chat image_url must reach Converse image.source.bytes, got {body}"
);
assert!(
!loss_dropped(&report, "part.image") && !loss_dropped(&report, "content"),
"Converse has image and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_audio_reaches_converse() {
let req = br#"{
"model": "gpt-4o-audio-preview",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "hear"},
{
"type": "input_audio",
"input_audio": {"data": "SUQz", "format": "mp3"}
}
]
}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/1/audio/format")
.and_then(Value::as_str),
Some("mp3"),
"dest Chat input_audio must reach Converse audio.format, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/1/audio/source/bytes")
.and_then(Value::as_str),
Some("SUQz"),
"dest Chat input_audio must reach Converse audio.source.bytes, got {body}"
);
assert!(
!loss_dropped(&report, "part.audio"),
"Converse has audio and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_json_object_round_trips() {
let req = br#"{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"response_format": { "type": "json_object" }
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(ir.sampling.json_object, Some(true));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/response_format/type")
.and_then(Value::as_str),
Some("json_object"),
"dest Chat json_object must emit Chat response_format, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.json_object"),
"Chat has json_object and must not Drop, got {report:?}"
);
}
#[test]
fn dest_gemini_json_mime_reaches_chat() {
let req = br#"{
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"generationConfig": { "responseMimeType": "application/json" }
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
assert_eq!(
ir.sampling.json_object,
Some(true),
"dest Gemini application/json without schema must land on IR"
);
assert!(ir.sampling.json_schema.is_none());
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/response_format/type")
.and_then(Value::as_str),
Some("json_object"),
"dest Gemini json mime must reach Chat json_object, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.json_object"),
"Chat has json_object and must not Drop, got {report:?}"
);
}
#[test]
fn gemini_media_resolution_round_trip() {
let req = br#"{
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"generationConfig": {"mediaResolution": "MEDIA_RESOLUTION_HIGH"}
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/generationConfig/mediaResolution")
.and_then(Value::as_str),
Some("MEDIA_RESOLUTION_HIGH"),
"Gemini mediaResolution must round-trip, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.media_resolution"),
"Gemini has mediaResolution and must not Drop, got {report:?}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("json");
assert!(
loss_dropped(&chat_report, "sampling.media_resolution"),
"Chat has no mediaResolution slot, got {chat_report:?}"
);
assert!(
!chat.to_string().contains("MEDIA_RESOLUTION_HIGH"),
"Chat must not emit the Gemini enum, got {chat}"
);
}
#[test]
fn gemini_part_media_resolution_round_trip() {
let req = br#"{
"contents": [{
"role": "user",
"parts": [{
"inlineData": {"mimeType": "image/jpeg", "data": "aaaa"},
"mediaResolution": {"level": "MEDIA_RESOLUTION_HIGH"}
}]
}]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/contents/0/parts/0/inlineData/data")
.and_then(Value::as_str),
Some("aaaa"),
"image bytes must stay, got {body}"
);
assert_eq!(
body.pointer("/contents/0/parts/0/mediaResolution/level")
.and_then(Value::as_str),
Some("MEDIA_RESOLUTION_HIGH"),
"part mediaResolution must round-trip, got {body}"
);
assert!(
!loss_dropped(&report, "part.media_hint"),
"Gemini keeps the part hint, got {report:?}"
);
for (wire, profile) in [
(Wire::ChatCompletions, chat_profile()),
(Wire::Messages, messages_profile()),
(Wire::Responses, hard_error_profile()),
(Wire::Converse, converse_profile()),
] {
let (encoded, dest_report) = encode(wire, &ir, &profile).expect("encode");
let dest: Value = serde_json::from_slice(&encoded).expect("json");
let rendered = dest.to_string();
assert!(
rendered.contains("aaaa"),
"{wire:?} dropped the image bytes, got {dest}"
);
assert!(
loss_dropped(&dest_report, "part.media_hint"),
"{wire:?} must record the media hint drop, got {dest_report:?}"
);
assert!(
!rendered.contains("mediaResolution"),
"{wire:?} leaked the Gemini hint, got {dest}"
);
}
let video = br#"{
"contents": [{
"role": "user",
"parts": [{
"inlineData": {"mimeType": "video/mp4", "data": "bbbb"},
"videoMetadata": {"fps": 2.0}
}]
}]
}"#;
let (ir, _) = decode(Wire::Gemini, video).expect("decode video");
let (bytes, _) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode video");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/contents/0/parts/0/videoMetadata/fps")
.and_then(Value::as_f64),
Some(2.0),
"videoMetadata must round-trip, got {body}"
);
assert_eq!(
body.pointer("/contents/0/parts/0/inlineData/data")
.and_then(Value::as_str),
Some("bbbb"),
"video bytes must stay, got {body}"
);
}
#[test]
fn gemini_image_config_round_trip() {
let req = br#"{
"contents": [{"role": "user", "parts": [{"text": "draw a kite"}]}],
"generationConfig": {
"imageConfig": {"aspectRatio": "16:9", "imageSize": "2K"}
}
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/generationConfig/imageConfig/aspectRatio")
.and_then(Value::as_str),
Some("16:9"),
"Gemini imageConfig.aspectRatio must round-trip, got {body}"
);
assert_eq!(
body.pointer("/generationConfig/imageConfig/imageSize")
.and_then(Value::as_str),
Some("2K"),
"Gemini imageConfig.imageSize must round-trip, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.image_config"),
"Gemini has imageConfig and must not Drop, got {report:?}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("json");
assert!(
loss_dropped(&chat_report, "sampling.image_config"),
"Chat has no imageConfig slot, got {chat_report:?}"
);
assert!(
!chat.to_string().contains("16:9"),
"Chat must not emit imageConfig, got {chat}"
);
}
#[test]
fn gemini_audio_transcript_and_speech_metadata_round_trip() {
let req = br#"{
"contents": [{
"role": "user",
"parts": [
{
"inlineData": {"mimeType": "audio/wav", "data": "cccc"},
"audioTranscription": {"text": "hello there", "speakerLabel": "spk_1"}
},
{
"text": "Good morning",
"speechMetadata": {"speaker": "Host", "style": "cheerful"}
}
]
}]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/contents/0/parts/0/audioTranscription/speakerLabel")
.and_then(Value::as_str),
Some("spk_1"),
"audio transcript must round-trip, got {body}"
);
assert_eq!(
body.pointer("/contents/0/parts/0/inlineData/data")
.and_then(Value::as_str),
Some("cccc"),
"audio bytes must stay, got {body}"
);
assert_eq!(
body.pointer("/contents/0/parts/1/speechMetadata/speaker")
.and_then(Value::as_str),
Some("Host"),
"speechMetadata must round-trip, got {body}"
);
assert_eq!(
body.pointer("/contents/0/parts/1/text")
.and_then(Value::as_str),
Some("Good morning"),
"spoken text must stay, got {body}"
);
assert!(
!loss_dropped(&report, "part.media_hint"),
"Gemini keeps these part fields, got {report:?}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("json");
let rendered = chat.to_string();
assert!(
rendered.contains("cccc") && rendered.contains("Good morning"),
"Chat must keep the audio bytes and the text, got {chat}"
);
assert!(
loss_dropped(&chat_report, "part.media_hint"),
"Chat must record the sidecar drop, got {chat_report:?}"
);
assert!(
!rendered.contains("spk_1") && !rendered.contains("speechMetadata"),
"Chat must not leak Gemini speech fields, got {chat}"
);
}
#[test]
fn gemini_generation_sidecars_round_trip() {
let req = br#"{
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"generationConfig": {
"audioTranscriptionConfig": {"languageCodes": ["en"], "diarization": true},
"translationConfig": {"targetLanguageCode": "fr"},
"enableAffectiveDialog": true,
"enableEnhancedCivicAnswers": true
}
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/generationConfig/audioTranscriptionConfig/languageCodes/0")
.and_then(Value::as_str),
Some("en"),
"audioTranscriptionConfig must round-trip, got {body}"
);
assert_eq!(
body.pointer("/generationConfig/audioTranscriptionConfig/diarization")
.and_then(Value::as_bool),
Some(true),
"diarization must round-trip, got {body}"
);
assert_eq!(
body.pointer("/generationConfig/translationConfig/targetLanguageCode")
.and_then(Value::as_str),
Some("fr"),
"translationConfig must round-trip, got {body}"
);
assert_eq!(
body.pointer("/generationConfig/enableAffectiveDialog")
.and_then(Value::as_bool),
Some(true),
"enableAffectiveDialog must round-trip, got {body}"
);
assert_eq!(
body.pointer("/generationConfig/enableEnhancedCivicAnswers")
.and_then(Value::as_bool),
Some(true),
"enableEnhancedCivicAnswers must round-trip, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.audio_transcription_config"),
"Gemini keeps audioTranscriptionConfig, got {report:?}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("json");
let rendered = chat.to_string();
assert!(
loss_dropped(&chat_report, "sampling.audio_transcription_config")
&& loss_dropped(&chat_report, "sampling.translation_config")
&& loss_dropped(&chat_report, "sampling.affective_dialog")
&& loss_dropped(&chat_report, "sampling.enhanced_civic_answers"),
"Chat must record the Gemini-only drops, got {chat_report:?}"
);
assert!(
!rendered.contains("diarization")
&& !rendered.contains("targetLanguageCode")
&& !rendered.contains("enableAffectiveDialog")
&& !rendered.contains("enableEnhancedCivicAnswers"),
"Chat must not emit these Gemini fields, got {chat}"
);
}
#[test]
fn gemini_multi_speaker_speech_round_trip() {
let req = br#"{
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"generationConfig": {
"speechConfig": {
"multiSpeakerVoiceConfig": {
"speakerVoiceConfigs": [
{
"speaker": "Host",
"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}}
},
{
"speaker": "Guest",
"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Puck"}}
}
]
}
}
}
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer(
"/generationConfig/speechConfig/multiSpeakerVoiceConfig/speakerVoiceConfigs/0/speaker"
)
.and_then(Value::as_str),
Some("Host"),
"multi-speaker speech must round-trip, got {body}"
);
assert_eq!(
body.pointer("/generationConfig/speechConfig/multiSpeakerVoiceConfig/speakerVoiceConfigs/1/voiceConfig/prebuiltVoiceConfig/voiceName")
.and_then(Value::as_str),
Some("Puck"),
"second speaker voice must round-trip, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.multi_speaker_speech"),
"Gemini keeps multi-speaker speech, got {report:?}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("json");
assert!(
loss_dropped(&chat_report, "sampling.multi_speaker_speech"),
"Chat has no multi-speaker speech slot, got {chat_report:?}"
);
assert!(
!chat.to_string().contains("multiSpeakerVoiceConfig"),
"Chat must not emit the Gemini speech object, got {chat}"
);
}
#[test]
fn gemini_visible_text_keeps_thought_signature() {
let req = br#"{
"contents": [{
"role": "model",
"parts": [{
"text": "The answer is 4",
"thoughtSignature": "sig_visible_text"
}]
}]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/contents/0/parts/0/text")
.and_then(Value::as_str),
Some("The answer is 4"),
"visible text must stay, got {body}"
);
assert_eq!(
body.pointer("/contents/0/parts/0/thoughtSignature")
.and_then(Value::as_str),
Some("sig_visible_text"),
"text thoughtSignature must round-trip, got {body}"
);
assert!(
!loss_dropped(&report, "part.media_hint"),
"Gemini keeps the signature, got {report:?}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("json");
let rendered = chat.to_string();
assert!(
rendered.contains("The answer is 4"),
"Chat must keep the visible text, got {chat}"
);
assert!(
loss_dropped(&chat_report, "part.media_hint"),
"Chat must record the signature drop, got {chat_report:?}"
);
assert!(
!rendered.contains("sig_visible_text"),
"Chat must not leak the Gemini signature, got {chat}"
);
}
#[test]
fn gemini_cached_content_round_trip() {
let req = br#"{
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"cachedContent": "cachedContents/abc"
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("cachedContent").and_then(Value::as_str),
Some("cachedContents/abc"),
"Gemini cachedContent must round-trip, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.cached_content"),
"Gemini keeps cachedContent, got {report:?}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("json");
assert!(
loss_dropped(&chat_report, "sampling.cached_content"),
"Chat has no cachedContent slot, got {chat_report:?}"
);
assert!(
!chat.to_string().contains("cachedContents/abc"),
"Chat must not emit the Gemini cache name, got {chat}"
);
}
#[test]
fn gemini_safety_settings_round_trip() {
let req = br#"{
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"safetySettings": [{
"category": "HARM_CATEGORY_HARASSMENT",
"threshold": "BLOCK_LOW_AND_ABOVE"
}]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/safetySettings/0/threshold")
.and_then(Value::as_str),
Some("BLOCK_LOW_AND_ABOVE"),
"Gemini safetySettings must round-trip, got {body}"
);
assert_eq!(
body.pointer("/safetySettings/0/category")
.and_then(Value::as_str),
Some("HARM_CATEGORY_HARASSMENT"),
"safety category must round-trip, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.safety_settings"),
"Gemini keeps safetySettings, got {report:?}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("json");
assert!(
loss_dropped(&chat_report, "sampling.safety_settings"),
"Chat has no safetySettings slot, got {chat_report:?}"
);
assert!(
!chat.to_string().contains("BLOCK_LOW_AND_ABOVE"),
"Chat must not emit the Gemini threshold, got {chat}"
);
}
#[test]
fn gemini_request_labels_round_trip() {
let req = br#"{
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"labels": {"team": "maps", "env": "prod"}
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/labels/team").and_then(Value::as_str),
Some("maps"),
"Gemini labels must round-trip, got {body}"
);
assert_eq!(
body.pointer("/labels/env").and_then(Value::as_str),
Some("prod"),
"Gemini label env must round-trip, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.request_labels"),
"Gemini keeps labels, got {report:?}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("json");
assert!(
loss_dropped(&chat_report, "sampling.request_labels"),
"Chat has no labels slot, got {chat_report:?}"
);
assert!(
!chat.to_string().contains("\"team\":\"maps\""),
"Chat must not emit the Gemini labels, got {chat}"
);
}
#[test]
fn dest_gemini_frequency_penalty_reaches_chat() {
let req = br#"{
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"generationConfig": {"frequencyPenalty": 0.5}
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode dest Gemini");
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("frequency_penalty").and_then(Value::as_f64),
Some(0.5),
"dest Gemini frequencyPenalty must reach Chat frequency_penalty, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.frequency_penalty"),
"Chat has frequency_penalty and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_frequency_penalty_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"frequency_penalty": 0.5
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode dest Chat");
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("frequency_penalty").and_then(Value::as_f64),
Some(0.5),
"dest Chat frequency_penalty must emit Chat frequency_penalty, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.frequency_penalty"),
"Chat has frequency_penalty and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_frequency_penalty_reaches_gemini() {
let req = br#"{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"frequency_penalty": 0.5
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode dest Chat");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode Gemini");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/generationConfig/frequencyPenalty")
.and_then(Value::as_f64),
Some(0.5),
"dest Chat frequency_penalty must reach Gemini frequencyPenalty, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.frequency_penalty"),
"Gemini has frequencyPenalty and must not Drop, got {report:?}"
);
}
#[test]
fn penalty_decimals_stay_short() {
let chat = br#"{
"model": "gpt-4o",
"frequency_penalty": 0.2,
"presence_penalty": 0.1,
"messages": [{"role": "user", "content": "Hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, chat).expect("decode chat");
let (bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("frequency_penalty"),
Some(&serde_json::json!(0.2)),
"Chat frequency_penalty must print 0.2, got {body}"
);
assert_eq!(
body.get("presence_penalty"),
Some(&serde_json::json!(0.1)),
"Chat presence_penalty must print 0.1, got {body}"
);
let gemini = br#"{
"contents": [{"role": "user", "parts": [{"text": "Hi"}]}],
"generationConfig": {"frequencyPenalty": 0.2, "presencePenalty": 0.1}
}"#;
let (ir, _) = decode(Wire::Gemini, gemini).expect("decode gemini");
let (bytes, _) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode gemini");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/generationConfig/frequencyPenalty"),
Some(&serde_json::json!(0.2)),
"Gemini frequencyPenalty must print 0.2, got {body}"
);
assert_eq!(
body.pointer("/generationConfig/presencePenalty"),
Some(&serde_json::json!(0.1)),
"Gemini presencePenalty must print 0.1, got {body}"
);
}
#[test]
fn dest_gemini_presence_penalty_reaches_chat() {
let req = br#"{
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"generationConfig": {"presencePenalty": 0.5}
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode dest Gemini");
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("presence_penalty").and_then(Value::as_f64),
Some(0.5),
"dest Gemini presencePenalty must reach Chat presence_penalty, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.presence_penalty"),
"Chat has presence_penalty and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_presence_penalty_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"presence_penalty": 0.5
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode dest Chat");
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("presence_penalty").and_then(Value::as_f64),
Some(0.5),
"dest Chat presence_penalty must emit Chat presence_penalty, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.presence_penalty"),
"Chat has presence_penalty and must not Drop, got {report:?}"
);
}
#[test]
fn dest_gemini_seed_reaches_chat() {
let req = br#"{
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"generationConfig": {"seed": 7}
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode dest Gemini");
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("seed").and_then(Value::as_i64),
Some(7),
"dest Gemini seed must reach Chat seed, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.seed"),
"Chat has seed and must not Drop, got {report:?}"
);
}
#[test]
fn dest_gemini_logprobs_reaches_chat_top_logprobs() {
let req = br#"{
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"generationConfig": {"logprobs": 5}
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode dest Gemini");
assert_eq!(ir.sampling.top_logprobs, Some(5));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("top_logprobs").and_then(Value::as_u64),
Some(5),
"dest Gemini logprobs must reach Chat top_logprobs, got {body}"
);
assert_eq!(
body.get("logprobs").and_then(Value::as_bool),
Some(true),
"dest Chat top_logprobs must emit logprobs true, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.top_logprobs"),
"Chat has top_logprobs and must not Drop, got {report:?}"
);
}
#[test]
fn dest_gemini_response_logprobs_reaches_chat_logprobs() {
let req = br#"{
"contents":[{"role":"user","parts":[{"text":"hi"}]}],
"generationConfig":{"responseLogprobs":true}
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode dest Gemini");
assert_eq!(ir.sampling.logprobs, Some(true));
assert_eq!(ir.sampling.top_logprobs, None);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("logprobs").and_then(Value::as_bool),
Some(true),
"dest Gemini responseLogprobs must reach Chat logprobs, got {body}"
);
assert!(
body.get("top_logprobs").is_none(),
"dest Gemini boolean-only responseLogprobs must not invent Chat top_logprobs, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.logprobs"),
"Chat has logprobs and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_top_logprobs_reaches_gemini_logprobs() {
let req = br#"{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"top_logprobs": 5,
"logprobs": true
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode dest Chat");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode dest Gemini");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/generationConfig/logprobs")
.and_then(Value::as_u64),
Some(5),
"dest Chat top_logprobs must reach Gemini logprobs, got {body}"
);
assert_eq!(
body.pointer("/generationConfig/responseLogprobs")
.and_then(Value::as_bool),
Some(true),
"dest Gemini logprobs must emit responseLogprobs, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.top_logprobs"),
"Gemini has generationConfig.logprobs and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_logprobs_reaches_gemini_response_logprobs() {
let req = br#"{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"logprobs": true
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode dest Chat");
assert_eq!(ir.sampling.logprobs, Some(true));
assert_eq!(ir.sampling.top_logprobs, None);
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode dest Gemini");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/generationConfig/responseLogprobs")
.and_then(Value::as_bool),
Some(true),
"dest Chat logprobs must reach Gemini responseLogprobs, got {body}"
);
assert!(
body.pointer("/generationConfig/logprobs").is_none(),
"dest Chat boolean-only logprobs must not invent Gemini integer logprobs, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.logprobs"),
"Gemini has responseLogprobs and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_seed_reaches_chat() {
let req = br#"{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"seed": 7
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode dest Chat");
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("seed").and_then(Value::as_i64),
Some(7),
"dest Chat seed must emit Chat seed, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.seed"),
"Chat has seed and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_logit_bias_reaches_chat() {
let req = br#"{
"model":"gpt-4o",
"messages":[{"role":"user","content":"hi"}],
"logit_bias":{"123":-100}
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode dest Chat");
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/logit_bias/123").and_then(Value::as_f64),
Some(-100.0),
"dest Chat logit_bias must emit Chat logit_bias, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.logit_bias"),
"Chat has logit_bias and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_prediction_reaches_chat() {
let req = br#"{
"model":"gpt-4o",
"messages":[{"role":"user","content":"hi"}],
"prediction":{"type":"content","content":"hello"}
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode dest Chat");
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("prediction"),
Some(&serde_json::json!({"type":"content","content":"hello"})),
"dest Chat prediction must emit Chat prediction, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.prediction"),
"Chat has prediction and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_web_search_options_reaches_chat() {
let req = br#"{
"model":"gpt-4o",
"messages":[{"role":"user","content":"hi"}],
"web_search_options":{"search_context_size":"low"}
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode dest Chat");
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/web_search_options/search_context_size")
.and_then(Value::as_str),
Some("low"),
"dest Chat web_search_options must emit Chat web_search_options, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.web_search_options"),
"Chat has web_search_options and must not Drop, got {report:?}"
);
}
#[test]
fn dest_gemini_logit_bias_drops() {
let req = br#"{
"model":"gpt-4o",
"messages":[{"role":"user","content":"hi"}],
"logit_bias":{"123":-100}
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode dest Chat");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode dest Gemini");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("logit_bias").is_none(),
"dest Gemini must not invent logit_bias, got {body}"
);
assert!(
body.pointer("/generationConfig/logit_bias").is_none(),
"dest Gemini must not invent generationConfig.logit_bias, got {body}"
);
assert!(
loss_dropped(&report, "sampling.logit_bias"),
"dest Gemini has no logit_bias slot and must Drop, got {report:?}"
);
}
#[test]
fn dest_gemini_candidate_count_reaches_chat_n() {
let req = br#"{
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"generationConfig": {"candidateCount": 2}
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode dest Gemini");
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("n").and_then(Value::as_u64),
Some(2),
"dest Gemini candidateCount must reach Chat n, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.n"),
"Chat has n and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_n_reaches_gemini_candidate_count() {
let req = br#"{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"n": 2
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode dest Chat");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode Gemini");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/generationConfig/candidateCount")
.and_then(Value::as_u64),
Some(2),
"dest Chat n must reach Gemini candidateCount, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.n"),
"Gemini has candidateCount and must not Drop, got {report:?}"
);
}
#[test]
fn dest_gemini_response_modalities_and_speech_reach_chat() {
let req = br#"{
"contents":[{"role":"user","parts":[{"text":"hi"}]}],
"generationConfig":{"responseModalities":["AUDIO"],"speechConfig":{"voiceConfig":{"prebuiltVoiceConfig":{"voiceName":"Puck"}}}}
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode dest Gemini");
assert_eq!(ir.sampling.output_modalities, ["audio"]);
assert_eq!(ir.sampling.audio_voice.as_deref(), Some("Puck"));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let modalities = body
.get("modalities")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
assert!(
modalities.iter().any(|v| v.as_str() == Some("audio")),
"dest Gemini responseModalities AUDIO must reach Chat modalities, got {body}"
);
assert_eq!(
body.pointer("/audio/voice").and_then(Value::as_str),
Some("Puck"),
"dest Gemini speechConfig voiceName must reach Chat audio.voice, got {body}"
);
assert_eq!(
body.pointer("/audio/format").and_then(Value::as_str),
Some("wav"),
"dest Chat audio.format must be present, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.output_modalities"),
"Chat has modalities and must not Drop, got {report:?}"
);
assert!(
!loss_dropped(&report, "sampling.audio_voice"),
"Chat has audio.voice and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_modalities_and_audio_reach_gemini() {
let req = br#"{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"modalities": ["audio"],
"audio": {"voice": "Puck", "format": "wav"}
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode dest Chat");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode dest Gemini");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let modalities = body
.pointer("/generationConfig/responseModalities")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
assert!(
modalities.iter().any(|v| v.as_str() == Some("AUDIO")),
"dest Chat modalities audio must reach Gemini responseModalities, got {body}"
);
assert_eq!(
body.pointer("/generationConfig/speechConfig/voiceConfig/prebuiltVoiceConfig/voiceName")
.and_then(Value::as_str),
Some("Puck"),
"dest Chat audio.voice must reach Gemini speechConfig voiceName, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.output_modalities"),
"Gemini has responseModalities and must not Drop, got {report:?}"
);
assert!(
!loss_dropped(&report, "sampling.audio_voice"),
"Gemini has speechConfig voiceName and must not Drop, got {report:?}"
);
}
#[test]
fn dest_gemini_image_modality_drops() {
let req = br#"{
"contents":[{"role":"user","parts":[{"text":"hi"}]}],
"generationConfig":{"responseModalities":["IMAGE"]}
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode dest Gemini");
assert_eq!(ir.sampling.output_modalities, ["image"]);
let (gemini_bytes, gemini_report) =
encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode Gemini");
let gemini: Value = serde_json::from_slice(&gemini_bytes).expect("json");
assert_eq!(
gemini
.pointer("/generationConfig/responseModalities/0")
.and_then(Value::as_str),
Some("IMAGE"),
"Gemini IMAGE must round-trip, got {gemini}"
);
assert!(
!loss_dropped(&gemini_report, "sampling.output_modalities"),
"Gemini has an IMAGE modality slot, got {gemini_report:?}"
);
let (bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let modalities = body
.get("modalities")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
assert!(
modalities
.iter()
.all(|v| { v.as_str().is_none_or(|s| !s.eq_ignore_ascii_case("image")) }),
"dest Gemini IMAGE must not reach Chat modalities, got {body}"
);
assert!(
loss_dropped(&chat_report, "sampling.output_modalities"),
"Chat has no image modality and must Drop, got {chat_report:?}"
);
}
#[test]
fn dest_gemini_image_plus_audio_still_reaches_chat() {
let req = br#"{
"contents":[{"role":"user","parts":[{"text":"hi"}]}],
"generationConfig":{"responseModalities":["IMAGE","AUDIO"]}
}"#;
let (ir, decode_report) = decode(Wire::Gemini, req).expect("decode dest Gemini");
assert_eq!(ir.sampling.output_modalities, ["image", "audio"]);
assert!(
!loss_dropped(&decode_report, "sampling.output_modalities"),
"decode must keep IMAGE for a Gemini dest, got {decode_report:?}"
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let modalities = body
.get("modalities")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
assert!(
modalities.iter().any(|v| v.as_str() == Some("audio")),
"dest Gemini AUDIO next to IMAGE must reach Chat modalities, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.output_modalities"),
"Chat has modalities and must not Drop AUDIO, got {report:?}"
);
}
#[test]
fn chat_unknown_modality_is_not_a_silent_preserve() {
let dropped = user_ir(IrSampling::patch(|s| {
s.output_modalities = vec!["video".into()];
}));
let (bytes, report) = encode(Wire::ChatCompletions, &dropped, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(body.get("modalities").is_none(), "{body}");
assert!(
loss_dropped(&report, "sampling.output_modalities"),
"unrecognized modality must Drop, got {report:?}"
);
let mixed = user_ir(IrSampling::patch(|s| {
s.output_modalities = vec!["text".into(), "video".into()];
}));
let (bytes, report) = encode(Wire::ChatCompletions, &mixed, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(body["modalities"], serde_json::json!(["text"]));
assert!(
report.events.iter().any(|event| {
event.path == "sampling.output_modalities" && event.action == LossAction::Degrade
}),
"a dropped sibling must Degrade, got {report:?}"
);
assert!(
!loss_dropped(&report, "sampling.output_modalities"),
"kept text must not share a Drop, got {report:?}"
);
}
#[test]
fn gemini_unknown_modality_is_not_a_silent_preserve() {
let dropped = user_ir(IrSampling::patch(|s| {
s.output_modalities = vec!["video".into()];
}));
let (bytes, report) = encode(Wire::Gemini, &dropped, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.pointer("/generationConfig/responseModalities")
.is_none(),
"{body}"
);
assert!(
loss_dropped(&report, "sampling.output_modalities"),
"unrecognized modality must Drop, got {report:?}"
);
let mixed = user_ir(IrSampling::patch(|s| {
s.output_modalities = vec!["text".into(), "video".into()];
}));
let (bytes, report) = encode(Wire::Gemini, &mixed, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/generationConfig/responseModalities"),
Some(&serde_json::json!(["TEXT"]))
);
assert!(
report.events.iter().any(|event| {
event.path == "sampling.output_modalities" && event.action == LossAction::Degrade
}),
"a dropped sibling must Degrade, got {report:?}"
);
assert!(
!loss_dropped(&report, "sampling.output_modalities"),
"kept text must not share a Drop, got {report:?}"
);
}
#[test]
fn chat_decode_unknown_modality_is_a_drop() {
let raw =
br#"{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],"modalities":["video"]}"#;
let (ir, _) = decode(Wire::ChatCompletions, raw).expect("decode");
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(body.get("modalities").is_none(), "{body}");
assert!(
loss_dropped(&report, "sampling.output_modalities"),
"decode must keep video so encode can Drop it, got {report:?}"
);
let mixed = br#"{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],"modalities":["text","video"]}"#;
let (ir, _) = decode(Wire::ChatCompletions, mixed).expect("decode");
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(body["modalities"], serde_json::json!(["text"]));
assert!(
report.events.iter().any(|event| {
event.path == "sampling.output_modalities" && event.action == LossAction::Degrade
}),
"mixed decode must Degrade, got {report:?}"
);
}
#[test]
fn dest_messages_output_modalities_drop() {
let req = br#"{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"modalities": ["audio"],
"audio": {"voice": "Puck", "format": "wav"}
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode dest Chat");
let (bytes, report) =
encode(Wire::Messages, &ir, &messages_profile()).expect("encode Messages");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("modalities").is_none(),
"Messages must not invent modalities, got {body}"
);
assert!(
body.pointer("/audio/voice").is_none(),
"Messages must not invent audio.voice, got {body}"
);
assert!(
loss_dropped(&report, "sampling.output_modalities"),
"Messages has no modalities slot and must Drop, got {report:?}"
);
}
#[test]
fn chat_json_schema_without_name_is_dropped() {
let ir = user_ir(IrSampling::patch(|s| {
s.json_schema = Some(serde_json::json!({"type": "object"}));
s.json_schema_name = None;
}));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("response_format").is_none(),
"nameless json_schema must not emit response_format, got {body}"
);
assert!(
loss_dropped(&report, "sampling.json_schema"),
"nameless json_schema must Drop, got {report:?}"
);
let ir = user_ir(IrSampling::patch(|s| {
s.json_schema = Some(serde_json::json!(["not", "object"]));
s.json_schema_name = Some("answer".into());
}));
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("response_format").is_none(),
"non-object json_schema must not emit response_format, got {body}"
);
assert!(
loss_dropped(&report, "sampling.json_schema"),
"non-object json_schema must Drop, got {report:?}"
);
}
#[test]
fn responses_json_schema_without_name_is_dropped() {
let ir = user_ir(IrSampling::patch(|s| {
s.json_schema = Some(serde_json::json!({"type": "object"}));
s.json_schema_name = None;
}));
let (bytes, report) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("text").is_none(),
"nameless json_schema must not emit text.format, got {body}"
);
assert!(
loss_dropped(&report, "sampling.json_schema"),
"nameless json_schema must Drop, got {report:?}"
);
let ir = user_ir(IrSampling::patch(|s| {
s.json_schema = Some(serde_json::json!("not-object"));
s.json_schema_name = Some("answer".into());
}));
let (bytes, report) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("text").is_none(),
"non-object json_schema must not emit text.format, got {body}"
);
assert!(
loss_dropped(&report, "sampling.json_schema"),
"non-object json_schema must Drop, got {report:?}"
);
}
#[test]
fn messages_json_schema_reaches_output_config_format() {
let ir = user_ir(IrSampling::patch(|s| {
s.json_schema = Some(serde_json::json!({"type": "object"}));
s.json_schema_name = Some("answer".into());
}));
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/output_config/format/type")
.and_then(Value::as_str),
Some("json_schema"),
"Messages must emit output_config.format.type, got {body}"
);
assert_eq!(
body.pointer("/output_config/format/schema"),
Some(&serde_json::json!({"type": "object"})),
"Messages must emit output_config.format.schema, got {body}"
);
assert!(
body.get("response_format").is_none() && body.get("output_format").is_none(),
"Messages must not invent a structured-output slot, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.json_schema"),
"Messages has output_config.format and must not Drop json_schema, got {report:?}"
);
assert!(
loss_dropped(&report, "sampling.json_schema_name"),
"Messages output_config.format has no name and must Drop json_schema_name, got {report:?}"
);
}
#[test]
fn gemini_json_schema_round_trips() {
let req = br#"{
"model": "gemini-2.5-pro",
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"generationConfig": {
"responseMimeType": "application/json",
"responseSchema": {"type": "object", "properties": {"ok": {"type": "boolean"}}}
}
}"#;
let (ir, decode_report) = decode(Wire::Gemini, req).expect("decode");
assert_eq!(
ir.sampling.json_schema,
Some(serde_json::json!({"type": "object", "properties": {"ok": {"type": "boolean"}}}))
);
assert!(
!loss_dropped(&decode_report, "sampling.json_schema"),
"Gemini has responseSchema and must not Drop on decode, got {decode_report:?}"
);
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/generationConfig/responseMimeType")
.and_then(Value::as_str),
Some("application/json"),
"Gemini must emit responseMimeType, got {body}"
);
assert_eq!(
body.pointer("/generationConfig/responseSchema"),
Some(&serde_json::json!({"type": "object", "properties": {"ok": {"type": "boolean"}}})),
"Gemini must emit responseSchema, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.json_schema"),
"Gemini has responseSchema and must not Drop on encode, got {report:?}"
);
}
#[test]
fn dest_gemini_response_schema_reaches_chat_json_schema() {
let req = br#"{
"model": "gemini-2.5-flash",
"contents": [{"role": "user", "parts": [{"text": "city"}]}],
"generationConfig": {
"responseMimeType": "application/json",
"responseSchema": {"type": "object", "properties": {"city": {"type": "string"}}}
}
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode dest Gemini");
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/response_format/type")
.and_then(Value::as_str),
Some("json_schema"),
"dest Gemini responseSchema must reach Chat json_schema, got {body}"
);
assert_eq!(
body.pointer("/response_format/json_schema/schema/properties/city/type")
.and_then(Value::as_str),
Some("string"),
"dest Gemini schema properties must reach Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.json_schema"),
"dest Gemini schema must not Drop on Chat encode, got {report:?}"
);
}
#[test]
fn dest_gemini_response_format_schema_reaches_chat() {
let req = br#"{
"model": "gemini-2.5-flash",
"contents": [{"role": "user", "parts": [{"text": "city"}]}],
"generationConfig": {
"responseFormat": {
"text": {
"mimeType": "APPLICATION_JSON",
"schema": {"type": "object", "properties": {"city": {"type": "string"}}}
}
}
}
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode dest Gemini");
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/response_format/type")
.and_then(Value::as_str),
Some("json_schema"),
"dest Gemini responseFormat.text.schema must reach Chat json_schema, got {body}"
);
assert_eq!(
body.pointer("/response_format/json_schema/schema/properties/city/type")
.and_then(Value::as_str),
Some("string"),
"dest Gemini responseFormat schema properties must reach Chat, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.json_schema"),
"dest Gemini responseFormat schema must not Drop on Chat encode, got {report:?}"
);
}
#[test]
fn dest_gemini_response_format_json_mime_reaches_chat() {
let req = br#"{
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
"generationConfig": {
"responseFormat": { "text": { "mimeType": "APPLICATION_JSON" } }
}
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
assert_eq!(
ir.sampling.json_object,
Some(true),
"dest Gemini responseFormat APPLICATION_JSON without schema must land on IR"
);
assert!(ir.sampling.json_schema.is_none());
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/response_format/type")
.and_then(Value::as_str),
Some("json_object"),
"dest Gemini responseFormat json mime must reach Chat json_object, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.json_object"),
"Chat has json_object and must not Drop, got {report:?}"
);
}
#[test]
fn dest_gemini_parameters_json_schema_reaches_chat() {
let req = br#"{
"contents": [{"role": "user", "parts": [{"text": "weather"}]}],
"tools": [{
"functionDeclarations": [{
"name": "get_weather",
"parametersJsonSchema": {
"type": "object",
"properties": {"city": {"type": "string"}}
}
}]
}]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode dest Gemini");
let Some(IrTool::Function { parameters, .. }) = ir.tools.first() else {
panic!(
"dest Gemini parametersJsonSchema must decode a function tool, got {:?}",
ir.tools
);
};
assert_eq!(
parameters
.pointer("/properties/city/type")
.and_then(Value::as_str),
Some("string"),
"dest Gemini parametersJsonSchema must land on IR, got {parameters}"
);
let (bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/tools/0/function/parameters/properties/city/type")
.and_then(Value::as_str),
Some("string"),
"dest Gemini parametersJsonSchema must reach Chat tools[0].function.parameters, got {body}"
);
assert_ne!(
body.pointer("/tools/0/function/parameters"),
Some(&serde_json::json!({"type": "object", "properties": {}})),
"dest-only parametersJsonSchema must not remap as empty parameters, got {body}"
);
}
#[test]
fn dest_gemini_store_reaches_chat() {
for want in [false, true] {
let req = format!(
r#"{{
"model": "gemini-2.5-flash",
"store": {want},
"contents": [{{"role": "user", "parts": [{{"text": "hi"}}]}}]
}}"#
);
let (ir, decode_report) = decode(Wire::Gemini, req.as_bytes()).expect("decode dest Gemini");
assert_eq!(
ir.sampling.store,
Some(want),
"dest Gemini store {want} must land on IR"
);
assert!(
!loss_dropped(&decode_report, "sampling.store"),
"dest Gemini store decode must not Drop, got {decode_report:?}"
);
let (bytes, report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("store").and_then(Value::as_bool),
Some(want),
"dest Gemini store must reach Chat store {want}, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.store"),
"Chat has store and must not Drop, got {report:?}"
);
}
}
#[test]
fn dest_gemini_service_tier_reaches_chat() {
let req = br#"{
"model": "gemini-2.5-flash",
"serviceTier": "priority",
"contents": [{"role": "user", "parts": [{"text": "hi"}]}]
}"#;
let (ir, decode_report) = decode(Wire::Gemini, req).expect("decode dest Gemini");
assert_eq!(
ir.sampling.service_tier.as_deref(),
Some("priority"),
"dest Gemini serviceTier must land on IR"
);
assert!(
!loss_dropped(&decode_report, "sampling.service_tier"),
"dest Gemini serviceTier decode must not Drop, got {decode_report:?}"
);
let (bytes, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("service_tier").and_then(Value::as_str),
Some("priority"),
"dest Gemini serviceTier must reach Chat service_tier, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.service_tier"),
"Chat has service_tier and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_store_reaches_gemini() {
let req = br#"{
"model": "gpt-4o",
"store": true,
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("store").and_then(Value::as_bool),
Some(true),
"dest Chat store must reach Gemini store, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.store"),
"Gemini has store and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_service_tier_reaches_gemini() {
let req = br#"{
"model": "gpt-4o",
"service_tier": "flex",
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("service_tier").is_none(),
"Gemini must not emit Chat service_tier key, got {body}"
);
assert_eq!(
body.get("serviceTier").and_then(Value::as_str),
Some("flex"),
"dest Chat service_tier flex must reach Gemini serviceTier, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.service_tier"),
"Gemini has serviceTier and must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_default_service_tier_degrades_to_gemini_standard() {
let req = br#"{
"model": "gpt-4o",
"service_tier": "default",
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("serviceTier").and_then(Value::as_str),
Some("standard"),
"dest Chat default must degrade to Gemini standard, got {body}"
);
assert!(
loss_degraded(&report, "sampling.service_tier"),
"default must Degrade to standard, got {report:?}"
);
assert!(
!loss_dropped(&report, "sampling.service_tier"),
"default must not Drop, got {report:?}"
);
}
#[test]
fn dest_chat_unknown_service_tier_drops_on_gemini() {
let req = br#"{
"model": "gpt-4o",
"service_tier": "turbo",
"messages": [{"role": "user", "content": "hi"}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("serviceTier").is_none() && body.get("service_tier").is_none(),
"unknown service_tier must not invent Gemini serviceTier, got {body}"
);
assert!(
loss_dropped(&report, "sampling.service_tier"),
"unknown service_tier must Drop, got {report:?}"
);
assert!(
!report.events.iter().any(|event| {
event.path == "sampling.service_tier"
&& event.action == LossAction::Drop
&& event.detail == "no slot"
}),
"Gemini has a slot; Drop detail must not be no slot, got {report:?}"
);
}
#[test]
fn gemini_nameless_json_schema_still_encodes() {
let ir = user_ir(IrSampling::patch(|s| {
s.json_schema =
Some(serde_json::json!({"type": "object", "properties": {"n": {"type": "number"}}}));
s.json_schema_name = None;
}));
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/generationConfig/responseSchema"),
Some(&serde_json::json!({"type": "object", "properties": {"n": {"type": "number"}}})),
"Gemini has no name slot; object schema must still encode, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.json_schema"),
"nameless object schema must not Drop on Gemini, got {report:?}"
);
}
#[test]
fn gemini_non_object_json_schema_is_dropped() {
let ir = user_ir(IrSampling::patch(|s| {
s.json_schema = Some(serde_json::json!(["not", "object"]));
s.json_schema_name = Some("answer".into());
}));
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.pointer("/generationConfig/responseSchema").is_none(),
"non-object json_schema must not emit responseSchema, got {body}"
);
assert!(
loss_dropped(&report, "sampling.json_schema"),
"non-object json_schema must Drop, got {report:?}"
);
assert!(
!report.events.iter().any(|event| {
event.path == "sampling.json_schema"
&& event.action == LossAction::Drop
&& event.detail == "no slot"
}),
"Gemini has a slot; Drop detail must not be no slot, got {report:?}"
);
}
fn chat_sampling_ir(model: &str) -> IrRequest {
IrRequest::new(model, vec![]).with_sampling(IrSampling::patch(|s| {
s.max_tokens = Some(64);
s.temperature = Some(0.2);
}))
}
fn assert_chat_max_completion(model: &str) {
let (bytes, report) = encode(
Wire::ChatCompletions,
&chat_sampling_ir(model),
&chat_profile(),
)
.expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("max_completion_tokens").and_then(Value::as_u64),
Some(64),
"{model} must write max_completion_tokens, got {body}"
);
assert!(
body.get("max_tokens").is_none(),
"{model} must omit max_tokens, got {body}"
);
assert!(
body.get("temperature").is_none(),
"{model} must omit temperature, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "sampling.temperature" && event.action == LossAction::Drop
}),
"{model} must Drop sampling.temperature, got {report:?}"
);
}
fn assert_chat_classic_max_tokens(model: &str) {
let (bytes, report) = encode(
Wire::ChatCompletions,
&chat_sampling_ir(model),
&chat_profile(),
)
.expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("max_tokens").and_then(Value::as_u64),
Some(64),
"{model} must write max_tokens, got {body}"
);
assert!(
body.get("max_completion_tokens").is_none(),
"{model} must omit max_completion_tokens, got {body}"
);
let temperature = body.get("temperature").and_then(Value::as_f64);
assert!(
temperature.is_some_and(|value| (value - 0.2).abs() < 1e-6),
"{model} must keep temperature, got {body}"
);
assert!(
!report.events.iter().any(|event| {
event.path == "sampling.temperature" && event.action == LossAction::Drop
}),
"{model} must not Drop temperature, got {report:?}"
);
}
#[test]
fn chat_encode_o1_uses_max_completion_tokens() {
assert_chat_max_completion("o1");
}
#[test]
fn chat_encode_o3_mini_uses_max_completion_tokens() {
assert_chat_max_completion("o3-mini");
}
#[test]
fn chat_encode_o4_mini_uses_max_completion_tokens() {
assert_chat_max_completion("o4-mini");
}
#[test]
fn chat_encode_openai_o3_mini_uses_max_completion_tokens() {
assert_chat_max_completion("openai/o3-mini");
}
#[test]
fn chat_encode_gpt_5_uses_max_completion_tokens() {
assert_chat_max_completion("gpt-5");
}
#[test]
fn chat_encode_gpt_5_mini_uses_max_completion_tokens() {
assert_chat_max_completion("gpt-5-mini");
}
#[test]
fn chat_encode_o3_mini_casefold_uses_max_completion_tokens() {
assert_chat_max_completion("O3-MINI");
}
#[test]
fn chat_encode_gpt_4o_does_not_use_max_completion_tokens() {
assert_chat_classic_max_tokens("gpt-4o");
}
#[test]
fn chat_encode_o10_does_not_use_max_completion_tokens() {
assert_chat_classic_max_tokens("o10");
}
#[test]
fn chat_decode_reads_max_completion_tokens() {
let req = br#"{
"model": "o3-mini",
"messages": [{"role": "user", "content": "hi"}],
"max_completion_tokens": 64
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(ir.sampling.max_tokens, Some(64));
}
#[test]
fn chat_encode_decode_roundtrip_keeps_max_completion_tokens() {
let ir = chat_sampling_ir("o3-mini");
let (bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode");
let (decoded, _) = decode(Wire::ChatCompletions, &bytes).expect("decode");
assert_eq!(decoded.sampling.max_tokens, Some(64));
}
#[test]
fn chat_decode_reads_max_tokens_for_classic_models() {
let req = br#"{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 32
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert_eq!(ir.sampling.max_tokens, Some(32));
}
fn chat_sampling_ir_with_top_p(model: &str) -> IrRequest {
let mut ir = chat_sampling_ir(model);
ir.sampling.top_p = Some(0.9);
ir
}
#[test]
fn chat_encode_o3_mini_omits_top_p() {
let (bytes, report) = encode(
Wire::ChatCompletions,
&chat_sampling_ir_with_top_p("o3-mini"),
&chat_profile(),
)
.expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("top_p").is_none(),
"o3-mini must omit top_p, got {body}"
);
assert!(
body.get("temperature").is_none(),
"o3-mini must omit temperature, got {body}"
);
assert_eq!(
body.get("max_completion_tokens").and_then(Value::as_u64),
Some(64),
"o3-mini must write max_completion_tokens, got {body}"
);
assert!(
report
.events
.iter()
.any(|event| { event.path == "sampling.top_p" && event.action == LossAction::Drop }),
"o3-mini must Drop sampling.top_p, got {report:?}"
);
assert!(
report.events.iter().any(|event| {
event.path == "sampling.temperature" && event.action == LossAction::Drop
}),
"o3-mini must Drop sampling.temperature, got {report:?}"
);
}
#[test]
fn chat_encode_gpt_4o_keeps_top_p() {
let (bytes, report) = encode(
Wire::ChatCompletions,
&chat_sampling_ir_with_top_p("gpt-4o"),
&chat_profile(),
)
.expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let top_p = body
.get("top_p")
.and_then(Value::as_f64)
.expect("gpt-4o must write top_p");
assert!(
(top_p - 0.9).abs() < 1e-6,
"gpt-4o top_p={top_p}, got {body}"
);
assert!(
!report
.events
.iter()
.any(|event| { event.path == "sampling.top_p" && event.action == LossAction::Drop }),
"gpt-4o must not Drop top_p, got {report:?}"
);
}
fn model_sampling_ir(model: &str, sampling: IrSampling) -> IrRequest {
IrRequest::new(
model,
vec![IrItem::User {
parts: vec![IrPart::Text("hi".into())],
}],
)
.with_sampling(sampling)
}
fn encode_chat_sampling(model: &str, sampling: IrSampling) -> (Value, LossReport) {
let (bytes, report) = encode(
Wire::ChatCompletions,
&model_sampling_ir(model, sampling),
&chat_profile(),
)
.expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
(body, report)
}
fn encode_responses_sampling(model: &str, sampling: IrSampling) -> (Value, LossReport) {
let (bytes, report) = encode(
Wire::Responses,
&model_sampling_ir(model, sampling),
&flatten_profile(),
)
.expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
(body, report)
}
fn assert_sampling_not_dropped(report: &LossReport, path: &str) {
assert!(
!loss_dropped(report, path),
"{path} must not Drop, got {report:?}"
);
}
fn assert_approx_number(body: &Value, key: &str, expect: f64) {
let got = body.get(key).and_then(Value::as_f64);
assert!(
got.is_some_and(|value| (value - expect).abs() < 1e-6),
"{key} must be {expect}, got {body}"
);
}
#[test]
fn chat_o3_omits_stop() {
let (body, report) = encode_chat_sampling(
"o3",
IrSampling::patch(|s| {
s.stop = vec!["END".into()];
}),
);
assert!(body.get("stop").is_none(), "o3 must omit stop, got {body}");
assert!(
loss_dropped(&report, "sampling.stop"),
"o3 must Drop sampling.stop, got {report:?}"
);
}
#[test]
fn chat_o3_omits_stop_o4_mini() {
let (body, report) = encode_chat_sampling(
"o4-mini",
IrSampling::patch(|s| {
s.stop = vec!["END".into()];
}),
);
assert!(
body.get("stop").is_none(),
"o4-mini must omit stop, got {body}"
);
assert!(
loss_dropped(&report, "sampling.stop"),
"o4-mini must Drop sampling.stop, got {report:?}"
);
}
#[test]
fn chat_o3_omits_stop_provider_dated() {
let (body, report) = encode_chat_sampling(
"openai/o3-2025-04-16",
IrSampling::patch(|s| {
s.stop = vec!["END".into()];
}),
);
assert!(
body.get("stop").is_none(),
"dated o3 must omit stop, got {body}"
);
assert!(
loss_dropped(&report, "sampling.stop"),
"dated o3 must Drop sampling.stop, got {report:?}"
);
}
#[test]
fn chat_encode_o3_mini_keeps_stop() {
let (body, report) = encode_chat_sampling(
"o3-mini",
IrSampling::patch(|s| {
s.stop = vec!["END".into()];
}),
);
assert_eq!(body["stop"], serde_json::json!(["END"]));
assert_sampling_not_dropped(&report, "sampling.stop");
}
#[test]
fn chat_encode_omits_stop_not_for_gpt_4o() {
let (body, report) = encode_chat_sampling(
"gpt-4o",
IrSampling::patch(|s| {
s.stop = vec!["END".into()];
}),
);
assert_eq!(body["stop"], serde_json::json!(["END"]));
assert_sampling_not_dropped(&report, "sampling.stop");
}
#[test]
fn chat_gpt5_omits_logprobs() {
let (body, report) = encode_chat_sampling(
"gpt-5",
IrSampling::patch(|s| {
s.logprobs = Some(true);
s.top_logprobs = Some(2);
}),
);
assert!(
body.get("logprobs").is_none(),
"gpt-5 must omit logprobs, got {body}"
);
assert!(
body.get("top_logprobs").is_none(),
"gpt-5 must omit top_logprobs, got {body}"
);
assert!(
loss_dropped(&report, "sampling.logprobs"),
"gpt-5 must Drop sampling.logprobs, got {report:?}"
);
assert!(
loss_dropped(&report, "sampling.top_logprobs"),
"gpt-5 must Drop sampling.top_logprobs, got {report:?}"
);
}
#[test]
fn chat_gpt5_omits_logprobs_mini_top_only() {
let (body, report) = encode_chat_sampling(
"gpt-5-mini",
IrSampling::patch(|s| {
s.top_logprobs = Some(1);
}),
);
assert!(
body.get("logprobs").is_none(),
"gpt-5-mini must omit logprobs, got {body}"
);
assert!(
body.get("top_logprobs").is_none(),
"gpt-5-mini must omit top_logprobs, got {body}"
);
assert!(
loss_dropped(&report, "sampling.logprobs"),
"gpt-5-mini must Drop sampling.logprobs, got {report:?}"
);
assert!(
loss_dropped(&report, "sampling.top_logprobs"),
"gpt-5-mini must Drop sampling.top_logprobs, got {report:?}"
);
}
#[test]
fn chat_gpt5_omits_logprobs_52_high() {
let (body, report) = encode_chat_sampling(
"gpt-5.2",
IrSampling::patch(|s| {
s.reasoning_effort = Some("high".into());
s.logprobs = Some(true);
}),
);
assert!(
body.get("logprobs").is_none(),
"gpt-5.2 high effort must omit logprobs, got {body}"
);
assert!(
loss_dropped(&report, "sampling.logprobs"),
"gpt-5.2 high effort must Drop sampling.logprobs, got {report:?}"
);
}
#[test]
fn chat_gpt5_2_none_keeps_logprobs() {
let (body, report) = encode_chat_sampling(
"gpt-5.2",
IrSampling::patch(|s| {
s.reasoning_effort = Some("none".into());
s.logprobs = Some(true);
s.top_logprobs = Some(2);
}),
);
assert_eq!(body.get("logprobs").and_then(Value::as_bool), Some(true));
assert_eq!(
body.get("top_logprobs").and_then(Value::as_u64),
Some(2),
"gpt-5.2 effort none must keep top_logprobs, got {body}"
);
assert_sampling_not_dropped(&report, "sampling.logprobs");
assert_sampling_not_dropped(&report, "sampling.top_logprobs");
}
#[test]
fn chat_gpt5_2_unset_keeps_logprobs() {
let (body, report) = encode_chat_sampling(
"gpt-5.2",
IrSampling::patch(|s| {
s.logprobs = Some(true);
}),
);
assert_eq!(body.get("logprobs").and_then(Value::as_bool), Some(true));
assert_sampling_not_dropped(&report, "sampling.logprobs");
}
#[test]
fn chat_gpt5_4o_keeps_logprobs() {
let (body, report) = encode_chat_sampling(
"gpt-4o",
IrSampling::patch(|s| {
s.logprobs = Some(true);
}),
);
assert_eq!(body.get("logprobs").and_then(Value::as_bool), Some(true));
assert_sampling_not_dropped(&report, "sampling.logprobs");
}
#[test]
fn responses_gpt5_omits_temperature() {
let (body, report) = encode_responses_sampling(
"gpt-5",
IrSampling::patch(|s| {
s.temperature = Some(0.2);
}),
);
assert!(
body.get("temperature").is_none(),
"gpt-5 Responses must omit temperature, got {body}"
);
assert!(
loss_dropped(&report, "sampling.temperature"),
"gpt-5 Responses must Drop sampling.temperature, got {report:?}"
);
}
#[test]
fn responses_gpt5_omits_temperature_nano_top_p() {
let (body, report) = encode_responses_sampling(
"gpt-5-nano",
IrSampling::patch(|s| {
s.top_p = Some(0.5);
}),
);
assert!(
body.get("top_p").is_none(),
"gpt-5-nano Responses must omit top_p, got {body}"
);
assert!(
loss_dropped(&report, "sampling.top_p"),
"gpt-5-nano Responses must Drop sampling.top_p, got {report:?}"
);
}
#[test]
fn responses_gpt5_omits_temperature_51_low() {
let (body, report) = encode_responses_sampling(
"gpt-5.1",
IrSampling::patch(|s| {
s.reasoning_effort = Some("low".into());
s.temperature = Some(0.2);
}),
);
assert!(
body.get("temperature").is_none(),
"gpt-5.1 low effort must omit temperature, got {body}"
);
assert!(
loss_dropped(&report, "sampling.temperature"),
"gpt-5.1 low effort must Drop sampling.temperature, got {report:?}"
);
}
#[test]
fn responses_gpt5_2_none_keeps_temperature() {
let (body, report) = encode_responses_sampling(
"gpt-5.2",
IrSampling::patch(|s| {
s.reasoning_effort = Some("none".into());
s.temperature = Some(0.2);
}),
);
assert_approx_number(&body, "temperature", 0.2);
assert_sampling_not_dropped(&report, "sampling.temperature");
}
#[test]
fn responses_gpt5_omits_top_logprobs() {
let (body, report) = encode_responses_sampling(
"gpt-5",
IrSampling::patch(|s| {
s.top_logprobs = Some(3);
}),
);
assert!(
body.get("top_logprobs").is_none(),
"gpt-5 Responses must omit top_logprobs, got {body}"
);
assert!(
loss_dropped(&report, "sampling.top_logprobs"),
"gpt-5 Responses must Drop sampling.top_logprobs, got {report:?}"
);
}
#[test]
fn responses_gpt5_4o_keeps_temperature() {
let (body, report) = encode_responses_sampling(
"gpt-4o",
IrSampling::patch(|s| {
s.temperature = Some(0.2);
}),
);
assert_approx_number(&body, "temperature", 0.2);
assert_sampling_not_dropped(&report, "sampling.temperature");
}
#[test]
fn messages_pdf_document_round_trips() {
let req = br#"{
"model": "claude-opus-4-6",
"messages": [{
"role": "user",
"content": [{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": "AAAA"
}
}]
}]
}"#;
let (ir, _) = decode(Wire::Messages, req).expect("decode");
assert!(
ir.items.iter().any(|item| matches!(
item,
IrItem::User { parts } if parts.iter().any(|p| matches!(
p,
IrPart::Document {
source: IrDocumentSource::Base64(data),
media_type,
..
} if data == "AAAA" && media_type == "application/pdf"
))
)),
"document part must become IrPart::Document, got {:?}",
ir.items
);
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/type")
.and_then(Value::as_str),
Some("document"),
"encode Messages must keep type document, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/source/data")
.and_then(Value::as_str),
Some("AAAA"),
"encode Messages must keep PDF bytes, got {body}"
);
assert!(
!report
.events
.iter()
.any(|event| { event.path.contains("document") && event.action == LossAction::Drop }),
"Messages PDF must not Drop, got {report:?}"
);
}
#[test]
fn messages_plain_text_document_keeps_citations_and_context() {
let req = br#"{
"model": "claude-sonnet-4-5",
"max_tokens": 32,
"messages": [{
"role": "user",
"content": [{
"type": "document",
"title": "notes",
"context": "Q3 notes",
"citations": {"enabled": true},
"source": {
"type": "text",
"media_type": "text/plain",
"data": "hello"
}
}]
}]
}"#;
let (ir, _) = decode(Wire::Messages, req).expect("decode");
assert!(
ir.items.iter().any(|item| matches!(
item,
IrItem::User { parts } if parts.iter().any(|p| matches!(
p,
IrPart::Document {
source: IrDocumentSource::Text(data),
citations_enabled: Some(true),
context: Some(ctx),
..
} if data == "hello" && ctx == "Q3 notes"
))
)),
"plain text document must stay text with citations and context, got {:?}",
ir.items
);
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let source = body
.pointer("/messages/0/content/0/source")
.cloned()
.unwrap_or(Value::Null);
assert_eq!(
source.get("type").and_then(Value::as_str),
Some("text"),
"plain text document must stay type text, got {body}"
);
assert_eq!(
source.get("media_type").and_then(Value::as_str),
Some("text/plain"),
"plain text document must keep media_type, got {body}"
);
assert_eq!(
source.get("data").and_then(Value::as_str),
Some("hello"),
"plain text document must keep data, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/citations/enabled")
.and_then(Value::as_bool),
Some(true),
"document citations must round-trip, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/context")
.and_then(Value::as_str),
Some("Q3 notes"),
"document context must round-trip, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/title")
.and_then(Value::as_str),
Some("notes")
);
assert!(
!report
.events
.iter()
.any(|event| { event.path.contains("document") && event.action == LossAction::Drop }),
"plain text document must not Drop, got {report:?} ir={ir:?}"
);
}
#[test]
fn messages_document_url_omits_media_type() {
let req = br#"{
"model": "claude-sonnet-4-5",
"max_tokens": 16,
"messages": [{
"role": "user",
"content": [{
"type": "document",
"title": "pdf-url",
"source": {"type": "url", "url": "https://example.com/a.pdf"}
}]
}]
}"#;
let (ir, _) = decode(Wire::Messages, req).expect("decode");
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let source = body
.pointer("/messages/0/content/0/source")
.cloned()
.unwrap_or(Value::Null);
assert_eq!(source.get("type").and_then(Value::as_str), Some("url"));
assert_eq!(
source.get("url").and_then(Value::as_str),
Some("https://example.com/a.pdf")
);
assert!(
source.get("media_type").is_none(),
"URLPDFSource has no media_type, got {body}"
);
}
#[test]
fn responses_input_file_part_round_trips_as_document() {
let req = br#"{
"model": "gpt-5",
"input": [{
"role": "user",
"content": [{
"type": "input_file",
"file_id": "file-abc"
}]
}]
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
assert!(
ir.items.iter().any(|item| matches!(
item,
IrItem::User { parts } if parts.iter().any(|p| matches!(
p,
IrPart::Document {
source: IrDocumentSource::FileId(id),
..
} if id == "file-abc"
))
)),
"input_file part must become IrPart::Document FileId, got {:?}",
ir.items
);
let (bytes, _) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let content = body
.pointer("/input/0/content")
.and_then(Value::as_array)
.expect("content");
assert!(
content.iter().any(|block| {
block.get("type").and_then(Value::as_str) == Some("input_file")
&& block.get("file_id").and_then(Value::as_str) == Some("file-abc")
}),
"encode Responses must keep type input_file, got {body}"
);
}
#[test]
fn responses_function_output_keeps_input_file_same_wire() {
let req = br#"{
"model": "gpt-5",
"input": [{
"type": "function_call_output",
"call_id": "call_1",
"output": [
{"type": "input_text", "text": "see file"},
{
"type": "input_file",
"filename": "a.pdf",
"file_data": "data:application/pdf;base64,AAAA"
}
]
}]
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
let (bytes, report) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let output = body
.pointer("/input/0/output")
.cloned()
.unwrap_or(Value::Null);
let parts = output.as_array().expect("structured output");
assert!(
parts.iter().any(|part| {
part.get("type").and_then(Value::as_str) == Some("input_text")
&& part.get("text").and_then(Value::as_str) == Some("see file")
}),
"text part must stay, got {body}"
);
assert!(
parts.iter().any(|part| {
part.get("type").and_then(Value::as_str) == Some("input_file")
&& part.get("filename").and_then(Value::as_str) == Some("a.pdf")
&& part.get("file_data").and_then(Value::as_str)
== Some("data:application/pdf;base64,AAAA")
}),
"input_file must round-trip inside function_call_output, got {body}"
);
assert!(
!report
.events
.iter()
.any(|event| { event.path.contains("part") && event.action == LossAction::Drop }),
"same-wire file must not Drop, got {report:?}"
);
}
#[test]
fn messages_tool_result_image_then_text_keeps_order() {
let req = br#"{
"model": "claude-opus-4-6",
"messages": [{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "toolu_1",
"content": [
{
"type": "image",
"source": {"type": "base64", "media_type": "image/png", "data": "AAAA"}
},
{"type": "text", "text": "see this"}
]
}]
}]
}"#;
let (ir, _) = decode(Wire::Messages, req).expect("decode");
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/content/0/type")
.and_then(Value::as_str),
Some("image"),
"image must stay before the text, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/content/1/text")
.and_then(Value::as_str),
Some("see this"),
"text must stay after the image, got {body}"
);
}
#[test]
fn responses_function_output_image_then_text_keeps_order() {
let req = br#"{
"model": "gpt-5",
"input": [{
"type": "function_call_output",
"call_id": "call_1",
"output": [
{"type": "input_image", "image_url": "data:image/png;base64,AAAA"},
{"type": "input_text", "text": "see this"}
]
}]
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
let (bytes, _) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/input/0/output/0/type")
.and_then(Value::as_str),
Some("input_image"),
"image must stay before the text, got {body}"
);
assert_eq!(
body.pointer("/input/0/output/1/text")
.and_then(Value::as_str),
Some("see this"),
"text must stay after the image, got {body}"
);
}
#[test]
fn messages_text_document_to_converse_keeps_text() {
let req = br#"{
"model": "claude-sonnet-4-5",
"max_tokens": 32,
"messages": [{
"role": "user",
"content": [{
"type": "document",
"source": {
"type": "text",
"media_type": "text/plain",
"data": "Policy: refunds within 30 days"
}
}]
}]
}"#;
let (ir, _) = decode(Wire::Messages, req).expect("decode");
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let rendered = body.to_string();
assert!(
rendered.contains("Policy: refunds within 30 days"),
"Converse must keep the document text, got {body}"
);
assert!(
!report
.events
.iter()
.any(|event| { event.path.contains("document") && event.action == LossAction::Drop }),
"plain text document must Degrade, not Drop, got {report:?}"
);
}
#[test]
#[allow(non_snake_case)]
fn gemini_fileData_part_round_trips_as_raw() {
let req = br#"{
"model": "gemini-2.5-flash",
"contents": [{
"role": "user",
"parts": [{
"fileData": {
"fileUri": "files/abc",
"mimeType": "application/pdf"
}
}]
}]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
assert!(
ir.items.iter().any(|item| matches!(
item,
IrItem::User { parts } if parts.iter().any(|p| matches!(
p,
IrPart::Document {
source: IrDocumentSource::FileId(id),
media_type,
..
} if id == "files/abc" && media_type == "application/pdf"
))
)),
"PDF fileData must become IrPart::Document FileId, got {:?}",
ir.items
);
let (bytes, _) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/contents/0/parts/0/fileData/fileUri")
.and_then(Value::as_str),
Some("files/abc"),
"encode Gemini must keep fileData, got {body}"
);
}
#[test]
fn gemini_code_execution_parts_round_trip() {
let req = br#"{
"contents": [{
"role": "model",
"parts": [
{"executableCode": {"language": "PYTHON", "code": "print(1)"}},
{"codeExecutionResult": {"outcome": "OUTCOME_OK", "output": "1\n"}}
]
}]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, _) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/contents/0/parts/0/executableCode/code")
.and_then(Value::as_str),
Some("print(1)"),
"Gemini executableCode must round-trip, got {body}"
);
assert_eq!(
body.pointer("/contents/0/parts/1/codeExecutionResult/output")
.and_then(Value::as_str),
Some("1\n"),
"Gemini codeExecutionResult must round-trip, got {body}"
);
let (_, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
assert!(
report
.events
.iter()
.any(|event| event.action == LossAction::Drop && event.path == "part.raw"),
"Chat must record a drop for Gemini code execution parts, got {report:?}"
);
}
#[test]
fn gemini_function_response_parts_round_trip() {
let req = br#"{
"contents": [{
"role": "user",
"parts": [{
"functionResponse": {
"name": "get_image",
"response": {"image_ref": {"$ref": "instrument.jpg"}},
"parts": [
{
"inlineData": {
"mimeType": "image/jpeg",
"displayName": "instrument.jpg",
"data": "aaaa"
}
},
{
"inlineData": {
"mimeType": "application/pdf",
"displayName": "notes.pdf",
"data": "bbbb"
}
}
]
}
}]
}]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let fr = body
.pointer("/contents/0/parts/0/functionResponse")
.unwrap_or_else(|| panic!("missing functionResponse, got {body}"));
assert_eq!(
fr.pointer("/response/image_ref/$ref")
.and_then(Value::as_str),
Some("instrument.jpg"),
"response object must stay, got {body}"
);
let parts = fr["parts"]
.as_array()
.unwrap_or_else(|| panic!("functionResponse.parts dropped, got {body} report {report:?}"));
assert_eq!(parts.len(), 2, "{body}");
assert_eq!(
parts[0].pointer("/inlineData/data").and_then(Value::as_str),
Some("aaaa"),
"{body}"
);
assert_eq!(
parts[0]
.pointer("/inlineData/displayName")
.and_then(Value::as_str),
Some("instrument.jpg"),
"{body}"
);
assert_eq!(
parts[0]
.pointer("/inlineData/mimeType")
.and_then(Value::as_str),
Some("image/jpeg"),
"{body}"
);
assert_eq!(
parts[1].pointer("/inlineData/data").and_then(Value::as_str),
Some("bbbb"),
"{body}"
);
assert_eq!(
parts[1]
.pointer("/inlineData/displayName")
.and_then(Value::as_str),
Some("notes.pdf"),
"{body}"
);
assert!(
!report
.events
.iter()
.any(|event| event.action == LossAction::Drop && event.path.contains("image")),
"image bytes have a functionResponse.parts slot, got {report:?}"
);
}
#[test]
fn gemini_function_response_file_data_round_trip() {
let req = br#"{
"contents": [
{
"role": "model",
"parts": [{
"functionCall": {"id": "call_a", "name": "lookup", "args": {}}
}]
},
{
"role": "user",
"parts": [{
"functionResponse": {
"id": "call_a",
"name": "lookup",
"response": {"ok": true},
"parts": [
{"fileData": {"mimeType": "application/pdf", "fileUri": "files/doc"}},
{"fileData": {"mimeType": "image/png", "fileUri": "files/pic"}}
]
}
}]
}
]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let parts = body
.pointer("/contents/1/parts/0/functionResponse/parts")
.and_then(Value::as_array)
.expect("functionResponse.parts");
assert_eq!(
parts[0]
.pointer("/fileData/fileUri")
.and_then(Value::as_str),
Some("files/doc"),
"PDF fileUri must survive a tool result, got {body}"
);
assert_eq!(
parts[1]
.pointer("/fileData/fileUri")
.and_then(Value::as_str),
Some("files/pic"),
"image fileUri must survive a tool result, got {body}"
);
assert!(
!report
.events
.iter()
.any(|event| event.action == LossAction::Drop && event.path.contains("part")),
"fileData has a functionResponse.parts slot, got {report:?}"
);
}
#[test]
fn gemini_drops_s3_and_blank_file_urls() {
let s3 = IrPart::Document {
source: IrDocumentSource::S3 {
uri: "s3://bucket/doc.pdf".into(),
bucket_owner: None,
},
media_type: "application/pdf".into(),
name: None,
citations_enabled: None,
context: None,
};
let blank = IrPart::Document {
source: IrDocumentSource::Url(" ".into()),
media_type: "application/pdf".into(),
name: None,
citations_enabled: None,
context: None,
};
let content = IrRequest::new(
"gemini-2.0-flash",
vec![IrItem::User {
parts: vec![s3.clone(), blank.clone()],
}],
);
let err = encode(Wire::Gemini, &content, &gemini_profile()).expect_err("content");
let message = err.to_string();
assert!(
message.contains("S3 document has no Gemini slot"),
"content S3 must be recorded, got {message}"
);
assert!(
message.contains("document url is empty"),
"blank content url must be recorded, got {message}"
);
assert!(
!message.contains("s3://bucket/doc.pdf"),
"the S3 URI must not be sent, got {message}"
);
let tool = IrRequest::new(
"gemini-2.0-flash",
vec![IrItem::FunctionOutput {
call_id: "call_a".into(),
output: "{}".into(),
parts: vec![s3, blank],
is_error: false,
}],
);
let (bytes, report) = encode(Wire::Gemini, &tool, &gemini_profile()).expect("tool");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.pointer("/contents/0/parts/0/functionResponse/parts")
.is_none(),
"a tool result must not send S3 or a blank fileUri, got {body}"
);
assert!(
report
.events
.iter()
.any(|event| event.detail.contains("S3 document has no Gemini slot")),
"tool S3 must be recorded, got {report:?}"
);
assert!(
report
.events
.iter()
.any(|event| event.detail.contains("document url is empty")),
"blank tool url must be recorded, got {report:?}"
);
}
#[test]
fn gemini_server_tool_call_round_trip() {
let req = br#"{
"contents": [
{
"role": "model",
"parts": [{
"toolCall": {
"id": "tc_1",
"toolName": "google_search",
"toolType": "GOOGLE_SEARCH_WEB",
"args": {"query": "wiremux"}
}
}]
},
{
"role": "user",
"parts": [{
"toolResponse": {
"id": "tc_1",
"toolType": "GOOGLE_SEARCH_WEB",
"response": {"output": "ok"}
}
}]
}
]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, _) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/contents/0/parts/0/toolCall/args/query")
.and_then(Value::as_str),
Some("wiremux"),
"Gemini toolCall must round-trip, got {body}"
);
assert_eq!(
body.pointer("/contents/0/parts/0/toolCall/toolType")
.and_then(Value::as_str),
Some("GOOGLE_SEARCH_WEB"),
"Gemini toolCall type must round-trip, got {body}"
);
assert_eq!(
body.pointer("/contents/1/parts/0/toolResponse/response/output")
.and_then(Value::as_str),
Some("ok"),
"Gemini toolResponse must round-trip, got {body}"
);
let (_, report) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("encode Chat");
assert!(
report
.events
.iter()
.any(|event| event.action == LossAction::Drop && event.path == "part.raw"),
"Chat must record a drop for Gemini server tool parts, got {report:?}"
);
}
#[test]
#[allow(non_snake_case)]
fn gemini_fileData_encode_messages_does_not_leak() {
let req = br#"{
"model": "gemini-2.5-flash",
"contents": [{
"role": "user",
"parts": [{
"fileData": {
"fileUri": "files/abc",
"mimeType": "application/pdf"
}
}]
}]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
!body.to_string().contains("fileData"),
"Messages encode must not leak Gemini fileData, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/type")
.and_then(Value::as_str),
Some("document"),
"PDF fileData must become Messages document, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/source/file_id")
.and_then(Value::as_str),
Some("files/abc"),
"Messages document must keep file_id, got {body}"
);
assert!(
!report
.events
.iter()
.any(|event| { event.action == LossAction::Drop && event.path.contains("document") }),
"Messages has a document file_id slot, got {report:?}"
);
}
#[test]
#[allow(non_snake_case)]
fn gemini_fileData_encode_responses_does_not_leak() {
let req = br#"{
"model": "gemini-2.5-flash",
"contents": [{
"role": "user",
"parts": [{
"fileData": {
"fileUri": "files/abc",
"mimeType": "application/pdf"
}
}]
}]
}"#;
let (ir, _) = decode(Wire::Gemini, req).expect("decode");
let (bytes, report) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
!body.to_string().contains("fileData"),
"Responses encode must not leak Gemini fileData, got {body}"
);
assert_eq!(
body.pointer("/input/0/content/0/type")
.and_then(Value::as_str),
Some("input_file"),
"PDF fileData must become Responses input_file, got {body}"
);
assert_eq!(
body.pointer("/input/0/content/0/file_id")
.and_then(Value::as_str),
Some("files/abc"),
"Responses input_file must keep file_id, got {body}"
);
assert!(
!report
.events
.iter()
.any(|event| { event.action == LossAction::Drop && event.path.contains("document") }),
"Responses has an input_file file_id slot, got {report:?}"
);
}
#[test]
fn chat_input_audio_maps_to_gemini_inline_data() {
let req = br#"{
"model": "gpt-4o-audio-preview",
"messages": [{
"role": "user",
"content": [{
"type": "input_audio",
"input_audio": {
"data": "AAAA",
"format": "wav"
}
}]
}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
assert!(
ir.items.iter().any(|item| matches!(
item,
IrItem::User { parts } if parts.iter().any(|p| matches!(
p,
IrPart::Audio { data, format } if data == "AAAA" && format == "wav"
))
)),
"input_audio must become IrPart::Audio, got {:?}",
ir.items
);
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/contents/0/parts/0/inlineData/mimeType")
.and_then(Value::as_str),
Some("audio/wav"),
"Gemini must get audio inlineData, got {body}"
);
assert_eq!(
body.pointer("/contents/0/parts/0/inlineData/data")
.and_then(Value::as_str),
Some("AAAA"),
"Gemini must encode audio data, got {body}"
);
assert!(
!report
.events
.iter()
.any(|event| event.path.contains("audio") && event.action == LossAction::Drop),
"Gemini has an audio inlineData slot, got {report:?}"
);
}
#[test]
fn chat_input_audio_records_loss_on_messages() {
let req = br#"{
"model": "gpt-4o-audio-preview",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "hi"},
{
"type": "input_audio",
"input_audio": {
"data": "AAAA",
"format": "wav"
}
}
]
}]
}"#;
let (ir, _) = decode(Wire::ChatCompletions, req).expect("decode");
let (_, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
assert!(
report
.events
.iter()
.any(|event| { event.path.contains("audio") && event.action == LossAction::Drop }),
"Messages has no audio slot and must Drop, got {report:?}"
);
}
#[test]
fn converse_document_round_trips() {
let req = br#"{
"modelId": "amazon.nova-lite-v1:0",
"messages": [{
"role": "user",
"content": [{
"document": {
"format": "pdf",
"name": "report",
"source": { "bytes": "AAAA" }
}
}]
}]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
assert!(
ir.items.iter().any(|item| matches!(
item,
IrItem::User { parts } if parts.iter().any(|p| matches!(
p,
IrPart::Document {
source: IrDocumentSource::Base64(data),
media_type,
name: Some(name),
..
} if data == "AAAA" && media_type == "application/pdf" && name == "report"
))
)),
"Converse document must become IrPart::Document, got {:?}",
ir.items
);
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/document/format")
.and_then(Value::as_str),
Some("pdf"),
"encode Converse must keep document format, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/document/source/bytes")
.and_then(Value::as_str),
Some("AAAA"),
"encode Converse must keep document bytes, got {body}"
);
assert!(
!report
.events
.iter()
.any(|event| { event.path.contains("document") && event.action == LossAction::Drop }),
"Converse has a document slot, got {report:?}"
);
}
#[test]
fn converse_document_name_matches_bedrock_rules() {
let cases = [
("report.pdf", "report pdf", true),
("My Document (1)", "My Document (1)", false),
("a b", "a b", true),
("my_file", "my file", true),
("...", "document", true),
(" ", "document", true),
(" a b ", "a b", true),
];
for (raw, want, degraded) in cases {
let ir = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![IrItem::User {
parts: vec![IrPart::Document {
source: IrDocumentSource::Base64("AAAA".into()),
media_type: "application/pdf".into(),
name: Some(raw.into()),
citations_enabled: None,
context: None,
}],
}],
);
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect(raw);
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/document/name")
.and_then(Value::as_str),
Some(want),
"Bedrock rejects {raw}, got {body}"
);
assert_eq!(
report.events.iter().any(|event| {
event.path == "part.document.name" && event.action == LossAction::Degrade
}),
degraded,
"degrade for {raw}, got {report:?}"
);
}
}
#[test]
fn converse_document_only_message_includes_a_text_prompt() {
let ir = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![IrItem::User {
parts: vec![IrPart::Document {
source: IrDocumentSource::Base64("AAAA".into()),
media_type: "application/pdf".into(),
name: Some("report".into()),
citations_enabled: None,
context: None,
}],
}],
);
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let content = body
.pointer("/messages/0/content")
.and_then(Value::as_array)
.expect("content");
assert!(
content.iter().any(|block| block.get("document").is_some()),
"document must stay, got {body}"
);
assert!(
content.iter().any(|block| block.get("text").is_some()),
"Bedrock rejects a document with no text prompt, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.action == LossAction::Degrade && event.detail.contains("text prompt")
}),
"added prompt must be a degrade, got {report:?}"
);
}
#[test]
fn converse_assistant_document_moves_to_a_user_turn() {
let ir = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![IrItem::Assistant {
parts: vec![
IrPart::Text("I read it.".into()),
IrPart::Document {
source: IrDocumentSource::Base64("AAAA".into()),
media_type: "application/pdf".into(),
name: Some("report".into()),
citations_enabled: None,
context: None,
},
],
phase: None,
}],
);
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let messages = body
.get("messages")
.and_then(Value::as_array)
.expect("messages");
assert_eq!(
messages
.first()
.and_then(|m| m.get("role"))
.and_then(Value::as_str),
Some("assistant"),
"assistant text stays on the assistant turn, got {body}"
);
assert!(
messages.first().is_some_and(|m| {
m.get("content")
.and_then(Value::as_array)
.is_some_and(|blocks| blocks.iter().all(|b| b.get("document").is_none()))
}),
"assistant turn must not carry a document, got {body}"
);
assert!(
messages.iter().any(|m| {
m.get("role").and_then(Value::as_str) == Some("user")
&& m.get("content")
.and_then(Value::as_array)
.is_some_and(|blocks| {
blocks.iter().any(|b| b.get("document").is_some())
&& blocks.iter().any(|b| b.get("text").is_some())
})
}),
"document must move to a user turn with a text prompt, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.action == LossAction::Degrade && event.detail.contains("user turn")
}),
"moved document must be a degrade, got {report:?}"
);
}
#[test]
fn converse_assistant_media_only_joins_the_previous_user_turn() {
for (label, part) in [
(
"image",
IrPart::ImageBase64 {
media_type: "image/png".into(),
data: "AAAA".into(),
},
),
(
"document",
IrPart::Document {
source: IrDocumentSource::Base64("AAAA".into()),
media_type: "application/pdf".into(),
name: Some("report".into()),
citations_enabled: None,
context: None,
},
),
] {
let ir = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![
IrItem::User {
parts: vec![IrPart::Text("Look.".into())],
},
IrItem::Assistant {
parts: vec![part],
phase: None,
},
IrItem::User {
parts: vec![IrPart::Text("Next.".into())],
},
],
);
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect(label);
let body: Value = serde_json::from_slice(&bytes).expect("json");
let messages = body
.get("messages")
.and_then(Value::as_array)
.expect("messages");
let roles: Vec<&str> = messages
.iter()
.filter_map(|message| message.get("role").and_then(Value::as_str))
.collect();
assert!(
roles.windows(2).all(|pair| pair != ["user", "user"]),
"{label} must not leave two user messages in a row, got {body}"
);
let text = body.to_string();
assert!(
text.contains("Look.") && text.contains("Next."),
"{label} must keep both user texts, got {body}"
);
assert!(
!text.contains("See the attached document."),
"{label} must not add a canned prompt when the user turn already has text, got {body}"
);
assert!(
messages.iter().any(|message| {
message.get("role").and_then(Value::as_str) == Some("user")
&& message
.get("content")
.and_then(Value::as_array)
.is_some_and(|blocks| blocks.iter().any(|block| block.get(label).is_some()))
}),
"{label} must stay on a user turn, got {body}"
);
}
}
#[test]
fn converse_document_citations_round_trip() {
let req = br#"{
"messages": [{
"role": "user",
"content": [{
"text": "Read this."
}, {
"document": {
"format": "pdf",
"name": "report",
"source": { "bytes": "AAAA" },
"citations": { "enabled": true },
"context": "section 2"
}
}]
}]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
assert!(
ir.items.iter().any(|item| matches!(
item,
IrItem::User { parts } if parts.iter().any(|p| matches!(
p,
IrPart::Document {
citations_enabled: Some(true),
context: Some(context),
..
} if context == "section 2"
))
)),
"Converse citations and context must be kept, got {:?}",
ir.items
);
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let doc = body
.pointer("/messages/0/content")
.and_then(Value::as_array)
.and_then(|blocks| blocks.iter().find(|b| b.get("document").is_some()))
.and_then(|b| b.get("document"))
.expect("document");
assert_eq!(
doc.pointer("/citations/enabled").and_then(Value::as_bool),
Some(true),
"encode must keep citations.enabled, got {body}"
);
assert_eq!(
doc.get("context").and_then(Value::as_str),
Some("section 2"),
"encode must keep document context, got {body}"
);
}
#[test]
fn converse_request_video_round_trips() {
let req = br#"{
"messages": [{
"role": "user",
"content": [
{"text": "What happens?"},
{"video": {"format": "mp4", "source": {"bytes": "AAAA"}}}
]
}]
}"#;
let (ir, report) = decode(Wire::Converse, req).expect("decode");
assert!(
!report
.events
.iter()
.any(|event| event.action == LossAction::Drop),
"same-wire video must not be dropped on decode, got {report:?}"
);
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/1/video/format")
.and_then(Value::as_str),
Some("mp4"),
"video format must round-trip, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/1/video/source/bytes")
.and_then(Value::as_str),
Some("AAAA"),
"video bytes must round-trip, got {body}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("chat json");
let chat_text = chat.to_string();
assert!(
!chat_text.contains("AAAA"),
"Chat has no video slot, got {chat}"
);
assert!(
chat_report.events.iter().any(|event| {
event.action == LossAction::Drop
&& (event.detail.to_lowercase().contains("video") || event.detail.contains("raw"))
}),
"Chat must record that the video was dropped, got {chat_report:?}"
);
}
#[test]
fn converse_cache_point_round_trips() {
let req = br#"{
"messages": [{
"role": "user",
"content": [
{"text": "Remember this prefix."},
{"cachePoint": {"type": "default"}}
]
}]
}"#;
let (ir, report) = decode(Wire::Converse, req).expect("decode");
assert!(
!report.events.iter().any(|event| {
event.action == LossAction::Drop && event.detail.to_lowercase().contains("cache")
}),
"same-wire cachePoint must not be dropped, got {report:?}"
);
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/1/cachePoint/type")
.and_then(Value::as_str),
Some("default"),
"cachePoint must round-trip, got {body}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("chat json");
let chat_text = chat.to_string();
assert!(
!chat_text.contains("cachePoint"),
"Chat has no cachePoint slot, got {chat}"
);
assert!(
chat_report.events.iter().any(|event| {
event.action == LossAction::Drop && event.detail.to_lowercase().contains("raw")
}),
"Chat must record that the cache point was dropped, got {chat_report:?}"
);
}
#[test]
fn converse_guard_content_round_trips() {
let req = br#"{
"messages": [{
"role": "user",
"content": [
{"text": "Only answer with a list of songs."},
{"guardContent": {"text": {"text": "Create a playlist of heavy metal songs."}}}
]
}]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/1/guardContent/text/text")
.and_then(Value::as_str),
Some("Create a playlist of heavy metal songs."),
"guardContent must round-trip, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/text")
.and_then(Value::as_str),
Some("Only answer with a list of songs."),
"the unguarded text must stay, got {body}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("chat json");
assert!(
!chat.to_string().contains("guardContent"),
"Chat has no guardContent slot, got {chat}"
);
assert!(
chat_report.events.iter().any(|event| {
event.action == LossAction::Drop && event.detail.to_lowercase().contains("raw")
}),
"Chat must record that the guard block was dropped, got {chat_report:?}"
);
}
#[test]
fn converse_system_cache_point_round_trips() {
let req = br#"{
"system": [
{"text": "Be brief."},
{"cachePoint": {"type": "default", "ttl": "5m"}},
{"text": "Use lists."}
],
"messages": [{
"role": "user",
"content": [{"text": "Hi"}]
}]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/system/0/text").and_then(Value::as_str),
Some("Be brief."),
"first system text must stay first, got {body}"
);
assert_eq!(
body.pointer("/system/1/cachePoint/type")
.and_then(Value::as_str),
Some("default"),
"system cachePoint must round-trip, got {body}"
);
assert_eq!(
body.pointer("/system/1/cachePoint/ttl")
.and_then(Value::as_str),
Some("5m"),
"system cachePoint ttl must round-trip, got {body}"
);
assert_eq!(
body.pointer("/system/2/text").and_then(Value::as_str),
Some("Use lists."),
"text after the cache point must stay after it, got {body}"
);
let text_only = br#"{
"system": [{"text": "Be brief."}],
"messages": [{"role": "user", "content": [{"text": "Hi"}]}]
}"#;
let (ir_only, _) = decode(Wire::Converse, text_only).expect("decode text");
let (only_bytes, _) =
encode(Wire::Converse, &ir_only, &converse_profile()).expect("encode text");
let only: Value = serde_json::from_slice(&only_bytes).expect("json text");
assert_eq!(
only.pointer("/system/0/text").and_then(Value::as_str),
Some("Be brief."),
"text-only system must stay text, got {only}"
);
assert!(
only["system"]
.as_array()
.is_some_and(|blocks| blocks.iter().all(|block| block.get("cachePoint").is_none())),
"text-only system must not gain a cachePoint, got {only}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("chat json");
assert!(
!chat.to_string().contains("cachePoint"),
"Chat has no system cachePoint slot, got {chat}"
);
assert!(
chat_report.events.iter().any(|event| {
event.action == LossAction::Drop && event.detail.to_lowercase().contains("cache")
}),
"Chat must record that the system cache point was dropped, got {chat_report:?}"
);
}
#[test]
fn converse_system_guard_content_round_trips() {
let req = br#"{
"system": [
{"text": "Only answer with a list of songs."},
{"guardContent": {"text": {"text": "Create a playlist of heavy metal songs."}}}
],
"messages": [{
"role": "user",
"content": [{"text": "Hi"}]
}]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/system/0/text").and_then(Value::as_str),
Some("Only answer with a list of songs."),
"unguarded system text must stay, got {body}"
);
assert_eq!(
body.pointer("/system/1/guardContent/text/text")
.and_then(Value::as_str),
Some("Create a playlist of heavy metal songs."),
"system guardContent must round-trip, got {body}"
);
let text_only = br#"{
"system": [{"text": "Only answer with a list of songs."}],
"messages": [{"role": "user", "content": [{"text": "Hi"}]}]
}"#;
let (ir_only, _) = decode(Wire::Converse, text_only).expect("decode text");
let (only_bytes, _) =
encode(Wire::Converse, &ir_only, &converse_profile()).expect("encode text");
let only: Value = serde_json::from_slice(&only_bytes).expect("json text");
assert!(
only["system"].as_array().is_some_and(|blocks| blocks
.iter()
.all(|block| block.get("guardContent").is_none())),
"text-only system must not gain guardContent, got {only}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("chat json");
assert!(
!chat.to_string().contains("guardContent"),
"Chat has no system guardContent slot, got {chat}"
);
assert!(
chat_report.events.iter().any(|event| {
event.action == LossAction::Drop && event.detail.to_lowercase().contains("guard")
}),
"Chat must record that the system guard block was dropped, got {chat_report:?}"
);
}
#[test]
fn converse_tool_cache_point_round_trips() {
let req = br#"{
"messages": [{"role": "user", "content": [{"text": "Hi"}]}],
"toolConfig": {
"tools": [
{"toolSpec": {
"name": "lookup",
"description": "Look up",
"inputSchema": {"json": {"type": "object"}}
}},
{"cachePoint": {"type": "default", "ttl": "5m"}},
{"toolSpec": {
"name": "search",
"description": "Search",
"inputSchema": {"json": {"type": "object"}}
}}
]
}
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/toolConfig/tools/0/toolSpec/name")
.and_then(Value::as_str),
Some("lookup"),
"first tool must stay first, got {body}"
);
assert_eq!(
body.pointer("/toolConfig/tools/1/cachePoint/type")
.and_then(Value::as_str),
Some("default"),
"tool cachePoint must round-trip, got {body}"
);
assert_eq!(
body.pointer("/toolConfig/tools/1/cachePoint/ttl")
.and_then(Value::as_str),
Some("5m"),
"tool cachePoint ttl must round-trip, got {body}"
);
assert_eq!(
body.pointer("/toolConfig/tools/2/toolSpec/name")
.and_then(Value::as_str),
Some("search"),
"tool after the cache point must stay after it, got {body}"
);
let specs_only = br#"{
"messages": [{"role": "user", "content": [{"text": "Hi"}]}],
"toolConfig": {
"tools": [{"toolSpec": {
"name": "lookup",
"description": "Look up",
"inputSchema": {"json": {"type": "object"}}
}}]
}
}"#;
let (ir_only, _) = decode(Wire::Converse, specs_only).expect("decode specs");
let (only_bytes, _) =
encode(Wire::Converse, &ir_only, &converse_profile()).expect("encode specs");
let only: Value = serde_json::from_slice(&only_bytes).expect("json specs");
assert!(
only.pointer("/toolConfig/tools")
.and_then(Value::as_array)
.is_some_and(|tools| tools.iter().all(|tool| tool.get("cachePoint").is_none())),
"tool specs must not gain a cachePoint, got {only}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("chat json");
assert!(
!chat.to_string().contains("cachePoint"),
"Chat has no tool cachePoint slot, got {chat}"
);
assert!(
chat_report.events.iter().any(|event| {
event.action == LossAction::Drop && event.detail.to_lowercase().contains("cache")
}),
"Chat must record that the tool cache point was dropped, got {chat_report:?}"
);
}
#[test]
fn converse_request_audio_s3_round_trips() {
let req = br#"{
"messages": [{
"role": "user",
"content": [{
"audio": {
"format": "mp3",
"source": {
"s3Location": {
"uri": "s3://bucket/a.mp3",
"bucketOwner": "111122223333"
}
}
}
}]
}]
}"#;
let (ir, report) = decode(Wire::Converse, req).expect("decode");
assert!(
!report
.events
.iter()
.any(|event| event.path == "part.audio" && event.action == LossAction::Drop),
"same-wire S3 audio must not be dropped, got {report:?}"
);
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/audio/source/s3Location/uri")
.and_then(Value::as_str),
Some("s3://bucket/a.mp3"),
"S3 audio uri must round-trip, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/audio/source/s3Location/bucketOwner")
.and_then(Value::as_str),
Some("111122223333"),
"S3 audio bucket owner must round-trip, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/audio/format")
.and_then(Value::as_str),
Some("mp3"),
"S3 audio format must round-trip, got {body}"
);
}
#[test]
fn converse_document_s3_bucket_owner_round_trips() {
let req = br#"{
"messages": [{
"role": "user",
"content": [
{"text": "Read this."},
{"document": {
"format": "pdf",
"name": "report",
"source": {
"s3Location": {
"uri": "s3://bucket/a.pdf",
"bucketOwner": "111122223333"
}
}
}}
]
}]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/1/document/source/s3Location/uri")
.and_then(Value::as_str),
Some("s3://bucket/a.pdf"),
"document S3 uri must round-trip, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/1/document/source/s3Location/bucketOwner")
.and_then(Value::as_str),
Some("111122223333"),
"document S3 bucketOwner must round-trip, got {body}"
);
let uri_only = br#"{
"messages": [{
"role": "user",
"content": [
{"text": "Read this."},
{"document": {
"format": "pdf",
"name": "report",
"source": { "s3Location": { "uri": "s3://bucket/a.pdf" } }
}}
]
}]
}"#;
let (ir_only, _) = decode(Wire::Converse, uri_only).expect("decode uri");
let (only_bytes, _) =
encode(Wire::Converse, &ir_only, &converse_profile()).expect("encode uri");
let only: Value = serde_json::from_slice(&only_bytes).expect("json uri");
assert_eq!(
only.pointer("/messages/0/content/1/document/source/s3Location/uri")
.and_then(Value::as_str),
Some("s3://bucket/a.pdf"),
"URI-only document must keep its uri, got {only}"
);
assert!(
only.pointer("/messages/0/content/1/document/source/s3Location/bucketOwner")
.is_none(),
"URI-only document must not gain bucketOwner, got {only}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("chat json");
let chat_text = chat.to_string();
assert!(
!chat_text.contains("s3://bucket/a.pdf") && !chat_text.contains("111122223333"),
"Chat has no document S3 slot, got {chat}"
);
assert!(
chat_report
.events
.iter()
.any(|event| { event.path == "part.document" && event.action == LossAction::Drop }),
"Chat must record that the S3 document was dropped, got {chat_report:?}"
);
}
#[test]
fn converse_document_s3_is_not_sent_as_a_foreign_url() {
let req = br#"{
"messages": [{
"role": "user",
"content": [
{"text": "Read this."},
{"document": {
"format": "pdf",
"name": "report",
"source": {
"s3Location": {
"uri": "s3://bucket/a.pdf",
"bucketOwner": "111122223333"
}
}
}}
]
}]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
for (wire, profile, label) in [
(Wire::Responses, hard_error_profile(), "Responses"),
(Wire::Messages, messages_profile(), "Messages"),
(Wire::Gemini, gemini_profile(), "Gemini"),
] {
let (bytes, report) = encode(wire, &ir, &profile).expect(label);
let body: Value = serde_json::from_slice(&bytes).expect("json");
let text = body.to_string();
assert!(
!text.contains("s3://bucket/a.pdf") && !text.contains("111122223333"),
"{label} must not send the S3 document or account id, got {body}"
);
assert!(
text.contains("Read this."),
"{label} must keep the text part, got {body}"
);
assert!(
loss_dropped(&report, "part.document"),
"{label} must record that the S3 document has no slot, got {report:?}"
);
}
}
#[test]
fn converse_image_s3_bucket_owner_round_trips() {
let req = br#"{
"messages": [{
"role": "user",
"content": [{
"image": {
"format": "png",
"source": {
"s3Location": {
"uri": "s3://bucket/a.png",
"bucketOwner": "111122223333"
}
}
}
}]
}]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/image/source/s3Location/uri")
.and_then(Value::as_str),
Some("s3://bucket/a.png"),
"image S3 uri must round-trip, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/image/source/s3Location/bucketOwner")
.and_then(Value::as_str),
Some("111122223333"),
"image S3 bucketOwner must round-trip, got {body}"
);
let uri_only = br#"{
"messages": [{
"role": "user",
"content": [{
"image": {
"format": "png",
"source": { "s3Location": { "uri": "s3://bucket/a.png" } }
}
}]
}]
}"#;
let (ir_only, _) = decode(Wire::Converse, uri_only).expect("decode uri");
let (only_bytes, _) =
encode(Wire::Converse, &ir_only, &converse_profile()).expect("encode uri");
let only: Value = serde_json::from_slice(&only_bytes).expect("json uri");
assert_eq!(
only.pointer("/messages/0/content/0/image/source/s3Location/uri")
.and_then(Value::as_str),
Some("s3://bucket/a.png"),
"URI-only image must keep its uri, got {only}"
);
assert!(
only.pointer("/messages/0/content/0/image/source/s3Location/bucketOwner")
.is_none(),
"URI-only image must not gain bucketOwner, got {only}"
);
let (chat_bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("chat json");
let chat_text = chat.to_string();
assert!(
chat_text.contains("s3://bucket/a.png") && !chat_text.contains("111122223333"),
"Chat keeps the image URL and drops the account id, got {chat}"
);
}
#[test]
fn converse_document_file_id_records_loss() {
let ir = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![IrItem::User {
parts: vec![
IrPart::Text("see attached".into()),
IrPart::Document {
source: IrDocumentSource::FileId("file-abc".into()),
media_type: "application/pdf".into(),
name: Some("report".into()),
citations_enabled: None,
context: None,
},
],
}],
);
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.pointer("/messages/0/content")
.and_then(Value::as_array)
.is_some_and(|blocks| blocks.iter().all(|b| b.get("document").is_none())),
"Converse must not invent a document from file_id, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "part.document"
&& event.action == LossAction::Drop
&& event.detail.contains("file_id")
}),
"file_id Document must record LossReport on Converse, got {report:?}"
);
}
#[test]
fn responses_include_extras_survive_remap() {
let req = br#"{
"model": "gpt-5",
"input": [{"role": "user", "content": "hi"}],
"include": ["file_search_call.results", "reasoning.encrypted_content"]
}"#;
let (ir, _) = decode(Wire::Responses, req).expect("decode");
assert!(
ir.sampling
.include
.iter()
.any(|item| item == "file_search_call.results"),
"decode must keep include extras, got {:?}",
ir.sampling.include
);
let (bytes, _) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let include = body
.get("include")
.and_then(Value::as_array)
.expect("include");
let as_str: Vec<&str> = include.iter().filter_map(Value::as_str).collect();
assert!(
as_str.contains(&"file_search_call.results"),
"encode must keep file_search_call.results, got {body}"
);
assert!(
as_str.contains(&"reasoning.encrypted_content"),
"encode must keep reasoning.encrypted_content, got {body}"
);
}
fn assert_no_include_loss(report: &LossReport) {
assert!(
report
.events
.iter()
.all(|event| event.path != "sampling.include"),
"caller include must not record sampling.include, got {report:?}"
);
}
#[test]
fn responses_encode_omits_unset_include() {
let ir = IrRequest::new(
"gpt-5",
vec![IrItem::User {
parts: vec![IrPart::Text("hi".into())],
}],
);
assert!(
ir.sampling.include.is_empty(),
"default include must be empty"
);
let (bytes, report) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("include").is_none(),
"empty include must omit the field, got {body}"
);
assert_no_include_loss(&report);
}
#[test]
fn responses_encode_writes_caller_encrypted_include() {
let ir = user_ir(IrSampling::patch(|s| {
s.include = vec!["reasoning.encrypted_content".into()];
}));
let (bytes, report) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("include"),
Some(&serde_json::json!(["reasoning.encrypted_content"])),
"asked include must be that one entry, got {body}"
);
assert_no_include_loss(&report);
}
#[test]
fn responses_encode_writes_only_caller_include() {
let ir = user_ir(IrSampling::patch(|s| {
s.include = vec!["file_search_call.results".into()];
}));
let (bytes, report) = encode(Wire::Responses, &ir, &flatten_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("include"),
Some(&serde_json::json!(["file_search_call.results"])),
"other include entries must not gain encrypted reasoning, got {body}"
);
assert_no_include_loss(&report);
}
#[test]
fn sampling_include_drops_off_responses() {
let ir = user_ir(IrSampling::patch(|s| {
s.include = vec!["file_search_call.results".into()];
}));
for (wire, profile) in [
(Wire::ChatCompletions, chat_profile()),
(Wire::Messages, messages_profile()),
(Wire::Gemini, gemini_profile()),
(Wire::Converse, converse_profile()),
] {
let (bytes, report) = encode(wire, &ir, &profile).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("include").is_none(),
"{wire:?} must not write include, got {body}"
);
assert!(
loss_dropped(&report, "sampling.include"),
"{wire:?} include drop missing, got {report:?}"
);
}
}
fn messages_roles(body: &Value) -> Vec<(String, Option<String>)> {
body.get("messages")
.and_then(Value::as_array)
.expect("messages")
.iter()
.map(|msg| {
let role = msg
.get("role")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned();
let text = msg
.pointer("/content/0/text")
.and_then(Value::as_str)
.map(str::to_owned);
(role, text)
})
.collect()
}
#[test]
fn messages_encode_appends_continue_on_assistant_last() {
let ir = IrRequest::new(
"claude-opus-4-6",
vec![
IrItem::User {
parts: vec![IrPart::Text("hi".into())],
},
IrItem::Assistant {
phase: None,
parts: vec![IrPart::Text("hello".into())],
},
],
);
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let roles = messages_roles(&body);
assert_eq!(
roles.last(),
Some(&("user".into(), Some("Continue.".into()))),
"assistant-last must append Continue., got {body}"
);
assert_eq!(roles.len(), 3, "user + assistant + Continue., got {body}");
}
#[test]
fn messages_encode_appends_continue_on_custom_tool_call_last() {
let ir = IrRequest::new(
"claude-opus-4-6",
vec![IrItem::CustomToolCall {
call_id: "call_custom".into(),
name: "lookup".into(),
input: "{}".into(),
responses_item: None,
}],
);
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let roles = messages_roles(&body);
assert_eq!(
roles.last(),
Some(&("user".into(), Some("Continue.".into()))),
"custom-tool-call-last must append Continue., got {body}"
);
assert_eq!(roles.len(), 2, "custom tool call + Continue., got {body}");
}
#[test]
fn messages_encode_appends_continue_on_function_call_last() {
let ir = IrRequest::new(
"claude-opus-4-6",
vec![IrItem::FunctionCall {
call_id: "call_1".into(),
name: "lookup".into(),
arguments: "{}".into(),
thought_signature: None,
}],
);
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let roles = messages_roles(&body);
assert_eq!(
roles.last(),
Some(&("user".into(), Some("Continue.".into()))),
"function-call-last must append Continue., got {body}"
);
}
#[test]
fn messages_encode_skips_continue_when_user_last_or_empty() {
let user_last = IrRequest::new(
"claude-opus-4-6",
vec![IrItem::User {
parts: vec![IrPart::Text("hi".into())],
}],
);
let (bytes, _) = encode(Wire::Messages, &user_last, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let roles = messages_roles(&body);
assert_eq!(
roles,
vec![("user".into(), Some("hi".into()))],
"user-last must stay unchanged, got {body}"
);
let empty = IrRequest::new("claude-opus-4-6", vec![]);
let (bytes, _) = encode(Wire::Messages, &empty, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let messages = body
.get("messages")
.and_then(Value::as_array)
.expect("messages");
assert!(messages.is_empty(), "empty IR must stay empty, got {body}");
}
#[test]
fn other_wires_do_not_append_continue_on_assistant_last() {
let ir = IrRequest::new(
"gpt-4",
vec![
IrItem::User {
parts: vec![IrPart::Text("hi".into())],
},
IrItem::Assistant {
phase: None,
parts: vec![IrPart::Text("hello".into())],
},
],
);
let (chat_bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("json");
let chat_msgs = chat
.get("messages")
.and_then(Value::as_array)
.expect("chat messages");
assert_eq!(
chat_msgs.len(),
2,
"Chat must not append Continue., got {chat}"
);
assert_eq!(
chat_msgs
.last()
.and_then(|m| m.get("role"))
.and_then(Value::as_str),
Some("assistant"),
"Chat last role, got {chat}"
);
let (gemini_bytes, _) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("gemini");
let gemini: Value = serde_json::from_slice(&gemini_bytes).expect("json");
let contents = gemini
.get("contents")
.and_then(Value::as_array)
.expect("gemini contents");
assert_eq!(
contents.len(),
2,
"Gemini must not append Continue., got {gemini}"
);
let (resp_bytes, _) = encode(Wire::Responses, &ir, &flatten_profile()).expect("responses");
let resp: Value = serde_json::from_slice(&resp_bytes).expect("json");
let input = resp
.get("input")
.and_then(Value::as_array)
.expect("responses input");
assert!(
!serde_json::to_string(&input)
.expect("ser")
.contains("Continue."),
"Responses must not append Continue., got {resp}"
);
}
#[test]
fn messages_encode_object_tool_schema_emits_required_array() {
let ir = IrRequest::new(
"grok-4",
vec![IrItem::User {
parts: vec![IrPart::Text("hi".into())],
}],
)
.with_tools(vec![
IrTool::Function {
name: "lookup".into(),
description: "lookup".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
strict: None,
},
IrTool::Function {
name: "null_required".into(),
description: "null required".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {},
"required": null
}),
strict: None,
},
IrTool::Function {
name: "keep".into(),
description: "keep listed required".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {"q": {"type": "string"}},
"required": ["q"]
}),
strict: None,
},
]);
let (bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let tools = body.get("tools").and_then(Value::as_array).expect("tools");
assert_eq!(
tools[0].pointer("/input_schema/required"),
Some(&serde_json::json!([])),
"omitted required becomes [], else Grok Build Messages 400s, got {body}"
);
assert_eq!(
tools[1].pointer("/input_schema/required"),
Some(&serde_json::json!([])),
"null required becomes [], else Grok Build Messages 400s, got {body}"
);
assert_eq!(
tools[2].pointer("/input_schema/required"),
Some(&serde_json::json!(["q"])),
"listed required must stay, got {body}"
);
}
#[test]
fn openai_object_tool_schema_without_properties_is_filled() {
let ir = IrRequest::new(
"gpt-4o",
vec![IrItem::User {
parts: vec![IrPart::Text("hi".into())],
}],
)
.with_tools(vec![
IrTool::Function {
name: "fetch_docs".into(),
description: "fetch".into(),
parameters: serde_json::json!({"type": "object"}),
strict: None,
},
IrTool::Function {
name: "nested".into(),
description: "nested".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {"child": {"type": "object"}}
}),
strict: None,
},
]);
let (chat_bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("json");
assert_eq!(
chat.pointer("/tools/0/function/parameters/properties"),
Some(&serde_json::json!({})),
"OpenAI 400s on an object schema with no properties, got {chat}"
);
assert_eq!(
chat.pointer("/tools/1/function/parameters/properties/child/properties"),
Some(&serde_json::json!({})),
"nested object schema must gain properties, got {chat}"
);
let (resp_bytes, _) = encode(Wire::Responses, &ir, &hard_error_profile()).expect("responses");
let resp: Value = serde_json::from_slice(&resp_bytes).expect("json");
assert_eq!(
resp.pointer("/tools/0/parameters/properties"),
Some(&serde_json::json!({})),
"Responses 400s on an object schema with no properties, got {resp}"
);
}
#[test]
fn strict_openai_tool_lists_properties_and_closes_the_object() {
let strict_tool = IrTool::Function {
name: "lookup".into(),
description: "lookup".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {"q": {"type": "string"}}
}),
strict: Some(true),
};
let loose_tool = IrTool::Function {
name: "lookup".into(),
description: "lookup".into(),
parameters: serde_json::json!({
"type": "object",
"properties": {"q": {"type": "string"}}
}),
strict: None,
};
let user = vec![IrItem::User {
parts: vec![IrPart::Text("hi".into())],
}];
let strict_ir = IrRequest::new("gpt-4o", user.clone()).with_tools(vec![strict_tool]);
let (chat_bytes, _) = encode(Wire::ChatCompletions, &strict_ir, &chat_profile()).expect("chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("json");
let params = chat
.pointer("/tools/0/function/parameters")
.expect("parameters");
assert_eq!(
params.get("additionalProperties"),
Some(&serde_json::json!(false)),
"{params}"
);
assert_eq!(
params.get("required"),
Some(&serde_json::json!(["q"])),
"{params}"
);
assert_eq!(
chat.pointer("/tools/0/function/strict"),
Some(&serde_json::json!(true))
);
let loose_ir = IrRequest::new("gpt-4o", user).with_tools(vec![loose_tool]);
let (loose_bytes, _) =
encode(Wire::ChatCompletions, &loose_ir, &chat_profile()).expect("loose");
let loose: Value = serde_json::from_slice(&loose_bytes).expect("json");
let loose_params = loose
.pointer("/tools/0/function/parameters")
.expect("parameters");
assert!(
loose_params.get("additionalProperties").is_none(),
"{loose_params}"
);
assert_eq!(
loose_params.get("required"),
Some(&serde_json::json!([])),
"{loose_params}"
);
let (resp_bytes, _) =
encode(Wire::Responses, &strict_ir, &hard_error_profile()).expect("responses");
let resp: Value = serde_json::from_slice(&resp_bytes).expect("json");
assert_eq!(
resp.pointer("/tools/0/parameters/additionalProperties"),
Some(&serde_json::json!(false)),
"{resp}"
);
assert_eq!(
resp.pointer("/tools/0/parameters/required"),
Some(&serde_json::json!(["q"])),
"{resp}"
);
}
fn assert_object_closed(schema: &Value) {
assert_eq!(
schema.get("additionalProperties"),
Some(&serde_json::json!(false)),
"{schema}"
);
let props = schema
.get("properties")
.and_then(Value::as_object)
.expect("properties");
let required = schema
.get("required")
.and_then(Value::as_array)
.expect("required");
for key in props.keys() {
assert!(
required.iter().any(|value| value.as_str() == Some(key)),
"required must list {key}, got {schema}"
);
}
}
fn strict_tool(parameters: serde_json::Value) -> IrRequest {
IrRequest::new(
"gpt-4o",
vec![IrItem::User {
parts: vec![IrPart::Text("hi".into())],
}],
)
.with_tools(vec![IrTool::Function {
name: "lookup".into(),
description: "lookup".into(),
parameters,
strict: Some(true),
}])
}
#[test]
fn strict_schema_closes_defs_combinators_and_tuple_items() {
let defs = strict_tool(serde_json::json!({
"type": "object",
"properties": { "child": { "$ref": "#/$defs/Child" } },
"$defs": {
"Child": {
"type": "object",
"properties": { "n": { "type": "integer" } }
}
}
}));
let (chat_bytes, _) = encode(Wire::ChatCompletions, &defs, &chat_profile()).expect("chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("json");
let params = chat
.pointer("/tools/0/function/parameters")
.expect("parameters");
assert_object_closed(params);
assert_object_closed(params.pointer("/$defs/Child").expect("child"));
let (resp_bytes, _) = encode(Wire::Responses, &defs, &hard_error_profile()).expect("responses");
let resp: Value = serde_json::from_slice(&resp_bytes).expect("json");
let resp_schema = resp.pointer("/tools/0/parameters").expect("parameters");
assert_object_closed(resp_schema);
assert_object_closed(resp_schema.pointer("/$defs/Child").expect("child"));
let any_of = strict_tool(serde_json::json!({
"anyOf": [{
"type": "object",
"properties": { "a": { "type": "string" } }
}]
}));
let (any_bytes, _) = encode(Wire::ChatCompletions, &any_of, &chat_profile()).expect("anyOf");
let any: Value = serde_json::from_slice(&any_bytes).expect("json");
assert_object_closed(
any.pointer("/tools/0/function/parameters/anyOf/0")
.expect("branch"),
);
let tuple = strict_tool(serde_json::json!({
"type": "object",
"properties": {
"pair": {
"type": "array",
"items": [{
"type": "object",
"properties": { "n": { "type": "integer" } }
}]
}
}
}));
let (tuple_bytes, _) = encode(Wire::ChatCompletions, &tuple, &chat_profile()).expect("tuple");
let tuple_body: Value = serde_json::from_slice(&tuple_bytes).expect("json");
assert_object_closed(
tuple_body
.pointer("/tools/0/function/parameters/properties/pair/items/0")
.expect("tuple item"),
);
}
#[test]
fn strict_messages_and_converse_close_the_object() {
let nested = serde_json::json!({
"type": "object",
"properties": {
"child": {
"type": "object",
"properties": { "n": { "type": "integer" } }
}
}
});
let ir = IrRequest::new(
"claude",
vec![IrItem::User {
parts: vec![IrPart::Text("hi".into())],
}],
)
.with_tools(vec![IrTool::Function {
name: "lookup".into(),
description: "lookup".into(),
parameters: nested,
strict: Some(true),
}]);
let (msg_bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("messages");
let msg: Value = serde_json::from_slice(&msg_bytes).expect("json");
let schema = msg.pointer("/tools/0/input_schema").expect("schema");
assert_object_closed(schema);
assert_object_closed(schema.pointer("/properties/child").expect("child"));
let bare = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![IrItem::User {
parts: vec![IrPart::Text("hi".into())],
}],
)
.with_tools(vec![IrTool::Function {
name: "lookup".into(),
description: "lookup".into(),
parameters: serde_json::json!({"type": "object"}),
strict: None,
}]);
let (bare_bytes, _) = encode(Wire::Converse, &bare, &converse_profile()).expect("bare");
let bare_body: Value = serde_json::from_slice(&bare_bytes).expect("json");
assert_eq!(
bare_body.pointer("/toolConfig/tools/0/toolSpec/inputSchema/json/properties"),
Some(&serde_json::json!({})),
"bare object must gain properties, got {bare_body}"
);
let (closed_bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("closed");
let closed: Value = serde_json::from_slice(&closed_bytes).expect("json");
let converse_schema = closed
.pointer("/toolConfig/tools/0/toolSpec/inputSchema/json")
.expect("schema");
assert_object_closed(converse_schema);
assert_object_closed(converse_schema.pointer("/properties/child").expect("child"));
}
#[test]
fn gemini_strict_lists_required_and_drops_additional_properties() {
let ir = IrRequest::new(
"gemini-2.5-flash",
vec![IrItem::User {
parts: vec![IrPart::Text("hi".into())],
}],
)
.with_tools(vec![IrTool::Function {
name: "lookup".into(),
description: "lookup".into(),
parameters: serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {
"child": {
"type": "object",
"additionalProperties": false,
"properties": { "n": { "type": "integer" } }
}
}
}),
strict: Some(true),
}]);
let (bytes, report) = encode(Wire::Gemini, &ir, &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let params = body
.pointer("/tools/0/functionDeclarations/0/parameters")
.expect("parameters");
assert!(params.get("additionalProperties").is_none(), "{params}");
assert_eq!(
params.get("required"),
Some(&serde_json::json!(["child"])),
"{params}"
);
let child = params.pointer("/properties/child").expect("child");
assert!(child.get("additionalProperties").is_none(), "{child}");
assert_eq!(
child.get("required"),
Some(&serde_json::json!(["n"])),
"{child}"
);
assert!(
report.events.iter().any(|event| {
event.action == LossAction::Drop
&& event
.detail
.contains("additionalProperties has no Gemini slot")
}),
"{report:?}"
);
assert!(
report.events.iter().any(|event| {
event.action == LossAction::Drop && event.detail.contains("strict additionalProperties")
}),
"{report:?}"
);
}
#[test]
fn non_array_required_becomes_empty_array() {
for required in [serde_json::json!("q"), serde_json::json!({"bad": true})] {
let req = serde_json::json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{
"type": "function",
"function": {
"name": "lookup",
"description": "lookup",
"parameters": {
"type": "object",
"properties": {"q": {"type": "string"}},
"required": required
}
}
}]
});
let bytes = serde_json::to_vec(&req).expect("ser");
let (ir, _) = decode(Wire::ChatCompletions, &bytes).expect("decode");
let (msg_bytes, _) = encode(Wire::Messages, &ir, &messages_profile()).expect("messages");
let msg: Value = serde_json::from_slice(&msg_bytes).expect("json");
assert_eq!(
msg.pointer("/tools/0/input_schema/required"),
Some(&serde_json::json!([])),
"string or object required becomes [] on Messages, got {msg}"
);
let (chat_bytes, _) = encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("chat");
let chat: Value = serde_json::from_slice(&chat_bytes).expect("json");
assert_eq!(
chat.pointer("/tools/0/function/parameters/required"),
Some(&serde_json::json!([])),
"string or object required becomes [] on Chat, got {chat}"
);
}
}
fn converse_profile() -> ResolvedProfile {
profile(
r#"
schema_version = 1
id = "amazon-bedrock"
wire = "converse"
aws_service = "bedrock"
aws_region = "us-east-1"
"#,
)
}
#[test]
fn converse_encode_does_not_invent_empty_function_call_args() {
let ir = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![IrItem::FunctionCall {
call_id: "t1".into(),
name: "lookup".into(),
arguments: "not-json".into(),
thought_signature: None,
}],
);
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let input = body.pointer("/messages/0/content/0/toolUse/input");
assert_ne!(
input,
Some(&serde_json::json!({})),
"invalid JSON must not become empty object, got {body}"
);
assert_eq!(
input,
Some(&serde_json::json!({ "raw": "not-json" })),
"invalid JSON must stay visible inside an object, got {body}"
);
assert!(
loss_degraded(&report, "toolUse.input"),
"non-object tool arguments must Degrade, got {report:?}"
);
}
#[test]
fn converse_tool_use_non_object_json_input_round_trips() {
let req = br#"{
"messages": [{"role": "assistant", "content": [
{"toolUse": {"toolUseId": "t1", "name": "lookup", "input": ["a", "b"]}},
{"toolUse": {"toolUseId": "t2", "name": "label", "input": "paris"}},
{"toolUse": {"toolUseId": "t3", "name": "count", "input": 3}}
]}]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/toolUse/input"),
Some(&serde_json::json!(["a", "b"])),
"array tool input must round-trip, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/1/toolUse/input"),
Some(&serde_json::json!("paris")),
"string tool input must round-trip, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/2/toolUse/input"),
Some(&serde_json::json!(3)),
"number tool input must round-trip, got {body}"
);
assert!(
!loss_degraded(&report, "toolUse.input"),
"JSON tool input must not be wrapped, got {report:?}"
);
}
#[test]
fn converse_custom_tool_json_array_input_is_not_wrapped() {
let ir = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![IrItem::CustomToolCall {
call_id: "c1".into(),
name: "lookup".into(),
input: "[1,2]".into(),
responses_item: None,
}],
);
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/toolUse/input"),
Some(&serde_json::json!([1, 2])),
"JSON array custom input must stay an array, got {body}"
);
assert!(
!loss_degraded(&report, "toolUse.input"),
"JSON custom input must not be wrapped, got {report:?}"
);
let text = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![IrItem::CustomToolCall {
call_id: "c2".into(),
name: "lookup".into(),
input: "print(1)".into(),
responses_item: None,
}],
);
let (text_bytes, text_report) =
encode(Wire::Converse, &text, &converse_profile()).expect("encode text");
let text_body: Value = serde_json::from_slice(&text_bytes).expect("json text");
assert_eq!(
text_body.pointer("/messages/0/content/0/toolUse/input"),
Some(&serde_json::json!({ "raw": "print(1)" })),
"non-JSON custom input must stay visible, got {text_body}"
);
assert!(
loss_degraded(&text_report, "toolUse.input"),
"non-JSON custom input must Degrade, got {text_report:?}"
);
}
#[test]
fn converse_mixed_assistant_text_then_tool_use_round_trips_one_message() {
let req = br#"{
"modelId": "amazon.nova-lite-v1:0",
"messages": [
{"role": "assistant", "content": [
{"text": "I'll look that up."},
{"toolUse": {"toolUseId": "t1", "name": "lookup", "input": {"q": "x"}}}
]}
]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode mixed assistant");
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let messages = body["messages"]
.as_array()
.expect("messages must be an array");
assert_eq!(
messages.len(),
1,
"re-encode must stay one assistant message, got {body}"
);
assert_eq!(messages[0]["role"], "assistant");
let content = messages[0]["content"]
.as_array()
.expect("content must be an array");
assert_eq!(content.len(), 2, "expected text then toolUse, got {body}");
assert_eq!(content[0]["text"], "I'll look that up.");
assert_eq!(content[1]["toolUse"]["toolUseId"], "t1");
assert_eq!(content[1]["toolUse"]["name"], "lookup");
}
#[test]
fn converse_round_trip_text_and_tool() {
let req = br#"{
"modelId": "amazon.nova-lite-v1:0",
"system": [{"text": "sys"}],
"messages": [
{"role": "user", "content": [{"text": "hi"}]},
{"role": "assistant", "content": [{"toolUse": {"toolUseId": "t1", "name": "lookup", "input": {"q": "x"}}}]},
{"role": "user", "content": [{"toolResult": {"toolUseId": "t1", "content": [{"text": "ok"}]}}]}
],
"inferenceConfig": {"maxTokens": 32, "temperature": 0.2},
"toolConfig": {
"tools": [{"toolSpec": {"name": "lookup", "description": "d", "inputSchema": {"json": {"type": "object"}}}}],
"toolChoice": {"auto": {}}
}
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode converse");
assert_eq!(ir.model, "amazon.nova-lite-v1:0");
assert!(matches!(&ir.items[0], IrItem::System { text, .. } if text == "sys"));
assert_eq!(ir.sampling.max_tokens, Some(32));
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("modelId").is_none(),
"model stays in chat_path, not the JSON body: {body}"
);
assert_eq!(body["messages"][0]["role"], "user");
assert_eq!(
body["messages"][1]["content"][0]["toolUse"]["name"],
"lookup"
);
assert_eq!(body["inferenceConfig"]["maxTokens"], 32);
assert!(body["toolConfig"]["tools"][0]["toolSpec"]["name"] == "lookup");
}
#[test]
fn converse_encode_empty_messages_fails() {
let ir = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![IrItem::System {
text: "sys".into(),
prompt_cache_breakpoint: None,
content_parts: vec![],
}],
);
let err = encode(Wire::Converse, &ir, &converse_profile())
.expect_err("Converse encode must fail when messages would be empty");
let msg = err.to_string();
assert!(msg.contains("messages") && msg.contains("empty"), "{msg}");
}
#[test]
fn converse_system_only_and_empty_request_are_rejected() {
let system_only = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![IrItem::System {
text: "rules".into(),
prompt_cache_breakpoint: None,
content_parts: vec![],
}],
);
let err = encode(Wire::Converse, &system_only, &converse_profile())
.expect_err("system-only Converse must fail");
let shown = err.to_string();
assert!(
matches!(err, MapError::Invalid(_)),
"expected MapError::Invalid, got {shown}"
);
assert!(
shown.contains("converse messages must not be empty"),
"{shown}"
);
let empty = IrRequest::new("amazon.nova-lite-v1:0", vec![]);
let err = encode(Wire::Converse, &empty, &converse_profile())
.expect_err("empty Converse request must fail");
let shown = err.to_string();
assert!(
matches!(err, MapError::Invalid(_)),
"expected MapError::Invalid, got {shown}"
);
assert!(
shown.contains("converse messages must not be empty"),
"{shown}"
);
let user = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![IrItem::User {
parts: vec![IrPart::Text("hi".into())],
}],
);
let (bytes, _) = encode(Wire::Converse, &user, &converse_profile()).expect("encode user");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let messages = body
.get("messages")
.and_then(Value::as_array)
.expect("messages");
assert_eq!(messages.len(), 1, "one user message, got {body}");
}
#[test]
fn converse_tool_result_document_and_video_round_trip() {
let req = br#"{
"messages": [{
"role": "user",
"content": [{
"toolResult": {
"toolUseId": "t1",
"content": [
{"document": {
"format": "pdf",
"name": "report",
"source": {"bytes": "AAAA"}
}},
{"video": {
"format": "mp4",
"source": {"bytes": "BBBB"}
}}
]
}
}]
}]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let content = body
.pointer("/messages/0/content/0/toolResult/content")
.and_then(Value::as_array)
.expect("tool result content");
assert!(
content.iter().any(|block| {
block
.pointer("/document/source/bytes")
.and_then(Value::as_str)
== Some("AAAA")
&& block.pointer("/document/name").and_then(Value::as_str) == Some("report")
}),
"tool result document must round-trip, got {body}"
);
assert!(
content.iter().any(|block| {
block.pointer("/video/source/bytes").and_then(Value::as_str) == Some("BBBB")
&& block.pointer("/video/format").and_then(Value::as_str) == Some("mp4")
}),
"tool result video must round-trip, got {body}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("chat");
let chat_text = serde_json::from_slice::<Value>(&chat_bytes)
.expect("chat json")
.to_string();
assert!(
!chat_text.contains("AAAA") && !chat_text.contains("BBBB"),
"Chat has no tool result document or video slot, got {chat_text}"
);
assert!(
chat_report
.events
.iter()
.any(|event| event.action == LossAction::Drop),
"Chat must record that the tool result media was dropped, got {chat_report:?}"
);
}
#[test]
fn converse_tool_result_search_result_round_trips() {
let req = br#"{
"messages": [{
"role": "user",
"content": [{
"toolResult": {
"toolUseId": "t1",
"content": [{
"searchResult": {
"source": "https://example.com/a",
"title": "Example",
"content": [{"text": "A short snippet."}]
}
}]
}
}]
}]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/toolResult/content/0/searchResult/source")
.and_then(Value::as_str),
Some("https://example.com/a"),
"search result source must round-trip, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/toolResult/content/0/searchResult/title")
.and_then(Value::as_str),
Some("Example"),
"search result title must round-trip, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/toolResult/content/0/searchResult/content/0/text")
.and_then(Value::as_str),
Some("A short snippet."),
"search result snippet must round-trip, got {body}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("chat");
let chat_text = serde_json::from_slice::<Value>(&chat_bytes)
.expect("chat json")
.to_string();
assert!(
!chat_text.contains("A short snippet.") && !chat_text.contains("https://example.com/a"),
"Chat has no search result slot, got {chat_text}"
);
assert!(
chat_report.events.iter().any(|event| {
event.action == LossAction::Drop && event.detail.to_lowercase().contains("search")
}),
"Chat must record that the search result was dropped, got {chat_report:?}"
);
}
#[test]
fn converse_message_search_result_round_trips() {
let req = br#"{
"messages": [{
"role": "user",
"content": [
{"text": "See this."},
{"searchResult": {
"source": "https://example.com/a",
"title": "Example",
"content": [{"text": "A short snippet."}]
}}
]
}]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/text")
.and_then(Value::as_str),
Some("See this."),
"message text must stay, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/1/searchResult/source")
.and_then(Value::as_str),
Some("https://example.com/a"),
"message searchResult source must round-trip, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/1/searchResult/title")
.and_then(Value::as_str),
Some("Example"),
"message searchResult title must round-trip, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/1/searchResult/content/0/text")
.and_then(Value::as_str),
Some("A short snippet."),
"message searchResult snippet must round-trip, got {body}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("chat");
let chat_text = serde_json::from_slice::<Value>(&chat_bytes)
.expect("chat json")
.to_string();
assert!(
!chat_text.contains("A short snippet.") && !chat_text.contains("https://example.com/a"),
"Chat has no message searchResult slot, got {chat_text}"
);
assert!(
chat_report.events.iter().any(|event| {
event.action == LossAction::Drop && event.detail.to_lowercase().contains("raw")
}),
"Chat must record that the search result was dropped, got {chat_report:?}"
);
}
#[test]
fn converse_citations_content_round_trips() {
let req = br#"{
"messages": [
{"role": "user", "content": [{"text": "Where?"}]},
{"role": "assistant", "content": [{
"citationsContent": {
"content": [{"text": "Paris is the capital."}],
"citations": [{"title": "Example", "source": "https://example.com/a"}]
}
}]}
]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/1/content/0/citationsContent/content/0/text")
.and_then(Value::as_str),
Some("Paris is the capital."),
"cited text must round-trip, got {body}"
);
assert_eq!(
body.pointer("/messages/1/content/0/citationsContent/citations/0/source")
.and_then(Value::as_str),
Some("https://example.com/a"),
"citation source must round-trip, got {body}"
);
let plain = br#"{
"messages": [
{"role": "user", "content": [{"text": "Where?"}]},
{"role": "assistant", "content": [{"text": "Paris."}]}
]
}"#;
let (ir_plain, _) = decode(Wire::Converse, plain).expect("decode plain");
let (plain_bytes, _) =
encode(Wire::Converse, &ir_plain, &converse_profile()).expect("encode plain");
let plain_body: Value = serde_json::from_slice(&plain_bytes).expect("json plain");
assert!(
!plain_body.to_string().contains("citationsContent"),
"a plain assistant message must not gain citationsContent, got {plain_body}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("chat");
let chat_text = serde_json::from_slice::<Value>(&chat_bytes)
.expect("chat json")
.to_string();
assert!(
!chat_text.contains("Paris is the capital.")
&& !chat_text.contains("https://example.com/a"),
"Chat has no citationsContent slot, got {chat_text}"
);
assert!(
chat_report.events.iter().any(|event| {
event.action == LossAction::Drop && event.detail.to_lowercase().contains("raw")
}),
"Chat must record that the citations block was dropped, got {chat_report:?}"
);
}
#[test]
fn converse_system_role_tool_add_and_remove_round_trip() {
let req = br#"{
"messages": [
{"role": "user", "content": [{"text": "hi"}]},
{"role": "system", "content": [
{"toolAddition": {"tool": {"toolSpec": {"name": "lookup", "inputSchema": {"json": {"type": "object"}}}}}},
{"toolRemoval": {"toolName": "old"}}
]}
]
}"#;
let (ir, report) = decode(Wire::Converse, req).expect("decode");
assert!(
matches!(
ir.items.as_slice(),
[
IrItem::User { .. },
IrItem::Unknown { type_name: add, .. },
IrItem::Unknown { type_name: remove, .. },
] if add == "toolAddition" && remove == "toolRemoval"
),
"system-role tool changes must stay in order, got {:?}",
ir.items
);
assert!(
!report.events.iter().any(|event| {
event.path == "message.role" && event.detail.contains("not user or assistant")
}),
"a tool add or remove is not an unknown role, got {report:?}"
);
let (bytes, encode_report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/1/role").and_then(Value::as_str),
Some("system"),
"tool add stays on a system-role message, got {body}"
);
assert_eq!(
body.pointer("/messages/1/content/0/toolAddition/tool/toolSpec/name")
.and_then(Value::as_str),
Some("lookup"),
"added tool name must round-trip, got {body}"
);
assert_eq!(
body.pointer("/messages/2/role").and_then(Value::as_str),
Some("system"),
"tool remove stays on a system-role message, got {body}"
);
assert_eq!(
body.pointer("/messages/2/content/0/toolRemoval/toolName")
.and_then(Value::as_str),
Some("old"),
"removed tool name must round-trip, got {body}"
);
let system_text = body
.get("system")
.map(ToString::to_string)
.unwrap_or_default();
assert!(
!system_text.contains("lookup") && !system_text.contains("toolAddition"),
"tool add is not a top-level system prompt, got {body}"
);
assert!(
!encode_report
.events
.iter()
.any(|event| { event.action == LossAction::Drop && event.detail.contains("no slot") }),
"same-wire encode must keep the tool change, got {encode_report:?}"
);
let plain = br#"{
"messages": [
{"role": "user", "content": [{"text": "hi"}]},
{"role": "assistant", "content": [{"text": "ok"}]}
]
}"#;
let (ir_plain, _) = decode(Wire::Converse, plain).expect("decode plain");
let (plain_bytes, _) =
encode(Wire::Converse, &ir_plain, &converse_profile()).expect("encode plain");
let plain_body: Value = serde_json::from_slice(&plain_bytes).expect("json plain");
assert!(
!plain_body.to_string().contains("toolAddition")
&& !plain_body.to_string().contains("toolRemoval"),
"a user and assistant turn must not gain a tool change, got {plain_body}"
);
let bogus = br#"{
"messages": [
{"role": "system", "content": [{"toolAddition": "lookup"}]},
{"role": "user", "content": [{"text": "hi"}]}
]
}"#;
let (ir_bogus, bogus_report) = decode(Wire::Converse, bogus).expect("decode string");
assert!(
bogus_report.events.iter().any(|event| {
event.path == "message.role"
&& event.detail.contains("system")
&& event.detail.contains("not user or assistant")
}),
"a string toolAddition is not a tool change, got {bogus_report:?}"
);
assert!(
!ir_bogus.items.iter().any(
|item| matches!(item, IrItem::Unknown { type_name, .. } if type_name == "toolAddition")
),
"a string toolAddition must not be kept, got {:?}",
ir_bogus.items
);
for (wire, profile, label) in [
(Wire::ChatCompletions, chat_profile(), "Chat"),
(Wire::Messages, messages_profile(), "Messages"),
(Wire::Responses, hard_error_profile(), "Responses"),
(Wire::Gemini, gemini_profile(), "Gemini"),
] {
let (dest_bytes, dest_report) = encode(wire, &ir, &profile).expect(label);
let dest_text = serde_json::from_slice::<Value>(&dest_bytes)
.expect("dest json")
.to_string();
assert!(
!dest_text.contains("lookup")
&& !dest_text.contains("toolAddition")
&& !dest_text.contains("toolRemoval"),
"{label} must not send the tool change, got {dest_text}"
);
assert!(
dest_report
.events
.iter()
.any(|event| event.action == LossAction::Drop),
"{label} must record that the tool change was dropped, got {dest_report:?}"
);
}
}
#[test]
fn converse_decode_tool_use_missing_id_fails() {
let req = br#"{
"messages": [
{"role": "assistant", "content": [{"toolUse": {"name": "lookup", "input": {}}}]}
]
}"#;
let err = decode(Wire::Converse, req).expect_err("toolUse without toolUseId");
let msg = err.to_string();
assert!(msg.contains("toolUse omitted toolUseId"), "{msg}");
}
#[test]
fn converse_decode_tool_result_missing_id_fails() {
let req = br#"{
"messages": [
{"role": "user", "content": [{"toolResult": {"content": [{"text": "ok"}]}}]}
]
}"#;
let err = decode(Wire::Converse, req).expect_err("toolResult without toolUseId");
let msg = err.to_string();
assert!(msg.contains("toolResult omitted toolUseId"), "{msg}");
}
#[test]
fn converse_function_output_is_error_sets_tool_result_status() {
let ir = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![IrItem::FunctionOutput {
call_id: "t1".into(),
output: "no".into(),
parts: Vec::new(),
is_error: true,
}],
);
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/toolResult/status"),
Some(&serde_json::json!("error")),
"failed tool must set toolResult.status, got {body}"
);
assert!(
!report
.events
.iter()
.any(|event| { event.action == LossAction::Drop && event.path.contains("is_error") }),
"is_error has a Converse status slot, got {report:?}"
);
let ok = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![IrItem::FunctionOutput {
call_id: "t2".into(),
output: "ok".into(),
parts: Vec::new(),
is_error: false,
}],
);
let (bytes, report) = encode(Wire::Converse, &ok, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.pointer("/messages/0/content/0/toolResult/status")
.is_none(),
"a successful tool omits status, got {body}"
);
assert!(
!report
.events
.iter()
.any(|event| { event.action == LossAction::Drop && event.path.contains("is_error") }),
"a successful tool must not Drop is_error, got {report:?}"
);
}
#[test]
fn converse_tool_result_status_error_decodes_is_error() {
let error_status = br#"{
"messages": [
{"role": "user", "content": [{
"toolResult": {
"toolUseId": "t1",
"content": [{"text": "no"}],
"status": "error"
}
}]}
]
}"#;
let (ir, _) = decode(Wire::Converse, error_status).expect("decode error status");
assert!(
matches!(
ir.items.as_slice(),
[IrItem::FunctionOutput {
call_id,
is_error: true,
..
}] if call_id == "t1"
),
"status error must set is_error, got {:?}",
ir.items
);
let padded = br#"{
"messages": [
{"role": "user", "content": [{
"toolResult": {
"toolUseId": "t2",
"content": [{"text": "no"}],
"status": " ERROR "
}
}]}
]
}"#;
let (ir, _) = decode(Wire::Converse, padded).expect("decode padded status");
assert!(
matches!(
ir.items.as_slice(),
[IrItem::FunctionOutput { is_error: true, .. }]
),
"trimmed status ERROR must set is_error, got {:?}",
ir.items
);
let absent = br#"{
"messages": [
{"role": "user", "content": [{
"toolResult": {"toolUseId": "t3", "content": [{"text": "ok"}]}
}]}
]
}"#;
let (ir, _) = decode(Wire::Converse, absent).expect("decode missing status");
assert!(
matches!(
ir.items.as_slice(),
[IrItem::FunctionOutput {
is_error: false,
..
}]
),
"missing status is success, got {:?}",
ir.items
);
let success = br#"{
"messages": [
{"role": "user", "content": [{
"toolResult": {
"toolUseId": "t4",
"content": [{"text": "ok"}],
"status": "success"
}
}]}
]
}"#;
let (ir, _) = decode(Wire::Converse, success).expect("decode success status");
assert!(
matches!(
ir.items.as_slice(),
[IrItem::FunctionOutput {
is_error: false,
..
}]
),
"status success must leave is_error false, got {:?}",
ir.items
);
}
#[test]
fn converse_unknown_message_role_records_a_drop() {
let req = br#"{
"messages": [
{"role": "system", "content": [{"text": "hidden"}]},
{"role": "user", "content": [{"text": "hi"}]},
{"role": "assistant", "content": [{"text": "hello"}]}
]
}"#;
let (ir, report) = decode(Wire::Converse, req).expect("decode");
let drops: Vec<_> = report
.events
.iter()
.filter(|event| event.path == "message.role" && event.action == LossAction::Drop)
.collect();
assert_eq!(
drops.len(),
1,
"only the system message role is a drop, got {report:?}"
);
assert!(
drops[0].detail.contains("system") && drops[0].detail.contains("not user or assistant"),
"the drop must name the role, got {}",
drops[0].detail
);
assert!(
!ir.items
.iter()
.any(|item| matches!(item, IrItem::System { text, .. } if text == "hidden")),
"a message role of system is not the top-level system field, got {:?}",
ir.items
);
assert!(
matches!(
ir.items.as_slice(),
[
IrItem::User { parts: user },
IrItem::Assistant { parts: assistant, ..}
] if matches!(user.as_slice(), [IrPart::Text(text)] if text == "hi")
&& matches!(assistant.as_slice(), [IrPart::Text(text)] if text == "hello")
),
"user and assistant messages must stay, got {:?}",
ir.items
);
let user_only = br#"{
"messages": [
{"role": "user", "content": [{"text": "hi"}]}
]
}"#;
let (ir, report) = decode(Wire::Converse, user_only).expect("decode user");
assert!(
!report
.events
.iter()
.any(|event| event.path == "message.role"),
"a user message must not record a role drop, got {report:?}"
);
assert!(
matches!(
ir.items.as_slice(),
[IrItem::User { parts }] if matches!(parts.as_slice(), [IrPart::Text(text)] if text == "hi")
),
"user text must decode, got {:?}",
ir.items
);
}
#[test]
fn converse_parallel_function_outputs_encode_one_user_message() {
let ir = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![
IrItem::FunctionOutput {
call_id: "t1".into(),
output: "one".into(),
parts: Vec::new(),
is_error: false,
},
IrItem::FunctionOutput {
call_id: "t2".into(),
output: "two".into(),
parts: Vec::new(),
is_error: false,
},
],
);
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let messages = body["messages"]
.as_array()
.expect("messages must be an array");
assert_eq!(
messages.len(),
1,
"parallel toolResults must be one user turn, got {body}"
);
assert_eq!(messages[0]["role"], "user");
let content = messages[0]["content"]
.as_array()
.expect("content must be an array");
assert_eq!(
content.len(),
2,
"expected two toolResult blocks, got {body}"
);
assert_eq!(content[0]["toolResult"]["toolUseId"], "t1");
assert_eq!(content[0]["toolResult"]["content"][0]["text"], "one");
assert_eq!(content[1]["toolResult"]["toolUseId"], "t2");
assert_eq!(content[1]["toolResult"]["content"][0]["text"], "two");
}
#[test]
fn converse_mixed_assistant_tool_use_then_text_round_trips_one_message() {
let req = br#"{
"modelId": "amazon.nova-lite-v1:0",
"messages": [
{"role": "assistant", "content": [
{"toolUse": {"toolUseId": "t1", "name": "lookup", "input": {"q": "x"}}},
{"text": "done looking."}
]}
]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode mixed assistant");
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let messages = body["messages"]
.as_array()
.expect("messages must be an array");
assert_eq!(
messages.len(),
1,
"re-encode must stay one assistant message, got {body}"
);
assert_eq!(messages[0]["role"], "assistant");
let content = messages[0]["content"]
.as_array()
.expect("content must be an array");
assert_eq!(content.len(), 2, "expected toolUse then text, got {body}");
assert_eq!(content[0]["toolUse"]["toolUseId"], "t1");
assert_eq!(content[0]["toolUse"]["name"], "lookup");
assert_eq!(content[1]["text"], "done looking.");
}
#[test]
fn converse_decode_tool_result_json_round_trips_nonempty() {
let req = br#"{
"modelId": "amazon.nova-lite-v1:0",
"messages": [
{"role": "user", "content": [{
"toolResult": {
"toolUseId": "t1",
"content": [{ "json": { "ok": true, "n": 1 } }]
}
}]}
]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode json toolResult");
let kept = ir.items.iter().any(|item| {
matches!(
item,
IrItem::FunctionOutput { call_id, parts, .. }
if call_id == "t1"
&& parts.iter().any(|part| matches!(
part,
IrPart::Raw { type_name, raw }
if type_name == "json" && raw.get("ok") == Some(&serde_json::json!(true))
))
)
});
assert!(
kept,
"json toolResult must stay a json block, got {:?}",
ir.items
);
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/toolResult/content/0/json/ok"),
Some(&serde_json::json!(true)),
"json toolResult must round-trip, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/toolResult/content/0/json/n"),
Some(&serde_json::json!(1)),
"json toolResult number must round-trip, got {body}"
);
}
#[test]
fn converse_tool_result_json_blocks_round_trip() {
let req = br#"{
"messages": [{"role": "user", "content": [{
"toolResult": {
"toolUseId": "t1",
"content": [
{"json": {"a": 1}},
{"json": {"b": 2}}
]
}
}]}]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/toolResult/content/0/json/a"),
Some(&serde_json::json!(1)),
"first json block must round-trip, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/toolResult/content/1/json/b"),
Some(&serde_json::json!(2)),
"second json block must round-trip, got {body}"
);
let glued = body.to_string();
assert!(
!glued.contains("}{"),
"json blocks must not be glued into one string, got {body}"
);
}
#[test]
fn converse_tool_result_json_then_text_keeps_order() {
let req = br#"{
"messages": [{"role": "user", "content": [{
"toolResult": {
"toolUseId": "t1",
"content": [
{"json": {"a": 1}},
{"text": "note"}
]
}
}]}]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/toolResult/content/0/json/a"),
Some(&serde_json::json!(1)),
"json must stay before the text, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/toolResult/content/1/text")
.and_then(Value::as_str),
Some("note"),
"text must stay after the json, got {body}"
);
}
#[test]
fn converse_tool_result_image_then_text_keeps_order() {
let req = br#"{
"messages": [{"role": "user", "content": [{
"toolResult": {
"toolUseId": "t1",
"content": [
{"image": {"format": "png", "source": {"bytes": "AAAA"}}},
{"text": "see this"}
]
}
}]}]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/toolResult/content/0/image/source/bytes")
.and_then(Value::as_str),
Some("AAAA"),
"image must stay before the text, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/toolResult/content/1/text")
.and_then(Value::as_str),
Some("see this"),
"text must stay after the image, got {body}"
);
}
#[test]
fn converse_whitespace_only_assistant_text_with_tool_use_omits_blank_text() {
let ir = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![
IrItem::Assistant {
phase: None,
parts: vec![IrPart::Text(" \n\t ".into())],
},
IrItem::FunctionCall {
call_id: "t1".into(),
name: "lookup".into(),
arguments: r#"{"q":"x"}"#.into(),
thought_signature: None,
},
],
);
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let messages = body["messages"]
.as_array()
.expect("messages must be an array");
assert_eq!(
messages.len(),
1,
"whitespace text plus toolUse must stay one assistant turn, got {body}"
);
assert_eq!(messages[0]["role"], "assistant");
let content = messages[0]["content"]
.as_array()
.expect("content must be an array");
assert_eq!(
content.len(),
1,
"blank text block must be omitted; only toolUse remains, got {body}"
);
assert!(
content[0].get("text").is_none(),
"must not emit a blank text contentBlock, got {body}"
);
assert_eq!(content[0]["toolUse"]["toolUseId"], "t1");
assert_eq!(content[0]["toolUse"]["name"], "lookup");
}
#[test]
fn converse_empty_function_output_encodes_nonempty_tool_result_text() {
let ir = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![IrItem::FunctionOutput {
call_id: "t1".into(),
output: String::new(),
parts: Vec::new(),
is_error: false,
}],
);
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let text = body
.pointer("/messages/0/content/0/toolResult/content/0/text")
.and_then(Value::as_str);
let text = text.expect("toolResult text");
assert!(
!text.trim().is_empty(),
"empty FunctionOutput must not emit blank toolResult text, got {body}"
);
assert_eq!(
text, ".",
"empty tool result uses the same '.' placeholder as Messages, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/toolResult/toolUseId")
.and_then(Value::as_str),
Some("t1"),
"toolUseId must stay, got {body}"
);
assert!(
loss_degraded(&report, "items[0]"),
"placeholder '.' must Degrade, got {report:?}"
);
}
#[test]
fn converse_mixed_user_tool_result_then_text_round_trips_one_message() {
let req = br#"{
"modelId": "anthropic.claude-sonnet-4-20250514-v1:0",
"messages": [
{"role": "user", "content": [
{"toolResult": {"toolUseId": "t1", "content": [{"text": "ok"}]}},
{"text": "thanks"}
]}
]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode mixed user");
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let messages = body["messages"]
.as_array()
.expect("messages must be an array");
assert_eq!(
messages.len(),
1,
"re-encode must stay one user message, got {body}"
);
assert_eq!(messages[0]["role"], "user");
let content = messages[0]["content"]
.as_array()
.expect("content must be an array");
assert_eq!(
content.len(),
2,
"expected toolResult then text, got {body}"
);
assert_eq!(content[0]["toolResult"]["toolUseId"], "t1");
assert_eq!(content[0]["toolResult"]["content"][0]["text"], "ok");
assert_eq!(content[1]["text"], "thanks");
}
#[test]
fn converse_mixed_user_text_then_tool_result_round_trips_one_message() {
let req = br#"{
"modelId": "anthropic.claude-sonnet-4-20250514-v1:0",
"messages": [
{"role": "user", "content": [
{"text": "here is the result"},
{"toolResult": {"toolUseId": "t1", "content": [{"text": "ok"}]}}
]}
]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode mixed user");
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let messages = body["messages"]
.as_array()
.expect("messages must be an array");
assert_eq!(
messages.len(),
1,
"re-encode must stay one user message, got {body}"
);
assert_eq!(messages[0]["role"], "user");
let content = messages[0]["content"]
.as_array()
.expect("content must be an array");
assert_eq!(
content.len(),
2,
"expected text then toolResult, got {body}"
);
assert_eq!(content[0]["text"], "here is the result");
assert_eq!(content[1]["toolResult"]["toolUseId"], "t1");
assert_eq!(content[1]["toolResult"]["content"][0]["text"], "ok");
}
#[test]
fn converse_reasoning_text_signature_round_trips() {
let req = br#"{
"modelId": "anthropic.claude-sonnet-4-20250514-v1:0",
"messages": [
{"role": "assistant", "content": [
{"reasoningContent": {"reasoningText": {"text": "plan", "signature": "sig_abc"}}},
{"text": "done"}
]}
]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode signed reasoning");
assert!(
ir.items.iter().any(|item| matches!(
item,
IrItem::Assistant { parts, ..} if parts.iter().any(|part| matches!(
part,
IrPart::Thinking { text, signature }
if text == "plan" && signature.as_deref() == Some("sig_abc")
))
)),
"decode must produce IrPart::Thinking {{ text: \"plan\", signature: Some(\"sig_abc\") }}, got {:?}",
ir.items
);
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let content = body
.pointer("/messages/0/content")
.and_then(Value::as_array)
.expect("content");
assert!(
content.iter().any(|block| {
block
.pointer("/reasoningContent/reasoningText/text")
.and_then(Value::as_str)
== Some("plan")
&& block
.pointer("/reasoningContent/reasoningText/signature")
.and_then(Value::as_str)
== Some("sig_abc")
}),
"replay must keep reasoningText.signature, got {body}"
);
assert_eq!(content[1]["text"], "done");
}
#[test]
fn converse_redacted_reasoning_round_trips() {
let req = br#"{
"messages": [
{"role": "user", "content": [{"text": "hi"}]},
{"role": "assistant", "content": [
{"reasoningContent": {"redactedContent": "YmxvYg=="}},
{"text": "done"}
]}
]
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/1/content/0/reasoningContent/redactedContent")
.and_then(Value::as_str),
Some("YmxvYg=="),
"redacted reasoning must round-trip, got {body}"
);
assert_eq!(
body.pointer("/messages/1/content/1/text")
.and_then(Value::as_str),
Some("done"),
"assistant text stays after redacted reasoning, got {body}"
);
let plain = br#"{
"messages": [
{"role": "assistant", "content": [
{"reasoningContent": {"reasoningText": {"text": "plan", "signature": "sig_abc"}}},
{"text": "done"}
]}
]
}"#;
let (ir_plain, _) = decode(Wire::Converse, plain).expect("decode plain");
let (plain_bytes, _) =
encode(Wire::Converse, &ir_plain, &converse_profile()).expect("encode plain");
let plain_body: Value = serde_json::from_slice(&plain_bytes).expect("json plain");
assert!(
!plain_body.to_string().contains("redactedContent"),
"signed reasoning text must not gain redactedContent, got {plain_body}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("chat");
let chat_text = serde_json::from_slice::<Value>(&chat_bytes)
.expect("chat json")
.to_string();
assert!(
!chat_text.contains("YmxvYg=="),
"Chat must not send the redacted blob, got {chat_text}"
);
assert!(
chat_report
.events
.iter()
.any(|event| event.action == LossAction::Drop),
"Chat must record that redacted reasoning was dropped, got {chat_report:?}"
);
}
#[test]
fn converse_tool_strict_and_system_tool_round_trip() {
let req = br#"{
"messages": [{"role": "user", "content": [{"text": "hi"}]}],
"toolConfig": {"tools": [
{"toolSpec": {
"name": "lookup",
"description": "Look up a record",
"inputSchema": {"json": {"type": "object", "properties": {}}},
"strict": true
}},
{"toolSpec": {
"name": "loose",
"description": "Free form",
"inputSchema": {"json": {"type": "object", "properties": {}}},
"strict": false
}},
{"systemTool": {"name": "web_search"}}
]}
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/toolConfig/tools/0/toolSpec/strict")
.and_then(Value::as_bool),
Some(true),
"strict true must round-trip, got {body}"
);
assert_eq!(
body.pointer("/toolConfig/tools/1/toolSpec/strict")
.and_then(Value::as_bool),
Some(false),
"strict false must round-trip, got {body}"
);
assert_eq!(
body.pointer("/toolConfig/tools/2/systemTool/name")
.and_then(Value::as_str),
Some("web_search"),
"system tool must round-trip, got {body}"
);
assert!(
!report
.events
.iter()
.any(|event| { event.action == LossAction::Drop && event.detail.contains("strict") }),
"Converse has a strict slot, got {report:?}"
);
let plain = br#"{
"messages": [{"role": "user", "content": [{"text": "hi"}]}],
"toolConfig": {"tools": [{
"toolSpec": {
"name": "lookup",
"description": "Look up a record",
"inputSchema": {"json": {"type": "object", "properties": {}}}
}
}]}
}"#;
let (ir_plain, _) = decode(Wire::Converse, plain).expect("decode plain");
let (plain_bytes, _) =
encode(Wire::Converse, &ir_plain, &converse_profile()).expect("encode plain");
let plain_body: Value = serde_json::from_slice(&plain_bytes).expect("json plain");
assert!(
plain_body
.pointer("/toolConfig/tools/0/toolSpec/strict")
.is_none()
&& !plain_body.to_string().contains("systemTool"),
"a plain tool must not gain strict or a system tool, got {plain_body}"
);
let (chat_bytes, chat_report) =
encode(Wire::ChatCompletions, &ir, &chat_profile()).expect("chat");
let chat_text = serde_json::from_slice::<Value>(&chat_bytes)
.expect("chat json")
.to_string();
assert!(
!chat_text.contains("web_search"),
"Chat must not send the system tool, got {chat_text}"
);
assert!(
chat_report
.events
.iter()
.any(|event| event.action == LossAction::Drop),
"Chat must record that the system tool was dropped, got {chat_report:?}"
);
}
#[test]
fn converse_encode_drops_thinking_schema_and_parallel() {
let schema = serde_json::json!({"type": "object"});
let ir = user_ir(IrSampling::patch(|s| {
s.max_reasoning_tokens = Some(2048);
s.include_thoughts = Some(true);
s.reasoning_effort = Some("high".into());
s.thinking_budget = Some(1024);
s.json_schema = Some(schema.clone());
s.json_schema_name = Some("answer".into());
s.parallel_tool_calls = Some(true);
}));
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/outputConfig/textFormat/type")
.and_then(Value::as_str),
Some("json_schema"),
"Converse must emit outputConfig.textFormat, got {body}"
);
assert_eq!(
body.pointer("/outputConfig/textFormat/structure/jsonSchema/name")
.and_then(Value::as_str),
Some("answer"),
"Converse must emit jsonSchema.name, got {body}"
);
assert_eq!(
body.pointer("/outputConfig/effort").and_then(Value::as_str),
Some("high"),
"Converse must emit outputConfig.effort, got {body}"
);
assert!(
body.get("max_reasoning_tokens").is_none()
&& body.get("maxReasoningTokens").is_none()
&& body.get("include_thoughts").is_none()
&& body.get("includeThoughts").is_none()
&& body.get("reasoning_effort").is_none()
&& body.get("reasoningEffort").is_none()
&& body.get("thinking_budget").is_none()
&& body.get("thinkingBudget").is_none()
&& body.get("thinkingConfig").is_none()
&& body.get("thinking").is_none()
&& body.get("json_schema").is_none()
&& body.get("jsonSchema").is_none()
&& body.get("responseSchema").is_none()
&& body.get("response_format").is_none()
&& body.get("output_format").is_none()
&& body.get("parallel_tool_calls").is_none()
&& body.get("parallelToolCalls").is_none(),
"Converse must not invent thinking/schema/parallel slots, got {body}"
);
for path in [
"sampling.max_reasoning_tokens",
"sampling.include_thoughts",
"sampling.thinking_budget",
"sampling.parallel_tool_calls",
] {
assert!(
loss_dropped(&report, path),
"Converse {path} drop missing, got {report:?}"
);
}
for path in [
"sampling.reasoning_effort",
"sampling.json_schema",
"sampling.json_schema_name",
] {
assert!(
!loss_dropped(&report, path),
"Converse has outputConfig and must not Drop {path}, got {report:?}"
);
}
}
#[test]
fn converse_encode_output_config_schema_and_effort() {
let schema = serde_json::json!({"type": "object", "properties": {"ok": {"type": "boolean"}}});
let ir = user_ir(IrSampling::patch(|s| {
s.json_schema = Some(schema.clone());
s.json_schema_name = Some("answer".into());
s.reasoning_effort = Some("high".into());
}));
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/outputConfig/textFormat/type")
.and_then(Value::as_str),
Some("json_schema"),
"textFormat.type must be json_schema, got {body}"
);
let schema_str = body
.pointer("/outputConfig/textFormat/structure/jsonSchema/schema")
.and_then(Value::as_str)
.expect("schema must be a JSON string");
let parsed: Value = serde_json::from_str(schema_str).expect("schema string parses");
assert_eq!(
parsed, schema,
"schema string must parse back to the object"
);
assert_eq!(
body.pointer("/outputConfig/textFormat/structure/jsonSchema/name")
.and_then(Value::as_str),
Some("answer"),
"jsonSchema.name must be answer, got {body}"
);
assert_eq!(
body.pointer("/outputConfig/effort").and_then(Value::as_str),
Some("high"),
"effort must be high, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.json_schema")
&& !loss_dropped(&report, "sampling.json_schema_name")
&& !loss_dropped(&report, "sampling.reasoning_effort"),
"mapped outputConfig fields must not Drop, got {report:?}"
);
}
#[test]
fn converse_decode_reads_output_config_schema_and_effort() {
let req = br#"{
"messages": [{"role": "user", "content": [{"text": "hi"}]}],
"outputConfig": {
"textFormat": {
"type": "json_schema",
"structure": {
"jsonSchema": {
"schema": "{\"type\":\"object\"}",
"name": "answer"
}
}
},
"effort": "high"
}
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
assert_eq!(ir.sampling.reasoning_effort.as_deref(), Some("high"));
assert_eq!(ir.sampling.json_schema_name.as_deref(), Some("answer"));
assert_eq!(
ir.sampling.json_schema,
Some(serde_json::json!({"type": "object"}))
);
}
#[test]
fn converse_json_schema_description_round_trips() {
let req = br#"{
"messages": [{"role": "user", "content": [{"text": "hi"}]}],
"outputConfig": {
"textFormat": {
"type": "json_schema",
"structure": {
"jsonSchema": {
"schema": "{\"type\":\"object\"}",
"name": "extract",
"description": "Extract structured data from unstructured text"
}
}
}
}
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
assert_eq!(
ir.sampling.json_schema_description.as_deref(),
Some("Extract structured data from unstructured text")
);
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/outputConfig/textFormat/structure/jsonSchema/description")
.and_then(Value::as_str),
Some("Extract structured data from unstructured text"),
"schema description must round-trip, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.json_schema_description"),
"Converse has a description slot, got {report:?}"
);
let plain = br#"{
"messages": [{"role": "user", "content": [{"text": "hi"}]}],
"outputConfig": {
"textFormat": {
"type": "json_schema",
"structure": {
"jsonSchema": {
"schema": "{\"type\":\"object\"}",
"name": "extract"
}
}
}
}
}"#;
let (ir_plain, _) = decode(Wire::Converse, plain).expect("decode plain");
let (plain_bytes, _) =
encode(Wire::Converse, &ir_plain, &converse_profile()).expect("encode plain");
let plain_body: Value = serde_json::from_slice(&plain_bytes).expect("json plain");
assert!(
plain_body
.pointer("/outputConfig/textFormat/structure/jsonSchema/description")
.is_none(),
"a schema with no description must not gain one, got {plain_body}"
);
}
#[test]
fn converse_non_object_json_schema_is_dropped() {
let ir = user_ir(IrSampling::patch(|s| {
s.json_schema = Some(serde_json::json!(["not", "object"]));
s.json_schema_name = Some("answer".into());
}));
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("outputConfig").is_none(),
"non-object json_schema must not emit outputConfig, got {body}"
);
assert!(
loss_dropped(&report, "sampling.json_schema"),
"non-object json_schema must Drop, got {report:?}"
);
assert!(
!report.events.iter().any(|event| {
event.path == "sampling.json_schema"
&& event.action == LossAction::Drop
&& event.detail == "no slot"
}),
"Converse has a slot; Drop detail must not be no slot, got {report:?}"
);
}
#[test]
fn converse_encode_unknown_effort_is_dropped() {
let ir = user_ir(IrSampling::patch(|s| {
s.reasoning_effort = Some("ultra".into());
}));
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("outputConfig").is_none(),
"unknown effort must not invent outputConfig, got {body}"
);
assert!(
loss_dropped(&report, "sampling.reasoning_effort"),
"unknown effort must Drop, got {report:?}"
);
}
#[test]
fn converse_encode_service_tier_auto_degrades_to_default() {
let ir = user_ir(IrSampling::patch(|s| {
s.service_tier = Some("auto".into());
}));
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/serviceTier/type").and_then(Value::as_str),
Some("default"),
"auto must map to serviceTier.type default, got {body}"
);
assert!(
loss_degraded(&report, "sampling.service_tier"),
"auto must Degrade to default, got {report:?}"
);
}
#[test]
fn converse_encode_service_tier_passthrough() {
for (input, want) in [
("priority", "priority"),
("reserved", "reserved"),
("DEFAULT", "default"),
] {
let ir = user_ir(IrSampling::patch(|s| {
s.service_tier = Some(input.into());
}));
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/serviceTier/type").and_then(Value::as_str),
Some(want),
"{input} must emit serviceTier.type {want}, got {body}"
);
assert!(
!loss_dropped(&report, "sampling.service_tier")
&& !loss_degraded(&report, "sampling.service_tier"),
"{input} must pass through, got {report:?}"
);
}
}
#[test]
fn converse_encode_unknown_service_tier_is_dropped() {
let ir = user_ir(IrSampling::patch(|s| {
s.service_tier = Some("turbo".into());
}));
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("serviceTier").is_none(),
"unknown service_tier must not invent serviceTier, got {body}"
);
assert!(
loss_dropped(&report, "sampling.service_tier"),
"unknown service_tier must Drop, got {report:?}"
);
assert!(
!report.events.iter().any(|event| {
event.path == "sampling.service_tier"
&& event.action == LossAction::Drop
&& event.detail == "no slot"
}),
"Converse has a slot; Drop detail must not be no slot, got {report:?}"
);
}
#[test]
fn converse_decode_reads_service_tier() {
let req = br#"{
"messages": [{"role": "user", "content": [{"text": "hi"}]}],
"serviceTier": { "type": "priority" }
}"#;
let (ir, _) = decode(Wire::Converse, req).expect("decode");
assert_eq!(ir.sampling.service_tier.as_deref(), Some("priority"));
}
#[test]
fn converse_encode_x_high_effort_degrades_to_xhigh() {
for effort in ["x-high", "X-High"] {
let ir = user_ir(IrSampling::patch(|s| {
s.reasoning_effort = Some(effort.into());
}));
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/outputConfig/effort").and_then(Value::as_str),
Some("xhigh"),
"{effort} must map to outputConfig.effort xhigh, got {body}"
);
assert!(
loss_degraded(&report, "sampling.reasoning_effort"),
"{effort} must Degrade to xhigh, got {report:?}"
);
}
for effort in ["xhigh", "XHIGH"] {
let ir = user_ir(IrSampling::patch(|s| {
s.reasoning_effort = Some(effort.into());
}));
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/outputConfig/effort").and_then(Value::as_str),
Some("xhigh"),
"{effort} must emit xhigh, got {body}"
);
assert!(
!loss_degraded(&report, "sampling.reasoning_effort")
&& !loss_dropped(&report, "sampling.reasoning_effort"),
"{effort} is a first-class effort and must not Drop/Degrade, got {report:?}"
);
}
}
#[test]
fn converse_none_tool_choice_omits_tool_config() {
let ir = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![IrItem::User {
parts: vec![IrPart::Text("hi".into())],
}],
)
.with_tools(vec![IrTool::Function {
name: "lookup".into(),
description: "d".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
strict: None,
}])
.with_sampling(IrSampling::patch(|s| {
s.tool_choice = IrToolChoice::None;
}));
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert!(
body.get("toolConfig").is_none(),
"None must omit toolConfig; Bedrock has no none slot, got {body}"
);
assert!(
body.pointer("/toolConfig/toolChoice/auto").is_none(),
"None must not become auto, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "sampling.tool_choice"
&& matches!(event.action, LossAction::Degrade | LossAction::Drop)
&& event.detail.contains("no none slot")
}),
"None with tools must record tool_choice no none slot, got {report:?}"
);
}
#[test]
fn converse_none_tool_choice_keeps_tools_when_history_has_a_call() {
let ir = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![
IrItem::FunctionCall {
call_id: "call_a".into(),
name: "lookup".into(),
arguments: "{}".into(),
thought_signature: None,
},
IrItem::FunctionOutput {
call_id: "call_a".into(),
output: "ok".into(),
parts: Vec::new(),
is_error: false,
},
],
)
.with_tools(vec![IrTool::Function {
name: "lookup".into(),
description: "d".into(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
strict: None,
}])
.with_sampling(IrSampling::patch(|s| {
s.tool_choice = IrToolChoice::None;
}));
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/toolConfig/tools/0/toolSpec/name")
.and_then(Value::as_str),
Some("lookup"),
"tool history must keep toolConfig or Bedrock rejects the turn, got {body}"
);
assert!(
body.pointer("/toolConfig/toolChoice").is_none(),
"None must not become a toolChoice, got {body}"
);
assert!(
report.events.iter().any(|event| {
event.path == "sampling.tool_choice" && event.detail.contains("no none slot")
}),
"None must still be recorded, got {report:?}"
);
}
#[test]
fn messages_image_file_id_round_trips() {
let req = br#"{
"model": "claude-sonnet-4-5",
"messages": [{
"role": "user",
"content": [
{"type": "image", "source": {"type": "file", "file_id": "file_img"}},
{"type": "text", "text": "What is this?"}
]
}]
}"#;
let (ir, _) = decode(Wire::Messages, req).expect("decode");
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/messages/0/content/0/source/file_id")
.and_then(Value::as_str),
Some("file_img"),
"Messages image file_id must stay on the next request, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/0/source/type")
.and_then(Value::as_str),
Some("file"),
"image source type must stay file, got {body}"
);
assert_eq!(
body.pointer("/messages/0/content/1/text")
.and_then(Value::as_str),
Some("What is this?"),
"text after the image must stay, got {body}"
);
assert!(
report
.events
.iter()
.all(|event| event.path != "part.image.file_id"),
"same-wire image file_id must not be recorded as dropped, got {report:?}"
);
}
#[test]
fn messages_image_file_id_does_not_leak_into_responses() {
let req = br#"{
"model": "claude-sonnet-4-5",
"messages": [{
"role": "user",
"content": [
{"type": "image", "source": {"type": "file", "file_id": "file_img"}},
{"type": "text", "text": "What is this?"}
]
}]
}"#;
let (ir, _) = decode(Wire::Messages, req).expect("decode");
let (bytes, report) = encode(Wire::Responses, &ir, &hard_error_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let encoded = String::from_utf8_lossy(&bytes);
assert!(
!encoded.contains("file_img"),
"Responses must not receive a Messages file image, got {body}"
);
assert!(
body.pointer("/input")
.and_then(Value::as_array)
.into_iter()
.flatten()
.all(
|part| part.get("type").and_then(Value::as_str) != Some("image")
&& part
.get("content")
.and_then(Value::as_array)
.into_iter()
.flatten()
.all(|item| item.get("type").and_then(Value::as_str) != Some("image"))
),
"Responses content must not keep type image, got {body}"
);
assert_eq!(
body.pointer("/input/0/content/0/text")
.and_then(Value::as_str),
Some("What is this?"),
"text must stay after the image is dropped, got {body}"
);
assert!(
loss_dropped(&report, "part.raw"),
"cross-wire file image must be recorded as dropped, got {report:?}"
);
}
fn system_content_parts_ir() -> IrRequest {
IrRequest::new(
"test-model",
vec![
IrItem::System {
text: "from-text".into(),
prompt_cache_breakpoint: None,
content_parts: vec![
IrPart::ImageUrl {
url: "https://example.com/a.png".into(),
detail: None,
bucket_owner: None,
file_id: None,
},
IrPart::Text("from-parts".into()),
],
},
IrItem::User {
parts: vec![IrPart::Text("go".into())],
},
],
)
}
#[test]
fn system_content_parts_messages_sends_part_text_and_drops_image() {
let (bytes, report) = encode(
Wire::Messages,
&system_content_parts_ir(),
&messages_profile(),
)
.expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let encoded = String::from_utf8(bytes).expect("utf8");
assert!(
encoded.contains("from-parts"),
"Messages must send the system part text, got {body}"
);
assert!(
!encoded.contains("from-text"),
"Messages must not send the separate system text copy, got {body}"
);
assert!(
loss_dropped(&report, "part.image"),
"Messages must Drop part.image, got {report:?}"
);
}
#[test]
fn system_content_parts_gemini_sends_part_text_and_drops_image() {
let (bytes, report) =
encode(Wire::Gemini, &system_content_parts_ir(), &gemini_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/systemInstruction/parts/0/text")
.and_then(Value::as_str),
Some("from-parts"),
"Gemini system instruction must be the part text, got {body}"
);
let encoded = String::from_utf8(bytes).expect("utf8");
assert!(
!encoded.contains("from-text"),
"Gemini must not send the separate system text copy, got {body}"
);
assert!(
loss_dropped(&report, "part.image"),
"Gemini must Drop part.image, got {report:?}"
);
}
#[test]
fn system_content_parts_converse_sends_part_text_and_drops_image() {
let (bytes, report) = encode(
Wire::Converse,
&system_content_parts_ir(),
&converse_profile(),
)
.expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.pointer("/system/0/text").and_then(Value::as_str),
Some("from-parts"),
"Converse system text must be the part text, got {body}"
);
let encoded = String::from_utf8(bytes).expect("utf8");
assert!(
!encoded.contains("from-text"),
"Converse must not send the separate system text copy, got {body}"
);
assert!(
loss_dropped(&report, "part.image"),
"Converse must Drop part.image, got {report:?}"
);
}
#[test]
fn system_content_parts_nested_breakpoint_keeps_text() {
let ir = IrRequest::new(
"test-model",
vec![
IrItem::System {
text: String::new(),
prompt_cache_breakpoint: None,
content_parts: vec![IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(IrPart::PromptCacheBreakpoint {
mode: "nested".into(),
inner: Box::new(IrPart::Text("kept-system-text".into())),
}),
}],
},
IrItem::User {
parts: vec![IrPart::Text("go".into())],
},
],
);
for (wire, profile) in [
(Wire::Messages, messages_profile()),
(Wire::Gemini, gemini_profile()),
(Wire::Converse, converse_profile()),
] {
let (bytes, _) = encode(wire, &ir, &profile).expect("encode");
let encoded = String::from_utf8(bytes).expect("utf8");
assert!(
encoded.contains("kept-system-text"),
"{wire:?} must keep system text inside two breakpoints, got {encoded}"
);
}
}
#[test]
fn converse_tool_nested_cache_breakpoint_keeps_text() {
let ir = IrRequest::new(
"amazon.nova-lite-v1:0",
vec![IrItem::FunctionOutput {
call_id: "call-1".into(),
output: String::new(),
is_error: false,
parts: vec![IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(IrPart::PromptCacheBreakpoint {
mode: "nested".into(),
inner: Box::new(IrPart::Text("kept-tool-text".into())),
}),
}],
}],
);
let (bytes, report) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let texts: Vec<&str> = body
.pointer("/messages/0/content/0/toolResult/content")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|block| block.pointer("/text").and_then(Value::as_str))
.collect();
assert_eq!(
texts,
vec!["kept-tool-text"],
"two breakpoints must keep the tool text and not add a dot, got {body}"
);
assert!(
!report
.events
.iter()
.any(|event| event.detail.contains("empty content became")),
"the tool text is present, got {report:?}"
);
}
#[test]
fn system_content_parts_messages_empty_parts_keeps_text() {
let ir = IrRequest::new(
"test-model",
vec![
IrItem::System {
text: "from-text".into(),
prompt_cache_breakpoint: None,
content_parts: vec![],
},
IrItem::User {
parts: vec![IrPart::Text("go".into())],
},
],
);
let (bytes, report) = encode(Wire::Messages, &ir, &messages_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(
body.get("system").and_then(Value::as_str),
Some("from-text"),
"empty content_parts must still send text, got {body}"
);
assert!(
!loss_dropped(&report, "part.image"),
"empty content_parts must not Drop part.image, got {report:?}"
);
}
#[test]
fn json_tool_output_inside_cache_breakpoint_is_kept() {
let json_part = || IrPart::Raw {
type_name: "json".into(),
raw: serde_json::json!({"ok": true}),
};
let wrapped = |inner: IrPart| IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(inner),
};
let nested = IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(IrPart::PromptCacheBreakpoint {
mode: "nested".into(),
inner: Box::new(json_part()),
}),
};
let cases = [
("wrapped", String::new(), vec![wrapped(json_part())], 1usize),
("nested", String::new(), vec![nested], 2),
(
"beside-text",
String::new(),
vec![IrPart::Text("hello".into()), wrapped(json_part())],
0,
),
(
"json-then-text",
String::new(),
vec![wrapped(json_part()), IrPart::Text("hello".into())],
0,
),
(
"output-and-json",
"hello-from-output".into(),
vec![wrapped(json_part())],
1,
),
];
for (label, output, parts, json_breakpoint_drops) in cases {
for wire in [
Wire::ChatCompletions,
Wire::Responses,
Wire::Converse,
Wire::Messages,
Wire::Gemini,
] {
let profile = match wire {
Wire::ChatCompletions => chat_profile(),
Wire::Responses => hard_error_profile(),
Wire::Converse => converse_profile(),
Wire::Messages => messages_profile(),
Wire::Gemini => gemini_profile(),
_ => unreachable!("tool JSON wires"),
};
let ir = IrRequest::new(
"probe-model",
vec![IrItem::FunctionOutput {
call_id: "call-1".into(),
output: output.clone(),
is_error: false,
parts: parts.clone(),
}],
);
let (bytes, report) = encode(wire, &ir, &profile).expect("encode");
let encoded = String::from_utf8(bytes).expect("utf8");
assert!(
encoded.contains("ok"),
"{wire:?} {label} must keep the JSON tool payload, got {encoded} {report:?}"
);
if label == "beside-text" || label == "json-then-text" {
assert!(
encoded.contains("hello"),
"{wire:?} {label} must keep the tool text beside the JSON, got {encoded}"
);
}
if label == "output-and-json" {
assert!(
encoded.contains("hello-from-output"),
"{wire:?} {label} must keep FunctionOutput.output, got {encoded}"
);
}
assert!(
!encoded.contains("\"text\":\".\""),
"{wire:?} {label} must not replace the JSON with a dot, got {encoded}"
);
assert!(
!report.events.iter().any(|event| {
event.detail.contains("not Responses-shaped")
|| event.detail.contains("not Messages-shaped")
|| event.detail.contains("converse image/raw dropped")
|| event.detail.contains("raw part has no Chat")
}),
"{wire:?} {label} must not drop the JSON as an unknown raw part, got {report:?}"
);
let expect_drops = match (label, wire) {
("beside-text" | "json-then-text", Wire::Responses) => 0,
("beside-text" | "json-then-text", _) => 1,
_ => json_breakpoint_drops,
};
assert_eq!(
breakpoint_drops(&report),
expect_drops,
"{wire:?} {label} breakpoint drops, got {report:?}"
);
if expect_drops == 0 {
assert!(
encoded.contains("\"mode\":\"explicit\""),
"{wire:?} {label} must keep the breakpoint on the JSON part, got {encoded}"
);
}
}
}
}
#[test]
fn responses_json_only_tool_output_keeps_the_json() {
let json_part = || IrPart::Raw {
type_name: "json".into(),
raw: serde_json::json!({"ok": true, "n": 1}),
};
let json_only = IrRequest::new(
"gpt-5",
vec![IrItem::FunctionOutput {
call_id: "call-1".into(),
output: String::new(),
is_error: false,
parts: vec![json_part()],
}],
);
let (bytes, _) = encode(Wire::Responses, &json_only, &hard_error_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let output = body
.pointer("/input/0/output")
.and_then(Value::as_str)
.unwrap_or("");
assert!(
!output.is_empty() && output.contains("ok"),
"json-only tool output must keep the JSON text, got {body}"
);
let joined = IrRequest::new(
"gpt-5",
vec![IrItem::FunctionOutput {
call_id: "call-1".into(),
output: "hello".into(),
is_error: false,
parts: vec![json_part()],
}],
);
let (bytes, _) = encode(Wire::Responses, &joined, &hard_error_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let output = body
.pointer("/input/0/output")
.and_then(Value::as_str)
.unwrap_or("");
assert!(
output.contains("hello") && output.contains("ok"),
"joined tool output must keep the text and the JSON, got {body}"
);
}
#[test]
fn responses_tool_image_survives_breakpoint_around_thinking() {
let ir = IrRequest::new(
"gpt-5",
vec![IrItem::FunctionOutput {
call_id: "call-1".into(),
output: String::new(),
is_error: false,
parts: vec![
IrPart::ImageUrl {
url: "https://example.com/cat.png".into(),
detail: None,
bucket_owner: None,
file_id: None,
},
IrPart::PromptCacheBreakpoint {
mode: "explicit".into(),
inner: Box::new(IrPart::Thinking {
text: "hidden".into(),
signature: None,
}),
},
],
}],
);
let (bytes, _) = encode(Wire::Responses, &ir, &hard_error_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let rendered = body.to_string();
assert!(
rendered.contains("https://example.com/cat.png"),
"a later breakpoint that adds no parts must not drop the image, got {body}"
);
}