llm/catalog/
model_spec.rs1use super::LlmModel;
2use crate::ReasoningEffort;
3use std::fmt;
4use std::str::FromStr;
5
6#[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 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 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
128pub 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}