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}