use crate::types::ThinkingLevel;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TurnTier {
Light,
Standard,
Heavy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelPreference {
Cheap,
Default,
Premium,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TurnRoute {
pub tier: TurnTier,
pub model: ModelPreference,
pub thinking: ThinkingLevel,
}
const LIGHT_MAX_CHARS: usize = 80;
const HEAVY_KEYWORDS: &[&str] = &[
"build", "compile", "debug", "fix", "error", "bug", "refactor", "implement",
"rustlite", "cartridge", "wasm", "publish", "deploy", "stack trace", "panic",
"exception", "failing", "broken", "optimize", "algorithm", "architect",
"diagnose", "investigate", "trace", "regression", "edit_file", "create_file",
];
const GREETINGS: &[&str] = &[
"hi", "hey", "hello", "yo", "sup", "thanks", "thank you", "ty", "ok", "okay",
"cool", "nice", "great", "gm", "good morning", "good night", "bye", "lol",
];
fn is_bare_greeting(lower: &str) -> bool {
GREETINGS.iter().any(|g| {
if lower == *g {
return true;
}
if let Some(rest) = lower.strip_prefix(g) {
let next_is_boundary = rest
.chars()
.next()
.map(|c| !c.is_alphanumeric())
.unwrap_or(true);
next_is_boundary
&& rest
.chars()
.all(|c| c.is_whitespace() || c.is_ascii_punctuation())
} else {
false
}
})
}
fn has_code_fence(prompt: &str) -> bool {
prompt.contains("```")
}
fn references_multiple_files(lower: &str) -> bool {
const EXTS: &[&str] = &[
".rs", ".ts", ".js", ".sol", ".rl", ".toml", ".json", ".html", ".css",
".md", ".sh", ".wasm",
];
let hits = lower
.split_whitespace()
.filter(|tok| {
EXTS.iter().any(|e| tok.contains(e)) || tok.contains("src/")
})
.count();
hits >= 2
}
pub fn classify_turn(prompt: &str, last_turn_used_tools: bool) -> TurnTier {
let trimmed = prompt.trim();
let lower = trimmed.to_lowercase();
if is_bare_greeting(&lower) {
return TurnTier::Light;
}
let heavy_signal = has_code_fence(trimmed)
|| HEAVY_KEYWORDS.iter().any(|k| lower.contains(k))
|| references_multiple_files(&lower);
if heavy_signal || last_turn_used_tools {
return TurnTier::Heavy;
}
if trimmed.chars().count() <= LIGHT_MAX_CHARS {
return TurnTier::Light;
}
TurnTier::Standard
}
pub fn route_tier(tier: TurnTier) -> TurnRoute {
let (model, thinking) = match tier {
TurnTier::Light => (ModelPreference::Cheap, ThinkingLevel::Minimal),
TurnTier::Standard => (ModelPreference::Default, ThinkingLevel::Medium),
TurnTier::Heavy => (ModelPreference::Premium, ThinkingLevel::High),
};
TurnRoute { tier, model, thinking }
}
pub fn route(prompt: &str, last_turn_used_tools: bool) -> TurnRoute {
route_tier(classify_turn(prompt, last_turn_used_tools))
}
#[cfg(feature = "anthropic")]
pub fn route_model(tier: TurnTier, session_model: &str) -> Option<String> {
if !session_model.starts_with("claude-") {
return None;
}
use crate::backends::anthropic::{DEFAULT_MODEL as HAIKU, OPUS_MODEL, SONNET_MODEL};
fn rank(model: &str) -> u8 {
if model.contains("opus") {
2
} else if model.contains("sonnet") {
1
} else {
0 }
}
let ceiling = rank(session_model);
let desired = match tier {
TurnTier::Light => 0, TurnTier::Standard => 1, TurnTier::Heavy => ceiling, };
let chosen = desired.min(ceiling);
if chosen == ceiling {
return None;
}
let id = match chosen {
0 => HAIKU,
_ => SONNET_MODEL, };
let _ = OPUS_MODEL;
if id == session_model {
None
} else {
Some(id.to_string())
}
}
#[cfg(not(feature = "anthropic"))]
pub fn route_model(_tier: TurnTier, _session_model: &str) -> Option<String> {
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConsultBackend {
Gemini,
Anthropic,
}
#[cfg(feature = "anthropic")]
pub const CONSULT_MODELS: &[(&str, &str)] = &[
(crate::types::DEFAULT_MODEL, "Gemini (default)"),
(crate::backends::anthropic::OPUS_MODEL, "Claude Opus"),
(crate::backends::anthropic::SONNET_MODEL, "Claude Sonnet"),
(crate::backends::anthropic::DEFAULT_MODEL, "Claude Haiku"),
];
#[cfg(not(feature = "anthropic"))]
pub const CONSULT_MODELS: &[(&str, &str)] = &[(crate::types::DEFAULT_MODEL, "Gemini (default)")];
pub fn select_consult_backend(model: &str) -> crate::error::Result<ConsultBackend> {
if !CONSULT_MODELS.iter().any(|(id, _)| *id == model) {
let supported = CONSULT_MODELS
.iter()
.map(|(id, _)| *id)
.collect::<Vec<_>>()
.join(", ");
return Err(crate::error::Error::other(format!(
"consult_model: unsupported model {model:?} — choose one of: {supported}"
)));
}
if model.starts_with("claude-") {
Ok(ConsultBackend::Anthropic)
} else {
Ok(ConsultBackend::Gemini)
}
}
pub fn clamp_thinking(desired: ThinkingLevel, ceiling: ThinkingLevel) -> ThinkingLevel {
fn rank(t: ThinkingLevel) -> u8 {
match t {
ThinkingLevel::Minimal => 0,
ThinkingLevel::Low => 1,
ThinkingLevel::Medium => 2,
ThinkingLevel::High => 3,
}
}
if rank(desired) <= rank(ceiling) {
desired
} else {
ceiling
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greetings_are_light() {
for g in ["hi", "Hey", "hello", "yo", "thanks", "Thank you", "ok", "gm"] {
assert_eq!(classify_turn(g, false), TurnTier::Light, "{g:?}");
}
assert_eq!(classify_turn("hi!", false), TurnTier::Light);
assert_eq!(classify_turn("thanks.", false), TurnTier::Light);
assert_eq!(classify_turn(" Hello? ", false), TurnTier::Light);
}
#[test]
fn greeting_word_then_real_ask_is_not_light_greeting() {
assert_eq!(
classify_turn("thanks, now fix the build", false),
TurnTier::Heavy
);
assert_eq!(classify_turn("hint", false), TurnTier::Light);
}
#[test]
fn short_simple_questions_are_light() {
assert_eq!(classify_turn("what is pricing?", false), TurnTier::Light);
assert_eq!(classify_turn("who are you", false), TurnTier::Light);
assert_eq!(classify_turn("how much do you charge", false), TurnTier::Light);
}
#[test]
fn build_debug_verbs_are_heavy() {
for p in [
"fix the failing test",
"debug this panic",
"compile the cartridge",
"implement a new facet",
"refactor the session module",
"why is the build broken",
"optimize this algorithm",
"investigate the regression",
] {
assert_eq!(classify_turn(p, false), TurnTier::Heavy, "{p:?}");
}
}
#[test]
fn code_fence_is_heavy() {
let p = "what does this do?\n```rust\nfn main() {}\n```";
assert_eq!(classify_turn(p, false), TurnTier::Heavy);
}
#[test]
fn multiple_file_refs_are_heavy() {
assert_eq!(
classify_turn("compare src/app/chat/mod.rs and session.rs behavior", false),
TurnTier::Heavy
);
assert_eq!(
classify_turn("open notes.md please", false),
TurnTier::Light );
}
#[test]
fn tool_use_last_turn_makes_short_prompt_heavy() {
assert_eq!(classify_turn("continue", true), TurnTier::Heavy);
assert_eq!(classify_turn("continue", false), TurnTier::Light);
}
#[test]
fn greeting_after_tool_use_stays_light() {
assert_eq!(classify_turn("thanks!", true), TurnTier::Light);
}
#[test]
fn long_neutral_prompt_is_standard() {
let p = "Please summarize the overall design philosophy behind this \
platform and how the pieces fit together at a high level for me.";
assert!(p.chars().count() > LIGHT_MAX_CHARS);
assert_eq!(classify_turn(p, false), TurnTier::Standard);
}
#[test]
fn empty_prompt_is_light() {
assert_eq!(classify_turn("", false), TurnTier::Light);
assert_eq!(classify_turn(" ", false), TurnTier::Light);
}
#[test]
fn tier_maps_to_expected_route() {
let l = route_tier(TurnTier::Light);
assert_eq!(l.model, ModelPreference::Cheap);
assert_eq!(l.thinking, ThinkingLevel::Minimal);
let s = route_tier(TurnTier::Standard);
assert_eq!(s.model, ModelPreference::Default);
assert_eq!(s.thinking, ThinkingLevel::Medium);
let h = route_tier(TurnTier::Heavy);
assert_eq!(h.model, ModelPreference::Premium);
assert_eq!(h.thinking, ThinkingLevel::High);
}
#[test]
fn route_combines_classify_and_map() {
assert_eq!(route("hi", false).thinking, ThinkingLevel::Minimal);
assert_eq!(route("fix the build", false).thinking, ThinkingLevel::High);
let standard = "Please walk me through the high-level economy ladder \
design in some reasonable amount of detail thank you.";
assert_eq!(route(standard, false).tier, TurnTier::Standard);
}
#[test]
fn clamp_never_exceeds_ceiling() {
assert_eq!(
clamp_thinking(ThinkingLevel::High, ThinkingLevel::Medium),
ThinkingLevel::Medium
);
assert_eq!(
clamp_thinking(ThinkingLevel::Minimal, ThinkingLevel::High),
ThinkingLevel::Minimal
);
assert_eq!(
clamp_thinking(ThinkingLevel::High, ThinkingLevel::High),
ThinkingLevel::High
);
assert_eq!(
clamp_thinking(ThinkingLevel::Low, ThinkingLevel::High),
ThinkingLevel::Low
);
}
#[test]
fn routed_thinking_respects_ceiling_for_every_tier() {
for ceiling in [
ThinkingLevel::Minimal,
ThinkingLevel::Low,
ThinkingLevel::Medium,
ThinkingLevel::High,
] {
for tier in [TurnTier::Light, TurnTier::Standard, TurnTier::Heavy] {
let desired = route_tier(tier).thinking;
let applied = clamp_thinking(desired, ceiling);
assert_eq!(clamp_thinking(applied, ceiling), applied);
}
}
}
#[test]
fn route_model_is_noop_off_anthropic_family() {
for session in [
"gemini-3.5-flash",
"gemma-3-270m",
"gpt-4o",
"",
"something-weird",
] {
for tier in [TurnTier::Light, TurnTier::Standard, TurnTier::Heavy] {
assert_eq!(route_model(tier, session), None, "{session:?}/{tier:?}");
}
}
}
#[cfg(feature = "anthropic")]
mod anthropic_family {
use super::*;
use crate::backends::anthropic::{
DEFAULT_MODEL as HAIKU, OPUS_MODEL as OPUS, SONNET_MODEL as SONNET,
};
#[test]
fn opus_session_downgrades_routine_turns() {
assert_eq!(route_model(TurnTier::Light, OPUS).as_deref(), Some(HAIKU));
assert_eq!(route_model(TurnTier::Standard, OPUS).as_deref(), Some(SONNET));
assert_eq!(route_model(TurnTier::Heavy, OPUS), None);
}
#[test]
fn sonnet_session_clamps_standard_to_ceiling() {
assert_eq!(route_model(TurnTier::Light, SONNET).as_deref(), Some(HAIKU));
assert_eq!(route_model(TurnTier::Standard, SONNET), None);
assert_eq!(route_model(TurnTier::Heavy, SONNET), None);
}
#[test]
fn haiku_session_never_overrides() {
for tier in [TurnTier::Light, TurnTier::Standard, TurnTier::Heavy] {
assert_eq!(route_model(tier, HAIKU), None, "{tier:?}");
}
}
#[test]
fn never_exceeds_ceiling_for_every_claude_session() {
fn rank(m: &str) -> u8 {
if m.contains("opus") {
2
} else if m.contains("sonnet") {
1
} else {
0
}
}
for session in [HAIKU, SONNET, OPUS] {
let ceiling = rank(session);
for tier in [TurnTier::Light, TurnTier::Standard, TurnTier::Heavy] {
let resolved = route_model(tier, session);
let applied = resolved.as_deref().unwrap_or(session);
assert!(
rank(applied) <= ceiling,
"session {session} tier {tier:?} routed to {applied} (rank {} > ceiling {ceiling})",
rank(applied)
);
if let Some(id) = &resolved {
assert!(id.starts_with("claude-"), "crossed backend: {id}");
}
}
}
}
#[test]
fn override_when_present_is_a_real_change() {
for session in [HAIKU, SONNET, OPUS] {
for tier in [TurnTier::Light, TurnTier::Standard, TurnTier::Heavy] {
if let Some(id) = route_model(tier, session) {
assert_ne!(id, session, "{session}/{tier:?} returned the session model");
}
}
}
}
}
#[cfg(feature = "anthropic")]
mod consult {
use super::*;
use crate::backends::anthropic::{
DEFAULT_MODEL as HAIKU, OPUS_MODEL as OPUS, SONNET_MODEL as SONNET,
};
#[test]
fn known_models_pick_the_right_backend() {
assert_eq!(
select_consult_backend(crate::types::DEFAULT_MODEL).unwrap(),
ConsultBackend::Gemini
);
for claude in [HAIKU, SONNET, OPUS] {
assert_eq!(
select_consult_backend(claude).unwrap(),
ConsultBackend::Anthropic,
"{claude}"
);
}
for (id, _) in CONSULT_MODELS {
assert!(select_consult_backend(id).is_ok(), "{id}");
}
}
#[test]
fn unknown_or_unsupported_models_are_rejected() {
for bad in [
"gemma-3-270m", "gpt-5-nano", "claude-imaginary-9", "gemini-2.5-flash", "", "garbage",
] {
let err = select_consult_backend(bad).unwrap_err();
assert!(
err.to_string().contains("unsupported model"),
"{bad}: {err}"
);
}
}
}
}