Skip to main content

agent_engine/runtime/openai/catalog/
validation.rs

1//! Shared mutation-time reasoning validation and dynamic option derivation.
2//!
3//! Single source of truth for "may this provider-qualified model accept this
4//! `ReasoningLevel`?" — used by `Runtime::set_reasoning_level_checked` (slash
5//! commands, settings) and by dynamic TUI option derivation. Capability data
6//! comes from the process-local capability cache (live catalogs) first, then
7//! the exact static descriptor tables. No model-name substring inference:
8//! dispatch keys on the provider-qualified id prefix only (bare `claude-*`
9//! ids are Anthropic routing parity with `resolve_route`).
10
11use agent_core::reasoning::ReasoningLevel;
12
13use super::{
14    anthropic_static_capability, capability_cache, codex_static_capability, kimi_static_capability,
15    plan_codex_execution, xai_static_capability, CatalogProviderKind, CodexMultiAgentVersion,
16    CodexRequestRole, KimiReasoningCapability, ReasoningSupport, XaiReasoningCapability,
17};
18
19/// Conservative option set for providers without authoritative exact-model
20/// metadata. Never includes max/ultra.
21const CONSERVATIVE_OPTIONS: &[&str] = &["off", "adaptive", "low", "medium", "high", "xhigh"];
22
23fn anthropic_model_id(model: &str) -> Option<&str> {
24    model
25        .strip_prefix("anthropic/")
26        .or_else(|| model.starts_with("claude-").then_some(model))
27}
28
29fn unsupported_msg(level: ReasoningLevel, model: &str, supported: &[ReasoningLevel]) -> String {
30    format!(
31        "reasoning level '{level}' is not supported by {model}; supported: [{}]",
32        supported
33            .iter()
34            .map(|l| l.as_str())
35            .collect::<Vec<_>>()
36            .join(", ")
37    )
38}
39
40fn codex_reasoning_capability(model: &str, model_id: &str) -> Option<ReasoningSupport> {
41    // A live exact row is authoritative as a whole. In particular, never
42    // borrow static v2 evidence when that live row omitted or contradicted it.
43    if let Some(model) = capability_cache::get(model) {
44        if model.provider_kind != CatalogProviderKind::OpenAiCodex {
45            return None;
46        }
47        return matches!(model.reasoning, ReasoningSupport::CodexNamed { .. })
48            .then_some(model.reasoning);
49    }
50    codex_static_capability(model_id)
51}
52
53fn codex_supported_levels(model: &str, model_id: &str) -> Option<Vec<ReasoningLevel>> {
54    match codex_reasoning_capability(model, model_id)? {
55        ReasoningSupport::CodexNamed {
56            mut supported,
57            multi_agent_version,
58            ..
59        } => {
60            if multi_agent_version != Some(CodexMultiAgentVersion::V2) {
61                supported.retain(|level| *level != ReasoningLevel::Ultra);
62            }
63            Some(supported)
64        }
65        _ => None,
66    }
67}
68
69fn anthropic_capability(model_id: &str) -> Option<ReasoningSupport> {
70    // A live catalog entry whose payload carried no capability evidence
71    // (`Unknown`) must not shadow the exact-id static table — absence of
72    // live metadata is not evidence against static knowledge.
73    capability_cache::get(&format!("anthropic/{model_id}"))
74        .map(|m| m.reasoning)
75        .filter(|r| !matches!(r, ReasoningSupport::Unknown))
76        .or_else(|| anthropic_static_capability(model_id))
77}
78
79/// Validate `level` for the provider-qualified `model` at mutation time
80/// (command/settings). `Err` carries a user-facing message; callers must not
81/// mutate state or persist config on `Err`.
82pub fn validate_reasoning_mutation(model: &str, level: ReasoningLevel) -> Result<(), String> {
83    if let Some(model_id) = model.strip_prefix("openai-codex/") {
84        // Off/Adaptive omit the provider effort field on the Codex wire.
85        if matches!(level, ReasoningLevel::Off | ReasoningLevel::Adaptive) {
86            return Ok(());
87        }
88        if matches!(level, ReasoningLevel::Max | ReasoningLevel::Ultra) {
89            return plan_codex_execution(model, level, CodexRequestRole::Foreground, None)
90                .map(|_| ())
91                .map_err(|error| error.to_string());
92        }
93        return match codex_supported_levels(model, model_id) {
94            Some(levels) if levels.contains(&level) => Ok(()),
95            Some(levels) => Err(unsupported_msg(level, model, &levels)),
96            // Unknown Codex model: metadata absence never authorizes the
97            // extended max/ultra modes (fail closed).
98            None if level.requires_codex_support() => Err(format!(
99                "no capability metadata for {model}; cannot authorize level '{level}'"
100            )),
101            None => Ok(()),
102        };
103    }
104    if let Some(model_id) = model.strip_prefix("xai-auth/") {
105        // Adaptive = provider default (omit `reasoning`) — always expressible.
106        if level == ReasoningLevel::Adaptive {
107            return Ok(());
108        }
109        return match xai_static_capability(model_id) {
110            Some(XaiReasoningCapability::Effort {
111                supported,
112                can_disable,
113                ..
114            }) => match level {
115                ReasoningLevel::Off if can_disable => Ok(()),
116                ReasoningLevel::Off => Err(format!(
117                    "reasoning cannot be disabled on {model}; use adaptive or a supported effort"
118                )),
119                l if supported.contains(&l) => Ok(()),
120                l => Err(unsupported_msg(l, model, supported)),
121            },
122            Some(XaiReasoningCapability::IntrinsicReasoning) => match level {
123                ReasoningLevel::Off => Err(format!(
124                    "{model} has no documented way to disable reasoning; use adaptive"
125                )),
126                l => Err(format!(
127                    "{model} has no documented effort control; level '{l}' cannot be sent — use adaptive"
128                )),
129            },
130            Some(XaiReasoningCapability::NonReasoning) => match level {
131                ReasoningLevel::Off => Ok(()),
132                l => Err(format!(
133                    "{model} is a non-reasoning model; level '{l}' is not supported"
134                )),
135            },
136            None => Err(format!(
137                "no capability metadata for {model}; cannot authorize level '{level}'"
138            )),
139        };
140    }
141    if let Some(model_id) = model.strip_prefix("kimi/") {
142        // Adaptive = provider default (omit the field) — always expressible.
143        if level == ReasoningLevel::Adaptive {
144            return Ok(());
145        }
146        return match kimi_static_capability(model_id) {
147            Some(KimiReasoningCapability::Effort {
148                supported,
149                can_disable,
150                ..
151            }) => match level {
152                ReasoningLevel::Off if can_disable => Ok(()),
153                ReasoningLevel::Off => Err(format!(
154                    "reasoning cannot be disabled on {model}; use adaptive or a supported effort"
155                )),
156                l if supported.contains(&l) => Ok(()),
157                l => Err(unsupported_msg(l, model, supported)),
158            },
159            Some(KimiReasoningCapability::AlwaysThinking) => match level {
160                ReasoningLevel::Off => Err(format!(
161                    "{model} always thinks and the API rejects disabling; use adaptive"
162                )),
163                l => Err(format!(
164                    "{model} has no documented effort control; level '{l}' cannot be sent — use adaptive"
165                )),
166            },
167            Some(KimiReasoningCapability::ToggleableThinking) => match level {
168                ReasoningLevel::Off => Ok(()),
169                l => Err(format!(
170                    "{model} supports only a thinking on/off toggle; level '{l}' cannot be sent — use off or adaptive"
171                )),
172            },
173            None => Err(format!(
174                "no capability metadata for {model}; cannot authorize level '{level}'"
175            )),
176        };
177    }
178    if let Some(model_id) = anthropic_model_id(model) {
179        if matches!(level, ReasoningLevel::Max | ReasoningLevel::UltraCode) {
180            // Max/UltraCode require an exact qualified Anthropic capability row;
181            // bare/unknown Anthropic identities must fail closed.
182            let capabilities = model
183                .starts_with("anthropic/")
184                .then(|| super::anthropic_mode_capabilities(model))
185                .flatten()
186                .map(|caps| {
187                    caps.narrow_with_live_effort(capability_cache::get(model).and_then(|entry| {
188                        match entry.reasoning {
189                            ReasoningSupport::AnthropicAdaptive { adaptive } => Some(adaptive),
190                            ReasoningSupport::None => Some(false),
191                            _ => None,
192                        }
193                    }))
194                });
195            let supported = match level {
196                ReasoningLevel::Max => capabilities.is_some_and(|caps| caps.max_supported),
197                ReasoningLevel::UltraCode => {
198                    capabilities.is_some_and(|caps| caps.ultracode_supported())
199                }
200                _ => unreachable!(),
201            };
202            return supported
203                .then_some(())
204                .ok_or_else(|| format!("reasoning level '{level}' is not supported by {model}"));
205        }
206        if level == ReasoningLevel::Ultra {
207            return Err(format!(
208                "reasoning level '{level}' requires authoritative exact-model capability metadata; {model} has none"
209            ));
210        }
211        // Live catalog evidence that thinking is unsupported fails closed for
212        // named levels; Off/Adaptive stay expressible (field omission).
213        if matches!(anthropic_capability(model_id), Some(ReasoningSupport::None))
214            && !matches!(level, ReasoningLevel::Off | ReasoningLevel::Adaptive)
215        {
216            return Err(format!(
217                "{model} does not support extended thinking; level '{level}' is not available"
218            ));
219        }
220        return Ok(());
221    }
222    // Other providers: only the Codex-extended modes are gated.
223    if level.requires_codex_support() {
224        return Err(format!(
225            "reasoning level '{level}' requires authoritative exact-model capability metadata; {model} has none"
226        ));
227    }
228    Ok(())
229}
230
231/// Model-default reasoning level applied on model switch when the user has
232/// not explicitly chosen a level. `None` = leave the current level untouched.
233pub fn default_level_for_model(model: &str) -> Option<ReasoningLevel> {
234    if let Some(model_id) = model.strip_prefix("openai-codex/") {
235        return match codex_reasoning_capability(model, model_id)? {
236            ReasoningSupport::CodexNamed { default_level, .. } => default_level,
237            _ => None,
238        };
239    }
240    if let Some(model_id) = model.strip_prefix("xai-auth/") {
241        return Some(match xai_static_capability(model_id) {
242            Some(XaiReasoningCapability::Effort {
243                default_level: Some(level),
244                ..
245            }) => level,
246            // No documented default / no effort control / unknown id →
247            // provider default via field omission.
248            _ => ReasoningLevel::Adaptive,
249        });
250    }
251    if let Some(model_id) = model.strip_prefix("kimi/") {
252        return Some(match kimi_static_capability(model_id) {
253            Some(KimiReasoningCapability::Effort {
254                default_level: Some(level),
255                ..
256            }) => level,
257            // Toggle/always-thinking/unknown → provider default via omission.
258            _ => ReasoningLevel::Adaptive,
259        });
260    }
261    None
262}
263
264/// Human-readable reasoning *type* for the settings UI, derived from the
265/// same exact capability sources as `thinking_options_for_model` (capability
266/// cache first, then exact static descriptors). Never claims a verified type
267/// for providers without authoritative metadata.
268pub fn reasoning_type_for_model(model: &str) -> &'static str {
269    if model.strip_prefix("openai-codex/").is_some() {
270        // Codex always expresses reasoning as named effort on the wire.
271        return "effort (named)";
272    }
273    if let Some(model_id) = model.strip_prefix("xai-auth/") {
274        return match xai_static_capability(model_id) {
275            Some(XaiReasoningCapability::Effort { .. }) => "effort",
276            Some(XaiReasoningCapability::IntrinsicReasoning) => "intrinsic",
277            Some(XaiReasoningCapability::NonReasoning) => "none",
278            None => "unknown",
279        };
280    }
281    if let Some(model_id) = model.strip_prefix("kimi/") {
282        return match kimi_static_capability(model_id) {
283            Some(KimiReasoningCapability::Effort { .. }) => "effort",
284            Some(KimiReasoningCapability::AlwaysThinking) => "intrinsic",
285            Some(KimiReasoningCapability::ToggleableThinking) => "toggle (on/off)",
286            None => "unknown",
287        };
288    }
289    if let Some(model_id) = anthropic_model_id(model) {
290        return match anthropic_capability(model_id) {
291            Some(ReasoningSupport::AnthropicAdaptive { adaptive: true }) => "adaptive",
292            Some(ReasoningSupport::None) => "none",
293            // Explicit non-adaptive metadata or no metadata: the legacy
294            // enabled+budget_tokens request shape.
295            _ => "budget (legacy)",
296        };
297    }
298    "unverified"
299}
300
301/// Dynamic thinking options for the settings UI, derived from exact
302/// catalog/static capabilities for the provider-qualified model id.
303pub fn thinking_options_for_model(model: &str) -> Vec<String> {
304    let owned = |slice: &[&str]| slice.iter().map(|s| s.to_string()).collect::<Vec<_>>();
305    if let Some(model_id) = model.strip_prefix("openai-codex/") {
306        if let Some(levels) = codex_supported_levels(model, model_id) {
307            let mut opts = vec!["off".to_string(), "adaptive".to_string()];
308            opts.extend(levels.iter().map(|l| l.as_str().to_string()));
309            return opts;
310        }
311        return owned(CONSERVATIVE_OPTIONS);
312    }
313    if let Some(model_id) = model.strip_prefix("xai-auth/") {
314        return match xai_static_capability(model_id) {
315            Some(XaiReasoningCapability::Effort {
316                supported,
317                can_disable,
318                ..
319            }) => {
320                let mut opts = Vec::new();
321                if can_disable {
322                    opts.push("off".to_string());
323                }
324                opts.push("adaptive".to_string());
325                opts.extend(supported.iter().map(|l| l.as_str().to_string()));
326                opts
327            }
328            Some(XaiReasoningCapability::NonReasoning) => owned(&["off", "adaptive"]),
329            // Intrinsic reasoning without effort control, or unknown id:
330            // only the provider default is expressible.
331            _ => owned(&["adaptive"]),
332        };
333    }
334    if let Some(model_id) = model.strip_prefix("kimi/") {
335        return match kimi_static_capability(model_id) {
336            Some(KimiReasoningCapability::Effort {
337                supported,
338                can_disable,
339                ..
340            }) => {
341                let mut opts = Vec::new();
342                if can_disable {
343                    opts.push("off".to_string());
344                }
345                opts.push("adaptive".to_string());
346                opts.extend(supported.iter().map(|l| l.as_str().to_string()));
347                opts
348            }
349            Some(KimiReasoningCapability::ToggleableThinking) => owned(&["off", "adaptive"]),
350            // Always-thinking without effort control, or unknown id: only
351            // the provider default is expressible.
352            _ => owned(&["adaptive"]),
353        };
354    }
355    if let Some(model_id) = anthropic_model_id(model) {
356        let live = model
357            .starts_with("anthropic/")
358            .then(|| capability_cache::get(model))
359            .flatten()
360            .map(|entry| entry.reasoning);
361        let specials_authorized = super::anthropic_mode_capabilities(model).is_some()
362            && !matches!(live, Some(ReasoningSupport::None));
363        return match anthropic_capability(model_id) {
364            // Thinking-capable (adaptive effort or legacy budget tiers). Generic
365            // live true preserves exact static authority but cannot invent it.
366            Some(ReasoningSupport::AnthropicAdaptive { .. }) if specials_authorized => owned(&[
367                "off",
368                "adaptive",
369                "low",
370                "medium",
371                "high",
372                "xhigh",
373                "max",
374                "ultracode",
375            ]),
376            Some(ReasoningSupport::AnthropicAdaptive { .. }) => owned(CONSERVATIVE_OPTIONS),
377            // Explicit live false revokes both special modes.
378            Some(ReasoningSupport::None) => owned(&["off", "adaptive"]),
379            _ if specials_authorized => owned(&[
380                "off",
381                "adaptive",
382                "low",
383                "medium",
384                "high",
385                "xhigh",
386                "max",
387                "ultracode",
388            ]),
389            _ => owned(CONSERVATIVE_OPTIONS),
390        };
391    }
392    owned(CONSERVATIVE_OPTIONS)
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use ReasoningLevel::*;
399
400    #[test]
401    fn fable_options_expose_distinct_special_modes() {
402        let model = "anthropic/claude-fable-5";
403        assert_eq!(
404            thinking_options_for_model(model),
405            [
406                "off",
407                "adaptive",
408                "low",
409                "medium",
410                "high",
411                "xhigh",
412                "max",
413                "ultracode"
414            ]
415        );
416    }
417
418    // ── xAI mutation matrix ──────────────────────────────────────────────────
419
420    #[test]
421    fn xai_45_accepts_exact_efforts_and_rejects_off_xhigh_max_ultra() {
422        for model in ["xai-auth/grok-4.5", "xai-auth/grok-4.5-latest"] {
423            for level in [Adaptive, Low, Medium, High] {
424                assert!(
425                    validate_reasoning_mutation(model, level).is_ok(),
426                    "{model} {level}"
427                );
428            }
429            for level in [Off, XHigh, Max, Ultra] {
430                let err = validate_reasoning_mutation(model, level).unwrap_err();
431                assert!(err.contains(model), "{err}");
432            }
433        }
434    }
435
436    #[test]
437    fn xai_multi_agent_accepts_xhigh_but_not_off() {
438        let model = "xai-auth/grok-4.20-multi-agent-0309";
439        for level in [Adaptive, Low, Medium, High, XHigh] {
440            assert!(validate_reasoning_mutation(model, level).is_ok(), "{level}");
441        }
442        for level in [Off, Max, Ultra] {
443            assert!(
444                validate_reasoning_mutation(model, level).is_err(),
445                "{level}"
446            );
447        }
448    }
449
450    #[test]
451    fn xai_intrinsic_reasoning_rejects_named_and_off_accepts_adaptive() {
452        for id in [
453            "grok-4.3",
454            "grok-4.3-latest",
455            "grok-latest",
456            "grok-4.20-0309-reasoning",
457        ] {
458            let model = format!("xai-auth/{id}");
459            assert!(validate_reasoning_mutation(&model, Adaptive).is_ok());
460            for level in [Off, Low, Medium, High, XHigh, Max, Ultra] {
461                assert!(
462                    validate_reasoning_mutation(&model, level).is_err(),
463                    "{model} {level}"
464                );
465            }
466        }
467    }
468
469    #[test]
470    fn xai_non_reasoning_rejects_named_accepts_off_adaptive() {
471        let model = "xai-auth/grok-4.20-0309-non-reasoning";
472        assert!(validate_reasoning_mutation(model, Off).is_ok());
473        assert!(validate_reasoning_mutation(model, Adaptive).is_ok());
474        for level in [Low, Medium, High, XHigh, Max, Ultra] {
475            assert!(
476                validate_reasoning_mutation(model, level).is_err(),
477                "{level}"
478            );
479        }
480    }
481
482    #[test]
483    fn xai_unknown_id_fails_closed_except_adaptive() {
484        let model = "xai-auth/grok-9000";
485        assert!(validate_reasoning_mutation(model, Adaptive).is_ok());
486        for level in [Off, Low, Medium, High, XHigh, Max, Ultra] {
487            assert!(
488                validate_reasoning_mutation(model, level).is_err(),
489                "{level}"
490            );
491        }
492    }
493
494    // ── Codex gap fix ────────────────────────────────────────────────────────
495
496    #[test]
497    fn unknown_codex_model_rejects_max_and_ultra_at_mutation_time() {
498        for level in [Max, Ultra] {
499            let err =
500                validate_reasoning_mutation("openai-codex/gpt-unknown-future", level).unwrap_err();
501            assert!(err.contains("no capability metadata"), "{err}");
502        }
503        // Non-extended named levels remain permissive without metadata.
504        for level in [Off, Adaptive, Low, Medium, High, XHigh] {
505            assert!(
506                validate_reasoning_mutation("openai-codex/gpt-unknown-future", level).is_ok(),
507                "{level}"
508            );
509        }
510    }
511
512    #[test]
513    fn known_codex_model_membership_still_enforced() {
514        assert!(validate_reasoning_mutation("openai-codex/gpt-5.6-sol", Ultra).is_ok());
515        assert!(validate_reasoning_mutation("openai-codex/gpt-5.5", Ultra).is_err());
516    }
517
518    #[test]
519    fn live_codex_ultra_requires_v2_at_mutation_and_in_options() {
520        use super::super::{
521            CatalogModel, CatalogProviderKind, CatalogSource, CodexMultiAgentVersion,
522        };
523
524        for (suffix, version) in [("missing", None), ("v1", Some(CodexMultiAgentVersion::V1))] {
525            let id = format!("gpt-ultra-validation-{suffix}");
526            let qualified = format!("openai-codex/{id}");
527            let mut live = CatalogModel::new("openai-codex", "OpenAI Codex", &id).unwrap();
528            live.provider_kind = CatalogProviderKind::OpenAiCodex;
529            live.source = CatalogSource::Live;
530            live.reasoning = ReasoningSupport::CodexNamed {
531                supported: vec![Low, Medium, High, XHigh, Max, Ultra],
532                default_level: Some(High),
533                multi_agent_version: version,
534            };
535            capability_cache::insert(live);
536
537            assert!(
538                validate_reasoning_mutation(&qualified, Max).is_ok(),
539                "Max does not require v2: {qualified}"
540            );
541            let err = validate_reasoning_mutation(&qualified, Ultra)
542                .expect_err("Ultra must fail closed without exact v2 evidence");
543            assert!(err.contains("multi-agent v2"), "{err}");
544
545            let options = thinking_options_for_model(&qualified);
546            assert!(options.contains(&"max".to_string()), "{options:?}");
547            assert!(!options.contains(&"ultra".to_string()), "{options:?}");
548        }
549    }
550
551    // ── Anthropic ────────────────────────────────────────────────────────────
552
553    #[test]
554    fn anthropic_accepts_budget_expressible_levels_and_rejects_extended() {
555        for model in ["anthropic/claude-opus-4-7", "claude-sonnet-4-6"] {
556            for level in [Off, Adaptive, Low, Medium, High, XHigh] {
557                assert!(
558                    validate_reasoning_mutation(model, level).is_ok(),
559                    "{model} {level}"
560                );
561            }
562            for level in [Max, Ultra, UltraCode] {
563                assert!(
564                    validate_reasoning_mutation(model, level).is_err(),
565                    "{model} {level}"
566                );
567            }
568        }
569    }
570
571    #[test]
572    fn anthropic_live_no_thinking_evidence_rejects_named() {
573        let mut m = super::super::CatalogModel::new(
574            "anthropic",
575            "Anthropic",
576            "claude-test-validation-nothink",
577        )
578        .unwrap();
579        m.provider_kind = super::super::CatalogProviderKind::Anthropic;
580        m.reasoning = ReasoningSupport::None;
581        m.source = super::super::CatalogSource::Live;
582        capability_cache::insert(m);
583        let model = "anthropic/claude-test-validation-nothink";
584        for level in [Off, Adaptive] {
585            assert!(validate_reasoning_mutation(model, level).is_ok(), "{level}");
586        }
587        for level in [Low, Medium, High, XHigh] {
588            assert!(
589                validate_reasoning_mutation(model, level).is_err(),
590                "{level}"
591            );
592        }
593    }
594
595    // ── Defaults ─────────────────────────────────────────────────────────────
596
597    #[test]
598    fn default_levels_come_from_exact_descriptors() {
599        assert_eq!(default_level_for_model("xai-auth/grok-4.5"), Some(High));
600        assert_eq!(
601            default_level_for_model("xai-auth/grok-4.5-latest"),
602            Some(High)
603        );
604        assert_eq!(
605            default_level_for_model("xai-auth/grok-4.20-multi-agent-0309"),
606            Some(Adaptive)
607        );
608        assert_eq!(default_level_for_model("xai-auth/grok-4.3"), Some(Adaptive));
609        assert_eq!(
610            default_level_for_model("xai-auth/grok-4.20-0309-non-reasoning"),
611            Some(Adaptive)
612        );
613        assert_eq!(default_level_for_model("anthropic/claude-opus-4-7"), None);
614        assert_eq!(
615            default_level_for_model("openai-codex/gpt-5.3-codex-spark"),
616            Some(High)
617        );
618    }
619
620    // ── Dynamic options ──────────────────────────────────────────────────────
621
622    #[test]
623    fn xai_options_derive_from_exact_capabilities() {
624        assert_eq!(
625            thinking_options_for_model("xai-auth/grok-4.5"),
626            vec!["adaptive", "low", "medium", "high"]
627        );
628        assert_eq!(
629            thinking_options_for_model("xai-auth/grok-4.20-multi-agent-0309"),
630            vec!["adaptive", "low", "medium", "high", "xhigh"]
631        );
632        assert_eq!(
633            thinking_options_for_model("xai-auth/grok-4.3"),
634            vec!["adaptive"]
635        );
636        assert_eq!(
637            thinking_options_for_model("xai-auth/grok-4.20-0309-non-reasoning"),
638            vec!["off", "adaptive"]
639        );
640        assert_eq!(
641            thinking_options_for_model("xai-auth/grok-9000"),
642            vec!["adaptive"]
643        );
644    }
645
646    // ── Reasoning type derivation (settings display) ─────────────────────────
647
648    #[test]
649    fn reasoning_type_derives_from_exact_capabilities() {
650        // Codex: named effort levels on the wire.
651        assert_eq!(
652            reasoning_type_for_model("openai-codex/gpt-5.6-sol"),
653            "effort (named)"
654        );
655        assert_eq!(
656            reasoning_type_for_model("openai-codex/gpt-unknown-future"),
657            "effort (named)"
658        );
659        // xAI: documented effort control / intrinsic / non-reasoning.
660        assert_eq!(reasoning_type_for_model("xai-auth/grok-4.5"), "effort");
661        assert_eq!(reasoning_type_for_model("xai-auth/grok-4.3"), "intrinsic");
662        assert_eq!(
663            reasoning_type_for_model("xai-auth/grok-4.20-0309-non-reasoning"),
664            "none"
665        );
666        assert_eq!(reasoning_type_for_model("xai-auth/grok-9000"), "unknown");
667        // Anthropic: adaptive effort vs legacy numeric budget.
668        assert_eq!(
669            reasoning_type_for_model("anthropic/claude-opus-4-7"),
670            "adaptive"
671        );
672        assert_eq!(
673            reasoning_type_for_model("anthropic/claude-sonnet-4-6"),
674            "budget (legacy)"
675        );
676        assert_eq!(
677            reasoning_type_for_model("claude-sonnet-4-6"),
678            "budget (legacy)"
679        );
680        // No authoritative metadata: legacy budget path, honestly labelled.
681        assert_eq!(
682            reasoning_type_for_model("anthropic/claude-future-x"),
683            "budget (legacy)"
684        );
685        // Other providers without exact metadata never claim a verified type.
686        assert_eq!(
687            reasoning_type_for_model("groq/llama-3.3-70b-versatile"),
688            "unverified"
689        );
690    }
691
692    /// Regression: a live catalog entry parsed WITHOUT capability metadata
693    /// (`ReasoningSupport::Unknown`, e.g. from pagination payloads) must not
694    /// shadow the exact-id static table.
695    #[test]
696    fn cached_unknown_reasoning_does_not_shadow_static_capability() {
697        let mut m =
698            super::super::CatalogModel::new("anthropic", "Anthropic", "claude-opus-4-7").unwrap();
699        m.reasoning = crate::runtime::openai::catalog::ReasoningSupport::Unknown;
700        capability_cache::insert(m);
701        assert_eq!(
702            reasoning_type_for_model("anthropic/claude-opus-4-7"),
703            "adaptive",
704            "cached Unknown must fall through to the static adaptive entry"
705        );
706    }
707
708    #[test]
709    fn reasoning_type_none_for_live_no_thinking_anthropic_evidence() {
710        let mut m = super::super::CatalogModel::new(
711            "anthropic",
712            "Anthropic",
713            "claude-test-reasoning-type-nothink",
714        )
715        .unwrap();
716        m.provider_kind = super::super::CatalogProviderKind::Anthropic;
717        m.reasoning = ReasoningSupport::None;
718        m.source = super::super::CatalogSource::Live;
719        capability_cache::insert(m);
720        assert_eq!(
721            reasoning_type_for_model("anthropic/claude-test-reasoning-type-nothink"),
722            "none"
723        );
724    }
725
726    #[test]
727    fn anthropic_options_derive_from_capabilities() {
728        assert_eq!(
729            thinking_options_for_model("anthropic/claude-opus-4-7"),
730            vec!["off", "adaptive", "low", "medium", "high", "xhigh"]
731        );
732        // Fixed-budget models keep the budget-tier set.
733        assert_eq!(
734            thinking_options_for_model("claude-sonnet-4-6"),
735            vec!["off", "adaptive", "low", "medium", "high", "xhigh"]
736        );
737    }
738
739    #[test]
740    fn options_never_advertise_max_ultra_off_codex() {
741        for model in [
742            "anthropic/claude-opus-4-7",
743            "xai-auth/grok-4.5",
744            "groq/llama-3.3-70b-versatile",
745        ] {
746            let opts = thinking_options_for_model(model);
747            assert!(!opts.contains(&"max".to_string()), "{model}");
748            assert!(!opts.contains(&"ultra".to_string()), "{model}");
749        }
750    }
751
752    #[test]
753    fn kimi_k3_accepts_exact_efforts_and_rejects_off_medium_xhigh_ultra() {
754        let model = "kimi/kimi-k3";
755        for level in [Adaptive, Low, High, Max] {
756            assert!(
757                validate_reasoning_mutation(model, level).is_ok(),
758                "{model} {level}"
759            );
760        }
761        for level in [Off, Medium, XHigh, Ultra, UltraCode] {
762            let err = validate_reasoning_mutation(model, level).unwrap_err();
763            assert!(err.contains("kimi"), "{err}");
764        }
765    }
766
767    #[test]
768    fn kimi_always_thinking_models_accept_only_adaptive() {
769        for model in ["kimi/kimi-k2.7-code", "kimi/kimi-k2.7-code-highspeed"] {
770            assert!(
771                validate_reasoning_mutation(model, Adaptive).is_ok(),
772                "{model}"
773            );
774            for level in [Off, Low, Medium, High, Max] {
775                assert!(
776                    validate_reasoning_mutation(model, level).is_err(),
777                    "{model} {level}"
778                );
779            }
780        }
781    }
782
783    #[test]
784    fn kimi_toggleable_models_accept_off_and_adaptive_only() {
785        for model in ["kimi/kimi-k2.6", "kimi/kimi-k2.5"] {
786            for level in [Off, Adaptive] {
787                assert!(
788                    validate_reasoning_mutation(model, level).is_ok(),
789                    "{model} {level}"
790                );
791            }
792            for level in [Low, Medium, High, Max] {
793                assert!(
794                    validate_reasoning_mutation(model, level).is_err(),
795                    "{model} {level}"
796                );
797            }
798        }
799    }
800
801    #[test]
802    fn kimi_unknown_ids_fail_closed_except_adaptive() {
803        let model = "kimi/kimi-k9000";
804        assert!(validate_reasoning_mutation(model, Adaptive).is_ok());
805        for level in [Off, Low, Medium, High, Max, Ultra] {
806            assert!(
807                validate_reasoning_mutation(model, level).is_err(),
808                "{level}"
809            );
810        }
811    }
812
813    #[test]
814    fn kimi_options_are_exact_per_model() {
815        assert_eq!(
816            thinking_options_for_model("kimi/kimi-k3"),
817            vec!["adaptive", "low", "high", "max"]
818        );
819        for model in ["kimi/kimi-k2.7-code", "kimi/kimi-k2.7-code-highspeed"] {
820            assert_eq!(
821                thinking_options_for_model(model),
822                vec!["adaptive"],
823                "{model}"
824            );
825        }
826        for model in ["kimi/kimi-k2.6", "kimi/kimi-k2.5"] {
827            assert_eq!(
828                thinking_options_for_model(model),
829                vec!["off", "adaptive"],
830                "{model}"
831            );
832        }
833        assert_eq!(
834            thinking_options_for_model("kimi/kimi-k9000"),
835            vec!["adaptive"]
836        );
837    }
838
839    #[test]
840    fn kimi_reasoning_types_and_default_level() {
841        assert_eq!(reasoning_type_for_model("kimi/kimi-k3"), "effort");
842        assert_eq!(reasoning_type_for_model("kimi/kimi-k2.7-code"), "intrinsic");
843        assert_eq!(
844            reasoning_type_for_model("kimi/kimi-k2.6"),
845            "toggle (on/off)"
846        );
847        assert_eq!(reasoning_type_for_model("kimi/kimi-k9000"), "unknown");
848        assert_eq!(
849            default_level_for_model("kimi/kimi-k3"),
850            Some(ReasoningLevel::Max)
851        );
852        assert_eq!(
853            default_level_for_model("kimi/kimi-k2.6"),
854            Some(ReasoningLevel::Adaptive)
855        );
856    }
857}