use parking_lot::RwLock;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, OnceLock};
static LIVE_ROLE_REGISTRY: OnceLock<Arc<RwLock<RoleRegistry>>> = OnceLock::new();
pub fn set_live_role_registry(registry: Arc<RwLock<RoleRegistry>>) {
let _ = LIVE_ROLE_REGISTRY.set(registry);
}
#[must_use]
pub fn live_role_registry() -> Option<&'static Arc<RwLock<RoleRegistry>>> {
LIVE_ROLE_REGISTRY.get()
}
pub const ROLE_ALIAS_PREFIX: &str = "pi/";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ModelRole {
Default,
Smol,
Slow,
Vision,
Plan,
Designer,
Commit,
Title,
Task,
Advisor,
}
impl ModelRole {
pub const ALL: [ModelRole; 10] = [
ModelRole::Default,
ModelRole::Smol,
ModelRole::Slow,
ModelRole::Vision,
ModelRole::Plan,
ModelRole::Designer,
ModelRole::Commit,
ModelRole::Title,
ModelRole::Task,
ModelRole::Advisor,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
ModelRole::Default => "default",
ModelRole::Smol => "smol",
ModelRole::Slow => "slow",
ModelRole::Vision => "vision",
ModelRole::Plan => "plan",
ModelRole::Designer => "designer",
ModelRole::Commit => "commit",
ModelRole::Title => "title",
ModelRole::Task => "task",
ModelRole::Advisor => "advisor",
}
}
#[must_use]
pub fn from_id(s: &str) -> Option<Self> {
Some(match s {
"default" => ModelRole::Default,
"smol" => ModelRole::Smol,
"slow" => ModelRole::Slow,
"vision" => ModelRole::Vision,
"plan" => ModelRole::Plan,
"designer" => ModelRole::Designer,
"commit" => ModelRole::Commit,
"title" => ModelRole::Title,
"task" => ModelRole::Task,
"advisor" => ModelRole::Advisor,
_ => return None,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RoleColor {
Success,
Warning,
Accent,
Error,
#[default]
Muted,
Dim,
}
#[derive(Debug, Clone)]
pub struct RoleInfo {
pub tag: Option<&'static str>,
pub name: &'static str,
pub color: RoleColor,
pub hidden: bool,
}
#[must_use]
pub fn builtin_role_info(role: ModelRole) -> RoleInfo {
match role {
ModelRole::Default => RoleInfo {
tag: Some("DEFAULT"),
name: "Default",
color: RoleColor::Success,
hidden: false,
},
ModelRole::Smol => RoleInfo {
tag: Some("SMOL"),
name: "Fast",
color: RoleColor::Warning,
hidden: false,
},
ModelRole::Slow => RoleInfo {
tag: Some("SLOW"),
name: "Thinking",
color: RoleColor::Accent,
hidden: false,
},
ModelRole::Vision => RoleInfo {
tag: Some("VISION"),
name: "Vision",
color: RoleColor::Error,
hidden: false,
},
ModelRole::Plan => RoleInfo {
tag: Some("PLAN"),
name: "Architect",
color: RoleColor::Muted,
hidden: false,
},
ModelRole::Designer => RoleInfo {
tag: Some("DESIGNER"),
name: "Designer",
color: RoleColor::Muted,
hidden: false,
},
ModelRole::Commit => RoleInfo {
tag: Some("COMMIT"),
name: "Commit",
color: RoleColor::Dim,
hidden: false,
},
ModelRole::Title => RoleInfo {
tag: Some("TITLE"),
name: "Title",
color: RoleColor::Dim,
hidden: true,
},
ModelRole::Task => RoleInfo {
tag: Some("TASK"),
name: "Subtask",
color: RoleColor::Muted,
hidden: false,
},
ModelRole::Advisor => RoleInfo {
tag: Some("ADVISOR"),
name: "Advisor",
color: RoleColor::Accent,
hidden: false,
},
}
}
#[must_use]
pub fn builtin_visible_ids() -> Vec<&'static str> {
ModelRole::ALL
.iter()
.filter(|r| !builtin_role_info(**r).hidden)
.map(|r| r.as_str())
.collect()
}
const THINKING_SUFFIXES: &[&str] = &["off", "minimal", "low", "medium", "high", "xhigh"];
fn parse_role_alias(value: &str) -> Option<&str> {
let normalized = value.trim();
let candidate = normalized.strip_prefix(ROLE_ALIAS_PREFIX)?;
if ModelRole::from_id(candidate).is_some() {
Some(candidate)
} else {
None
}
}
fn split_thinking_suffix(pattern: &str) -> (&str, Option<&str>) {
let Some((base, suffix)) = pattern.rsplit_once(':') else {
return (pattern, None);
};
if THINKING_SUFFIXES.contains(&suffix) {
(base, Some(suffix))
} else {
(pattern, None)
}
}
fn normalize_pattern_list(value: &str) -> Vec<String> {
value
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect()
}
fn inherits_default(role: &str) -> bool {
matches!(role, "smol" | "slow" | "designer")
}
#[derive(Debug, Clone, Default)]
pub struct RoleRegistry {
roles: HashMap<String, String>,
}
impl RoleRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn from_map(roles: HashMap<String, String>) -> Self {
Self { roles }
}
#[must_use]
pub fn get(&self, role: &str) -> Option<&str> {
self.roles.get(role).map(String::as_str)
}
pub fn set(&mut self, role: impl Into<String>, model: impl Into<String>) {
self.roles.insert(role.into(), model.into());
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.roles.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
self.roles.iter()
}
#[must_use]
pub fn known_ids(&self) -> Vec<String> {
let mut out: Vec<String> = builtin_visible_ids()
.into_iter()
.map(String::from)
.collect();
let mut seen: HashSet<String> = out.iter().cloned().collect();
let mut customs: Vec<&String> = self.roles.keys().filter(|r| !seen.contains(*r)).collect();
customs.sort();
for role in customs {
seen.insert(role.clone());
out.push(role.clone());
}
out.into_iter()
.filter(|r| !seen_is_hidden(r))
.collect::<Vec<_>>()
}
#[must_use]
pub fn resolve(&self, role: &str) -> Vec<String> {
let mut visited: HashSet<String> = HashSet::new();
self.resolve_role(role, &mut visited)
}
#[must_use]
pub fn builtin_defaults(&self, _role: &str) -> Vec<String> {
Vec::new()
}
fn resolve_role(&self, role: &str, visited: &mut HashSet<String>) -> Vec<String> {
if visited.contains(role) {
return Vec::new();
}
visited.insert(role.to_string());
let role_defaults = self.builtin_defaults(role);
let raw: Vec<String> = if let Some(cfg) = self.roles.get(role) {
normalize_pattern_list(cfg)
} else if inherits_default(role) && self.roles.contains_key(ModelRole::Default.as_str()) {
normalize_pattern_list(&self.roles[ModelRole::Default.as_str()])
} else {
Vec::new()
};
let mut resolved = Vec::new();
for pattern in raw {
resolved.extend(self.expand_pattern(&pattern, visited));
}
if resolved.is_empty() {
resolved = role_defaults;
}
resolved
}
fn expand_pattern(&self, pattern: &str, visited: &mut HashSet<String>) -> Vec<String> {
let normalized = pattern.trim();
if normalized.is_empty() {
return Vec::new();
}
let (base, thinking_level) = split_thinking_suffix(normalized);
match parse_role_alias(base) {
None => vec![normalized.to_string()],
Some(alias) => {
let mut expanded = self.resolve_role(alias, visited);
if let Some(level) = thinking_level {
expanded = expanded
.into_iter()
.map(|p| format!("{p}:{level}"))
.collect();
}
expanded
}
}
}
}
fn seen_is_hidden(role: &str) -> bool {
ModelRole::from_id(role)
.map(|r| builtin_role_info(r).hidden)
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
fn registry(pairs: &[(&str, &str)]) -> RoleRegistry {
let mut r = RoleRegistry::new();
for (role, model) in pairs {
r.set(*role, *model);
}
r
}
#[test]
fn role_str_roundtrip() {
for role in ModelRole::ALL {
let s = role.as_str();
assert_eq!(ModelRole::from_id(s), Some(role));
}
assert_eq!(ModelRole::from_id("custom"), None);
}
#[test]
fn builtin_metadata_matches_omp() {
assert_eq!(builtin_role_info(ModelRole::Smol).tag, Some("SMOL"));
assert!(builtin_role_info(ModelRole::Title).hidden);
assert_eq!(builtin_role_info(ModelRole::Commit).color, RoleColor::Dim);
assert!(!builtin_visible_ids().contains(&"title"));
assert!(builtin_visible_ids().contains(&"commit"));
assert_eq!(ModelRole::ALL.len(), 10);
}
#[test]
fn concrete_pattern_passes_through() {
let r = registry(&[("default", "anthropic/claude-sonnet-4")]);
assert_eq!(r.resolve("default"), vec!["anthropic/claude-sonnet-4"]);
}
#[test]
fn non_alias_pi_prefix_is_concrete() {
let r = registry(&[("default", "pi/mygateway/model")]);
assert_eq!(r.resolve("default"), vec!["pi/mygateway/model"]);
}
#[test]
fn cross_role_alias_expands() {
let r = registry(&[("default", "pi/slow"), ("slow", "anthropic/claude-opus")]);
assert_eq!(r.resolve("default"), vec!["anthropic/claude-opus"]);
}
#[test]
fn alias_preserves_thinking_suffix() {
let r = registry(&[
("default", "pi/slow:high"),
("slow", "anthropic/claude-opus"),
]);
assert_eq!(r.resolve("default"), vec!["anthropic/claude-opus:high"]);
}
#[test]
fn cycle_terminates() {
let r = registry(&[("smol", "pi/slow"), ("slow", "pi/smol")]);
assert!(r.resolve("smol").is_empty());
}
#[test]
fn self_alias_collapses_to_builtin_defaults() {
let r = registry(&[("default", "pi/default")]);
assert!(r.resolve("default").is_empty());
}
#[test]
fn unset_smol_inherits_default() {
let r = registry(&[("default", "anthropic/claude-sonnet-4")]);
assert_eq!(r.resolve("smol"), vec!["anthropic/claude-sonnet-4"]);
}
#[test]
fn unset_non_inheriting_role_resolves_empty() {
let r = registry(&[("default", "anthropic/claude-sonnet-4")]);
assert!(r.resolve("commit").is_empty());
}
#[test]
fn smol_self_alias_via_default_collapses() {
let r = registry(&[("default", "pi/smol")]);
assert!(r.resolve("smol").is_empty());
}
#[test]
fn comma_list_normalizes() {
let r = registry(&[("default", "openai/gpt-4o, anthropic/claude-haiku")]);
assert_eq!(
r.resolve("default"),
vec!["openai/gpt-4o", "anthropic/claude-haiku"]
);
}
#[test]
fn openrouter_variant_suffix_not_split() {
let r = registry(&[("default", "openrouter/anthropic/claude-haiku:nitro")]);
assert_eq!(
r.resolve("default"),
vec!["openrouter/anthropic/claude-haiku:nitro"]
);
}
#[test]
fn custom_role_accepted() {
let r = registry(&[("myrole", "google/gemini-2.5-flash")]);
assert_eq!(r.get("myrole"), Some("google/gemini-2.5-flash"));
assert_eq!(r.resolve("myrole"), vec!["google/gemini-2.5-flash"]);
}
#[test]
fn known_ids_builtins_then_customs_sorted() {
let mut r = registry(&[("zebra", "a/b"), ("default", "c/d")]);
r.set("alpha", "e/f");
let ids = r.known_ids();
assert_eq!(ids.first(), Some(&"default".to_string()));
let custom_start = ids
.iter()
.position(|x| x == "alpha")
.expect("alpha present");
assert!(ids[custom_start..].contains(&"zebra".to_string()));
assert!(custom_start < ids.iter().position(|x| x == "zebra").unwrap());
assert!(!ids.contains(&"title".to_string()));
}
#[test]
fn resolve_unset_and_unconfigured_default_is_empty() {
let r = RoleRegistry::new();
assert!(r.resolve("default").is_empty());
assert!(r.resolve("smol").is_empty());
}
}