use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CapabilityLevel {
#[default]
None,
Likely,
Supported,
}
impl CapabilityLevel {
const fn is_supported(self) -> bool {
matches!(self, Self::Supported)
}
const fn status(self) -> &'static str {
match self {
Self::Supported => "supported",
Self::Likely => "likely",
Self::None => "none",
}
}
const fn label(self) -> Option<&'static str> {
match self {
Self::Supported => Some("yes"),
Self::Likely => Some("likely"),
Self::None => None,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ModelCapabilities {
pub multimodal: bool,
pub vision: CapabilityLevel,
pub audio: CapabilityLevel,
pub reasoning: CapabilityLevel,
pub tool_use: CapabilityLevel,
pub moe: bool,
}
impl Default for ModelCapabilities {
fn default() -> Self {
Self {
multimodal: false,
vision: CapabilityLevel::None,
audio: CapabilityLevel::None,
reasoning: CapabilityLevel::None,
tool_use: CapabilityLevel::None,
moe: false,
}
}
}
impl ModelCapabilities {
pub fn supports_multimodal_runtime(self) -> bool {
self.multimodal || self.supports_vision_runtime() || self.supports_audio_runtime()
}
pub fn supports_vision_runtime(self) -> bool {
self.vision.is_supported()
}
pub fn supports_audio_runtime(self) -> bool {
self.audio.is_supported()
}
pub fn multimodal_status(self) -> &'static str {
if self.supports_multimodal_runtime() {
"supported"
} else {
"none"
}
}
pub fn multimodal_label(self) -> Option<&'static str> {
if self.supports_multimodal_runtime() {
Some("yes")
} else {
None
}
}
pub fn vision_status(self) -> &'static str {
self.vision.status()
}
pub fn vision_label(self) -> Option<&'static str> {
self.vision.label()
}
pub fn audio_status(self) -> &'static str {
self.audio.status()
}
pub fn audio_label(self) -> Option<&'static str> {
self.audio.label()
}
pub fn reasoning_status(self) -> &'static str {
self.reasoning.status()
}
pub fn reasoning_label(self) -> Option<&'static str> {
self.reasoning.label()
}
pub fn tool_use_status(self) -> &'static str {
self.tool_use.status()
}
pub fn tool_use_label(self) -> Option<&'static str> {
self.tool_use.label()
}
pub fn upgrade_vision(&mut self, level: CapabilityLevel) {
self.vision = self.vision.max(level);
if self.vision != CapabilityLevel::None {
self.multimodal = true;
}
}
pub fn upgrade_audio(&mut self, level: CapabilityLevel) {
self.audio = self.audio.max(level);
if self.audio != CapabilityLevel::None {
self.multimodal = true;
}
}
pub fn upgrade_reasoning(&mut self, level: CapabilityLevel) {
self.reasoning = self.reasoning.max(level);
}
pub fn upgrade_tool_use(&mut self, level: CapabilityLevel) {
self.tool_use = self.tool_use.max(level);
}
pub fn normalize(mut self) -> Self {
if self.vision != CapabilityLevel::None || self.audio != CapabilityLevel::None {
self.multimodal = true;
}
self
}
}
pub fn merge_name_signals(mut caps: ModelCapabilities, values: &[&str]) -> ModelCapabilities {
for value in values {
merge_name_signal(&mut caps, value, true);
}
caps.normalize()
}
pub fn merge_sibling_signals<I, S>(mut caps: ModelCapabilities, siblings: I) -> ModelCapabilities
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut saw_processor = false;
let mut saw_reasoning_template = false;
let mut saw_tool_template = false;
for sibling in siblings {
let name = sibling.as_ref().to_lowercase();
if name.contains("mmproj") {
caps.upgrade_vision(CapabilityLevel::Supported);
}
if name.contains("audio") || name.contains("whisper") || name.contains("ultravox") {
caps.upgrade_audio(CapabilityLevel::Likely);
}
if name.ends_with("preprocessor_config.json")
|| name.ends_with("processor_config.json")
|| name.ends_with("image_processor_config.json")
{
saw_processor = true;
}
if name.ends_with("tokenizer_config.json")
|| name.ends_with("chat_template.json")
|| name.contains("reasoning")
|| name.contains("thinking")
{
saw_reasoning_template = true;
}
if name.contains("tool") || name.contains("function") {
saw_tool_template = true;
}
}
if saw_processor {
caps.upgrade_vision(CapabilityLevel::Likely);
}
if saw_reasoning_template {
caps.upgrade_reasoning(CapabilityLevel::Likely);
}
if saw_tool_template {
caps.upgrade_tool_use(CapabilityLevel::Likely);
}
caps.normalize()
}
pub fn merge_config_signals(mut caps: ModelCapabilities, config: &Value) -> ModelCapabilities {
if config.get("vision_config").is_some() {
caps.upgrade_vision(CapabilityLevel::Supported);
}
if config.get("audio_config").is_some() {
caps.upgrade_audio(CapabilityLevel::Supported);
}
for key in [
"image_token_id",
"video_token_id",
"vision_start_token_id",
"vision_end_token_id",
"vision_token_id",
] {
if config.get(key).is_some() {
caps.upgrade_vision(CapabilityLevel::Supported);
}
}
for key in [
"audio_token_id",
"audio_start_token_id",
"audio_end_token_id",
"audio_bos_token_id",
"audio_eos_token_id",
"audio_chunk_size",
] {
if config.get(key).is_some() {
caps.upgrade_audio(CapabilityLevel::Supported);
}
}
if let Some(architectures) = config.get("architectures").and_then(Value::as_array) {
for architecture in architectures.iter().filter_map(Value::as_str) {
merge_name_signal(&mut caps, architecture, false);
}
}
if let Some(model_type) = config.get("model_type").and_then(Value::as_str) {
merge_name_signal(&mut caps, model_type, false);
}
if json_contains_reasoning_tokens(config) {
caps.upgrade_reasoning(CapabilityLevel::Supported);
}
if json_contains_tool_use_tokens(config) {
caps.upgrade_tool_use(CapabilityLevel::Supported);
}
caps.normalize()
}
fn merge_name_signal(caps: &mut ModelCapabilities, value: &str, allow_likely_vision: bool) {
caps.upgrade_vision(name_signal_level(
value,
allow_likely_vision,
strong_vision_name_signal,
likely_vision_name_signal,
));
caps.upgrade_audio(name_signal_level(
value,
true,
strong_audio_name_signal,
likely_audio_name_signal,
));
caps.upgrade_reasoning(name_signal_level(
value,
true,
strong_reasoning_name_signal,
likely_reasoning_name_signal,
));
caps.upgrade_tool_use(name_signal_level(
value,
true,
strong_tool_use_name_signal,
likely_tool_use_name_signal,
));
}
fn name_signal_level(
value: &str,
allow_likely: bool,
strong: fn(&str) -> bool,
likely: fn(&str) -> bool,
) -> CapabilityLevel {
if strong(value) {
CapabilityLevel::Supported
} else if allow_likely && likely(value) {
CapabilityLevel::Likely
} else {
CapabilityLevel::None
}
}
fn strong_vision_name_signal(value: &str) -> bool {
let value = value.to_lowercase();
[
"vision",
"qwen3-vl",
"qwen3_vl",
"qwen3vl",
"qwen2-vl",
"qwen2_vl",
"qwen2.5-vl",
"qwen2_5_vl",
"llava",
"mllama",
"paligemma",
"idefics",
"molmo",
"internvl",
"glm-4v",
"glm4v",
"ovis",
"florence",
]
.iter()
.any(|needle| value.contains(needle))
}
fn likely_vision_name_signal(value: &str) -> bool {
let value = value.to_lowercase();
value.contains("-vl")
|| value.contains("vl-")
|| value.contains("_vl")
|| value.contains("video")
|| value.contains("multimodal")
|| value.contains("image")
}
fn strong_audio_name_signal(value: &str) -> bool {
let value = value.to_lowercase();
[
"audio",
"qwen2-audio",
"qwen2_audio",
"seallm-audio",
"seallm_audio",
"ultravox",
"omni",
"speech",
"whisper",
]
.iter()
.any(|needle| value.contains(needle))
}
fn likely_audio_name_signal(value: &str) -> bool {
let value = value.to_lowercase();
value.contains("audio")
|| value.contains("speech")
|| value.contains("voice")
|| value.contains("omni")
}
fn strong_reasoning_name_signal(value: &str) -> bool {
let value = value.to_lowercase();
[
"reasoning",
"reasoner",
"reason",
"thinking",
"deepthink",
"deep_think",
"<think>",
"</think>",
]
.iter()
.any(|needle| value.contains(needle))
}
fn likely_reasoning_name_signal(value: &str) -> bool {
let value = value.to_lowercase();
[
"-r1",
"_r1",
" r1",
"think",
"thought",
"chain-of-thought",
"cot",
]
.iter()
.any(|needle| value.contains(needle))
}
fn strong_tool_use_name_signal(value: &str) -> bool {
let value = value.to_lowercase();
[
"tool calling",
"tool-calling",
"tool use",
"function calling",
"function-calling",
"function call",
"tool_use",
"tool_calls",
"function_call",
"function_calls",
]
.iter()
.any(|needle| value.contains(needle))
}
fn likely_tool_use_name_signal(value: &str) -> bool {
let value = value.to_lowercase();
["tool", "agentic", "function", "coding"]
.iter()
.any(|needle| value.contains(needle))
}
fn json_contains_reasoning_tokens(value: &Value) -> bool {
match value {
Value::Null | Value::Bool(_) | Value::Number(_) => false,
Value::String(text) => {
let lower = text.to_lowercase();
lower.contains("<think>")
|| lower.contains("</think>")
|| lower.contains("reasoning")
|| lower.contains("thinking")
}
Value::Array(items) => items.iter().any(json_contains_reasoning_tokens),
Value::Object(map) => map.iter().any(|(key, value)| {
let key_lower = key.to_lowercase();
key_lower.contains("reason")
|| key_lower.contains("think")
|| json_contains_reasoning_tokens(value)
}),
}
}
fn json_contains_tool_use_tokens(value: &Value) -> bool {
match value {
Value::Null | Value::Bool(_) | Value::Number(_) => false,
Value::String(text) => {
let lower = text.to_lowercase();
lower.contains("tool_call")
|| lower.contains("tool_calls")
|| lower.contains("tool_use")
|| lower.contains("tool_result")
|| lower.contains("function_call")
|| lower.contains("function_calls")
|| lower.contains("parallel_tool_calls")
|| lower.contains("\"tool\"")
}
Value::Array(items) => items.iter().any(json_contains_tool_use_tokens),
Value::Object(map) => map.iter().any(|(key, value)| {
let key_lower = key.to_lowercase();
key_lower == "tool_calls"
|| key_lower == "tool_call"
|| key_lower == "tool_use"
|| key_lower == "tool_result"
|| key_lower == "parallel_tool_calls"
|| key_lower == "function_call"
|| key_lower == "function_calls"
|| json_contains_tool_use_tokens(value)
}),
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{
CapabilityLevel, ModelCapabilities, merge_config_signals, merge_name_signals,
merge_sibling_signals,
};
const _: bool = CapabilityLevel::Supported.is_supported();
const _: &str = CapabilityLevel::Likely.status();
const _: Option<&str> = CapabilityLevel::None.label();
#[test]
fn capability_level_status_and_label_matrix_is_stable() {
let cases = [
(CapabilityLevel::None, "none", None),
(CapabilityLevel::Likely, "likely", Some("likely")),
(CapabilityLevel::Supported, "supported", Some("yes")),
];
for (level, expected_status, expected_label) in cases {
let caps = ModelCapabilities {
vision: level,
audio: level,
reasoning: level,
tool_use: level,
..Default::default()
};
assert_eq!(caps.vision_status(), expected_status);
assert_eq!(caps.vision_label(), expected_label);
assert_eq!(caps.audio_status(), expected_status);
assert_eq!(caps.audio_label(), expected_label);
assert_eq!(caps.reasoning_status(), expected_status);
assert_eq!(caps.reasoning_label(), expected_label);
assert_eq!(caps.tool_use_status(), expected_status);
assert_eq!(caps.tool_use_label(), expected_label);
}
}
#[test]
fn name_signals_only_upgrade_existing_levels() {
let caps = ModelCapabilities {
vision: CapabilityLevel::Likely,
audio: CapabilityLevel::Supported,
reasoning: CapabilityLevel::Likely,
tool_use: CapabilityLevel::Likely,
..Default::default()
};
let merged = merge_name_signals(
caps,
&["generic-image-model", "Whisper-Reasoning-Tool-Calling"],
);
assert_eq!(merged.vision, CapabilityLevel::Likely);
assert_eq!(merged.audio, CapabilityLevel::Supported);
assert_eq!(merged.reasoning, CapabilityLevel::Supported);
assert_eq!(merged.tool_use, CapabilityLevel::Supported);
assert!(merged.multimodal);
}
#[test]
fn config_architectures_preserve_all_capability_results() {
let config = json!({
"architectures": [
"Qwen3VLForConditionalGeneration",
"WhisperForConditionalGeneration",
"ReasoningToolCallingModel"
],
"model_type": "generic"
});
let caps = merge_config_signals(Default::default(), &config);
assert_eq!(caps.vision, CapabilityLevel::Supported);
assert_eq!(caps.audio, CapabilityLevel::Supported);
assert_eq!(caps.reasoning, CapabilityLevel::Supported);
assert_eq!(caps.tool_use, CapabilityLevel::Likely);
assert!(caps.multimodal);
}
#[test]
fn config_model_type_preserves_likely_and_supported_levels() {
let likely = merge_config_signals(
Default::default(),
&json!({ "model_type": "voice-r1-coding" }),
);
let supported = merge_config_signals(
Default::default(),
&json!({ "model_type": "qwen3vl-whisper-reasoning-tool-calling" }),
);
assert_eq!(likely.vision, CapabilityLevel::None);
assert_eq!(likely.audio, CapabilityLevel::Likely);
assert_eq!(likely.reasoning, CapabilityLevel::Likely);
assert_eq!(likely.tool_use, CapabilityLevel::Likely);
assert_eq!(supported.vision, CapabilityLevel::Supported);
assert_eq!(supported.audio, CapabilityLevel::Supported);
assert_eq!(supported.reasoning, CapabilityLevel::Supported);
assert_eq!(supported.tool_use, CapabilityLevel::Supported);
}
#[test]
fn sibling_signals_preserve_current_extension_precedence() {
let caps = merge_sibling_signals(
Default::default(),
[
"model-mmproj.gguf",
"audio-adapter.bin",
"tokenizer_config.json",
"function-template.json",
],
);
assert_eq!(caps.vision, CapabilityLevel::Supported);
assert_eq!(caps.audio, CapabilityLevel::Likely);
assert_eq!(caps.reasoning, CapabilityLevel::Likely);
assert_eq!(caps.tool_use, CapabilityLevel::Likely);
assert!(caps.multimodal);
}
#[test]
fn unknown_metadata_remains_unknown_without_false_positive() {
let config = json!({
"architectures": ["PlainCausalLM"],
"model_type": "plain_text"
});
let caps = merge_config_signals(Default::default(), &config);
assert_eq!(caps, ModelCapabilities::default());
}
#[test]
fn qwen3vl_name_signal_is_supported_vision() {
let caps = merge_name_signals(
Default::default(),
&[
"Qwen3VL-2B-Instruct-Q4_K_M",
"Qwen/Qwen3-VL-2B-Instruct-GGUF",
],
);
assert_eq!(caps.vision, CapabilityLevel::Supported);
assert!(caps.multimodal);
}
}