oxicode_ai/
role_routing.rs1use crate::providers::{Provider, StreamResult};
17use crate::role_switcher::{
18 DEFAULT_LONG_CONTEXT_THRESHOLD, RoleSignals, decide_role, resolve_role_to_model,
19};
20use crate::roles::RoleRegistry;
21use crate::{Context, Model, StreamOptions, ThinkingLevel};
22use parking_lot::RwLock;
23use std::pin::Pin;
24use std::sync::Arc;
25
26pub struct RoleRoutingProvider {
33 default: Arc<dyn Provider>,
34 registry: Arc<RwLock<RoleRegistry>>,
35}
36
37impl RoleRoutingProvider {
38 #[must_use]
43 pub fn new(default: Arc<dyn Provider>, registry: Arc<RwLock<RoleRegistry>>) -> Self {
44 Self { default, registry }
45 }
46
47 pub fn update_registry(&self, f: impl FnOnce(&mut RoleRegistry)) {
50 f(&mut self.registry.write());
51 }
52
53 #[must_use]
60 pub fn signals_from_request(
61 context: &Context,
62 options: &Option<StreamOptions>,
63 ) -> RoleSignals<'static> {
64 let thinking_enabled = options
65 .as_ref()
66 .and_then(|o| o.thinking_level)
67 .is_some_and(|level| level != ThinkingLevel::Off);
68 RoleSignals {
69 explicit_override: None,
70 current_tool: None,
71 thinking_enabled,
72 estimated_tokens: estimate_tokens(context),
73 long_context_threshold: DEFAULT_LONG_CONTEXT_THRESHOLD,
74 is_trivial: is_trivial(context),
75 }
76 }
77}
78
79fn estimate_tokens(context: &Context) -> usize {
81 context
82 .messages
83 .iter()
84 .map(|m| m.text_content().unwrap_or_default().len() / 4)
85 .sum()
86}
87
88fn is_trivial(context: &Context) -> bool {
91 let last = context
92 .messages
93 .last()
94 .map(|m| m.text_content().unwrap_or_default())
95 .unwrap_or_default();
96 last.len() < 40 && !last.contains("```")
97}
98
99impl Provider for RoleRoutingProvider {
100 fn stream<'a>(
101 &'a self,
102 model: &'a Model,
103 context: &'a Context,
104 options: Option<StreamOptions>,
105 ) -> Pin<Box<dyn Future<Output = StreamResult> + Send + 'a>> {
106 let default = Arc::clone(&self.default);
107 let registry = Arc::clone(&self.registry);
108 Box::pin(async move {
109 let role_model = {
112 let reg = registry.read();
113 if reg.is_empty() {
114 None
115 } else {
116 let signals = Self::signals_from_request(context, &options);
117 let role = decide_role(&signals);
118 resolve_role_to_model(role, ®)
119 .filter(|m| m.provider != model.provider || m.id != model.id)
120 }
121 };
122 let Some(role_model) = role_model else {
123 return default.stream(model, context, options).await;
124 };
125 match crate::get_provider_arc(&role_model.provider) {
129 Some(provider) => {
130 match provider.stream(&role_model, context, options.clone()).await {
131 Ok(stream) => Ok(stream),
132 Err(err) => {
133 tracing::warn!(
134 target: "role-router",
135 error = %err,
136 "role-model provider failed; falling back to default model"
137 );
138 default.stream(model, context, options).await
139 }
140 }
141 }
142 None => default.stream(model, context, options).await,
143 }
144 })
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use crate::{Context, Message, StreamOptions, ThinkingLevel};
152
153 fn ctx_with_last(text: &str) -> Context {
154 Context {
155 messages: vec![Message::user(text)],
156 ..Context::default()
157 }
158 }
159
160 #[test]
161 fn signals_thinking_from_options() {
162 let ctx = ctx_with_last("explain");
163 let opts = StreamOptions::default().thinking_level(ThinkingLevel::High);
164 let s = RoleRoutingProvider::signals_from_request(&ctx, &Some(opts));
165 assert!(s.thinking_enabled);
166 }
167
168 #[test]
169 fn signals_thinking_off_is_disabled() {
170 let ctx = ctx_with_last("explain");
171 let opts = StreamOptions::default().thinking_level(ThinkingLevel::Off);
172 let s = RoleRoutingProvider::signals_from_request(&ctx, &Some(opts));
173 assert!(!s.thinking_enabled);
174 }
175
176 #[test]
177 fn signals_no_options_is_not_thinking() {
178 let ctx = ctx_with_last("explain");
179 let s = RoleRoutingProvider::signals_from_request(&ctx, &None);
180 assert!(!s.thinking_enabled);
181 }
182
183 #[test]
184 fn signals_long_context_exceeds_threshold() {
185 let big = "x".repeat(60_000 * 4 + 100);
188 let ctx = ctx_with_last(&big);
189 let s = RoleRoutingProvider::signals_from_request(&ctx, &None);
190 assert!(
191 s.estimated_tokens > DEFAULT_LONG_CONTEXT_THRESHOLD,
192 "estimated {} should exceed {}",
193 s.estimated_tokens,
194 DEFAULT_LONG_CONTEXT_THRESHOLD
195 );
196 }
197
198 #[test]
199 fn signals_short_message_is_trivial() {
200 let ctx = ctx_with_last("hi");
201 let s = RoleRoutingProvider::signals_from_request(&ctx, &None);
202 assert!(s.is_trivial);
203 }
204
205 #[test]
206 fn signals_code_fence_is_not_trivial() {
207 let ctx = ctx_with_last("```\ncode\n```");
208 let s = RoleRoutingProvider::signals_from_request(&ctx, &None);
209 assert!(!s.is_trivial);
210 }
211
212 #[test]
213 fn estimate_tokens_scales_with_text() {
214 let small = ctx_with_last("hi");
215 let large = ctx_with_last(&"x".repeat(4_000));
216 assert!(estimate_tokens(&large) > estimate_tokens(&small));
217 }
218
219 #[test]
220 fn empty_registry_passes_through() {
221 let r = RoleRegistry::new();
226 assert!(r.is_empty());
227 }
228}