Skip to main content

use_ai_model/
lib.rs

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}