use crate::compat::ToolResultMedia;
use crate::errors::{ErrorMeta, Lm15Error};
use crate::types::{Part, ToolResultPart};
pub(crate) fn validate_slots(
request: &crate::types::Request,
provider: &str,
dialect: &str,
tool_media: ToolResultMedia,
) -> Result<(), Lm15Error> {
use crate::types::{Role, SystemContent};
if let Some(SystemContent::Parts(parts)) = &request.system {
for (j, p) in parts.iter().enumerate() {
if is_media(p) {
return Err(crate::adaptation::refusal(
provider,
&format!("system[{j}]"),
"the program depends on this part, but the system text slot cannot carry media",
));
}
}
}
for (i, m) in request.messages.iter().enumerate() {
for (j, p) in m.parts.iter().enumerate() {
let path = format!("messages[{i}].parts[{j}]");
if let Part::ToolResult(result) = p {
if dialect == "gemini"
&& result.name.is_none()
&& !request.messages[..i]
.iter()
.rev()
.filter(|m| m.role == Role::Assistant)
.flat_map(|m| m.parts.iter().rev())
.any(|p| matches!(p,Part::ToolCall(call) if call.id==result.id))
{
return Err(crate::adaptation::refusal(provider,&format!("{path}.name"),"a real choice is needed: the Gemini function result needs a name and no preceding call with this id supplied one"));
}
for (k, part) in result.content.iter().enumerate() {
let kind = part.type_name();
if is_media(part) && !tool_media.admits(kind) {
let why = if tool_media == ToolResultMedia::Reject {
"this server takes text-only tool results".to_string()
} else {
format!(
"this server carries images but not {kind} parts in a tool result"
)
};
return Err(crate::adaptation::refusal(
provider,
&format!("{path}.content[{k}]"),
format!(
"a {kind} part in tool_result {:?} cannot reach this wire — {why} \
(compat tool_result_media={:?}). Carried natively by {}; or render the \
part to text yourself before building the tool result (MAP-10)",
result.id,
tool_media.as_str(),
door(kind)
),
));
}
}
}
let gap = match dialect {
"anthropic" => {
matches!(p, Part::Audio(_) | Part::Video(_) | Part::Binary(_))
|| (m.role == Role::Developer && is_media(p))
}
"openai" => is_media(p) && m.role == Role::Assistant,
"openai_chat" => {
is_media(p) && (m.role == Role::Assistant || !matches!(p, Part::Image(_)))
}
"gemini" => m.role == Role::Developer && is_media(p),
_ => false,
};
if gap {
return Err(crate::adaptation::refusal(provider,&path,format!("the program depends on this {} {} part; no native {} content slot carries it (MAP-10)",m.role,p.type_name(),dialect)));
}
}
}
Ok(())
}
pub fn is_media(part: &Part) -> bool {
matches!(
part,
Part::Image(_) | Part::Audio(_) | Part::Video(_) | Part::Document(_) | Part::Binary(_)
)
}
fn unsupported(provider: &str, message: String) -> Lm15Error {
let mut meta = ErrorMeta::new(format!("{provider}: {message}"));
meta.provider = Some(provider.to_string());
Lm15Error::UnsupportedFeatureError(meta)
}
pub fn parts_to_text(parts: &[Part], provider: &str, where_: &str) -> Result<String, Lm15Error> {
let mut out: Vec<String> = Vec::new();
for part in parts {
if is_media(part) {
return Err(unsupported(
provider,
format!(
"a {} part cannot reach {where_}, which takes text only; no text rendering of a media part is made (MAP-10)",
part.type_name()
),
));
}
match part {
Part::Text(text) => out.push(text.text.clone()),
Part::Data(data) => out.push(data.value.to_string()),
Part::Thinking(thinking) if !thinking.text.is_empty() => {
out.push(thinking.text.clone())
}
Part::Citation(citation) => {
let bits: Vec<&str> = [
citation.title.as_deref(),
citation.url.as_deref(),
citation.text.as_deref(),
]
.into_iter()
.flatten()
.filter(|s| !s.is_empty())
.collect();
if !bits.is_empty() {
out.push(bits.join(" — "));
}
}
_ => {}
}
}
Ok(out.join("\n"))
}
fn door(kind: &str) -> &'static str {
match kind {
"image" => "the OpenAI Responses, Anthropic Messages and Gemini dialects (and the xai/moonshotai/zai chat presets)",
"document" => "the OpenAI Responses, Anthropic Messages and Gemini dialects",
_ => "no lm15 door yet",
}
}
pub fn check_tool_result_media(
provider: &str,
part: &ToolResultPart,
policy: ToolResultMedia,
wire: &str,
) -> Result<(), Lm15Error> {
for p in &part.content {
let kind = p.type_name();
if is_media(p) && !policy.admits(kind) {
let why = if policy == ToolResultMedia::Reject {
"this server takes text-only tool results".to_string()
} else {
format!("this server carries images but not {kind} parts in a tool result")
};
return Err(unsupported(
provider,
format!(
"a {kind} part in tool_result {:?} cannot reach {wire} — {why} (compat tool_result_media={:?}, \
measured: lm15-contract/research/tool-result-content/). Carried natively by {}; or render the \
part to text yourself before building the tool result (MAP-10)",
part.id,
policy.as_str(),
door(kind)
),
));
}
}
Ok(())
}
pub fn error_text(part: &ToolResultPart, text: String) -> String {
if part.is_error {
format!("[error] {text}")
} else {
text
}
}
pub fn text_only(parts: &[Part]) -> bool {
parts.iter().all(|p| !is_media(p))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ImagePart, TextPart};
fn image() -> Part {
Part::Image(ImagePart {
media_type: "image/png".into(),
data: Some("QUJD".into()),
..Default::default()
})
}
#[test]
fn media_never_renders_as_text() {
let text = Part::Text(TextPart::new("a"));
assert_eq!(
parts_to_text(std::slice::from_ref(&text), "p", "x").unwrap(),
"a"
);
let err = parts_to_text(&[text, image()], "p", "a tool row").unwrap_err();
assert_eq!(err.class_name(), "UnsupportedFeatureError");
assert!(err.message().contains("image part cannot reach a tool row"));
}
#[test]
fn policy_matrix() {
let result = ToolResultPart {
id: "c".into(),
content: vec![image()],
name: None,
is_error: false,
continuation: vec![],
};
assert!(check_tool_result_media("p", &result, ToolResultMedia::Native, "w").is_ok());
assert!(check_tool_result_media("p", &result, ToolResultMedia::Images, "w").is_ok());
let err = check_tool_result_media("p", &result, ToolResultMedia::Reject, "w").unwrap_err();
assert!(err.message().contains("tool_result_media=\"reject\""));
assert!(err.message().contains("Responses"));
assert_eq!(error_text(&result, "x".into()), "x");
}
}