1use std::fmt;
2
3use crate::ProtocolError;

A pinned model version such as jev-1.13.0: a name, then a major.minor.patch version (docs.typesafe.ai/models, 2026-09-22). Aliases (jev-latest, jev-preview, any future one) are refused: the vendor says an alias "moves when a new release ships, so the answers behind it can change without a change on your side".

10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct ModelId(String);
13impl ModelId {
14    pub fn pinned(id: &str) -> Result<Self, ProtocolError> {
15        let id = id.trim();
16        if id.is_empty() {
17            return Err(ProtocolError::Invalid("the model id is empty".into()));
18        }
19        if !is_versioned(id) {
20            return Err(ProtocolError::Invalid(format!(
21                "{id} is an alias; pin a versioned model such as jev-1.13.0"
22            )));
23        }
24        Ok(ModelId(id.to_owned()))
25    }
26
27    pub fn as_str(&self) -> &str {
28        &self.0
29    }
30}
31
32impl fmt::Display for ModelId {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        f.write_str(&self.0)
35    }
36}

<name>-<major>.<minor>.<patch>, with an optional -<suffix>.

39fn is_versioned(id: &str) -> bool {
40    let Some((name, rest)) = id.split_once('-') else { return false };
41    let version = rest.split_once('-').map_or(rest, |(v, _)| v);
42    let parts: Vec<&str> = version.split('.').collect();
43    !name.is_empty()
44        && parts.len() == 3
45        && parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
46}
48#[cfg(test)]
49mod tests {
50    use super::*;
51
52    #[test]
53    fn refuses_aliases() {
54        for id in ["jev-1.13.0", "jev-2.0.0-rc1"] {
55            assert!(ModelId::pinned(id).is_ok(), "{id}");
56        }
57        for alias in ["jev-latest", "jev-preview", "jev-stable", "jev", "jev-1.13", "jev-1.x.0", ""] {
58            assert!(ModelId::pinned(alias).is_err(), "{alias}");
59        }
60    }
61}