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/// What form of reasoning control a model accepts.
132///
133/// Provider controls do not share a shape, and three of these cannot be
134/// expressed by a level-to-string table:
135///
136/// * [`EffortShape::AlwaysOn`] is not [`EffortShape::None`]. Mistral's
137/// Magistral models always reason and reject `reasoning_effort` with HTTP
138/// 422 — a hard failure, not a degradation. `None` means "passing nothing is
139/// correct"; `AlwaysOn` means "passing anything breaks every call".
140/// * [`EffortShape::Binary`] is not a degenerate `Graded`. Qwen and GLM have a
141/// switch, not a dial, and spell it differently (`chat_template_kwargs`
142/// versus `thinking: {type}`). Two other agent products shipped that exact
143/// confusion.
144/// * [`EffortShape::Budget`] takes an integer on the wire but still carries a
145/// level list, because the user and both UIs deal in levels — only the
146/// client converts, at the last possible moment.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum EffortShape {
149 /// Levels this model accepts, cheapest first.
150 Graded(&'static [Effort]),
151 /// Thinking is a switch; `on_at` is the lowest level that turns it on.
152 Binary { on_at: Effort },
153 /// Wants an integer token budget; these are the levels to offer.
154 Budget(&'static [Effort]),
155 /// Always reasons and rejects the parameter. Send nothing.
156 AlwaysOn,
157 /// No reasoning control.
158 None,
159}
160
161/// A level set is an arbitrary SUBSET of [`Effort::ALL`], not a prefix of it.
162/// Two tiers below have holes: Anthropic's pre-4.7 lines have `max` but no
163/// `xhigh` (that step was inserted between them in 4.7), and DeepSeek V4
164/// publishes low/high/max with no medium. Naming these by a ceiling —
165/// `LEVELS_TO_HIGH` and the like — is what produced a first draft that silently
166/// downgraded `max` to `high` on Opus 4.6. Name the membership, not a bound.
167const LEVELS_ALL: &[Effort] = &[
168 Effort::Low,
169 Effort::Medium,
170 Effort::High,
171 Effort::Xhigh,
172 Effort::Max,
173];
174/// Anthropic before 4.7: the whole scale except the `xhigh` step.
175const LEVELS_NO_XHIGH: &[Effort] = &[Effort::Low, Effort::Medium, Effort::High, Effort::Max];
176const LEVELS_LMHX: &[Effort] = &[Effort::Low, Effort::Medium, Effort::High, Effort::Xhigh];
177const LEVELS_LMH: &[Effort] = &[Effort::Low, Effort::Medium, Effort::High];
178/// DeepSeek V4 publishes low / high / max — there is no medium step.
179const LEVELS_DEEPSEEK: &[Effort] = &[Effort::Low, Effort::High, Effort::Max];
180/// A switch has two positions and must offer two, not three that collapse to
181/// two. `Low` is off, `High` is on; the threshold lives in `Binary::on_at`.
182const LEVELS_BINARY: &[Effort] = &[Effort::Low, Effort::High];
183
184impl EffortShape {
185 /// The levels a UI should offer. Empty for the two shapes that take no
186 /// level from the user.
187 pub fn levels(&self) -> &'static [Effort] {
188 match self {
189 EffortShape::Graded(l) | EffortShape::Budget(l) => l,
190 EffortShape::Binary { .. } => LEVELS_BINARY,
191 EffortShape::AlwaysOn | EffortShape::None => &[],
192 }
193 }
194}
195
196/// Which reasoning control `model` accepts.
197///
198/// Keyed on the model id with any `vendor/` prefix stripped, never on the
199/// registry's `provider:` field — that records the wire protocol, so DeepSeek,
200/// Qwen and every other OpenAI-compatible third party all read `openai`.
201///
202/// Capability is version-scoped, not family-scoped: grok-4.3, 4.5 and 4.6 each
203/// accept a different set, exactly as the Claude 4-5 / 4-6 / 4-7 / 5 lines do.
204/// Named const lists per tier, so a new model is one edit in one place.
205///
206/// Vendor levels below `Low` (`none`, `minimal`) are deliberately dropped:
207/// MUR's scale starts at `Low`, and a level MUR cannot name is a level MUR
208/// does not offer. Nothing is mis-sent.
209pub fn effort_shape(model: &str) -> EffortShape {
210 /// Anthropic lines with the `xhigh` step (Opus 4.7 and later).
211 const ANTHROPIC_FULL: &[&str] = &[
212 "claude-opus-5",
213 "claude-opus-4-8",
214 "claude-opus-4-7",
215 "claude-sonnet-5",
216 "claude-fable-5",
217 "claude-mythos-5",
218 ];
219 /// Anthropic lines that take effort but have no `xhigh` step.
220 const ANTHROPIC_NO_XHIGH: &[&str] =
221 &["claude-opus-4-6", "claude-sonnet-4-6", "claude-opus-4-5"];
222 /// OpenAI reasoning families. `xhigh`/`max` clamp to `high` at the wire.
223 const OPENAI_REASONING: &[&str] = &["gpt-5", "o1", "o3", "o4"];
224 /// DeepSeek V4 thinking effort.
225 const DEEPSEEK_GRADED: &[&str] = &["deepseek-v4"];
226 /// Grok lines that added the `xhigh` step.
227 const GROK_XHIGH: &[&str] = &["grok-4.6", "grok-4.7"];
228 /// Grok lines with low/medium/high only.
229 const GROK_GRADED: &[&str] = &["grok-4.3", "grok-4.5"];
230 /// Gemini 3+ takes `thinkingConfig.thinkingLevel` — a level name.
231 const GEMINI_LEVEL: &[&str] = &["gemini-3"];
232 /// Gemini 2.5 takes `thinkingConfig.thinkingBudget` — an integer.
233 const GEMINI_BUDGET: &[&str] = &["gemini-2.5"];
234 /// Mistral hybrids where `reasoning_effort` enables reasoning.
235 const MISTRAL_GRADED: &[&str] = &["mistral-small-3", "mistral-small-4"];
236 /// Dedicated reasoning models that REJECT the parameter with HTTP 422.
237 const ALWAYS_ON: &[&str] = &["magistral"];
238 /// Models whose only control is an on/off switch.
239 const BINARY: &[&str] = &["qwen3", "glm-"];
240
241 let m = model.to_lowercase();
242 let bare = m.rsplit('/').next().unwrap_or(&m);
243 let has = |prefixes: &[&str]| prefixes.iter().any(|p| bare.starts_with(p));
244
245 // AlwaysOn is checked first: sending anything to these is a hard error, so
246 // no later arm may claim them.
247 if has(ALWAYS_ON) {
248 return EffortShape::AlwaysOn;
249 }
250 if has(ANTHROPIC_FULL) {
251 return EffortShape::Graded(LEVELS_ALL);
252 }
253 // Anthropic pre-4.7 keeps `max`; it is only the `xhigh` step it lacks.
254 if has(ANTHROPIC_NO_XHIGH) {
255 return EffortShape::Graded(LEVELS_NO_XHIGH);
256 }
257 if has(OPENAI_REASONING) || has(GROK_GRADED) {
258 return EffortShape::Graded(LEVELS_LMH);
259 }
260 if has(GROK_XHIGH) {
261 return EffortShape::Graded(LEVELS_LMHX);
262 }
263 if has(DEEPSEEK_GRADED) {
264 return EffortShape::Graded(LEVELS_DEEPSEEK);
265 }
266 if has(GEMINI_LEVEL) || has(MISTRAL_GRADED) {
267 return EffortShape::Graded(LEVELS_LMH);
268 }
269 if has(GEMINI_BUDGET) {
270 return EffortShape::Budget(LEVELS_LMH);
271 }
272 if has(BINARY) {
273 return EffortShape::Binary {
274 on_at: Effort::Medium,
275 };
276 }
277 EffortShape::None
278}
279
280/// Where the effort in force came from, so a surface can say so instead of
281/// showing a bare level the user cannot account for.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum EffortSource {
284 /// Set for this conversation only (murmur `/effort`).
285 SessionOverride,
286 /// Stored on the agent profile.
287 Profile,
288 /// Nothing set — the provider's own default applies. Note that is not
289 /// "no effort": the API default is high.
290 Unset,
291}
292
293/// The effort actually in force for `model`, and where it came from.
294///
295/// The ONLY derivation of this. murmur and the Hub both call it; neither
296/// computes its own, because two surfaces answering one question differently
297/// is a failure this codebase has already shipped twice.
298///
299/// A stored level the model cannot accept is narrowed to the nearest level it
300/// can, and the source is preserved — the user did set it; they are simply
301/// getting the closest thing that will not 400.
302pub fn effective_effort(
303 session: Option<Effort>,
304 profile: Option<Effort>,
305 model: &str,
306) -> (Option<Effort>, EffortSource) {
307 let (want, source) = match (session, profile) {
308 (Some(e), _) => (e, EffortSource::SessionOverride),
309 (None, Some(e)) => (e, EffortSource::Profile),
310 (None, None) => return (None, EffortSource::Unset),
311 };
312 let levels = effort_shape(model).levels();
313 if levels.is_empty() {
314 return (None, EffortSource::Unset);
315 }
316 if levels.contains(&want) {
317 return (Some(want), source);
318 }
319 match levels.iter().rev().find(|l| **l < want).copied() {
320 Some(narrowed) => (Some(narrowed), source),
321 // `want` is below every level this model offers; take its cheapest.
322 None => (levels.first().copied(), source),
323 }
324}
325
326/// The effort level to actually send for `model`, or `None` when the model
327/// takes no effort parameter at all.
328///
329/// Sending an unsupported level is a 400, and the support matrix is per-model:
330/// the `xhigh` step arrived with Opus 4.7, so older models that otherwise
331/// accept effort reject it, and models before the 4.6 line reject the
332/// parameter outright. Rather than let each call site memorise that, requests
333/// state the effort they *want* and this narrows it — downgrading `xhigh` to
334/// `high` (the cheaper neighbour) and dropping the field entirely where it
335/// isn't understood.
336///
337/// Deliberately shaped like the sampling-param guard next door: one named
338/// list, one place to edit when a model ships, rather than a literal model ID
339/// buried in a conditional that goes stale on the next release.
340pub fn supported_effort(model: &str, want: Effort) -> Option<Effort> {
341 /// This mapper is Anthropic's. [`effort_shape`] is vendor-neutral — it
342 /// answers "which levels", never "whose client is this" — so the vendor
343 /// gate stays here, symmetric with `openai_reasoning_effort`'s family
344 /// gate. Without it a delegated `supported_effort` starts claiming
345 /// `gpt-5`, which an existing test caught immediately.
346 const ANTHROPIC: &str = "claude-";
347
348 let m = model.to_lowercase();
349 let bare = m.rsplit('/').next().unwrap_or(&m);
350 if !bare.starts_with(ANTHROPIC) {
351 return None;
352 }
353 let levels = match effort_shape(model) {
354 EffortShape::Graded(l) => l,
355 // Anthropic is Graded or nothing. Budget/Binary models never reach this
356 // client, and AlwaysOn must be sent nothing anywhere.
357 _ => return None,
358 };
359 if levels.contains(&want) {
360 return Some(want);
361 }
362 // Degrade to the most expensive level this model DOES accept rather than
363 // send one it will 400 on. `levels` is a subset, not a prefix, so this
364 // steps over holes (pre-4.7 Anthropic has `max` but no `xhigh`).
365 levels.iter().rev().find(|l| **l < want).copied()
366}
367
368/// The `reasoning_effort` value to send for an OpenAI-compatible model, or
369/// `None` when the model takes no such parameter.
370///
371/// Verified against the current OpenAI reasoning guide rather than recalled:
372/// the parameter is `reasoning.effort` (accepted as `reasoning_effort` on the
373/// chat endpoint) and its vocabulary is `none | minimal | low | medium | high
374/// | xhigh | max` — a superset of ours, not the three levels an older memory
375/// would suggest. Checking mattered: building the mapping from that memory
376/// would have clamped `xhigh` away for no reason.
377///
378/// Two deliberate narrowings, both because this client is not "OpenAI" — it is
379/// *anything OpenAI-compatible*, including OpenRouter and local servers:
380///
381/// * Gated on the model family, not the provider. A local llama behind an
382/// OpenAI-shaped endpoint has no idea what `reasoning_effort` means.
383/// * `Xhigh`/`Max` clamp to `high`. The docs state the accepted values are
384/// model-dependent and publish no table, so passing the top of the scale
385/// through would be a 400 waiting for whichever model lacks it. Degrading is
386/// the same call made for Anthropic's missing `xhigh` step — and the same
387/// one made everywhere today: never fail the default path, lose a little
388/// depth instead. Lift the clamp per-model once a support table exists.
389pub fn openai_reasoning_effort(model: &str, want: Effort) -> Option<&'static str> {
390 /// Model families that take a reasoning effort. Prefixes, matched after
391 /// any `vendor/` prefix is stripped — OpenRouter names models
392 /// `openai/gpt-5`, and `google/gemini-3.6-flash` must NOT match.
393 const REASONING_FAMILIES: &[&str] = &["gpt-5", "o1", "o3", "o4"];
394
395 let m = model.to_lowercase();
396 let bare = m.rsplit('/').next().unwrap_or(&m);
397 if !REASONING_FAMILIES.iter().any(|f| bare.starts_with(f)) {
398 return None;
399 }
400 // Second gate: the table is the owner, so a family member the table has
401 // demoted (or that becomes AlwaysOn) stops being sent a value here too.
402 if !matches!(effort_shape(model), EffortShape::Graded(_)) {
403 return None;
404 }
405 Some(match want {
406 Effort::Low => "low",
407 Effort::Medium => "medium",
408 Effort::High | Effort::Xhigh | Effort::Max => "high",
409 })
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn effective_effort_reports_value_and_where_it_came_from() {
418 use EffortSource::*;
419 // Session beats profile.
420 assert_eq!(
421 effective_effort(Some(Effort::Low), Some(Effort::Max), "claude-opus-5"),
422 (Some(Effort::Low), SessionOverride)
423 );
424 // Profile when there is no session override.
425 assert_eq!(
426 effective_effort(None, Some(Effort::Max), "claude-opus-5"),
427 (Some(Effort::Max), Profile)
428 );
429 // Neither set.
430 assert_eq!(effective_effort(None, None, "claude-opus-5"), (None, Unset));
431 // A value the model cannot take is narrowed, and the SOURCE is
432 // preserved: the user still set it, they just get the nearest level
433 // that works. DeepSeek V4 has no medium step.
434 assert_eq!(
435 effective_effort(None, Some(Effort::Medium), "deepseek-v4-pro"),
436 (Some(Effort::Low), Profile)
437 );
438 // A model with no control reports nothing regardless of what is stored.
439 assert_eq!(
440 effective_effort(Some(Effort::Max), Some(Effort::Max), "gpt-4o"),
441 (None, Unset)
442 );
443 }
444
445 /// Delegation must not change what the mappers return. This pins the exact
446 /// case a first draft of this plan got wrong: Anthropic's pre-4.7 lines keep
447 /// `max` and lack only `xhigh`, so a level set expressed as a ceiling
448 /// silently downgrades `max` to `high` here.
449 #[test]
450 fn delegation_preserves_the_hole_in_the_pre_4_7_scale() {
451 // The hole: xhigh absent, max present.
452 assert_eq!(
453 supported_effort("claude-opus-4-6", Effort::Xhigh),
454 Some(Effort::High)
455 );
456 assert_eq!(
457 supported_effort("claude-opus-4-6", Effort::Max),
458 Some(Effort::Max)
459 );
460 // The full scale is unaffected.
461 assert_eq!(
462 supported_effort("claude-opus-5", Effort::Xhigh),
463 Some(Effort::Xhigh)
464 );
465 // A model the table calls AlwaysOn must get nothing from either mapper,
466 // because sending it anything is a 422.
467 assert_eq!(
468 supported_effort("magistral-small-latest", Effort::High),
469 None
470 );
471 assert_eq!(
472 openai_reasoning_effort("magistral-small-latest", Effort::High),
473 None
474 );
475 }
476
477 #[test]
478 fn effort_shape_covers_each_vendor_tier() {
479 use EffortShape::*;
480 assert!(matches!(effort_shape("claude-opus-5"), Graded(l) if l.len() == 5));
481 // Four, not three: 4.6 keeps `max` and lacks only the `xhigh` step that
482 // 4.7 inserted between them. A level set is a subset, not a prefix.
483 assert!(matches!(effort_shape("claude-opus-4-6"), Graded(l) if l.len() == 4));
484 assert!(matches!(effort_shape("claude-opus-4-6"), Graded(l) if l.contains(&Effort::Max)));
485 assert!(
486 matches!(effort_shape("claude-opus-4-6"), Graded(l) if !l.contains(&Effort::Xhigh))
487 );
488 assert!(matches!(effort_shape("gpt-5"), Graded(_)));
489 assert!(matches!(effort_shape("grok-4.6"), Graded(l) if l.contains(&Effort::Xhigh)));
490 assert!(matches!(effort_shape("grok-4.5"), Graded(l) if !l.contains(&Effort::Xhigh)));
491 assert!(matches!(effort_shape("gemini-3-pro"), Graded(_)));
492 assert!(matches!(effort_shape("gemini-2.5-pro"), Budget(_)));
493 assert!(matches!(effort_shape("qwen3-32b"), Binary { .. }));
494 // A switch offers exactly two positions, never three that collapse to two.
495 assert_eq!(effort_shape("qwen3-32b").levels().len(), 2);
496 assert!(matches!(effort_shape("glm-4.6"), Binary { .. }));
497 assert!(matches!(effort_shape("magistral-medium-latest"), AlwaysOn));
498 assert!(matches!(effort_shape("gpt-4o"), None));
499 assert!(matches!(effort_shape("llama3.2:3b"), None));
500 }
501
502 /// The three cases the design turns on. Each must fail if its guard is removed.
503 #[test]
504 fn effort_shape_negative_cases() {
505 use EffortShape::*;
506 // 1. DeepSeek V4 has low/high/max and NO medium. Offering medium is a 400.
507 let EffortShape::Graded(levels) = effort_shape("deepseek-v4-pro") else {
508 panic!("deepseek-v4-pro must be Graded");
509 };
510 assert!(
511 !levels.contains(&Effort::Medium),
512 "deepseek has no medium: {levels:?}"
513 );
514 assert_eq!(levels, &[Effort::Low, Effort::High, Effort::Max]);
515
516 // 2. A vendor prefix must be stripped before matching, and a Google model
517 // must never match an OpenAI family prefix.
518 assert!(matches!(effort_shape("openai/gpt-5"), Graded(_)));
519 assert!(matches!(effort_shape("google/gemini-3.6-flash"), Graded(_)));
520 assert!(!matches!(
521 effort_shape("google/gemini-2.5-flash"),
522 Graded(_)
523 ));
524
525 // 3. Magistral REJECTS the parameter (HTTP 422). AlwaysOn is not None:
526 // None means "send nothing and that is correct", AlwaysOn means
527 // "send nothing or every call fails".
528 assert!(matches!(effort_shape("magistral-small-latest"), AlwaysOn));
529 assert!(effort_shape("magistral-small-latest").levels().is_empty());
530 }
531
532 #[test]
533 fn supported_effort_narrows_per_model_capability() {
534 // Full scale: passed through unchanged.
535 assert_eq!(
536 supported_effort("claude-opus-5", Effort::Xhigh),
537 Some(Effort::Xhigh)
538 );
539 assert_eq!(
540 supported_effort("claude-sonnet-5", Effort::Low),
541 Some(Effort::Low)
542 );
543 // Prefix match, so dated or suffixed variants resolve the same.
544 assert_eq!(
545 supported_effort("claude-opus-5-preview", Effort::Max),
546 Some(Effort::Max)
547 );
548 // No `xhigh` step before Opus 4.7 — downgrade to the cheaper neighbour
549 // rather than 400.
550 assert_eq!(
551 supported_effort("claude-opus-4-6", Effort::Xhigh),
552 Some(Effort::High)
553 );
554 // …but the levels it does have pass through.
555 assert_eq!(
556 supported_effort("claude-opus-4-6", Effort::Max),
557 Some(Effort::Max)
558 );
559 // Models with no effort parameter: drop the field entirely.
560 assert_eq!(supported_effort("claude-haiku-4-5", Effort::Low), None);
561 assert_eq!(supported_effort("claude-sonnet-4-5", Effort::High), None);
562 // Non-Anthropic models never carry it.
563 assert_eq!(supported_effort("llama3.2:3b", Effort::Low), None);
564 assert_eq!(supported_effort("gpt-5", Effort::Low), None);
565 }
566
567 #[test]
568 fn effort_strings_match_the_api_scale() {
569 assert_eq!(Effort::Low.as_str(), "low");
570 assert_eq!(Effort::Xhigh.as_str(), "xhigh");
571 assert_eq!(Effort::Max.as_str(), "max");
572 // Ordered cheapest-first so a caller can clamp with `min`.
573 assert!(Effort::Low < Effort::High && Effort::High < Effort::Max);
574 }
575
576 #[test]
577 fn openai_effort_gates_on_family_and_clamps_the_top() {
578 // The three shared levels pass through by name.
579 assert_eq!(openai_reasoning_effort("gpt-5", Effort::Low), Some("low"));
580 assert_eq!(
581 openai_reasoning_effort("o3-mini", Effort::Medium),
582 Some("medium")
583 );
584 // Top of the scale degrades rather than risking a 400 on a model whose
585 // subset lacks it — the accepted values are model-dependent and there
586 // is no published table to key on.
587 assert_eq!(
588 openai_reasoning_effort("gpt-5", Effort::Xhigh),
589 Some("high")
590 );
591 assert_eq!(openai_reasoning_effort("gpt-5", Effort::Max), Some("high"));
592 // OpenRouter prefixes its models; the family gate must see through it…
593 assert_eq!(
594 openai_reasoning_effort("openai/gpt-5", Effort::Low),
595 Some("low")
596 );
597 // …without letting a non-OpenAI model routed the same way through.
598 assert_eq!(
599 openai_reasoning_effort("google/gemini-3.6-flash", Effort::Low),
600 None
601 );
602 // A local model behind an OpenAI-shaped endpoint takes no such param.
603 assert_eq!(openai_reasoning_effort("llama3.2:3b", Effort::Low), None);
604 assert_eq!(openai_reasoning_effort("gpt-4o", Effort::Low), None);
605 }
606
607 #[test]
608 fn test_is_reasoning_model() {
609 // Anthropic opus models
610 assert!(is_reasoning_model("claude-opus-5"));
611 assert!(is_reasoning_model("claude-opus-4-20250514"));
612
613 // OpenAI reasoning models
614 assert!(is_reasoning_model("gpt-5"));
615 assert!(is_reasoning_model("chatgpt-5.4"));
616 assert!(is_reasoning_model("o3-mini"));
617 assert!(is_reasoning_model("o4-preview"));
618
619 // Gemini pro >= 3 (version before or after the tier)
620 assert!(is_reasoning_model("gemini-pro-3.5"));
621 assert!(is_reasoning_model("gemini-pro-3"));
622 assert!(is_reasoning_model("gemini-3.5-pro"));
623 assert!(!is_reasoning_model("gemini-2.5-pro"));
624 assert!(!is_reasoning_model("gemini-pro-2"));
625 assert!(!is_reasoning_model("gemini-pro-1.5"));
626
627 // Generic reasoning/thinking
628 assert!(is_reasoning_model("deepseek-reasoning-v2"));
629 assert!(is_reasoning_model("qwen-thinking-32b"));
630
631 // Non-recommended
632 assert!(!is_reasoning_model("claude-sonnet-4-20250514"));
633 assert!(!is_reasoning_model("gpt-4o"));
634 assert!(!is_reasoning_model("gemini-flash-2"));
635 assert!(!is_reasoning_model("llama3"));
636 }
637}