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