Skip to main content

lash_core/
model.rs

1use std::num::NonZeroUsize;
2
3use crate::provider::{ModelCapability, ReasoningSelection};
4
5#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
6#[serde(deny_unknown_fields)]
7pub struct ModelSpec {
8    pub id: String,
9    #[serde(default)]
10    pub variant: ReasoningSelection,
11    pub limits: ModelLimits,
12    /// Host-supplied capability metadata: what effort levels the model exposes
13    /// and how they encode on the wire. Lash validates the requested variant
14    /// against this and threads it onto every request the provider receives.
15    #[serde(default, skip_serializing_if = "ModelCapability::is_empty")]
16    pub capability: ModelCapability,
17}
18
19impl ModelSpec {
20    pub fn new(id: impl Into<String>, context_window_tokens: NonZeroUsize) -> Self {
21        Self {
22            id: id.into(),
23            variant: ReasoningSelection::ProviderDefault,
24            limits: ModelLimits {
25                context_window_tokens,
26                output_token_capacity: None,
27            },
28            capability: ModelCapability::default(),
29        }
30    }
31
32    pub fn with_limits(
33        id: impl Into<String>,
34        variant: ReasoningSelection,
35        limits: ModelLimits,
36    ) -> Self {
37        Self {
38            id: id.into(),
39            variant,
40            limits,
41            capability: ModelCapability::default(),
42        }
43    }
44
45    pub fn with_variant(mut self, variant: ReasoningSelection) -> Self {
46        self.variant = variant;
47        self
48    }
49
50    pub fn with_capability(mut self, capability: ModelCapability) -> Self {
51        self.capability = capability;
52        self
53    }
54
55    /// Build a spec from the prompt budget (`context_window_tokens` — the
56    /// maximum input the provider accepts for this model on this route) and the
57    /// optional output cap. The budget is what history pruning and the UI
58    /// measure against.
59    pub fn from_token_limits(
60        id: impl Into<String>,
61        variant: ReasoningSelection,
62        context_window_tokens: usize,
63        output_token_capacity: Option<usize>,
64    ) -> Result<Self, String> {
65        Ok(Self::with_limits(
66            id,
67            variant,
68            ModelLimits::from_token_limits(context_window_tokens, output_token_capacity)?,
69        ))
70    }
71
72    pub fn context_window_tokens(&self) -> usize {
73        self.limits.context_window_tokens.get()
74    }
75}
76
77impl Default for ModelSpec {
78    fn default() -> Self {
79        Self::new(
80            String::new(),
81            NonZeroUsize::new(1).expect("one is non-zero"),
82        )
83    }
84}
85
86#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
87#[serde(deny_unknown_fields)]
88pub struct ModelLimits {
89    /// The prompt budget: the maximum input tokens the provider accepts for
90    /// this model on this route. History pruning and the UI measure against
91    /// this — not the model's total context (input + output), which would
92    /// over-budget by the whole response reservation.
93    pub context_window_tokens: NonZeroUsize,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub output_token_capacity: Option<NonZeroUsize>,
96}
97
98impl ModelLimits {
99    pub fn from_token_limits(
100        context_window_tokens: usize,
101        output_token_capacity: Option<usize>,
102    ) -> Result<Self, String> {
103        Ok(Self {
104            context_window_tokens: nonzero_token_limit(
105                "context_window_tokens",
106                context_window_tokens,
107            )?,
108            output_token_capacity: optional_nonzero_token_limit(
109                "output_token_capacity",
110                output_token_capacity,
111            )?,
112        })
113    }
114}
115
116impl Default for ModelLimits {
117    fn default() -> Self {
118        Self {
119            context_window_tokens: NonZeroUsize::new(1).expect("one is non-zero"),
120            output_token_capacity: None,
121        }
122    }
123}
124
125fn nonzero_token_limit(name: &str, value: usize) -> Result<NonZeroUsize, String> {
126    NonZeroUsize::new(value).ok_or_else(|| format!("{name} must be greater than zero"))
127}
128
129fn optional_nonzero_token_limit(
130    name: &str,
131    value: Option<usize>,
132) -> Result<Option<NonZeroUsize>, String> {
133    value
134        .map(|value| nonzero_token_limit(name, value))
135        .transpose()
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn model_spec_reasoning_selection_serde_is_explicit() {
144        for (selection, expected) in [
145            (
146                ReasoningSelection::ProviderDefault,
147                serde_json::json!("provider_default"),
148            ),
149            (ReasoningSelection::Disabled, serde_json::json!("disabled")),
150            (
151                ReasoningSelection::Effort("high".to_string()),
152                serde_json::json!({ "effort": "high" }),
153            ),
154        ] {
155            let spec = ModelSpec::new("model", NonZeroUsize::new(1024).expect("non-zero"))
156                .with_variant(selection.clone());
157            let json = serde_json::to_value(&spec).expect("serialize model spec");
158            assert_eq!(json["variant"], expected);
159            let round_trip: ModelSpec =
160                serde_json::from_value(json).expect("deserialize model spec");
161            assert_eq!(round_trip.variant, selection);
162        }
163    }
164
165    #[test]
166    fn model_spec_constructors_preserve_identity_variant_and_limits() {
167        let limits = ModelLimits::from_token_limits(8_192, Some(1_024)).expect("valid limits");
168
169        let spec = ModelSpec::with_limits(
170            "provider/model",
171            ReasoningSelection::Effort("fast".to_string()),
172            limits.clone(),
173        );
174
175        assert_eq!(spec.id, "provider/model");
176        assert_eq!(spec.variant, ReasoningSelection::Effort("fast".to_string()));
177        assert_eq!(spec.limits, limits);
178
179        let changed = spec
180            .clone()
181            .with_variant(ReasoningSelection::Effort("accurate".to_string()));
182        assert_eq!(changed.id, "provider/model");
183        assert_eq!(
184            changed.variant,
185            ReasoningSelection::Effort("accurate".to_string())
186        );
187        assert_eq!(changed.limits, spec.limits);
188
189        let cleared = changed.with_variant(ReasoningSelection::ProviderDefault);
190        assert_eq!(cleared.id, "provider/model");
191        assert_eq!(cleared.variant, ReasoningSelection::ProviderDefault);
192        assert_eq!(cleared.context_window_tokens(), 8_192);
193    }
194
195    #[test]
196    fn model_token_limit_constructors_reject_zero_and_preserve_output_cap() {
197        let spec = ModelSpec::from_token_limits(
198            "provider/model",
199            ReasoningSelection::Effort("variant-a".to_string()),
200            200_000,
201            Some(4_096),
202        )
203        .expect("valid token limits");
204
205        assert_eq!(spec.id, "provider/model");
206        assert_eq!(
207            spec.variant,
208            ReasoningSelection::Effort("variant-a".to_string())
209        );
210        assert_eq!(spec.context_window_tokens(), 200_000);
211        assert_eq!(
212            spec.limits.output_token_capacity.map(NonZeroUsize::get),
213            Some(4_096)
214        );
215
216        let context_error =
217            ModelSpec::from_token_limits("bad-context", Default::default(), 0, Some(1))
218                .expect_err("zero context");
219        assert!(
220            context_error.contains("context_window_tokens"),
221            "context error should name the invalid field: {context_error}"
222        );
223
224        let output_error = ModelLimits::from_token_limits(1, Some(0)).expect_err("zero output cap");
225        assert!(
226            output_error.contains("output_token_capacity"),
227            "output error should name the invalid field: {output_error}"
228        );
229    }
230}