use crate::prompt::{load_prompt, substitute};
use crate::retry::RetryExhausted;
use crate::util::INJECTED_IMAGE_TAG;
use crate::{ChatMessage, ChatRole};
const INPUT_INSPECTION_CODE: &str = "data_inspection_failed";
const INAPPROPRIATE_CONTENT_FRAGMENT: &str = "inappropriate content";
const REASON_FALLBACK: &str = "no specific reason was provided";
const REASON_CAP: usize = 500;
const PHRASE_ASSET: &str = "context/image_rejected.md";
#[must_use]
pub(crate) fn detect_input_image_rejection(
exhausted: &RetryExhausted,
history: &[ChatMessage],
) -> Option<usize> {
let trail: Vec<String> = exhausted
.failures
.iter()
.map(|f| f.error_chain.to_lowercase())
.collect();
let has_code = trail.iter().any(|t| t.contains(INPUT_INSPECTION_CODE));
let has_image_fragment = trail
.iter()
.any(|t| t.contains(INAPPROPRIATE_CONTENT_FRAGMENT) && t.contains("image"));
if !has_code || !has_image_fragment {
return None;
}
let idx = history.iter().rposition(|m| m.role == ChatRole::User)?;
has_image_marker(&history[idx].content).then_some(idx)
}
fn has_image_marker(content: &str) -> bool {
crate::util::MEDIA_MARKER_RE
.captures_iter(content)
.any(|caps| crate::util::parse_media_marker(&caps).0 == "IMAGE")
}
#[must_use]
pub(crate) fn extract_provider_reason(exhausted: &RetryExhausted) -> Option<String> {
exhausted
.failures
.iter()
.find_map(|f| reason_from_chain(&f.error_chain))
}
fn reason_from_chain(chain: &str) -> Option<String> {
let sep = chain.find("): ")?;
let body = &chain[sep + 3..];
let body_json = serde_json::from_str::<serde_json::Value>(body).ok()?;
crate::util::extract_provider_error_detail(&body_json)
}
#[must_use]
pub(crate) fn strip_image_markers(content: &str, reason: Option<&str>) -> String {
let reason = reason.unwrap_or(REASON_FALLBACK);
let sanitized = crate::util::truncate(&crate::util::scrub_credentials(reason), REASON_CAP);
let sanitized = sanitized
.trim_end_matches(|c: char| c == '.' || c.is_whitespace())
.to_string();
let phrase = substitute(
&load_prompt(PHRASE_ASSET),
&[("{{reason}}", sanitized.as_str())],
);
let mut out = String::with_capacity(content.len() + phrase.len());
let mut first = true;
let mut last_end = 0usize;
for caps in crate::util::MEDIA_MARKER_RE.captures_iter(content) {
let (kind, _) = crate::util::parse_media_marker(&caps);
if kind != "IMAGE" {
continue;
}
let whole = caps.get_match();
if first {
let seg = &content[last_end..whole.start()];
if seg.contains(INJECTED_IMAGE_TAG) {
let cleaned = seg.replace(INJECTED_IMAGE_TAG, "").trim().to_string();
out.push_str(&cleaned);
} else {
out.push_str(seg);
}
out.push_str(&phrase);
first = false;
} else {
out.push_str(&content[last_end..whole.start()]);
}
last_end = whole.end();
}
out.push_str(&content[last_end..]);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::retry::{FailureClass, RetryFailureRecord};
fn trail(error_chain: &str) -> RetryExhausted {
RetryExhausted::with_last_raw(
vec![RetryFailureRecord::new_simple(
FailureClass::NonRetryable,
&anyhow::anyhow!("{error_chain}"),
None,
)],
FailureClass::NonRetryable,
None,
)
}
const REJECTION_CHAIN: &str = r#"OpenRouter API error (400): {"error":{"message":"Input image data may contain inappropriate content.","code":"data_inspection_failed","type":"invalid_request_error"}}"#;
fn history_with_image() -> Vec<ChatMessage> {
vec![
ChatMessage::system("role description"),
ChatMessage::user("earlier text without image"),
ChatMessage::user("[IMAGE:/tmp/photo.png]\n\ndescribe this"),
]
}
#[test]
fn image_rejection_detected_on_most_recent_user_message() {
let idx = detect_input_image_rejection(&trail(REJECTION_CHAIN), &history_with_image());
assert_eq!(
idx,
Some(2),
"the image-bearing user message must be targeted"
);
}
#[test]
fn text_content_rejection_with_same_code_does_not_trigger_strip() {
let chain = r#"OpenRouter API error (400): {"error":{"message":"Input data may contain inappropriate content.","code":"data_inspection_failed","type":"invalid_request_error"}}"#;
assert_eq!(
detect_input_image_rejection(&trail(chain), &history_with_image()),
None
);
}
#[test]
fn missing_inspection_code_does_not_trigger_strip() {
let chain = r#"OpenRouter API error (400): {"error":{"message":"Input image data may contain inappropriate content.","code":"other_code","type":"invalid_request_error"}}"#;
assert_eq!(
detect_input_image_rejection(&trail(chain), &history_with_image()),
None
);
}
#[test]
fn case_insensitive_matching() {
let chain = r#"OpenRouter API error (400): {"error":{"message":"INPUT IMAGE DATA MAY CONTAIN INAPPROPRIATE CONTENT.","code":"DATA_INSPECTION_FAILED"}}"#;
assert_eq!(
detect_input_image_rejection(&trail(chain), &history_with_image()),
Some(2)
);
}
#[test]
fn strip_targets_only_the_most_recent_user_message() {
let history = vec![
ChatMessage::system("role description"),
ChatMessage::user("[IMAGE:/tmp/old.png] old image"),
ChatMessage::user("a later text-only message"),
];
assert_eq!(
detect_input_image_rejection(&trail(REJECTION_CHAIN), &history),
None
);
let history = vec![
ChatMessage::user("[IMAGE:/tmp/old.png] old"),
ChatMessage::user("[IMAGE:/tmp/new.png] new"),
];
let idx = detect_input_image_rejection(&trail(REJECTION_CHAIN), &history);
assert_eq!(idx, Some(1));
}
#[test]
fn malformed_or_empty_marker_does_not_trigger_strip() {
for content in [
"hello [IMAGE:] world",
"unclosed [IMAGE:foo",
"trailing [IMAGE:",
"[AUDIO:/tmp/sound.mp3] audio-only",
] {
let history = vec![ChatMessage::system("role"), ChatMessage::user(content)];
assert_eq!(
detect_input_image_rejection(&trail(REJECTION_CHAIN), &history),
None,
"content with no strippable IMAGE marker must not trigger: {content:?}"
);
}
}
#[test]
fn malformed_latest_marker_with_earlier_image_is_conservative_noop() {
let history = vec![
ChatMessage::user("[IMAGE:/tmp/old.png] earlier image"),
ChatMessage::user("see [IMAGE: here"),
];
assert_eq!(
detect_input_image_rejection(&trail(REJECTION_CHAIN), &history),
None
);
}
#[test]
fn strip_replaces_all_image_markers_with_single_phrase() {
let content = "see [IMAGE:/tmp/a.png] and [IMAGE:/tmp/b.png] here";
let out = strip_image_markers(content, Some("the image is blocked"));
assert!(
out.starts_with("see "),
"user text before the first marker is preserved"
);
assert!(!out.contains("[IMAGE:"), "all image markers removed");
assert!(
out.contains("rejected by the provider's content-inspection check"),
"phrase explains the rejection: {out}"
);
assert!(
out.contains("the image is blocked"),
"sanitized reason embedded: {out}"
);
assert!(
out.contains("here"),
"user text after the last marker is preserved"
);
assert!(
out.contains("and "),
"separator text preserved verbatim: {out}"
);
}
#[test]
fn strip_preserves_non_image_markers() {
let content = "[AUDIO:/tmp/sound.mp3] listen and [IMAGE:/tmp/img.png] look";
let out = strip_image_markers(content, None);
assert!(
out.contains("[AUDIO:/tmp/sound.mp3]"),
"audio marker untouched"
);
assert!(!out.contains("[IMAGE:"), "image marker removed");
assert!(out.contains("listen and"), "user text preserved");
assert!(out.contains("look"));
}
#[test]
fn strip_fallback_reason_when_absent() {
let out = strip_image_markers("[IMAGE:/tmp/a.png] hi", None);
assert!(out.contains("no specific reason was provided"));
}
#[test]
fn strip_scrubs_credentials_from_reason() {
let content = "[IMAGE:/tmp/a.png] hi";
let out = strip_image_markers(content, Some("leaked API_KEY=sk-1234567890abcdef"));
assert!(
!out.contains("sk-1234567890abcdef"),
"credential scrubbed: {out}"
);
}
#[test]
fn strip_empty_marker_preserved_verbatim() {
let content = "hello [IMAGE:] world";
assert_eq!(strip_image_markers(content, None), content);
}
#[test]
fn strip_consumes_injected_image_tag_with_marker() {
let content = "<injected-tool-result-image>\n[IMAGE:data:image/jpeg;base64,abc]";
let out = strip_image_markers(content, Some("blocked"));
assert!(!out.contains("[IMAGE:"), "image marker removed: {out}");
assert!(
!out.contains("<injected-tool-result-image>"),
"provenance tag consumed: {out}"
);
assert!(
out.contains("rejected by the provider's content-inspection check"),
"phrase present: {out}"
);
assert!(
!out.starts_with("<injected-tool-result-image>"),
"no dangling tag next to the phrase: {out}"
);
assert!(!out.contains('\n'), "no dangling newline prefix: {out:?}");
assert!(!out.starts_with(' '), "no leading whitespace: {out:?}");
}
#[test]
fn reason_extracted_from_http_error_body() {
let reason = extract_provider_reason(&trail(REJECTION_CHAIN));
assert_eq!(
reason.as_deref(),
Some("Input image data may contain inappropriate content.")
);
}
#[test]
fn reason_extracted_from_nested_envelope() {
let chain = r#"OpenRouter API error (400): {"error":{"code":"data_inspection_failed","metadata":{"raw":"Input image data may contain inappropriate content."}}}"#;
assert_eq!(
extract_provider_reason(&trail(chain)).as_deref(),
Some("Input image data may contain inappropriate content.")
);
}
#[test]
fn reason_absent_for_non_http_chain() {
let exhausted = RetryExhausted::with_last_raw(
vec![RetryFailureRecord::new_simple(
FailureClass::Transport,
&anyhow::anyhow!("connection timed out"),
None,
)],
FailureClass::Transport,
None,
);
assert_eq!(extract_provider_reason(&exhausted), None);
}
#[test]
fn reason_falls_back_to_code_when_no_message() {
let chain = r#"OpenRouter API error (400): {"error":{"code":"data_inspection_failed","type":"invalid_request_error"}}"#;
assert_eq!(
extract_provider_reason(&trail(chain)).as_deref(),
Some("data_inspection_failed")
);
}
}