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)]
33 pub mandatory: bool,
34}
35
36#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
38#[serde(rename_all = "snake_case")]
39pub enum ReasoningEncoding {
40 #[default]
42 Effort,
43 Budget(BTreeMap<String, u32>),
45}
46
47#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum ModelEffortValidationCategory {
52 UnsupportedEffort,
53 EffortNotConfigurable,
54 EffortRequired,
55}
56
57impl ModelEffortValidationCategory {
58 pub fn code(&self) -> &'static str {
61 match self {
62 Self::UnsupportedEffort => "unsupported_effort",
63 Self::EffortNotConfigurable => "effort_not_configurable",
64 Self::EffortRequired => "effort_required",
65 }
66 }
67}
68
69#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
70pub struct ModelEffortValidationError {
71 pub category: ModelEffortValidationCategory,
72 pub message: String,
73}
74
75impl std::fmt::Display for ModelEffortValidationError {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 f.write_str(&self.message)
78 }
79}
80
81impl std::error::Error for ModelEffortValidationError {}
82
83impl ModelCapability {
84 pub fn is_empty(&self) -> bool {
85 self.reasoning.is_none()
86 }
87
88 pub fn resolve_effort(&self, requested: &str) -> Option<String> {
92 let reasoning = self.reasoning.as_ref()?;
93 let key = requested.trim().to_lowercase();
94 if let Some(canonical) = reasoning.aliases.get(&key) {
95 return Some(canonical.clone());
96 }
97 if reasoning.efforts.iter().any(|effort| effort == &key) {
98 return Some(key);
99 }
100 None
101 }
102
103 pub fn validate_effort(
107 &self,
108 model: &str,
109 provider_kind: &str,
110 requested: Option<&str>,
111 ) -> Result<Option<String>, ModelEffortValidationError> {
112 match (self.reasoning.as_ref(), requested) {
113 (None, Some(effort)) => Err(ModelEffortValidationError {
114 category: ModelEffortValidationCategory::EffortNotConfigurable,
115 message: format!(
116 "Model `{model}` on {provider_kind} does not expose configurable effort (requested `{effort}`)."
117 ),
118 }),
119 (None, None) => Ok(None),
120 (Some(reasoning), None) => {
121 if reasoning.mandatory {
122 Err(ModelEffortValidationError {
123 category: ModelEffortValidationCategory::EffortRequired,
124 message: format!(
125 "Model `{model}` on {provider_kind} requires an explicit effort. Available: {}",
126 reasoning.efforts.join(", ")
127 ),
128 })
129 } else {
130 Ok(None)
131 }
132 }
133 (Some(reasoning), Some(effort)) => {
134 if reasoning.efforts.is_empty() {
135 return Err(ModelEffortValidationError {
136 category: ModelEffortValidationCategory::EffortNotConfigurable,
137 message: format!(
138 "Model `{model}` on {provider_kind} does not expose configurable effort (requested `{effort}`)."
139 ),
140 });
141 }
142 match self.resolve_effort(effort) {
143 Some(resolved) if reasoning.efforts.contains(&resolved) => Ok(Some(resolved)),
144 _ => Err(ModelEffortValidationError {
145 category: ModelEffortValidationCategory::UnsupportedEffort,
146 message: format!(
147 "Unsupported effort `{effort}` for `{model}` on {provider_kind}. Available: {}",
148 reasoning.efforts.join(", ")
149 ),
150 }),
151 }
152 }
153 }
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 fn efforts() -> Vec<String> {
162 ["low", "medium", "high", "max"]
163 .into_iter()
164 .map(String::from)
165 .collect()
166 }
167
168 fn reasoning() -> ReasoningCapability {
169 ReasoningCapability {
170 efforts: efforts(),
171 default_effort: Some("medium".to_string()),
172 aliases: BTreeMap::new(),
173 encoding: ReasoningEncoding::Effort,
174 mandatory: false,
175 }
176 }
177
178 fn capability(reasoning: Option<ReasoningCapability>) -> ModelCapability {
179 ModelCapability { reasoning }
180 }
181
182 #[test]
183 fn is_empty_tracks_reasoning_presence() {
184 assert!(capability(None).is_empty());
185 assert!(!capability(Some(reasoning())).is_empty());
186 }
187
188 #[test]
189 fn resolve_effort_prefers_alias_then_membership() {
190 let mut r = reasoning();
191 r.aliases.insert("xhigh".to_string(), "max".to_string());
192 r.aliases.insert("minimal".to_string(), "low".to_string());
193 let cap = capability(Some(r));
194
195 assert_eq!(cap.resolve_effort("xhigh").as_deref(), Some("max"));
197 assert_eq!(cap.resolve_effort(" XHigh ").as_deref(), Some("max"));
198 assert_eq!(cap.resolve_effort("MINIMAL").as_deref(), Some("low"));
199 assert_eq!(cap.resolve_effort("high").as_deref(), Some("high"));
201 assert_eq!(cap.resolve_effort("turbo"), None);
203 }
204
205 #[test]
206 fn resolve_effort_none_when_no_reasoning() {
207 assert_eq!(capability(None).resolve_effort("low"), None);
208 }
209
210 #[test]
211 fn validate_effort_none_reasoning_with_request_is_not_configurable() {
212 let err = capability(None)
213 .validate_effort("m", "test", Some("low"))
214 .expect_err("no reasoning + request must fail");
215 assert_eq!(
216 err.category,
217 ModelEffortValidationCategory::EffortNotConfigurable
218 );
219 assert!(err.message.contains("does not expose configurable effort"));
220 assert!(err.message.contains("`low`"));
221 }
222
223 #[test]
224 fn validate_effort_none_reasoning_no_request_is_ok_none() {
225 assert_eq!(
226 capability(None).validate_effort("m", "test", None),
227 Ok(None)
228 );
229 }
230
231 #[test]
232 fn validate_effort_optional_reasoning_no_request_is_ok_none() {
233 assert_eq!(
234 capability(Some(reasoning())).validate_effort("m", "test", None),
235 Ok(None)
236 );
237 }
238
239 #[test]
240 fn validate_effort_mandatory_reasoning_no_request_is_required() {
241 let mut r = reasoning();
242 r.mandatory = true;
243 let err = capability(Some(r))
244 .validate_effort("m", "test", None)
245 .expect_err("mandatory + no request must fail");
246 assert_eq!(err.category, ModelEffortValidationCategory::EffortRequired);
247 assert!(err.message.contains("requires an explicit effort"));
248 assert!(err.message.contains("low, medium, high, max"));
249 }
250
251 #[test]
252 fn validate_effort_empty_efforts_is_not_configurable() {
253 let mut r = reasoning();
254 r.efforts.clear();
255 let err = capability(Some(r))
256 .validate_effort("m", "test", Some("low"))
257 .expect_err("empty efforts + request must fail");
258 assert_eq!(
259 err.category,
260 ModelEffortValidationCategory::EffortNotConfigurable
261 );
262 }
263
264 #[test]
265 fn validate_effort_alias_resolves_to_canonical() {
266 let mut r = reasoning();
267 r.aliases.insert("xhigh".to_string(), "max".to_string());
268 let resolved = capability(Some(r))
269 .validate_effort("m", "test", Some("xhigh"))
270 .expect("alias must resolve");
271 assert_eq!(resolved.as_deref(), Some("max"));
272 }
273
274 #[test]
275 fn validate_effort_direct_membership_ok() {
276 let resolved = capability(Some(reasoning()))
277 .validate_effort("m", "test", Some("High"))
278 .expect("membership must resolve");
279 assert_eq!(resolved.as_deref(), Some("high"));
280 }
281
282 #[test]
283 fn validate_effort_unknown_is_unsupported() {
284 let err = capability(Some(reasoning()))
285 .validate_effort("m", "test", Some("turbo"))
286 .expect_err("unknown effort must fail");
287 assert_eq!(
288 err.category,
289 ModelEffortValidationCategory::UnsupportedEffort
290 );
291 assert!(err.message.contains("Unsupported effort `turbo`"));
292 assert!(err.message.contains("low, medium, high, max"));
293 }
294
295 #[test]
296 fn validate_effort_alias_to_missing_effort_is_unsupported() {
297 let mut r = reasoning();
298 r.aliases
299 .insert("ludicrous".to_string(), "ludicrous".to_string());
300 let err = capability(Some(r))
301 .validate_effort("m", "test", Some("ludicrous"))
302 .expect_err("alias to non-existent effort must fail");
303 assert_eq!(
304 err.category,
305 ModelEffortValidationCategory::UnsupportedEffort
306 );
307 }
308
309 #[test]
310 fn category_codes_are_stable_snake_case() {
311 assert_eq!(
312 ModelEffortValidationCategory::UnsupportedEffort.code(),
313 "unsupported_effort"
314 );
315 assert_eq!(
316 ModelEffortValidationCategory::EffortNotConfigurable.code(),
317 "effort_not_configurable"
318 );
319 assert_eq!(
320 ModelEffortValidationCategory::EffortRequired.code(),
321 "effort_required"
322 );
323 }
324
325 #[test]
326 fn model_capability_serde_roundtrips_and_skips_empties() {
327 let cap = capability(None);
328 let json = serde_json::to_value(&cap).expect("serialize");
329 assert_eq!(json, serde_json::json!({}));
330 let back: ModelCapability = serde_json::from_value(json).expect("deserialize");
331 assert_eq!(back, cap);
332
333 let mut r = reasoning();
334 r.aliases.insert("xhigh".to_string(), "max".to_string());
335 r.encoding = ReasoningEncoding::Budget(BTreeMap::from([
336 ("low".to_string(), 1024u32),
337 ("high".to_string(), 8192u32),
338 ]));
339 let cap = capability(Some(r));
340 let json = serde_json::to_value(&cap).expect("serialize");
341 let back: ModelCapability = serde_json::from_value(json).expect("deserialize");
342 assert_eq!(back, cap);
343 }
344}