Skip to main content

agent_sdk_providers/
router.rs

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/// A capability/cost tier a request can be routed to.
12///
13/// Tiers are ordered cheapest-and-fastest (`Fast`) to most-capable-and-costly
14/// (`Advanced`); [`TaskComplexity::recommended_tier`] maps a classified
15/// complexity onto one of these.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ModelTier {
18    /// Cheapest, fastest tier for trivial single-step work.
19    Fast,
20    /// Mid tier for multi-step reasoning, summarization, standard tool use.
21    Capable,
22    /// Most capable tier for creative, multi-step, or domain-heavy work.
23    Advanced,
24}
25
26/// The complexity a classifier assigns to an incoming request.
27///
28/// Produced by [`ModelRouter::classify`]. When the classifier itself errors or
29/// is rate limited the router falls back to the conservative
30/// [`TaskComplexity::Complex`] so a misclassification never silently downgrades
31/// a hard request to a weak model.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum TaskComplexity {
34    /// Basic factual questions, lookups, single-step operations.
35    Simple,
36    /// Multi-step reasoning, summarization, standard tool usage.
37    Moderate,
38    /// Creative generation, planning, synthesis, deep domain knowledge.
39    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
53/// Routes each request to a model tier chosen by an LLM classifier.
54///
55/// A `ModelRouter` wraps a `classifier` provider plus three tier providers and,
56/// for every request, makes one extra classifier call to decide whether the work
57/// is [`Simple`](TaskComplexity::Simple), [`Moderate`](TaskComplexity::Moderate),
58/// or [`Complex`](TaskComplexity::Complex), then dispatches to the `fast`,
59/// `capable`, or `advanced` provider respectively. If the classifier call fails
60/// (error or rate limit) the router conservatively treats the request as
61/// `Complex` rather than risk under-serving it (see [`classify`](Self::classify)).
62///
63/// `ModelRouter` itself implements [`LlmProvider`], so it can be passed anywhere
64/// a `&dyn LlmProvider` is expected (`run_structured`, `RefreshingProvider`,
65/// etc.). [`chat`](LlmProvider::chat) classifies then routes; the streaming
66/// [`chat_stream`](LlmProvider::chat_stream) classifies first, then streams the
67/// chosen tier.
68///
69/// Note: the `fast` and `capable` tiers currently share one provider type `S`
70/// (only `advanced` has its own type `A`), so mixing e.g. a Gemini fast tier with
71/// an `OpenAI` capable tier requires both behind the same concrete type. Use
72/// `Arc<dyn LlmProvider>` for all three tiers to mix providers freely.
73pub 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    /// # Errors
96    /// Returns an error if the LLM provider fails.
97    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            // `ChatOutcome` is `#[non_exhaustive]`; an unrecognized outcome
137            // takes the same conservative fallback as the error variants.
138            _ => {
139                log::error!("Classifier returned unrecognized outcome, defaulting to Complex");
140                Ok(TaskComplexity::Complex)
141            }
142        }
143    }
144
145    /// # Errors
146    /// Returns an error if the LLM provider fails.
147    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    /// # Errors
161    /// Returns an error if the LLM provider fails.
162    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    /// Reports the `capable` (mid) tier's model as the router's representative
223    /// model identifier.
224    fn model(&self) -> &str {
225        self.capable.model()
226    }
227
228    /// Reports the `capable` (mid) tier's provider as the router's representative
229    /// provider identifier.
230    fn provider(&self) -> &'static str {
231        self.capable.provider()
232    }
233
234    /// Reports the **`capable` tier's** route — pre-dispatch identity only.
235    /// Post-dispatch attribution does not go through this method: the tier
236    /// that actually serves a stream stamps itself on
237    /// [`StreamDelta::Done::served_route`](crate::streaming::StreamDelta),
238    /// which this router forwards untouched.
239    fn route(&self) -> &str {
240        self.capable.route()
241    }
242
243    /// Pre-dispatch image pricing and limits come from the representative
244    /// capable tier, but classification may select a heterogeneous fast or
245    /// advanced tier. Dynamic model routing therefore never opts in.
246    fn supports_historical_image_blocks(&self) -> bool {
247        false
248    }
249
250    /// A request may route to any tier, so the budget is the most
251    /// restrictive tier budget; unknown (`None`) anywhere keeps the router
252    /// unknown so consumers stay conservative.
253    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
324/// Truncate `s` to at most `max_bytes`, backing off to the nearest UTF-8
325/// character boundary so the byte slice never panics on a multi-byte character
326/// (emoji, CJK, accented text) that straddles the limit.
327fn 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    /// Chat-only mock: streaming goes through the trait's default
360    /// `chat_stream`, which stamps this provider's `route()` on `Done` —
361    /// exactly what a concrete provider does.
362    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    /// A request classified `SIMPLE` streams from the `fast` tier, and the
419    /// stream's `Done` names that tier — not the `capable` tier the
420    /// router's own `route()` reports.
421    #[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        // "😀" is a 4-byte character. Truncating at byte 1, 2, or 3 would land
475        // inside it and panic with naive `&s[..n]`; the helper must back off to a
476        // valid boundary instead.
477        let s = "😀😀😀";
478        for max in 0..=s.len() {
479            let truncated = truncate_on_char_boundary(s, max);
480            // Must be a valid prefix of the original (never panics, always UTF-8).
481            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        // A system prompt longer than 200 bytes whose 200th byte falls inside a
492        // multi-byte char must not panic when building the classification prompt.
493        let system = "é".repeat(150); // 300 bytes; byte 200 is mid-character
494        let request = ChatRequest::new(system, vec![Message::user("日本語".repeat(300))]);
495        // The bug manifested as a panic; reaching this assertion means no panic.
496        let prompt = build_classification_prompt(&request);
497        assert!(prompt.contains("System context:"));
498        assert!(prompt.ends_with("..."));
499    }
500}