1use std::num::NonZeroUsize;
2
3use crate::provider::ModelCapability;
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, skip_serializing_if = "Option::is_none")]
10 pub variant: Option<String>,
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: None,
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: Option<String>,
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: Option<String>) -> 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: Option<String>,
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_constructors_preserve_identity_variant_and_limits() {
144 let limits = ModelLimits::from_token_limits(8_192, Some(1_024)).expect("valid limits");
145
146 let spec =
147 ModelSpec::with_limits("provider/model", Some("fast".to_string()), limits.clone());
148
149 assert_eq!(spec.id, "provider/model");
150 assert_eq!(spec.variant.as_deref(), Some("fast"));
151 assert_eq!(spec.limits, limits);
152
153 let changed = spec.clone().with_variant(Some("accurate".to_string()));
154 assert_eq!(changed.id, "provider/model");
155 assert_eq!(changed.variant.as_deref(), Some("accurate"));
156 assert_eq!(changed.limits, spec.limits);
157
158 let cleared = changed.with_variant(None);
159 assert_eq!(cleared.id, "provider/model");
160 assert_eq!(cleared.variant, None);
161 assert_eq!(cleared.context_window_tokens(), 8_192);
162 }
163
164 #[test]
165 fn model_token_limit_constructors_reject_zero_and_preserve_output_cap() {
166 let spec = ModelSpec::from_token_limits(
167 "provider/model",
168 Some("variant-a".to_string()),
169 200_000,
170 Some(4_096),
171 )
172 .expect("valid token limits");
173
174 assert_eq!(spec.id, "provider/model");
175 assert_eq!(spec.variant.as_deref(), Some("variant-a"));
176 assert_eq!(spec.context_window_tokens(), 200_000);
177 assert_eq!(
178 spec.limits.output_token_capacity.map(NonZeroUsize::get),
179 Some(4_096)
180 );
181
182 let context_error = ModelSpec::from_token_limits("bad-context", None, 0, Some(1))
183 .expect_err("zero context");
184 assert!(
185 context_error.contains("context_window_tokens"),
186 "context error should name the invalid field: {context_error}"
187 );
188
189 let output_error = ModelLimits::from_token_limits(1, Some(0)).expect_err("zero output cap");
190 assert!(
191 output_error.contains("output_token_capacity"),
192 "output error should name the invalid field: {output_error}"
193 );
194 }
195}