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