oxicode_ai/
role_switcher.rs1use crate::Model;
18use crate::roles::{ModelRole, RoleRegistry};
19
20pub const DEFAULT_LONG_CONTEXT_THRESHOLD: usize = 60_000;
24
25#[derive(Debug, Clone)]
30pub struct RoleSignals<'a> {
31 pub explicit_override: Option<ModelRole>,
33 pub current_tool: Option<&'a str>,
36 pub thinking_enabled: bool,
38 pub estimated_tokens: usize,
40 pub long_context_threshold: usize,
42 pub is_trivial: bool,
44}
45
46impl Default for RoleSignals<'_> {
47 fn default() -> Self {
48 Self {
49 explicit_override: None,
50 current_tool: None,
51 thinking_enabled: false,
52 estimated_tokens: 0,
53 long_context_threshold: DEFAULT_LONG_CONTEXT_THRESHOLD,
54 is_trivial: false,
55 }
56 }
57}
58
59#[must_use]
74pub fn decide_role(signals: &RoleSignals<'_>) -> ModelRole {
75 if let Some(role) = signals.explicit_override {
76 return role;
77 }
78 if let Some(tool) = signals.current_tool
79 && let Some(role) = role_for_tool(tool)
80 {
81 return role;
82 }
83 if signals.estimated_tokens > signals.long_context_threshold {
84 return ModelRole::Slow;
85 }
86 if signals.thinking_enabled {
87 return ModelRole::Slow;
88 }
89 if signals.is_trivial {
90 return ModelRole::Smol;
91 }
92 ModelRole::Default
93}
94
95#[must_use]
101pub fn role_for_tool(tool_name: &str) -> Option<ModelRole> {
102 match tool_name {
103 "commit" => Some(ModelRole::Commit),
104 _ => None,
105 }
106}
107
108#[must_use]
114pub fn resolve_role_to_model(role: ModelRole, registry: &RoleRegistry) -> Option<Model> {
115 let pattern = registry.resolve(role.as_str()).into_iter().next()?;
116 let (provider, model_id) = pattern.split_once('/')?;
117 crate::lookup_model(provider, model_id)
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 #[test]
125 fn override_wins_over_everything() {
126 let s = RoleSignals {
127 explicit_override: Some(ModelRole::Advisor),
128 current_tool: Some("commit"),
129 thinking_enabled: true,
130 estimated_tokens: 100_000,
131 is_trivial: false,
132 ..RoleSignals::default()
133 };
134 assert_eq!(decide_role(&s), ModelRole::Advisor);
135 }
136
137 #[test]
138 fn tool_signal_selects_commit_role() {
139 let s = RoleSignals {
140 current_tool: Some("commit"),
141 ..RoleSignals::default()
142 };
143 assert_eq!(decide_role(&s), ModelRole::Commit);
144 }
145
146 #[test]
147 fn unknown_tool_falls_through() {
148 let s = RoleSignals {
149 current_tool: Some("read"),
150 ..RoleSignals::default()
151 };
152 assert_eq!(decide_role(&s), ModelRole::Default);
153 }
154
155 #[test]
156 fn long_context_selects_slow() {
157 let s = RoleSignals {
158 estimated_tokens: 80_000,
159 long_context_threshold: 60_000,
160 ..RoleSignals::default()
161 };
162 assert_eq!(decide_role(&s), ModelRole::Slow);
163 }
164
165 #[test]
166 fn long_context_respects_custom_threshold() {
167 let s = RoleSignals {
168 estimated_tokens: 5_000,
169 long_context_threshold: 4_000,
170 ..RoleSignals::default()
171 };
172 assert_eq!(decide_role(&s), ModelRole::Slow);
173 }
174
175 #[test]
176 fn thinking_selects_slow_even_when_short() {
177 let s = RoleSignals {
178 thinking_enabled: true,
179 estimated_tokens: 100,
180 ..RoleSignals::default()
181 };
182 assert_eq!(decide_role(&s), ModelRole::Slow);
183 }
184
185 #[test]
186 fn trivial_selects_smol() {
187 let s = RoleSignals {
188 is_trivial: true,
189 ..RoleSignals::default()
190 };
191 assert_eq!(decide_role(&s), ModelRole::Smol);
192 }
193
194 #[test]
195 fn default_when_no_signal() {
196 assert_eq!(decide_role(&RoleSignals::default()), ModelRole::Default);
197 }
198
199 #[test]
200 fn long_context_beats_thinking_order_independence() {
201 let s = RoleSignals {
203 thinking_enabled: true,
204 estimated_tokens: 100_000,
205 ..RoleSignals::default()
206 };
207 assert_eq!(decide_role(&s), ModelRole::Slow);
208 }
209
210 #[test]
211 fn role_for_tool_bindings() {
212 assert_eq!(role_for_tool("commit"), Some(ModelRole::Commit));
213 assert_eq!(role_for_tool("generate_image"), None);
214 assert_eq!(role_for_tool(""), None);
215 }
216
217 #[test]
218 fn resolve_unconfigured_role_is_none() {
219 let r = RoleRegistry::new();
220 assert!(resolve_role_to_model(ModelRole::Commit, &r).is_none());
221 }
222
223 #[test]
224 fn resolve_pattern_without_slash_is_none() {
225 let mut r = RoleRegistry::new();
226 r.set("commit", "just-a-bare-id");
227 assert!(resolve_role_to_model(ModelRole::Commit, &r).is_none());
228 }
229
230 #[test]
231 fn resolve_unknown_model_is_none() {
232 let mut r = RoleRegistry::new();
233 r.set("commit", "no-such-provider/does-not-exist-xyz");
234 assert!(resolve_role_to_model(ModelRole::Commit, &r).is_none());
235 }
236
237 #[test]
238 fn resolve_registered_model_is_some() {
239 let model = crate::Model::new(
241 "role-switcher-test-model",
242 "Role Switcher Test",
243 crate::Api::AnthropicMessages,
244 "role-switcher-test",
245 "",
246 );
247 crate::register_model(model);
248 let mut r = RoleRegistry::new();
249 r.set("commit", "role-switcher-test/role-switcher-test-model");
250 let resolved = resolve_role_to_model(ModelRole::Commit, &r);
251 assert!(
252 resolved.is_some(),
253 "registered model must resolve, got {resolved:?}"
254 );
255 }
256}