mod chat;
mod converse;
mod gemini;
mod messages;
mod responses;
mod tools;
pub(crate) use converse::converse_service_tier;
pub(crate) use gemini::{gemini_decode_service_tier, gemini_service_tier};
pub(crate) use messages::cache_floor_tokens as messages_cache_floor_tokens;
pub use messages::restore_same_wire_messages_tool_choice;
use std::collections::BTreeMap;
use serde_json::Value;
use wiremux_auth::{ForbiddenFieldPolicy, ResolvedProfile, ToolNameCase, Wire};
use crate::ir::{IrDocumentSource, IrItem, IrPart, IrRequest, IrSampling, LossAction, LossReport};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum MapError {
#[error("invalid JSON: {0}")]
Json(#[from] serde_json::Error),
#[error("hard-error at {path}: {detail}")]
HardError {
path: String,
detail: String,
},
#[error("{0}")]
Invalid(String),
}
impl MapError {
fn hard(path: impl Into<String>, detail: impl Into<String>) -> Self {
Self::HardError {
path: path.into(),
detail: detail.into(),
}
}
}
fn json_kind(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
pub(super) fn json_object_or_raw(
raw_text: &str,
path: &str,
report: &mut LossReport,
detail: &str,
wrap_key: &str,
) -> Value {
let (value, degraded) = json_object_or_wrapped(raw_text, wrap_key);
if degraded {
report.record(path, LossAction::Degrade, detail);
}
value
}
fn json_object_or_wrapped(raw_text: &str, wrap_key: &str) -> (Value, bool) {
match serde_json::from_str::<Value>(raw_text) {
Ok(value) if value.is_object() => (value, false),
Ok(value) => (wrapped_json(wrap_key, value), true),
Err(_) => (
wrapped_json(wrap_key, Value::String(raw_text.to_string())),
true,
),
}
}
fn wrapped_json(wrap_key: &str, value: Value) -> Value {
let mut map = serde_json::Map::new();
map.insert(wrap_key.to_string(), value);
Value::Object(map)
}
pub(crate) fn response_custom_tool_input(raw_text: &str) -> Value {
if raw_text.is_empty() {
return Value::Object(serde_json::Map::new());
}
json_object_or_wrapped(raw_text, "raw").0
}
pub fn decode(wire: Wire, bytes: &[u8]) -> Result<(IrRequest, LossReport), MapError> {
let value: Value = match serde_json::from_slice(bytes) {
Ok(value) => value,
Err(err) => {
return Err(MapError::Invalid(format!(
"request body for wire `{}` is invalid JSON: {err}",
wire.as_str()
)));
}
};
if !value.is_object() {
return Err(MapError::Invalid(format!(
"request body for wire `{}` must be a JSON object, got {}",
wire.as_str(),
json_kind(&value)
)));
}
match wire {
Wire::ChatCompletions => chat::decode(&value),
Wire::Messages => messages::decode(&value),
Wire::Responses => responses::decode(&value),
Wire::Gemini => gemini::decode(&value),
Wire::Converse => converse::decode(&value),
_ => Err(MapError::Invalid(format!(
"unsupported wire `{}`",
wire.as_str()
))),
}
}
pub(super) fn note_dropped_image_detail(detail: Option<&str>, wire: &str, report: &mut LossReport) {
if detail.is_some_and(|text| !text.is_empty()) {
report.record(
"part.image.detail",
LossAction::Drop,
format!("image detail has no {wire} slot"),
);
}
}
pub(super) fn with_prompt_cache(part: Option<IrPart>, src: &Value) -> Option<IrPart> {
let part = part?;
let Some(marker) = src.get("prompt_cache_breakpoint") else {
return Some(part);
};
if !marker.is_object() {
return Some(part);
}
let mode = marker
.get("mode")
.and_then(Value::as_str)
.map(str::trim)
.filter(|mode| !mode.is_empty())
.unwrap_or("explicit");
Some(IrPart::PromptCacheBreakpoint {
mode: mode.to_string(),
inner: Box::new(part),
})
}
pub(super) fn peel_prompt_cache(part: &IrPart) -> (&IrPart, Option<&str>) {
match part {
IrPart::PromptCacheBreakpoint { mode, inner } => (inner, Some(mode.as_str())),
other => (other, None),
}
}
pub(super) fn peel_prompt_cache_modes(part: &IrPart) -> (&IrPart, Vec<&str>) {
let mut modes = Vec::new();
let mut current = part;
while let IrPart::PromptCacheBreakpoint { mode, inner } = current {
modes.push(mode.as_str());
current = inner.as_ref();
}
(current, modes)
}
pub(super) fn peel_prompt_cache_all(part: &IrPart) -> (&IrPart, Option<&str>) {
let IrPart::PromptCacheBreakpoint { mode, inner } = part else {
return (part, None);
};
let mut current = inner.as_ref();
while let IrPart::PromptCacheBreakpoint { inner, .. } = current {
current = inner.as_ref();
}
(current, Some(mode.as_str()))
}
pub(super) fn apply_prompt_cache(
value: &mut Value,
mode: Option<&str>,
keep: bool,
report: &mut LossReport,
) {
let Some(mode) = mode.filter(|mode| !mode.is_empty()) else {
return;
};
if !keep {
report.record(
"part.prompt_cache_breakpoint",
LossAction::Drop,
"prompt cache breakpoint has no slot",
);
return;
}
if let Some(obj) = value.as_object_mut() {
obj.insert(
"prompt_cache_breakpoint".to_string(),
serde_json::json!({ "mode": mode }),
);
}
}
pub(super) fn role_text_breakpoint(parts: &[IrPart]) -> Option<String> {
if parts.len() != 1 {
return None;
}
peel_prompt_cache(&parts[0]).1.map(str::to_string)
}
pub(super) fn role_content_parts(parts: &[IrPart]) -> Vec<IrPart> {
if parts.len() > 1 && parts.iter().any(|part| peel_prompt_cache(part).1.is_some()) {
parts.to_vec()
} else {
Vec::new()
}
}
pub(super) fn note_role_breakpoint(mode: Option<&str>, report: &mut LossReport) {
if mode.is_some_and(|mode| !mode.is_empty()) {
report.record(
"part.prompt_cache_breakpoint",
LossAction::Drop,
"prompt cache breakpoint has no slot",
);
}
}
pub(super) fn system_role_text(
text: &str,
content_parts: &[IrPart],
report: &mut LossReport,
) -> String {
if content_parts.is_empty() {
return text.to_string();
}
let mut texts = Vec::new();
for part in content_parts {
let (inner, _) = peel_prompt_cache_all(part);
match inner {
IrPart::Text(value) => texts.push(value.as_str()),
IrPart::Thinking { text, .. } => texts.push(text.as_str()),
IrPart::PromptCacheBreakpoint { .. } => {}
IrPart::ImageUrl { .. } | IrPart::ImageBase64 { .. } => {
report.record(
"part.image",
LossAction::Drop,
"system role has no image slot",
);
}
IrPart::Document { .. } => {
report.record(
"part.document",
LossAction::Drop,
"system role has no document slot",
);
}
IrPart::Audio { .. } => {
report.record(
"part.audio",
LossAction::Drop,
"system role has no audio slot",
);
}
IrPart::Raw { .. } => {
report.record("part.raw", LossAction::Drop, "system role has no slot");
}
}
}
texts.join("")
}
pub(super) fn image_file_id_without_url(url: &str, file_id: Option<&str>) -> bool {
url.is_empty() && file_id.is_some_and(|id| !id.is_empty())
}
pub(super) fn drop_image_file_id_without_url(
url: &str,
file_id: Option<&str>,
report: &mut LossReport,
) -> bool {
if image_file_id_without_url(url, file_id) {
report.record("part.image.file_id", LossAction::Drop, "no slot");
true
} else {
false
}
}
pub(super) fn dropped_details(report: &LossReport) -> Vec<&str> {
report
.events
.iter()
.filter(|event| event.action == LossAction::Drop)
.map(|event| event.detail.as_str())
.collect()
}
pub fn encode(
wire: Wire,
ir: &IrRequest,
profile: &ResolvedProfile,
) -> Result<(Vec<u8>, LossReport), MapError> {
let mut report = LossReport::default();
let mut ir = apply_system_prefix(ir, profile);
strip_or_refuse_store(&mut ir, profile, &mut report)?;
let prepared = tools::prepare_tools(wire, &ir, profile, &mut report)?;
let mut body = match wire {
Wire::ChatCompletions => chat::encode(&ir, &prepared, &mut report)?,
Wire::Messages => messages::encode(&ir, &prepared, &mut report)?,
Wire::Responses => responses::encode(&ir, &prepared, profile, &mut report)?,
Wire::Gemini => gemini::encode(&ir, &prepared, &mut report)?,
Wire::Converse => converse::encode(&ir, &prepared, &mut report)?,
_ => {
return Err(MapError::Invalid(format!(
"unsupported wire `{}`",
wire.as_str()
)));
}
};
merge_extra_body(&mut body, profile);
apply_forbidden_fields(&mut body, profile, &mut report)?;
Ok((serde_json::to_vec(&body)?, report))
}
fn apply_system_prefix(ir: &IrRequest, profile: &ResolvedProfile) -> IrRequest {
let mut out = ir.clone();
let Some(prefix) = profile
.fingerprint
.as_ref()
.and_then(|fp| fp.system_prompt_prefix.as_deref())
else {
return out;
};
if prefix.is_empty() {
return out;
}
match out
.items
.iter_mut()
.find(|item| matches!(item, IrItem::System { .. }))
{
Some(IrItem::System { text, .. }) => {
if !text.starts_with(prefix) {
*text = format!("{prefix}{text}");
}
}
_ => out.items.insert(
0,
IrItem::System {
text: prefix.to_string(),
prompt_cache_breakpoint: None,
content_parts: vec![],
},
),
}
out
}
fn strip_or_refuse_store(
ir: &mut IrRequest,
profile: &ResolvedProfile,
report: &mut LossReport,
) -> Result<(), MapError> {
let Some(fp) = profile.fingerprint.as_ref() else {
return Ok(());
};
if ir.sampling.store.is_none() {
return Ok(());
}
if !fp.forbidden_body_fields.iter().any(|key| key == "store") {
return Ok(());
}
match fp.forbidden_field_policy {
ForbiddenFieldPolicy::HardError => Err(MapError::hard(
"sampling.store",
"forbidden body field `store`",
)),
ForbiddenFieldPolicy::Strip => {
ir.sampling.store = None;
report.record(
"sampling.store",
LossAction::Drop,
"forbidden_body_fields strip",
);
Ok(())
}
}
}
fn merge_extra_body(body: &mut Value, profile: &ResolvedProfile) {
let Some(fp) = profile.fingerprint.as_ref() else {
return;
};
let Some(obj) = body.as_object_mut() else {
return;
};
for (key, value) in &fp.extra_body {
obj.insert(key.clone(), value.clone());
}
}
fn apply_forbidden_fields(
body: &mut Value,
profile: &ResolvedProfile,
report: &mut LossReport,
) -> Result<(), MapError> {
let Some(fp) = profile.fingerprint.as_ref() else {
return Ok(());
};
let Some(obj) = body.as_object_mut() else {
return Ok(());
};
for key in &fp.forbidden_body_fields {
if !obj.contains_key(key) {
continue;
}
match fp.forbidden_field_policy {
ForbiddenFieldPolicy::HardError => {
return Err(MapError::hard(
key.clone(),
format!("forbidden body field `{key}`"),
));
}
ForbiddenFieldPolicy::Strip => {
obj.remove(key);
report.record(key.clone(), LossAction::Drop, "forbidden_body_fields strip");
}
}
}
Ok(())
}
fn str_field(value: &Value, key: &str) -> Option<String> {
value.get(key)?.as_str().map(str::to_string)
}
pub(super) fn assistant_phase(phase: &Option<String>) -> Option<&str> {
phase.as_deref().filter(|value| !value.trim().is_empty())
}
pub(super) fn drop_assistant_phase(phase: &Option<String>, path: &str, report: &mut LossReport) {
if assistant_phase(phase).is_some() {
report.record(path, LossAction::Drop, "phase has no slot");
}
}
fn drop_dest_chat_sampling_extras(s: &IrSampling, report: &mut LossReport) {
if s.prompt_cache_mode.is_some() {
report.record("sampling.prompt_cache_mode", LossAction::Drop, "no slot");
}
if s.prompt_cache_ttl.is_some() {
report.record("sampling.prompt_cache_ttl", LossAction::Drop, "no slot");
}
if s.moderation_model.is_some() {
report.record("sampling.moderation_model", LossAction::Drop, "no slot");
}
if s.moderation_input.is_some() {
report.record("sampling.moderation_input", LossAction::Drop, "no slot");
}
if s.moderation_output.is_some() {
report.record("sampling.moderation_output", LossAction::Drop, "no slot");
}
if s.include_obfuscation.is_some() {
report.record("sampling.include_obfuscation", LossAction::Drop, "no slot");
}
drop_dest_chat_logit_bias(s, report);
}
fn drop_dest_chat_logit_bias(s: &IrSampling, report: &mut LossReport) {
if !s.logit_bias.is_empty() {
report.record("sampling.logit_bias", LossAction::Drop, "no slot");
}
if s.prediction.is_some() {
report.record("sampling.prediction", LossAction::Drop, "no slot");
}
if s.web_search_options.is_some() {
report.record("sampling.web_search_options", LossAction::Drop, "no slot");
}
}
fn drop_dest_top_logprobs(s: &IrSampling, report: &mut LossReport) {
if s.top_logprobs.is_some() {
report.record("sampling.top_logprobs", LossAction::Drop, "no slot");
}
}
fn drop_dest_logprobs(s: &IrSampling, report: &mut LossReport) {
if s.logprobs.is_some() {
report.record("sampling.logprobs", LossAction::Drop, "no slot");
}
}
fn drop_dest_n_and_penalties(s: &IrSampling, report: &mut LossReport) {
if s.frequency_penalty.is_some() {
report.record("sampling.frequency_penalty", LossAction::Drop, "no slot");
}
if s.presence_penalty.is_some() {
report.record("sampling.presence_penalty", LossAction::Drop, "no slot");
}
if s.seed.is_some() {
report.record("sampling.seed", LossAction::Drop, "no slot");
}
if s.n.is_some() {
report.record("sampling.n", 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");
}
}
fn drop_responses_request_fields(s: &IrSampling, report: &mut LossReport) {
if s.truncation
.as_deref()
.is_some_and(|text| !text.trim().is_empty())
{
report.record("sampling.truncation", LossAction::Drop, "no slot");
}
if s.max_tool_calls.is_some() {
report.record("sampling.max_tool_calls", LossAction::Drop, "no slot");
}
if s.background.is_some() {
report.record("sampling.background", LossAction::Drop, "no slot");
}
if s.conversation.is_some() {
report.record("sampling.conversation", LossAction::Drop, "no slot");
}
if s.responses_context_management.is_some() {
report.record(
"sampling.responses_context_management",
LossAction::Drop,
"no slot",
);
}
}
fn drop_messages_native_thinking(s: &IrSampling, report: &mut LossReport) {
if s.messages_thinking.is_some() {
report.record("sampling.messages_thinking", LossAction::Drop, "no slot");
}
}
fn drop_dest_output_modalities(s: &IrSampling, report: &mut LossReport) {
if !s.output_modalities.is_empty() {
report.record("sampling.output_modalities", LossAction::Drop, "no slot");
}
if s.audio_voice.is_some() {
report.record("sampling.audio_voice", LossAction::Drop, "no slot");
}
if s.audio_format.is_some() {
report.record("sampling.audio_format", LossAction::Drop, "no slot");
}
}
fn string_object_field(value: &Value, key: &str) -> BTreeMap<String, String> {
let Some(obj) = value.get(key).and_then(Value::as_object) else {
return BTreeMap::new();
};
obj.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
}
fn split_data_url(url: &str) -> Option<(&str, &str)> {
url.strip_prefix("data:")?.split_once(";base64,")
}
fn is_pdf_media_type(media: &str) -> bool {
let media = media.to_ascii_lowercase();
media == "application/pdf" || media == "application/x-pdf" || media == "pdf"
}
fn is_audio_media_type(media: &str) -> bool {
media.to_ascii_lowercase().starts_with("audio/")
}
fn audio_format_from_mime(mime: &str) -> String {
mime.split_once('/')
.map(|(_, rest)| rest.to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| mime.to_string())
}
fn audio_mime_from_format(format: &str) -> String {
if format.contains('/') {
format.to_string()
} else {
format!("audio/{format}")
}
}
fn converse_document_format(media_type: &str) -> Option<&'static str> {
let media = media_type.to_ascii_lowercase();
match media.as_str() {
"application/pdf" | "application/x-pdf" | "pdf" => Some("pdf"),
"text/csv" | "csv" => Some("csv"),
"application/msword" | "doc" => Some("doc"),
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" | "docx" => {
Some("docx")
}
"application/vnd.ms-excel" | "xls" => Some("xls"),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" | "xlsx" => {
Some("xlsx")
}
"text/html" | "html" => Some("html"),
"text/plain" | "txt" => Some("txt"),
"text/markdown" | "md" => Some("md"),
_ => None,
}
}
fn media_type_from_converse_format(format: &str) -> String {
match format.to_ascii_lowercase().as_str() {
"pdf" => "application/pdf".into(),
"csv" => "text/csv".into(),
"doc" => "application/msword".into(),
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document".into(),
"xls" => "application/vnd.ms-excel".into(),
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(),
"html" => "text/html".into(),
"txt" => "text/plain".into(),
"md" => "text/markdown".into(),
other => other.to_string(),
}
}
fn media_type_from_converse_image_format(format: &str) -> String {
let format = format.trim();
if format.contains('/') {
return format.to_string();
}
match format.to_ascii_lowercase().as_str() {
"png" => "image/png".into(),
"jpeg" | "jpg" => "image/jpeg".into(),
"gif" => "image/gif".into(),
"webp" => "image/webp".into(),
"" => "image/png".into(),
other => format!("image/{other}"),
}
}
pub(crate) fn converse_image_format(media_type: &str) -> Option<&'static str> {
let media = media_type.to_ascii_lowercase();
let media = media.strip_prefix("image/").unwrap_or(media.as_str());
match media {
"png" => Some("png"),
"jpeg" | "jpg" => Some("jpeg"),
"gif" => Some("gif"),
"webp" => Some("webp"),
_ => None,
}
}
pub(super) struct FunctionResponseInline {
pub mime: String,
pub data: String,
pub display_name: Option<String>,
}
fn function_response_mime_allowed(mime: &str) -> bool {
let mime = mime.to_ascii_lowercase();
mime.starts_with("image/") || is_pdf_media_type(&mime) || mime == "text/plain"
}
pub(super) fn function_output_text(output: &str, parts: &[IrPart]) -> String {
let has_text_part = parts
.iter()
.any(|part| matches!(part, IrPart::Text(text) if !text.is_empty()));
if has_text_part {
let mut text = String::new();
for part in parts {
let piece = match part {
IrPart::Text(piece) if !piece.is_empty() => piece.clone(),
IrPart::Raw { type_name, raw } if type_name == "json" => raw.to_string(),
_ => continue,
};
if !text.is_empty() {
text.push('\n');
}
text.push_str(&piece);
}
return text;
}
let mut text = String::new();
if !output.is_empty() {
text.push_str(output);
}
for part in parts {
if let IrPart::Raw { type_name, raw } = part
&& type_name == "json"
{
if !text.is_empty() {
text.push('\n');
}
text.push_str(&raw.to_string());
}
}
text
}
pub(super) fn function_response_image(part: &IrPart) -> Option<(String, String)> {
let inline = function_response_inline(part)?;
inline
.mime
.to_ascii_lowercase()
.starts_with("image/")
.then_some((inline.mime, inline.data))
}
pub(super) fn part_has_media_hint(part: &Value) -> bool {
part.get("mediaResolution").is_some()
|| part.get("mediaProcessing").is_some()
|| part.get("videoMetadata").is_some()
|| part.get("audioTranscription").is_some()
|| part.get("speechMetadata").is_some()
|| part.get("partMetadata").is_some()
|| part.get("thoughtSignature").is_some()
}
pub(super) fn raw_prompt_chars(part: &IrPart) -> usize {
let IrPart::Raw { raw, .. } = part else {
return 0;
};
if part_has_media_hint(raw) {
if raw.get("inlineData").is_some() {
return hinted_inline_prompt_chars(raw);
}
if let Some(text) = raw.get("text").and_then(Value::as_str) {
return if text.trim().is_empty() {
0
} else {
text.len()
};
}
if let Some(chars) = hinted_pdf_uri_chars(raw) {
return chars;
}
}
inline_image_prompt_chars(raw).unwrap_or(0)
}
fn hinted_inline_prompt_chars(raw: &Value) -> usize {
let Some(inline) = raw.get("inlineData") else {
return 0;
};
let media = inline.get("mimeType").and_then(Value::as_str).unwrap_or("");
let data = inline.get("data").and_then(Value::as_str).unwrap_or("");
if data.is_empty() || is_audio_media_type(media) {
return 0;
}
data.len()
}
fn hinted_pdf_uri_chars(raw: &Value) -> Option<usize> {
let file = raw.get("fileData")?;
let media = file.get("mimeType").and_then(Value::as_str).unwrap_or("");
let uri = file.get("fileUri").and_then(Value::as_str).unwrap_or("");
(is_pdf_media_type(media) && !uri.is_empty())
.then_some(uri.len().max(messages::MESSAGES_REMOTE_SOURCE_CHARS))
}
fn inline_image_prompt_chars(raw: &Value) -> Option<usize> {
let inline = raw.get("inlineData")?;
let mime = inline.get("mimeType").and_then(Value::as_str)?;
let data = inline.get("data").and_then(Value::as_str)?;
if data.is_empty() || !function_response_mime_allowed(mime) {
return None;
}
mime.to_ascii_lowercase()
.starts_with("image/")
.then_some(data.len())
}
pub(super) fn plain_media_part(part: &IrPart) -> Option<IrPart> {
let IrPart::Raw { raw, .. } = part else {
return None;
};
if !part_has_media_hint(raw) {
return None;
}
if let Some(inline) = raw.get("inlineData") {
let media = str_field(inline, "mimeType").unwrap_or_default();
let data = str_field(inline, "data").unwrap_or_default();
if data.is_empty() {
return None;
}
if is_audio_media_type(&media) {
return Some(IrPart::Audio {
data,
format: audio_format_from_mime(&media),
});
}
if is_pdf_media_type(&media) {
return Some(IrPart::Document {
source: IrDocumentSource::Base64(data),
media_type: media,
name: None,
citations_enabled: None,
context: None,
});
}
return Some(IrPart::ImageBase64 {
media_type: media,
data,
});
}
if let Some(text) = raw.get("text").and_then(Value::as_str) {
return Some(IrPart::Text(text.to_string()));
}
let file = raw.get("fileData")?;
let media = str_field(file, "mimeType").unwrap_or_default();
let uri = str_field(file, "fileUri").unwrap_or_default();
if is_pdf_media_type(&media) && !uri.is_empty() {
return Some(IrPart::Document {
source: document_ref_source(uri),
media_type: media,
name: None,
citations_enabled: None,
context: None,
});
}
None
}
pub(super) fn function_response_inline(part: &IrPart) -> Option<FunctionResponseInline> {
let (mime, data, name) = match part {
IrPart::ImageBase64 { media_type, data } => (media_type.clone(), data.clone(), None),
IrPart::ImageUrl { url, .. } => {
let (mime, data) = split_data_url(url)?;
(mime.to_string(), data.to_string(), None)
}
IrPart::Document {
source: IrDocumentSource::Base64(data),
media_type,
name,
..
} => (media_type.clone(), data.clone(), name.clone()),
IrPart::Raw { raw, .. } => {
let inline = raw.get("inlineData")?;
(
str_field(inline, "mimeType")?,
str_field(inline, "data")?,
str_field(inline, "displayName"),
)
}
_ => return None,
};
if data.trim().is_empty() || !function_response_mime_allowed(&mime) {
return None;
}
Some(FunctionResponseInline {
mime,
data,
display_name: name.filter(|item| !item.is_empty()),
})
}
fn converse_image_format_from_url(url: &str) -> &'static str {
let path = url.split(['?', '#']).next().unwrap_or(url);
let ext = path
.rsplit_once('.')
.map(|(_, ext)| ext)
.unwrap_or("")
.to_ascii_lowercase();
match ext.as_str() {
"png" => "png",
"jpg" | "jpeg" => "jpeg",
"gif" => "gif",
"webp" => "webp",
_ => "png",
}
}
fn converse_audio_format(format: &str) -> Option<&'static str> {
let lower = format.to_ascii_lowercase();
let lower = lower.strip_prefix("audio/").unwrap_or(&lower);
Some(match lower {
"mp3" => "mp3",
"opus" => "opus",
"wav" => "wav",
"aac" => "aac",
"flac" => "flac",
"mp4" => "mp4",
"ogg" => "ogg",
"mkv" => "mkv",
"mka" => "mka",
"x-aac" => "x-aac",
"m4a" => "m4a",
"mpeg" => "mpeg",
"mpga" => "mpga",
"pcm" => "pcm",
"webm" => "webm",
_ => return None,
})
}
fn document_filename(name: Option<&str>, media_type: &str) -> String {
if let Some(name) = name.map(str::trim).filter(|s| !s.is_empty()) {
return name.to_string();
}
match converse_document_format(media_type) {
Some(ext) => format!("document.{ext}"),
None => "document".into(),
}
}
fn document_ref_source(uri: String) -> IrDocumentSource {
let lower = uri.to_ascii_lowercase();
if lower.starts_with("http://")
|| lower.starts_with("https://")
|| lower.starts_with("s3://")
|| lower.starts_with("gs://")
{
IrDocumentSource::Url(uri)
} else {
IrDocumentSource::FileId(uri)
}
}
fn decode_openai_file_part(part: &Value) -> Option<IrPart> {
let file = part.get("file").unwrap_or(part);
let name = str_field(file, "filename")
.or_else(|| str_field(part, "filename"))
.or_else(|| str_field(file, "name"))
.or_else(|| str_field(part, "name"))
.filter(|s| !s.is_empty());
let media_type = str_field(file, "media_type")
.or_else(|| str_field(part, "media_type"))
.unwrap_or_default();
if let Some(file_id) = str_field(file, "file_id").or_else(|| str_field(part, "file_id")) {
return Some(IrPart::Document {
source: IrDocumentSource::FileId(file_id),
media_type,
name,
citations_enabled: None,
context: None,
});
}
if let Some(url) = str_field(file, "file_url")
.or_else(|| str_field(part, "file_url"))
.or_else(|| str_field(file, "url"))
.or_else(|| str_field(part, "url"))
{
return Some(IrPart::Document {
source: document_ref_source(url),
media_type,
name,
citations_enabled: None,
context: None,
});
}
let data = str_field(file, "file_data")
.or_else(|| str_field(part, "file_data"))
.or_else(|| str_field(file, "data"))?;
let (media_type, payload) = if let Some((mt, b64)) = split_data_url(&data) {
(mt.to_string(), b64.to_string())
} else {
let media_type = if media_type.is_empty() {
"application/pdf".into()
} else {
media_type
};
(media_type, data)
};
Some(IrPart::Document {
source: IrDocumentSource::Base64(payload),
media_type,
name,
citations_enabled: None,
context: None,
})
}
fn decode_input_audio_part(part: &Value) -> Option<IrPart> {
let audio = part.get("input_audio").unwrap_or(part);
let data = str_field(audio, "data")?;
let format = str_field(audio, "format").unwrap_or_else(|| "wav".into());
Some(IrPart::Audio { data, format })
}
pub(crate) fn openai_model_leaf(model: &str) -> &str {
model.rsplit('/').next().unwrap_or(model)
}
pub(crate) fn chat_stop_unsupported(model: &str) -> bool {
let id = openai_model_leaf(model).to_ascii_lowercase();
let o3 = id == "o3" || (id.starts_with("o3-") && !id.starts_with("o3-mini"));
let o4_mini = id == "o4-mini" || id.starts_with("o4-mini-");
o3 || o4_mini
}
pub(crate) fn gpt5_sampling_params_rejected(model: &str, effort: Option<&str>) -> bool {
let id = openai_model_leaf(model).to_ascii_lowercase();
let older = id == "gpt-5"
|| id.starts_with("gpt-5-20")
|| id == "gpt-5-mini"
|| id.starts_with("gpt-5-mini-")
|| id == "gpt-5-nano"
|| id.starts_with("gpt-5-nano-");
if older {
return true;
}
let later = id == "gpt-5.1"
|| id.starts_with("gpt-5.1-")
|| id == "gpt-5.2"
|| id.starts_with("gpt-5.2-");
if !later {
return false;
}
match effort.map(str::trim).filter(|text| !text.is_empty()) {
Some(text) => !text.eq_ignore_ascii_case("none"),
None => false,
}
}
fn json_f32(value: f32) -> Value {
let parsed = value.to_string().parse::<f64>().unwrap_or(f64::from(value));
serde_json::json!(parsed)
}
fn f32_field(value: &Value, key: &str) -> Option<f32> {
value.get(key)?.as_f64().map(|n| n as f32)
}
fn i64_field(value: &Value, key: &str) -> Option<i64> {
value.get(key)?.as_i64()
}
fn u32_field(value: &Value, key: &str) -> Option<u32> {
value.get(key)?.as_u64().and_then(|n| u32::try_from(n).ok())
}
fn bool_field(value: &Value, key: &str) -> Option<bool> {
value.get(key)?.as_bool()
}
fn is_gemini_file_raw(raw: &Value) -> bool {
raw.get("fileData").is_some() || raw.get("fileUri").is_some()
}
fn raw_type_name(raw: &Value) -> Option<&str> {
raw.get("type")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
}
fn messages_raw_passthrough(raw: &Value) -> bool {
raw_type_name(raw).is_some() && !is_gemini_file_raw(raw)
}
fn responses_raw_passthrough(raw: &Value) -> bool {
messages_raw_passthrough(raw)
}
fn off_dialect_raw_path(raw: &Value) -> &'static str {
if raw.get("fileData").is_some() {
"item.part.fileData"
} else if raw.get("fileUri").is_some() {
"item.part.fileUri"
} else {
"item.part.raw"
}
}
pub(super) fn kept_stop<'a>(stop: &'a [String], report: &mut LossReport) -> Vec<&'a str> {
let mut kept = Vec::new();
for seq in stop {
if seq.is_empty() {
report.record("sampling.stop", LossAction::Drop, "stop sequence is empty");
} else {
kept.push(seq.as_str());
}
}
kept
}
pub(super) fn reject_sampling_number(
value: Option<f32>,
min: f32,
max: f32,
path: &str,
detail: &str,
) -> Result<(), MapError> {
if let Some(value) = value
&& (!value.is_finite() || value < min || value > max)
{
return Err(MapError::hard(path, detail));
}
Ok(())
}
fn stop_values(value: &Value, keys: &[&str]) -> Vec<String> {
for key in keys {
let Some(raw) = value.get(*key) else {
continue;
};
if let Some(s) = raw.as_str() {
return vec![s.to_string()];
}
if let Some(arr) = raw.as_array() {
return arr
.iter()
.filter_map(|item| item.as_str().map(str::to_string))
.collect();
}
}
Vec::new()
}
fn official_json_schema<'a>(
schema: &'a Value,
name: Option<&'a str>,
report: &mut LossReport,
) -> Option<(&'a Value, &'a str)> {
let name = name.map(str::trim).filter(|s| !s.is_empty());
match name {
Some(name) if schema.is_object() => Some((schema, name)),
_ => {
report.record(
"sampling.json_schema",
LossAction::Drop,
"json_schema requires nonempty name and object schema",
);
None
}
}
}
fn value_as_string(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn apply_tool_name_case(name: &str, case: Option<ToolNameCase>) -> String {
match case {
None | Some(ToolNameCase::AsIs) => name.to_string(),
Some(ToolNameCase::Snake) => convert_case(name, '_'),
Some(ToolNameCase::Kebab) => convert_case(name, '-'),
}
}
fn convert_case(name: &str, sep: char) -> String {
let mut out = String::new();
for (i, ch) in name.chars().enumerate() {
if ch == '-' || ch == '_' {
out.push(sep);
continue;
}
if ch.is_uppercase() {
if i > 0 && !out.ends_with(sep) && !out.ends_with('.') {
out.push(sep);
}
out.extend(ch.to_lowercase());
continue;
}
out.push(ch);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use wiremux_auth::Wire;
#[test]
fn decode_non_object_names_the_wire_and_json_kind() {
let err = decode(Wire::Messages, b"[1]").expect_err("array is not an object");
let text = err.to_string();
assert!(
text.contains("wire `messages`") && text.contains("got array"),
"{text}"
);
}
#[test]
fn cache_only_request_names_the_dropped_checkpoint() {
let ir = crate::ir::IrRequest::new(
"m",
vec![crate::ir::IrItem::SystemCache {
raw: serde_json::json!({"type": "default"}),
}],
);
let cases = [
(
Wire::ChatCompletions,
"chat-completions",
"chat messages must not be empty",
"system cache point has no slot",
),
(
Wire::Messages,
"messages",
"messages must not be empty",
"system cache point has no slot",
),
(
Wire::Responses,
"responses",
"responses input must not be empty",
"system cache point has no slot",
),
(
Wire::Gemini,
"gemini",
"gemini contents must not be empty",
"system cache point has no slot",
),
(
Wire::Converse,
"converse",
"converse messages must not be empty",
"system block needs a message turn",
),
];
for (wire, name, needle, reason) in cases {
let profile = crate::parse_profile_str(&format!(
"schema_version = 1\nid = \"t\"\nwire = \"{name}\"\n"
))
.expect("profile");
let err = encode(wire, &ir, &profile).expect_err(name);
let text = err.to_string();
assert!(
text.contains(needle) && text.contains(reason),
"{name}: {text}"
);
}
}
#[test]
fn system_only_chat_does_not_become_an_empty_messages_or_responses_success() {
let src = br#"{"model":"gpt-4o","messages":[{"role":"system","content":"be brief"}]}"#;
let (ir, _) = decode(Wire::ChatCompletions, src).expect("decode");
for (wire, name, needle) in [
(
Wire::Messages,
"messages",
"system text needs a message turn",
),
(
Wire::Responses,
"responses",
"instructions need an input turn",
),
] {
let profile = crate::parse_profile_str(&format!(
"schema_version = 1\nid = \"t\"\nwire = \"{name}\"\n"
))
.expect("profile");
let err = encode(wire, &ir, &profile).expect_err(name);
let text = err.to_string();
assert!(text.contains(needle), "{name}: {text}");
}
}
#[test]
fn system_only_chat_does_not_become_an_empty_gemini_success() {
let src = br#"{"model":"gpt-4o","messages":[{"role":"system","content":"be brief"}]}"#;
let (ir, _) = decode(Wire::ChatCompletions, src).expect("decode");
let profile =
crate::parse_profile_str("schema_version = 1\nid = \"g\"\nwire = \"gemini\"\n")
.expect("profile");
let err = encode(Wire::Gemini, &ir, &profile).expect_err("system only");
let text = err.to_string();
assert!(
text.contains("gemini contents must not be empty")
&& text.contains("system instruction needs a contents turn"),
"{text}"
);
}
#[test]
fn empty_chat_turn_does_not_become_an_empty_gemini_success() {
let src = br#"{"model":"gpt-4o","messages":[{"role":"user","content":[]}]}"#;
let (ir, _) = decode(Wire::ChatCompletions, src).expect("decode");
let profile =
crate::parse_profile_str("schema_version = 1\nid = \"g\"\nwire = \"gemini\"\n")
.expect("profile");
let err = encode(Wire::Gemini, &ir, &profile).expect_err("empty contents");
let text = err.to_string();
assert!(
text.contains("gemini contents must not be empty")
&& text.contains("empty turn has no gemini parts"),
"{text}"
);
}
#[test]
fn chat_image_detail_is_dropped_on_messages_with_a_loss_line() {
let src = br#"{"model":"gpt-4o","messages":[{"role":"user","content":[{"type":"text","text":"what is this"},{"type":"image_url","image_url":{"url":"https://example.com/cat.png","detail":"high"}}]}]}"#;
let (ir, decode_loss) = decode(Wire::ChatCompletions, src).expect("decode");
assert!(
decode_loss
.events
.iter()
.all(|event| event.path != "part.image.detail"),
"chat keeps detail, got {decode_loss:?}"
);
let profile =
crate::parse_profile_str("schema_version = 1\nid = \"m\"\nwire = \"messages\"\n")
.expect("profile");
let (body, loss) = encode(Wire::Messages, &ir, &profile).expect("encode");
let text = String::from_utf8(body).expect("utf8");
assert!(text.contains("https://example.com/cat.png"), "{text}");
assert!(!text.contains("high"), "{text}");
assert!(
loss.events.iter().any(|event| {
event.path == "part.image.detail" && event.detail.contains("no messages slot")
}),
"{loss:?}"
);
let chat = crate::parse_profile_str(
"schema_version = 1\nid = \"c\"\nwire = \"chat-completions\"\n",
)
.expect("profile");
let (kept, kept_loss) = encode(Wire::ChatCompletions, &ir, &chat).expect("chat");
let kept_text = String::from_utf8(kept).expect("utf8");
assert!(kept_text.contains("high"), "{kept_text}");
assert!(
kept_loss
.events
.iter()
.all(|event| event.path != "part.image.detail"),
"{kept_loss:?}"
);
}
#[test]
fn chat_image_detail_stays_on_responses_and_drops_on_gemini_and_converse() {
let src = br#"{"model":"gpt-4o","messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://example.com/cat.png","detail":"low"}}]}]}"#;
let (ir, _) = decode(Wire::ChatCompletions, src).expect("decode");
let responses =
crate::parse_profile_str("schema_version = 1\nid = \"t\"\nwire = \"responses\"\n")
.expect("profile");
let (body, loss) = encode(Wire::Responses, &ir, &responses).expect("responses");
let text = String::from_utf8(body).expect("utf8");
assert!(
text.contains("low"),
"responses must keep image detail: {text}"
);
assert!(
loss.events
.iter()
.all(|event| event.path != "part.image.detail"),
"responses must not drop image detail: {loss:?}"
);
let gemini =
crate::parse_profile_str("schema_version = 1\nid = \"t\"\nwire = \"gemini\"\n")
.expect("profile");
let (_body, loss) = encode(Wire::Gemini, &ir, &gemini).expect("gemini");
assert!(
loss.events.iter().any(|event| {
event.path == "part.image.detail" && event.detail.contains("no gemini slot")
}),
"gemini dropped image detail without a loss line: {loss:?}"
);
let profile =
crate::parse_profile_str("schema_version = 1\nid = \"t\"\nwire = \"converse\"\n")
.expect("profile");
let err = encode(Wire::Converse, &ir, &profile).expect_err("image-only https");
let text = err.to_string();
assert!(
text.contains("converse messages must not be empty")
&& text.contains("image url has no converse slot")
&& text.contains("image detail has no converse slot"),
"{text}"
);
}
#[test]
fn chat_data_url_detail_round_trips_and_other_wires_record_the_drop() {
let src = br#"{"model":"gpt-4o","messages":[{"role":"user","content":[{"type":"text","text":"what is this"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abc","detail":"high"}}]}]}"#;
let (ir, decode_loss) = decode(Wire::ChatCompletions, src).expect("decode");
assert!(
decode_loss
.events
.iter()
.all(|event| event.path != "part.image.detail"),
"same-wire decode must keep detail, got {decode_loss:?}"
);
assert!(
ir.items.iter().any(|item| matches!(
item,
crate::ir::IrItem::User { parts } if parts.iter().any(|part| matches!(
part,
crate::ir::IrPart::ImageUrl { url, detail, .. }
if url == "data:image/png;base64,abc" && detail.as_deref() == Some("high")
))
)),
"data URL detail must stay on ImageUrl, got {:?}",
ir.items
);
let chat = crate::parse_profile_str(
"schema_version = 1\nid = \"c\"\nwire = \"chat-completions\"\n",
)
.expect("profile");
let (kept, kept_loss) = encode(Wire::ChatCompletions, &ir, &chat).expect("chat");
let kept_text = String::from_utf8(kept).expect("utf8");
assert!(
kept_text.contains("data:image/png;base64,abc")
&& kept_text.contains("\"detail\":\"high\""),
"{kept_text}"
);
assert!(
kept_loss
.events
.iter()
.all(|event| event.path != "part.image.detail"),
"{kept_loss:?}"
);
let auto = br#"{"model":"gpt-4o","messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,abc","detail":"auto"}}]}]}"#;
let (auto_ir, _) = decode(Wire::ChatCompletions, auto).expect("auto");
let (auto_body, _) = encode(Wire::ChatCompletions, &auto_ir, &chat).expect("auto encode");
let auto_text = String::from_utf8(auto_body).expect("utf8");
assert!(
!auto_text.contains("detail") && auto_text.contains("data:image/png;base64,abc"),
"{auto_text}"
);
let https = br#"{"model":"gpt-4o","messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://example.com/cat.png","detail":"high"}}]}]}"#;
let (https_ir, _) = decode(Wire::ChatCompletions, https).expect("https");
let (https_body, _) =
encode(Wire::ChatCompletions, &https_ir, &chat).expect("https encode");
let https_text = String::from_utf8(https_body).expect("utf8");
assert!(https_text.contains("\"detail\":\"high\""), "{https_text}");
let responses =
crate::parse_profile_str("schema_version = 1\nid = \"r\"\nwire = \"responses\"\n")
.expect("profile");
let responses_src = br#"{"model":"gpt-4o","input":[{"role":"user","content":[{"type":"input_image","image_url":"data:image/png;base64,abc","detail":"high"}]}]}"#;
let (responses_ir, _) = decode(Wire::Responses, responses_src).expect("responses decode");
let (responses_body, responses_loss) =
encode(Wire::Responses, &responses_ir, &responses).expect("responses");
let responses_text = String::from_utf8(responses_body).expect("utf8");
assert!(
responses_text.contains("\"detail\":\"high\""),
"{responses_text}"
);
assert!(
responses_loss
.events
.iter()
.all(|event| event.path != "part.image.detail"),
"{responses_loss:?}"
);
let messages =
crate::parse_profile_str("schema_version = 1\nid = \"m\"\nwire = \"messages\"\n")
.expect("profile");
let (messages_body, messages_loss) =
encode(Wire::Messages, &ir, &messages).expect("messages");
let messages_text = String::from_utf8(messages_body).expect("utf8");
assert!(
messages_text.contains("\"data\":\"abc\"") && !messages_text.contains("high"),
"{messages_text}"
);
assert!(
messages_loss.events.iter().any(|event| {
event.path == "part.image.detail" && event.detail.contains("no messages slot")
}),
"{messages_loss:?}"
);
let gemini =
crate::parse_profile_str("schema_version = 1\nid = \"g\"\nwire = \"gemini\"\n")
.expect("profile");
let (gemini_body, gemini_loss) = encode(Wire::Gemini, &ir, &gemini).expect("gemini");
let gemini_text = String::from_utf8(gemini_body).expect("utf8");
assert!(
gemini_text.contains("\"data\":\"abc\"") && !gemini_text.contains("high"),
"{gemini_text}"
);
assert!(
gemini_loss.events.iter().any(|event| {
event.path == "part.image.detail" && event.detail.contains("no gemini slot")
}),
"{gemini_loss:?}"
);
let converse =
crate::parse_profile_str("schema_version = 1\nid = \"v\"\nwire = \"converse\"\n")
.expect("profile");
let (converse_body, converse_loss) =
encode(Wire::Converse, &ir, &converse).expect("converse");
let converse_text = String::from_utf8(converse_body).expect("utf8");
assert!(
converse_text.contains("\"bytes\":\"abc\"") && !converse_text.contains("high"),
"{converse_text}"
);
assert!(
converse_loss.events.iter().any(|event| {
event.path == "part.image.detail" && event.detail.contains("no converse slot")
}),
"{converse_loss:?}"
);
}
#[test]
fn decode_invalid_json_names_the_wire() {
let err = decode(Wire::ChatCompletions, b"").expect_err("empty");
let text = err.to_string();
assert!(
text.contains("wire `chat-completions`") && text.contains("invalid JSON"),
"{text}"
);
}
#[test]
fn snake_and_kebab_leave_dots() {
assert_eq!(convert_case("crm.lookupId", '_'), "crm.lookup_id");
assert_eq!(convert_case("crm.lookup_id", '-'), "crm.lookup-id");
}
}