1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7pub mod prelude {
8 pub use crate::{
9 AiModelContextWindow, AiModelDeploymentKind, AiModelError, AiModelFamily, AiModelId,
10 AiModelInterfaceKind, AiModelKind, AiModelLifecycleStage, AiModelModality, AiModelName,
11 AiModelOutputLimit, AiModelReasoningMode,
12 };
13}
14
15macro_rules! model_text_newtype {
16 ($name:ident) => {
17 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
18 pub struct $name(String);
19
20 impl $name {
21 pub fn new(value: impl AsRef<str>) -> Result<Self, AiModelError> {
22 non_empty_text(value).map(Self)
23 }
24
25 pub fn as_str(&self) -> &str {
26 &self.0
27 }
28
29 pub fn value(&self) -> &str {
30 self.as_str()
31 }
32
33 pub fn into_string(self) -> String {
34 self.0
35 }
36 }
37
38 impl AsRef<str> for $name {
39 fn as_ref(&self) -> &str {
40 self.as_str()
41 }
42 }
43
44 impl fmt::Display for $name {
45 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46 formatter.write_str(self.as_str())
47 }
48 }
49
50 impl FromStr for $name {
51 type Err = AiModelError;
52
53 fn from_str(value: &str) -> Result<Self, Self::Err> {
54 Self::new(value)
55 }
56 }
57
58 impl TryFrom<&str> for $name {
59 type Error = AiModelError;
60
61 fn try_from(value: &str) -> Result<Self, Self::Error> {
62 Self::new(value)
63 }
64 }
65 };
66}
67
68macro_rules! model_positive_u32 {
69 ($name:ident) => {
70 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
71 pub struct $name(u32);
72
73 impl $name {
74 pub fn new(value: u32) -> Result<Self, AiModelError> {
75 if value == 0 {
76 Err(AiModelError::Zero)
77 } else {
78 Ok(Self(value))
79 }
80 }
81
82 pub const fn value(self) -> u32 {
83 self.0
84 }
85
86 pub const fn get(self) -> u32 {
87 self.0
88 }
89 }
90 };
91}
92
93macro_rules! model_enum {
94 ($name:ident { $($variant:ident => $label:literal),+ $(,)? }) => {
95 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
96 pub enum $name {
97 $($variant),+
98 }
99
100 impl $name {
101 pub const ALL: &'static [Self] = &[$(Self::$variant),+];
102
103 pub const fn as_str(self) -> &'static str {
104 match self {
105 $(Self::$variant => $label),+
106 }
107 }
108 }
109
110 impl fmt::Display for $name {
111 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
112 formatter.write_str(self.as_str())
113 }
114 }
115
116 impl FromStr for $name {
117 type Err = AiModelError;
118
119 fn from_str(value: &str) -> Result<Self, Self::Err> {
120 match normalized_label(value)?.as_str() {
121 $($label => Ok(Self::$variant),)+
122 _ => Err(AiModelError::UnknownLabel),
123 }
124 }
125 }
126 };
127}
128
129model_text_newtype!(AiModelName);
130model_text_newtype!(AiModelId);
131model_text_newtype!(AiModelFamily);
132model_positive_u32!(AiModelContextWindow);
133model_positive_u32!(AiModelOutputLimit);
134
135model_enum!(AiModelKind {
136 Chat => "chat",
137 Completion => "completion",
138 Embedding => "embedding",
139 Reranker => "reranker",
140 ImageGeneration => "image-generation",
141 ImageEditing => "image-editing",
142 SpeechToText => "speech-to-text",
143 TextToSpeech => "text-to-speech",
144 Multimodal => "multimodal",
145 ToolUsing => "tool-using",
146 Reasoning => "reasoning",
147 Custom => "custom",
148});
149
150model_enum!(AiModelModality {
151 Text => "text",
152 Image => "image",
153 Audio => "audio",
154 Video => "video",
155 Code => "code",
156 Embedding => "embedding",
157 Multimodal => "multimodal",
158 Custom => "custom",
159});
160
161model_enum!(AiModelInterfaceKind {
162 ChatCompletions => "chat-completions",
163 Responses => "responses",
164 Completions => "completions",
165 Embeddings => "embeddings",
166 Realtime => "realtime",
167 Batch => "batch",
168 Custom => "custom",
169});
170
171model_enum!(AiModelReasoningMode {
172 None => "none",
173 Hidden => "hidden",
174 SummaryOnly => "summary-only",
175 VisibleScratchpad => "visible-scratchpad",
176 ToolAugmented => "tool-augmented",
177 Unknown => "unknown",
178});
179
180model_enum!(AiModelDeploymentKind {
181 HostedApi => "hosted-api",
182 SelfHosted => "self-hosted",
183 Local => "local",
184 Edge => "edge",
185 Browser => "browser",
186 Embedded => "embedded",
187 Unknown => "unknown",
188});
189
190model_enum!(AiModelLifecycleStage {
191 Experimental => "experimental",
192 Preview => "preview",
193 Stable => "stable",
194 Deprecated => "deprecated",
195 Retired => "retired",
196 Unknown => "unknown",
197});
198
199#[derive(Clone, Copy, Debug, Eq, PartialEq)]
200pub enum AiModelError {
201 Empty,
202 Zero,
203 UnknownLabel,
204}
205
206impl fmt::Display for AiModelError {
207 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
208 match self {
209 Self::Empty => formatter.write_str("AI model metadata text cannot be empty"),
210 Self::Zero => formatter.write_str("AI model numeric value must be positive"),
211 Self::UnknownLabel => formatter.write_str("unknown AI model metadata label"),
212 }
213 }
214}
215
216impl Error for AiModelError {}
217
218fn non_empty_text(value: impl AsRef<str>) -> Result<String, AiModelError> {
219 let trimmed = value.as_ref().trim();
220 if trimmed.is_empty() {
221 Err(AiModelError::Empty)
222 } else {
223 Ok(trimmed.to_string())
224 }
225}
226
227fn normalized_label(value: &str) -> Result<String, AiModelError> {
228 let trimmed = value.trim();
229 if trimmed.is_empty() {
230 Err(AiModelError::Empty)
231 } else {
232 Ok(trimmed.to_ascii_lowercase().replace(['_', ' '], "-"))
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::{
239 AiModelContextWindow, AiModelDeploymentKind, AiModelError, AiModelFamily, AiModelId,
240 AiModelInterfaceKind, AiModelKind, AiModelLifecycleStage, AiModelModality, AiModelName,
241 AiModelOutputLimit, AiModelReasoningMode,
242 };
243 use core::{fmt, str::FromStr};
244
245 macro_rules! assert_text_newtype {
246 ($type:ty, $value:literal) => {{
247 let value = <$type>::new(concat!(" ", $value, " "))?;
248 assert_eq!(value.as_str(), $value);
249 assert_eq!(value.value(), $value);
250 assert_eq!(value.as_ref(), $value);
251 assert_eq!(value.to_string(), $value);
252 assert_eq!(<$type as TryFrom<&str>>::try_from($value)?, value);
253 assert_eq!(value.into_string(), $value.to_string());
254 }};
255 }
256
257 fn assert_enum_family<T>(variants: &[T]) -> Result<(), AiModelError>
258 where
259 T: Copy + Eq + fmt::Debug + fmt::Display + FromStr<Err = AiModelError>,
260 {
261 for variant in variants {
262 let label = variant.to_string();
263 assert_eq!(label.parse::<T>()?, *variant);
264 assert_eq!(label.replace('-', "_").parse::<T>()?, *variant);
265 assert_eq!(label.replace('-', " ").parse::<T>()?, *variant);
266 }
267 Ok(())
268 }
269
270 #[test]
271 fn validates_model_text_newtypes() -> Result<(), AiModelError> {
272 assert_text_newtype!(AiModelName, "reasoning-chat");
273 assert_text_newtype!(AiModelId, "model-001");
274 assert_text_newtype!(AiModelFamily, "reasoning");
275 assert_eq!(AiModelName::new(" "), Err(AiModelError::Empty));
276 Ok(())
277 }
278
279 #[test]
280 fn validates_model_limits() -> Result<(), AiModelError> {
281 let context = AiModelContextWindow::new(128_000)?;
282 let output = AiModelOutputLimit::new(4_096)?;
283
284 assert_eq!(context.value(), 128_000);
285 assert_eq!(output.get(), 4_096);
286 assert_eq!(AiModelContextWindow::new(0), Err(AiModelError::Zero));
287 assert_eq!(AiModelOutputLimit::new(0), Err(AiModelError::Zero));
288 Ok(())
289 }
290
291 #[test]
292 fn displays_and_parses_model_enums() -> Result<(), AiModelError> {
293 assert_enum_family(AiModelKind::ALL)?;
294 assert_enum_family(AiModelModality::ALL)?;
295 assert_enum_family(AiModelInterfaceKind::ALL)?;
296 assert_enum_family(AiModelReasoningMode::ALL)?;
297 assert_enum_family(AiModelDeploymentKind::ALL)?;
298 assert_enum_family(AiModelLifecycleStage::ALL)?;
299 assert_eq!(
300 "image generation".parse::<AiModelKind>()?,
301 AiModelKind::ImageGeneration
302 );
303 Ok(())
304 }
305}