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/// The `reasoning_effort` value to send for an OpenAI-compatible model, or
174/// `None` when the model takes no such parameter.
175///
176/// Verified against the current OpenAI reasoning guide rather than recalled:
177/// the parameter is `reasoning.effort` (accepted as `reasoning_effort` on the
178/// chat endpoint) and its vocabulary is `none | minimal | low | medium | high
179/// | xhigh | max` — a superset of ours, not the three levels an older memory
180/// would suggest. Checking mattered: building the mapping from that memory
181/// would have clamped `xhigh` away for no reason.
182///
183/// Two deliberate narrowings, both because this client is not "OpenAI" — it is
184/// *anything OpenAI-compatible*, including OpenRouter and local servers:
185///
186/// * Gated on the model family, not the provider. A local llama behind an
187///   OpenAI-shaped endpoint has no idea what `reasoning_effort` means.
188/// * `Xhigh`/`Max` clamp to `high`. The docs state the accepted values are
189///   model-dependent and publish no table, so passing the top of the scale
190///   through would be a 400 waiting for whichever model lacks it. Degrading is
191///   the same call made for Anthropic's missing `xhigh` step — and the same
192///   one made everywhere today: never fail the default path, lose a little
193///   depth instead. Lift the clamp per-model once a support table exists.
194pub fn openai_reasoning_effort(model: &str, want: Effort) -> Option<&'static str> {
195    /// Model families that take a reasoning effort. Prefixes, matched after
196    /// any `vendor/` prefix is stripped — OpenRouter names models
197    /// `openai/gpt-5`, and `google/gemini-3.6-flash` must NOT match.
198    const REASONING_FAMILIES: &[&str] = &["gpt-5", "o1", "o3", "o4"];
199
200    let m = model.to_lowercase();
201    let bare = m.rsplit('/').next().unwrap_or(&m);
202    if !REASONING_FAMILIES.iter().any(|f| bare.starts_with(f)) {
203        return None;
204    }
205    Some(match want {
206        Effort::Low => "low",
207        Effort::Medium => "medium",
208        Effort::High | Effort::Xhigh | Effort::Max => "high",
209    })
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn supported_effort_narrows_per_model_capability() {
218        // Full scale: passed through unchanged.
219        assert_eq!(
220            supported_effort("claude-opus-5", Effort::Xhigh),
221            Some(Effort::Xhigh)
222        );
223        assert_eq!(
224            supported_effort("claude-sonnet-5", Effort::Low),
225            Some(Effort::Low)
226        );
227        // Prefix match, so dated or suffixed variants resolve the same.
228        assert_eq!(
229            supported_effort("claude-opus-5-preview", Effort::Max),
230            Some(Effort::Max)
231        );
232        // No `xhigh` step before Opus 4.7 — downgrade to the cheaper neighbour
233        // rather than 400.
234        assert_eq!(
235            supported_effort("claude-opus-4-6", Effort::Xhigh),
236            Some(Effort::High)
237        );
238        // …but the levels it does have pass through.
239        assert_eq!(
240            supported_effort("claude-opus-4-6", Effort::Max),
241            Some(Effort::Max)
242        );
243        // Models with no effort parameter: drop the field entirely.
244        assert_eq!(supported_effort("claude-haiku-4-5", Effort::Low), None);
245        assert_eq!(supported_effort("claude-sonnet-4-5", Effort::High), None);
246        // Non-Anthropic models never carry it.
247        assert_eq!(supported_effort("llama3.2:3b", Effort::Low), None);
248        assert_eq!(supported_effort("gpt-5", Effort::Low), None);
249    }
250
251    #[test]
252    fn effort_strings_match_the_api_scale() {
253        assert_eq!(Effort::Low.as_str(), "low");
254        assert_eq!(Effort::Xhigh.as_str(), "xhigh");
255        assert_eq!(Effort::Max.as_str(), "max");
256        // Ordered cheapest-first so a caller can clamp with `min`.
257        assert!(Effort::Low < Effort::High && Effort::High < Effort::Max);
258    }
259
260    #[test]
261    fn openai_effort_gates_on_family_and_clamps_the_top() {
262        // The three shared levels pass through by name.
263        assert_eq!(openai_reasoning_effort("gpt-5", Effort::Low), Some("low"));
264        assert_eq!(
265            openai_reasoning_effort("o3-mini", Effort::Medium),
266            Some("medium")
267        );
268        // Top of the scale degrades rather than risking a 400 on a model whose
269        // subset lacks it — the accepted values are model-dependent and there
270        // is no published table to key on.
271        assert_eq!(
272            openai_reasoning_effort("gpt-5", Effort::Xhigh),
273            Some("high")
274        );
275        assert_eq!(openai_reasoning_effort("gpt-5", Effort::Max), Some("high"));
276        // OpenRouter prefixes its models; the family gate must see through it…
277        assert_eq!(
278            openai_reasoning_effort("openai/gpt-5", Effort::Low),
279            Some("low")
280        );
281        // …without letting a non-OpenAI model routed the same way through.
282        assert_eq!(
283            openai_reasoning_effort("google/gemini-3.6-flash", Effort::Low),
284            None
285        );
286        // A local model behind an OpenAI-shaped endpoint takes no such param.
287        assert_eq!(openai_reasoning_effort("llama3.2:3b", Effort::Low), None);
288        assert_eq!(openai_reasoning_effort("gpt-4o", Effort::Low), None);
289    }
290
291    #[test]
292    fn test_is_reasoning_model() {
293        // Anthropic opus models
294        assert!(is_reasoning_model("claude-opus-5"));
295        assert!(is_reasoning_model("claude-opus-4-20250514"));
296
297        // OpenAI reasoning models
298        assert!(is_reasoning_model("gpt-5"));
299        assert!(is_reasoning_model("chatgpt-5.4"));
300        assert!(is_reasoning_model("o3-mini"));
301        assert!(is_reasoning_model("o4-preview"));
302
303        // Gemini pro >= 3 (version before or after the tier)
304        assert!(is_reasoning_model("gemini-pro-3.5"));
305        assert!(is_reasoning_model("gemini-pro-3"));
306        assert!(is_reasoning_model("gemini-3.5-pro"));
307        assert!(!is_reasoning_model("gemini-2.5-pro"));
308        assert!(!is_reasoning_model("gemini-pro-2"));
309        assert!(!is_reasoning_model("gemini-pro-1.5"));
310
311        // Generic reasoning/thinking
312        assert!(is_reasoning_model("deepseek-reasoning-v2"));
313        assert!(is_reasoning_model("qwen-thinking-32b"));
314
315        // Non-recommended
316        assert!(!is_reasoning_model("claude-sonnet-4-20250514"));
317        assert!(!is_reasoning_model("gpt-4o"));
318        assert!(!is_reasoning_model("gemini-flash-2"));
319        assert!(!is_reasoning_model("llama3"));
320    }
321}