use serde_json::{Value, json};
use wiremux_auth::{ResolvedProfile, ToolTypePolicy};
use super::tools::{PreparedTool, decode_tool, qualify_call_name, split_namespace_name};
use super::{
MapError, bool_field, decode_input_audio_part, decode_openai_file_part,
drop_dest_chat_logit_bias, drop_dest_logprobs, drop_dest_n_and_penalties,
drop_dest_output_modalities, drop_messages_native_thinking, f32_field, off_dialect_raw_path,
responses_raw_passthrough, stop_values, str_field, string_object_field, u32_field,
value_as_string,
};
use crate::ir::{
IrCache, IrDocumentSource, IrItem, IrPart, IrRequest, IrSampling, IrToolChoice, LossAction,
LossReport,
};
pub(super) fn decode(value: &Value) -> Result<(IrRequest, LossReport), MapError> {
let report = LossReport::default();
let mut items = Vec::new();
if let Some(instructions) = str_field(value, "instructions")
&& !instructions.is_empty()
{
items.push(IrItem::System {
text: instructions,
prompt_cache_breakpoint: None,
content_parts: vec![],
});
}
match value.get("input") {
Some(Value::String(text)) => items.push(IrItem::User {
parts: vec![IrPart::Text(text.clone())],
}),
Some(Value::Array(arr)) => {
for item in arr {
items.extend(decode_input_item(item));
}
}
_ => {}
}
let tools = value
.get("tools")
.and_then(Value::as_array)
.map(|arr| arr.iter().map(decode_tool).collect())
.unwrap_or_default();
let ir = IrRequest {
model: str_field(value, "model").unwrap_or_default(),
items,
tools,
sampling: decode_sampling(value),
};
Ok((ir, report))
}
fn decode_input_item(item: &Value) -> Vec<IrItem> {
if let Some(text) = item.as_str() {
return vec![IrItem::User {
parts: vec![IrPart::Text(text.to_string())],
}];
}
let ty = item.get("type").and_then(Value::as_str).unwrap_or("");
match ty {
"function_call" => vec![IrItem::FunctionCall {
call_id: str_field(item, "call_id")
.or_else(|| str_field(item, "id"))
.unwrap_or_default(),
name: qualify_call_name(item),
arguments: item
.get("arguments")
.map(value_as_string)
.unwrap_or_else(|| "{}".into()),
thought_signature: None,
}],
"custom_tool_call" => vec![IrItem::CustomToolCall {
call_id: str_field(item, "call_id")
.or_else(|| str_field(item, "id"))
.unwrap_or_default(),
name: str_field(item, "name").unwrap_or_default(),
input: item.get("input").map(value_as_string).unwrap_or_default(),
responses_item: Some(item.clone()),
}],
"function_call_output" => vec![decode_function_output(item)],
"reasoning" => vec![IrItem::Reasoning {
encrypted: str_field(item, "encrypted_content"),
summary: reasoning_summary(item),
raw: Some(item.clone()),
}],
"message" | "" => decode_message_item(item),
other if is_hosted_item(other) => vec![IrItem::HostedToolCall {
kind: other.to_string(),
raw: item.clone(),
}],
other => vec![IrItem::Unknown {
type_name: other.to_string(),
raw: item.clone(),
}],
}
}
fn decode_function_output(item: &Value) -> IrItem {
let call_id = str_field(item, "call_id").unwrap_or_default();
let (output, parts) = match item.get("output") {
Some(Value::Array(arr)) => {
let decoded: Vec<IrPart> = arr
.iter()
.filter_map(decode_part)
.filter(|part| !matches!(part, IrPart::Text(text) if text.is_empty()))
.collect();
let keep_text_in_order = decoded.iter().any(|part| !matches!(part, IrPart::Text(_)));
if keep_text_in_order {
(String::new(), decoded)
} else {
let text = decoded
.into_iter()
.filter_map(|part| match part {
IrPart::Text(text) => Some(text),
_ => None,
})
.collect::<Vec<_>>()
.join("");
(text, Vec::new())
}
}
Some(other) => (value_as_string(other), Vec::new()),
None => (String::new(), Vec::new()),
};
IrItem::FunctionOutput {
call_id,
output,
parts,
is_error: false,
}
}
fn is_hosted_item(ty: &str) -> bool {
ty.ends_with("_call") || ty == "web_search_call" || ty == "file_search_call"
}
fn reasoning_summary(item: &Value) -> Option<String> {
if let Some(s) = str_field(item, "summary") {
return Some(s);
}
let arr = item.get("summary")?.as_array()?;
let texts: Vec<_> = arr
.iter()
.filter_map(|part| {
part.get("text")
.and_then(Value::as_str)
.or_else(|| part.as_str())
})
.collect();
if texts.is_empty() {
None
} else {
Some(texts.join("\n"))
}
}
fn decode_message_item(item: &Value) -> Vec<IrItem> {
let role = item.get("role").and_then(Value::as_str).unwrap_or("user");
let parts = decode_parts(item.get("content"));
match role {
"system" => vec![IrItem::System {
text: parts_text(&parts),
prompt_cache_breakpoint: super::role_text_breakpoint(&parts),
content_parts: super::role_content_parts(&parts),
}],
"developer" => vec![IrItem::Developer {
text: parts_text(&parts),
prompt_cache_breakpoint: super::role_text_breakpoint(&parts),
content_parts: super::role_content_parts(&parts),
}],
"assistant" => vec![IrItem::Assistant {
parts,
phase: str_field(item, "phase").filter(|phase| !phase.trim().is_empty()),
}],
_ => vec![IrItem::User { parts }],
}
}
fn decode_parts(content: Option<&Value>) -> Vec<IrPart> {
let Some(content) = content else {
return Vec::new();
};
if let Some(text) = content.as_str() {
return vec![IrPart::Text(text.to_string())];
}
let Some(arr) = content.as_array() else {
return Vec::new();
};
arr.iter().filter_map(decode_part).collect()
}
fn decode_part(part: &Value) -> Option<IrPart> {
if let Some(text) = part.as_str() {
return Some(IrPart::Text(text.to_string()));
}
let type_name = part.get("type").and_then(Value::as_str).unwrap_or("text");
let decoded = match type_name {
"input_text" | "output_text" | "text" => part
.get("text")
.and_then(Value::as_str)
.map(|t| IrPart::Text(t.to_string())),
"input_image" | "image" => decode_image(part),
"input_file" | "file" => decode_openai_file_part(part),
"input_audio" => decode_input_audio_part(part),
"thinking" => Some(IrPart::Thinking {
text: part
.get("text")
.or_else(|| part.get("thinking"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
signature: str_field(part, "signature"),
}),
_ => part
.get("text")
.and_then(Value::as_str)
.map(|t| IrPart::Text(t.to_string()))
.or_else(|| {
Some(IrPart::Raw {
type_name: type_name.to_string(),
raw: part.clone(),
})
}),
};
super::with_prompt_cache(decoded, part)
}
fn responses_image_detail(part: &Value) -> Option<String> {
part.get("detail")
.and_then(Value::as_str)
.map(str::trim)
.filter(|detail| !detail.is_empty() && *detail != "auto")
.map(str::to_string)
}
fn decode_image(part: &Value) -> Option<IrPart> {
let file_id = str_field(part, "file_id").filter(|id| !id.is_empty());
let detail = responses_image_detail(part);
if let Some(url) = part
.get("image_url")
.and_then(|u| {
u.as_str()
.map(str::to_string)
.or_else(|| str_field(u, "url"))
})
.or_else(|| str_field(part, "url"))
{
if file_id.is_none()
&& detail.is_none()
&& let Some((media_type, data)) = super::split_data_url(&url)
{
return Some(IrPart::ImageBase64 {
media_type: media_type.to_string(),
data: data.to_string(),
});
}
return Some(IrPart::ImageUrl {
url,
detail,
bucket_owner: None,
file_id,
});
}
if let Some(data) =
str_field(part, "data").or_else(|| part.get("source").and_then(|s| str_field(s, "data")))
{
return Some(IrPart::ImageBase64 {
media_type: str_field(part, "media_type")
.or_else(|| part.get("source").and_then(|s| str_field(s, "media_type")))
.unwrap_or_else(|| "image/png".into()),
data,
});
}
file_id.map(|file_id| IrPart::ImageUrl {
url: String::new(),
detail,
bucket_owner: None,
file_id: Some(file_id),
})
}
fn encode_input_image(url: &str, detail: Option<&str>, file_id: Option<&str>) -> Value {
let mut image = json!({ "type": "input_image" });
if let Some(file_id) = file_id.filter(|id| !id.is_empty()) {
image["file_id"] = json!(file_id);
}
if !url.is_empty() {
image["image_url"] = json!(url);
}
if let Some(detail) = detail.filter(|text| !text.is_empty()) {
image["detail"] = json!(detail);
}
image
}
fn parts_text(parts: &[IrPart]) -> String {
parts
.iter()
.filter_map(|part| match part {
IrPart::Text(text) => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("")
}
fn decode_sampling(value: &Value) -> IrSampling {
let (json_schema, json_schema_name, json_schema_strict, json_schema_description) =
responses_json_schema(value);
let json_object = responses_json_object(value);
IrSampling {
temperature: f32_field(value, "temperature"),
top_p: f32_field(value, "top_p"),
top_k: None,
max_tokens: u32_field(value, "max_output_tokens")
.or_else(|| u32_field(value, "max_tokens")),
stop: stop_values(value, &["stop"]),
tool_choice: decode_tool_choice(value.get("tool_choice")),
parallel_tool_calls: bool_field(value, "parallel_tool_calls"),
store: bool_field(value, "store"),
previous_response_id: str_field(value, "previous_response_id"),
cache: IrCache::default(),
stream: bool_field(value, "stream"),
include_thoughts: value
.get("reasoning")
.and_then(|r| str_field(r, "summary"))
.filter(|s| !s.trim().is_empty())
.map(|_| true),
thinking_budget: None,
reasoning_effort: value
.get("reasoning")
.and_then(|r| str_field(r, "effort"))
.filter(|s| !s.trim().is_empty()),
max_reasoning_tokens: value
.get("reasoning")
.and_then(|r| u32_field(r, "max_tokens")),
json_schema,
json_schema_name,
json_schema_description,
json_schema_strict,
json_object,
include: decode_include(value),
prompt_cache_key: str_field(value, "prompt_cache_key").filter(|s| !s.trim().is_empty()),
prompt_cache_retention: str_field(value, "prompt_cache_retention")
.filter(|s| !s.trim().is_empty()),
prompt_cache_mode: value
.get("prompt_cache_options")
.and_then(|opts| str_field(opts, "mode"))
.filter(|s| !s.trim().is_empty()),
prompt_cache_ttl: value
.get("prompt_cache_options")
.and_then(|opts| str_field(opts, "ttl"))
.filter(|s| !s.trim().is_empty()),
top_logprobs: u32_field(value, "top_logprobs"),
moderation_model: value
.get("moderation")
.and_then(|m| str_field(m, "model"))
.filter(|s| !s.trim().is_empty()),
moderation_input: value
.pointer("/moderation/policy/input/mode")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string),
moderation_output: value
.pointer("/moderation/policy/output/mode")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string),
include_obfuscation: value
.get("stream_options")
.and_then(|opts| bool_field(opts, "include_obfuscation")),
service_tier: str_field(value, "service_tier").filter(|s| !s.trim().is_empty()),
user: str_field(value, "user").filter(|s| !s.trim().is_empty()),
verbosity: value
.pointer("/text/verbosity")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string),
safety_identifier: str_field(value, "safety_identifier").filter(|s| !s.trim().is_empty()),
metadata: string_object_field(value, "metadata"),
frequency_penalty: None,
presence_penalty: None,
seed: None,
n: None,
output_modalities: Vec::new(),
audio_voice: None,
audio_format: None,
logprobs: None,
logit_bias: std::collections::BTreeMap::new(),
prediction: None,
web_search_options: None,
inference_geo: None,
performance_latency: None,
previous_message_id: None,
response_field_paths: Vec::new(),
additional_request_fields: None,
prompt_variables: None,
guardrail: None,
container: None,
context_management: None,
mcp_servers: None,
media_resolution: None,
image_config: None,
audio_transcription_config: None,
translation_config: None,
affective_dialog: None,
enhanced_civic_answers: None,
multi_speaker_speech: None,
cached_content: None,
safety_settings: None,
request_labels: None,
truncation: str_field(value, "truncation").filter(|text| !text.trim().is_empty()),
max_tool_calls: u32_field(value, "max_tool_calls"),
background: bool_field(value, "background"),
conversation: responses_conversation(value),
responses_context_management: value
.get("context_management")
.filter(|mgmt| mgmt.is_array())
.cloned(),
messages_thinking: None,
}
}
fn responses_conversation(value: &Value) -> Option<Value> {
let conversation = value.get("conversation")?;
if conversation
.as_str()
.is_some_and(|text| !text.trim().is_empty())
|| conversation.is_object()
{
Some(conversation.clone())
} else {
None
}
}
fn decode_include(value: &Value) -> Vec<String> {
let Some(arr) = value.get("include").and_then(Value::as_array) else {
return Vec::new();
};
arr.iter()
.filter_map(|item| item.as_str().map(str::to_string))
.collect()
}
fn responses_json_object(value: &Value) -> Option<bool> {
let format = value.get("text").and_then(|t| t.get("format"))?;
(format.get("type").and_then(Value::as_str) == Some("json_object")).then_some(true)
}
fn responses_json_schema(
value: &Value,
) -> (Option<Value>, Option<String>, Option<bool>, Option<String>) {
let format = value.get("text").and_then(|t| t.get("format"));
let Some(format) = format else {
return (None, None, None, None);
};
if format.get("type").and_then(Value::as_str) != Some("json_schema") {
return (None, None, None, None);
}
let schema = format.get("schema").cloned();
let name = format
.get("name")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string);
let strict = bool_field(format, "strict");
let description = format
.get("description")
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::to_string);
(schema, name, strict, description)
}
fn decode_tool_choice(value: Option<&Value>) -> IrToolChoice {
let Some(value) = value else {
return IrToolChoice::Auto;
};
if let Some(s) = value.as_str() {
return match s {
"none" => IrToolChoice::None,
"required" => IrToolChoice::Required,
_ => IrToolChoice::Auto,
};
}
if let Some(name) = value.get("name").and_then(Value::as_str).or_else(|| {
value
.get("function")
.and_then(|f| f.get("name"))
.and_then(Value::as_str)
}) {
return IrToolChoice::Named(name.to_string());
}
match value.get("type").and_then(Value::as_str) {
Some("none") => IrToolChoice::None,
Some("required") => IrToolChoice::Required,
_ => IrToolChoice::Auto,
}
}
pub(super) fn encode(
ir: &IrRequest,
tools: &[PreparedTool],
profile: &ResolvedProfile,
report: &mut LossReport,
) -> Result<Value, MapError> {
let restore_calls = matches!(profile.dialect.tool_type_policy, ToolTypePolicy::HardError);
let (instructions, input) = encode_items(ir, restore_calls, report);
if input.as_array().is_none_or(|rows| rows.is_empty()) {
let message = if instructions.is_some() {
"responses input must not be empty: instructions need an input turn".to_string()
} else {
let dropped = super::dropped_details(report);
if dropped.is_empty() {
"responses input must not be empty".to_string()
} else {
format!("responses input must not be empty: {}", dropped.join("; "))
}
};
return Err(MapError::Invalid(message));
}
let mut body = json!({
"model": ir.model,
"input": input,
});
if let Some(instructions) = instructions {
body["instructions"] = json!(instructions);
}
if !tools.is_empty() {
body["tools"] = Value::Array(tools.iter().map(encode_tool).collect());
}
if !super::gpt5_sampling_params_rejected(&ir.model, ir.sampling.reasoning_effort.as_deref()) {
super::reject_sampling_number(
ir.sampling.temperature,
0.0,
2.0,
"sampling.temperature",
"temperature must be between 0 and 2",
)?;
super::reject_sampling_number(
ir.sampling.top_p,
0.0,
1.0,
"sampling.top_p",
"top_p must be between 0 and 1",
)?;
}
if ir.sampling.max_tokens == Some(0) {
return Err(MapError::hard(
"sampling.max_tokens",
"max_tokens must be at least 1",
));
}
encode_sampling(ir, &mut body, report);
Ok(body)
}
fn role_text_message(role: &str, text: &str, mode: &str) -> Value {
json!({
"role": role,
"content": [{
"type": "input_text",
"text": text,
"prompt_cache_breakpoint": {"mode": mode}
}]
})
}
fn encode_items(
ir: &IrRequest,
restore_calls: bool,
report: &mut LossReport,
) -> (Option<String>, Value) {
let mut instructions = Vec::new();
let mut input = Vec::new();
for (idx, item) in ir.items.iter().enumerate() {
match item {
IrItem::System {
text,
prompt_cache_breakpoint,
content_parts,
} => {
if !content_parts.is_empty() {
input.push(json!({
"role": "system",
"content": encode_parts(content_parts, true, report),
}));
} else if let Some(mode) = prompt_cache_breakpoint
.as_deref()
.filter(|mode| !mode.is_empty())
{
input.push(role_text_message("system", text, mode));
} else {
if !input.is_empty() {
report.record(
format!("items[{idx}]"),
LossAction::Degrade,
"system message moved out of the turn list",
);
}
instructions.push(text.clone());
}
}
IrItem::SystemCache { .. } => {
report.record(
format!("items[{idx}]"),
LossAction::Drop,
"system cache point has no slot",
);
}
IrItem::SystemGuard { .. } => {
report.record(
format!("items[{idx}]"),
LossAction::Drop,
"system guard content has no slot",
);
}
IrItem::Developer {
text,
prompt_cache_breakpoint,
content_parts,
} => {
if !content_parts.is_empty() {
input.push(json!({
"role": "developer",
"content": encode_parts(content_parts, true, report),
}));
} else if let Some(mode) = prompt_cache_breakpoint
.as_deref()
.filter(|mode| !mode.is_empty())
{
input.push(role_text_message("developer", text, mode));
} else {
report.record(
format!("items[{idx}]"),
LossAction::Degrade,
"developer to system",
);
if !input.is_empty() {
report.record(
format!("items[{idx}]"),
LossAction::Degrade,
"system message moved out of the turn list",
);
}
instructions.push(text.clone());
}
}
IrItem::User { parts } => input.push(json!({
"role": "user",
"content": encode_parts(parts, true, report),
})),
IrItem::Assistant { parts, phase } => {
let rest = peel_signed_thinking(parts, &mut input, report);
if !rest.is_empty() || !parts.iter().any(is_signed_thinking) {
let mut message = json!({
"type": "message",
"role": "assistant",
"content": encode_parts(&rest, false, report),
});
if let Some(phase) = super::assistant_phase(phase) {
message["phase"] = json!(phase);
}
input.push(message);
} else if super::assistant_phase(phase).is_some() {
report.record(
format!("items[{idx}].phase"),
LossAction::Drop,
"phase has no assistant message",
);
}
}
IrItem::FunctionCall {
call_id,
name,
arguments,
thought_signature,
} => {
if thought_signature.is_some() {
report.record(
"item.function_call",
LossAction::Drop,
"thoughtSignature has no Responses slot",
);
}
input.push(encode_function_call(
call_id,
name,
arguments,
restore_calls,
));
}
IrItem::CustomToolCall {
call_id,
name,
input: tool_input,
responses_item,
} => {
let mut item = match responses_item {
Some(value) if value.is_object() => value.clone(),
_ => json!({ "type": "custom_tool_call" }),
};
item["type"] = json!("custom_tool_call");
item["call_id"] = json!(call_id);
item["name"] = json!(name);
item["input"] = json!(tool_input);
input.push(item);
}
IrItem::FunctionOutput {
call_id,
output,
parts,
is_error,
} => input.push(encode_function_output(
call_id, output, parts, *is_error, idx, report,
)),
IrItem::Reasoning {
encrypted,
summary,
raw,
} => input.push(encode_reasoning(
encrypted.as_deref(),
summary.as_deref(),
raw.as_ref(),
)),
IrItem::Unknown { type_name, .. }
if matches!(type_name.as_str(), "toolAddition" | "toolRemoval") =>
{
report.record(
format!("items[{idx}]"),
LossAction::Drop,
"tool addition or removal has no slot",
);
}
IrItem::HostedToolCall { raw, .. } | IrItem::Unknown { raw, .. } => {
input.push(raw.clone());
}
}
}
let instructions = if instructions.is_empty() {
None
} else {
Some(instructions.join("\n\n"))
};
(instructions, Value::Array(input))
}
fn encode_function_call(call_id: &str, name: &str, arguments: &str, restore: bool) -> Value {
if restore && let Some((ns, leaf)) = split_namespace_name(name) {
return json!({
"type": "function_call",
"call_id": call_id,
"name": leaf,
"namespace": ns,
"arguments": arguments,
});
}
json!({
"type": "function_call",
"call_id": call_id,
"name": name,
"arguments": arguments,
})
}
fn encode_reasoning(encrypted: Option<&str>, summary: Option<&str>, raw: Option<&Value>) -> Value {
if let Some(raw) = raw {
return raw.clone();
}
let mut obj = json!({"type": "reasoning"});
if let Some(enc) = encrypted {
obj["encrypted_content"] = json!(enc);
}
if let Some(summary) = summary {
obj["summary"] = json!([{"type": "summary_text", "text": summary}]);
}
obj
}
fn is_signed_thinking(part: &IrPart) -> bool {
matches!(
part,
IrPart::Thinking {
signature: Some(sig),
..
} if !sig.is_empty()
)
}
fn peel_signed_thinking(
parts: &[IrPart],
input: &mut Vec<Value>,
report: &mut LossReport,
) -> Vec<IrPart> {
let mut rest = Vec::with_capacity(parts.len());
for part in parts {
match part {
IrPart::Thinking { text, signature }
if signature.as_deref().is_some_and(|sig| !sig.is_empty()) =>
{
let summary = if text.is_empty() {
None
} else {
Some(text.as_str())
};
input.push(encode_reasoning(signature.as_deref(), summary, None));
report.record(
"part.thinking",
LossAction::Preserve,
"signed thinking remapped to reasoning",
);
}
other => rest.push(other.clone()),
}
}
rest
}
fn messages_file_image_raw(raw: &Value) -> bool {
raw.get("type").and_then(Value::as_str) == Some("image")
&& raw.pointer("/source/type").and_then(Value::as_str) == Some("file")
}
fn encode_parts(parts: &[IrPart], input: bool, report: &mut LossReport) -> Value {
let text_ty = if input { "input_text" } else { "output_text" };
let visible: Vec<&IrPart> = parts
.iter()
.filter(|part| {
let (part, _) = super::peel_prompt_cache_all(part);
match part {
IrPart::ImageUrl { url, file_id, .. }
if url.trim().is_empty()
&& file_id.as_deref().unwrap_or("").trim().is_empty() =>
{
report.record("part.image", LossAction::Drop, "image url is empty");
false
}
IrPart::Audio { data, .. } if data.trim().is_empty() => {
report.record("part.audio", LossAction::Drop, "audio data is empty");
false
}
IrPart::ImageBase64 { data, .. } if data.trim().is_empty() => {
report.record("part.image", LossAction::Drop, "image data is empty");
false
}
IrPart::ImageUrl { url, .. }
if super::split_data_url(url)
.is_some_and(|(_, data)| data.trim().is_empty()) =>
{
report.record("part.image", LossAction::Drop, "image data is empty");
false
}
IrPart::Thinking { .. } => {
report.record(
"part.thinking",
LossAction::Drop,
"thinking has no Responses slot",
);
false
}
IrPart::Raw { raw, .. } => {
if let Some(IrPart::Audio { data, .. }) = super::plain_media_part(part)
&& data.trim().is_empty()
{
report.record("part.audio", LossAction::Drop, "audio data is empty");
false
} else if let Some(IrPart::ImageBase64 { data, .. }) =
super::plain_media_part(part)
&& data.trim().is_empty()
{
report.record("part.image", LossAction::Drop, "image data is empty");
false
} else if super::plain_media_part(part).is_some() {
report.record("part.media_hint", LossAction::Drop, "no slot");
true
} else if messages_file_image_raw(raw) {
report.record(
"part.raw",
LossAction::Drop,
"Messages image file_id has no Responses slot",
);
false
} else if responses_raw_passthrough(raw) {
true
} else {
report.record(
off_dialect_raw_path(raw),
LossAction::Drop,
"raw part is not Responses-shaped",
);
false
}
}
_ => true,
}
})
.collect();
if visible.len() == 1
&& let IrPart::Text(text) = visible[0]
&& input
{
return Value::Array(vec![json!({"type": text_ty, "text": text})]);
}
Value::Array(
visible
.iter()
.filter_map(|part| {
let (part, mode) = super::peel_prompt_cache_all(part);
let mut value = match part {
IrPart::Text(text) => json!({"type": text_ty, "text": text}),
IrPart::ImageUrl {
url,
detail,
bucket_owner: _,
file_id,
} => {
if super::split_data_url(url)
.is_some_and(|(_, data)| data.trim().is_empty())
{
report.record("part.image", LossAction::Drop, "image data is empty");
return None;
}
encode_input_image(url, detail.as_deref(), file_id.as_deref())
}
IrPart::ImageBase64 { media_type, data } => {
if data.trim().is_empty() {
report.record("part.image", LossAction::Drop, "image data is empty");
return None;
}
json!({
"type": "input_image",
"image_url": format!("data:{media_type};base64,{data}")
})
}
IrPart::Document {
source,
media_type,
name,
..
} => encode_document(source, media_type, name.as_deref(), text_ty, report)?,
IrPart::Audio { data, format } => {
if data.trim().is_empty() {
report.record("part.audio", LossAction::Drop, "audio data is empty");
return None;
}
json!({
"type": "input_audio",
"input_audio": { "data": data, "format": format }
})
}
IrPart::Raw { raw, .. } => {
if let Some(plain) = super::plain_media_part(part) {
match plain {
IrPart::ImageBase64 { media_type, data } => {
if data.trim().is_empty() {
report.record(
"part.image",
LossAction::Drop,
"image data is empty",
);
return None;
}
json!({
"type": "input_image",
"image_url": format!("data:{media_type};base64,{data}")
})
}
IrPart::Document {
source,
media_type,
name,
..
} => encode_document(
&source,
&media_type,
name.as_deref(),
text_ty,
report,
)?,
IrPart::Audio { data, format } => {
if data.trim().is_empty() {
report.record(
"part.audio",
LossAction::Drop,
"audio data is empty",
);
return None;
}
json!({
"type": "input_audio",
"input_audio": { "data": data, "format": format }
})
}
IrPart::Text(text) => json!({"type": text_ty, "text": text}),
_ => raw.clone(),
}
} else {
raw.clone()
}
}
IrPart::Thinking { .. } => {
report.record(
"part.thinking",
LossAction::Drop,
"thinking has no Responses slot",
);
return None;
}
IrPart::PromptCacheBreakpoint { .. } => return None,
};
super::apply_prompt_cache(&mut value, mode, true, report);
Some(value)
})
.collect(),
)
}
fn encode_document(
source: &IrDocumentSource,
media_type: &str,
name: Option<&str>,
text_ty: &str,
report: &mut LossReport,
) -> Option<Value> {
if let IrDocumentSource::S3 { .. } = source {
report.record(
"part.document",
LossAction::Drop,
"S3 document has no Responses slot",
);
return None;
}
if let IrDocumentSource::Text(data) = source {
report.record(
"part.document",
LossAction::Degrade,
"plain text document becomes text",
);
return Some(json!({ "type": text_ty, "text": data }));
}
let mut obj = json!({ "type": "input_file" });
if let Some(name) = name.map(str::trim).filter(|s| !s.is_empty()) {
obj["filename"] = json!(name);
}
match source {
IrDocumentSource::Text(_) | IrDocumentSource::S3 { .. } => unreachable!("returned above"),
IrDocumentSource::Base64(data) => {
if data.trim().is_empty() {
report.record("part.document", LossAction::Drop, "document data is empty");
return None;
}
let media_type = if media_type.is_empty() {
"application/pdf"
} else {
media_type
};
if obj.get("filename").and_then(Value::as_str).is_none() {
obj["filename"] = json!(super::document_filename(None, media_type));
}
obj["file_data"] = json!(format!("data:{media_type};base64,{data}"));
}
IrDocumentSource::Url(url) => {
obj["file_url"] = json!(url);
}
IrDocumentSource::FileId(id) => {
if id.trim().is_empty() {
report.record("part.document", LossAction::Drop, "file id is empty");
return None;
}
obj["file_id"] = json!(id);
}
}
Some(obj)
}
fn encode_function_output(
call_id: &str,
output: &str,
parts: &[IrPart],
is_error: bool,
idx: usize,
report: &mut LossReport,
) -> Value {
if is_error {
report.record(
format!("items[{idx}].is_error"),
LossAction::Drop,
"no slot",
);
}
let ordered_text = parts
.iter()
.any(|part| matches!(part, IrPart::Text(text) if !text.is_empty()));
let text = if ordered_text {
String::new()
} else {
super::function_output_text(output, parts)
};
let mut content = Vec::new();
if !text.is_empty() {
content.push(json!({"type": "input_text", "text": text}));
}
let mut kept_structured = false;
for part in parts {
match part {
IrPart::Text(text) if ordered_text && !text.is_empty() => {
content.push(json!({"type": "input_text", "text": text}));
kept_structured = true;
}
IrPart::Text(_) => {}
IrPart::ImageUrl {
url,
detail,
bucket_owner: _,
file_id,
} => {
if url.trim().is_empty() && file_id.as_deref().unwrap_or("").trim().is_empty() {
report.record("part.image", LossAction::Drop, "image url is empty");
} else if super::split_data_url(url).is_some_and(|(_, data)| data.trim().is_empty())
{
report.record("part.image", LossAction::Drop, "image data is empty");
} else {
content.push(encode_input_image(
url,
detail.as_deref(),
file_id.as_deref(),
));
kept_structured = true;
}
}
IrPart::ImageBase64 { media_type, data } => {
if data.trim().is_empty() {
report.record("part.image", LossAction::Drop, "image data is empty");
} else {
content.push(json!({
"type": "input_image",
"image_url": format!("data:{media_type};base64,{data}")
}));
kept_structured = true;
}
}
IrPart::Document {
source,
media_type,
name,
..
} => {
if let Some(value) =
encode_document(source, media_type, name.as_deref(), "input_text", report)
{
content.push(value);
kept_structured = true;
}
}
IrPart::Raw { type_name, raw } if type_name == "json" && ordered_text => {
content.push(json!({"type": "input_text", "text": raw.to_string()}));
kept_structured = true;
}
IrPart::Raw { type_name, .. } if type_name == "json" => {}
IrPart::Raw { .. } => {
if let Some((media_type, data)) = super::function_response_image(part) {
content.push(json!({
"type": "input_image",
"image_url": format!("data:{media_type};base64,{data}")
}));
kept_structured = true;
} else {
report.record(
format!("items[{idx}].part"),
LossAction::Drop,
"function_call_output has no slot",
);
}
}
IrPart::PromptCacheBreakpoint { inner, mode } => {
let before = content.len();
let encoded = encode_parts(std::slice::from_ref(inner.as_ref()), true, report);
if let Some(mut items) = encoded.as_array().cloned() {
for item in &mut items {
super::apply_prompt_cache(item, Some(mode), true, report);
}
content.extend(items);
}
if content.len() == before {
super::apply_prompt_cache(
&mut serde_json::Value::Null,
Some(mode),
false,
report,
);
}
if content.len() > before {
kept_structured = true;
}
}
_ => report.record(
format!("items[{idx}].part"),
LossAction::Drop,
"function_call_output has no slot",
),
}
}
let output_value = if kept_structured {
Value::Array(content)
} else if !ordered_text && !text.is_empty() {
json!(text)
} else {
json!(output)
};
json!({
"type": "function_call_output",
"call_id": call_id,
"output": output_value,
})
}
fn encode_tool(tool: &PreparedTool) -> Value {
match tool {
PreparedTool::Function {
name,
description,
parameters,
strict,
} => {
let mut parameters = parameters.clone();
super::messages::normalize_object_schema_required(&mut parameters);
if *strict == Some(true) {
super::messages::strict_object_schema(&mut parameters);
}
let mut tool = json!({
"type": "function",
"name": name,
"description": description,
"parameters": parameters,
});
if let Some(strict) = strict {
tool["strict"] = json!(strict);
}
tool
}
PreparedTool::Raw(raw) => raw.clone(),
}
}
fn encode_responses_request_fields(s: &IrSampling, body: &mut Value, report: &mut LossReport) {
if let Some(truncation) = s
.truncation
.as_deref()
.filter(|text| !text.trim().is_empty())
{
body["truncation"] = json!(truncation);
report.record(
"sampling.truncation",
LossAction::Preserve,
"responses truncation",
);
}
if let Some(max) = s.max_tool_calls {
body["max_tool_calls"] = json!(max);
report.record(
"sampling.max_tool_calls",
LossAction::Preserve,
"responses max_tool_calls",
);
}
if let Some(background) = s.background {
body["background"] = json!(background);
report.record(
"sampling.background",
LossAction::Preserve,
"responses background",
);
}
if let Some(conversation) = &s.conversation {
body["conversation"] = conversation.clone();
report.record(
"sampling.conversation",
LossAction::Preserve,
"responses conversation",
);
}
if let Some(mgmt) = &s.responses_context_management {
body["context_management"] = mgmt.clone();
report.record(
"sampling.responses_context_management",
LossAction::Preserve,
"responses context_management",
);
}
}
fn encode_sampling(ir: &IrRequest, body: &mut Value, report: &mut LossReport) {
let s = &ir.sampling;
let reject_gpt5_sampling =
super::gpt5_sampling_params_rejected(&ir.model, s.reasoning_effort.as_deref());
if let Some(t) = s.temperature {
if reject_gpt5_sampling {
report.record(
"sampling.temperature",
LossAction::Drop,
"gpt-5 family Responses reject temperature unless gpt-5.1 or gpt-5.2 reasoning effort is none",
);
} else {
body["temperature"] = super::json_f32(t);
}
}
if let Some(p) = s.top_p {
if reject_gpt5_sampling {
report.record(
"sampling.top_p",
LossAction::Drop,
"gpt-5 family Responses reject top_p unless gpt-5.1 or gpt-5.2 reasoning effort is none",
);
} else {
body["top_p"] = super::json_f32(p);
}
}
if s.top_k.is_some() {
report.record("sampling.top_k", LossAction::Drop, "no slot");
}
if s.inference_geo.is_some() {
report.record("sampling.inference_geo", LossAction::Drop, "no slot");
}
if s.performance_latency.is_some() {
report.record("sampling.performance_latency", LossAction::Drop, "no slot");
}
if s.previous_message_id.is_some() {
report.record("sampling.previous_message_id", LossAction::Drop, "no slot");
}
if !s.response_field_paths.is_empty() {
report.record("sampling.response_field_paths", LossAction::Drop, "no slot");
}
if s.additional_request_fields.is_some() {
report.record(
"sampling.additional_request_fields",
LossAction::Drop,
"no slot",
);
}
if s.prompt_variables.is_some() {
report.record("sampling.prompt_variables", LossAction::Drop, "no slot");
}
if s.guardrail.is_some() {
report.record("sampling.guardrail", LossAction::Drop, "no slot");
}
if s.container.is_some() {
report.record("sampling.container", LossAction::Drop, "no slot");
}
if s.context_management.is_some() {
report.record("sampling.context_management", LossAction::Drop, "no slot");
}
encode_responses_request_fields(s, body, report);
if s.mcp_servers.is_some() {
report.record("sampling.mcp_servers", LossAction::Drop, "no slot");
}
if let Some(max) = s.max_tokens {
body["max_output_tokens"] = json!(max);
}
if !s.stop.is_empty() {
report.record("sampling.stop", LossAction::Drop, "no slot");
}
encode_tool_choice(&s.tool_choice, body);
if let Some(parallel) = s.parallel_tool_calls {
body["parallel_tool_calls"] = json!(parallel);
report.record(
"sampling.parallel_tool_calls",
LossAction::Preserve,
"responses parallel_tool_calls",
);
}
if let Some(store) = s.store {
body["store"] = json!(store);
report.record("sampling.store", LossAction::Preserve, "responses store");
}
if let Some(id) = &s.previous_response_id {
body["previous_response_id"] = json!(id);
report.record(
"sampling.previous_response_id",
LossAction::Preserve,
"responses previous_response_id",
);
}
if s.cache.enabled {
report.record("sampling.cache", LossAction::Drop, "no slot");
}
if let Some(key) = s
.prompt_cache_key
.as_deref()
.filter(|s| !s.trim().is_empty())
{
body["prompt_cache_key"] = json!(key);
report.record(
"sampling.prompt_cache_key",
LossAction::Preserve,
"responses prompt_cache_key",
);
}
if let Some(retention) = s
.prompt_cache_retention
.as_deref()
.filter(|s| !s.trim().is_empty())
{
body["prompt_cache_retention"] = json!(retention);
report.record(
"sampling.prompt_cache_retention",
LossAction::Preserve,
"responses prompt_cache_retention",
);
}
encode_prompt_cache_options(s, body, report);
if let Some(n) = s.top_logprobs {
if reject_gpt5_sampling {
report.record(
"sampling.top_logprobs",
LossAction::Drop,
"gpt-5 family Responses reject top_logprobs unless gpt-5.1 or gpt-5.2 reasoning effort is none",
);
} else {
body["top_logprobs"] = json!(n);
report.record(
"sampling.top_logprobs",
LossAction::Preserve,
"responses top_logprobs",
);
}
}
encode_moderation(s, body, report);
if let Some(user) = s.user.as_deref().filter(|s| !s.trim().is_empty()) {
body["user"] = json!(user);
}
if let Some(id) = s
.safety_identifier
.as_deref()
.filter(|s| !s.trim().is_empty())
{
body["safety_identifier"] = json!(id);
report.record(
"sampling.safety_identifier",
LossAction::Preserve,
"responses safety_identifier",
);
}
if !s.metadata.is_empty() {
body["metadata"] = json!(s.metadata);
report.record(
"sampling.metadata",
LossAction::Preserve,
"responses metadata",
);
}
if let Some(verbosity) = s.verbosity.as_deref().filter(|s| !s.trim().is_empty()) {
if !body.get("text").is_some_and(Value::is_object) {
body["text"] = json!({});
}
body["text"]["verbosity"] = json!(verbosity);
report.record(
"sampling.verbosity",
LossAction::Preserve,
"responses text.verbosity",
);
}
if let Some(tier) = s.service_tier.as_deref().filter(|s| !s.trim().is_empty()) {
body["service_tier"] = json!(tier);
report.record(
"sampling.service_tier",
LossAction::Preserve,
"responses service_tier",
);
}
if let Some(stream) = s.stream {
body["stream"] = json!(stream);
}
if let Some(include_obfuscation) = s.include_obfuscation {
if !body.get("stream_options").is_some_and(Value::is_object) {
body["stream_options"] = json!({});
}
body["stream_options"]["include_obfuscation"] = json!(include_obfuscation);
report.record(
"sampling.include_obfuscation",
LossAction::Preserve,
"responses stream_options.include_obfuscation",
);
}
if !s.include.is_empty() {
body["include"] = json!(s.include);
}
if let Some(effort) = s
.reasoning_effort
.as_deref()
.filter(|s| !s.trim().is_empty())
{
body["reasoning"] = json!({ "effort": effort });
}
if s.include_thoughts == Some(true) {
if !body.get("reasoning").is_some_and(Value::is_object) {
body["reasoning"] = json!({});
}
body["reasoning"]["summary"] = json!("auto");
report.record(
"sampling.include_thoughts",
LossAction::Preserve,
"responses reasoning.summary",
);
} else if s.include_thoughts == Some(false) {
report.record("sampling.include_thoughts", LossAction::Drop, "no slot");
}
if s.max_reasoning_tokens.is_some() {
report.record("sampling.max_reasoning_tokens", LossAction::Drop, "no slot");
}
if s.thinking_budget.is_some() {
report.record("sampling.thinking_budget", LossAction::Drop, "no slot");
}
drop_messages_native_thinking(s, report);
drop_dest_n_and_penalties(s, report);
drop_dest_output_modalities(s, report);
drop_dest_logprobs(s, report);
drop_dest_chat_logit_bias(s, report);
if let Some(schema) = &s.json_schema
&& let Some((schema, name)) =
super::official_json_schema(schema, s.json_schema_name.as_deref(), report)
{
if !body.get("text").is_some_and(Value::is_object) {
body["text"] = json!({});
}
let mut schema = schema.clone();
if s.json_schema_strict == Some(true) {
super::messages::normalize_object_schema_required(&mut schema);
super::messages::strict_object_schema(&mut schema);
}
let mut format = json!({
"type": "json_schema",
"name": name,
"schema": schema,
});
if let Some(strict) = s.json_schema_strict {
format["strict"] = json!(strict);
}
if let Some(description) = s
.json_schema_description
.as_deref()
.map(str::trim)
.filter(|text| !text.is_empty())
{
format["description"] = json!(description);
}
body["text"]["format"] = format;
} else if s.json_object == Some(true) {
if !body.get("text").is_some_and(Value::is_object) {
body["text"] = json!({});
}
body["text"]["format"] = json!({ "type": "json_object" });
}
}
fn encode_prompt_cache_options(s: &IrSampling, body: &mut Value, report: &mut LossReport) {
let mode = s
.prompt_cache_mode
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty());
let ttl = s
.prompt_cache_ttl
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty());
if mode.is_none() && ttl.is_none() {
return;
}
let mut opts = serde_json::Map::new();
if let Some(mode) = mode {
opts.insert("mode".into(), json!(mode));
report.record(
"sampling.prompt_cache_mode",
LossAction::Preserve,
"responses prompt_cache_options.mode",
);
}
if let Some(ttl) = ttl {
opts.insert("ttl".into(), json!(ttl));
report.record(
"sampling.prompt_cache_ttl",
LossAction::Preserve,
"responses prompt_cache_options.ttl",
);
}
body["prompt_cache_options"] = Value::Object(opts);
}
fn encode_moderation(s: &IrSampling, body: &mut Value, report: &mut LossReport) {
let model = s
.moderation_model
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty());
let input = s
.moderation_input
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty());
let output = s
.moderation_output
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty());
if model.is_none() && input.is_none() && output.is_none() {
return;
}
let mut obj = serde_json::Map::new();
if let Some(model) = model {
obj.insert("model".into(), json!(model));
report.record(
"sampling.moderation_model",
LossAction::Preserve,
"responses moderation.model",
);
}
if input.is_some() || output.is_some() {
let mut policy = serde_json::Map::new();
if let Some(mode) = input {
policy.insert("input".into(), json!({ "mode": mode }));
report.record(
"sampling.moderation_input",
LossAction::Preserve,
"responses moderation.policy.input.mode",
);
}
if let Some(mode) = output {
policy.insert("output".into(), json!({ "mode": mode }));
report.record(
"sampling.moderation_output",
LossAction::Preserve,
"responses moderation.policy.output.mode",
);
}
obj.insert("policy".into(), Value::Object(policy));
}
body["moderation"] = Value::Object(obj);
}
fn encode_tool_choice(choice: &IrToolChoice, body: &mut Value) {
match choice {
IrToolChoice::Auto => {}
IrToolChoice::None => body["tool_choice"] = json!("none"),
IrToolChoice::Required => body["tool_choice"] = json!("required"),
IrToolChoice::Named(name) => {
body["tool_choice"] = json!({"type": "function", "name": name});
}
}
}