Skip to main content

llm/catalog/
model_spec.rs

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