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 #[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 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 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}