1use std::collections::BTreeMap;
9
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
15pub struct ModelCapability {
16 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub reasoning: Option<ReasoningCapability>,
18}
19
20#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
22pub struct ReasoningCapability {
23 #[serde(default)]
24 pub efforts: Vec<String>,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub default_effort: Option<String>,
27 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
29 pub aliases: BTreeMap<String, String>,
30 #[serde(default)]
31 pub encoding: ReasoningEncoding,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub disable: Option<ReasoningDisableEncoding>,
34 #[serde(default)]
35 pub mandatory: bool,
36}
37
38#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
40#[serde(rename_all = "snake_case")]
41pub enum ReasoningSelection {
42 #[default]
44 ProviderDefault,
45 Disabled,
47 Effort(String),
49}
50
51impl ReasoningSelection {
52 pub fn effort(&self) -> Option<&str> {
53 match self {
54 Self::Effort(effort) => Some(effort),
55 Self::ProviderDefault | Self::Disabled => None,
56 }
57 }
58}
59
60#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
62#[serde(rename_all = "snake_case")]
63pub enum ReasoningDisableEncoding {
64 Native,
65 Omit,
66 Effort(String),
67 Budget(u32),
68 ToggleFalse,
69}
70
71#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
73#[serde(rename_all = "snake_case")]
74pub enum ReasoningEncoding {
75 #[default]
77 Effort,
78 Budget(BTreeMap<String, u32>),
80}
81
82#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum ModelEffortValidationCategory {
87 UnsupportedEffort,
88 EffortNotConfigurable,
89 EffortRequired,
90}
91
92impl ModelEffortValidationCategory {
93 pub fn code(&self) -> &'static str {
96 match self {
97 Self::UnsupportedEffort => "unsupported_effort",
98 Self::EffortNotConfigurable => "effort_not_configurable",
99 Self::EffortRequired => "effort_required",
100 }
101 }
102}
103
104#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
105pub struct ModelEffortValidationError {
106 pub category: ModelEffortValidationCategory,
107 pub message: String,
108}
109
110impl std::fmt::Display for ModelEffortValidationError {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 f.write_str(&self.message)
113 }
114}
115
116impl std::error::Error for ModelEffortValidationError {}
117
118impl ModelCapability {
119 pub fn is_empty(&self) -> bool {
120 self.reasoning.is_none()
121 }
122
123 pub fn resolve_effort(&self, requested: &str) -> Option<String> {
127 let reasoning = self.reasoning.as_ref()?;
128 let key = requested.trim().to_lowercase();
129 if let Some(canonical) = reasoning.aliases.get(&key) {
130 return Some(canonical.clone());
131 }
132 if reasoning.efforts.iter().any(|effort| effort == &key) {
133 return Some(key);
134 }
135 None
136 }
137
138 pub fn validate_selection(
142 &self,
143 model: &str,
144 provider_kind: &str,
145 requested: &ReasoningSelection,
146 ) -> Result<ReasoningSelection, ModelEffortValidationError> {
147 match (self.reasoning.as_ref(), requested) {
148 (None, ReasoningSelection::Effort(effort)) => Err(ModelEffortValidationError {
149 category: ModelEffortValidationCategory::EffortNotConfigurable,
150 message: format!(
151 "Model `{model}` on {provider_kind} does not expose configurable effort (requested `{effort}`)."
152 ),
153 }),
154 (None, ReasoningSelection::Disabled) => Err(ModelEffortValidationError {
155 category: ModelEffortValidationCategory::EffortNotConfigurable,
156 message: format!(
157 "Model `{model}` on {provider_kind} does not expose configurable effort (requested disabled)."
158 ),
159 }),
160 (None, ReasoningSelection::ProviderDefault) => Ok(ReasoningSelection::ProviderDefault),
161 (Some(reasoning), ReasoningSelection::ProviderDefault) => {
162 if reasoning.mandatory {
163 Err(ModelEffortValidationError {
164 category: ModelEffortValidationCategory::EffortRequired,
165 message: format!(
166 "Model `{model}` on {provider_kind} requires an explicit effort. Available: {}",
167 reasoning.efforts.join(", ")
168 ),
169 })
170 } else {
171 Ok(ReasoningSelection::ProviderDefault)
172 }
173 }
174 (Some(reasoning), ReasoningSelection::Disabled) => {
175 if reasoning.disable.is_some() {
176 Ok(ReasoningSelection::Disabled)
177 } else {
178 Err(ModelEffortValidationError {
179 category: ModelEffortValidationCategory::UnsupportedEffort,
180 message: format!(
181 "Model `{model}` on {provider_kind} does not support disabling reasoning."
182 ),
183 })
184 }
185 }
186 (Some(reasoning), ReasoningSelection::Effort(effort)) => {
187 if reasoning.efforts.is_empty() {
188 return Err(ModelEffortValidationError {
189 category: ModelEffortValidationCategory::EffortNotConfigurable,
190 message: format!(
191 "Model `{model}` on {provider_kind} does not expose configurable effort (requested `{effort}`)."
192 ),
193 });
194 }
195 match self.resolve_effort(effort) {
196 Some(resolved) if reasoning.efforts.contains(&resolved) => {
197 Ok(ReasoningSelection::Effort(resolved))
198 }
199 _ => Err(ModelEffortValidationError {
200 category: ModelEffortValidationCategory::UnsupportedEffort,
201 message: format!(
202 "Unsupported effort `{effort}` for `{model}` on {provider_kind}. Available: {}",
203 reasoning.efforts.join(", ")
204 ),
205 }),
206 }
207 }
208 }
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 fn efforts() -> Vec<String> {
217 ["low", "medium", "high", "max"]
218 .into_iter()
219 .map(String::from)
220 .collect()
221 }
222
223 fn reasoning() -> ReasoningCapability {
224 ReasoningCapability {
225 efforts: efforts(),
226 default_effort: Some("medium".to_string()),
227 aliases: BTreeMap::new(),
228 encoding: ReasoningEncoding::Effort,
229 disable: Some(ReasoningDisableEncoding::Effort("none".to_string())),
230 mandatory: false,
231 }
232 }
233
234 fn capability(reasoning: Option<ReasoningCapability>) -> ModelCapability {
235 ModelCapability { reasoning }
236 }
237
238 #[test]
239 fn is_empty_tracks_reasoning_presence() {
240 assert!(capability(None).is_empty());
241 assert!(!capability(Some(reasoning())).is_empty());
242 }
243
244 #[test]
245 fn resolve_effort_prefers_alias_then_membership() {
246 let mut r = reasoning();
247 r.aliases.insert("xhigh".to_string(), "max".to_string());
248 r.aliases.insert("minimal".to_string(), "low".to_string());
249 let cap = capability(Some(r));
250
251 assert_eq!(cap.resolve_effort("xhigh").as_deref(), Some("max"));
253 assert_eq!(cap.resolve_effort(" XHigh ").as_deref(), Some("max"));
254 assert_eq!(cap.resolve_effort("MINIMAL").as_deref(), Some("low"));
255 assert_eq!(cap.resolve_effort("high").as_deref(), Some("high"));
257 assert_eq!(cap.resolve_effort("turbo"), None);
259 }
260
261 #[test]
262 fn resolve_effort_none_when_no_reasoning() {
263 assert_eq!(capability(None).resolve_effort("low"), None);
264 }
265
266 #[test]
267 fn resolved_selection_classifier_covers_ratified_table() {
268 struct Case {
269 name: &'static str,
270 capability: ModelCapability,
271 selection: ReasoningSelection,
272 expected: Result<ReasoningSelection, ModelEffortValidationCategory>,
273 }
274
275 let mut aliased = reasoning();
276 aliased
277 .aliases
278 .insert("xhigh".to_string(), "max".to_string());
279 let mut cannot_disable = reasoning();
280 cannot_disable.disable = None;
281 let mut mandatory = reasoning();
282 mandatory.mandatory = true;
283 let mut malformed = reasoning();
284 malformed
285 .aliases
286 .insert("broken".to_string(), "missing".to_string());
287
288 let cases = [
289 Case {
290 name: "default",
291 capability: capability(Some(reasoning())),
292 selection: ReasoningSelection::ProviderDefault,
293 expected: Ok(ReasoningSelection::ProviderDefault),
294 },
295 Case {
296 name: "effort",
297 capability: capability(Some(reasoning())),
298 selection: ReasoningSelection::Effort("High".to_string()),
299 expected: Ok(ReasoningSelection::Effort("high".to_string())),
300 },
301 Case {
302 name: "alias_to_effort",
303 capability: capability(Some(aliased)),
304 selection: ReasoningSelection::Effort("xhigh".to_string()),
305 expected: Ok(ReasoningSelection::Effort("max".to_string())),
306 },
307 Case {
308 name: "disabled",
309 capability: capability(Some(reasoning())),
310 selection: ReasoningSelection::Disabled,
311 expected: Ok(ReasoningSelection::Disabled),
312 },
313 Case {
314 name: "disabled_unsupported",
315 capability: capability(Some(cannot_disable)),
316 selection: ReasoningSelection::Disabled,
317 expected: Err(ModelEffortValidationCategory::UnsupportedEffort),
318 },
319 Case {
320 name: "no_reasoning",
321 capability: capability(None),
322 selection: ReasoningSelection::Effort("low".to_string()),
323 expected: Err(ModelEffortValidationCategory::EffortNotConfigurable),
324 },
325 Case {
326 name: "mandatory_without_selection",
327 capability: capability(Some(mandatory)),
328 selection: ReasoningSelection::ProviderDefault,
329 expected: Err(ModelEffortValidationCategory::EffortRequired),
330 },
331 Case {
332 name: "malformed_capability",
333 capability: capability(Some(malformed)),
334 selection: ReasoningSelection::Effort("broken".to_string()),
335 expected: Err(ModelEffortValidationCategory::UnsupportedEffort),
336 },
337 ];
338
339 for case in cases {
340 let actual = case
341 .capability
342 .validate_selection("m", "test", &case.selection)
343 .map_err(|error| error.category);
344 assert_eq!(actual, case.expected, "{}", case.name);
345 }
346 }
347
348 #[test]
349 fn category_codes_are_stable_snake_case() {
350 assert_eq!(
351 ModelEffortValidationCategory::UnsupportedEffort.code(),
352 "unsupported_effort"
353 );
354 assert_eq!(
355 ModelEffortValidationCategory::EffortNotConfigurable.code(),
356 "effort_not_configurable"
357 );
358 assert_eq!(
359 ModelEffortValidationCategory::EffortRequired.code(),
360 "effort_required"
361 );
362 }
363
364 #[test]
365 fn model_capability_serde_roundtrips_and_skips_empties() {
366 let cap = capability(None);
367 let json = serde_json::to_value(&cap).expect("serialize");
368 assert_eq!(json, serde_json::json!({}));
369 let back: ModelCapability = serde_json::from_value(json).expect("deserialize");
370 assert_eq!(back, cap);
371
372 let mut r = reasoning();
373 r.aliases.insert("xhigh".to_string(), "max".to_string());
374 r.encoding = ReasoningEncoding::Budget(BTreeMap::from([
375 ("low".to_string(), 1024u32),
376 ("high".to_string(), 8192u32),
377 ]));
378 let cap = capability(Some(r));
379 let json = serde_json::to_value(&cap).expect("serialize");
380 let back: ModelCapability = serde_json::from_value(json).expect("deserialize");
381 assert_eq!(back, cap);
382 }
383}