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/// 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}