use crate::error::LlmError;
pub trait LlmClient: Send + Sync {
fn complete(
&self,
prompt: &str,
system: Option<&str>,
) -> impl Future<Output = Result<String, LlmError>> + Send;
fn embed(&self, text: &str) -> impl Future<Output = Result<Vec<f32>, LlmError>> + Send;
}
use std::future::Future;
pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
pub fn anthropic_base_url() -> String {
let raw = std::env::var("ANTHROPIC_BASE_URL")
.unwrap_or_else(|_| ANTHROPIC_DEFAULT_BASE_URL.to_string());
raw.trim_end_matches('/').to_string()
}
#[allow(clippy::collapsible_if)]
pub fn is_reasoning_model(model: &str) -> bool {
let m = model.to_lowercase();
if m.contains("opus") {
return true;
}
if m.contains("gpt-5") || m.contains("o3") || m.contains("o4") {
return true;
}
if m.contains("gemini") && m.contains("pro") {
if let Some(start) = m.find(|c: char| c.is_ascii_digit()) {
let tail = &m[start..];
let end = tail
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(tail.len());
if let Ok(v) = tail[..end].parse::<u32>()
&& v >= 3
{
return true;
}
}
}
if m.contains("reasoning") || m.contains("think") {
return true;
}
false
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "lowercase")]
pub enum Effort {
Low,
Medium,
High,
Xhigh,
Max,
}
impl Effort {
pub const ALL: &'static [Effort] = &[
Effort::Low,
Effort::Medium,
Effort::High,
Effort::Xhigh,
Effort::Max,
];
pub fn as_str(self) -> &'static str {
match self {
Effort::Low => "low",
Effort::Medium => "medium",
Effort::High => "high",
Effort::Xhigh => "xhigh",
Effort::Max => "max",
}
}
}
impl std::str::FromStr for Effort {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let want = s.trim().to_lowercase();
Effort::ALL
.iter()
.copied()
.find(|e| e.as_str() == want)
.ok_or_else(|| {
let valid: Vec<&str> = Effort::ALL.iter().map(|e| e.as_str()).collect();
format!("unknown effort '{s}' (valid: {})", valid.join(", "))
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EffortShape {
Graded(&'static [Effort]),
Binary { on_at: Effort },
Budget(&'static [Effort]),
AlwaysOn,
None,
}
const LEVELS_ALL: &[Effort] = &[
Effort::Low,
Effort::Medium,
Effort::High,
Effort::Xhigh,
Effort::Max,
];
const LEVELS_NO_XHIGH: &[Effort] = &[Effort::Low, Effort::Medium, Effort::High, Effort::Max];
const LEVELS_LMHX: &[Effort] = &[Effort::Low, Effort::Medium, Effort::High, Effort::Xhigh];
const LEVELS_LMH: &[Effort] = &[Effort::Low, Effort::Medium, Effort::High];
const LEVELS_DEEPSEEK: &[Effort] = &[Effort::Low, Effort::High, Effort::Max];
const LEVELS_BINARY: &[Effort] = &[Effort::Low, Effort::High];
impl EffortShape {
pub fn levels(&self) -> &'static [Effort] {
match self {
EffortShape::Graded(l) | EffortShape::Budget(l) => l,
EffortShape::Binary { .. } => LEVELS_BINARY,
EffortShape::AlwaysOn | EffortShape::None => &[],
}
}
}
pub fn effort_shape(model: &str) -> EffortShape {
const ANTHROPIC_FULL: &[&str] = &[
"claude-opus-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-sonnet-5",
"claude-fable-5",
"claude-mythos-5",
];
const ANTHROPIC_NO_XHIGH: &[&str] =
&["claude-opus-4-6", "claude-sonnet-4-6", "claude-opus-4-5"];
const OPENAI_REASONING: &[&str] = &["gpt-5", "o1", "o3", "o4"];
const DEEPSEEK_GRADED: &[&str] = &["deepseek-v4"];
const GROK_XHIGH: &[&str] = &["grok-4.6", "grok-4.7"];
const GROK_GRADED: &[&str] = &["grok-4.3", "grok-4.5"];
const GEMINI_LEVEL: &[&str] = &["gemini-3"];
const GEMINI_BUDGET: &[&str] = &["gemini-2.5"];
const MISTRAL_GRADED: &[&str] = &["mistral-small-3", "mistral-small-4"];
const ALWAYS_ON: &[&str] = &["magistral"];
const BINARY: &[&str] = &["qwen3", "glm-"];
let m = model.to_lowercase();
let bare = m.rsplit('/').next().unwrap_or(&m);
let has = |prefixes: &[&str]| prefixes.iter().any(|p| bare.starts_with(p));
if has(ALWAYS_ON) {
return EffortShape::AlwaysOn;
}
if has(ANTHROPIC_FULL) {
return EffortShape::Graded(LEVELS_ALL);
}
if has(ANTHROPIC_NO_XHIGH) {
return EffortShape::Graded(LEVELS_NO_XHIGH);
}
if has(OPENAI_REASONING) || has(GROK_GRADED) {
return EffortShape::Graded(LEVELS_LMH);
}
if has(GROK_XHIGH) {
return EffortShape::Graded(LEVELS_LMHX);
}
if has(DEEPSEEK_GRADED) {
return EffortShape::Graded(LEVELS_DEEPSEEK);
}
if has(GEMINI_LEVEL) || has(MISTRAL_GRADED) {
return EffortShape::Graded(LEVELS_LMH);
}
if has(GEMINI_BUDGET) {
return EffortShape::Budget(LEVELS_LMH);
}
if has(BINARY) {
return EffortShape::Binary {
on_at: Effort::Medium,
};
}
EffortShape::None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EffortSource {
SessionOverride,
Profile,
Unset,
}
pub fn effective_effort(
session: Option<Effort>,
profile: Option<Effort>,
model: &str,
) -> (Option<Effort>, EffortSource) {
let (want, source) = match (session, profile) {
(Some(e), _) => (e, EffortSource::SessionOverride),
(None, Some(e)) => (e, EffortSource::Profile),
(None, None) => return (None, EffortSource::Unset),
};
let levels = effort_shape(model).levels();
if levels.is_empty() {
return (None, EffortSource::Unset);
}
if levels.contains(&want) {
return (Some(want), source);
}
match levels.iter().rev().find(|l| **l < want).copied() {
Some(narrowed) => (Some(narrowed), source),
None => (levels.first().copied(), source),
}
}
pub fn supported_effort(model: &str, want: Effort) -> Option<Effort> {
const ANTHROPIC: &str = "claude-";
let m = model.to_lowercase();
let bare = m.rsplit('/').next().unwrap_or(&m);
if !bare.starts_with(ANTHROPIC) {
return None;
}
let levels = match effort_shape(model) {
EffortShape::Graded(l) => l,
_ => return None,
};
if levels.contains(&want) {
return Some(want);
}
levels.iter().rev().find(|l| **l < want).copied()
}
pub fn openai_reasoning_effort(model: &str, want: Effort) -> Option<&'static str> {
const REASONING_FAMILIES: &[&str] = &["gpt-5", "o1", "o3", "o4"];
let m = model.to_lowercase();
let bare = m.rsplit('/').next().unwrap_or(&m);
if !REASONING_FAMILIES.iter().any(|f| bare.starts_with(f)) {
return None;
}
if !matches!(effort_shape(model), EffortShape::Graded(_)) {
return None;
}
Some(match want {
Effort::Low => "low",
Effort::Medium => "medium",
Effort::High | Effort::Xhigh | Effort::Max => "high",
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn effective_effort_reports_value_and_where_it_came_from() {
use EffortSource::*;
assert_eq!(
effective_effort(Some(Effort::Low), Some(Effort::Max), "claude-opus-5"),
(Some(Effort::Low), SessionOverride)
);
assert_eq!(
effective_effort(None, Some(Effort::Max), "claude-opus-5"),
(Some(Effort::Max), Profile)
);
assert_eq!(effective_effort(None, None, "claude-opus-5"), (None, Unset));
assert_eq!(
effective_effort(None, Some(Effort::Medium), "deepseek-v4-pro"),
(Some(Effort::Low), Profile)
);
assert_eq!(
effective_effort(Some(Effort::Max), Some(Effort::Max), "gpt-4o"),
(None, Unset)
);
}
#[test]
fn delegation_preserves_the_hole_in_the_pre_4_7_scale() {
assert_eq!(
supported_effort("claude-opus-4-6", Effort::Xhigh),
Some(Effort::High)
);
assert_eq!(
supported_effort("claude-opus-4-6", Effort::Max),
Some(Effort::Max)
);
assert_eq!(
supported_effort("claude-opus-5", Effort::Xhigh),
Some(Effort::Xhigh)
);
assert_eq!(
supported_effort("magistral-small-latest", Effort::High),
None
);
assert_eq!(
openai_reasoning_effort("magistral-small-latest", Effort::High),
None
);
}
#[test]
fn effort_shape_covers_each_vendor_tier() {
use EffortShape::*;
assert!(matches!(effort_shape("claude-opus-5"), Graded(l) if l.len() == 5));
assert!(matches!(effort_shape("claude-opus-4-6"), Graded(l) if l.len() == 4));
assert!(matches!(effort_shape("claude-opus-4-6"), Graded(l) if l.contains(&Effort::Max)));
assert!(
matches!(effort_shape("claude-opus-4-6"), Graded(l) if !l.contains(&Effort::Xhigh))
);
assert!(matches!(effort_shape("gpt-5"), Graded(_)));
assert!(matches!(effort_shape("grok-4.6"), Graded(l) if l.contains(&Effort::Xhigh)));
assert!(matches!(effort_shape("grok-4.5"), Graded(l) if !l.contains(&Effort::Xhigh)));
assert!(matches!(effort_shape("gemini-3-pro"), Graded(_)));
assert!(matches!(effort_shape("gemini-2.5-pro"), Budget(_)));
assert!(matches!(effort_shape("qwen3-32b"), Binary { .. }));
assert_eq!(effort_shape("qwen3-32b").levels().len(), 2);
assert!(matches!(effort_shape("glm-4.6"), Binary { .. }));
assert!(matches!(effort_shape("magistral-medium-latest"), AlwaysOn));
assert!(matches!(effort_shape("gpt-4o"), None));
assert!(matches!(effort_shape("llama3.2:3b"), None));
}
#[test]
fn effort_shape_negative_cases() {
use EffortShape::*;
let EffortShape::Graded(levels) = effort_shape("deepseek-v4-pro") else {
panic!("deepseek-v4-pro must be Graded");
};
assert!(
!levels.contains(&Effort::Medium),
"deepseek has no medium: {levels:?}"
);
assert_eq!(levels, &[Effort::Low, Effort::High, Effort::Max]);
assert!(matches!(effort_shape("openai/gpt-5"), Graded(_)));
assert!(matches!(effort_shape("google/gemini-3.6-flash"), Graded(_)));
assert!(!matches!(
effort_shape("google/gemini-2.5-flash"),
Graded(_)
));
assert!(matches!(effort_shape("magistral-small-latest"), AlwaysOn));
assert!(effort_shape("magistral-small-latest").levels().is_empty());
}
#[test]
fn supported_effort_narrows_per_model_capability() {
assert_eq!(
supported_effort("claude-opus-5", Effort::Xhigh),
Some(Effort::Xhigh)
);
assert_eq!(
supported_effort("claude-sonnet-5", Effort::Low),
Some(Effort::Low)
);
assert_eq!(
supported_effort("claude-opus-5-preview", Effort::Max),
Some(Effort::Max)
);
assert_eq!(
supported_effort("claude-opus-4-6", Effort::Xhigh),
Some(Effort::High)
);
assert_eq!(
supported_effort("claude-opus-4-6", Effort::Max),
Some(Effort::Max)
);
assert_eq!(supported_effort("claude-haiku-4-5", Effort::Low), None);
assert_eq!(supported_effort("claude-sonnet-4-5", Effort::High), None);
assert_eq!(supported_effort("llama3.2:3b", Effort::Low), None);
assert_eq!(supported_effort("gpt-5", Effort::Low), None);
}
#[test]
fn effort_strings_match_the_api_scale() {
assert_eq!(Effort::Low.as_str(), "low");
assert_eq!(Effort::Xhigh.as_str(), "xhigh");
assert_eq!(Effort::Max.as_str(), "max");
assert!(Effort::Low < Effort::High && Effort::High < Effort::Max);
}
#[test]
fn openai_effort_gates_on_family_and_clamps_the_top() {
assert_eq!(openai_reasoning_effort("gpt-5", Effort::Low), Some("low"));
assert_eq!(
openai_reasoning_effort("o3-mini", Effort::Medium),
Some("medium")
);
assert_eq!(
openai_reasoning_effort("gpt-5", Effort::Xhigh),
Some("high")
);
assert_eq!(openai_reasoning_effort("gpt-5", Effort::Max), Some("high"));
assert_eq!(
openai_reasoning_effort("openai/gpt-5", Effort::Low),
Some("low")
);
assert_eq!(
openai_reasoning_effort("google/gemini-3.6-flash", Effort::Low),
None
);
assert_eq!(openai_reasoning_effort("llama3.2:3b", Effort::Low), None);
assert_eq!(openai_reasoning_effort("gpt-4o", Effort::Low), None);
}
#[test]
fn test_is_reasoning_model() {
assert!(is_reasoning_model("claude-opus-5"));
assert!(is_reasoning_model("claude-opus-4-20250514"));
assert!(is_reasoning_model("gpt-5"));
assert!(is_reasoning_model("chatgpt-5.4"));
assert!(is_reasoning_model("o3-mini"));
assert!(is_reasoning_model("o4-preview"));
assert!(is_reasoning_model("gemini-pro-3.5"));
assert!(is_reasoning_model("gemini-pro-3"));
assert!(is_reasoning_model("gemini-3.5-pro"));
assert!(!is_reasoning_model("gemini-2.5-pro"));
assert!(!is_reasoning_model("gemini-pro-2"));
assert!(!is_reasoning_model("gemini-pro-1.5"));
assert!(is_reasoning_model("deepseek-reasoning-v2"));
assert!(is_reasoning_model("qwen-thinking-32b"));
assert!(!is_reasoning_model("claude-sonnet-4-20250514"));
assert!(!is_reasoning_model("gpt-4o"));
assert!(!is_reasoning_model("gemini-flash-2"));
assert!(!is_reasoning_model("llama3"));
}
}