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 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub cache_control: Option<CacheControlDialect>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub stream_termination: Option<StreamTermination>,
24}
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
28#[serde(rename_all = "snake_case")]
29pub enum StreamTermination {
30 RequireTerminalEvidence,
32 EofTolerated,
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
38#[serde(rename_all = "snake_case")]
39pub enum CacheControlDialect {
40 Anthropic,
42 Gemini,
44}
45
46#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
48pub struct ReasoningCapability {
49 #[serde(default)]
50 pub efforts: Vec<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub default_effort: Option<String>,
53 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
55 pub aliases: BTreeMap<String, String>,
56 #[serde(default)]
57 pub encoding: ReasoningEncoding,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub disable: Option<ReasoningDisableEncoding>,
60 #[serde(default)]
61 pub mandatory: bool,
62}
63
64#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
66#[serde(rename_all = "snake_case")]
67pub enum ReasoningSelection {
68 #[default]
70 ProviderDefault,
71 Disabled,
73 Effort(String),
75}
76
77impl ReasoningSelection {
78 pub fn effort(&self) -> Option<&str> {
79 match self {
80 Self::Effort(effort) => Some(effort),
81 Self::ProviderDefault | Self::Disabled => None,
82 }
83 }
84}
85
86#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
88#[serde(rename_all = "snake_case")]
89pub enum ReasoningDisableEncoding {
90 Native,
91 Omit,
92 Effort(String),
93 Budget(u32),
94 ToggleFalse,
95}
96
97#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
99#[serde(rename_all = "snake_case")]
100pub enum ReasoningEncoding {
101 #[default]
103 Effort,
104 Budget(BTreeMap<String, u32>),
106}
107
108#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum ModelEffortValidationCategory {
113 UnsupportedEffort,
114 EffortNotConfigurable,
115 EffortRequired,
116 MalformedCapability,
117}
118
119impl ModelEffortValidationCategory {
120 pub fn code(&self) -> &'static str {
123 match self {
124 Self::UnsupportedEffort => "unsupported_effort",
125 Self::EffortNotConfigurable => "effort_not_configurable",
126 Self::EffortRequired => "effort_required",
127 Self::MalformedCapability => "malformed_capability",
128 }
129 }
130}
131
132#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
133pub struct ModelEffortValidationError {
134 pub category: ModelEffortValidationCategory,
135 pub message: String,
136}
137
138impl std::fmt::Display for ModelEffortValidationError {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 f.write_str(&self.message)
141 }
142}
143
144impl std::error::Error for ModelEffortValidationError {}
145
146impl ModelCapability {
147 pub fn is_empty(&self) -> bool {
148 self.reasoning.is_none()
149 && self.cache_control.is_none()
150 && self.stream_termination.is_none()
151 }
152
153 pub fn resolve_effort(&self, requested: &str) -> Option<String> {
157 let reasoning = self.reasoning.as_ref()?;
158 let key = requested.trim().to_lowercase();
159 if let Some(canonical) = reasoning.aliases.get(&key) {
160 return Some(canonical.clone());
161 }
162 if reasoning.efforts.iter().any(|effort| effort == &key) {
163 return Some(key);
164 }
165 None
166 }
167
168 pub fn validate_selection(
172 &self,
173 model: &str,
174 provider_kind: &str,
175 requested: &ReasoningSelection,
176 ) -> Result<ReasoningSelection, ModelEffortValidationError> {
177 if let Some(ReasoningCapability {
178 efforts,
179 encoding: ReasoningEncoding::Budget(budgets),
180 ..
181 }) = self.reasoning.as_ref()
182 && let Some(missing) = efforts.iter().find(|effort| !budgets.contains_key(*effort))
183 {
184 return Err(ModelEffortValidationError {
185 category: ModelEffortValidationCategory::MalformedCapability,
186 message: format!(
187 "Malformed capability for model `{model}` on {provider_kind}: budget encoding is missing advertised effort `{missing}`."
188 ),
189 });
190 }
191
192 match (self.reasoning.as_ref(), requested) {
193 (None, ReasoningSelection::Effort(effort)) => Err(ModelEffortValidationError {
194 category: ModelEffortValidationCategory::EffortNotConfigurable,
195 message: format!(
196 "Model `{model}` on {provider_kind} does not expose configurable effort (requested `{effort}`)."
197 ),
198 }),
199 (None, ReasoningSelection::Disabled) => Err(ModelEffortValidationError {
200 category: ModelEffortValidationCategory::EffortNotConfigurable,
201 message: format!(
202 "Model `{model}` on {provider_kind} does not expose configurable effort (requested disabled)."
203 ),
204 }),
205 (None, ReasoningSelection::ProviderDefault) => Ok(ReasoningSelection::ProviderDefault),
206 (Some(reasoning), ReasoningSelection::ProviderDefault) => {
207 if reasoning.mandatory {
208 Err(ModelEffortValidationError {
209 category: ModelEffortValidationCategory::EffortRequired,
210 message: format!(
211 "Model `{model}` on {provider_kind} requires an explicit effort. Available: {}",
212 reasoning.efforts.join(", ")
213 ),
214 })
215 } else {
216 Ok(ReasoningSelection::ProviderDefault)
217 }
218 }
219 (Some(reasoning), ReasoningSelection::Disabled) => {
220 if reasoning.disable.is_some() {
221 Ok(ReasoningSelection::Disabled)
222 } else {
223 Err(ModelEffortValidationError {
224 category: ModelEffortValidationCategory::UnsupportedEffort,
225 message: format!(
226 "Model `{model}` on {provider_kind} does not support disabling reasoning."
227 ),
228 })
229 }
230 }
231 (Some(reasoning), ReasoningSelection::Effort(effort)) => {
232 if reasoning.efforts.is_empty() {
233 return Err(ModelEffortValidationError {
234 category: ModelEffortValidationCategory::EffortNotConfigurable,
235 message: format!(
236 "Model `{model}` on {provider_kind} does not expose configurable effort (requested `{effort}`)."
237 ),
238 });
239 }
240 match self.resolve_effort(effort) {
241 Some(resolved) if reasoning.efforts.contains(&resolved) => {
242 Ok(ReasoningSelection::Effort(resolved))
243 }
244 _ => Err(ModelEffortValidationError {
245 category: ModelEffortValidationCategory::UnsupportedEffort,
246 message: format!(
247 "Unsupported effort `{effort}` for `{model}` on {provider_kind}. Available: {}",
248 reasoning.efforts.join(", ")
249 ),
250 }),
251 }
252 }
253 }
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 fn efforts() -> Vec<String> {
262 ["low", "medium", "high", "max"]
263 .into_iter()
264 .map(String::from)
265 .collect()
266 }
267
268 fn reasoning() -> ReasoningCapability {
269 ReasoningCapability {
270 efforts: efforts(),
271 default_effort: Some("medium".to_string()),
272 aliases: BTreeMap::new(),
273 encoding: ReasoningEncoding::Effort,
274 disable: Some(ReasoningDisableEncoding::Effort("none".to_string())),
275 mandatory: false,
276 }
277 }
278
279 fn capability(reasoning: Option<ReasoningCapability>) -> ModelCapability {
280 ModelCapability {
281 reasoning,
282 cache_control: None,
283 stream_termination: None,
284 }
285 }
286
287 #[test]
288 fn is_empty_tracks_reasoning_presence() {
289 assert!(capability(None).is_empty());
290 assert!(!capability(Some(reasoning())).is_empty());
291 assert!(
292 !ModelCapability {
293 cache_control: Some(CacheControlDialect::Anthropic),
294 ..ModelCapability::default()
295 }
296 .is_empty()
297 );
298 assert!(
299 !ModelCapability {
300 stream_termination: Some(StreamTermination::RequireTerminalEvidence),
301 ..ModelCapability::default()
302 }
303 .is_empty()
304 );
305 }
306
307 #[test]
308 fn resolve_effort_prefers_alias_then_membership() {
309 let mut r = reasoning();
310 r.aliases.insert("xhigh".to_string(), "max".to_string());
311 r.aliases.insert("minimal".to_string(), "low".to_string());
312 let cap = capability(Some(r));
313
314 assert_eq!(cap.resolve_effort("xhigh").as_deref(), Some("max"));
316 assert_eq!(cap.resolve_effort(" XHigh ").as_deref(), Some("max"));
317 assert_eq!(cap.resolve_effort("MINIMAL").as_deref(), Some("low"));
318 assert_eq!(cap.resolve_effort("high").as_deref(), Some("high"));
320 assert_eq!(cap.resolve_effort("turbo"), None);
322 }
323
324 #[test]
325 fn resolve_effort_none_when_no_reasoning() {
326 assert_eq!(capability(None).resolve_effort("low"), None);
327 }
328
329 #[test]
330 fn resolved_selection_classifier_covers_ratified_table() {
331 struct Case {
332 name: &'static str,
333 capability: ModelCapability,
334 selection: ReasoningSelection,
335 expected: Result<ReasoningSelection, ModelEffortValidationCategory>,
336 }
337
338 let mut aliased = reasoning();
339 aliased
340 .aliases
341 .insert("xhigh".to_string(), "max".to_string());
342 let mut cannot_disable = reasoning();
343 cannot_disable.disable = None;
344 let mut mandatory = reasoning();
345 mandatory.mandatory = true;
346 let mut malformed = reasoning();
347 malformed
348 .aliases
349 .insert("broken".to_string(), "missing".to_string());
350
351 let cases = [
352 Case {
353 name: "default",
354 capability: capability(Some(reasoning())),
355 selection: ReasoningSelection::ProviderDefault,
356 expected: Ok(ReasoningSelection::ProviderDefault),
357 },
358 Case {
359 name: "effort",
360 capability: capability(Some(reasoning())),
361 selection: ReasoningSelection::Effort("High".to_string()),
362 expected: Ok(ReasoningSelection::Effort("high".to_string())),
363 },
364 Case {
365 name: "alias_to_effort",
366 capability: capability(Some(aliased)),
367 selection: ReasoningSelection::Effort("xhigh".to_string()),
368 expected: Ok(ReasoningSelection::Effort("max".to_string())),
369 },
370 Case {
371 name: "disabled",
372 capability: capability(Some(reasoning())),
373 selection: ReasoningSelection::Disabled,
374 expected: Ok(ReasoningSelection::Disabled),
375 },
376 Case {
377 name: "disabled_unsupported",
378 capability: capability(Some(cannot_disable)),
379 selection: ReasoningSelection::Disabled,
380 expected: Err(ModelEffortValidationCategory::UnsupportedEffort),
381 },
382 Case {
383 name: "no_reasoning",
384 capability: capability(None),
385 selection: ReasoningSelection::Effort("low".to_string()),
386 expected: Err(ModelEffortValidationCategory::EffortNotConfigurable),
387 },
388 Case {
389 name: "mandatory_without_selection",
390 capability: capability(Some(mandatory)),
391 selection: ReasoningSelection::ProviderDefault,
392 expected: Err(ModelEffortValidationCategory::EffortRequired),
393 },
394 Case {
395 name: "malformed_capability",
396 capability: capability(Some(malformed)),
397 selection: ReasoningSelection::Effort("broken".to_string()),
398 expected: Err(ModelEffortValidationCategory::UnsupportedEffort),
399 },
400 ];
401
402 for case in cases {
403 let actual = case
404 .capability
405 .validate_selection("m", "test", &case.selection)
406 .map_err(|error| error.category);
407 assert_eq!(actual, case.expected, "{}", case.name);
408 }
409 }
410
411 #[test]
412 fn category_codes_are_stable_snake_case() {
413 assert_eq!(
414 ModelEffortValidationCategory::UnsupportedEffort.code(),
415 "unsupported_effort"
416 );
417 assert_eq!(
418 ModelEffortValidationCategory::EffortNotConfigurable.code(),
419 "effort_not_configurable"
420 );
421 assert_eq!(
422 ModelEffortValidationCategory::EffortRequired.code(),
423 "effort_required"
424 );
425 assert_eq!(
426 ModelEffortValidationCategory::MalformedCapability.code(),
427 "malformed_capability"
428 );
429 }
430
431 #[test]
432 fn budget_encoding_completeness_is_validated_for_every_selection() {
433 struct Case {
434 name: &'static str,
435 selection: ReasoningSelection,
436 }
437
438 let cases = [
439 Case {
440 name: "provider_default",
441 selection: ReasoningSelection::ProviderDefault,
442 },
443 Case {
444 name: "disabled",
445 selection: ReasoningSelection::Disabled,
446 },
447 Case {
448 name: "effort",
449 selection: ReasoningSelection::Effort("low".to_string()),
450 },
451 ];
452
453 for case in cases {
454 let mut r = reasoning();
455 r.encoding = ReasoningEncoding::Budget(BTreeMap::from([
456 ("low".to_string(), 1024),
457 ("high".to_string(), 8192),
458 ]));
459 let error = capability(Some(r))
460 .validate_selection("m", "test", &case.selection)
461 .expect_err(case.name);
462 assert_eq!(
463 error.category,
464 ModelEffortValidationCategory::MalformedCapability,
465 "{}",
466 case.name
467 );
468 assert_eq!(
469 error.message,
470 "Malformed capability for model `m` on test: budget encoding is missing advertised effort `medium`.",
471 "{}",
472 case.name
473 );
474 }
475 }
476
477 #[test]
478 fn complete_budget_encoding_passes_integrity_validation() {
479 let mut r = reasoning();
480 r.encoding = ReasoningEncoding::Budget(BTreeMap::from([
481 ("low".to_string(), 1024),
482 ("medium".to_string(), 4096),
483 ("high".to_string(), 8192),
484 ("max".to_string(), 16384),
485 ]));
486
487 assert_eq!(
488 capability(Some(r)).validate_selection(
489 "m",
490 "test",
491 &ReasoningSelection::Effort("high".to_string())
492 ),
493 Ok(ReasoningSelection::Effort("high".to_string()))
494 );
495 }
496
497 #[test]
498 fn model_capability_serde_roundtrips_and_skips_empties() {
499 let cap = capability(None);
500 let json = serde_json::to_value(&cap).expect("serialize");
501 assert_eq!(json, serde_json::json!({}));
502 let back: ModelCapability = serde_json::from_value(json).expect("deserialize");
503 assert_eq!(back, cap);
504
505 let cap = ModelCapability {
506 cache_control: Some(CacheControlDialect::Gemini),
507 ..ModelCapability::default()
508 };
509 let json = serde_json::to_value(&cap).expect("serialize");
510 assert_eq!(json, serde_json::json!({ "cache_control": "gemini" }));
511 let back: ModelCapability = serde_json::from_value(json).expect("deserialize");
512 assert_eq!(back, cap);
513
514 let mut r = reasoning();
515 r.aliases.insert("xhigh".to_string(), "max".to_string());
516 r.encoding = ReasoningEncoding::Budget(BTreeMap::from([
517 ("low".to_string(), 1024u32),
518 ("medium".to_string(), 4096u32),
519 ("high".to_string(), 8192u32),
520 ("max".to_string(), 16384u32),
521 ]));
522 let cap = capability(Some(r));
523 let json = serde_json::to_value(&cap).expect("serialize");
524 let back: ModelCapability = serde_json::from_value(json).expect("deserialize");
525 assert_eq!(back, cap);
526 }
527}