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(", "))
})
}
}
pub fn supported_effort(model: &str, want: Effort) -> Option<Effort> {
const FULL_SCALE: &[&str] = &[
"claude-opus-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-sonnet-5",
"claude-fable-5",
"claude-mythos-5",
];
const NO_XHIGH: &[&str] = &["claude-opus-4-6", "claude-sonnet-4-6", "claude-opus-4-5"];
let m = model.to_lowercase();
if FULL_SCALE.iter().any(|p| m.starts_with(p)) {
return Some(want);
}
if NO_XHIGH.iter().any(|p| m.starts_with(p)) {
return Some(match want {
Effort::Xhigh => Effort::High,
other => other,
});
}
None
}
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;
}
Some(match want {
Effort::Low => "low",
Effort::Medium => "medium",
Effort::High | Effort::Xhigh | Effort::Max => "high",
})
}
#[cfg(test)]
mod tests {
use super::*;
#[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"));
}
}