1use base64::{engine::general_purpose::STANDARD, Engine as _};
10use serde::Serialize;
11use serde_json::Value;
12use std::io::{self, Write};
13
14use super::openai::{self, catalog, AuthPolicy, WireProtocol};
15use catalog::{capability_cache, CatalogModel, CatalogSource, Modality};
16
17pub const MAX_ATTACHMENTS_PER_MESSAGE: usize = 8;
19pub const MAX_IMAGE_BYTES: usize = 3_670_016;
21pub const MAX_PDF_BYTES: usize = 10 * 1024 * 1024;
22pub const MAX_TEXT_DOCUMENT_BYTES: usize = 256 * 1024;
24pub const MAX_HISTORY_ENCODED_BYTES: usize = 20 * 1024 * 1024;
26pub const MAX_BROKER_PROXY_BYTES: usize = crate::auth::broker::MAX_PROXY_REQUEST_BYTES;
28
29const BROKER_ENVELOPE_RESERVE_BYTES: usize = 64 * 1024;
33const WIRE_OVERHEAD_PER_ATTACHMENT: usize = 1024;
34const MAX_TITLE_BYTES: usize = 255;
35
36const BAD_ATTACHMENT: &str = "Attachment has an invalid canonical source or media type";
37const BAD_BASE64: &str = "Attachment has invalid base64 data";
38const TOO_LARGE: &str = "Attachment exceeds its byte limit";
39const HISTORY_LIMIT: &str = "Attachments exceed the encoded history byte limit";
40const BROKER_LIMIT: &str = "Attachment history exceeds the conservative 2 MiB broker proxy budget";
41const UNSUPPORTED_TRANSPORT: &str =
42 "Selected transport does not support image or document attachments";
43
44pub fn validate_messages(model: &str, messages: &[crate::SharedMessage]) -> Result<(), String> {
50 validate_history(model, messages, MAX_HISTORY_ENCODED_BYTES)
51}
52
53pub(crate) fn native_image_retention(model: &str) -> bool {
55 if crate::extensions::providers::ProviderRegistry::parse_model_id(model).is_some()
56 || crate::auth::cloud::split_model_route(model).1.is_some()
57 {
58 return false;
59 }
60 if model.contains('/') && !model.starts_with("anthropic/") { return false; }
62 openai::resolve_route(model).is_some_and(|route| {
63 route.provider == "anthropic" && route.wire == WireProtocol::AnthropicMessages
64 && route.auth != AuthPolicy::BrokerProxy
65 })
66}
67
68pub fn validate_history_for_request(model: &str, messages: &[crate::SharedMessage]) -> Result<(), String> {
71 super::image_retention::resource_prepass(messages)?;
72 if !native_image_retention(model) { return validate_messages(model, messages); }
73 validate_history(model, messages, super::image_retention::MAX_ORIGINAL_BYTES)?;
74 super::image_retention::plan(messages)?;
75 Ok(())
76}
77
78fn validate_history(model: &str, messages: &[crate::SharedMessage], limit: usize) -> Result<(), String> {
79 let mut validator = Validator::new(model);
80 validator.history_limit = limit;
81 for message in messages {
82 let mut count = 0;
83 if let Some(blocks) = message.get("content").and_then(Value::as_array) {
84 validator.blocks(
85 blocks,
86 message.get("role").and_then(Value::as_str) == Some("user"),
87 false,
88 &mut count,
89 )?;
90 } else if message.get("content").is_some_and(contains_media) {
91 return Err("Attachments require an array of canonical content blocks".into());
94 }
95 }
96 validator.broker_budget(messages)
97}
98
99pub fn validate_tool_blocks(model: &str, blocks: &[Value]) -> Result<(), String> {
104 let mut validator = Validator::new(model);
105 validator.blocks(blocks, true, true, &mut 0)?;
106 validator.broker_budget(blocks)
107}
108
109pub(crate) fn bounded_tool_results(
114 model: &str,
115 history: &[crate::SharedMessage],
116 mut results: Vec<Value>,
117) -> Result<Value, (String, Vec<Value>)> {
118 if let Err(error) = validate_history_for_request(model, history) {
119 return Err((error, results));
122 }
123 let mut candidate = history.to_vec();
124 candidate.push(std::sync::Arc::new(
125 serde_json::json!({"role":"user","content":results}),
126 ));
127 if native_image_retention(model) {
128 if let Err(error) = validate_history_for_request(model, &candidate) {
129 for result in &mut results {
131 if result.get("content").is_some_and(contains_media) {
132 result["content"] = Value::String(format!("Attachment not sent: {error}"));
133 result["is_error"] = Value::Bool(true);
134 }
135 }
136 }
137 return Ok(serde_json::json!({"role":"user","content":results}));
138 }
139 for index in (0..results.len()).rev() {
140 let Err(error) = validate_messages(model, &candidate) else {
141 break;
142 };
143 if results[index].get("content").is_some_and(contains_media) {
144 results[index]["content"] = Value::String(format!("Attachment not sent: {error}"));
145 results[index]["is_error"] = Value::Bool(true);
146 *candidate.last_mut().expect("candidate tail") =
147 std::sync::Arc::new(serde_json::json!({"role":"user","content":results}));
148 }
149 }
150 Ok(serde_json::json!({"role":"user","content":results}))
151}
152
153struct Transport {
154 wire: WireProtocol,
155 broker_proxy: bool,
156 image: bool,
157 pdf: bool,
158}
159
160impl Transport {
161 fn for_model(model: &str) -> Result<Self, String> {
162 let (cloud_model, context) = crate::auth::cloud::split_model_route(model);
165 if context.is_some()
166 || cloud_model.split_once('/').is_some_and(|(provider, _)| {
167 provider.parse::<crate::auth::CloudProviderId>().is_ok()
168 })
169 || crate::extensions::providers::ProviderRegistry::parse_model_id(model).is_some()
170 {
171 return Err(UNSUPPORTED_TRANSPORT.into());
172 }
173 let (provider, id) = model.split_once('/').unwrap_or(("anthropic", model));
174 if provider.is_empty()
175 || id.is_empty()
176 || model.trim() != model
177 || model.chars().any(char::is_control)
178 || provider == "google-gemini"
179 {
180 return Err(UNSUPPORTED_TRANSPORT.into());
181 }
182
183 let (wire, broker_proxy) = if provider == "local" {
186 (WireProtocol::OpenAiChatCompletions, true)
187 } else {
188 let route = openai::resolve_route(model).ok_or(UNSUPPORTED_TRANSPORT)?;
189 if route.provider != provider || route.model != id {
190 return Err(UNSUPPORTED_TRANSPORT.into());
191 }
192 (route.wire, route.auth == AuthPolicy::BrokerProxy)
193 };
194 if wire == WireProtocol::GoogleGeminiCodeAssist {
195 return Err(UNSUPPORTED_TRANSPORT.into());
196 }
197
198 let qualified = format!("{provider}/{id}");
199 let known_native = provider == "anthropic"
200 && agent_core::models::KNOWN_MODELS
201 .iter()
202 .any(|(known, _)| *known == id);
203 let cached = capability_cache::get(&qualified);
206 let fallback = if cached.is_none() && provider == "openai-codex" {
207 catalog::codex_static_catalog_models()
208 .into_iter()
209 .find(|row| row.runtime_id() == qualified)
210 } else {
211 None
212 };
213 Ok(Self::with_evidence(
214 wire,
215 broker_proxy,
216 provider,
217 id,
218 known_native,
219 cached.as_ref(),
220 fallback.as_ref(),
221 ))
222 }
223
224 fn with_evidence(
225 wire: WireProtocol,
226 broker_proxy: bool,
227 provider: &str,
228 id: &str,
229 known_native: bool,
230 cached: Option<&CatalogModel>,
231 fallback: Option<&CatalogModel>,
232 ) -> Self {
233 let advertised = |modality: Modality| {
234 let row = cached.or(fallback);
235 row.is_some_and(|row| {
236 row.provider_key == provider
239 && row.id == id
240 && (row.source == CatalogSource::Live
241 || (provider == "openai-codex"
242 && row.source == CatalogSource::StaticFallback))
243 && row.input_modalities.contains(&modality)
244 })
245 };
246 let image = if wire == WireProtocol::AnthropicMessages {
251 known_native
252 } else {
253 advertised(Modality::Image)
254 };
255 let pdf = match wire {
256 WireProtocol::AnthropicMessages => known_native,
257 WireProtocol::OpenAiChatCompletions
258 | WireProtocol::OpenAiResponses
259 | WireProtocol::CodexResponses => advertised(Modality::File),
260 WireProtocol::GoogleGeminiCodeAssist => false,
261 };
262 Self {
263 wire,
264 broker_proxy,
265 image,
266 pdf,
267 }
268 }
269
270 fn permits(&self, kind: AttachmentKind) -> Result<(), String> {
271 if !matches!(
272 self.wire,
273 WireProtocol::AnthropicMessages
274 | WireProtocol::OpenAiChatCompletions
275 | WireProtocol::OpenAiResponses
276 | WireProtocol::CodexResponses
277 ) {
278 return Err(UNSUPPORTED_TRANSPORT.into());
279 }
280 match kind {
281 AttachmentKind::Image if !self.image => {
282 Err("Selected model lacks exact image input capability metadata".into())
283 }
284 AttachmentKind::Pdf if !self.pdf => {
285 Err("Selected model and transport lack supported PDF file input capability".into())
286 }
287 _ => Ok(()),
290 }
291 }
292}
293
294#[derive(Clone, Copy)]
295enum AttachmentKind {
296 Image,
297 Pdf,
298 Text,
299}
300
301struct Validator<'a> {
302 model: &'a str,
303 transport: Option<Transport>,
304 encoded_bytes: usize,
305 history_limit: usize,
306 attachments: usize,
307}
308
309impl<'a> Validator<'a> {
310 fn new(model: &'a str) -> Self {
311 Self {
312 model,
313 transport: None,
314 encoded_bytes: 0,
315 history_limit: MAX_HISTORY_ENCODED_BYTES,
316 attachments: 0,
317 }
318 }
319
320 fn blocks(
321 &mut self,
322 blocks: &[Value],
323 user: bool,
324 in_tool_result: bool,
325 count: &mut usize,
326 ) -> Result<(), String> {
327 for block in blocks {
328 let kind = block.get("type").and_then(Value::as_str);
329 match kind {
330 Some("image" | "document") => {
331 if !user {
332 return Err(
333 "Attachments are only supported in user content or tool results".into(),
334 );
335 }
336 *count += 1;
337 if *count > MAX_ATTACHMENTS_PER_MESSAGE {
338 return Err(
339 "A message may contain at most 8 attachments, including tool media"
340 .into(),
341 );
342 }
343 self.attachment(block)?;
344 }
345 Some("tool_result") => {
346 if let Some(content) = block.get("content") {
347 match content {
348 Value::Array(nested) if user && !in_tool_result => {
349 self.blocks(nested, true, true, count)?;
350 }
351 _ if contains_media(content) => {
352 return Err(
353 "Tool attachments require a canonical user tool_result array"
354 .into(),
355 );
356 }
357 _ => {}
358 }
359 }
360 if block.get("source").is_some() {
361 return Err(BAD_ATTACHMENT.into());
362 }
363 }
364 _ if is_media_block(block) || (block.is_array() && contains_media(block)) => {
368 return Err("Unsupported or malformed canonical media block".into());
369 }
370 _ => {}
371 }
372 }
373 Ok(())
374 }
375
376 fn attachment(&mut self, block: &Value) -> Result<(), String> {
377 if self.transport.is_none() {
378 self.transport = Some(Transport::for_model(self.model)?);
379 }
380 let kind = validate_attachment(block)?;
381 self.transport
382 .as_ref()
383 .expect("transport resolved")
384 .permits(kind)?;
385 let remaining = self.history_limit.saturating_sub(self.encoded_bytes);
386 self.encoded_bytes += serialized_size(block, remaining).ok_or(HISTORY_LIMIT)?;
387 self.attachments += 1;
388 Ok(())
389 }
390
391 fn broker_budget<T: Serialize + ?Sized>(&self, content: &T) -> Result<(), String> {
392 if !self.transport.as_ref().is_some_and(|t| t.broker_proxy) {
393 return Ok(());
394 }
395 let overhead = self
396 .attachments
397 .saturating_mul(WIRE_OVERHEAD_PER_ATTACHMENT);
398 let remaining = MAX_BROKER_PROXY_BYTES
399 .saturating_sub(BROKER_ENVELOPE_RESERVE_BYTES)
400 .saturating_sub(overhead);
401 serialized_size(content, remaining).ok_or(BROKER_LIMIT)?;
402 Ok(())
403 }
404}
405
406fn validate_attachment(block: &Value) -> Result<AttachmentKind, String> {
407 let object = block.as_object().ok_or(BAD_ATTACHMENT)?;
408 if object
409 .keys()
410 .any(|key| !matches!(key.as_str(), "type" | "source" | "title" | "cache_control"))
411 {
412 return Err(BAD_ATTACHMENT.into());
413 }
414 let source = block
415 .get("source")
416 .and_then(Value::as_object)
417 .ok_or(BAD_ATTACHMENT)?;
418 if source.len() != 3
421 || source
422 .keys()
423 .any(|key| !matches!(key.as_str(), "type" | "media_type" | "data"))
424 {
425 return Err(BAD_ATTACHMENT.into());
426 }
427 let source_type = source
428 .get("type")
429 .and_then(Value::as_str)
430 .ok_or(BAD_ATTACHMENT)?;
431 let mime = source
432 .get("media_type")
433 .and_then(Value::as_str)
434 .ok_or(BAD_ATTACHMENT)?;
435 let data = source
436 .get("data")
437 .and_then(Value::as_str)
438 .ok_or(BAD_ATTACHMENT)?;
439 if let Some(title) = block.get("title") {
440 validate_title(title)?;
441 }
442 match (block.get("type").and_then(Value::as_str), source_type, mime) {
443 (Some("image"), "base64", "image/png" | "image/jpeg" | "image/gif" | "image/webp") => {
444 if block.get("title").is_some() {
445 return Err(BAD_ATTACHMENT.into());
446 }
447 let bytes = decode_bounded(data, MAX_IMAGE_BYTES)?;
448 if crate::tools::read::sniff_image_mime(&bytes) != Some(mime) {
449 return Err("Image attachment data does not match its declared media type".into());
450 }
451 if crate::tools::read::image_integrity_error(mime, &bytes).is_some()
454 || !crate::tools::read::image_dimensions(mime, &bytes)
455 .is_some_and(|(w, h)| (1..=8000).contains(&w) && (1..=8000).contains(&h))
456 {
457 return Err("Image attachment is corrupt or has unsupported dimensions".into());
458 }
459 Ok(AttachmentKind::Image)
460 }
461 (Some("document"), "base64", "application/pdf") => {
462 let bytes = decode_bounded(data, MAX_PDF_BYTES)?;
463 if !bytes.starts_with(b"%PDF-")
464 || !bytes
465 .windows(5)
466 .rev()
467 .take(1024)
468 .any(|window| window == b"%%EOF")
469 {
470 return Err(
471 "PDF attachment is invalid or does not match its declared media type".into(),
472 );
473 }
474 Ok(AttachmentKind::Pdf)
475 }
476 (Some("document"), "text", "text/plain") => {
477 validate_title(block.get("title").ok_or(BAD_ATTACHMENT)?)?;
479 if data.len() > MAX_TEXT_DOCUMENT_BYTES {
480 return Err(TOO_LARGE.into());
481 }
482 Ok(AttachmentKind::Text)
483 }
484 _ => Err(BAD_ATTACHMENT.into()),
485 }
486}
487
488fn validate_title(title: &Value) -> Result<(), String> {
489 let title = title.as_str().ok_or(BAD_ATTACHMENT)?;
490 if title.is_empty()
491 || title.len() > MAX_TITLE_BYTES
492 || matches!(title, "." | "..")
493 || title.contains(['/', '\\'])
494 || title.chars().any(char::is_control)
495 {
496 return Err("Document attachment title must be a bounded filename".into());
497 }
498 Ok(())
499}
500
501fn decode_bounded(data: &str, max_bytes: usize) -> Result<Vec<u8>, String> {
502 let max_encoded = max_bytes.div_ceil(3) * 4;
505 if data.len() > max_encoded {
506 return Err(TOO_LARGE.into());
507 }
508 let decoded = STANDARD.decode(data).map_err(|_| BAD_BASE64)?;
509 if decoded.is_empty() {
510 return Err(BAD_BASE64.into());
511 }
512 if decoded.len() > max_bytes {
513 return Err(TOO_LARGE.into());
514 }
515 Ok(decoded)
516}
517
518fn is_media_block(block: &Value) -> bool {
521 block.get("source").is_some()
522 || [
523 "image_url",
524 "input_image",
525 "input_file",
526 "file_id",
527 "file_data",
528 "mimeType",
529 ]
530 .iter()
531 .any(|key| block.get(key).is_some())
532 || matches!(
533 block.get("type").and_then(Value::as_str),
534 Some(
535 "image"
536 | "document"
537 | "image_url"
538 | "input_image"
539 | "input_file"
540 | "file"
541 | "audio"
542 | "input_audio"
543 | "video"
544 | "resource"
545 | "resource_link"
546 )
547 )
548}
549
550fn contains_media(content: &Value) -> bool {
553 let mut pending = vec![content];
554 while let Some(value) = pending.pop() {
555 if is_media_block(value) {
556 return true;
557 }
558 if let Some(blocks) = value.as_array() {
559 pending.extend(blocks);
560 } else if value.get("type").and_then(Value::as_str) == Some("tool_result") {
561 if let Some(nested) = value.get("content") {
562 pending.push(nested);
563 }
564 }
565 }
566 false
567}
568
569pub(super) fn serialized_size<T: Serialize + ?Sized>(value: &T, limit: usize) -> Option<usize> {
571 struct Counter {
572 bytes: usize,
573 limit: usize,
574 }
575 impl Write for Counter {
576 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
577 if bytes.len() > self.limit.saturating_sub(self.bytes) {
578 return Err(io::Error::other("attachment serialization budget exceeded"));
579 }
580 self.bytes += bytes.len();
581 Ok(bytes.len())
582 }
583 fn flush(&mut self) -> io::Result<()> {
584 Ok(())
585 }
586 }
587 let mut counter = Counter { bytes: 0, limit };
588 serde_json::to_writer(&mut counter, value).ok()?;
589 Some(counter.bytes)
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595 use serde_json::json;
596 use std::sync::Arc;
597
598 const NATIVE: &str = "anthropic/claude-sonnet-4-6";
599 const ASTRA: &str = "openai-codex/gpt-6-astra";
600 const SPARK: &str = "openai-codex/gpt-5.3-codex-spark";
601 const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII=";
602
603 fn image() -> Value {
604 json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":PNG}})
605 }
606
607 fn pdf() -> Value {
608 json!({"type":"document","title":"sample.pdf","source":{
610 "type":"base64","media_type":"application/pdf",
611 "data":STANDARD.encode(b"%PDF-1.7\n%%EOF\n")
612 }})
613 }
614
615 fn text(data: &str) -> Value {
616 json!({"type":"document","title":"notes.txt","source":{
617 "type":"text","media_type":"text/plain","data":data
618 }})
619 }
620
621 fn message(blocks: Vec<Value>) -> crate::SharedMessage {
622 Arc::new(json!({"role":"user","content":blocks}))
623 }
624
625 fn check(model: &str, block: Value) -> Result<(), String> {
626 validate_messages(model, &[message(vec![block])])
627 }
628
629 fn row(
630 provider: &str,
631 id: &str,
632 modalities: Vec<Modality>,
633 source: CatalogSource,
634 ) -> CatalogModel {
635 let mut row = CatalogModel::new(provider, provider, id).unwrap();
636 row.input_modalities = modalities;
637 row.source = source;
638 row
639 }
640
641 #[test]
642 fn combined_tool_batch_rejects_media_atomically_and_preserves_ids() {
643 let results: Vec<_> = (0..9).map(|i| json!({"type":"tool_result", "tool_use_id":format!("tool_{i}"), "content":[image()]})).collect();
644 let bounded = bounded_tool_results(NATIVE, &[], results).unwrap();
645 assert!(validate_messages(NATIVE, &[Arc::new(bounded.clone())]).is_ok());
646 for i in 0..9 {
647 assert_eq!(bounded["content"][i]["tool_use_id"], format!("tool_{i}"));
648 }
649 assert_eq!(bounded["content"][7]["is_error"], true);
650 assert_eq!(bounded["content"][8]["is_error"], true);
651 assert!(bounded["content"][8]["content"]
652 .as_str()
653 .unwrap()
654 .starts_with("Attachment not sent:"));
655 }
656
657 #[test]
658 fn original_admission_preserves_adjacent_batch_limits_before_sanitize() {
659 let original = vec![message(vec![image(); 8]), message(vec![image(); 8])];
660 validate_history_for_request(NATIVE, &original).unwrap();
661 let mut outgoing = original.clone();
662 super::super::image_retention::apply(&mut outgoing).unwrap();
663 validate_messages(NATIVE, &outgoing).unwrap();
664 super::super::helpers::HelperMethods::sanitize_thinking_blocks(&mut outgoing);
665 assert_eq!(outgoing.len(), 1);
666 assert!(validate_messages(NATIVE, &outgoing).is_err());
667 assert_eq!(original.len(), 2);
668 }
669
670 #[test]
671 fn invalid_retired_original_and_resource_cap_fail_admission() {
672 let mut history = vec![message(vec![image()]); 101];
673 let mut malformed = image();
674 malformed["source"]["data"] = json!("not base64");
675 history[0] = message(vec![malformed]);
676 assert!(validate_history_for_request(NATIVE, &history).is_err());
677 assert!(validate_history_for_request(NATIVE, &vec![message(vec![image()]); 4097])
678 .unwrap_err().contains("4096"));
679 let evidence = vec![json!({
680 "type":"tool_result","tool_use_id":"text","content":"unchanged"
681 })];
682 let (_, returned) = bounded_tool_results(NATIVE, &history, evidence.clone()).unwrap_err();
683 assert_eq!(returned, evidence);
684 }
685
686 #[test]
687 fn atomic_rejection_preserves_text_siblings() {
688 let mut results = vec![json!({"type":"tool_result","tool_use_id":"text","content":"ok"})];
689 results.extend((0..9).map(|i| json!({
690 "type":"tool_result","tool_use_id":format!("image-{i}"),"content":[image()]
691 })));
692 let bounded = bounded_tool_results(NATIVE, &[], results.clone()).unwrap();
693 assert_eq!(bounded["content"][0], results[0]);
694 for i in 1..10 {
695 assert_eq!(bounded["content"][i]["tool_use_id"], results[i]["tool_use_id"]);
696 assert_eq!(bounded["content"][i]["is_error"], true);
697 }
698 }
699
700 #[test]
701 fn native_route_is_exact_and_other_admission_stays_strict() {
702 assert!(native_image_retention(NATIVE));
703 assert!(native_image_retention("claude-sonnet-4-6"));
704 for model in ["local/test", "openrouter/anthropic/claude-sonnet-4-6",
705 "github-copilot/claude-sonnet-4.6", "bedrock/claude-sonnet-4-6",
706 "plugin:provider:model", "anthropic/not-claude"] {
707 assert!(!native_image_retention(model), "{model}");
708 let history = vec![message(vec![image()])];
709 assert_eq!(validate_history_for_request(model, &history), validate_messages(model, &history));
710 }
711 }
712
713 #[test]
714 fn astra_image_and_text_work_but_static_pdf_and_spark_image_do_not() {
715 assert!(check(ASTRA, image()).is_ok());
716 assert!(validate_tool_blocks(ASTRA, &[image()]).is_ok());
717 assert!(check(ASTRA, text("attached text")).is_ok());
718 assert!(check(ASTRA, pdf()).is_err());
719 assert!(check(SPARK, image()).is_err());
720 assert!(check(SPARK, text("still a text model")).is_ok());
721 assert!(check("openai-codex/gpt-5.4", image()).is_err());
722 assert!(check("openai-codex/gpt-6-astra-unobserved", image()).is_err());
723 }
724
725 #[test]
726 fn exact_native_anthropic_accepts_image_pdf_and_text() {
727 for model in [NATIVE, "claude-sonnet-4-6"] {
728 assert!(
729 validate_messages(model, &[message(vec![image(), pdf(), text("hello")])]).is_ok()
730 );
731 assert!(validate_tool_blocks(model, &[image(), pdf(), text("hello")]).is_ok());
732 }
733 for model in [
734 "anthropic/claude-unobserved",
735 "claude-sonnet-4-6-unobserved",
736 ] {
737 assert!(check(model, image()).is_err());
738 assert!(check(model, pdf()).is_err());
739 }
740 let cached = row(
743 "anthropic",
744 "claude-sonnet-4-6",
745 vec![Modality::Text],
746 CatalogSource::Live,
747 );
748 let transport = Transport::with_evidence(
749 WireProtocol::AnthropicMessages,
750 false,
751 "anthropic",
752 "claude-sonnet-4-6",
753 true,
754 Some(&cached),
755 None,
756 );
757 assert!(transport.permits(AttachmentKind::Image).is_ok());
758 assert!(transport.permits(AttachmentKind::Pdf).is_ok());
759 }
760
761 #[test]
765 fn opus_5_5_native_accepts_image_tool_results_pdf_and_text() {
766 for model in ["anthropic/claude-opus-5-5", "claude-opus-5-5"] {
767 assert!(validate_tool_blocks(model, &[image()]).is_ok(), "{model}");
768 assert!(
769 validate_messages(model, &[message(vec![image(), pdf(), text("hello")])]).is_ok(),
770 "{model}"
771 );
772 let bounded = bounded_tool_results(
773 model,
774 &[],
775 vec![json!({"type":"tool_result","tool_use_id":"toolu_read","content":[image()]})],
776 ).unwrap();
777 assert!(bounded["content"][0]["content"].is_array(), "{model}");
778 assert!(bounded["content"][0].get("is_error").is_none(), "{model}");
779 }
780 for model in [
782 "anthropic/claude-opus-5.5",
783 "anthropic/claude-opus-5-5-preview",
784 ] {
785 assert!(check(model, image()).is_err(), "{model}");
786 }
787 }
788
789 #[test]
790 fn exact_live_cache_support_and_revocation_are_rechecked_for_generic_and_codex() {
791 for provider in ["local", "openai-codex"] {
794 let id = "attachment-validator-live-revocation-sa26";
795 let model = format!("{provider}/{id}");
796 let history = [message(vec![image(), pdf()])];
797 assert!(validate_messages(&model, &history).is_err());
798 capability_cache::insert(row(
799 provider,
800 id,
801 vec![Modality::Image, Modality::File],
802 CatalogSource::Live,
803 ));
804 assert!(validate_messages(&model, &history).is_ok());
805 assert!(validate_tool_blocks(&model, &[image(), pdf()]).is_ok());
806 for modalities in [vec![Modality::Text], vec![]] {
807 capability_cache::insert(row(provider, id, modalities, CatalogSource::Live));
808 assert!(validate_messages(&model, &history).is_err());
809 assert!(validate_tool_blocks(&model, &[image()]).is_err());
810 assert!(check(&model, pdf()).is_err());
811 assert!(check(&model, text("text needs no binary capability")).is_ok());
812 }
813 }
814 }
815
816 #[test]
817 fn generic_cache_requires_exact_provider_id_and_live_source() {
818 let id = "attachment-validator-provider-scope-sa26";
819 capability_cache::insert(row(
820 "groq",
821 id,
822 vec![Modality::Image, Modality::File],
823 CatalogSource::Live,
824 ));
825 assert!(check(&format!("local/{id}"), image()).is_err());
826 assert!(check(&format!("local/{id}"), pdf()).is_err());
827 for source in [
828 CatalogSource::StaticFallback,
829 CatalogSource::StaticWithLive,
830 CatalogSource::Inferred,
831 ] {
832 capability_cache::insert(row(
833 "local",
834 id,
835 vec![Modality::Image, Modality::File],
836 source,
837 ));
838 assert!(check(&format!("local/{id}"), image()).is_err());
839 assert!(check(&format!("local/{id}"), pdf()).is_err());
840 }
841 let wrong = row(
842 "local",
843 "different-model",
844 vec![Modality::Image],
845 CatalogSource::Live,
846 );
847 let transport = Transport::with_evidence(
848 WireProtocol::OpenAiChatCompletions,
849 true,
850 "local",
851 id,
852 false,
853 Some(&wrong),
854 None,
855 );
856 assert!(transport.permits(AttachmentKind::Image).is_err());
857 }
858
859 #[test]
860 fn cached_codex_revocation_overrides_astra_static_evidence_without_global_mutation() {
861 let fallback = catalog::codex_static_catalog_models()
862 .into_iter()
863 .find(|row| row.runtime_id() == ASTRA)
864 .unwrap();
865 for modalities in [vec![Modality::Text], vec![]] {
866 let cached = row(
867 "openai-codex",
868 "gpt-6-astra",
869 modalities,
870 CatalogSource::Live,
871 );
872 let transport = Transport::with_evidence(
873 WireProtocol::CodexResponses,
874 false,
875 "openai-codex",
876 "gpt-6-astra",
877 false,
878 Some(&cached),
879 Some(&fallback),
880 );
881 assert!(transport.permits(AttachmentKind::Image).is_err());
882 assert!(transport.permits(AttachmentKind::Pdf).is_err());
883 }
884 }
885
886 #[test]
887 fn supported_wire_protocols_require_separate_image_and_file_evidence() {
888 for wire in [
889 WireProtocol::OpenAiChatCompletions,
890 WireProtocol::OpenAiResponses,
891 WireProtocol::CodexResponses,
892 ] {
893 let cached = row(
894 "local",
895 "wire-evidence",
896 vec![Modality::Image],
897 CatalogSource::Live,
898 );
899 let transport = Transport::with_evidence(
900 wire,
901 false,
902 "local",
903 "wire-evidence",
904 false,
905 Some(&cached),
906 None,
907 );
908 assert!(transport.permits(AttachmentKind::Image).is_ok());
909 assert!(transport.permits(AttachmentKind::Pdf).is_err());
910 assert!(transport.permits(AttachmentKind::Text).is_ok());
911 }
912 }
913
914 #[test]
915 fn unsupported_cloud_gemini_extensions_reject_all_attachment_kinds() {
916 for model in [
917 "aws-bedrock/anthropic.claude-3-haiku",
918 "aws-bedrock/anthropic.claude-3-haiku#synaps-context=ctx-test",
919 "google-vertex/claude-sonnet-4-6",
920 "azure-openai/gpt-6-astra",
921 "google-gemini/gemini-2.5-pro",
922 "plugin:provider:model",
923 ] {
924 for block in [image(), pdf(), text("not even text documents")] {
925 assert_eq!(
926 check(model, block.clone()).unwrap_err(),
927 UNSUPPORTED_TRANSPORT,
928 "{model}"
929 );
930 assert_eq!(
931 validate_tool_blocks(model, &[block]).unwrap_err(),
932 UNSUPPORTED_TRANSPORT,
933 "{model}"
934 );
935 }
936 assert!(validate_messages(
937 model,
938 &[message(vec![json!({"type":"text","text":"ordinary text"})])]
939 )
940 .is_ok());
941 }
942 }
943
944 #[test]
945 fn invalid_base64_is_rejected_without_echoing_payload() {
946 for data in [
947 "",
948 "private-payload%%%",
949 "Zg",
950 "Zh==",
951 "Zg==\n",
952 "____",
953 "data:image/png;base64,Zg==",
954 ] {
955 let mut block = image();
956 block["source"]["data"] = json!(data);
957 assert_eq!(check(NATIVE, block).unwrap_err(), BAD_BASE64);
958 }
959 }
960
961 #[test]
962 fn corrupt_mismatched_and_out_of_bounds_images_are_rejected() {
963 let bytes = STANDARD.decode(PNG).unwrap();
964 let mut truncated = image();
965 truncated["source"]["data"] = json!(STANDARD.encode(&bytes[..bytes.len() - 1]));
966 assert!(check(NATIVE, truncated).unwrap_err().contains("corrupt"));
967 let mut mismatch = image();
968 mismatch["source"]["media_type"] = json!("image/jpeg");
969 assert!(check(NATIVE, mismatch)
970 .unwrap_err()
971 .contains("declared media type"));
972 for width in [0_u32, 8001] {
973 let mut invalid = bytes.clone();
974 invalid[16..20].copy_from_slice(&width.to_be_bytes());
975 let mut block = image();
976 block["source"]["data"] = json!(STANDARD.encode(invalid));
977 assert!(check(NATIVE, block).unwrap_err().contains("dimensions"));
978 }
979 for bytes in [
980 b"\x89PNG\r\n\x1a\n".as_slice(),
981 b"RIFF\0\0\0\0WEBP".as_slice(),
982 ] {
983 let mut block = image();
984 block["source"]["media_type"] =
985 json!(crate::tools::read::sniff_image_mime(bytes).unwrap());
986 block["source"]["data"] = json!(STANDARD.encode(bytes));
987 assert!(check(NATIVE, block).is_err());
988 }
989 }
990
991 #[test]
992 fn pdf_requires_matching_header_and_nearby_eof() {
993 for bytes in [
994 b"not a PDF %%EOF".to_vec(),
995 b"%PDF-1.7\ntruncated".to_vec(),
996 [b"%PDF-1.7\n%%EOF".as_slice(), &vec![b'x'; 1030]].concat(),
997 ] {
998 let mut block = pdf();
999 block["source"]["data"] = json!(STANDARD.encode(bytes));
1000 assert!(check(NATIVE, block)
1001 .unwrap_err()
1002 .starts_with("PDF attachment is invalid"));
1003 }
1004 }
1005
1006 #[test]
1007 fn canonical_sources_mime_and_titles_are_exact() {
1008 for (key, value) in [
1009 ("type", json!("url")),
1010 ("media_type", json!("IMAGE/PNG")),
1011 ("data", json!(null)),
1012 ("url", json!("https://example.invalid/private")),
1013 ("file_id", json!("secret-id")),
1014 ] {
1015 let mut block = image();
1016 block["source"][key] = value;
1017 assert_eq!(check(NATIVE, block).unwrap_err(), BAD_ATTACHMENT);
1018 }
1019 let mut titled_image = image();
1020 titled_image["title"] = json!("image.png");
1021 assert_eq!(check(NATIVE, titled_image).unwrap_err(), BAD_ATTACHMENT);
1022 for title in [
1023 "",
1024 ".",
1025 "..",
1026 "../private.txt",
1027 "a/b",
1028 "a\\b",
1029 "private\nname",
1030 ] {
1031 let mut block = text("private-payload");
1032 block["title"] = json!(title);
1033 assert_eq!(
1034 check(NATIVE, block).unwrap_err(),
1035 "Document attachment title must be a bounded filename"
1036 );
1037 }
1038 let mut missing_title = text("private-payload");
1039 missing_title.as_object_mut().unwrap().remove("title");
1040 assert_eq!(check(NATIVE, missing_title).unwrap_err(), BAD_ATTACHMENT);
1041 let mut long_title = text("x");
1042 long_title["title"] = json!("x".repeat(MAX_TITLE_BYTES + 1));
1043 assert!(check(NATIVE, long_title).is_err());
1044 for mime in [
1045 "text/html",
1046 "application/octet-stream",
1047 "text/plain; charset=utf-8",
1048 ] {
1049 let mut block = text("x");
1050 block["source"]["media_type"] = json!(mime);
1051 assert_eq!(check(NATIVE, block).unwrap_err(), BAD_ATTACHMENT);
1052 }
1053 }
1054
1055 #[test]
1056 fn byte_bounds_cover_encoded_and_decoded_lengths_and_utf8() {
1057 assert_eq!(MAX_IMAGE_BYTES, 3_670_016);
1058 assert_eq!(MAX_PDF_BYTES, 10 * 1024 * 1024);
1059 assert_eq!(MAX_TEXT_DOCUMENT_BYTES, 256 * 1024);
1060 assert_eq!(MAX_HISTORY_ENCODED_BYTES, 20 * 1024 * 1024);
1061 assert_eq!(MAX_BROKER_PROXY_BYTES, 2 * 1024 * 1024);
1062 assert_eq!(
1063 decode_bounded(&STANDARD.encode([0_u8; 2]), 2).unwrap(),
1064 vec![0, 0]
1065 );
1066 assert_eq!(
1069 decode_bounded(&STANDARD.encode([0_u8; 3]), 2).unwrap_err(),
1070 TOO_LARGE
1071 );
1072 for (mut block, limit) in [(image(), MAX_IMAGE_BYTES), (pdf(), MAX_PDF_BYTES)] {
1073 block["source"]["data"] = json!("A".repeat(limit.div_ceil(3) * 4 + 4));
1074 assert_eq!(check(NATIVE, block).unwrap_err(), TOO_LARGE);
1075 }
1076 let at_limit = "é".repeat(MAX_TEXT_DOCUMENT_BYTES / 2);
1077 assert!(check(NATIVE, text(&at_limit)).is_ok());
1078 assert_eq!(
1079 check(NATIVE, text(&(at_limit + "é"))).unwrap_err(),
1080 TOO_LARGE
1081 );
1082 }
1083
1084 #[test]
1085 fn nested_tool_results_share_message_count_but_history_messages_do_not() {
1086 let seven = vec![image(); MAX_ATTACHMENTS_PER_MESSAGE - 1];
1087 let nested = json!({"type":"tool_result","tool_use_id":"read-1","content":seven});
1088 assert!(validate_messages(NATIVE, &[message(vec![image(), nested.clone()])]).is_ok());
1089 let error =
1090 validate_messages(NATIVE, &[message(vec![image(), image(), nested])]).unwrap_err();
1091 assert_eq!(
1092 error,
1093 "A message may contain at most 8 attachments, including tool media"
1094 );
1095 assert!(validate_tool_blocks(NATIVE, &vec![image(); MAX_ATTACHMENTS_PER_MESSAGE]).is_ok());
1096 assert!(
1097 validate_tool_blocks(NATIVE, &vec![image(); MAX_ATTACHMENTS_PER_MESSAGE + 1]).is_err()
1098 );
1099 let full = message(vec![image(); MAX_ATTACHMENTS_PER_MESSAGE]);
1100 assert!(validate_messages(NATIVE, &[full.clone(), full]).is_ok());
1101 }
1102
1103 #[test]
1104 fn encoded_history_budget_covers_all_messages_and_json_escaping() {
1105 let full = message(vec![
1106 text(&"x".repeat(MAX_TEXT_DOCUMENT_BYTES));
1107 MAX_ATTACHMENTS_PER_MESSAGE
1108 ]);
1109 assert!(validate_messages(NATIVE, &vec![full.clone(); 9]).is_ok());
1110 assert_eq!(
1111 validate_messages(NATIVE, &vec![full; 10]).unwrap_err(),
1112 HISTORY_LIMIT
1113 );
1114 let escaped = text(&"\n".repeat(MAX_TEXT_DOCUMENT_BYTES));
1115 assert!(serialized_size(&escaped, MAX_TEXT_DOCUMENT_BYTES).is_none());
1116 let size = serialized_size(&escaped, usize::MAX).unwrap();
1117 assert_eq!(serialized_size(&escaped, size), Some(size));
1118 assert_eq!(serialized_size(&escaped, size - 1), None);
1119 }
1120
1121 #[test]
1122 fn broker_budget_counts_ordinary_history_and_keeps_envelope_reserve() {
1123 let full = text(&"x".repeat(MAX_TEXT_DOCUMENT_BYTES));
1124 assert!(validate_messages(
1125 "local/attachment-validator-budget-sa26",
1126 &[message(vec![full.clone(); 7])]
1127 )
1128 .is_ok());
1129 assert_eq!(
1130 validate_messages(
1131 "local/attachment-validator-budget-sa26",
1132 &[message(vec![full; 8])]
1133 )
1134 .unwrap_err(),
1135 BROKER_LIMIT
1136 );
1137 let history = [message(vec![
1138 text("small"),
1139 json!({"type":"text","text":"x".repeat(MAX_BROKER_PROXY_BYTES)}),
1140 ])];
1141 assert_eq!(
1142 validate_messages("local/attachment-validator-budget-sa26", &history).unwrap_err(),
1143 BROKER_LIMIT
1144 );
1145 assert!(validate_messages(NATIVE, &history).is_ok());
1146 }
1147
1148 #[test]
1149 fn malformed_media_containers_fail_but_opaque_tool_inputs_are_not_walked() {
1150 let assistant = Arc::new(json!({"role":"assistant","content":[image()]}));
1151 assert!(validate_messages(NATIVE, &[assistant]).is_err());
1152 let single = Arc::new(json!({"role":"user","content":image()}));
1153 assert!(validate_messages(NATIVE, &[single]).is_err());
1154 for block in [
1155 json!({"type":"image_url","image_url":{"url":"https://example.invalid/image"}}),
1156 json!({"type":"input_file","file_id":"private-id"}),
1157 json!({"type":"tool_result","content":image()}),
1158 json!({"type":"tool_result","content":[{"type":"tool_result","content":[image()]}]}),
1159 json!([image()]),
1160 ] {
1161 assert!(check(NATIVE, block).is_err());
1162 }
1163 let opaque = Arc::new(json!({"role":"assistant","content":[
1164 {"type":"tool_use","id":"t","name":"example","input":{"type":"image","source":{"data":"not media"}}},
1165 {"type":"thinking","thinking":"ordinary reasoning","signature":"opaque"},
1166 {"type":"provider_extension","value":"opaque"}
1167 ]}));
1168 assert!(validate_messages("plugin:provider:model", &[opaque]).is_ok());
1169 assert!(
1170 validate_tool_blocks(NATIVE, &[json!({"type":"tool_result","content":[image()]})])
1171 .is_err()
1172 );
1173 }
1174}