aurum_core/provider_platform/
id.rs1use crate::error::{Result, UserError};
4use serde::{Deserialize, Serialize};
5use std::fmt;
6use std::str::FromStr;
7
8pub const MAX_PROVIDER_ID_LEN: usize = 32;
10
11#[derive(Clone, PartialEq, Eq, Hash, Serialize)]
19pub struct ProviderId(String);
20
21impl ProviderId {
22 pub fn parse(raw: &str) -> Result<Self> {
24 let trimmed = raw.trim();
25 if trimmed.is_empty() {
26 return Err(invalid("provider id must not be empty"));
27 }
28 if trimmed.len() > MAX_PROVIDER_ID_LEN {
29 return Err(invalid(&format!(
30 "provider id exceeds {MAX_PROVIDER_ID_LEN} characters"
31 )));
32 }
33 if trimmed.chars().any(|c| c.is_control() || c.is_whitespace()) {
34 return Err(invalid(
35 "provider id must not contain whitespace or control characters",
36 ));
37 }
38
39 let lowered = trimmed.to_ascii_lowercase();
40 let canonical = match lowered.as_str() {
41 "grok" => "xai".to_string(),
43 other => other.to_string(),
44 };
45
46 if !is_valid_canonical(&canonical) {
47 return Err(invalid(
48 "provider id must match [a-z][a-z0-9_]* (lowercase letters, digits, underscore)",
49 ));
50 }
51
52 if matches!(
54 canonical.as_str(),
55 "auto" | "default" | "none" | "null" | "any" | "all" | "system"
56 ) {
57 return Err(invalid(&format!("provider id '{canonical}' is reserved")));
58 }
59
60 Ok(Self(canonical))
61 }
62
63 pub fn must(raw: &str) -> Self {
65 Self::parse(raw).unwrap_or_else(|e| panic!("invalid ProviderId {raw:?}: {e}"))
66 }
67
68 pub fn as_str(&self) -> &str {
69 &self.0
70 }
71
72 pub fn local() -> Self {
73 Self::must("local")
74 }
75
76 pub fn openrouter() -> Self {
77 Self::must("openrouter")
78 }
79}
80
81fn is_valid_canonical(s: &str) -> bool {
82 let mut chars = s.chars();
83 match chars.next() {
84 Some(c) if c.is_ascii_lowercase() => {}
85 _ => return false,
86 }
87 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
88}
89
90fn invalid(reason: &str) -> crate::error::TranscriptionError {
91 UserError::InvalidConfig {
92 reason: reason.into(),
93 }
94 .into()
95}
96
97impl fmt::Debug for ProviderId {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 f.debug_tuple("ProviderId").field(&self.0).finish()
100 }
101}
102
103impl fmt::Display for ProviderId {
104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 f.write_str(&self.0)
106 }
107}
108
109impl AsRef<str> for ProviderId {
110 fn as_ref(&self) -> &str {
111 self.as_str()
112 }
113}
114
115impl FromStr for ProviderId {
116 type Err = crate::error::TranscriptionError;
117
118 fn from_str(s: &str) -> Result<Self> {
119 Self::parse(s)
120 }
121}
122
123impl<'de> Deserialize<'de> for ProviderId {
124 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
125 where
126 D: serde::Deserializer<'de>,
127 {
128 let s = String::deserialize(deserializer)?;
129 ProviderId::parse(&s).map_err(serde::de::Error::custom)
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn accepts_canonical_ids() {
139 assert_eq!(ProviderId::parse("local").unwrap().as_str(), "local");
140 assert_eq!(
141 ProviderId::parse("OpenRouter").unwrap().as_str(),
142 "openrouter"
143 );
144 assert_eq!(ProviderId::parse("xai").unwrap().as_str(), "xai");
145 assert_eq!(
146 ProviderId::parse("elevenlabs").unwrap().as_str(),
147 "elevenlabs"
148 );
149 }
150
151 #[test]
152 fn alias_grok_maps_to_xai() {
153 assert_eq!(ProviderId::parse("grok").unwrap().as_str(), "xai");
154 assert_eq!(ProviderId::parse("Grok").unwrap().as_str(), "xai");
155 }
156
157 #[test]
158 fn rejects_empty_and_controls() {
159 assert!(ProviderId::parse("").is_err());
160 assert!(ProviderId::parse(" ").is_err());
161 assert!(ProviderId::parse("loc al").is_err());
162 assert!(ProviderId::parse("loc\nal").is_err());
163 assert_eq!(ProviderId::parse(" local\n").unwrap().as_str(), "local");
165 assert!(ProviderId::parse("a/b").is_err());
166 assert!(ProviderId::parse("../x").is_err());
167 assert!(ProviderId::parse("1bad").is_err());
168 assert!(ProviderId::parse("-dash").is_err());
169 }
170
171 #[test]
172 fn rejects_reserved() {
173 assert!(ProviderId::parse("auto").is_err());
174 assert!(ProviderId::parse("default").is_err());
175 assert!(ProviderId::parse("none").is_err());
176 }
177
178 #[test]
179 fn rejects_oversized() {
180 let s = "a".repeat(MAX_PROVIDER_ID_LEN + 1);
181 assert!(ProviderId::parse(&s).is_err());
182 }
183
184 #[test]
185 fn serde_deserialize_uses_parse() {
186 let id: ProviderId = serde_json::from_str("\"OpenRouter\"").unwrap();
187 assert_eq!(id.as_str(), "openrouter");
188 let id: ProviderId = serde_json::from_str("\"grok\"").unwrap();
189 assert_eq!(id.as_str(), "xai");
190 assert!(serde_json::from_str::<ProviderId>("\"auto\"").is_err());
191 assert!(serde_json::from_str::<ProviderId>("\"a/b\"").is_err());
192 assert!(serde_json::from_str::<ProviderId>("\"1bad\"").is_err());
193 assert!(serde_json::from_str::<ProviderId>("\"loc al\"").is_err());
194 }
195}