1use std::fmt::Write;
2
3use anyhow::Result;
4use async_trait::async_trait;
5use futures::StreamExt;
6
7use crate::provider::LlmProvider;
8use crate::streaming::StreamBox;
9use agent_sdk_foundation::llm::{ChatOutcome, ChatRequest, ChatResponse, Message, Role};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ModelTier {
18 Fast,
20 Capable,
22 Advanced,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum TaskComplexity {
34 Simple,
36 Moderate,
38 Complex,
40}
41
42impl TaskComplexity {
43 #[must_use]
44 pub const fn recommended_tier(self) -> ModelTier {
45 match self {
46 Self::Simple => ModelTier::Fast,
47 Self::Moderate => ModelTier::Capable,
48 Self::Complex => ModelTier::Advanced,
49 }
50 }
51}
52
53pub struct ModelRouter<C, S, A> {
74 classifier: C,
75 fast: S,
76 capable: S,
77 advanced: A,
78}
79
80impl<C, S, A> ModelRouter<C, S, A>
81where
82 C: LlmProvider,
83 S: LlmProvider,
84 A: LlmProvider,
85{
86 pub const fn new(classifier: C, fast: S, capable: S, advanced: A) -> Self {
87 Self {
88 classifier,
89 fast,
90 capable,
91 advanced,
92 }
93 }
94
95 pub async fn classify(&self, request: &ChatRequest) -> Result<TaskComplexity> {
98 let classification_prompt = build_classification_prompt(request);
99
100 let classification_request = ChatRequest {
101 system: CLASSIFICATION_SYSTEM.to_owned(),
102 messages: vec![Message::user(classification_prompt)],
103 tools: None,
104 max_tokens: 50,
105 max_tokens_explicit: true,
106 session_id: None,
107 cached_content: None,
108 thinking: None,
109 tool_choice: None,
110 response_format: None,
111 cache: None,
112 };
113
114 match self.classifier.chat(classification_request).await? {
115 ChatOutcome::Success(response) => {
116 let complexity = parse_complexity(&response);
117 log::debug!(
118 "Model router classified request as {:?} using {}",
119 complexity,
120 self.classifier.model()
121 );
122 Ok(complexity)
123 }
124 ChatOutcome::RateLimited(_) => {
125 log::warn!("Classifier rate limited, defaulting to Complex");
126 Ok(TaskComplexity::Complex)
127 }
128 ChatOutcome::InvalidRequest(e) => {
129 log::error!("Classifier invalid request: {e}, defaulting to Complex");
130 Ok(TaskComplexity::Complex)
131 }
132 ChatOutcome::ServerError(e) => {
133 log::error!("Classifier server error: {e}, defaulting to Complex");
134 Ok(TaskComplexity::Complex)
135 }
136 _ => {
139 log::error!("Classifier returned unrecognized outcome, defaulting to Complex");
140 Ok(TaskComplexity::Complex)
141 }
142 }
143 }
144
145 pub async fn route(&self, request: ChatRequest) -> Result<ChatOutcome> {
148 let complexity = self.classify(&request).await?;
149 let tier = complexity.recommended_tier();
150
151 log::info!("Routing request to {tier:?} tier (complexity: {complexity:?})");
152
153 match tier {
154 ModelTier::Fast => self.fast.chat(request).await,
155 ModelTier::Capable => self.capable.chat(request).await,
156 ModelTier::Advanced => self.advanced.chat(request).await,
157 }
158 }
159
160 pub async fn route_with_tier(
163 &self,
164 request: ChatRequest,
165 tier: ModelTier,
166 ) -> Result<ChatOutcome> {
167 match tier {
168 ModelTier::Fast => self.fast.chat(request).await,
169 ModelTier::Capable => self.capable.chat(request).await,
170 ModelTier::Advanced => self.advanced.chat(request).await,
171 }
172 }
173
174 #[must_use]
175 pub const fn fast_provider(&self) -> &S {
176 &self.fast
177 }
178
179 #[must_use]
180 pub const fn capable_provider(&self) -> &S {
181 &self.capable
182 }
183
184 #[must_use]
185 pub const fn advanced_provider(&self) -> &A {
186 &self.advanced
187 }
188}
189
190#[async_trait]
191impl<C, S, A> LlmProvider for ModelRouter<C, S, A>
192where
193 C: LlmProvider,
194 S: LlmProvider,
195 A: LlmProvider,
196{
197 async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
198 self.route(request).await
199 }
200
201 fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
202 Box::pin(async_stream::stream! {
203 let tier = match self.classify(&request).await {
204 Ok(complexity) => complexity.recommended_tier(),
205 Err(error) => {
206 yield Err(error);
207 return;
208 }
209 };
210 log::info!("Streaming request to {tier:?} tier");
211 let mut stream = match tier {
212 ModelTier::Fast => self.fast.chat_stream(request),
213 ModelTier::Capable => self.capable.chat_stream(request),
214 ModelTier::Advanced => self.advanced.chat_stream(request),
215 };
216 while let Some(item) = stream.next().await {
217 yield item;
218 }
219 })
220 }
221
222 fn model(&self) -> &str {
225 self.capable.model()
226 }
227
228 fn provider(&self) -> &'static str {
231 self.capable.provider()
232 }
233
234 fn route(&self) -> &str {
240 self.capable.route()
241 }
242
243 fn supports_historical_image_blocks(&self) -> bool {
247 false
248 }
249
250 fn max_request_attachment_bytes(&self) -> Option<u64> {
254 [
255 self.fast.max_request_attachment_bytes(),
256 self.capable.max_request_attachment_bytes(),
257 self.advanced.max_request_attachment_bytes(),
258 ]
259 .into_iter()
260 .try_fold(u64::MAX, |min, budget| budget.map(|bytes| min.min(bytes)))
261 }
262}
263
264const CLASSIFICATION_SYSTEM: &str = r"You are a task complexity classifier. Analyze the user's request and classify it as one of: SIMPLE, MODERATE, or COMPLEX.
265
266SIMPLE tasks:
267- Basic questions with factual answers
268- Simple calculations
269- Direct lookups or retrievals
270- Yes/no questions
271- Single-step operations
272
273MODERATE tasks:
274- Multi-step reasoning
275- Summarization
276- Basic analysis
277- Comparisons
278- Standard tool usage
279
280COMPLEX tasks:
281- Creative writing or content generation
282- Multi-step planning
283- Complex analysis or synthesis
284- Nuanced decisions
285- Tasks requiring deep domain knowledge
286- Financial advice or calculations
287- Multi-tool orchestration
288
289Respond with ONLY one word: SIMPLE, MODERATE, or COMPLEX.";
290
291fn build_classification_prompt(request: &ChatRequest) -> String {
292 let mut prompt = String::new();
293
294 prompt.push_str("Classify this task:\n\n");
295
296 if !request.system.is_empty() {
297 prompt.push_str("System context: ");
298 let truncated = truncate_on_char_boundary(&request.system, 200);
299 prompt.push_str(truncated);
300 if truncated.len() < request.system.len() {
301 prompt.push_str("...");
302 }
303 prompt.push_str("\n\n");
304 }
305
306 if let Some(last_user_message) = request.messages.iter().rev().find(|m| m.role == Role::User)
307 && let Some(text) = last_user_message.content.first_text()
308 {
309 prompt.push_str("User request: ");
310 let truncated = truncate_on_char_boundary(text, 500);
311 prompt.push_str(truncated);
312 if truncated.len() < text.len() {
313 prompt.push_str("...");
314 }
315 }
316
317 if let Some(tools) = &request.tools {
318 let _ = write!(prompt, "\n\nAvailable tools: {}", tools.len());
319 }
320
321 prompt
322}
323
324fn truncate_on_char_boundary(s: &str, max_bytes: usize) -> &str {
328 if s.len() <= max_bytes {
329 return s;
330 }
331 let mut end = max_bytes;
332 while end > 0 && !s.is_char_boundary(end) {
333 end -= 1;
334 }
335 &s[..end]
336}
337
338fn parse_complexity(response: &ChatResponse) -> TaskComplexity {
339 let text = response.first_text().unwrap_or("").to_uppercase();
340
341 if text.contains("SIMPLE") {
342 TaskComplexity::Simple
343 } else if text.contains("MODERATE") {
344 TaskComplexity::Moderate
345 } else {
346 TaskComplexity::Complex
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use crate::streaming::StreamDelta;
354 use agent_sdk_foundation::llm::{ContentBlock, StopReason, Usage};
355 use anyhow::Result;
356 use async_trait::async_trait;
357 use futures::StreamExt;
358
359 struct StaticProvider {
363 name: &'static str,
364 reply: &'static str,
365 supports_historical_images: bool,
366 }
367
368 #[async_trait]
369 impl LlmProvider for StaticProvider {
370 async fn chat(&self, _request: ChatRequest) -> Result<ChatOutcome> {
371 Ok(ChatOutcome::Success(ChatResponse {
372 id: "r".to_owned(),
373 content: vec![ContentBlock::Text {
374 text: self.reply.to_owned(),
375 }],
376 model: self.name.to_owned(),
377 stop_reason: Some(StopReason::EndTurn),
378 usage: Usage {
379 served_speed: None,
380 input_tokens: 1,
381 output_tokens: 1,
382 cached_input_tokens: 0,
383 cache_creation_input_tokens: 0,
384 },
385 }))
386 }
387
388 fn model(&self) -> &str {
389 self.name
390 }
391
392 fn provider(&self) -> &'static str {
393 self.name
394 }
395
396 fn supports_historical_image_blocks(&self) -> bool {
397 self.supports_historical_images
398 }
399 }
400
401 #[test]
402 fn historical_images_stay_disabled_for_dynamic_model_routing() {
403 let capable = |name: &'static str| StaticProvider {
404 name,
405 reply: "MODERATE",
406 supports_historical_images: true,
407 };
408 let router = ModelRouter::new(
409 capable("classifier"),
410 capable("fast"),
411 capable("capable"),
412 capable("advanced"),
413 );
414
415 assert!(!router.supports_historical_image_blocks());
416 }
417
418 #[tokio::test]
422 async fn streamed_dispatch_attributes_the_tier_that_served() -> Result<()> {
423 let router = ModelRouter::new(
424 StaticProvider {
425 name: "classifier",
426 reply: "SIMPLE",
427 supports_historical_images: false,
428 },
429 StaticProvider {
430 name: "fast-tier",
431 reply: "quick answer",
432 supports_historical_images: false,
433 },
434 StaticProvider {
435 name: "capable-tier",
436 reply: "unused",
437 supports_historical_images: false,
438 },
439 StaticProvider {
440 name: "advanced-tier",
441 reply: "unused",
442 supports_historical_images: false,
443 },
444 );
445 assert_eq!(LlmProvider::route(&router), "capable-tier");
446
447 let request = ChatRequest::new("system", vec![Message::user("2+2?")]);
448 let mut stream = router.chat_stream(request);
449 let mut served = None;
450 while let Some(item) = stream.next().await {
451 if let StreamDelta::Done { served_route, .. } = item? {
452 served = served_route;
453 }
454 }
455 assert_eq!(served.as_deref(), Some("fast-tier"));
456 Ok(())
457 }
458
459 #[test]
460 fn complexity_to_tier() {
461 assert_eq!(TaskComplexity::Simple.recommended_tier(), ModelTier::Fast);
462 assert_eq!(
463 TaskComplexity::Moderate.recommended_tier(),
464 ModelTier::Capable
465 );
466 assert_eq!(
467 TaskComplexity::Complex.recommended_tier(),
468 ModelTier::Advanced
469 );
470 }
471
472 #[test]
473 fn truncate_on_char_boundary_never_splits_multibyte_char() {
474 let s = "😀😀😀";
478 for max in 0..=s.len() {
479 let truncated = truncate_on_char_boundary(s, max);
480 assert!(s.starts_with(truncated));
482 assert!(truncated.len() <= max);
483 }
484 assert_eq!(truncate_on_char_boundary(s, 4), "😀");
485 assert_eq!(truncate_on_char_boundary(s, 5), "😀");
486 assert_eq!(truncate_on_char_boundary(s, 100), s);
487 }
488
489 #[test]
490 fn build_classification_prompt_handles_multibyte_at_limit() {
491 let system = "é".repeat(150); let request = ChatRequest::new(system, vec![Message::user("日本語".repeat(300))]);
495 let prompt = build_classification_prompt(&request);
497 assert!(prompt.contains("System context:"));
498 assert!(prompt.ends_with("..."));
499 }
500}