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,
};
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_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}");
}
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!(coords.get("items").is_some(), "{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!(inlined.get("items").is_some(), "{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!(coords.get("items").is_some(), "{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_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 {
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 {
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_asks_for_encrypted_reasoning_and_drops_unsigned_thinking() {
let ir = IrRequest::new(
"gpt-5",
vec![IrItem::Assistant {
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_eq!(
body.get("include"),
Some(&serde_json::json!(["reasoning.encrypted_content"])),
"Responses must request encrypted reasoning, 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().any(|event| {
event.path == "sampling.include"
&& event.action == LossAction::Degrade
&& event.detail.contains("reasoning.encrypted_content")
}),
"injected include must be in the loss report, 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 messages_whitespace_only_assistant_becomes_dot() {
let ir = IrRequest::new(
"claude-opus-4-6",
vec![IrItem::Assistant {
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 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,
},
],
}],
);
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:?}"
);
}
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(),
},
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(),
},
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(),
},
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(),
}],
)
.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}");
}
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}"),
})
.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(),
},
IrItem::System {
text: "Restored goal state from previous session:\nGoal: current task".into(),
},
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(),
},
IrItem::System {
text: "resume".into(),
},
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() },
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),
},
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(),
},
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 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 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_eq!(
body.get("include"),
Some(&serde_json::json!(["reasoning.encrypted_content"])),
"include must stay, 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 {
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 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 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:?}"
);
}
#[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 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:?}"
);
}
#[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_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_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_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}"
);
}
#[test]
fn responses_include_default_still_writes_encrypted_reasoning() {
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, _) = 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"])),
"empty include must still write encrypted reasoning, got {body}"
);
}
#[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 {
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 {
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_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() }],
);
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(),
}],
);
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_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_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 output = ir.items.iter().find_map(|item| match item {
IrItem::FunctionOutput {
call_id, output, ..
} if call_id == "t1" => Some(output.as_str()),
_ => None,
});
let output = output.expect("FunctionOutput t1");
assert!(
!output.is_empty(),
"json toolResult must not decode to empty string, got {output:?}"
);
assert_eq!(
output, r#"{"n":1,"ok":true}"#,
"json toolResult must keep the object, got {output:?}"
);
let (bytes, _) = encode(Wire::Converse, &ir, &converse_profile()).expect("encode");
let body: Value = serde_json::from_slice(&bytes).expect("json");
let arr = body
.pointer("/messages/0/content/0/toolResult/content")
.and_then(Value::as_array)
.expect("toolResult content");
assert!(
!arr.is_empty(),
"re-encode must keep toolResult content, got {body}"
);
let lost = arr.iter().all(|p| {
p.get("text")
.and_then(Value::as_str)
.is_none_or(str::is_empty)
&& p.get("json").is_none()
});
assert!(
!lost,
"re-encode must not be empty text-only loss, 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 {
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_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_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:?}"
);
}