1use parking_lot::RwLock;
28use std::collections::{HashMap, HashSet};
29use std::sync::{Arc, OnceLock};
30
31static LIVE_ROLE_REGISTRY: OnceLock<Arc<RwLock<RoleRegistry>>> = OnceLock::new();
37
38pub fn set_live_role_registry(registry: Arc<RwLock<RoleRegistry>>) {
41 let _ = LIVE_ROLE_REGISTRY.set(registry);
42}
43
44#[must_use]
46pub fn live_role_registry() -> Option<&'static Arc<RwLock<RoleRegistry>>> {
47 LIVE_ROLE_REGISTRY.get()
48}
49
50pub const ROLE_ALIAS_PREFIX: &str = "pi/";
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59pub enum ModelRole {
60 Default,
62 Smol,
64 Slow,
66 Vision,
68 Plan,
70 Designer,
72 Commit,
74 Title,
76 Task,
78 Advisor,
80}
81
82impl ModelRole {
83 pub const ALL: [ModelRole; 10] = [
85 ModelRole::Default,
86 ModelRole::Smol,
87 ModelRole::Slow,
88 ModelRole::Vision,
89 ModelRole::Plan,
90 ModelRole::Designer,
91 ModelRole::Commit,
92 ModelRole::Title,
93 ModelRole::Task,
94 ModelRole::Advisor,
95 ];
96
97 #[must_use]
99 pub const fn as_str(self) -> &'static str {
100 match self {
101 ModelRole::Default => "default",
102 ModelRole::Smol => "smol",
103 ModelRole::Slow => "slow",
104 ModelRole::Vision => "vision",
105 ModelRole::Plan => "plan",
106 ModelRole::Designer => "designer",
107 ModelRole::Commit => "commit",
108 ModelRole::Title => "title",
109 ModelRole::Task => "task",
110 ModelRole::Advisor => "advisor",
111 }
112 }
113
114 #[must_use]
116 pub fn from_id(s: &str) -> Option<Self> {
117 Some(match s {
118 "default" => ModelRole::Default,
119 "smol" => ModelRole::Smol,
120 "slow" => ModelRole::Slow,
121 "vision" => ModelRole::Vision,
122 "plan" => ModelRole::Plan,
123 "designer" => ModelRole::Designer,
124 "commit" => ModelRole::Commit,
125 "title" => ModelRole::Title,
126 "task" => ModelRole::Task,
127 "advisor" => ModelRole::Advisor,
128 _ => return None,
129 })
130 }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
135pub enum RoleColor {
136 Success,
138 Warning,
140 Accent,
142 Error,
144 #[default]
146 Muted,
147 Dim,
149}
150
151#[derive(Debug, Clone)]
153pub struct RoleInfo {
154 pub tag: Option<&'static str>,
156 pub name: &'static str,
158 pub color: RoleColor,
160 pub hidden: bool,
162}
163
164#[must_use]
166pub fn builtin_role_info(role: ModelRole) -> RoleInfo {
167 match role {
168 ModelRole::Default => RoleInfo {
169 tag: Some("DEFAULT"),
170 name: "Default",
171 color: RoleColor::Success,
172 hidden: false,
173 },
174 ModelRole::Smol => RoleInfo {
175 tag: Some("SMOL"),
176 name: "Fast",
177 color: RoleColor::Warning,
178 hidden: false,
179 },
180 ModelRole::Slow => RoleInfo {
181 tag: Some("SLOW"),
182 name: "Thinking",
183 color: RoleColor::Accent,
184 hidden: false,
185 },
186 ModelRole::Vision => RoleInfo {
187 tag: Some("VISION"),
188 name: "Vision",
189 color: RoleColor::Error,
190 hidden: false,
191 },
192 ModelRole::Plan => RoleInfo {
193 tag: Some("PLAN"),
194 name: "Architect",
195 color: RoleColor::Muted,
196 hidden: false,
197 },
198 ModelRole::Designer => RoleInfo {
199 tag: Some("DESIGNER"),
200 name: "Designer",
201 color: RoleColor::Muted,
202 hidden: false,
203 },
204 ModelRole::Commit => RoleInfo {
205 tag: Some("COMMIT"),
206 name: "Commit",
207 color: RoleColor::Dim,
208 hidden: false,
209 },
210 ModelRole::Title => RoleInfo {
211 tag: Some("TITLE"),
212 name: "Title",
213 color: RoleColor::Dim,
214 hidden: true,
215 },
216 ModelRole::Task => RoleInfo {
217 tag: Some("TASK"),
218 name: "Subtask",
219 color: RoleColor::Muted,
220 hidden: false,
221 },
222 ModelRole::Advisor => RoleInfo {
223 tag: Some("ADVISOR"),
224 name: "Advisor",
225 color: RoleColor::Accent,
226 hidden: false,
227 },
228 }
229}
230
231#[must_use]
235pub fn builtin_visible_ids() -> Vec<&'static str> {
236 ModelRole::ALL
237 .iter()
238 .filter(|r| !builtin_role_info(**r).hidden)
239 .map(|r| r.as_str())
240 .collect()
241}
242
243const THINKING_SUFFIXES: &[&str] = &["off", "minimal", "low", "medium", "high", "xhigh"];
248
249fn parse_role_alias(value: &str) -> Option<&str> {
255 let normalized = value.trim();
256 let candidate = normalized.strip_prefix(ROLE_ALIAS_PREFIX)?;
257 if ModelRole::from_id(candidate).is_some() {
259 Some(candidate)
260 } else {
261 None
262 }
263}
264
265fn split_thinking_suffix(pattern: &str) -> (&str, Option<&str>) {
272 let Some((base, suffix)) = pattern.rsplit_once(':') else {
273 return (pattern, None);
274 };
275 if THINKING_SUFFIXES.contains(&suffix) {
276 (base, Some(suffix))
277 } else {
278 (pattern, None)
279 }
280}
281
282fn normalize_pattern_list(value: &str) -> Vec<String> {
284 value
285 .split(',')
286 .map(str::trim)
287 .filter(|s| !s.is_empty())
288 .map(String::from)
289 .collect()
290}
291
292fn inherits_default(role: &str) -> bool {
295 matches!(role, "smol" | "slow" | "designer")
296}
297
298#[derive(Debug, Clone, Default)]
304pub struct RoleRegistry {
305 roles: HashMap<String, String>,
307}
308
309impl RoleRegistry {
310 #[must_use]
312 pub fn new() -> Self {
313 Self::default()
314 }
315
316 #[must_use]
318 pub fn from_map(roles: HashMap<String, String>) -> Self {
319 Self { roles }
320 }
321
322 #[must_use]
324 pub fn get(&self, role: &str) -> Option<&str> {
325 self.roles.get(role).map(String::as_str)
326 }
327
328 pub fn set(&mut self, role: impl Into<String>, model: impl Into<String>) {
330 self.roles.insert(role.into(), model.into());
331 }
332
333 #[must_use]
335 pub fn is_empty(&self) -> bool {
336 self.roles.is_empty()
337 }
338
339 pub fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
341 self.roles.iter()
342 }
343
344 #[must_use]
348 pub fn known_ids(&self) -> Vec<String> {
349 let mut out: Vec<String> = builtin_visible_ids()
350 .into_iter()
351 .map(String::from)
352 .collect();
353 let mut seen: HashSet<String> = out.iter().cloned().collect();
354 let mut customs: Vec<&String> = self.roles.keys().filter(|r| !seen.contains(*r)).collect();
355 customs.sort();
356 for role in customs {
357 seen.insert(role.clone());
358 out.push(role.clone());
359 }
360 out.into_iter()
361 .filter(|r| !seen_is_hidden(r))
362 .collect::<Vec<_>>()
363 }
364
365 #[must_use]
378 pub fn resolve(&self, role: &str) -> Vec<String> {
379 let mut visited: HashSet<String> = HashSet::new();
380 self.resolve_role(role, &mut visited)
381 }
382
383 #[must_use]
390 pub fn builtin_defaults(&self, _role: &str) -> Vec<String> {
391 Vec::new()
392 }
393
394 fn resolve_role(&self, role: &str, visited: &mut HashSet<String>) -> Vec<String> {
407 if visited.contains(role) {
408 return Vec::new();
409 }
410 visited.insert(role.to_string());
411
412 let role_defaults = self.builtin_defaults(role);
413
414 let raw: Vec<String> = if let Some(cfg) = self.roles.get(role) {
417 normalize_pattern_list(cfg)
418 } else if inherits_default(role) && self.roles.contains_key(ModelRole::Default.as_str()) {
419 normalize_pattern_list(&self.roles[ModelRole::Default.as_str()])
420 } else {
421 Vec::new()
422 };
423
424 let mut resolved = Vec::new();
425 for pattern in raw {
426 resolved.extend(self.expand_pattern(&pattern, visited));
427 }
428 if resolved.is_empty() {
429 resolved = role_defaults;
430 }
431 resolved
432 }
433
434 fn expand_pattern(&self, pattern: &str, visited: &mut HashSet<String>) -> Vec<String> {
438 let normalized = pattern.trim();
439 if normalized.is_empty() {
440 return Vec::new();
441 }
442 let (base, thinking_level) = split_thinking_suffix(normalized);
443 match parse_role_alias(base) {
444 None => vec![normalized.to_string()],
445 Some(alias) => {
446 let mut expanded = self.resolve_role(alias, visited);
447 if let Some(level) = thinking_level {
448 expanded = expanded
449 .into_iter()
450 .map(|p| format!("{p}:{level}"))
451 .collect();
452 }
453 expanded
454 }
455 }
456 }
457}
458
459fn seen_is_hidden(role: &str) -> bool {
461 ModelRole::from_id(role)
462 .map(|r| builtin_role_info(r).hidden)
463 .unwrap_or(false)
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 fn registry(pairs: &[(&str, &str)]) -> RoleRegistry {
471 let mut r = RoleRegistry::new();
472 for (role, model) in pairs {
473 r.set(*role, *model);
474 }
475 r
476 }
477
478 #[test]
479 fn role_str_roundtrip() {
480 for role in ModelRole::ALL {
481 let s = role.as_str();
482 assert_eq!(ModelRole::from_id(s), Some(role));
483 }
484 assert_eq!(ModelRole::from_id("custom"), None);
485 }
486
487 #[test]
488 fn builtin_metadata_matches_omp() {
489 assert_eq!(builtin_role_info(ModelRole::Smol).tag, Some("SMOL"));
490 assert!(builtin_role_info(ModelRole::Title).hidden);
491 assert_eq!(builtin_role_info(ModelRole::Commit).color, RoleColor::Dim);
492 assert!(!builtin_visible_ids().contains(&"title"));
493 assert!(builtin_visible_ids().contains(&"commit"));
494 assert_eq!(ModelRole::ALL.len(), 10);
495 }
496
497 #[test]
498 fn concrete_pattern_passes_through() {
499 let r = registry(&[("default", "anthropic/claude-sonnet-4")]);
500 assert_eq!(r.resolve("default"), vec!["anthropic/claude-sonnet-4"]);
501 }
502
503 #[test]
504 fn non_alias_pi_prefix_is_concrete() {
505 let r = registry(&[("default", "pi/mygateway/model")]);
507 assert_eq!(r.resolve("default"), vec!["pi/mygateway/model"]);
508 }
509
510 #[test]
511 fn cross_role_alias_expands() {
512 let r = registry(&[("default", "pi/slow"), ("slow", "anthropic/claude-opus")]);
514 assert_eq!(r.resolve("default"), vec!["anthropic/claude-opus"]);
515 }
516
517 #[test]
518 fn alias_preserves_thinking_suffix() {
519 let r = registry(&[
520 ("default", "pi/slow:high"),
521 ("slow", "anthropic/claude-opus"),
522 ]);
523 assert_eq!(r.resolve("default"), vec!["anthropic/claude-opus:high"]);
524 }
525
526 #[test]
527 fn cycle_terminates() {
528 let r = registry(&[("smol", "pi/slow"), ("slow", "pi/smol")]);
530 assert!(r.resolve("smol").is_empty());
533 }
534
535 #[test]
536 fn self_alias_collapses_to_builtin_defaults() {
537 let r = registry(&[("default", "pi/default")]);
539 assert!(r.resolve("default").is_empty());
540 }
541
542 #[test]
543 fn unset_smol_inherits_default() {
544 let r = registry(&[("default", "anthropic/claude-sonnet-4")]);
545 assert_eq!(r.resolve("smol"), vec!["anthropic/claude-sonnet-4"]);
547 }
548
549 #[test]
550 fn unset_non_inheriting_role_resolves_empty() {
551 let r = registry(&[("default", "anthropic/claude-sonnet-4")]);
553 assert!(r.resolve("commit").is_empty());
554 }
555
556 #[test]
557 fn smol_self_alias_via_default_collapses() {
558 let r = registry(&[("default", "pi/smol")]);
561 assert!(r.resolve("smol").is_empty());
562 }
563
564 #[test]
565 fn comma_list_normalizes() {
566 let r = registry(&[("default", "openai/gpt-4o, anthropic/claude-haiku")]);
567 assert_eq!(
568 r.resolve("default"),
569 vec!["openai/gpt-4o", "anthropic/claude-haiku"]
570 );
571 }
572
573 #[test]
574 fn openrouter_variant_suffix_not_split() {
575 let r = registry(&[("default", "openrouter/anthropic/claude-haiku:nitro")]);
577 assert_eq!(
578 r.resolve("default"),
579 vec!["openrouter/anthropic/claude-haiku:nitro"]
580 );
581 }
582
583 #[test]
584 fn custom_role_accepted() {
585 let r = registry(&[("myrole", "google/gemini-2.5-flash")]);
586 assert_eq!(r.get("myrole"), Some("google/gemini-2.5-flash"));
587 assert_eq!(r.resolve("myrole"), vec!["google/gemini-2.5-flash"]);
588 }
589
590 #[test]
591 fn known_ids_builtins_then_customs_sorted() {
592 let mut r = registry(&[("zebra", "a/b"), ("default", "c/d")]);
593 r.set("alpha", "e/f");
594 let ids = r.known_ids();
595 assert_eq!(ids.first(), Some(&"default".to_string()));
597 let custom_start = ids
598 .iter()
599 .position(|x| x == "alpha")
600 .expect("alpha present");
601 assert!(ids[custom_start..].contains(&"zebra".to_string()));
602 assert!(custom_start < ids.iter().position(|x| x == "zebra").unwrap());
603 assert!(!ids.contains(&"title".to_string()));
605 }
606
607 #[test]
608 fn resolve_unset_and_unconfigured_default_is_empty() {
609 let r = RoleRegistry::new();
610 assert!(r.resolve("default").is_empty());
611 assert!(r.resolve("smol").is_empty());
612 }
613}