Skip to main content

llm/catalog/
model_spec.rs

1use super::LlmModel;
2use crate::ReasoningEffort;
3use std::fmt;
4use std::str::FromStr;
5use thiserror::Error;
6
7/// A validated model selection: a single model or several alloyed models,
8/// parsed from a comma-separated `provider:model` list.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ModelSpec(Vec<LlmModel>);
11
12impl ModelSpec {
13    pub fn models(&self) -> &[LlmModel] {
14        &self.0
15    }
16
17    /// Reasoning levels supported by every model in the spec.
18    pub fn reasoning_levels(&self) -> Vec<ReasoningEffort> {
19        ReasoningEffort::selectable_levels()
20            .iter()
21            .filter(|level| self.0.iter().all(|model| model.effective_reasoning_levels().contains(level)))
22            .copied()
23            .collect()
24    }
25
26    pub fn validate_reasoning_effort(&self, effort: Option<ReasoningEffort>) -> Result<(), ReasoningEffortError> {
27        let Some(effort) = effort else {
28            return Ok(());
29        };
30        self.0.iter().try_for_each(|model| model.validate_reasoning_effort(effort))
31    }
32
33    /// The nearest effort supported by every model in the spec, or `None`
34    /// when the spec does not support reasoning at all.
35    pub fn clamp_reasoning_effort(&self, effort: Option<ReasoningEffort>) -> Option<ReasoningEffort> {
36        if effort.is_some_and(|effort| !effort.is_enabled()) {
37            return effort;
38        }
39        let levels: Vec<_> = self.reasoning_levels().into_iter().filter(|effort| effort.is_enabled()).collect();
40        effort.filter(|_| !levels.is_empty()).map(|effort| effort.clamp_to(&levels))
41    }
42}
43
44impl FromStr for ModelSpec {
45    type Err = ModelSpecError;
46
47    fn from_str(spec: &str) -> Result<Self, Self::Err> {
48        if spec.trim().is_empty() {
49            return Err(ModelSpecError::Empty);
50        }
51        spec.split(',')
52            .map(str::trim)
53            .map(|part| {
54                if part.is_empty() {
55                    return Err(ModelSpecError::EmptyEntry);
56                }
57                part.parse::<LlmModel>()
58                    .map_err(|reason| ModelSpecError::InvalidModel { model: part.to_string(), reason })
59            })
60            .collect::<Result<Vec<_>, _>>()
61            .map(Self)
62    }
63}
64
65impl fmt::Display for ModelSpec {
66    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
67        formatter.write_str(&self.0.iter().map(ToString::to_string).collect::<Vec<_>>().join(","))
68    }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Error)]
72pub enum ModelSpecError {
73    #[error("model spec cannot be empty")]
74    Empty,
75    #[error("model spec contains an empty entry")]
76    EmptyEntry,
77    #[error("invalid model '{model}': {reason}")]
78    InvalidModel { model: String, reason: String },
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Error)]
82pub enum ReasoningEffortError {
83    #[error("{0}")]
84    InvalidSpec(#[from] ModelSpecError),
85    #[error("model '{model}' does not support reasoning{}", supported_efforts(*effort, supported))]
86    Unsupported { model: String, effort: ReasoningEffort, supported: Vec<ReasoningEffort> },
87}
88
89/// Validate `effort` against every model in a comma-separated model spec.
90pub fn validate_reasoning_effort(
91    model_spec: &str,
92    effort: Option<ReasoningEffort>,
93) -> Result<(), ReasoningEffortError> {
94    if effort.is_none() {
95        return Ok(());
96    }
97    model_spec.parse::<ModelSpec>()?.validate_reasoning_effort(effort)
98}
99
100fn supported_efforts(effort: ReasoningEffort, supported: &[ReasoningEffort]) -> String {
101    if supported.is_empty() {
102        return String::new();
103    }
104    let supported = supported.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ");
105    format!(" effort '{effort}'; supported: {supported}")
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn control_states_validate_and_clamp_without_enabling_disabled() {
114        for model in LlmModel::all() {
115            assert!(model.validate_reasoning_effort(ReasoningEffort::Default).is_ok());
116            assert_eq!(
117                model.validate_reasoning_effort(ReasoningEffort::Disabled).is_ok(),
118                model.supports_reasoning_off_transport()
119            );
120            assert_eq!(
121                model.supports_reasoning_off(),
122                model.reasoning_disabled_support() != crate::ReasoningDisabledSupport::Unsupported
123            );
124            assert!(!model.reasoning_levels().contains(&ReasoningEffort::Default));
125        }
126        for text in ["openai:gpt-4o-mini", "codex:gpt-5.4", "ollama:unknown", "openai:gpt-5.4,codex:gpt-5.4"] {
127            let spec: ModelSpec = text.parse().unwrap();
128            assert_eq!(spec.clamp_reasoning_effort(Some(ReasoningEffort::Disabled)), Some(ReasoningEffort::Disabled));
129            assert!(spec.validate_reasoning_effort(Some(ReasoningEffort::Disabled)).is_err());
130        }
131        let spec: ModelSpec = "openai:gpt-5.4,openai:gpt-5.1".parse().unwrap();
132        assert!(spec.reasoning_levels().contains(&ReasoningEffort::Disabled));
133        assert!(spec.validate_reasoning_effort(Some(ReasoningEffort::Disabled)).is_ok());
134    }
135
136    #[test]
137    fn parses_and_displays_alloyed_specs_canonically() {
138        let spec: ModelSpec = " codex:gpt-5.6-sol , anthropic:claude-opus-4-6 ".parse().unwrap();
139        assert_eq!(spec.models().len(), 2);
140        assert_eq!(spec.to_string(), "codex:gpt-5.6-sol,anthropic:claude-opus-4-6");
141    }
142
143    #[test]
144    fn parse_rejects_empty_and_invalid_specs() {
145        assert_eq!("".parse::<ModelSpec>().unwrap_err(), ModelSpecError::Empty);
146        assert_eq!("anthropic:claude-opus-4-6,".parse::<ModelSpec>().unwrap_err(), ModelSpecError::EmptyEntry);
147        assert!(matches!("mystery:some-model".parse::<ModelSpec>().unwrap_err(), ModelSpecError::InvalidModel { .. }));
148    }
149
150    #[test]
151    fn validates_reasoning_effort_for_single_and_alloyed_models() {
152        assert!(validate_reasoning_effort("codex:gpt-5.6-sol", Some(ReasoningEffort::Max)).is_ok());
153        assert!(validate_reasoning_effort("anthropic:claude-opus-4-6", Some(ReasoningEffort::Xhigh)).is_err());
154        assert!(
155            validate_reasoning_effort("codex:gpt-5.6-sol,anthropic:claude-opus-4-6", Some(ReasoningEffort::Xhigh))
156                .is_err()
157        );
158    }
159
160    #[test]
161    fn reasoning_levels_intersect_across_alloyed_models() {
162        let spec: ModelSpec = "codex:gpt-5.6-sol,anthropic:claude-opus-4-6".parse().unwrap();
163        assert_eq!(
164            spec.reasoning_levels(),
165            vec![ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, ReasoningEffort::Max]
166        );
167    }
168
169    #[test]
170    fn clamp_reasoning_effort_snaps_to_nearest_supported_level() {
171        let opus: ModelSpec = "anthropic:claude-opus-4-6".parse().unwrap();
172        assert_eq!(opus.clamp_reasoning_effort(Some(ReasoningEffort::Xhigh)), Some(ReasoningEffort::High));
173        assert_eq!(opus.clamp_reasoning_effort(Some(ReasoningEffort::Max)), Some(ReasoningEffort::Max));
174        assert_eq!(opus.clamp_reasoning_effort(None), None);
175
176        let non_reasoning: ModelSpec = "openai:gpt-4o-mini".parse().unwrap();
177        assert_eq!(non_reasoning.clamp_reasoning_effort(Some(ReasoningEffort::High)), None);
178    }
179}