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