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                multi_agent_reasoning_effort: None,
551                supported: vec![Low, Medium, High, XHigh, Max, Ultra],
552                default_level: Some(High),
553                multi_agent_version: version,
554            };
555            capability_cache::insert(live);
556
557            assert!(
558                validate_reasoning_mutation(&qualified, Max).is_ok(),
559                "Max does not require v2: {qualified}"
560            );
561            let err = validate_reasoning_mutation(&qualified, Ultra)
562                .expect_err("Ultra must fail closed without exact v2 evidence");
563            assert!(err.contains("multi-agent v2"), "{err}");
564
565            let options = thinking_options_for_model(&qualified);
566            assert!(options.contains(&"max".to_string()), "{options:?}");
567            assert!(!options.contains(&"ultra".to_string()), "{options:?}");
568        }
569    }
570
571    // ── Anthropic ────────────────────────────────────────────────────────────
572
573    #[test]
574    fn anthropic_accepts_budget_expressible_levels_and_rejects_extended() {
575        for model in ["anthropic/claude-opus-4-7", "claude-sonnet-4-6"] {
576            for level in [Off, Adaptive, Low, Medium, High, XHigh] {
577                assert!(
578                    validate_reasoning_mutation(model, level).is_ok(),
579                    "{model} {level}"
580                );
581            }
582            for level in [Max, Ultra, UltraCode] {
583                assert!(
584                    validate_reasoning_mutation(model, level).is_err(),
585                    "{model} {level}"
586                );
587            }
588        }
589    }
590
591    #[test]
592    fn anthropic_live_no_thinking_evidence_rejects_named() {
593        let mut m = super::super::CatalogModel::new(
594            "anthropic",
595            "Anthropic",
596            "claude-test-validation-nothink",
597        )
598        .unwrap();
599        m.provider_kind = super::super::CatalogProviderKind::Anthropic;
600        m.reasoning = ReasoningSupport::None;
601        m.source = super::super::CatalogSource::Live;
602        capability_cache::insert(m);
603        let model = "anthropic/claude-test-validation-nothink";
604        for level in [Off, Adaptive] {
605            assert!(validate_reasoning_mutation(model, level).is_ok(), "{level}");
606        }
607        for level in [Low, Medium, High, XHigh] {
608            assert!(
609                validate_reasoning_mutation(model, level).is_err(),
610                "{level}"
611            );
612        }
613    }
614
615    // ── Defaults ─────────────────────────────────────────────────────────────
616
617    #[test]
618    fn default_levels_come_from_exact_descriptors() {
619        assert_eq!(default_level_for_model("xai-auth/grok-4.5"), Some(High));
620        assert_eq!(
621            default_level_for_model("xai-auth/grok-4.5-latest"),
622            Some(High)
623        );
624        assert_eq!(default_level_for_model("xai-auth/grok-4.6"), Some(High));
625        assert_eq!(
626            default_level_for_model("xai-auth/grok-4.20-multi-agent-0309"),
627            Some(Adaptive)
628        );
629        assert_eq!(default_level_for_model("xai-auth/grok-4.3"), Some(Adaptive));
630        assert_eq!(
631            default_level_for_model("xai-auth/grok-4.20-0309-non-reasoning"),
632            Some(Adaptive)
633        );
634        assert_eq!(default_level_for_model("anthropic/claude-opus-4-7"), None);
635        assert_eq!(
636            default_level_for_model("openai-codex/gpt-5.3-codex-spark"),
637            Some(High)
638        );
639    }
640
641    // ── Dynamic options ──────────────────────────────────────────────────────
642
643    #[test]
644    fn xai_options_derive_from_exact_capabilities() {
645        assert_eq!(
646            thinking_options_for_model("xai-auth/grok-4.5"),
647            vec!["adaptive", "low", "medium", "high"]
648        );
649        assert_eq!(
650            thinking_options_for_model("xai-auth/grok-4.6"),
651            vec!["adaptive", "low", "medium", "high"]
652        );
653        assert_eq!(
654            thinking_options_for_model("xai-auth/grok-4.20-multi-agent-0309"),
655            vec!["adaptive", "low", "medium", "high", "xhigh"]
656        );
657        assert_eq!(
658            thinking_options_for_model("xai-auth/grok-4.3"),
659            vec!["adaptive"]
660        );
661        assert_eq!(
662            thinking_options_for_model("xai-auth/grok-4.20-0309-non-reasoning"),
663            vec!["off", "adaptive"]
664        );
665        assert_eq!(
666            thinking_options_for_model("xai-auth/grok-9000"),
667            vec!["adaptive"]
668        );
669    }
670
671    // ── Reasoning type derivation (settings display) ─────────────────────────
672
673    #[test]
674    fn reasoning_type_derives_from_exact_capabilities() {
675        // Codex: named effort levels on the wire.
676        assert_eq!(
677            reasoning_type_for_model("openai-codex/gpt-5.6-sol"),
678            "effort (named)"
679        );
680        assert_eq!(
681            reasoning_type_for_model("openai-codex/gpt-unknown-future"),
682            "effort (named)"
683        );
684        // xAI: documented effort control / intrinsic / non-reasoning.
685        assert_eq!(reasoning_type_for_model("xai-auth/grok-4.5"), "effort");
686        assert_eq!(reasoning_type_for_model("xai-auth/grok-4.6"), "effort");
687        assert_eq!(reasoning_type_for_model("xai-auth/grok-4.3"), "intrinsic");
688        assert_eq!(
689            reasoning_type_for_model("xai-auth/grok-4.20-0309-non-reasoning"),
690            "none"
691        );
692        assert_eq!(reasoning_type_for_model("xai-auth/grok-9000"), "unknown");
693        // Anthropic: adaptive effort vs legacy numeric budget.
694        assert_eq!(
695            reasoning_type_for_model("anthropic/claude-opus-4-7"),
696            "adaptive"
697        );
698        assert_eq!(
699            reasoning_type_for_model("anthropic/claude-sonnet-4-6"),
700            "budget (legacy)"
701        );
702        assert_eq!(
703            reasoning_type_for_model("claude-sonnet-4-6"),
704            "budget (legacy)"
705        );
706        // No authoritative metadata: legacy budget path, honestly labelled.
707        assert_eq!(
708            reasoning_type_for_model("anthropic/claude-future-x"),
709            "budget (legacy)"
710        );
711        // Other providers without exact metadata never claim a verified type.
712        assert_eq!(
713            reasoning_type_for_model("groq/llama-3.3-70b-versatile"),
714            "unverified"
715        );
716    }
717
718    /// Regression: a live catalog entry parsed WITHOUT capability metadata
719    /// (`ReasoningSupport::Unknown`, e.g. from pagination payloads) must not
720    /// shadow the exact-id static table.
721    #[test]
722    fn cached_unknown_reasoning_does_not_shadow_static_capability() {
723        let mut m =
724            super::super::CatalogModel::new("anthropic", "Anthropic", "claude-opus-4-7").unwrap();
725        m.reasoning = crate::runtime::openai::catalog::ReasoningSupport::Unknown;
726        capability_cache::insert(m);
727        assert_eq!(
728            reasoning_type_for_model("anthropic/claude-opus-4-7"),
729            "adaptive",
730            "cached Unknown must fall through to the static adaptive entry"
731        );
732    }
733
734    #[test]
735    fn reasoning_type_none_for_live_no_thinking_anthropic_evidence() {
736        let mut m = super::super::CatalogModel::new(
737            "anthropic",
738            "Anthropic",
739            "claude-test-reasoning-type-nothink",
740        )
741        .unwrap();
742        m.provider_kind = super::super::CatalogProviderKind::Anthropic;
743        m.reasoning = ReasoningSupport::None;
744        m.source = super::super::CatalogSource::Live;
745        capability_cache::insert(m);
746        assert_eq!(
747            reasoning_type_for_model("anthropic/claude-test-reasoning-type-nothink"),
748            "none"
749        );
750    }
751
752    #[test]
753    fn anthropic_options_derive_from_capabilities() {
754        assert_eq!(
755            thinking_options_for_model("anthropic/claude-opus-4-7"),
756            vec!["off", "adaptive", "low", "medium", "high", "xhigh"]
757        );
758        // Fixed-budget models keep the budget-tier set.
759        assert_eq!(
760            thinking_options_for_model("claude-sonnet-4-6"),
761            vec!["off", "adaptive", "low", "medium", "high", "xhigh"]
762        );
763    }
764
765    #[test]
766    fn options_never_advertise_max_ultra_off_codex() {
767        for model in [
768            "anthropic/claude-opus-4-7",
769            "xai-auth/grok-4.5",
770            "groq/llama-3.3-70b-versatile",
771        ] {
772            let opts = thinking_options_for_model(model);
773            assert!(!opts.contains(&"max".to_string()), "{model}");
774            assert!(!opts.contains(&"ultra".to_string()), "{model}");
775        }
776    }
777
778    #[test]
779    fn kimi_k3_accepts_exact_efforts_and_rejects_off_medium_xhigh_ultra() {
780        let model = "kimi/kimi-k3";
781        for level in [Adaptive, Low, High, Max] {
782            assert!(
783                validate_reasoning_mutation(model, level).is_ok(),
784                "{model} {level}"
785            );
786        }
787        for level in [Off, Medium, XHigh, Ultra, UltraCode] {
788            let err = validate_reasoning_mutation(model, level).unwrap_err();
789            assert!(err.contains("kimi"), "{err}");
790        }
791    }
792
793    #[test]
794    fn kimi_always_thinking_models_accept_only_adaptive() {
795        for model in ["kimi/kimi-k2.7-code", "kimi/kimi-k2.7-code-highspeed"] {
796            assert!(
797                validate_reasoning_mutation(model, Adaptive).is_ok(),
798                "{model}"
799            );
800            for level in [Off, Low, Medium, High, Max] {
801                assert!(
802                    validate_reasoning_mutation(model, level).is_err(),
803                    "{model} {level}"
804                );
805            }
806        }
807    }
808
809    #[test]
810    fn kimi_toggleable_models_accept_off_and_adaptive_only() {
811        for model in ["kimi/kimi-k2.6", "kimi/kimi-k2.5"] {
812            for level in [Off, Adaptive] {
813                assert!(
814                    validate_reasoning_mutation(model, level).is_ok(),
815                    "{model} {level}"
816                );
817            }
818            for level in [Low, Medium, High, Max] {
819                assert!(
820                    validate_reasoning_mutation(model, level).is_err(),
821                    "{model} {level}"
822                );
823            }
824        }
825    }
826
827    #[test]
828    fn kimi_unknown_ids_fail_closed_except_adaptive() {
829        let model = "kimi/kimi-k9000";
830        assert!(validate_reasoning_mutation(model, Adaptive).is_ok());
831        for level in [Off, Low, Medium, High, Max, Ultra] {
832            assert!(
833                validate_reasoning_mutation(model, level).is_err(),
834                "{level}"
835            );
836        }
837    }
838
839    #[test]
840    fn kimi_options_are_exact_per_model() {
841        assert_eq!(
842            thinking_options_for_model("kimi/kimi-k3"),
843            vec!["adaptive", "low", "high", "max"]
844        );
845        for model in ["kimi/kimi-k2.7-code", "kimi/kimi-k2.7-code-highspeed"] {
846            assert_eq!(
847                thinking_options_for_model(model),
848                vec!["adaptive"],
849                "{model}"
850            );
851        }
852        for model in ["kimi/kimi-k2.6", "kimi/kimi-k2.5"] {
853            assert_eq!(
854                thinking_options_for_model(model),
855                vec!["off", "adaptive"],
856                "{model}"
857            );
858        }
859        assert_eq!(
860            thinking_options_for_model("kimi/kimi-k9000"),
861            vec!["adaptive"]
862        );
863    }
864
865    #[test]
866    fn kimi_reasoning_types_and_default_level() {
867        assert_eq!(reasoning_type_for_model("kimi/kimi-k3"), "effort");
868        assert_eq!(reasoning_type_for_model("kimi/kimi-k2.7-code"), "intrinsic");
869        assert_eq!(
870            reasoning_type_for_model("kimi/kimi-k2.6"),
871            "toggle (on/off)"
872        );
873        assert_eq!(reasoning_type_for_model("kimi/kimi-k9000"), "unknown");
874        assert_eq!(
875            default_level_for_model("kimi/kimi-k3"),
876            Some(ReasoningLevel::Max)
877        );
878        assert_eq!(
879            default_level_for_model("kimi/kimi-k2.6"),
880            Some(ReasoningLevel::Adaptive)
881        );
882    }
883}