Skip to main content

oxi_ai/
roles.rs

1//! Model roles — named model assignments ported from omp's `model-roles.ts`.
2//!
3//! Pure data + string resolution. There is **no** `model_db` / `Model`
4//! dependency here: this module resolves a role to one or more model
5//! *pattern strings* (e.g. `"anthropic/claude-haiku"`, `"pi/slow:high"`).
6//! Converting those strings into a concrete [`crate::Model`] — and the
7//! role-switching layer that decides *which* role is active — is the
8//! consumer's job (deferred).
9//!
10//! Ported from omp `packages/coding-agent/src/config/model-roles.ts` (the
11//! `ModelRole` type + `MODEL_ROLES` metadata table) and the role-alias
12//! resolution in `packages/coding-agent/src/config/model-resolver.ts`
13//! (`resolveConfiguredRolePattern` / `resolveDefaultInheritedPatterns`).
14//!
15//! What is **not** ported: omp's `MODEL_PRIO` built-in default model chains.
16//! Those hardcode omp-catalog model ids that do not exist in oxi; oxi leaves
17//! built-in defaults empty (see [`RoleRegistry::builtin_defaults`]) for the
18//! switching layer to fill via oxi's own catalog / `FallbackChain`.
19
20//!
21//! # Attribution
22//!
23//! Translated to Rust from omp (oh-my-pi), which is MIT licensed
24//! (Copyright (c) 2025 Mario Zechner; Copyright (c) 2025-2026 Can Bölük;
25//! see the omp repository `LICENSE`). oxi's translation remains under oxi's
26//! own MIT license.
27use parking_lot::RwLock;
28use std::collections::{HashMap, HashSet};
29use std::sync::{Arc, OnceLock};
30
31// ── Live role registry ─────────────────────────────────────────────
32/// Process-wide live role registry, shared between the [`crate::role_routing`]
33/// provider and the settings UI. Installed once at bootstrap; mutated in place
34/// by the UI so edits apply to the next `stream()` call without rewrapping the
35/// provider (mirrors the global model-registry pattern).
36static LIVE_ROLE_REGISTRY: OnceLock<Arc<RwLock<RoleRegistry>>> = OnceLock::new();
37
38/// Install the live role registry. Called once at bootstrap; subsequent calls
39/// are no-ops (the first installation wins).
40pub fn set_live_role_registry(registry: Arc<RwLock<RoleRegistry>>) {
41    let _ = LIVE_ROLE_REGISTRY.set(registry);
42}
43
44/// Access the live role registry, if installed.
45#[must_use]
46pub fn live_role_registry() -> Option<&'static Arc<RwLock<RoleRegistry>>> {
47    LIVE_ROLE_REGISTRY.get()
48}
49
50/// Prefix marking a role-alias reference, ported from omp's `pi/`.
51///
52/// A model-pattern string of the form `pi/<role>` is treated as a reference
53/// to another role (e.g. `pi/smol`, `pi/slow:high`) and expanded by
54/// [`RoleRegistry::resolve`]. Any other string is a concrete model id.
55pub const ROLE_ALIAS_PREFIX: &str = "pi/";
56
57/// The 10 built-in model roles, ported verbatim from omp's `ModelRole`.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59pub enum ModelRole {
60    /// Main conversational model.
61    Default,
62    /// Small / fast / cheap model — background tasks, quick completions.
63    Smol,
64    /// Heavy reasoning ("thinking") model.
65    Slow,
66    /// Image-input ("vision") capable model.
67    Vision,
68    /// Architecture / planning model.
69    Plan,
70    /// Design-oriented model.
71    Designer,
72    /// Commit-message generation model.
73    Commit,
74    /// Session-title generation model (hidden from the selector UI).
75    Title,
76    /// Subagent / task delegation model.
77    Task,
78    /// Background advisor model.
79    Advisor,
80}
81
82impl ModelRole {
83    /// All built-in roles in omp declaration order (matches `MODEL_ROLE_IDS`).
84    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    /// Lowercase identifier, matching omp's role strings and config keys.
98    #[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    /// Parse a lowercase role id. Returns `None` for unknown / custom names.
115    #[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/// Role color tag, ported from omp's theme color names.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
135pub enum RoleColor {
136    /// `success` — default role.
137    Success,
138    /// `warning` — fast / cheap role.
139    Warning,
140    /// `accent` — reasoning / advisor role.
141    Accent,
142    /// `error` — vision role.
143    Error,
144    /// `muted` — planning / subtask roles. (Default for custom roles.)
145    #[default]
146    Muted,
147    /// `dim` — low-prominence roles (commit, title).
148    Dim,
149}
150
151/// Display metadata for a role, ported from omp's `ModelRoleInfo`.
152#[derive(Debug, Clone)]
153pub struct RoleInfo {
154    /// Short uppercase tag (e.g. `"SMOL"`), or `None` for custom roles.
155    pub tag: Option<&'static str>,
156    /// Human-readable name (e.g. `"Fast"`).
157    pub name: &'static str,
158    /// Theme color slot.
159    pub color: RoleColor,
160    /// If `true`, the role is functional but hidden from the selector UI.
161    pub hidden: bool,
162}
163
164/// Built-in metadata for a role — omp's `MODEL_ROLES` table.
165#[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/// Visible built-in role ids (hidden ones excluded), the base of omp's
232/// `getKnownRoleIds`. Custom roles configured by the user are appended by
233/// [`RoleRegistry::known_ids`].
234#[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
243/// Recognized thinking-level suffix keywords. A model pattern may carry a
244/// trailing `:<level>` (e.g. `pi/slow:high`) which is preserved through
245/// alias expansion and re-attached to the resolved pattern. Matches oxi's
246/// [`crate::ThinkingLevel`] variants.
247const THINKING_SUFFIXES: &[&str] = &["off", "minimal", "low", "medium", "high", "xhigh"];
248
249/// Parse a `pi/<role>` alias reference, ported from omp's `getModelRoleAlias`.
250///
251/// Returns the built-in role id when `value` is exactly `pi/<known-role>`.
252/// A `pi/<custom>` or non-`pi/` string is **not** an alias (returns `None`)
253/// and is treated as a concrete model pattern.
254fn parse_role_alias(value: &str) -> Option<&str> {
255    let normalized = value.trim();
256    let candidate = normalized.strip_prefix(ROLE_ALIAS_PREFIX)?;
257    // Only built-in role ids are aliases; custom role names are concrete ids.
258    if ModelRole::from_id(candidate).is_some() {
259        Some(candidate)
260    } else {
261        None
262    }
263}
264
265/// Split a trailing `:<level>` thinking suffix, ported from omp's
266/// `splitThinkingSuffix`. Only splits when the suffix after the *last* colon
267/// is a recognized thinking level — so provider routing variants like
268/// OpenRouter's `:nitro` / `:exacto` stay attached to the id.
269///
270/// Returns `(base, Some(level))` or `(original, None)`.
271fn 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
282/// Split a comma-separated pattern list into trimmed, non-empty entries.
283fn 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
292/// Whether an unset role inherits the `default` role's patterns before
293/// falling back, ported from omp's `shouldInheritDefaultBeforePriority`.
294fn inherits_default(role: &str) -> bool {
295    matches!(role, "smol" | "slow" | "designer")
296}
297
298/// Role registry: maps role names to configured model patterns, with
299/// `pi/<role>` alias expansion and cycle detection.
300///
301/// String-keyed so custom roles (beyond the 10 built-ins) are accepted, just
302/// like omp's `modelRoles: Record<string, string>`.
303#[derive(Debug, Clone, Default)]
304pub struct RoleRegistry {
305    /// Role name → model pattern (e.g. `"anthropic/claude-haiku"` or `"pi/slow:high"`).
306    roles: HashMap<String, String>,
307}
308
309impl RoleRegistry {
310    /// Create an empty registry.
311    #[must_use]
312    pub fn new() -> Self {
313        Self::default()
314    }
315
316    /// Build a registry from a `role → pattern` map (e.g. parsed from settings).
317    #[must_use]
318    pub fn from_map(roles: HashMap<String, String>) -> Self {
319        Self { roles }
320    }
321
322    /// Get the configured pattern for a role, if any.
323    #[must_use]
324    pub fn get(&self, role: &str) -> Option<&str> {
325        self.roles.get(role).map(String::as_str)
326    }
327
328    /// Assign a model pattern to a role.
329    pub fn set(&mut self, role: impl Into<String>, model: impl Into<String>) {
330        self.roles.insert(role.into(), model.into());
331    }
332
333    /// Whether any role is configured.
334    #[must_use]
335    pub fn is_empty(&self) -> bool {
336        self.roles.is_empty()
337    }
338
339    /// Iterate over `(role, pattern)` pairs.
340    pub fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
341        self.roles.iter()
342    }
343
344    /// Known role ids: visible built-ins first (in declaration order), then
345    /// any configured custom roles sorted by name — ported from omp's
346    /// `getKnownRoleIds`.
347    #[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    /// Resolve a role to concrete model pattern(s), expanding `pi/<role>`
366    /// aliases with cycle detection. Ported from omp's
367    /// `resolveConfiguredRolePattern`.
368    ///
369    /// - A directly-configured role yields its (alias-expanded) patterns.
370    /// - An unset `smol` / `slow` / `designer` inherits the `default` role's
371    ///   patterns (which themselves may alias another role).
372    /// - Self-aliases (`default = "pi/default"`) collapse to the built-in
373    ///   default chain (empty in oxi — see [`Self::builtin_defaults`]).
374    /// - A cycle (`a → pi/b`, `b → pi/a`) terminates, returning what
375    ///   resolved before the cycle closed.
376    /// - An entirely unset role with no inheritance returns an empty vec.
377    #[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    /// Built-in default model chain for a role.
384    ///
385    /// omp seeds these from `MODEL_PRIO` (omp-catalog ids). oxi returns an
386    /// empty list: the switching layer (deferred) fills defaults via oxi's
387    /// own catalog / `FallbackChain`. This keeps the *machinery* faithful
388    /// without embedding ids that don't exist in oxi.
389    #[must_use]
390    pub fn builtin_defaults(&self, _role: &str) -> Vec<String> {
391        Vec::new()
392    }
393
394    /// Resolve a role name to concrete model patterns. Cycle-safe.
395    ///
396    /// Raw patterns come from the configured value, or (for `smol` / `slow` /
397    /// `designer`) the inherited `default` value. Each pattern is then fully
398    /// expanded: concrete model ids pass through; `pi/<role>` aliases recurse.
399    /// Cyclic or otherwise unresolvable aliases are dropped — a dangling role
400    /// reference is not a usable model id.
401    ///
402    /// Divergence from omp: omp's `resolveConfiguredRolePattern` leaves
403    /// `pi/<role>` aliases inside a configured value for a downstream pass to
404    /// expand; oxi expands them inline so [`Self::resolve`] always yields
405    /// concrete model patterns.
406    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        // Raw patterns for this role: the configured value, else (for the
415        // inheriting roles) the `default` role's value, else none.
416        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    /// Expand a single pattern value: a concrete model id yields itself; a
435    /// `pi/<role>[:<level>]` alias recurses into [`Self::resolve_role`] with
436    /// the thinking suffix re-attached to every resolved pattern.
437    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
459/// Whether a built-in role id is hidden from the selector UI.
460fn 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        // pi/<custom> is NOT an alias (custom roles are concrete), so it stays literal.
506        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        // default -> pi/slow, slow -> anthropic/claude-opus
513        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        // a -> pi/b, b -> pi/a  (roles "smol"/"slow" used as the cycle pair)
529        let r = registry(&[("smol", "pi/slow"), ("slow", "pi/smol")]);
530        // Resolving smol: visits smol, expands to pi/slow -> visits slow,
531        // expands to pi/smol -> already visited -> empty. Net: nothing resolves.
532        assert!(r.resolve("smol").is_empty());
533    }
534
535    #[test]
536    fn self_alias_collapses_to_builtin_defaults() {
537        // default = "pi/default" -> self-alias -> builtin defaults (empty in oxi)
538        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        // smol is unset but inherits default.
546        assert_eq!(r.resolve("smol"), vec!["anthropic/claude-sonnet-4"]);
547    }
548
549    #[test]
550    fn unset_non_inheriting_role_resolves_empty() {
551        // commit does NOT inherit default; unset -> empty.
552        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        // default = "pi/smol"; resolving smol (unset) inherits default, which
559        // aliases back to smol -> self-alias -> builtin (empty).
560        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        // :nitro is a routing variant, not a thinking level -> stays attached.
576        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        // built-in visible first (default in declaration order), then customs sorted.
596        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        // hidden built-in 'title' is excluded.
604        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}