Skip to main content

perspt_sdk/model/
id.rs

1//! Fully qualified model identity (PSP-9 system 1).
2//!
3//! Routing, calibration strata, verdict attribution, and replay all need to
4//! name a model without knowing what a client is. A bare model-name string
5//! cannot do that once several providers are live, so the identifier carries
6//! both halves and is rendered `provider::model`.
7
8use std::fmt;
9use std::str::FromStr;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::SdkError;
14
15/// A fully qualified model: which configured provider serves it, and which
16/// provider-native model name.
17///
18/// `provider` is the *configuration key* from the `[providers]` table (e.g.
19/// `"anthropic"`, `"local"`), not a vendor family — `family` classification is
20/// [`super::ModelFamily`]'s job. Route values stay fully qualified so
21/// identity, calibration, and replay never depend on an ambient default
22/// provider.
23#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
24pub struct ModelId {
25    /// Provider key from configuration: `"anthropic"`, `"openai"`, `"local"`.
26    pub provider: String,
27    /// Provider-native model name, passed through verbatim.
28    pub model: String,
29}
30
31impl ModelId {
32    /// Build an id from its two halves.
33    pub fn new(provider: impl Into<String>, model: impl Into<String>) -> Self {
34        Self {
35            provider: provider.into(),
36            model: model.into(),
37        }
38    }
39}
40
41impl fmt::Display for ModelId {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        write!(f, "{}::{}", self.provider, self.model)
44    }
45}
46
47impl FromStr for ModelId {
48    type Err = SdkError;
49
50    /// Parse `provider::model`. Both halves are required and non-empty; a
51    /// bare model name is rejected because an ambient default provider is
52    /// exactly what fully qualified routes exist to rule out.
53    fn from_str(s: &str) -> Result<Self, Self::Err> {
54        let (provider, model) = s.split_once("::").ok_or_else(|| {
55            SdkError::Domain(format!(
56                "model id {s:?} must be fully qualified as provider::model"
57            ))
58        })?;
59        if provider.is_empty() || model.is_empty() {
60            return Err(SdkError::Domain(format!(
61                "model id {s:?} has an empty provider or model half"
62            )));
63        }
64        Ok(Self::new(provider, model))
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn renders_and_parses_fully_qualified_ids() {
74        let id = ModelId::new("anthropic", "claude-opus-5");
75        assert_eq!(id.to_string(), "anthropic::claude-opus-5");
76        assert_eq!("anthropic::claude-opus-5".parse::<ModelId>().unwrap(), id);
77    }
78
79    #[test]
80    fn model_halves_may_contain_separators() {
81        // Vertex-style names carry slashes; only the FIRST `::` splits.
82        let id: ModelId = "vertex::publishers/google/models/gemini".parse().unwrap();
83        assert_eq!(id.provider, "vertex");
84        assert_eq!(id.model, "publishers/google/models/gemini");
85    }
86
87    #[test]
88    fn rejects_bare_names_and_empty_halves() {
89        assert!("claude-opus-5".parse::<ModelId>().is_err());
90        assert!("::model".parse::<ModelId>().is_err());
91        assert!("provider::".parse::<ModelId>().is_err());
92    }
93}