1use crate::error::LlmError;
2
3pub trait LlmClient: Send + Sync {
8 fn complete(
10 &self,
11 prompt: &str,
12 system: Option<&str>,
13 ) -> impl Future<Output = Result<String, LlmError>> + Send;
14
15 fn embed(&self, text: &str) -> impl Future<Output = Result<Vec<f32>, LlmError>> + Send;
17}
18
19use std::future::Future;
20
21pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
23
24pub fn anthropic_base_url() -> String {
31 let raw = std::env::var("ANTHROPIC_BASE_URL")
32 .unwrap_or_else(|_| ANTHROPIC_DEFAULT_BASE_URL.to_string());
33 raw.trim_end_matches('/').to_string()
34}
35
36#[allow(clippy::collapsible_if)]
41pub fn is_reasoning_model(model: &str) -> bool {
42 let m = model.to_lowercase();
43
44 if m.contains("opus") {
45 return true;
46 }
47 if m.contains("gpt-5") || m.contains("o3") || m.contains("o4") {
48 return true;
49 }
50 if m.contains("gemini") && m.contains("pro") {
51 if let Some(start) = m.find(|c: char| c.is_ascii_digit()) {
54 let tail = &m[start..];
55 let end = tail
56 .find(|c: char| !c.is_ascii_digit())
57 .unwrap_or(tail.len());
58 if let Ok(v) = tail[..end].parse::<u32>()
59 && v >= 3
60 {
61 return true;
62 }
63 }
64 }
65 if m.contains("reasoning") || m.contains("think") {
66 return true;
67 }
68 false
69}
70
71#[derive(
82 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
83)]
84#[serde(rename_all = "lowercase")]
85pub enum Effort {
86 Low,
87 Medium,
88 High,
89 Xhigh,
90 Max,
91}
92
93impl Effort {
94 pub const ALL: &'static [Effort] = &[
97 Effort::Low,
98 Effort::Medium,
99 Effort::High,
100 Effort::Xhigh,
101 Effort::Max,
102 ];
103
104 pub fn as_str(self) -> &'static str {
105 match self {
106 Effort::Low => "low",
107 Effort::Medium => "medium",
108 Effort::High => "high",
109 Effort::Xhigh => "xhigh",
110 Effort::Max => "max",
111 }
112 }
113}
114
115impl std::str::FromStr for Effort {
116 type Err = String;
117
118 fn from_str(s: &str) -> Result<Self, Self::Err> {
119 let want = s.trim().to_lowercase();
120 Effort::ALL
121 .iter()
122 .copied()
123 .find(|e| e.as_str() == want)
124 .ok_or_else(|| {
125 let valid: Vec<&str> = Effort::ALL.iter().map(|e| e.as_str()).collect();
126 format!("unknown effort '{s}' (valid: {})", valid.join(", "))
127 })
128 }
129}
130
131pub fn supported_effort(model: &str, want: Effort) -> Option<Effort> {
146 const FULL_SCALE: &[&str] = &[
148 "claude-opus-5",
149 "claude-opus-4-8",
150 "claude-opus-4-7",
151 "claude-sonnet-5",
152 "claude-fable-5",
153 "claude-mythos-5",
154 ];
155 const NO_XHIGH: &[&str] = &["claude-opus-4-6", "claude-sonnet-4-6", "claude-opus-4-5"];
157
158 let m = model.to_lowercase();
159 if FULL_SCALE.iter().any(|p| m.starts_with(p)) {
160 return Some(want);
161 }
162 if NO_XHIGH.iter().any(|p| m.starts_with(p)) {
163 return Some(match want {
164 Effort::Xhigh => Effort::High,
165 other => other,
166 });
167 }
168 None
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn supported_effort_narrows_per_model_capability() {
179 assert_eq!(
181 supported_effort("claude-opus-5", Effort::Xhigh),
182 Some(Effort::Xhigh)
183 );
184 assert_eq!(
185 supported_effort("claude-sonnet-5", Effort::Low),
186 Some(Effort::Low)
187 );
188 assert_eq!(
190 supported_effort("claude-opus-5-preview", Effort::Max),
191 Some(Effort::Max)
192 );
193 assert_eq!(
196 supported_effort("claude-opus-4-6", Effort::Xhigh),
197 Some(Effort::High)
198 );
199 assert_eq!(
201 supported_effort("claude-opus-4-6", Effort::Max),
202 Some(Effort::Max)
203 );
204 assert_eq!(supported_effort("claude-haiku-4-5", Effort::Low), None);
206 assert_eq!(supported_effort("claude-sonnet-4-5", Effort::High), None);
207 assert_eq!(supported_effort("llama3.2:3b", Effort::Low), None);
209 assert_eq!(supported_effort("gpt-5", Effort::Low), None);
210 }
211
212 #[test]
213 fn effort_strings_match_the_api_scale() {
214 assert_eq!(Effort::Low.as_str(), "low");
215 assert_eq!(Effort::Xhigh.as_str(), "xhigh");
216 assert_eq!(Effort::Max.as_str(), "max");
217 assert!(Effort::Low < Effort::High && Effort::High < Effort::Max);
219 }
220
221 #[test]
222 fn test_is_reasoning_model() {
223 assert!(is_reasoning_model("claude-opus-5"));
225 assert!(is_reasoning_model("claude-opus-4-20250514"));
226
227 assert!(is_reasoning_model("gpt-5"));
229 assert!(is_reasoning_model("chatgpt-5.4"));
230 assert!(is_reasoning_model("o3-mini"));
231 assert!(is_reasoning_model("o4-preview"));
232
233 assert!(is_reasoning_model("gemini-pro-3.5"));
235 assert!(is_reasoning_model("gemini-pro-3"));
236 assert!(is_reasoning_model("gemini-3.5-pro"));
237 assert!(!is_reasoning_model("gemini-2.5-pro"));
238 assert!(!is_reasoning_model("gemini-pro-2"));
239 assert!(!is_reasoning_model("gemini-pro-1.5"));
240
241 assert!(is_reasoning_model("deepseek-reasoning-v2"));
243 assert!(is_reasoning_model("qwen-thinking-32b"));
244
245 assert!(!is_reasoning_model("claude-sonnet-4-20250514"));
247 assert!(!is_reasoning_model("gpt-4o"));
248 assert!(!is_reasoning_model("gemini-flash-2"));
249 assert!(!is_reasoning_model("llama3"));
250 }
251}