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    MalformedCapability,
91}
92
93impl ModelEffortValidationCategory {
94    /// Stable snake_case code, matching the serde representation. Turn-driver
95    /// validation surfaces this as the turn-issue code.
96    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    /// Resolve a requested effort to its canonical form: alias-map first
126    /// (input lowercased/trimmed), then direct membership in `efforts`.
127    /// Returns `None` when the request maps to nothing this model exposes.
128    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    /// Validate a requested effort against this capability and return the
141    /// canonical (alias-normalized) effort to send on the wire. `Ok(None)`
142    /// means no effort is configured on the request.
143    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        // alias, including case/whitespace normalization
269        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        // direct membership
273        assert_eq!(cap.resolve_effort("high").as_deref(), Some("high"));
274        // neither
275        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}