Skip to main content

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}