Skip to main content

mur_common/
llm.rs

1use crate::error::LlmError;
2
3/// Trait for LLM providers (Anthropic, OpenAI, Ollama).
4/// Shared between mur-core and mur-commander.
5///
6/// Edition 2024 supports async fn in traits natively.
7pub trait LlmClient: Send + Sync {
8    /// Text completion
9    fn complete(
10        &self,
11        prompt: &str,
12        system: Option<&str>,
13    ) -> impl Future<Output = Result<String, LlmError>> + Send;
14
15    /// Generate embedding vector
16    fn embed(&self, text: &str) -> impl Future<Output = Result<Vec<f32>, LlmError>> + Send;
17}
18
19use std::future::Future;
20
21/// Default Anthropic API base URL.
22pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
23
24/// Resolve the Anthropic API base URL from `ANTHROPIC_BASE_URL` env, with a
25/// trailing slash stripped. Falls back to `ANTHROPIC_DEFAULT_BASE_URL`.
26///
27/// Honored at every upstream call site so that users can route Anthropic
28/// traffic through Bedrock, Vertex, a corporate egress proxy, an external
29/// auth bridge, or test fixtures without touching code.
30pub 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/// Check if a model name matches recommended reasoning models for session analysis.
37///
38/// Recommended: Anthropic Opus, OpenAI GPT-5/O3/O4, Gemini Pro 3+,
39/// or any model with "reasoning" or "think" in the name.
40#[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        // The version may follow ("gemini-pro-3.5") or precede ("gemini-3.5-pro")
52        // the tier, so take the major version from the first number in the name.
53        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/// How hard the model should work on a request — the Anthropic
72/// `output_config.effort` scale.
73///
74/// Effort is a property of the JOB, not of the model: the same model routing a
75/// one-line JSON plan and the same model doing a multi-file refactor want
76/// different levels. Set it at the call site that knows what it is asking for.
77///
78/// Note that NOT sending effort is not neutral — the API default is `High`.
79/// Every call that leaves it unset is already paying for high effort, so the
80/// useful direction for mechanical work is *down*.
81#[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    /// Every level, cheapest first — for `--help` text and error messages, so
95    /// the valid set is never spelled out in two places.
96    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
131/// The effort level to actually send for `model`, or `None` when the model
132/// takes no effort parameter at all.
133///
134/// Sending an unsupported level is a 400, and the support matrix is per-model:
135/// the `xhigh` step arrived with Opus 4.7, so older models that otherwise
136/// accept effort reject it, and models before the 4.6 line reject the
137/// parameter outright. Rather than let each call site memorise that, requests
138/// state the effort they *want* and this narrows it — downgrading `xhigh` to
139/// `high` (the cheaper neighbour) and dropping the field entirely where it
140/// isn't understood.
141///
142/// Deliberately shaped like the sampling-param guard next door: one named
143/// list, one place to edit when a model ships, rather than a literal model ID
144/// buried in a conditional that goes stale on the next release.
145pub fn supported_effort(model: &str, want: Effort) -> Option<Effort> {
146    /// Accept every level including `xhigh` (Opus 4.7 and later lines).
147    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    /// Accept effort but have no `xhigh` step.
156    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    // Everything else — older Claude models, and any non-Anthropic model that
169    // reaches this path — takes no effort parameter.
170    None
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn supported_effort_narrows_per_model_capability() {
179        // Full scale: passed through unchanged.
180        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        // Prefix match, so dated or suffixed variants resolve the same.
189        assert_eq!(
190            supported_effort("claude-opus-5-preview", Effort::Max),
191            Some(Effort::Max)
192        );
193        // No `xhigh` step before Opus 4.7 — downgrade to the cheaper neighbour
194        // rather than 400.
195        assert_eq!(
196            supported_effort("claude-opus-4-6", Effort::Xhigh),
197            Some(Effort::High)
198        );
199        // …but the levels it does have pass through.
200        assert_eq!(
201            supported_effort("claude-opus-4-6", Effort::Max),
202            Some(Effort::Max)
203        );
204        // Models with no effort parameter: drop the field entirely.
205        assert_eq!(supported_effort("claude-haiku-4-5", Effort::Low), None);
206        assert_eq!(supported_effort("claude-sonnet-4-5", Effort::High), None);
207        // Non-Anthropic models never carry it.
208        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        // Ordered cheapest-first so a caller can clamp with `min`.
218        assert!(Effort::Low < Effort::High && Effort::High < Effort::Max);
219    }
220
221    #[test]
222    fn test_is_reasoning_model() {
223        // Anthropic opus models
224        assert!(is_reasoning_model("claude-opus-5"));
225        assert!(is_reasoning_model("claude-opus-4-20250514"));
226
227        // OpenAI reasoning models
228        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        // Gemini pro >= 3 (version before or after the tier)
234        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        // Generic reasoning/thinking
242        assert!(is_reasoning_model("deepseek-reasoning-v2"));
243        assert!(is_reasoning_model("qwen-thinking-32b"));
244
245        // Non-recommended
246        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}