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::all()
19            .iter()
20            .filter(|level| self.0.iter().all(|model| model.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        let levels = self.reasoning_levels();
36        effort.filter(|_| !levels.is_empty()).map(|effort| effort.clamp_to(&levels))
37    }
38}
39
40impl FromStr for ModelSpec {
41    type Err = ModelSpecError;
42
43    fn from_str(spec: &str) -> Result<Self, Self::Err> {
44        if spec.trim().is_empty() {
45            return Err(ModelSpecError::Empty);
46        }
47        spec.split(',')
48            .map(str::trim)
49            .map(|part| {
50                if part.is_empty() {
51                    return Err(ModelSpecError::EmptyEntry);
52                }
53                part.parse::<LlmModel>()
54                    .map_err(|source| ModelSpecError::InvalidModel { model: part.to_string(), source })
55            })
56            .collect::<Result<Vec<_>, _>>()
57            .map(Self)
58    }
59}
60
61impl fmt::Display for ModelSpec {
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        for (index, model) in self.0.iter().enumerate() {
64            if index > 0 {
65                write!(formatter, ",")?;
66            }
67            write!(formatter, "{model}")?;
68        }
69        Ok(())
70    }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum ModelSpecError {
75    Empty,
76    EmptyEntry,
77    InvalidModel { model: String, source: String },
78}
79
80impl fmt::Display for ModelSpecError {
81    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
82        match self {
83            Self::Empty => write!(formatter, "model spec cannot be empty"),
84            Self::EmptyEntry => write!(formatter, "model spec contains an empty entry"),
85            Self::InvalidModel { model, source } => write!(formatter, "invalid model '{model}': {source}"),
86        }
87    }
88}
89
90impl std::error::Error for ModelSpecError {}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum ReasoningEffortError {
94    InvalidSpec(ModelSpecError),
95    Unsupported { model: String, effort: ReasoningEffort, supported: Vec<ReasoningEffort> },
96}
97
98impl fmt::Display for ReasoningEffortError {
99    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
100        match self {
101            Self::InvalidSpec(source) => write!(formatter, "{source}"),
102            Self::Unsupported { model, supported, .. } if supported.is_empty() => {
103                write!(formatter, "model '{model}' does not support reasoning")
104            }
105            Self::Unsupported { model, effort, supported } => {
106                let supported = supported.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ");
107                write!(
108                    formatter,
109                    "model '{model}' does not support reasoning effort '{effort}'; supported: {supported}"
110                )
111            }
112        }
113    }
114}
115
116impl std::error::Error for ReasoningEffortError {
117    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
118        match self {
119            Self::InvalidSpec(source) => Some(source),
120            Self::Unsupported { .. } => None,
121        }
122    }
123}
124
125impl LlmModel {
126    pub fn validate_reasoning_effort(&self, effort: ReasoningEffort) -> Result<(), ReasoningEffortError> {
127        let supported = self.reasoning_levels();
128        if supported.contains(&effort) {
129            Ok(())
130        } else {
131            Err(ReasoningEffortError::Unsupported { model: self.to_string(), effort, supported: supported.to_vec() })
132        }
133    }
134}
135
136/// Validate `effort` against every model in a comma-separated model spec.
137pub fn validate_reasoning_effort(
138    model_spec: &str,
139    effort: Option<ReasoningEffort>,
140) -> Result<(), ReasoningEffortError> {
141    if effort.is_none() {
142        return Ok(());
143    }
144    let spec = model_spec.parse::<ModelSpec>().map_err(ReasoningEffortError::InvalidSpec)?;
145    spec.validate_reasoning_effort(effort)
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn parses_and_displays_alloyed_specs_canonically() {
154        let spec: ModelSpec = " codex:gpt-5.6-sol , anthropic:claude-opus-4-6 ".parse().unwrap();
155        assert_eq!(spec.models().len(), 2);
156        assert_eq!(spec.to_string(), "codex:gpt-5.6-sol,anthropic:claude-opus-4-6");
157    }
158
159    #[test]
160    fn parse_rejects_empty_and_invalid_specs() {
161        assert_eq!("".parse::<ModelSpec>().unwrap_err(), ModelSpecError::Empty);
162        assert_eq!("anthropic:claude-opus-4-6,".parse::<ModelSpec>().unwrap_err(), ModelSpecError::EmptyEntry);
163        assert!(matches!("mystery:some-model".parse::<ModelSpec>().unwrap_err(), ModelSpecError::InvalidModel { .. }));
164    }
165
166    #[test]
167    fn validates_reasoning_effort_for_single_and_alloyed_models() {
168        assert!(validate_reasoning_effort("codex:gpt-5.6-sol", Some(ReasoningEffort::Max)).is_ok());
169        assert!(validate_reasoning_effort("anthropic:claude-opus-4-6", Some(ReasoningEffort::Xhigh)).is_err());
170        assert!(
171            validate_reasoning_effort("codex:gpt-5.6-sol,anthropic:claude-opus-4-6", Some(ReasoningEffort::Xhigh))
172                .is_err()
173        );
174    }
175
176    #[test]
177    fn reasoning_levels_intersect_across_alloyed_models() {
178        let spec: ModelSpec = "codex:gpt-5.6-sol,anthropic:claude-opus-4-6".parse().unwrap();
179        assert_eq!(
180            spec.reasoning_levels(),
181            vec![ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High, ReasoningEffort::Max]
182        );
183    }
184
185    #[test]
186    fn clamp_reasoning_effort_snaps_to_nearest_supported_level() {
187        let opus: ModelSpec = "anthropic:claude-opus-4-6".parse().unwrap();
188        assert_eq!(opus.clamp_reasoning_effort(Some(ReasoningEffort::Xhigh)), Some(ReasoningEffort::High));
189        assert_eq!(opus.clamp_reasoning_effort(Some(ReasoningEffort::Max)), Some(ReasoningEffort::Max));
190        assert_eq!(opus.clamp_reasoning_effort(None), None);
191
192        let non_reasoning: ModelSpec = "deepseek:deepseek-chat".parse().unwrap();
193        assert_eq!(non_reasoning.clamp_reasoning_effort(Some(ReasoningEffort::High)), None);
194    }
195}