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    /// Cache-control wire dialect accepted by this model on its selected route.
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub cache_control: Option<CacheControlDialect>,
21    /// How a streaming provider proves that this model's response completed.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub stream_termination: Option<StreamTermination>,
24}
25
26/// Host-supplied policy for interpreting a clean EOF on a provider stream.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
28#[serde(rename_all = "snake_case")]
29pub enum StreamTermination {
30    /// EOF is successful only after the dialect's semantic terminal event.
31    RequireTerminalEvidence,
32    /// Clean EOF is a valid completion boundary for this route.
33    EofTolerated,
34}
35
36/// How an OpenAI-compatible Chat Completions route places `cache_control`.
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
38#[serde(rename_all = "snake_case")]
39pub enum CacheControlDialect {
40    /// Anthropic-style canonical placement, including extended TTL support.
41    Anthropic,
42    /// A single ephemeral breakpoint with no extended TTL.
43    Gemini,
44}
45
46/// What reasoning/effort the model exposes and how effort maps onto the wire.
47#[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    /// requested-effort -> canonical-effort clamp map (e.g. "xhigh" -> "max", "minimal" -> "low")
54    #[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/// The host-resolved reasoning choice carried from model selection to providers.
65#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
66#[serde(rename_all = "snake_case")]
67pub enum ReasoningSelection {
68    /// Leave reasoning configuration entirely to the provider.
69    #[default]
70    ProviderDefault,
71    /// Explicitly disable reasoning using the model capability's disable encoding.
72    Disabled,
73    /// Request a named, capability-validated effort.
74    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/// How an explicit [`ReasoningSelection::Disabled`] is encoded on the wire.
87#[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/// How a resolved effort level is encoded on the wire.
98#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
99#[serde(rename_all = "snake_case")]
100pub enum ReasoningEncoding {
101    /// Named effort level sent as-is on the wire (Anthropic adaptive thinking, OpenAI reasoning.effort, Gemini thinkingLevel)
102    #[default]
103    Effort,
104    /// Effort name resolves to a token budget (Anthropic budget thinking, Gemini 2.5 thinkingBudget); map is effort -> tokens
105    Budget(BTreeMap<String, u32>),
106}
107
108/// Deterministic taxonomy of effort-validation failures. The serde snake_case
109/// codes are a stable contract: downstream consumers match on them.
110#[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    /// Stable snake_case code, matching the serde representation. Turn-driver
121    /// validation surfaces this as the turn-issue code.
122    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    /// Resolve a requested effort to its canonical form: alias-map first
154    /// (input lowercased/trimmed), then direct membership in `efforts`.
155    /// Returns `None` when the request maps to nothing this model exposes.
156    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    /// Validate a requested effort against this capability and return the
169    /// canonical (alias-normalized) effort to send on the wire. `Ok(None)`
170    /// means no effort is configured on the request.
171    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        // alias, including case/whitespace normalization
315        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        // direct membership
319        assert_eq!(cap.resolve_effort("high").as_deref(), Some("high"));
320        // neither
321        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}