Skip to main content

lash_sansio/llm/
capability.rs

1//! Host-supplied model capability metadata.
2//!
3//! Capability is data the host attaches to a model spec and threads onto every
4//! [`LlmRequest`](crate::llm::types::LlmRequest). Lash validates a requested
5//! effort against it and normalizes (alias-clamps) the value before a provider
6//! sees it. Providers consume capability; they never produce it.
7
8use std::collections::BTreeMap;
9
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13/// Capability metadata for a single model on a route, supplied by the host.
14#[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/// What reasoning/effort the model exposes and how effort maps onto the wire.
21#[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    /// requested-effort -> canonical-effort clamp map (e.g. "xhigh" -> "max", "minimal" -> "low")
28    #[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/// How a resolved effort level is encoded on the wire.
37#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
38#[serde(rename_all = "snake_case")]
39pub enum ReasoningEncoding {
40    /// Named effort level sent as-is on the wire (Anthropic adaptive thinking, OpenAI reasoning.effort, Gemini thinkingLevel)
41    #[default]
42    Effort,
43    /// Effort name resolves to a token budget (Anthropic budget thinking, Gemini 2.5 thinkingBudget); map is effort -> tokens
44    Budget(BTreeMap<String, u32>),
45}
46
47/// Deterministic taxonomy of effort-validation failures. The serde snake_case
48/// codes are a stable contract: downstream consumers match on them.
49#[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    /// Stable snake_case code, matching the serde representation. Turn-driver
59    /// validation surfaces this as the turn-issue code.
60    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    /// Resolve a requested effort to its canonical form: alias-map first
89    /// (input lowercased/trimmed), then direct membership in `efforts`.
90    /// Returns `None` when the request maps to nothing this model exposes.
91    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    /// Validate a requested effort against this capability and return the
104    /// canonical (alias-normalized) effort to send on the wire. `Ok(None)`
105    /// means no effort is configured on the request.
106    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        // alias, including case/whitespace normalization
196        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        // direct membership
200        assert_eq!(cap.resolve_effort("high").as_deref(), Some("high"));
201        // neither
202        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}