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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
14#[serde(rename_all = "snake_case")]
15#[non_exhaustive]
16pub enum SupportLevel {
17 Native,
19 Emulated,
21 Unsupported,
23 Unknown,
25}
26
27#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
29pub struct FeatureSupport {
30 pub level: SupportLevel,
32 pub constraints: BTreeMap<String, Value>,
34}
35
36impl FeatureSupport {
37 pub fn new(level: SupportLevel) -> Self {
39 Self {
40 level,
41 constraints: BTreeMap::new(),
42 }
43 }
44}
45
46#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
48pub struct ModelCapabilities {
49 pub streaming: FeatureSupport,
51 pub tools: FeatureSupport,
53 pub parallel_tools: FeatureSupport,
55 pub structured_output: FeatureSupport,
57 pub reasoning: FeatureSupport,
59 pub image_input: FeatureSupport,
61 pub audio_input: FeatureSupport,
63 pub document_input: FeatureSupport,
65 pub max_context_tokens: Option<u64>,
67 pub extensions: BTreeMap<String, FeatureSupport>,
69}
70
71#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
73pub struct CapabilityAudit {
74 pub features: Vec<CapabilityAuditEntry>,
76 pub max_context_tokens: Option<u64>,
78}
79
80impl CapabilityAudit {
81 #[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 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#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
103pub struct CapabilityAuditEntry {
104 pub feature: String,
106 pub support: FeatureSupport,
108 pub diagnostic_code: String,
110 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 #[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 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}