use std::collections::BTreeMap;
use serde_json::{Value, json};
use super::tools::{PreparedTool, decode_tool};
use super::{
MapError, bool_field, decode_input_audio_part, decode_openai_file_part,
drop_messages_native_thinking, drop_responses_request_fields, f32_field, i64_field,
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 messages = value
.get("messages")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut items = Vec::new();
let mut report = LossReport::default();
for msg in &messages {
decode_message(msg, &mut items, &mut report);
}
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_message(msg: &Value, items: &mut Vec<IrItem>, report: &mut LossReport) {
let role = msg.get("role").and_then(Value::as_str).unwrap_or("user");
let mut parts = decode_content(msg.get("content"));
if role == "assistant"
&& let Some(refusal) = str_field(msg, "refusal").filter(|text| !text.is_empty())
{
report.record(
"messages.refusal",
LossAction::Degrade,
"refusal kept as text",
);
parts.push(IrPart::Text(refusal));
}
match role {
"system" => items.push(IrItem::System {
text: parts_text(&parts),
prompt_cache_breakpoint: super::role_text_breakpoint(&parts),
content_parts: super::role_content_parts(&parts),
}),
"developer" => items.push(IrItem::Developer {
text: parts_text(&parts),
prompt_cache_breakpoint: super::role_text_breakpoint(&parts),
content_parts: super::role_content_parts(&parts),
}),
"assistant" => {
if !parts.is_empty() || msg.get("tool_calls").is_none() {
items.push(IrItem::Assistant { parts, phase: None });
}
if let Some(calls) = msg.get("tool_calls").and_then(Value::as_array) {
for call in calls {
items.push(decode_tool_call(call));
}
}
}
"tool" => {
let has_breakpoint = parts
.iter()
.any(|part| super::peel_prompt_cache(part).1.is_some());
let (output, kept) = if has_breakpoint {
let kept = parts
.iter()
.filter(|part| {
matches!(
super::peel_prompt_cache(part).0,
IrPart::Text(_) | IrPart::ImageUrl { .. } | IrPart::ImageBase64 { .. }
)
})
.cloned()
.collect();
(String::new(), kept)
} else {
let images = parts
.iter()
.filter(|part| {
matches!(part, IrPart::ImageUrl { .. } | IrPart::ImageBase64 { .. })
})
.cloned()
.collect();
(parts_text(&parts), images)
};
items.push(IrItem::FunctionOutput {
call_id: str_field(msg, "tool_call_id").unwrap_or_default(),
output,
parts: kept,
is_error: false,
});
}
_ => items.push(IrItem::User { parts }),
}
}
fn decode_tool_call(call: &Value) -> IrItem {
if is_custom_tool_call(call) {
let custom = call.get("custom").unwrap_or(call);
return IrItem::CustomToolCall {
call_id: str_field(call, "id").unwrap_or_default(),
name: str_field(custom, "name").unwrap_or_default(),
input: custom.get("input").map(value_as_string).unwrap_or_default(),
responses_item: None,
};
}
let func = call.get("function").unwrap_or(call);
IrItem::FunctionCall {
call_id: str_field(call, "id").unwrap_or_default(),
name: str_field(func, "name").unwrap_or_default(),
arguments: func
.get("arguments")
.map(value_as_string)
.unwrap_or_else(|| "{}".into()),
thought_signature: None,
}
}
fn is_custom_tool_call(call: &Value) -> bool {
match call.get("type").and_then(Value::as_str) {
Some("custom") => true,
Some(_) => false,
None => call.get("custom").is_some() && call.get("function").is_none(),
}
}
fn decode_content(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 decoded = match part.get("type").and_then(Value::as_str).unwrap_or("text") {
"text" => part
.get("text")
.and_then(Value::as_str)
.map(|t| IrPart::Text(t.to_string())),
"image_url" => {
let image = part.get("image_url")?;
let url = image
.as_str()
.map(str::to_string)
.or_else(|| str_field(image, "url"))?;
let detail = image
.get("detail")
.and_then(Value::as_str)
.filter(|detail| !detail.is_empty() && *detail != "auto")
.map(str::to_string);
if detail.is_none()
&& let Some((media_type, data)) = super::split_data_url(&url)
{
Some(IrPart::ImageBase64 {
media_type: media_type.to_string(),
data: data.to_string(),
})
} else {
Some(IrPart::ImageUrl {
url,
detail,
bucket_owner: None,
file_id: None,
})
}
}
"file" => decode_openai_file_part(part),
"input_audio" => decode_input_audio_part(part),
"refusal" => part
.get("refusal")
.or_else(|| part.get("text"))
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(|text| IrPart::Text(text.to_string())),
"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())),
};
super::with_prompt_cache(decoded, part)
}
fn role_text_content(text: &str, mode: Option<&str>) -> Value {
let Some(mode) = mode.filter(|mode| !mode.is_empty()) else {
return json!(text);
};
json!([{
"type": "text",
"text": text,
"prompt_cache_breakpoint": {"mode": mode}
}])
}
fn parts_text(parts: &[IrPart]) -> String {
parts
.iter()
.filter_map(|part| {
let (part, _) = super::peel_prompt_cache(part);
match part {
IrPart::Text(text) => Some(text.as_str()),
IrPart::Thinking { 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) =
chat_json_schema(value);
let json_object = chat_json_object(value);
let top_logprobs = u32_field(value, "top_logprobs");
let logprobs = bool_field(value, "logprobs").or(top_logprobs.is_some().then_some(true));
IrSampling {
temperature: f32_field(value, "temperature"),
top_p: f32_field(value, "top_p"),
top_k: None,
max_tokens: u32_field(value, "max_completion_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: None,
thinking_budget: None,
reasoning_effort: str_field(value, "reasoning_effort").filter(|s| !s.trim().is_empty()),
max_reasoning_tokens: u32_field(value, "max_reasoning_tokens"),
json_schema,
json_schema_name,
json_schema_description,
json_schema_strict,
json_object,
include: Vec::new(),
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,
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: str_field(value, "verbosity").filter(|s| !s.trim().is_empty()),
safety_identifier: str_field(value, "safety_identifier").filter(|s| !s.trim().is_empty()),
metadata: string_object_field(value, "metadata"),
frequency_penalty: f32_field(value, "frequency_penalty"),
presence_penalty: f32_field(value, "presence_penalty"),
seed: i64_field(value, "seed"),
n: u32_field(value, "n"),
output_modalities: chat_output_modalities(value),
audio_voice: chat_audio_voice(value),
audio_format: chat_audio_format(value),
logprobs,
logit_bias: chat_logit_bias(value),
prediction: chat_object_field(value, "prediction"),
web_search_options: chat_object_field(value, "web_search_options"),
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: None,
max_tool_calls: None,
background: None,
conversation: None,
responses_context_management: None,
messages_thinking: None,
}
}
fn chat_logit_bias(value: &Value) -> BTreeMap<String, f64> {
let Some(obj) = value.get("logit_bias").and_then(Value::as_object) else {
return BTreeMap::new();
};
obj.iter()
.filter_map(|(k, v)| v.as_f64().map(|n| (k.clone(), n)))
.collect()
}
fn chat_object_field(value: &Value, key: &str) -> Option<Value> {
value
.get(key)
.filter(|v| v.as_object().is_some_and(|o| !o.is_empty()))
.cloned()
}
fn chat_output_modalities(value: &Value) -> Vec<String> {
let Some(arr) = value.get("modalities").and_then(Value::as_array) else {
return Vec::new();
};
arr.iter()
.filter_map(Value::as_str)
.map(|raw| {
if raw.eq_ignore_ascii_case("text") {
"text".to_string()
} else if raw.eq_ignore_ascii_case("audio") {
"audio".to_string()
} else {
raw.to_string()
}
})
.collect()
}
fn chat_audio_voice(value: &Value) -> Option<String> {
let audio = value.get("audio")?;
if let Some(voice) = audio.get("voice").and_then(Value::as_str) {
let trimmed = voice.trim();
if trimmed.is_empty() {
return None;
}
return Some(trimmed.to_string());
}
audio
.get("voice")
.and_then(|v| str_field(v, "id"))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn chat_audio_format(value: &Value) -> Option<String> {
value
.get("audio")
.and_then(|audio| str_field(audio, "format"))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn chat_json_object(value: &Value) -> Option<bool> {
let format = value.get("response_format")?;
(format.get("type").and_then(Value::as_str) == Some("json_object")).then_some(true)
}
fn chat_json_schema(
value: &Value,
) -> (Option<Value>, Option<String>, Option<bool>, Option<String>) {
let format = value.get("response_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 js = format.get("json_schema");
let schema = js.and_then(|js| js.get("schema")).cloned();
let name = js
.and_then(|js| js.get("name"))
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string);
let strict = js.and_then(|js| bool_field(js, "strict"));
let description = js.and_then(schema_description);
(schema, name, strict, description)
}
fn schema_description(value: &Value) -> Option<String> {
value
.get("description")
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::to_string)
}
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,
};
}
let Some(obj) = value.as_object() else {
return IrToolChoice::Auto;
};
if let Some(name) = obj
.get("function")
.and_then(|f| f.get("name"))
.and_then(Value::as_str)
.or_else(|| obj.get("name").and_then(Value::as_str))
{
return IrToolChoice::Named(name.to_string());
}
match obj.get("type").and_then(Value::as_str) {
Some("none") => IrToolChoice::None,
Some("required") | Some("any") => IrToolChoice::Required,
_ => IrToolChoice::Auto,
}
}
pub(super) fn encode(
ir: &IrRequest,
tools: &[PreparedTool],
report: &mut LossReport,
) -> Result<Value, MapError> {
let mut body = json!({
"model": ir.model,
"messages": encode_messages(ir, report),
});
if !tools.is_empty() {
body["tools"] = Value::Array(tools.iter().map(encode_tool).collect());
}
encode_sampling(ir, &mut body, report);
Ok(body)
}
fn encode_messages(ir: &IrRequest, report: &mut LossReport) -> Value {
let mut messages = Vec::new();
let mut idx = 0;
while idx < ir.items.len() {
match &ir.items[idx] {
IrItem::System {
text,
prompt_cache_breakpoint,
content_parts,
} => {
let content = if content_parts.is_empty() {
role_text_content(text, prompt_cache_breakpoint.as_deref())
} else {
encode_parts(content_parts, report)
};
messages.push(json!({
"role": "system",
"content": content
}));
idx += 1;
}
IrItem::SystemCache { .. } => {
report.record(
format!("items[{idx}]"),
LossAction::Drop,
"system cache point has no slot",
);
idx += 1;
}
IrItem::SystemGuard { .. } => {
report.record(
format!("items[{idx}]"),
LossAction::Drop,
"system guard content has no slot",
);
idx += 1;
}
IrItem::Developer {
text,
prompt_cache_breakpoint,
content_parts,
} => {
report.record(format!("items[{idx}]"), LossAction::Preserve, "developer");
let content = if content_parts.is_empty() {
role_text_content(text, prompt_cache_breakpoint.as_deref())
} else {
encode_parts(content_parts, report)
};
messages.push(json!({
"role": "developer",
"content": content
}));
idx += 1;
}
IrItem::User { parts } => {
messages.push(json!({"role": "user", "content": encode_parts(parts, report)}));
idx += 1;
}
IrItem::Assistant { parts, phase } => {
super::drop_assistant_phase(phase, &format!("items[{idx}].phase"), report);
let (msg, consumed) = encode_assistant(ir, idx, parts, report);
messages.push(msg);
idx += consumed;
}
IrItem::FunctionCall { .. } | IrItem::CustomToolCall { .. } => {
let (msg, consumed) = encode_standalone_function_calls(ir, idx, report);
messages.push(msg);
idx += consumed;
}
IrItem::FunctionOutput {
call_id,
output,
parts,
is_error,
} => {
for part in parts {
if matches!(part, IrPart::ImageUrl { .. } | IrPart::ImageBase64 { .. })
|| super::function_response_image(part).is_some()
{
report.record(format!("items[{idx}].image"), LossAction::Drop, "no slot");
} else if matches!(part, IrPart::Document { .. })
|| matches!(
part,
IrPart::Raw { type_name, .. }
if type_name == "video" || type_name == "searchResult"
)
{
report.record(
format!("items[{idx}].part"),
LossAction::Drop,
"tool result document, video, or search result has no slot",
);
}
}
if *is_error {
report.record(
format!("items[{idx}].is_error"),
LossAction::Drop,
"no slot",
);
}
let has_breakpoint = parts
.iter()
.any(|part| super::peel_prompt_cache(part).1.is_some());
let content = if has_breakpoint {
let text_parts: Vec<IrPart> = parts
.iter()
.filter(|part| matches!(super::peel_prompt_cache(part).0, IrPart::Text(_)))
.cloned()
.collect();
encode_parts(&text_parts, report)
} else {
json!(super::function_output_text(output, parts))
};
messages.push(json!({
"role": "tool",
"tool_call_id": call_id,
"content": content,
}));
idx += 1;
}
IrItem::Reasoning { .. } => {
report.record(
format!("items[{idx}]"),
LossAction::Degrade,
"reasoning omitted on Chat Completions",
);
idx += 1;
}
IrItem::HostedToolCall { kind, raw } => {
if chat_string_role(raw) {
messages.push(raw.clone());
report.record(
format!("items[{idx}]"),
LossAction::Preserve,
format!("hosted item `{kind}` passthrough"),
);
} else {
report.record(
format!("items[{idx}].role"),
LossAction::Drop,
"chat messages require role",
);
}
idx += 1;
}
IrItem::Unknown { type_name, .. }
if matches!(type_name.as_str(), "toolAddition" | "toolRemoval") =>
{
report.record(
format!("items[{idx}]"),
LossAction::Drop,
"tool addition or removal has no slot",
);
idx += 1;
}
IrItem::Unknown { raw, .. } => {
if chat_string_role(raw) {
messages.push(raw.clone());
} else {
report.record(
format!("items[{idx}].role"),
LossAction::Drop,
"chat messages require role",
);
}
idx += 1;
}
}
}
Value::Array(messages)
}
fn encode_assistant(
ir: &IrRequest,
start: usize,
parts: &[IrPart],
report: &mut LossReport,
) -> (Value, usize) {
let (calls, extra) = take_function_calls(ir, start + 1, report);
let mut msg = json!({
"role": "assistant",
"content": encode_parts(parts, report),
});
if !calls.is_empty() {
msg["tool_calls"] = Value::Array(calls);
}
(msg, 1 + extra)
}
fn encode_standalone_function_calls(
ir: &IrRequest,
start: usize,
report: &mut LossReport,
) -> (Value, usize) {
let (calls, consumed) = take_function_calls(ir, start, report);
(
json!({
"role": "assistant",
"content": null,
"tool_calls": calls,
}),
consumed,
)
}
fn take_function_calls(
ir: &IrRequest,
start: usize,
report: &mut LossReport,
) -> (Vec<Value>, usize) {
let mut consumed = 0;
let mut calls = Vec::new();
while let Some(item) = ir.items.get(start + consumed) {
match item {
IrItem::FunctionCall {
call_id,
name,
arguments,
thought_signature,
} => {
if thought_signature.is_some() {
report.record(
format!("items[{}]", start + consumed),
LossAction::Drop,
"thoughtSignature has no Chat Completions slot",
);
}
calls.push(function_call_json(call_id, name, arguments));
consumed += 1;
}
IrItem::CustomToolCall {
call_id,
name,
input,
..
} => {
calls.push(custom_tool_call_json(call_id, name, input));
consumed += 1;
}
_ => break,
}
}
(calls, consumed)
}
fn function_call_json(call_id: &str, name: &str, arguments: &str) -> Value {
json!({
"id": call_id,
"type": "function",
"function": {
"name": name,
"arguments": arguments,
}
})
}
fn custom_tool_call_json(call_id: &str, name: &str, input: &str) -> Value {
json!({
"id": call_id,
"type": "custom",
"custom": {
"name": name,
"input": input,
}
})
}
fn encode_parts(parts: &[IrPart], report: &mut LossReport) -> Value {
let visible: Vec<&IrPart> = parts
.iter()
.filter(|part| {
let (part, mode) = super::peel_prompt_cache(part);
let keep = match part {
IrPart::ImageUrl { url, file_id, .. }
if super::image_file_id_without_url(url, file_id.as_deref()) =>
{
report.record("part.image.file_id", LossAction::Drop, "no slot");
false
}
IrPart::Thinking { .. } => {
report.record(
"part.thinking",
LossAction::Drop,
"thinking has no Chat Completions slot",
);
false
}
IrPart::Raw { .. } => match super::plain_media_part(part) {
Some(IrPart::Document {
source: IrDocumentSource::Url(_),
..
}) => {
report.record(
"part.document",
LossAction::Drop,
"document url has no Chat Completions slot",
);
false
}
Some(_) => {
report.record("part.media_hint", LossAction::Drop, "no slot");
true
}
None => {
report.record(
"part.raw",
LossAction::Drop,
"raw part has no Chat Completions slot",
);
false
}
},
IrPart::Document {
source: IrDocumentSource::Url(_) | IrDocumentSource::S3 { .. },
..
} => {
report.record(
"part.document",
LossAction::Drop,
"document url has no Chat Completions slot",
);
false
}
_ => true,
};
if !keep && mode.is_some() {
super::apply_prompt_cache(&mut serde_json::Value::Null, mode, false, report);
}
keep
})
.collect();
if visible.is_empty() {
return Value::String(String::new());
}
let (only, only_mode) = super::peel_prompt_cache(visible[0]);
if visible.len() == 1
&& only_mode.is_none()
&& let IrPart::Text(text) = only
{
return Value::String(text.clone());
}
Value::Array(
visible
.iter()
.map(|part| {
let (part, mode) = super::peel_prompt_cache(part);
let mut value = match part {
IrPart::Text(text) => json!({"type": "text", "text": text}),
IrPart::ImageUrl {
url,
detail,
bucket_owner: _,
file_id: _,
} => {
let mut image_url = json!({ "url": url });
if let Some(detail) = detail {
image_url["detail"] = json!(detail);
}
json!({ "type": "image_url", "image_url": image_url })
}
IrPart::ImageBase64 { media_type, data } => json!({
"type": "image_url",
"image_url": {"url": format!("data:{media_type};base64,{data}")}
}),
IrPart::Document {
source,
media_type,
name,
..
} => encode_document(source, media_type, name.as_deref(), report),
IrPart::Audio { data, format } => json!({
"type": "input_audio",
"input_audio": { "data": data, "format": format }
}),
IrPart::Raw { .. } => {
match super::plain_media_part(part).expect("media hint") {
IrPart::ImageBase64 { media_type, data } => json!({
"type": "image_url",
"image_url": {"url": format!("data:{media_type};base64,{data}")}
}),
IrPart::Document {
source: IrDocumentSource::Url(_),
..
} => {
report.record(
"part.document",
LossAction::Drop,
"document url has no Chat Completions slot",
);
json!({"type": "text", "text": ""})
}
IrPart::Document {
source,
media_type,
name,
..
} => encode_document(&source, &media_type, name.as_deref(), report),
IrPart::Audio { data, format } => json!({
"type": "input_audio",
"input_audio": { "data": data, "format": format }
}),
IrPart::Text(text) => json!({"type": "text", "text": text}),
_ => unreachable!("plain media"),
}
}
IrPart::Thinking { .. } => unreachable!("filtered"),
IrPart::PromptCacheBreakpoint { .. } => unreachable!("peeled"),
};
super::apply_prompt_cache(&mut value, mode, true, report);
value
})
.collect(),
)
}
fn encode_document(
source: &IrDocumentSource,
media_type: &str,
name: Option<&str>,
report: &mut LossReport,
) -> Value {
if matches!(source, IrDocumentSource::Text(_)) {
report.record(
"part.document",
LossAction::Degrade,
"plain text document becomes text",
);
}
let filename = super::document_filename(name, media_type);
match source {
IrDocumentSource::Text(data) => json!({"type": "text", "text": data}),
IrDocumentSource::Base64(data) => {
let media_type = if media_type.is_empty() {
"application/pdf"
} else {
media_type
};
json!({
"type": "file",
"file": {
"filename": filename,
"file_data": format!("data:{media_type};base64,{data}")
}
})
}
IrDocumentSource::FileId(id) => {
let mut file = json!({ "file_id": id });
if let Some(name) = name.map(str::trim).filter(|s| !s.is_empty()) {
file["filename"] = json!(name);
}
json!({ "type": "file", "file": file })
}
IrDocumentSource::Url(_) | IrDocumentSource::S3 { .. } => {
report.record(
"part.document",
LossAction::Drop,
"document url has no Chat Completions slot",
);
json!({"type": "text", "text": ""})
}
}
}
fn chat_string_role(raw: &Value) -> bool {
raw.get("role").is_some_and(Value::is_string)
}
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",
"function": {
"name": name,
"description": description,
"parameters": parameters,
}
});
if let Some(strict) = strict {
tool["function"]["strict"] = json!(strict);
}
tool
}
PreparedTool::Raw(raw) => raw.clone(),
}
}
fn uses_max_completion_tokens(model: &str) -> bool {
let id = model
.rsplit('/')
.next()
.unwrap_or(model)
.to_ascii_lowercase();
id == "o1"
|| id.starts_with("o1-")
|| id == "o3"
|| id.starts_with("o3-")
|| id == "o4"
|| id.starts_with("o4-")
|| id.starts_with("gpt-5")
}
fn encode_sampling(ir: &IrRequest, body: &mut Value, report: &mut LossReport) {
let s = &ir.sampling;
let max_completion = uses_max_completion_tokens(&ir.model);
if let Some(t) = s.temperature {
if max_completion {
report.record(
"sampling.temperature",
LossAction::Drop,
"o-series and gpt-5 Chat Completions reject temperature",
);
} else {
body["temperature"] = super::json_f32(t);
}
}
if let Some(p) = s.top_p {
if max_completion {
report.record(
"sampling.top_p",
LossAction::Drop,
"o-series and gpt-5 Chat Completions reject top_p",
);
} 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.media_resolution.is_some() {
report.record("sampling.media_resolution", LossAction::Drop, "no slot");
}
if s.image_config.is_some() {
report.record("sampling.image_config", LossAction::Drop, "no slot");
}
if s.audio_transcription_config.is_some() {
report.record(
"sampling.audio_transcription_config",
LossAction::Drop,
"no slot",
);
}
if s.translation_config.is_some() {
report.record("sampling.translation_config", LossAction::Drop, "no slot");
}
if s.affective_dialog.is_some() {
report.record("sampling.affective_dialog", LossAction::Drop, "no slot");
}
if s.enhanced_civic_answers.is_some() {
report.record(
"sampling.enhanced_civic_answers",
LossAction::Drop,
"no slot",
);
}
if s.multi_speaker_speech.is_some() {
report.record("sampling.multi_speaker_speech", LossAction::Drop, "no slot");
}
if s.cached_content.is_some() {
report.record("sampling.cached_content", LossAction::Drop, "no slot");
}
if s.safety_settings.is_some() {
report.record("sampling.safety_settings", LossAction::Drop, "no slot");
}
if s.request_labels.is_some() {
report.record("sampling.request_labels", 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");
}
drop_responses_request_fields(s, report);
drop_messages_native_thinking(s, report);
if s.mcp_servers.is_some() {
report.record("sampling.mcp_servers", LossAction::Drop, "no slot");
}
if let Some(max) = s.max_tokens {
if max_completion {
body["max_completion_tokens"] = json!(max);
} else {
body["max_tokens"] = json!(max);
}
}
if !s.stop.is_empty() {
body["stop"] = json!(s.stop);
}
if let Some(fp) = s.frequency_penalty {
body["frequency_penalty"] = super::json_f32(fp);
}
if let Some(pp) = s.presence_penalty {
body["presence_penalty"] = super::json_f32(pp);
}
if let Some(seed) = s.seed {
body["seed"] = json!(seed);
}
if let Some(n) = s.n {
body["n"] = json!(n);
}
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,
"chat parallel_tool_calls",
);
}
if let Some(store) = s.store {
body["store"] = json!(store);
report.record("sampling.store", LossAction::Preserve, "chat store");
}
if s.previous_response_id.is_some() {
report.record(
"sampling.previous_response_id",
LossAction::Degrade,
"drop id, rebuild full input",
);
}
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,
"chat 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,
"chat prompt_cache_retention",
);
}
encode_prompt_cache_options(s, body, report);
if s.logprobs == Some(true) || s.top_logprobs.is_some() {
body["logprobs"] = json!(true);
}
if s.logprobs == Some(true) {
report.record("sampling.logprobs", LossAction::Preserve, "chat logprobs");
}
if let Some(n) = s.top_logprobs {
body["top_logprobs"] = json!(n);
report.record(
"sampling.top_logprobs",
LossAction::Preserve,
"chat 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(verbosity) = s.verbosity.as_deref().filter(|s| !s.trim().is_empty()) {
body["verbosity"] = json!(verbosity);
report.record("sampling.verbosity", LossAction::Preserve, "chat verbosity");
}
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,
"chat safety_identifier",
);
}
if !s.metadata.is_empty() {
body["metadata"] = json!(s.metadata);
report.record("sampling.metadata", LossAction::Preserve, "chat metadata");
}
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,
"chat service_tier",
);
}
if let Some(stream) = s.stream {
body["stream"] = json!(stream);
if stream {
body["stream_options"] = json!({ "include_usage": true });
}
}
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,
"chat stream_options.include_obfuscation",
);
}
if let Some(effort) = s
.reasoning_effort
.as_deref()
.filter(|s| !s.trim().is_empty())
{
body["reasoning_effort"] = json!(effort);
}
if s.max_reasoning_tokens.is_some() {
report.record("sampling.max_reasoning_tokens", LossAction::Drop, "no slot");
}
if s.include_thoughts.is_some() {
report.record("sampling.include_thoughts", LossAction::Drop, "no slot");
}
if !s.include.is_empty() {
report.record("sampling.include", LossAction::Drop, "no slot");
}
if s.thinking_budget.is_some() {
report.record("sampling.thinking_budget", LossAction::Drop, "no slot");
}
if let Some(schema) = &s.json_schema
&& let Some((schema, name)) =
super::official_json_schema(schema, s.json_schema_name.as_deref(), report)
{
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 json_schema = json!({
"name": name,
"schema": schema,
});
if let Some(strict) = s.json_schema_strict {
json_schema["strict"] = json!(strict);
}
if let Some(description) = s
.json_schema_description
.as_deref()
.map(str::trim)
.filter(|text| !text.is_empty())
{
json_schema["description"] = json!(description);
}
body["response_format"] = json!({
"type": "json_schema",
"json_schema": json_schema,
});
} else if s.json_object == Some(true) {
body["response_format"] = json!({ "type": "json_object" });
}
encode_output_modalities_and_audio(s, body, report);
if !s.logit_bias.is_empty() {
body["logit_bias"] = json!(s.logit_bias);
report.record(
"sampling.logit_bias",
LossAction::Preserve,
"chat logit_bias",
);
}
if let Some(prediction) = &s.prediction {
body["prediction"] = prediction.clone();
report.record(
"sampling.prediction",
LossAction::Preserve,
"chat prediction",
);
}
if let Some(opts) = &s.web_search_options {
body["web_search_options"] = opts.clone();
report.record(
"sampling.web_search_options",
LossAction::Preserve,
"chat web_search_options",
);
}
}
fn encode_output_modalities_and_audio(s: &IrSampling, body: &mut Value, report: &mut LossReport) {
let modalities: Vec<String> = s
.output_modalities
.iter()
.filter_map(|raw| {
if raw.eq_ignore_ascii_case("text") {
Some("text".to_string())
} else if raw.eq_ignore_ascii_case("audio") {
Some("audio".to_string())
} else {
None
}
})
.collect();
if !s.output_modalities.is_empty() && modalities.is_empty() {
report.record(
"sampling.output_modalities",
LossAction::Drop,
"chat modalities are text or audio",
);
} else if !modalities.is_empty() {
body["modalities"] = json!(modalities);
let action = if modalities.len() < s.output_modalities.len() {
LossAction::Degrade
} else {
LossAction::Preserve
};
report.record("sampling.output_modalities", action, "chat modalities");
}
let has_audio_modality = s
.output_modalities
.iter()
.any(|raw| raw.eq_ignore_ascii_case("audio"));
let voice = s
.audio_voice
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty());
if voice.is_none() && !has_audio_modality {
return;
}
let format = s
.audio_format
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.unwrap_or("wav");
let mut audio = serde_json::Map::new();
if let Some(voice) = voice {
audio.insert("voice".into(), json!(voice));
report.record(
"sampling.audio_voice",
LossAction::Preserve,
"chat audio.voice",
);
}
audio.insert("format".into(), json!(format));
body["audio"] = Value::Object(audio);
}
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,
"chat prompt_cache_options.mode",
);
}
if let Some(ttl) = ttl {
opts.insert("ttl".into(), json!(ttl));
report.record(
"sampling.prompt_cache_ttl",
LossAction::Preserve,
"chat 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,
"chat 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,
"chat moderation.policy.input.mode",
);
}
if let Some(mode) = output {
policy.insert("output".into(), json!({ "mode": mode }));
report.record(
"sampling.moderation_output",
LossAction::Preserve,
"chat 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",
"function": {"name": name}
});
}
}
}
#[cfg(test)]
mod tests {
use super::encode_parts;
use crate::ir::{IrPart, LossAction};
use serde_json::json;
#[test]
fn assistant_refusal_is_kept_as_text() {
let value = json!({
"model": "gpt-4o",
"messages": [{
"role": "assistant",
"content": null,
"refusal": "I can't help with that."
}]
});
let (ir, report) = super::decode(&value).expect("decode");
assert!(
matches!(
&ir.items[0],
crate::ir::IrItem::Assistant { parts, .. }
if *parts == [IrPart::Text("I can't help with that.".into())]
),
"{ir:?}"
);
assert!(
report
.events
.iter()
.any(|event| event.detail.contains("refusal")),
"{report:?}"
);
let mut encode_report = crate::ir::LossReport::default();
let body = super::encode(&ir, &[], &mut encode_report).expect("encode");
assert_eq!(body["messages"][0]["content"], "I can't help with that.");
}
#[test]
fn gemini_pdf_url_with_media_hint_is_dropped() {
let part = IrPart::Raw {
type_name: "fileData".into(),
raw: json!({
"fileData": {
"mimeType": "application/pdf",
"fileUri": "https://example.com/a.pdf"
},
"mediaResolution": {"level": "MEDIA_RESOLUTION_LOW"}
}),
};
let mut report = crate::ir::LossReport::default();
let value = encode_parts(&[IrPart::Text("hello".into()), part], &mut report);
assert_eq!(value, json!("hello"));
assert!(
report.events.iter().any(|event| {
event.action == LossAction::Drop && event.detail.contains("document url")
}),
"{report:?}"
);
}
}