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, skip_serializing_if = "Option::is_none")]
33    pub disable: Option<ReasoningDisableEncoding>,
34    #[serde(default)]
35    pub mandatory: bool,
36}
37
38/// The host-resolved reasoning choice carried from model selection to providers.
39#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
40#[serde(rename_all = "snake_case")]
41pub enum ReasoningSelection {
42    /// Leave reasoning configuration entirely to the provider.
43    #[default]
44    ProviderDefault,
45    /// Explicitly disable reasoning using the model capability's disable encoding.
46    Disabled,
47    /// Request a named, capability-validated effort.
48    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/// How an explicit [`ReasoningSelection::Disabled`] is encoded on the wire.
61#[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/// How a resolved effort level is encoded on the wire.
72#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
73#[serde(rename_all = "snake_case")]
74pub enum ReasoningEncoding {
75    /// Named effort level sent as-is on the wire (Anthropic adaptive thinking, OpenAI reasoning.effort, Gemini thinkingLevel)
76    #[default]
77    Effort,
78    /// Effort name resolves to a token budget (Anthropic budget thinking, Gemini 2.5 thinkingBudget); map is effort -> tokens
79    Budget(BTreeMap<String, u32>),
80}
81
82/// Deterministic taxonomy of effort-validation failures. The serde snake_case
83/// codes are a stable contract: downstream consumers match on them.
84#[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    /// Stable snake_case code, matching the serde representation. Turn-driver
94    /// validation surfaces this as the turn-issue code.
95    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    /// Resolve a requested effort to its canonical form: alias-map first
124    /// (input lowercased/trimmed), then direct membership in `efforts`.
125    /// Returns `None` when the request maps to nothing this model exposes.
126    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    /// Validate a requested effort against this capability and return the
139    /// canonical (alias-normalized) effort to send on the wire. `Ok(None)`
140    /// means no effort is configured on the request.
141    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        // alias, including case/whitespace normalization
252        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        // direct membership
256        assert_eq!(cap.resolve_effort("high").as_deref(), Some("high"));
257        // neither
258        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}