Skip to main content

runifold_model/
capability.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::{
7    ContentPart, FeaturePolicy, ModelError, ModelErrorKind, ModelRequest, ModelWarning,
8    OutputFormat, SupportLevel::Emulated, SupportLevel::Native, SupportLevel::Unknown,
9    SupportLevel::Unsupported, ToolChoice,
10};
11
12/// How a model or adapter supports a feature.
13#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
14#[serde(rename_all = "snake_case")]
15#[non_exhaustive]
16pub enum SupportLevel {
17    /// The provider implements the feature directly.
18    Native,
19    /// The adapter can approximate the feature with visible degradation.
20    Emulated,
21    /// The feature is known to be unsupported.
22    Unsupported,
23    /// Support is not known reliably.
24    Unknown,
25}
26
27/// Support level plus machine-readable constraints.
28#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
29pub struct FeatureSupport {
30    /// Support level.
31    pub level: SupportLevel,
32    /// Provider- or model-specific constraints.
33    pub constraints: BTreeMap<String, Value>,
34}
35
36impl FeatureSupport {
37    /// Creates feature support without constraints.
38    pub fn new(level: SupportLevel) -> Self {
39        Self {
40            level,
41            constraints: BTreeMap::new(),
42        }
43    }
44}
45
46/// Capabilities of a specific model endpoint.
47#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
48pub struct ModelCapabilities {
49    /// Streaming output.
50    pub streaming: FeatureSupport,
51    /// Tool calling.
52    pub tools: FeatureSupport,
53    /// Parallel tool calls.
54    pub parallel_tools: FeatureSupport,
55    /// Native structured output.
56    pub structured_output: FeatureSupport,
57    /// Reasoning or thinking round trips.
58    pub reasoning: FeatureSupport,
59    /// Image input.
60    pub image_input: FeatureSupport,
61    /// Audio input.
62    pub audio_input: FeatureSupport,
63    /// Document input.
64    pub document_input: FeatureSupport,
65    /// Known context-window limit.
66    pub max_context_tokens: Option<u64>,
67    /// Namespaced additional capabilities.
68    pub extensions: BTreeMap<String, FeatureSupport>,
69}
70
71/// Machine-readable inventory of one model endpoint's declared capabilities.
72#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
73pub struct CapabilityAudit {
74    /// Stable feature entries, sorted by feature name.
75    pub features: Vec<CapabilityAuditEntry>,
76    /// Known context-window limit, when declared by the adapter or application.
77    pub max_context_tokens: Option<u64>,
78}
79
80impl CapabilityAudit {
81    /// Returns whether every capability has an explicit native, emulated, or
82    /// unsupported declaration.
83    #[must_use]
84    pub fn is_fully_declared(&self) -> bool {
85        self.features
86            .iter()
87            .all(|entry| entry.support.level != SupportLevel::Unknown)
88    }
89
90    /// Returns entries requiring deployment review before strict use.
91    pub fn review_required(&self) -> impl Iterator<Item = &CapabilityAuditEntry> {
92        self.features.iter().filter(|entry| {
93            matches!(
94                entry.support.level,
95                SupportLevel::Unknown | SupportLevel::Emulated
96            )
97        })
98    }
99}
100
101/// One stable feature declaration and its actionable diagnostic.
102#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
103pub struct CapabilityAuditEntry {
104    /// Canonical feature name or `extension.<namespace>`.
105    pub feature: String,
106    /// Adapter- or application-declared support and constraints.
107    pub support: FeatureSupport,
108    /// Stable diagnostic code for deployment tooling.
109    pub diagnostic_code: String,
110    /// Safe actionable recommendation.
111    pub recommendation: String,
112}
113
114impl Default for ModelCapabilities {
115    fn default() -> Self {
116        let unknown = || FeatureSupport::new(SupportLevel::Unknown);
117        Self {
118            streaming: unknown(),
119            tools: unknown(),
120            parallel_tools: unknown(),
121            structured_output: unknown(),
122            reasoning: unknown(),
123            image_input: unknown(),
124            audio_input: unknown(),
125            document_input: unknown(),
126            max_context_tokens: None,
127            extensions: BTreeMap::new(),
128        }
129    }
130}
131
132impl ModelCapabilities {
133    /// Produces a stable, serializable capability inventory for deployment
134    /// audits and `doctor` tooling.
135    #[must_use]
136    pub fn audit(&self) -> CapabilityAudit {
137        let mut features = vec![
138            audit_entry("streaming", &self.streaming),
139            audit_entry("tools", &self.tools),
140            audit_entry("parallel_tools", &self.parallel_tools),
141            audit_entry("structured_output", &self.structured_output),
142            audit_entry("reasoning", &self.reasoning),
143            audit_entry("image_input", &self.image_input),
144            audit_entry("audio_input", &self.audio_input),
145            audit_entry("document_input", &self.document_input),
146        ];
147        features.extend(
148            self.extensions
149                .iter()
150                .map(|(name, support)| audit_entry(&format!("extension.{name}"), support)),
151        );
152        features.sort_by(|left, right| left.feature.cmp(&right.feature));
153        CapabilityAudit {
154            features,
155            max_context_tokens: self.max_context_tokens,
156        }
157    }
158
159    /// Validates the features required by a request and returns visible
160    /// compatibility warnings.
161    ///
162    /// Unsupported features always fail because generic middleware cannot
163    /// invent a safe degradation. Unknown support is accepted only under
164    /// [`FeaturePolicy::BestEffort`]. Emulation is accepted by
165    /// [`FeaturePolicy::AllowEmulation`] and [`FeaturePolicy::BestEffort`].
166    ///
167    /// # Errors
168    ///
169    /// Returns [`ModelErrorKind::UnsupportedFeature`] when the request's
170    /// feature policy cannot accept a required capability.
171    pub fn validate_request(
172        &self,
173        request: &ModelRequest,
174        streaming: bool,
175    ) -> Result<Vec<ModelWarning>, ModelError> {
176        let requires_tools = !request.tools.is_empty()
177            || !request.provider_tools().is_empty()
178            || matches!(
179                request.tool_choice,
180                ToolChoice::Required | ToolChoice::Named { .. }
181            );
182        let requires_structured_output = !matches!(request.output_format, OutputFormat::Text);
183        let has_capability_sensitive_content = request
184            .messages
185            .iter()
186            .flat_map(|message| &message.content)
187            .any(|part| {
188                matches!(
189                    part,
190                    ContentPart::Image { .. }
191                        | ContentPart::Audio { .. }
192                        | ContentPart::Document { .. }
193                        | ContentPart::Reasoning(_)
194                )
195            });
196        if !requires_tools && !requires_structured_output && !has_capability_sensitive_content {
197            let mut warnings = Vec::new();
198            if streaming {
199                assess_feature(
200                    "streaming",
201                    &self.streaming,
202                    request.feature_policy,
203                    &mut warnings,
204                )?;
205            }
206            return Ok(warnings);
207        }
208
209        let mut required = Vec::new();
210        if streaming {
211            required.push(("streaming", &self.streaming));
212        }
213        if requires_tools {
214            required.push(("tools", &self.tools));
215        }
216        if requires_structured_output {
217            required.push(("structured_output", &self.structured_output));
218        }
219        for message in &request.messages {
220            for part in &message.content {
221                match part {
222                    ContentPart::Image { .. } => required.push(("image_input", &self.image_input)),
223                    ContentPart::Audio { .. } => required.push(("audio_input", &self.audio_input)),
224                    ContentPart::Document { .. } => {
225                        required.push(("document_input", &self.document_input));
226                    }
227                    ContentPart::Reasoning(_) => required.push(("reasoning", &self.reasoning)),
228                    _ => {}
229                }
230            }
231        }
232
233        required.sort_by_key(|(name, _)| *name);
234        required.dedup_by_key(|(name, _)| *name);
235        let mut warnings = Vec::new();
236        for (name, support) in required {
237            assess_feature(name, support, request.feature_policy, &mut warnings)?;
238        }
239        Ok(warnings)
240    }
241}
242
243fn audit_entry(feature: &str, support: &FeatureSupport) -> CapabilityAuditEntry {
244    let (diagnostic_code, recommendation) = match support.level {
245        SupportLevel::Native => (
246            "runifold.capability.native",
247            "No compatibility action is required.",
248        ),
249        SupportLevel::Emulated => (
250            "runifold.capability.emulated",
251            "Review the declared constraints and opt into emulation explicitly.",
252        ),
253        SupportLevel::Unsupported => (
254            "runifold.capability.unsupported",
255            "Do not request this feature for the selected model endpoint.",
256        ),
257        SupportLevel::Unknown => (
258            "runifold.capability.unknown",
259            "Declare verified model-specific support before using strict policy.",
260        ),
261    };
262    CapabilityAuditEntry {
263        feature: feature.into(),
264        support: support.clone(),
265        diagnostic_code: diagnostic_code.into(),
266        recommendation: recommendation.into(),
267    }
268}
269
270fn assess_feature(
271    name: &str,
272    support: &FeatureSupport,
273    policy: FeaturePolicy,
274    warnings: &mut Vec<ModelWarning>,
275) -> Result<(), ModelError> {
276    match (support.level, policy) {
277        (Native, _) => Ok(()),
278        (Emulated, FeaturePolicy::AllowEmulation | FeaturePolicy::BestEffort) => {
279            warnings.push(compatibility_warning(
280                "runifold.feature_emulated",
281                name,
282                support,
283            ));
284            Ok(())
285        }
286        (Unknown, FeaturePolicy::BestEffort) => {
287            warnings.push(compatibility_warning(
288                "runifold.feature_support_unknown",
289                name,
290                support,
291            ));
292            Ok(())
293        }
294        (Unsupported, _) => Err(unsupported_error(name, "unsupported", support)),
295        (Emulated, FeaturePolicy::Strict) => Err(unsupported_error(name, "emulated", support)),
296        (Unknown, FeaturePolicy::Strict | FeaturePolicy::AllowEmulation) => {
297            Err(unsupported_error(name, "unknown", support))
298        }
299    }
300}
301
302fn compatibility_warning(code: &str, name: &str, support: &FeatureSupport) -> ModelWarning {
303    let mut metadata = support.constraints.clone();
304    metadata.insert("feature".into(), Value::String(name.into()));
305    ModelWarning {
306        code: code.into(),
307        message: format!("feature `{name}` is not natively supported"),
308        metadata,
309    }
310}
311
312fn unsupported_error(name: &str, level: &str, support: &FeatureSupport) -> ModelError {
313    let mut error = ModelError::local(
314        ModelErrorKind::UnsupportedFeature,
315        format!("required feature `{name}` has support level `{level}`"),
316    );
317    error
318        .metadata
319        .insert("feature".into(), Value::String(name.into()));
320    error.metadata.insert(
321        "constraints".into(),
322        Value::Object(support.constraints.clone().into_iter().collect()),
323    );
324    error
325}
326
327#[cfg(test)]
328mod tests {
329    use crate::{
330        FeaturePolicy, FeatureSupport, Message, ModelCapabilities, ModelErrorKind, ModelRef,
331        ModelRequest, OutputFormat, SupportLevel,
332    };
333
334    #[test]
335    fn strict_policy_rejects_unknown_required_support() {
336        let request = ModelRequest::new(ModelRef::new("test", "model"), Message::user("hello"));
337
338        let error = ModelCapabilities::default()
339            .validate_request(&request, true)
340            .unwrap_err();
341
342        assert_eq!(error.kind, ModelErrorKind::UnsupportedFeature);
343    }
344
345    #[test]
346    fn best_effort_makes_unknown_support_visible() {
347        let request = ModelRequest::new(ModelRef::new("test", "model"), Message::user("hello"))
348            .feature_policy(FeaturePolicy::BestEffort);
349
350        let warnings = ModelCapabilities::default()
351            .validate_request(&request, true)
352            .unwrap();
353
354        assert_eq!(warnings.len(), 1);
355        assert_eq!(warnings[0].code, "runifold.feature_support_unknown");
356    }
357
358    #[test]
359    fn unsupported_features_fail_even_in_best_effort_mode() {
360        let capabilities = ModelCapabilities {
361            structured_output: FeatureSupport::new(SupportLevel::Unsupported),
362            streaming: FeatureSupport::new(SupportLevel::Native),
363            ..ModelCapabilities::default()
364        };
365        let request = ModelRequest::new(ModelRef::new("test", "model"), Message::user("hello"))
366            .output_format(OutputFormat::Json)
367            .feature_policy(FeaturePolicy::BestEffort);
368
369        let error = capabilities.validate_request(&request, true).unwrap_err();
370
371        assert_eq!(error.kind, ModelErrorKind::UnsupportedFeature);
372        assert_eq!(error.metadata["feature"], "structured_output");
373    }
374
375    #[test]
376    fn capability_audit_is_stable_sorted_and_actionable() {
377        let capabilities = ModelCapabilities {
378            tools: FeatureSupport::new(SupportLevel::Native),
379            ..ModelCapabilities::default()
380        };
381
382        let audit = capabilities.audit();
383
384        assert!(!audit.is_fully_declared());
385        assert_eq!(audit.features[0].feature, "audio_input");
386        assert_eq!(
387            audit
388                .features
389                .iter()
390                .find(|entry| entry.feature == "tools")
391                .unwrap()
392                .diagnostic_code,
393            "runifold.capability.native"
394        );
395        assert!(audit.review_required().count() > 0);
396    }
397}