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 MalformedCapability,
91}
92
93impl ModelEffortValidationCategory {
94 pub fn code(&self) -> &'static str {
97 match self {
98 Self::UnsupportedEffort => "unsupported_effort",
99 Self::EffortNotConfigurable => "effort_not_configurable",
100 Self::EffortRequired => "effort_required",
101 Self::MalformedCapability => "malformed_capability",
102 }
103 }
104}
105
106#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
107pub struct ModelEffortValidationError {
108 pub category: ModelEffortValidationCategory,
109 pub message: String,
110}
111
112impl std::fmt::Display for ModelEffortValidationError {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 f.write_str(&self.message)
115 }
116}
117
118impl std::error::Error for ModelEffortValidationError {}
119
120impl ModelCapability {
121 pub fn is_empty(&self) -> bool {
122 self.reasoning.is_none()
123 }
124
125 pub fn resolve_effort(&self, requested: &str) -> Option<String> {
129 let reasoning = self.reasoning.as_ref()?;
130 let key = requested.trim().to_lowercase();
131 if let Some(canonical) = reasoning.aliases.get(&key) {
132 return Some(canonical.clone());
133 }
134 if reasoning.efforts.iter().any(|effort| effort == &key) {
135 return Some(key);
136 }
137 None
138 }
139
140 pub fn validate_selection(
144 &self,
145 model: &str,
146 provider_kind: &str,
147 requested: &ReasoningSelection,
148 ) -> Result<ReasoningSelection, ModelEffortValidationError> {
149 if let Some(ReasoningCapability {
150 efforts,
151 encoding: ReasoningEncoding::Budget(budgets),
152 ..
153 }) = self.reasoning.as_ref()
154 && let Some(missing) = efforts.iter().find(|effort| !budgets.contains_key(*effort))
155 {
156 return Err(ModelEffortValidationError {
157 category: ModelEffortValidationCategory::MalformedCapability,
158 message: format!(
159 "Malformed capability for model `{model}` on {provider_kind}: budget encoding is missing advertised effort `{missing}`."
160 ),
161 });
162 }
163
164 match (self.reasoning.as_ref(), requested) {
165 (None, ReasoningSelection::Effort(effort)) => Err(ModelEffortValidationError {
166 category: ModelEffortValidationCategory::EffortNotConfigurable,
167 message: format!(
168 "Model `{model}` on {provider_kind} does not expose configurable effort (requested `{effort}`)."
169 ),
170 }),
171 (None, ReasoningSelection::Disabled) => Err(ModelEffortValidationError {
172 category: ModelEffortValidationCategory::EffortNotConfigurable,
173 message: format!(
174 "Model `{model}` on {provider_kind} does not expose configurable effort (requested disabled)."
175 ),
176 }),
177 (None, ReasoningSelection::ProviderDefault) => Ok(ReasoningSelection::ProviderDefault),
178 (Some(reasoning), ReasoningSelection::ProviderDefault) => {
179 if reasoning.mandatory {
180 Err(ModelEffortValidationError {
181 category: ModelEffortValidationCategory::EffortRequired,
182 message: format!(
183 "Model `{model}` on {provider_kind} requires an explicit effort. Available: {}",
184 reasoning.efforts.join(", ")
185 ),
186 })
187 } else {
188 Ok(ReasoningSelection::ProviderDefault)
189 }
190 }
191 (Some(reasoning), ReasoningSelection::Disabled) => {
192 if reasoning.disable.is_some() {
193 Ok(ReasoningSelection::Disabled)
194 } else {
195 Err(ModelEffortValidationError {
196 category: ModelEffortValidationCategory::UnsupportedEffort,
197 message: format!(
198 "Model `{model}` on {provider_kind} does not support disabling reasoning."
199 ),
200 })
201 }
202 }
203 (Some(reasoning), ReasoningSelection::Effort(effort)) => {
204 if reasoning.efforts.is_empty() {
205 return Err(ModelEffortValidationError {
206 category: ModelEffortValidationCategory::EffortNotConfigurable,
207 message: format!(
208 "Model `{model}` on {provider_kind} does not expose configurable effort (requested `{effort}`)."
209 ),
210 });
211 }
212 match self.resolve_effort(effort) {
213 Some(resolved) if reasoning.efforts.contains(&resolved) => {
214 Ok(ReasoningSelection::Effort(resolved))
215 }
216 _ => Err(ModelEffortValidationError {
217 category: ModelEffortValidationCategory::UnsupportedEffort,
218 message: format!(
219 "Unsupported effort `{effort}` for `{model}` on {provider_kind}. Available: {}",
220 reasoning.efforts.join(", ")
221 ),
222 }),
223 }
224 }
225 }
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 fn efforts() -> Vec<String> {
234 ["low", "medium", "high", "max"]
235 .into_iter()
236 .map(String::from)
237 .collect()
238 }
239
240 fn reasoning() -> ReasoningCapability {
241 ReasoningCapability {
242 efforts: efforts(),
243 default_effort: Some("medium".to_string()),
244 aliases: BTreeMap::new(),
245 encoding: ReasoningEncoding::Effort,
246 disable: Some(ReasoningDisableEncoding::Effort("none".to_string())),
247 mandatory: false,
248 }
249 }
250
251 fn capability(reasoning: Option<ReasoningCapability>) -> ModelCapability {
252 ModelCapability { reasoning }
253 }
254
255 #[test]
256 fn is_empty_tracks_reasoning_presence() {
257 assert!(capability(None).is_empty());
258 assert!(!capability(Some(reasoning())).is_empty());
259 }
260
261 #[test]
262 fn resolve_effort_prefers_alias_then_membership() {
263 let mut r = reasoning();
264 r.aliases.insert("xhigh".to_string(), "max".to_string());
265 r.aliases.insert("minimal".to_string(), "low".to_string());
266 let cap = capability(Some(r));
267
268 assert_eq!(cap.resolve_effort("xhigh").as_deref(), Some("max"));
270 assert_eq!(cap.resolve_effort(" XHigh ").as_deref(), Some("max"));
271 assert_eq!(cap.resolve_effort("MINIMAL").as_deref(), Some("low"));
272 assert_eq!(cap.resolve_effort("high").as_deref(), Some("high"));
274 assert_eq!(cap.resolve_effort("turbo"), None);
276 }
277
278 #[test]
279 fn resolve_effort_none_when_no_reasoning() {
280 assert_eq!(capability(None).resolve_effort("low"), None);
281 }
282
283 #[test]
284 fn resolved_selection_classifier_covers_ratified_table() {
285 struct Case {
286 name: &'static str,
287 capability: ModelCapability,
288 selection: ReasoningSelection,
289 expected: Result<ReasoningSelection, ModelEffortValidationCategory>,
290 }
291
292 let mut aliased = reasoning();
293 aliased
294 .aliases
295 .insert("xhigh".to_string(), "max".to_string());
296 let mut cannot_disable = reasoning();
297 cannot_disable.disable = None;
298 let mut mandatory = reasoning();
299 mandatory.mandatory = true;
300 let mut malformed = reasoning();
301 malformed
302 .aliases
303 .insert("broken".to_string(), "missing".to_string());
304
305 let cases = [
306 Case {
307 name: "default",
308 capability: capability(Some(reasoning())),
309 selection: ReasoningSelection::ProviderDefault,
310 expected: Ok(ReasoningSelection::ProviderDefault),
311 },
312 Case {
313 name: "effort",
314 capability: capability(Some(reasoning())),
315 selection: ReasoningSelection::Effort("High".to_string()),
316 expected: Ok(ReasoningSelection::Effort("high".to_string())),
317 },
318 Case {
319 name: "alias_to_effort",
320 capability: capability(Some(aliased)),
321 selection: ReasoningSelection::Effort("xhigh".to_string()),
322 expected: Ok(ReasoningSelection::Effort("max".to_string())),
323 },
324 Case {
325 name: "disabled",
326 capability: capability(Some(reasoning())),
327 selection: ReasoningSelection::Disabled,
328 expected: Ok(ReasoningSelection::Disabled),
329 },
330 Case {
331 name: "disabled_unsupported",
332 capability: capability(Some(cannot_disable)),
333 selection: ReasoningSelection::Disabled,
334 expected: Err(ModelEffortValidationCategory::UnsupportedEffort),
335 },
336 Case {
337 name: "no_reasoning",
338 capability: capability(None),
339 selection: ReasoningSelection::Effort("low".to_string()),
340 expected: Err(ModelEffortValidationCategory::EffortNotConfigurable),
341 },
342 Case {
343 name: "mandatory_without_selection",
344 capability: capability(Some(mandatory)),
345 selection: ReasoningSelection::ProviderDefault,
346 expected: Err(ModelEffortValidationCategory::EffortRequired),
347 },
348 Case {
349 name: "malformed_capability",
350 capability: capability(Some(malformed)),
351 selection: ReasoningSelection::Effort("broken".to_string()),
352 expected: Err(ModelEffortValidationCategory::UnsupportedEffort),
353 },
354 ];
355
356 for case in cases {
357 let actual = case
358 .capability
359 .validate_selection("m", "test", &case.selection)
360 .map_err(|error| error.category);
361 assert_eq!(actual, case.expected, "{}", case.name);
362 }
363 }
364
365 #[test]
366 fn category_codes_are_stable_snake_case() {
367 assert_eq!(
368 ModelEffortValidationCategory::UnsupportedEffort.code(),
369 "unsupported_effort"
370 );
371 assert_eq!(
372 ModelEffortValidationCategory::EffortNotConfigurable.code(),
373 "effort_not_configurable"
374 );
375 assert_eq!(
376 ModelEffortValidationCategory::EffortRequired.code(),
377 "effort_required"
378 );
379 assert_eq!(
380 ModelEffortValidationCategory::MalformedCapability.code(),
381 "malformed_capability"
382 );
383 }
384
385 #[test]
386 fn budget_encoding_completeness_is_validated_for_every_selection() {
387 struct Case {
388 name: &'static str,
389 selection: ReasoningSelection,
390 }
391
392 let cases = [
393 Case {
394 name: "provider_default",
395 selection: ReasoningSelection::ProviderDefault,
396 },
397 Case {
398 name: "disabled",
399 selection: ReasoningSelection::Disabled,
400 },
401 Case {
402 name: "effort",
403 selection: ReasoningSelection::Effort("low".to_string()),
404 },
405 ];
406
407 for case in cases {
408 let mut r = reasoning();
409 r.encoding = ReasoningEncoding::Budget(BTreeMap::from([
410 ("low".to_string(), 1024),
411 ("high".to_string(), 8192),
412 ]));
413 let error = capability(Some(r))
414 .validate_selection("m", "test", &case.selection)
415 .expect_err(case.name);
416 assert_eq!(
417 error.category,
418 ModelEffortValidationCategory::MalformedCapability,
419 "{}",
420 case.name
421 );
422 assert_eq!(
423 error.message,
424 "Malformed capability for model `m` on test: budget encoding is missing advertised effort `medium`.",
425 "{}",
426 case.name
427 );
428 }
429 }
430
431 #[test]
432 fn complete_budget_encoding_passes_integrity_validation() {
433 let mut r = reasoning();
434 r.encoding = ReasoningEncoding::Budget(BTreeMap::from([
435 ("low".to_string(), 1024),
436 ("medium".to_string(), 4096),
437 ("high".to_string(), 8192),
438 ("max".to_string(), 16384),
439 ]));
440
441 assert_eq!(
442 capability(Some(r)).validate_selection(
443 "m",
444 "test",
445 &ReasoningSelection::Effort("high".to_string())
446 ),
447 Ok(ReasoningSelection::Effort("high".to_string()))
448 );
449 }
450
451 #[test]
452 fn model_capability_serde_roundtrips_and_skips_empties() {
453 let cap = capability(None);
454 let json = serde_json::to_value(&cap).expect("serialize");
455 assert_eq!(json, serde_json::json!({}));
456 let back: ModelCapability = serde_json::from_value(json).expect("deserialize");
457 assert_eq!(back, cap);
458
459 let mut r = reasoning();
460 r.aliases.insert("xhigh".to_string(), "max".to_string());
461 r.encoding = ReasoningEncoding::Budget(BTreeMap::from([
462 ("low".to_string(), 1024u32),
463 ("medium".to_string(), 4096u32),
464 ("high".to_string(), 8192u32),
465 ("max".to_string(), 16384u32),
466 ]));
467 let cap = capability(Some(r));
468 let json = serde_json::to_value(&cap).expect("serialize");
469 let back: ModelCapability = serde_json::from_value(json).expect("deserialize");
470 assert_eq!(back, cap);
471 }
472}