modelplease/capabilities.rs
1// Copyright 2026 Thomas Santerre and Moderately AI Inc.
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Per-model media capability declarations + the validation error type
6//! returned when a request asks for something a model doesn't support.
7//!
8//! Capability checking has two layers:
9//!
10//! 1. **Runtime**: every provider impl publishes a static `MODEL_CAPABILITIES` table that maps its
11//! catalog ids to [`ModelCapabilities`] entries.
12//! [`LanguageModelProvider::capabilities`](crate::LanguageModelProvider::capabilities) returns
13//! the entry for a given model; the default impl of
14//! [`LanguageModelProvider::validate_request`](crate::LanguageModelProvider::validate_request)
15//! walks every non-text content part and consults the table, returning a typed
16//! [`CapabilityError`] before any wire call.
17//!
18//! 2. **Compile-time** (concrete callers only): the `Accepts*` marker traits below let
19//! known-concrete provider call sites (predict optimizers wired to a specific provider,
20//! examples, tests) refuse to compile if they hand the wrong source kind to the wrong provider.
21//! The markers vanish under `Arc<dyn LanguageModelProvider>` — that's by design; the dyn path
22//! relies on Layer 1's runtime check.
23
24use std::{collections::BTreeMap, ops::RangeInclusive};
25
26use enumset::{EnumSet, EnumSetType};
27use serde::{Deserialize, Serialize};
28use thiserror::Error;
29
30use crate::{
31 config::{ReasoningConfig, ReasoningEffort},
32 media::{MediaSource, MediaType, SourceKind},
33};
34
35/// Bucket for media content parts.
36///
37/// Discriminates the four modalities we expose at the
38/// [`ContentPart`](crate::ContentPart) level. Iteration order is
39/// variant declaration order via `Ord`, which matches the workspace's
40/// "ordered iteration when observable" rule for any logging or error
41/// surfaces that walk a `BTreeMap<MediaKind, ...>`.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum MediaKind {
45 Image,
46 Document,
47 Audio,
48 Video,
49}
50
51impl MediaKind {
52 /// Bucket an RFC-6838 top-level media type into a [`MediaKind`].
53 ///
54 /// `image/*` → `Image`, `audio/*` → `Audio`, `video/*` → `Video`,
55 /// `application/*` and `text/*` → `Document`. Returns `None` for
56 /// anything else (e.g. `multipart/*`, `message/*`) — those need
57 /// explicit handling we haven't designed yet.
58 #[must_use]
59 pub fn from_media_type(media_type: &MediaType) -> Option<Self> {
60 match media_type.top_level() {
61 "image" => Some(Self::Image),
62 "audio" => Some(Self::Audio),
63 "video" => Some(Self::Video),
64 "application" | "text" => Some(Self::Document),
65 _ => None,
66 }
67 }
68
69 /// Human-readable label used in error messages and prompt strings.
70 #[must_use]
71 pub const fn label(self) -> &'static str {
72 match self {
73 Self::Image => "image",
74 Self::Document => "document",
75 Self::Audio => "audio",
76 Self::Video => "video",
77 }
78 }
79}
80
81impl std::fmt::Display for MediaKind {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 f.write_str(self.label())
84 }
85}
86
87/// What a model accepts for one [`MediaKind`].
88///
89/// Stored on [`ModelCapabilities::media_support`] keyed by `MediaKind`.
90/// A missing entry (vs. an empty `MediaSupport`) means "not supported";
91/// an entry with empty `sources` is a bug in the table.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct MediaSupport {
94 /// Source kinds the provider can carry to this model for this
95 /// modality (e.g. Bedrock Image = `{InlineBytes, S3}`, no `Url`).
96 pub sources: EnumSet<SourceKind>,
97 /// Accepted RFC-6838 subtypes (e.g. `["png", "jpeg", "gif", "webp"]`).
98 /// Matched against [`MediaType::subtype`](crate::MediaType::subtype).
99 /// Empty means "the provider didn't publish a constraint; accept
100 /// any subtype" — use sparingly.
101 pub formats: &'static [&'static str],
102 /// Max byte size accepted for [`MediaSource::InlineBytes`]. `None`
103 /// = no local check (e.g. for `Url`/`ProviderFile`/`S3` sources the
104 /// size is opaque locally).
105 pub max_bytes: Option<u64>,
106 /// Max number of parts of this kind per message. `None` = no
107 /// declared limit; counts are still cheap to enforce locally.
108 pub max_count_per_message: Option<u8>,
109}
110
111impl MediaSupport {
112 /// Validate one media source against this support entry.
113 ///
114 /// `URL`, `ProviderFile`, and `S3` sources skip format/size checks
115 /// — the bytes aren't reachable locally and the provider performs
116 /// the equivalent check server-side. `InlineBytes` validates
117 /// `mime.subtype()` against [`Self::formats`] and `data.len()` against
118 /// [`Self::max_bytes`].
119 ///
120 /// # Errors
121 /// Returns the first matching [`CapabilityError`] variant.
122 pub fn validate(
123 &self,
124 model: &str,
125 kind: MediaKind,
126 source: &MediaSource,
127 ) -> Result<(), CapabilityError> {
128 let source_kind = source.kind();
129 if !self.sources.contains(source_kind) {
130 return Err(CapabilityError::SourceKindUnsupported {
131 model: model.to_owned(),
132 kind,
133 attempted: source_kind,
134 accepted: self.sources,
135 });
136 }
137 if let MediaSource::InlineBytes { mime, data } = source {
138 if !self.formats.is_empty() {
139 let subtype = mime.subtype();
140 if !self.formats.contains(&subtype) {
141 return Err(CapabilityError::FormatUnsupported {
142 model: model.to_owned(),
143 kind,
144 format: subtype.to_owned(),
145 accepted: self.formats.to_vec(),
146 });
147 }
148 }
149 if let Some(max) = self.max_bytes {
150 let bytes = u64::try_from(data.len()).unwrap_or(u64::MAX);
151 if bytes > max {
152 return Err(CapabilityError::SizeExceeded {
153 model: model.to_owned(),
154 kind,
155 bytes,
156 max,
157 });
158 }
159 }
160 }
161 Ok(())
162 }
163}
164
165/// What a single model can carry across every [`MediaKind`].
166///
167/// Returned by
168/// [`LanguageModelProvider::capabilities`](crate::LanguageModelProvider::capabilities).
169/// `media_support` keys are deterministic via `BTreeMap` so iteration
170/// (used in error messages and logging) doesn't change between runs.
171///
172/// `Eq` is intentionally not derived — `ReasoningCapability` carries an
173/// `Option<RangeInclusive<f64>>` for `top_p` clamping and `f64` is not
174/// `Eq`. Callers needing equality use `PartialEq`.
175#[derive(Debug, Clone, PartialEq)]
176pub struct ModelCapabilities {
177 /// Provider-specific model id (matches
178 /// [`ChatModelInfo.id`](crate::ChatModelInfo)).
179 pub model_id: String,
180 /// One [`MediaSupport`] per [`MediaKind`] the model accepts. Missing
181 /// keys → modality unsupported. Empty map → text-only model.
182 pub media_support: BTreeMap<MediaKind, MediaSupport>,
183 /// Reasoning / extended-thinking capability. `None` = model has no
184 /// reasoning surface at all; any non-Off `ReasoningConfig` will fail
185 /// validation upstream.
186 pub reasoning: Option<ReasoningCapability>,
187 /// Whether the model accepts AWS Bedrock latency-optimized
188 /// ("accelerated") inference. `false` for every non-Bedrock provider
189 /// and for Bedrock models AWS doesn't list as latency-eligible. The
190 /// caller gates [`LatencyMode::Optimized`](crate::config::LatencyMode::Optimized)
191 /// on this flag and fails loud before the wire.
192 pub latency_optimized_supported: bool,
193 /// Whether the model supports extended (1-hour) prompt-cache TTL. Basic
194 /// 5-minute caching is broader; the 1-hour tier is Anthropic-only (and,
195 /// on Bedrock, only the Claude 4.5+ family). The caller gates an
196 /// explicit [`CacheTtl::OneHour`](crate::config::CacheTtl::OneHour) on
197 /// this flag and fails loud, so a model that can't accept it never
198 /// receives a 1-hour `cachePoint`.
199 pub extended_cache_ttl_supported: bool,
200}
201
202impl ModelCapabilities {
203 /// Validate one (kind, source) pair against this model.
204 ///
205 /// # Errors
206 /// - [`CapabilityError::ModalityUnsupported`] when no entry exists for `kind`.
207 /// - Anything [`MediaSupport::validate`] can return.
208 pub fn validate(&self, kind: MediaKind, source: &MediaSource) -> Result<(), CapabilityError> {
209 let support =
210 self.media_support
211 .get(&kind)
212 .ok_or_else(|| CapabilityError::ModalityUnsupported {
213 model: self.model_id.clone(),
214 kind,
215 })?;
216 support.validate(&self.model_id, kind, source)
217 }
218}
219
220/// Provider wire-mode classes a model accepts.
221///
222/// `Adaptive` covers OpenAI's `reasoning_effort` string, Ollama's
223/// OpenAI-compat `reasoning_effort`, and Anthropic adaptive-thinking
224/// (`thinking: {type: "adaptive", effort: ...}`). `Manual` is
225/// Anthropic-family only — `thinking: {type: "enabled", budget_tokens:
226/// N}` natively and the same shape on Bedrock via
227/// `additionalModelRequestFields`.
228/// `EnumSetType` auto-derives `Copy + Clone + PartialEq + Eq`; `Debug`
229/// and `Hash` are derived separately.
230#[derive(EnumSetType, Debug, Hash)]
231pub enum ReasoningMode {
232 Adaptive,
233 Manual,
234}
235
236impl ReasoningMode {
237 /// Human-readable label used in `ReasoningValidationError` messages.
238 #[must_use]
239 pub const fn label(self) -> &'static str {
240 match self {
241 Self::Adaptive => "adaptive",
242 Self::Manual => "manual",
243 }
244 }
245}
246
247impl std::fmt::Display for ReasoningMode {
248 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249 f.write_str(self.label())
250 }
251}
252
253/// Sampling-parameter restrictions that apply when reasoning is on.
254///
255/// Anthropic-family upstream rejects `temperature` and `top_k` outright
256/// when `thinking` is enabled and clamps `top_p` to `[0.95, 1]`. OpenAI
257/// and Ollama have no such restrictions. Carried per-model so the
258/// caller can produce precise errors that name the offending field
259/// + the constraint upstream actually enforces.
260#[derive(Debug, Clone, PartialEq, Default)]
261pub struct ReasoningParamConflicts {
262 /// `true` ⇒ the model rejects a non-`None` `temperature` whenever
263 /// reasoning is on.
264 pub temperature_forbidden: bool,
265 /// `true` ⇒ the model rejects a non-`None` `top_k`.
266 pub top_k_forbidden: bool,
267 /// `Some(range)` ⇒ `top_p` is only honoured inside the inclusive
268 /// range; values outside fail validation. `None` ⇒ unrestricted.
269 pub top_p_allowed_range: Option<RangeInclusive<f64>>,
270}
271
272/// What reasoning a single model supports.
273///
274/// Populated alongside [`MediaSupport`] in each provider's static
275/// `MODEL_CAPABILITIES` table and exposed on [`ModelCapabilities`].
276/// Callers reads this synchronously before any wire call and
277/// rejects user configurations whose intent doesn't fit.
278#[derive(Debug, Clone, PartialEq)]
279pub struct ReasoningCapability {
280 /// Modes this model accepts. Non-empty by construction — a model
281 /// with no reasoning capability omits the whole `ReasoningCapability`
282 /// from `ModelCapabilities.reasoning`.
283 pub supported_modes: EnumSet<ReasoningMode>,
284 /// Effort levels accepted in `Adaptive` (and OpenAI-shape) mode.
285 /// Also non-empty by construction when `Adaptive ∈ supported_modes`.
286 pub supported_efforts: EnumSet<ReasoningEffort>,
287 /// `budget_tokens` range when `Manual ∈ supported_modes`. `None`
288 /// when manual mode isn't supported.
289 pub manual_budget_range: Option<RangeInclusive<u32>>,
290 /// Sampling-parameter restrictions when reasoning is on.
291 pub conflicts: ReasoningParamConflicts,
292 /// `true` ⇒ the model removed `temperature` / `top_p` / `top_k` entirely
293 /// (the 4.7+ Anthropic generation — Opus 4.7/4.8, Sonnet 5) and rejects
294 /// them with a 400 in *every* request, regardless of reasoning state.
295 /// The caller suppresses its default-temperature injection and fails
296 /// loud on any user-explicit sampling value for such models. `false` ⇒
297 /// the `conflicts` above apply only when reasoning is active (the
298 /// 4.6-and-earlier rule, where sampling is accepted with thinking off).
299 pub sampling_params_removed: bool,
300}
301
302impl ReasoningCapability {
303 /// Check a resolved [`ReasoningConfig`] against this capability.
304 ///
305 /// Returns the first violation, if any. `ReasoningConfig::Off` always
306 /// passes — disabling reasoning is universally allowed at the
307 /// reasoning-capability layer (some models like Mythos Preview reject
308 /// `thinking: {type: "disabled"}` at the wire, but that's a per-model
309 /// concern the provider impl surfaces, not a general rule here).
310 ///
311 /// # Errors
312 /// Returns the precise [`ReasoningValidationError`] variant identifying
313 /// which constraint failed.
314 pub fn validate(
315 &self,
316 model_id: &str,
317 config: &ReasoningConfig,
318 ) -> Result<(), ReasoningValidationError> {
319 match config {
320 ReasoningConfig::Off => Ok(()),
321 ReasoningConfig::Adaptive { effort } => {
322 if !self.supported_modes.contains(ReasoningMode::Adaptive) {
323 return Err(ReasoningValidationError::ModeUnsupported {
324 model: model_id.to_owned(),
325 requested: ReasoningMode::Adaptive,
326 supported: self.supported_modes,
327 });
328 }
329 if !self.supported_efforts.contains(*effort) {
330 return Err(ReasoningValidationError::EffortUnsupported {
331 model: model_id.to_owned(),
332 requested: *effort,
333 supported: self.supported_efforts,
334 });
335 }
336 Ok(())
337 }
338 ReasoningConfig::Manual { budget_tokens } => {
339 if !self.supported_modes.contains(ReasoningMode::Manual) {
340 return Err(ReasoningValidationError::ModeUnsupported {
341 model: model_id.to_owned(),
342 requested: ReasoningMode::Manual,
343 supported: self.supported_modes,
344 });
345 }
346 let range = self.manual_budget_range.as_ref().ok_or_else(|| {
347 ReasoningValidationError::ModeUnsupported {
348 model: model_id.to_owned(),
349 requested: ReasoningMode::Manual,
350 supported: self.supported_modes,
351 }
352 })?;
353 if !range.contains(budget_tokens) {
354 return Err(ReasoningValidationError::BudgetOutOfRange {
355 model: model_id.to_owned(),
356 requested: *budget_tokens,
357 min: *range.start(),
358 max: *range.end(),
359 });
360 }
361 Ok(())
362 }
363 }
364 }
365}
366
367/// Reasons a [`ReasoningConfig`] can fail [`ReasoningCapability::validate`].
368///
369/// Each variant names the offending field and what the model actually
370/// accepts so the caller can build a clean user-facing error without
371/// pulling the model back out of the lookup.
372#[derive(Debug, Clone, PartialEq, Eq, Error)]
373pub enum ReasoningValidationError {
374 /// Model has no reasoning capability at all (`ModelCapabilities.reasoning ==
375 /// None`) but the caller supplied a non-`Off` config.
376 #[error("model `{model}` does not support reasoning")]
377 Unsupported { model: String },
378
379 /// The requested mode (Adaptive / Manual) is outside the model's
380 /// `supported_modes` set.
381 #[error(
382 "model `{model}` does not support {requested} reasoning mode (supported: {supported:?})"
383 )]
384 ModeUnsupported {
385 model: String,
386 requested: ReasoningMode,
387 supported: EnumSet<ReasoningMode>,
388 },
389
390 /// The requested effort level is outside the model's `supported_efforts`
391 /// set for the chosen mode.
392 #[error(
393 "model `{model}` does not support reasoning effort `{requested}` (supported: {supported:?})"
394 )]
395 EffortUnsupported {
396 model: String,
397 requested: ReasoningEffort,
398 supported: EnumSet<ReasoningEffort>,
399 },
400
401 /// `Manual { budget_tokens }` is outside the model's
402 /// `manual_budget_range`.
403 #[error(
404 "model `{model}` rejects manual budget_tokens={requested} \
405 (supported range: {min}..={max})"
406 )]
407 BudgetOutOfRange {
408 model: String,
409 requested: u32,
410 min: u32,
411 max: u32,
412 },
413}
414
415/// Reasons a request can fail capability validation, by precision.
416///
417/// Returned from
418/// [`LanguageModelProvider::validate_request`](crate::LanguageModelProvider::validate_request)
419/// and surfaced both at the application boundary (predict / direct
420/// generate) and at the application configuration preflight (where
421/// the same validation runs against the *declared* schema rather than
422/// the actual values).
423#[derive(Debug, Clone, PartialEq, Eq, Error)]
424pub enum CapabilityError {
425 /// The model has no [`MediaSupport`] entry for this modality at all.
426 #[error("model `{model}` does not accept {kind} content")]
427 ModalityUnsupported { model: String, kind: MediaKind },
428
429 /// The model accepts this modality but not via this source kind.
430 /// `accepted` enumerates what would work. The field is named
431 /// `attempted` (rather than `source`) so thiserror doesn't try to
432 /// treat it as an `Error::source()` provider — it's a discriminant,
433 /// not a chained error.
434 #[error(
435 "model `{model}` accepts {kind} content but not via {attempted:?} (accepted: {accepted:?})"
436 )]
437 SourceKindUnsupported {
438 model: String,
439 kind: MediaKind,
440 attempted: SourceKind,
441 accepted: EnumSet<SourceKind>,
442 },
443
444 /// The model accepts this modality + source but not this MIME
445 /// subtype (e.g. JPEG image submitted to a model that only takes
446 /// PNG).
447 #[error("model `{model}` rejects {kind} format `{format}` (accepted: {accepted:?})")]
448 FormatUnsupported {
449 model: String,
450 kind: MediaKind,
451 format: String,
452 accepted: Vec<&'static str>,
453 },
454
455 /// `InlineBytes` payload exceeds the model's declared cap.
456 #[error("{kind} payload {bytes} exceeds model `{model}` cap {max}")]
457 SizeExceeded {
458 model: String,
459 kind: MediaKind,
460 bytes: u64,
461 max: u64,
462 },
463
464 /// The capability table has no entry for `model` at all — the model
465 /// is either too new (unmodeled) or misspelled. Surfaced when a
466 /// non-text content part is sent against an unknown model. Pure
467 /// text calls do not produce this; they bypass capability lookup.
468 #[error("model `{model}` has no capability metadata; cannot validate {kind} content")]
469 UnknownModel { model: String, kind: MediaKind },
470}
471
472// =============================================================================
473// Marker traits — compile-time capability gating for concrete-typed callers.
474//
475// Each marker trait expresses one (modality, source kind) pair that a
476// concrete provider impl publishes. Concrete-typed call sites can bound
477// their generic on the markers they need; the bound disappears under
478// `Arc<dyn LanguageModelProvider>`, so the dyn path falls back to Layer
479// 1's runtime capability check. There is no `dyn`-bridge by design.
480//
481// Provider impls declare these in their own module. The presence/absence
482// here describes the trait shape only.
483// =============================================================================
484
485/// Implementor accepts at least one model that takes
486/// `MediaSource::Url` for [`MediaKind::Image`]. (OpenAI, Anthropic.)
487pub trait AcceptsImageUrl {}
488
489/// Implementor accepts at least one model that takes
490/// `MediaSource::InlineBytes` for [`MediaKind::Image`]. (All providers
491/// except text-only ones.)
492pub trait AcceptsImageBytes {}
493
494/// Implementor accepts at least one model that takes
495/// `MediaSource::S3` for [`MediaKind::Image`]. (Bedrock only.)
496pub trait AcceptsImageS3 {}
497
498/// Implementor accepts at least one model that takes
499/// `MediaSource::InlineBytes` for [`MediaKind::Audio`]. (`OpenAI`
500/// gpt-audio family, Bedrock Voxtral.)
501pub trait AcceptsAudioBytes {}
502
503/// Implementor accepts at least one model that takes
504/// `MediaSource::InlineBytes` for [`MediaKind::Document`]. (Anthropic,
505/// `OpenAI` file API, Bedrock.)
506pub trait AcceptsDocumentBytes {}
507
508/// Implementor accepts at least one model that takes
509/// `MediaSource::InlineBytes` for [`MediaKind::Video`]. (Bedrock Nova
510/// Pro/Lite only.)
511pub trait AcceptsVideoBytes {}
512
513/// Implementor accepts at least one model that takes
514/// `MediaSource::S3` for [`MediaKind::Video`]. (Bedrock only.)
515pub trait AcceptsVideoS3 {}
516
517#[cfg(test)]
518mod tests {
519 use enumset::enum_set;
520
521 use super::*;
522 use crate::media::{HttpsUrl, MediaType};
523
524 fn anthropic_image_support() -> MediaSupport {
525 MediaSupport {
526 sources: enum_set!(
527 SourceKind::Url | SourceKind::InlineBytes | SourceKind::ProviderFile
528 ),
529 formats: &["png", "jpeg", "gif", "webp"],
530 max_bytes: Some(5 * 1024 * 1024),
531 max_count_per_message: None,
532 }
533 }
534
535 fn bedrock_image_support() -> MediaSupport {
536 MediaSupport {
537 sources: enum_set!(SourceKind::InlineBytes | SourceKind::S3),
538 formats: &["png", "jpeg", "gif", "webp"],
539 max_bytes: Some(3_932_160), // 3.75 MB
540 max_count_per_message: None,
541 }
542 }
543
544 #[test]
545 fn media_kind_from_media_type_buckets_correctly() {
546 let png = MediaType::parse("image/png").unwrap();
547 assert_eq!(MediaKind::from_media_type(&png), Some(MediaKind::Image));
548 let mp3 = MediaType::parse("audio/mpeg").unwrap();
549 assert_eq!(MediaKind::from_media_type(&mp3), Some(MediaKind::Audio));
550 let mp4 = MediaType::parse("video/mp4").unwrap();
551 assert_eq!(MediaKind::from_media_type(&mp4), Some(MediaKind::Video));
552 let pdf = MediaType::parse("application/pdf").unwrap();
553 assert_eq!(MediaKind::from_media_type(&pdf), Some(MediaKind::Document));
554 let txt = MediaType::parse("text/plain").unwrap();
555 assert_eq!(MediaKind::from_media_type(&txt), Some(MediaKind::Document));
556 let multipart = MediaType::parse("multipart/form-data").unwrap();
557 assert_eq!(MediaKind::from_media_type(&multipart), None);
558 }
559
560 #[test]
561 fn support_accepts_url_when_listed() {
562 let support = anthropic_image_support();
563 let src = MediaSource::Url {
564 url: HttpsUrl::parse("https://x/y.png").unwrap(),
565 };
566 assert!(support.validate("claude", MediaKind::Image, &src).is_ok());
567 }
568
569 #[test]
570 fn support_rejects_url_when_not_listed() {
571 let support = bedrock_image_support();
572 let src = MediaSource::Url {
573 url: HttpsUrl::parse("https://x/y.png").unwrap(),
574 };
575 let err = support
576 .validate("bedrock-claude", MediaKind::Image, &src)
577 .unwrap_err();
578 assert!(matches!(
579 err,
580 CapabilityError::SourceKindUnsupported {
581 attempted: SourceKind::Url,
582 ..
583 }
584 ));
585 }
586
587 #[test]
588 fn support_rejects_inline_bytes_with_wrong_subtype() {
589 let support = anthropic_image_support();
590 let src = MediaSource::InlineBytes {
591 mime: MediaType::parse("image/bmp").unwrap(),
592 data: vec![0, 1, 2, 3],
593 };
594 let err = support
595 .validate("claude", MediaKind::Image, &src)
596 .unwrap_err();
597 match err {
598 CapabilityError::FormatUnsupported {
599 format, accepted, ..
600 } => {
601 assert_eq!(format, "bmp");
602 assert_eq!(accepted, vec!["png", "jpeg", "gif", "webp"]);
603 }
604 other => panic!("expected FormatUnsupported, got {other:?}"),
605 }
606 }
607
608 #[test]
609 fn support_rejects_oversize_inline_bytes() {
610 let support = MediaSupport {
611 sources: enum_set!(SourceKind::InlineBytes),
612 formats: &["png"],
613 max_bytes: Some(8),
614 max_count_per_message: None,
615 };
616 let src = MediaSource::InlineBytes {
617 mime: MediaType::parse("image/png").unwrap(),
618 data: vec![0; 16],
619 };
620 let err = support.validate("m", MediaKind::Image, &src).unwrap_err();
621 match err {
622 CapabilityError::SizeExceeded { bytes, max, .. } => {
623 assert_eq!(bytes, 16);
624 assert_eq!(max, 8);
625 }
626 other => panic!("expected SizeExceeded, got {other:?}"),
627 }
628 }
629
630 #[test]
631 fn support_skips_format_check_for_non_inline_sources() {
632 let support = bedrock_image_support();
633 // S3 source — format/size unknown locally; validate succeeds.
634 let src = MediaSource::S3 {
635 uri: crate::media::S3Uri::parse("s3://bucket/key").unwrap(),
636 bucket_owner: None,
637 };
638 assert!(
639 support
640 .validate("bedrock-claude", MediaKind::Image, &src)
641 .is_ok()
642 );
643 }
644
645 #[test]
646 fn capabilities_rejects_unknown_modality() {
647 let caps = ModelCapabilities {
648 model_id: "claude".to_owned(),
649 media_support: BTreeMap::from([(MediaKind::Image, anthropic_image_support())]),
650 reasoning: None,
651 latency_optimized_supported: false,
652 extended_cache_ttl_supported: false,
653 };
654 let src = MediaSource::InlineBytes {
655 mime: MediaType::parse("audio/mpeg").unwrap(),
656 data: vec![0],
657 };
658 let err = caps.validate(MediaKind::Audio, &src).unwrap_err();
659 assert!(matches!(
660 err,
661 CapabilityError::ModalityUnsupported {
662 kind: MediaKind::Audio,
663 ..
664 }
665 ));
666 }
667
668 #[test]
669 fn capabilities_routes_through_to_support_validate() {
670 let caps = ModelCapabilities {
671 model_id: "claude".to_owned(),
672 media_support: BTreeMap::from([(MediaKind::Image, anthropic_image_support())]),
673 reasoning: None,
674 latency_optimized_supported: false,
675 extended_cache_ttl_supported: false,
676 };
677 let src = MediaSource::Url {
678 url: HttpsUrl::parse("https://x/y.png").unwrap(),
679 };
680 assert!(caps.validate(MediaKind::Image, &src).is_ok());
681 }
682}