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_and_46_accept_exact_efforts_and_reject_off_xhigh_max_ultra() {
437        for model in [
438            "xai-auth/grok-4.5",
439            "xai-auth/grok-4.5-latest",
440            "xai-auth/grok-4.6",
441        ] {
442            for level in [Adaptive, Low, Medium, High] {
443                assert!(
444                    validate_reasoning_mutation(model, level).is_ok(),
445                    "{model} {level}"
446                );
447            }
448            for level in [Off, XHigh, Max, Ultra] {
449                let err = validate_reasoning_mutation(model, level).unwrap_err();
450                assert!(err.contains(model), "{err}");
451            }
452        }
453    }
454
455    #[test]
456    fn xai_multi_agent_accepts_xhigh_but_not_off() {
457        let model = "xai-auth/grok-4.20-multi-agent-0309";
458        for level in [Adaptive, Low, Medium, High, XHigh] {
459            assert!(validate_reasoning_mutation(model, level).is_ok(), "{level}");
460        }
461        for level in [Off, Max, Ultra] {
462            assert!(
463                validate_reasoning_mutation(model, level).is_err(),
464                "{level}"
465            );
466        }
467    }
468
469    #[test]
470    fn xai_intrinsic_reasoning_rejects_named_and_off_accepts_adaptive() {
471        for id in [
472            "grok-4.3",
473            "grok-4.3-latest",
474            "grok-latest",
475            "grok-4.20-0309-reasoning",
476        ] {
477            let model = format!("xai-auth/{id}");
478            assert!(validate_reasoning_mutation(&model, Adaptive).is_ok());
479            for level in [Off, Low, Medium, High, XHigh, Max, Ultra] {
480                assert!(
481                    validate_reasoning_mutation(&model, level).is_err(),
482                    "{model} {level}"
483                );
484            }
485        }
486    }
487
488    #[test]
489    fn xai_non_reasoning_rejects_named_accepts_off_adaptive() {
490        let model = "xai-auth/grok-4.20-0309-non-reasoning";
491        assert!(validate_reasoning_mutation(model, Off).is_ok());
492        assert!(validate_reasoning_mutation(model, Adaptive).is_ok());
493        for level in [Low, Medium, High, XHigh, Max, Ultra] {
494            assert!(
495                validate_reasoning_mutation(model, level).is_err(),
496                "{level}"
497            );
498        }
499    }
500
501    #[test]
502    fn xai_unknown_id_fails_closed_except_adaptive() {
503        let model = "xai-auth/grok-9000";
504        assert!(validate_reasoning_mutation(model, Adaptive).is_ok());
505        for level in [Off, Low, Medium, High, XHigh, Max, Ultra] {
506            assert!(
507                validate_reasoning_mutation(model, level).is_err(),
508                "{level}"
509            );
510        }
511    }
512
513    // ── Codex gap fix ────────────────────────────────────────────────────────
514
515    #[test]
516    fn unknown_codex_model_rejects_max_and_ultra_at_mutation_time() {
517        for level in [Max, Ultra] {
518            let err =
519                validate_reasoning_mutation("openai-codex/gpt-unknown-future", level).unwrap_err();
520            assert!(err.contains("no capability metadata"), "{err}");
521        }
522        // Non-extended named levels remain permissive without metadata.
523        for level in [Off, Adaptive, Low, Medium, High, XHigh] {
524            assert!(
525                validate_reasoning_mutation("openai-codex/gpt-unknown-future", level).is_ok(),
526                "{level}"
527            );
528        }
529    }
530
531    #[test]
532    fn known_codex_model_membership_still_enforced() {
533        assert!(validate_reasoning_mutation("openai-codex/gpt-5.6-sol", Ultra).is_ok());
534        assert!(validate_reasoning_mutation("openai-codex/gpt-5.5", Ultra).is_err());
535    }
536
537    #[test]
538    fn live_codex_ultra_requires_v2_at_mutation_and_in_options() {
539        use super::super::{
540            CatalogModel, CatalogProviderKind, CatalogSource, CodexMultiAgentVersion,
541        };
542
543        for (suffix, version) in [("missing", None), ("v1", Some(CodexMultiAgentVersion::V1))] {
544            let id = format!("gpt-ultra-validation-{suffix}");
545            let qualified = format!("openai-codex/{id}");
546            let mut live = CatalogModel::new("openai-codex", "OpenAI Codex", &id).unwrap();
547            live.provider_kind = CatalogProviderKind::OpenAiCodex;
548            live.source = CatalogSource::Live;
549            live.reasoning = ReasoningSupport::CodexNamed {
550                supported: vec![Low, Medium, High, XHigh, Max, Ultra],
551                default_level: Some(High),
552                multi_agent_version: version,
553            };
554            capability_cache::insert(live);
555
556            assert!(
557                validate_reasoning_mutation(&qualified, Max).is_ok(),
558                "Max does not require v2: {qualified}"
559            );
560            let err = validate_reasoning_mutation(&qualified, Ultra)
561                .expect_err("Ultra must fail closed without exact v2 evidence");
562            assert!(err.contains("multi-agent v2"), "{err}");
563
564            let options = thinking_options_for_model(&qualified);
565            assert!(options.contains(&"max".to_string()), "{options:?}");
566            assert!(!options.contains(&"ultra".to_string()), "{options:?}");
567        }
568    }
569
570    // ── Anthropic ────────────────────────────────────────────────────────────
571
572    #[test]
573    fn anthropic_accepts_budget_expressible_levels_and_rejects_extended() {
574        for model in ["anthropic/claude-opus-4-7", "claude-sonnet-4-6"] {
575            for level in [Off, Adaptive, Low, Medium, High, XHigh] {
576                assert!(
577                    validate_reasoning_mutation(model, level).is_ok(),
578                    "{model} {level}"
579                );
580            }
581            for level in [Max, Ultra, UltraCode] {
582                assert!(
583                    validate_reasoning_mutation(model, level).is_err(),
584                    "{model} {level}"
585                );
586            }
587        }
588    }
589
590    #[test]
591    fn anthropic_live_no_thinking_evidence_rejects_named() {
592        let mut m = super::super::CatalogModel::new(
593            "anthropic",
594            "Anthropic",
595            "claude-test-validation-nothink",
596        )
597        .unwrap();
598        m.provider_kind = super::super::CatalogProviderKind::Anthropic;
599        m.reasoning = ReasoningSupport::None;
600        m.source = super::super::CatalogSource::Live;
601        capability_cache::insert(m);
602        let model = "anthropic/claude-test-validation-nothink";
603        for level in [Off, Adaptive] {
604            assert!(validate_reasoning_mutation(model, level).is_ok(), "{level}");
605        }
606        for level in [Low, Medium, High, XHigh] {
607            assert!(
608                validate_reasoning_mutation(model, level).is_err(),
609                "{level}"
610            );
611        }
612    }
613
614    // ── Defaults ─────────────────────────────────────────────────────────────
615
616    #[test]
617    fn default_levels_come_from_exact_descriptors() {
618        assert_eq!(default_level_for_model("xai-auth/grok-4.5"), Some(High));
619        assert_eq!(
620            default_level_for_model("xai-auth/grok-4.5-latest"),
621            Some(High)
622        );
623        assert_eq!(default_level_for_model("xai-auth/grok-4.6"), Some(High));
624        assert_eq!(
625            default_level_for_model("xai-auth/grok-4.20-multi-agent-0309"),
626            Some(Adaptive)
627        );
628        assert_eq!(default_level_for_model("xai-auth/grok-4.3"), Some(Adaptive));
629        assert_eq!(
630            default_level_for_model("xai-auth/grok-4.20-0309-non-reasoning"),
631            Some(Adaptive)
632        );
633        assert_eq!(default_level_for_model("anthropic/claude-opus-4-7"), None);
634        assert_eq!(
635            default_level_for_model("openai-codex/gpt-5.3-codex-spark"),
636            Some(High)
637        );
638    }
639
640    // ── Dynamic options ──────────────────────────────────────────────────────
641
642    #[test]
643    fn xai_options_derive_from_exact_capabilities() {
644        assert_eq!(
645            thinking_options_for_model("xai-auth/grok-4.5"),
646            vec!["adaptive", "low", "medium", "high"]
647        );
648        assert_eq!(
649            thinking_options_for_model("xai-auth/grok-4.6"),
650            vec!["adaptive", "low", "medium", "high"]
651        );
652        assert_eq!(
653            thinking_options_for_model("xai-auth/grok-4.20-multi-agent-0309"),
654            vec!["adaptive", "low", "medium", "high", "xhigh"]
655        );
656        assert_eq!(
657            thinking_options_for_model("xai-auth/grok-4.3"),
658            vec!["adaptive"]
659        );
660        assert_eq!(
661            thinking_options_for_model("xai-auth/grok-4.20-0309-non-reasoning"),
662            vec!["off", "adaptive"]
663        );
664        assert_eq!(
665            thinking_options_for_model("xai-auth/grok-9000"),
666            vec!["adaptive"]
667        );
668    }
669
670    // ── Reasoning type derivation (settings display) ─────────────────────────
671
672    #[test]
673    fn reasoning_type_derives_from_exact_capabilities() {
674        // Codex: named effort levels on the wire.
675        assert_eq!(
676            reasoning_type_for_model("openai-codex/gpt-5.6-sol"),
677            "effort (named)"
678        );
679        assert_eq!(
680            reasoning_type_for_model("openai-codex/gpt-unknown-future"),
681            "effort (named)"
682        );
683        // xAI: documented effort control / intrinsic / non-reasoning.
684        assert_eq!(reasoning_type_for_model("xai-auth/grok-4.5"), "effort");
685        assert_eq!(reasoning_type_for_model("xai-auth/grok-4.6"), "effort");
686        assert_eq!(reasoning_type_for_model("xai-auth/grok-4.3"), "intrinsic");
687        assert_eq!(
688            reasoning_type_for_model("xai-auth/grok-4.20-0309-non-reasoning"),
689            "none"
690        );
691        assert_eq!(reasoning_type_for_model("xai-auth/grok-9000"), "unknown");
692        // Anthropic: adaptive effort vs legacy numeric budget.
693        assert_eq!(
694            reasoning_type_for_model("anthropic/claude-opus-4-7"),
695            "adaptive"
696        );
697        assert_eq!(
698            reasoning_type_for_model("anthropic/claude-sonnet-4-6"),
699            "budget (legacy)"
700        );
701        assert_eq!(
702            reasoning_type_for_model("claude-sonnet-4-6"),
703            "budget (legacy)"
704        );
705        // No authoritative metadata: legacy budget path, honestly labelled.
706        assert_eq!(
707            reasoning_type_for_model("anthropic/claude-future-x"),
708            "budget (legacy)"
709        );
710        // Other providers without exact metadata never claim a verified type.
711        assert_eq!(
712            reasoning_type_for_model("groq/llama-3.3-70b-versatile"),
713            "unverified"
714        );
715    }
716
717    /// Regression: a live catalog entry parsed WITHOUT capability metadata
718    /// (`ReasoningSupport::Unknown`, e.g. from pagination payloads) must not
719    /// shadow the exact-id static table.
720    #[test]
721    fn cached_unknown_reasoning_does_not_shadow_static_capability() {
722        let mut m =
723            super::super::CatalogModel::new("anthropic", "Anthropic", "claude-opus-4-7").unwrap();
724        m.reasoning = crate::runtime::openai::catalog::ReasoningSupport::Unknown;
725        capability_cache::insert(m);
726        assert_eq!(
727            reasoning_type_for_model("anthropic/claude-opus-4-7"),
728            "adaptive",
729            "cached Unknown must fall through to the static adaptive entry"
730        );
731    }
732
733    #[test]
734    fn reasoning_type_none_for_live_no_thinking_anthropic_evidence() {
735        let mut m = super::super::CatalogModel::new(
736            "anthropic",
737            "Anthropic",
738            "claude-test-reasoning-type-nothink",
739        )
740        .unwrap();
741        m.provider_kind = super::super::CatalogProviderKind::Anthropic;
742        m.reasoning = ReasoningSupport::None;
743        m.source = super::super::CatalogSource::Live;
744        capability_cache::insert(m);
745        assert_eq!(
746            reasoning_type_for_model("anthropic/claude-test-reasoning-type-nothink"),
747            "none"
748        );
749    }
750
751    #[test]
752    fn anthropic_options_derive_from_capabilities() {
753        assert_eq!(
754            thinking_options_for_model("anthropic/claude-opus-4-7"),
755            vec!["off", "adaptive", "low", "medium", "high", "xhigh"]
756        );
757        // Fixed-budget models keep the budget-tier set.
758        assert_eq!(
759            thinking_options_for_model("claude-sonnet-4-6"),
760            vec!["off", "adaptive", "low", "medium", "high", "xhigh"]
761        );
762    }
763
764    #[test]
765    fn options_never_advertise_max_ultra_off_codex() {
766        for model in [
767            "anthropic/claude-opus-4-7",
768            "xai-auth/grok-4.5",
769            "groq/llama-3.3-70b-versatile",
770        ] {
771            let opts = thinking_options_for_model(model);
772            assert!(!opts.contains(&"max".to_string()), "{model}");
773            assert!(!opts.contains(&"ultra".to_string()), "{model}");
774        }
775    }
776
777    #[test]
778    fn kimi_k3_accepts_exact_efforts_and_rejects_off_medium_xhigh_ultra() {
779        let model = "kimi/kimi-k3";
780        for level in [Adaptive, Low, High, Max] {
781            assert!(
782                validate_reasoning_mutation(model, level).is_ok(),
783                "{model} {level}"
784            );
785        }
786        for level in [Off, Medium, XHigh, Ultra, UltraCode] {
787            let err = validate_reasoning_mutation(model, level).unwrap_err();
788            assert!(err.contains("kimi"), "{err}");
789        }
790    }
791
792    #[test]
793    fn kimi_always_thinking_models_accept_only_adaptive() {
794        for model in ["kimi/kimi-k2.7-code", "kimi/kimi-k2.7-code-highspeed"] {
795            assert!(
796                validate_reasoning_mutation(model, Adaptive).is_ok(),
797                "{model}"
798            );
799            for level in [Off, Low, Medium, High, Max] {
800                assert!(
801                    validate_reasoning_mutation(model, level).is_err(),
802                    "{model} {level}"
803                );
804            }
805        }
806    }
807
808    #[test]
809    fn kimi_toggleable_models_accept_off_and_adaptive_only() {
810        for model in ["kimi/kimi-k2.6", "kimi/kimi-k2.5"] {
811            for level in [Off, Adaptive] {
812                assert!(
813                    validate_reasoning_mutation(model, level).is_ok(),
814                    "{model} {level}"
815                );
816            }
817            for level in [Low, Medium, High, Max] {
818                assert!(
819                    validate_reasoning_mutation(model, level).is_err(),
820                    "{model} {level}"
821                );
822            }
823        }
824    }
825
826    #[test]
827    fn kimi_unknown_ids_fail_closed_except_adaptive() {
828        let model = "kimi/kimi-k9000";
829        assert!(validate_reasoning_mutation(model, Adaptive).is_ok());
830        for level in [Off, Low, Medium, High, Max, Ultra] {
831            assert!(
832                validate_reasoning_mutation(model, level).is_err(),
833                "{level}"
834            );
835        }
836    }
837
838    #[test]
839    fn kimi_options_are_exact_per_model() {
840        assert_eq!(
841            thinking_options_for_model("kimi/kimi-k3"),
842            vec!["adaptive", "low", "high", "max"]
843        );
844        for model in ["kimi/kimi-k2.7-code", "kimi/kimi-k2.7-code-highspeed"] {
845            assert_eq!(
846                thinking_options_for_model(model),
847                vec!["adaptive"],
848                "{model}"
849            );
850        }
851        for model in ["kimi/kimi-k2.6", "kimi/kimi-k2.5"] {
852            assert_eq!(
853                thinking_options_for_model(model),
854                vec!["off", "adaptive"],
855                "{model}"
856            );
857        }
858        assert_eq!(
859            thinking_options_for_model("kimi/kimi-k9000"),
860            vec!["adaptive"]
861        );
862    }
863
864    #[test]
865    fn kimi_reasoning_types_and_default_level() {
866        assert_eq!(reasoning_type_for_model("kimi/kimi-k3"), "effort");
867        assert_eq!(reasoning_type_for_model("kimi/kimi-k2.7-code"), "intrinsic");
868        assert_eq!(
869            reasoning_type_for_model("kimi/kimi-k2.6"),
870            "toggle (on/off)"
871        );
872        assert_eq!(reasoning_type_for_model("kimi/kimi-k9000"), "unknown");
873        assert_eq!(
874            default_level_for_model("kimi/kimi-k3"),
875            Some(ReasoningLevel::Max)
876        );
877        assert_eq!(
878            default_level_for_model("kimi/kimi-k2.6"),
879            Some(ReasoningLevel::Adaptive)
880        );
881    }
882}