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, muse_default_level, muse_routable_id, muse_supported_levels,
16    plan_codex_execution, xai_static_capability, CatalogProviderKind, CodexMultiAgentVersion,
17    CodexRequestRole, KimiReasoningCapability, ReasoningSupport, 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("muse/") {
99        // Adaptive = provider default (omit `reasoning`) — always expressible
100        // on a routable id. Named levels need the exact live effort set.
101        if !muse_routable_id(model_id) {
102            return Err(format!(
103                "no capability metadata for {model}; cannot authorize level '{level}'"
104            ));
105        }
106        if level == ReasoningLevel::Adaptive {
107            return Ok(());
108        }
109        return match muse_supported_levels(model_id) {
110            Some(levels) if levels.contains(&level) => Ok(()),
111            Some(levels) => Err(unsupported_msg(level, model, &levels)),
112            None => Err(format!(
113                "{model} has no advertised effort levels (live Muse catalog not loaded, or the \
114                 model has none); level '{level}' cannot be sent — use adaptive"
115            )),
116        };
117    }
118    if let Some(model_id) = model.strip_prefix("openai-codex/") {
119        // Off/Adaptive omit the provider effort field on the Codex wire.
120        if matches!(level, ReasoningLevel::Off | ReasoningLevel::Adaptive) {
121            return Ok(());
122        }
123        if matches!(level, ReasoningLevel::Max | ReasoningLevel::Ultra) {
124            return plan_codex_execution(model, level, CodexRequestRole::Foreground, None)
125                .map(|_| ())
126                .map_err(|error| error.to_string());
127        }
128        return match codex_supported_levels(model, model_id) {
129            Some(levels) if levels.contains(&level) => Ok(()),
130            Some(levels) => Err(unsupported_msg(level, model, &levels)),
131            // Unknown Codex model: metadata absence never authorizes the
132            // extended max/ultra modes (fail closed).
133            None if level.requires_codex_support() => Err(format!(
134                "no capability metadata for {model}; cannot authorize level '{level}'"
135            )),
136            None => Ok(()),
137        };
138    }
139    if let Some(model_id) = model.strip_prefix("xai-auth/") {
140        // Adaptive = provider default (omit `reasoning`) — always expressible.
141        if level == ReasoningLevel::Adaptive {
142            return Ok(());
143        }
144        return match xai_static_capability(model_id) {
145            Some(XaiReasoningCapability::Effort {
146                supported,
147                can_disable,
148                ..
149            }) => match level {
150                ReasoningLevel::Off if can_disable => Ok(()),
151                ReasoningLevel::Off => Err(format!(
152                    "reasoning cannot be disabled on {model}; use adaptive or a supported effort"
153                )),
154                l if supported.contains(&l) => Ok(()),
155                l => Err(unsupported_msg(l, model, supported)),
156            },
157            Some(XaiReasoningCapability::IntrinsicReasoning) => match level {
158                ReasoningLevel::Off => Err(format!(
159                    "{model} has no documented way to disable reasoning; use adaptive"
160                )),
161                l => Err(format!(
162                    "{model} has no documented effort control; level '{l}' cannot be sent — use adaptive"
163                )),
164            },
165            Some(XaiReasoningCapability::NonReasoning) => match level {
166                ReasoningLevel::Off => Ok(()),
167                l => Err(format!(
168                    "{model} is a non-reasoning model; level '{l}' is not supported"
169                )),
170            },
171            None => Err(format!(
172                "no capability metadata for {model}; cannot authorize level '{level}'"
173            )),
174        };
175    }
176    if let Some(capability) = kimi_route_capability(model) {
177        // Adaptive = provider default (omit the field) — always expressible.
178        if level == ReasoningLevel::Adaptive {
179            return Ok(());
180        }
181        return match capability {
182            Some(KimiReasoningCapability::Effort {
183                supported,
184                can_disable,
185                ..
186            }) => match level {
187                ReasoningLevel::Off if can_disable => Ok(()),
188                ReasoningLevel::Off => Err(format!(
189                    "reasoning cannot be disabled on {model}; use adaptive or a supported effort"
190                )),
191                l if supported.contains(&l) => Ok(()),
192                l => Err(unsupported_msg(l, model, supported)),
193            },
194            Some(KimiReasoningCapability::AlwaysThinking) => match level {
195                ReasoningLevel::Off => Err(format!(
196                    "{model} always thinks and the API rejects disabling; use adaptive"
197                )),
198                l => Err(format!(
199                    "{model} has no documented effort control; level '{l}' cannot be sent — use adaptive"
200                )),
201            },
202            Some(KimiReasoningCapability::ToggleableThinking) => match level {
203                ReasoningLevel::Off => Ok(()),
204                l => Err(format!(
205                    "{model} supports only a thinking on/off toggle; level '{l}' cannot be sent — use off or adaptive"
206                )),
207            },
208            None => Err(format!(
209                "no capability metadata for {model}; cannot authorize level '{level}'"
210            )),
211        };
212    }
213    if let Some(model_id) = anthropic_model_id(model) {
214        if matches!(level, ReasoningLevel::Max | ReasoningLevel::UltraCode) {
215            // Max/UltraCode require an exact qualified Anthropic capability row;
216            // bare/unknown Anthropic identities must fail closed.
217            let capabilities = model
218                .starts_with("anthropic/")
219                .then(|| super::anthropic_mode_capabilities(model))
220                .flatten()
221                .map(|caps| {
222                    caps.narrow_with_live_effort(capability_cache::get(model).and_then(|entry| {
223                        match entry.reasoning {
224                            ReasoningSupport::AnthropicAdaptive { adaptive } => Some(adaptive),
225                            ReasoningSupport::None => Some(false),
226                            _ => None,
227                        }
228                    }))
229                });
230            let supported = match level {
231                ReasoningLevel::Max => capabilities.is_some_and(|caps| caps.max_supported),
232                ReasoningLevel::UltraCode => {
233                    capabilities.is_some_and(|caps| caps.ultracode_supported())
234                }
235                _ => unreachable!(),
236            };
237            return supported
238                .then_some(())
239                .ok_or_else(|| format!("reasoning level '{level}' is not supported by {model}"));
240        }
241        if level == ReasoningLevel::Ultra {
242            return Err(format!(
243                "reasoning level '{level}' requires authoritative exact-model capability metadata; {model} has none"
244            ));
245        }
246        // Live catalog evidence that thinking is unsupported fails closed for
247        // named levels; Off/Adaptive stay expressible (field omission).
248        if matches!(anthropic_capability(model_id), Some(ReasoningSupport::None))
249            && !matches!(level, ReasoningLevel::Off | ReasoningLevel::Adaptive)
250        {
251            return Err(format!(
252                "{model} does not support extended thinking; level '{level}' is not available"
253            ));
254        }
255        return Ok(());
256    }
257    // Other providers: only the Codex-extended modes are gated.
258    if level.requires_codex_support() {
259        return Err(format!(
260            "reasoning level '{level}' requires authoritative exact-model capability metadata; {model} has none"
261        ));
262    }
263    Ok(())
264}
265
266/// Model-default reasoning level applied on model switch when the user has
267/// not explicitly chosen a level. `None` = leave the current level untouched.
268pub fn default_level_for_model(model: &str) -> Option<ReasoningLevel> {
269    if let Some(model_id) = model.strip_prefix("muse/") {
270        // Live catalog default (what the official client sends) when the
271        // exact row advertised one; otherwise provider default via omission.
272        return Some(muse_default_level(model_id).unwrap_or(ReasoningLevel::Adaptive));
273    }
274    if let Some(model_id) = model.strip_prefix("openai-codex/") {
275        return match codex_reasoning_capability(model, model_id)? {
276            ReasoningSupport::CodexNamed { default_level, .. } => default_level,
277            _ => None,
278        };
279    }
280    if let Some(model_id) = model.strip_prefix("xai-auth/") {
281        return Some(match xai_static_capability(model_id) {
282            Some(XaiReasoningCapability::Effort {
283                default_level: Some(level),
284                ..
285            }) => level,
286            // No documented default / no effort control / unknown id →
287            // provider default via field omission.
288            _ => ReasoningLevel::Adaptive,
289        });
290    }
291    if let Some(capability) = kimi_route_capability(model) {
292        return Some(match capability {
293            Some(KimiReasoningCapability::Effort {
294                default_level: Some(level),
295                ..
296            }) => level,
297            // Toggle/always-thinking/unknown → provider default via omission.
298            _ => ReasoningLevel::Adaptive,
299        });
300    }
301    None
302}
303
304/// Human-readable reasoning *type* for the settings UI, derived from the
305/// same exact capability sources as `thinking_options_for_model` (capability
306/// cache first, then exact static descriptors). Never claims a verified type
307/// for providers without authoritative metadata.
308pub fn reasoning_type_for_model(model: &str) -> &'static str {
309    if let Some(model_id) = model.strip_prefix("muse/") {
310        return if muse_supported_levels(model_id).is_some() {
311            "effort (named)"
312        } else {
313            "unknown"
314        };
315    }
316    if model.strip_prefix("openai-codex/").is_some() {
317        // Codex always expresses reasoning as named effort on the wire.
318        return "effort (named)";
319    }
320    if let Some(model_id) = model.strip_prefix("xai-auth/") {
321        return match xai_static_capability(model_id) {
322            Some(XaiReasoningCapability::Effort { .. }) => "effort",
323            Some(XaiReasoningCapability::IntrinsicReasoning) => "intrinsic",
324            Some(XaiReasoningCapability::NonReasoning) => "none",
325            None => "unknown",
326        };
327    }
328    if let Some(capability) = kimi_route_capability(model) {
329        return match capability {
330            Some(KimiReasoningCapability::Effort { .. }) => "effort",
331            Some(KimiReasoningCapability::AlwaysThinking) => "intrinsic",
332            Some(KimiReasoningCapability::ToggleableThinking) => "toggle (on/off)",
333            None => "unknown",
334        };
335    }
336    if let Some(model_id) = anthropic_model_id(model) {
337        return match anthropic_capability(model_id) {
338            Some(ReasoningSupport::AnthropicAdaptive { adaptive: true }) => "adaptive",
339            Some(ReasoningSupport::None) => "none",
340            // Explicit non-adaptive metadata or no metadata: the legacy
341            // enabled+budget_tokens request shape.
342            _ => "budget (legacy)",
343        };
344    }
345    "unverified"
346}
347
348/// Dynamic thinking options for the settings UI, derived from exact
349/// catalog/static capabilities for the provider-qualified model id.
350pub fn thinking_options_for_model(model: &str) -> Vec<String> {
351    let owned = |slice: &[&str]| slice.iter().map(|s| s.to_string()).collect::<Vec<_>>();
352    if let Some(model_id) = model.strip_prefix("muse/") {
353        // Exactly what validation authorizes: adaptive + the live effort set
354        // (`off` appears only when the catalog advertised `none`).
355        let mut opts = vec!["adaptive".to_string()];
356        if let Some(levels) = muse_supported_levels(model_id) {
357            if levels.contains(&ReasoningLevel::Off) {
358                opts.insert(0, "off".to_string());
359            }
360            opts.extend(
361                levels
362                    .iter()
363                    .filter(|l| **l != ReasoningLevel::Off)
364                    .map(|l| l.as_str().to_string()),
365            );
366        }
367        return opts;
368    }
369    if let Some(model_id) = model.strip_prefix("openai-codex/") {
370        if let Some(levels) = codex_supported_levels(model, model_id) {
371            let mut opts = vec!["off".to_string(), "adaptive".to_string()];
372            opts.extend(levels.iter().map(|l| l.as_str().to_string()));
373            return opts;
374        }
375        return owned(CONSERVATIVE_OPTIONS);
376    }
377    if let Some(model_id) = model.strip_prefix("xai-auth/") {
378        return match xai_static_capability(model_id) {
379            Some(XaiReasoningCapability::Effort {
380                supported,
381                can_disable,
382                ..
383            }) => {
384                let mut opts = Vec::new();
385                if can_disable {
386                    opts.push("off".to_string());
387                }
388                opts.push("adaptive".to_string());
389                opts.extend(supported.iter().map(|l| l.as_str().to_string()));
390                opts
391            }
392            Some(XaiReasoningCapability::NonReasoning) => owned(&["off", "adaptive"]),
393            // Intrinsic reasoning without effort control, or unknown id:
394            // only the provider default is expressible.
395            _ => owned(&["adaptive"]),
396        };
397    }
398    if let Some(capability) = kimi_route_capability(model) {
399        return match capability {
400            Some(KimiReasoningCapability::Effort {
401                supported,
402                can_disable,
403                ..
404            }) => {
405                let mut opts = Vec::new();
406                if can_disable {
407                    opts.push("off".to_string());
408                }
409                opts.push("adaptive".to_string());
410                opts.extend(supported.iter().map(|l| l.as_str().to_string()));
411                opts
412            }
413            Some(KimiReasoningCapability::ToggleableThinking) => owned(&["off", "adaptive"]),
414            // Always-thinking without effort control, or unknown id: only
415            // the provider default is expressible.
416            _ => owned(&["adaptive"]),
417        };
418    }
419    if let Some(model_id) = anthropic_model_id(model) {
420        let live = model
421            .starts_with("anthropic/")
422            .then(|| capability_cache::get(model))
423            .flatten()
424            .map(|entry| entry.reasoning);
425        let specials_authorized = super::anthropic_mode_capabilities(model).is_some()
426            && !matches!(live, Some(ReasoningSupport::None));
427        return match anthropic_capability(model_id) {
428            // Thinking-capable (adaptive effort or legacy budget tiers). Generic
429            // live true preserves exact static authority but cannot invent it.
430            Some(ReasoningSupport::AnthropicAdaptive { .. }) if specials_authorized => owned(&[
431                "off",
432                "adaptive",
433                "low",
434                "medium",
435                "high",
436                "xhigh",
437                "max",
438                "ultracode",
439            ]),
440            Some(ReasoningSupport::AnthropicAdaptive { .. }) => owned(CONSERVATIVE_OPTIONS),
441            // Explicit live false revokes both special modes.
442            Some(ReasoningSupport::None) => owned(&["off", "adaptive"]),
443            _ if specials_authorized => owned(&[
444                "off",
445                "adaptive",
446                "low",
447                "medium",
448                "high",
449                "xhigh",
450                "max",
451                "ultracode",
452            ]),
453            _ => owned(CONSERVATIVE_OPTIONS),
454        };
455    }
456    owned(CONSERVATIVE_OPTIONS)
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use ReasoningLevel::*;
463
464    #[test]
465    fn muse_levels_follow_exact_live_catalog_and_fail_closed_otherwise() {
466        let _guard = crate::runtime::openai::catalog::muse::test_cache_guard();
467        // Static seed: only the provider default is expressible.
468        let seed = "muse/muse-wonder-1.0";
469        assert!(validate_reasoning_mutation(seed, Adaptive).is_ok());
470        for level in [Off, Low, High, Max, Ultra] {
471            assert!(validate_reasoning_mutation(seed, level).is_err(), "{level}");
472        }
473        assert_eq!(thinking_options_for_model(seed), ["adaptive"]);
474        assert_eq!(default_level_for_model(seed), Some(Adaptive));
475        // Uncataloged (routable) id: provider default only.
476        assert!(validate_reasoning_mutation("muse/never-cataloged-zz9", Adaptive).is_ok());
477        assert!(validate_reasoning_mutation("muse/never-cataloged-zz9", High).is_err());
478        // Malformed id: nothing, not even adaptive, is authorized.
479        assert!(validate_reasoning_mutation("muse/Bad Id", Adaptive).is_err());
480
481        // A live row with an exact effort set authorizes exactly that set.
482        let models = crate::runtime::openai::catalog::parse_muse_catalog_models(
483            r#"{"models":[{"id":"muse-validation-probe-zz1","reasoning_effort_variants":
484                [{"effort":"none"},{"effort":"low"},{"effort":"high"}]}]}"#,
485        )
486        .unwrap();
487        capability_cache::replace_provider("muse", &models);
488        let live = "muse/muse-validation-probe-zz1";
489        for level in [Adaptive, Off, Low, High] {
490            assert!(validate_reasoning_mutation(live, level).is_ok(), "{level}");
491        }
492        for level in [Medium, XHigh, Max, Ultra, UltraCode] {
493            assert!(validate_reasoning_mutation(live, level).is_err(), "{level}");
494        }
495        assert_eq!(
496            thinking_options_for_model(live),
497            ["off", "adaptive", "low", "high"]
498        );
499        assert_eq!(reasoning_type_for_model(live), "effort (named)");
500        capability_cache::replace_provider("muse", &[]);
501    }
502
503    #[test]
504    fn fable_options_expose_distinct_special_modes() {
505        let model = "anthropic/claude-fable-5";
506        assert_eq!(
507            thinking_options_for_model(model),
508            [
509                "off",
510                "adaptive",
511                "low",
512                "medium",
513                "high",
514                "xhigh",
515                "max",
516                "ultracode"
517            ]
518        );
519    }
520
521    // ── xAI mutation matrix ──────────────────────────────────────────────────
522
523    #[test]
524    fn xai_45_and_46_accept_exact_efforts_and_reject_off_xhigh_max_ultra() {
525        for model in [
526            "xai-auth/grok-4.5",
527            "xai-auth/grok-4.5-latest",
528            "xai-auth/grok-4.6",
529        ] {
530            for level in [Adaptive, Low, Medium, High] {
531                assert!(
532                    validate_reasoning_mutation(model, level).is_ok(),
533                    "{model} {level}"
534                );
535            }
536            for level in [Off, XHigh, Max, Ultra] {
537                let err = validate_reasoning_mutation(model, level).unwrap_err();
538                assert!(err.contains(model), "{err}");
539            }
540        }
541    }
542
543    #[test]
544    fn xai_multi_agent_accepts_xhigh_but_not_off() {
545        let model = "xai-auth/grok-4.20-multi-agent-0309";
546        for level in [Adaptive, Low, Medium, High, XHigh] {
547            assert!(validate_reasoning_mutation(model, level).is_ok(), "{level}");
548        }
549        for level in [Off, Max, Ultra] {
550            assert!(
551                validate_reasoning_mutation(model, level).is_err(),
552                "{level}"
553            );
554        }
555    }
556
557    #[test]
558    fn xai_intrinsic_reasoning_rejects_named_and_off_accepts_adaptive() {
559        for id in [
560            "grok-4.3",
561            "grok-4.3-latest",
562            "grok-latest",
563            "grok-4.20-0309-reasoning",
564        ] {
565            let model = format!("xai-auth/{id}");
566            assert!(validate_reasoning_mutation(&model, Adaptive).is_ok());
567            for level in [Off, Low, Medium, High, XHigh, Max, Ultra] {
568                assert!(
569                    validate_reasoning_mutation(&model, level).is_err(),
570                    "{model} {level}"
571                );
572            }
573        }
574    }
575
576    #[test]
577    fn xai_non_reasoning_rejects_named_accepts_off_adaptive() {
578        let model = "xai-auth/grok-4.20-0309-non-reasoning";
579        assert!(validate_reasoning_mutation(model, Off).is_ok());
580        assert!(validate_reasoning_mutation(model, Adaptive).is_ok());
581        for level in [Low, Medium, High, XHigh, Max, Ultra] {
582            assert!(
583                validate_reasoning_mutation(model, level).is_err(),
584                "{level}"
585            );
586        }
587    }
588
589    #[test]
590    fn xai_unknown_id_fails_closed_except_adaptive() {
591        let model = "xai-auth/grok-9000";
592        assert!(validate_reasoning_mutation(model, Adaptive).is_ok());
593        for level in [Off, Low, Medium, High, XHigh, Max, Ultra] {
594            assert!(
595                validate_reasoning_mutation(model, level).is_err(),
596                "{level}"
597            );
598        }
599    }
600
601    // ── Codex gap fix ────────────────────────────────────────────────────────
602
603    #[test]
604    fn unknown_codex_model_rejects_max_and_ultra_at_mutation_time() {
605        for level in [Max, Ultra] {
606            let err =
607                validate_reasoning_mutation("openai-codex/gpt-unknown-future", level).unwrap_err();
608            assert!(err.contains("no capability metadata"), "{err}");
609        }
610        // Non-extended named levels remain permissive without metadata.
611        for level in [Off, Adaptive, Low, Medium, High, XHigh] {
612            assert!(
613                validate_reasoning_mutation("openai-codex/gpt-unknown-future", level).is_ok(),
614                "{level}"
615            );
616        }
617    }
618
619    #[test]
620    fn known_codex_model_membership_still_enforced() {
621        assert!(validate_reasoning_mutation("openai-codex/gpt-5.6-sol", Ultra).is_ok());
622        assert!(validate_reasoning_mutation("openai-codex/gpt-5.5", Ultra).is_err());
623    }
624
625    #[test]
626    fn live_codex_ultra_requires_v2_at_mutation_and_in_options() {
627        use super::super::{
628            CatalogModel, CatalogProviderKind, CatalogSource, CodexMultiAgentVersion,
629        };
630
631        for (suffix, version) in [("missing", None), ("v1", Some(CodexMultiAgentVersion::V1))] {
632            let id = format!("gpt-ultra-validation-{suffix}");
633            let qualified = format!("openai-codex/{id}");
634            let mut live = CatalogModel::new("openai-codex", "OpenAI Codex", &id).unwrap();
635            live.provider_kind = CatalogProviderKind::OpenAiCodex;
636            live.source = CatalogSource::Live;
637            live.reasoning = ReasoningSupport::CodexNamed {
638                multi_agent_reasoning_effort: None,
639                supported: vec![Low, Medium, High, XHigh, Max, Ultra],
640                default_level: Some(High),
641                multi_agent_version: version,
642            };
643            capability_cache::insert(live);
644
645            assert!(
646                validate_reasoning_mutation(&qualified, Max).is_ok(),
647                "Max does not require v2: {qualified}"
648            );
649            let err = validate_reasoning_mutation(&qualified, Ultra)
650                .expect_err("Ultra must fail closed without exact v2 evidence");
651            assert!(err.contains("multi-agent v2"), "{err}");
652
653            let options = thinking_options_for_model(&qualified);
654            assert!(options.contains(&"max".to_string()), "{options:?}");
655            assert!(!options.contains(&"ultra".to_string()), "{options:?}");
656        }
657    }
658
659    // ── Anthropic ────────────────────────────────────────────────────────────
660
661    #[test]
662    fn anthropic_accepts_budget_expressible_levels_and_rejects_extended() {
663        for model in ["anthropic/claude-opus-4-7", "claude-sonnet-4-6"] {
664            for level in [Off, Adaptive, Low, Medium, High, XHigh] {
665                assert!(
666                    validate_reasoning_mutation(model, level).is_ok(),
667                    "{model} {level}"
668                );
669            }
670            for level in [Max, Ultra, UltraCode] {
671                assert!(
672                    validate_reasoning_mutation(model, level).is_err(),
673                    "{model} {level}"
674                );
675            }
676        }
677    }
678
679    #[test]
680    fn anthropic_live_no_thinking_evidence_rejects_named() {
681        let mut m = super::super::CatalogModel::new(
682            "anthropic",
683            "Anthropic",
684            "claude-test-validation-nothink",
685        )
686        .unwrap();
687        m.provider_kind = super::super::CatalogProviderKind::Anthropic;
688        m.reasoning = ReasoningSupport::None;
689        m.source = super::super::CatalogSource::Live;
690        capability_cache::insert(m);
691        let model = "anthropic/claude-test-validation-nothink";
692        for level in [Off, Adaptive] {
693            assert!(validate_reasoning_mutation(model, level).is_ok(), "{level}");
694        }
695        for level in [Low, Medium, High, XHigh] {
696            assert!(
697                validate_reasoning_mutation(model, level).is_err(),
698                "{level}"
699            );
700        }
701    }
702
703    // ── Defaults ─────────────────────────────────────────────────────────────
704
705    #[test]
706    fn default_levels_come_from_exact_descriptors() {
707        assert_eq!(default_level_for_model("xai-auth/grok-4.5"), Some(High));
708        assert_eq!(
709            default_level_for_model("xai-auth/grok-4.5-latest"),
710            Some(High)
711        );
712        assert_eq!(default_level_for_model("xai-auth/grok-4.6"), Some(High));
713        assert_eq!(
714            default_level_for_model("xai-auth/grok-4.20-multi-agent-0309"),
715            Some(Adaptive)
716        );
717        assert_eq!(default_level_for_model("xai-auth/grok-4.3"), Some(Adaptive));
718        assert_eq!(
719            default_level_for_model("xai-auth/grok-4.20-0309-non-reasoning"),
720            Some(Adaptive)
721        );
722        assert_eq!(default_level_for_model("anthropic/claude-opus-4-7"), None);
723        assert_eq!(
724            default_level_for_model("openai-codex/gpt-5.3-codex-spark"),
725            Some(High)
726        );
727    }
728
729    // ── Dynamic options ──────────────────────────────────────────────────────
730
731    #[test]
732    fn xai_options_derive_from_exact_capabilities() {
733        assert_eq!(
734            thinking_options_for_model("xai-auth/grok-4.5"),
735            vec!["adaptive", "low", "medium", "high"]
736        );
737        assert_eq!(
738            thinking_options_for_model("xai-auth/grok-4.6"),
739            vec!["adaptive", "low", "medium", "high"]
740        );
741        assert_eq!(
742            thinking_options_for_model("xai-auth/grok-4.20-multi-agent-0309"),
743            vec!["adaptive", "low", "medium", "high", "xhigh"]
744        );
745        assert_eq!(
746            thinking_options_for_model("xai-auth/grok-4.3"),
747            vec!["adaptive"]
748        );
749        assert_eq!(
750            thinking_options_for_model("xai-auth/grok-4.20-0309-non-reasoning"),
751            vec!["off", "adaptive"]
752        );
753        assert_eq!(
754            thinking_options_for_model("xai-auth/grok-9000"),
755            vec!["adaptive"]
756        );
757    }
758
759    // ── Reasoning type derivation (settings display) ─────────────────────────
760
761    #[test]
762    fn reasoning_type_derives_from_exact_capabilities() {
763        // Codex: named effort levels on the wire.
764        assert_eq!(
765            reasoning_type_for_model("openai-codex/gpt-5.6-sol"),
766            "effort (named)"
767        );
768        assert_eq!(
769            reasoning_type_for_model("openai-codex/gpt-unknown-future"),
770            "effort (named)"
771        );
772        // xAI: documented effort control / intrinsic / non-reasoning.
773        assert_eq!(reasoning_type_for_model("xai-auth/grok-4.5"), "effort");
774        assert_eq!(reasoning_type_for_model("xai-auth/grok-4.6"), "effort");
775        assert_eq!(reasoning_type_for_model("xai-auth/grok-4.3"), "intrinsic");
776        assert_eq!(
777            reasoning_type_for_model("xai-auth/grok-4.20-0309-non-reasoning"),
778            "none"
779        );
780        assert_eq!(reasoning_type_for_model("xai-auth/grok-9000"), "unknown");
781        // Anthropic: adaptive effort vs legacy numeric budget.
782        assert_eq!(
783            reasoning_type_for_model("anthropic/claude-opus-4-7"),
784            "adaptive"
785        );
786        assert_eq!(
787            reasoning_type_for_model("anthropic/claude-sonnet-4-6"),
788            "budget (legacy)"
789        );
790        assert_eq!(
791            reasoning_type_for_model("claude-sonnet-4-6"),
792            "budget (legacy)"
793        );
794        // No authoritative metadata: legacy budget path, honestly labelled.
795        assert_eq!(
796            reasoning_type_for_model("anthropic/claude-future-x"),
797            "budget (legacy)"
798        );
799        // Other providers without exact metadata never claim a verified type.
800        assert_eq!(
801            reasoning_type_for_model("groq/llama-3.3-70b-versatile"),
802            "unverified"
803        );
804    }
805
806    /// Regression: a live catalog entry parsed WITHOUT capability metadata
807    /// (`ReasoningSupport::Unknown`, e.g. from pagination payloads) must not
808    /// shadow the exact-id static table.
809    #[test]
810    fn cached_unknown_reasoning_does_not_shadow_static_capability() {
811        let mut m =
812            super::super::CatalogModel::new("anthropic", "Anthropic", "claude-opus-4-7").unwrap();
813        m.reasoning = crate::runtime::openai::catalog::ReasoningSupport::Unknown;
814        capability_cache::insert(m);
815        assert_eq!(
816            reasoning_type_for_model("anthropic/claude-opus-4-7"),
817            "adaptive",
818            "cached Unknown must fall through to the static adaptive entry"
819        );
820    }
821
822    #[test]
823    fn reasoning_type_none_for_live_no_thinking_anthropic_evidence() {
824        let mut m = super::super::CatalogModel::new(
825            "anthropic",
826            "Anthropic",
827            "claude-test-reasoning-type-nothink",
828        )
829        .unwrap();
830        m.provider_kind = super::super::CatalogProviderKind::Anthropic;
831        m.reasoning = ReasoningSupport::None;
832        m.source = super::super::CatalogSource::Live;
833        capability_cache::insert(m);
834        assert_eq!(
835            reasoning_type_for_model("anthropic/claude-test-reasoning-type-nothink"),
836            "none"
837        );
838    }
839
840    #[test]
841    fn anthropic_options_derive_from_capabilities() {
842        assert_eq!(
843            thinking_options_for_model("anthropic/claude-opus-4-7"),
844            vec!["off", "adaptive", "low", "medium", "high", "xhigh"]
845        );
846        // Fixed-budget models keep the budget-tier set.
847        assert_eq!(
848            thinking_options_for_model("claude-sonnet-4-6"),
849            vec!["off", "adaptive", "low", "medium", "high", "xhigh"]
850        );
851    }
852
853    #[test]
854    fn options_never_advertise_max_ultra_off_codex() {
855        for model in [
856            "anthropic/claude-opus-4-7",
857            "xai-auth/grok-4.5",
858            "groq/llama-3.3-70b-versatile",
859        ] {
860            let opts = thinking_options_for_model(model);
861            assert!(!opts.contains(&"max".to_string()), "{model}");
862            assert!(!opts.contains(&"ultra".to_string()), "{model}");
863        }
864    }
865
866    #[test]
867    fn kimi_k3_accepts_exact_efforts_and_rejects_off_medium_xhigh_ultra() {
868        for model in [
869            "kimi/kimi-k3",
870            "kimi/kimi-k3.1-pro",
871            "kimi/kimi-k3.1-pro-highspeed",
872        ] {
873            for level in [Adaptive, Low, High, Max] {
874                assert!(
875                    validate_reasoning_mutation(model, level).is_ok(),
876                    "{model} {level}"
877                );
878            }
879            for level in [Off, Medium, XHigh, Ultra, UltraCode] {
880                let err = validate_reasoning_mutation(model, level).unwrap_err();
881                assert!(err.contains("kimi"), "{model}: {err}");
882            }
883        }
884    }
885
886    #[test]
887    fn kimi_always_thinking_models_accept_only_adaptive() {
888        for model in ["kimi/kimi-k2.7-code", "kimi/kimi-k2.7-code-highspeed"] {
889            assert!(
890                validate_reasoning_mutation(model, Adaptive).is_ok(),
891                "{model}"
892            );
893            for level in [Off, Low, Medium, High, Max] {
894                assert!(
895                    validate_reasoning_mutation(model, level).is_err(),
896                    "{model} {level}"
897                );
898            }
899        }
900    }
901
902    #[test]
903    fn kimi_toggleable_models_accept_off_and_adaptive_only() {
904        for model in ["kimi/kimi-k2.6", "kimi/kimi-k2.5"] {
905            for level in [Off, Adaptive] {
906                assert!(
907                    validate_reasoning_mutation(model, level).is_ok(),
908                    "{model} {level}"
909                );
910            }
911            for level in [Low, Medium, High, Max] {
912                assert!(
913                    validate_reasoning_mutation(model, level).is_err(),
914                    "{model} {level}"
915                );
916            }
917        }
918    }
919
920    #[test]
921    fn kimi_unknown_ids_fail_closed_except_adaptive() {
922        let model = "kimi/kimi-k9000";
923        assert!(validate_reasoning_mutation(model, Adaptive).is_ok());
924        for level in [Off, Low, Medium, High, Max, Ultra] {
925            assert!(
926                validate_reasoning_mutation(model, level).is_err(),
927                "{level}"
928            );
929        }
930    }
931
932    #[test]
933    fn kimi_options_are_exact_per_model() {
934        for model in [
935            "kimi/kimi-k3",
936            "kimi/kimi-k3.1-pro",
937            "kimi/kimi-k3.1-pro-highspeed",
938        ] {
939            assert_eq!(
940                thinking_options_for_model(model),
941                vec!["adaptive", "low", "high", "max"],
942                "{model}"
943            );
944        }
945        for model in ["kimi/kimi-k2.7-code", "kimi/kimi-k2.7-code-highspeed"] {
946            assert_eq!(
947                thinking_options_for_model(model),
948                vec!["adaptive"],
949                "{model}"
950            );
951        }
952        for model in ["kimi/kimi-k2.6", "kimi/kimi-k2.5"] {
953            assert_eq!(
954                thinking_options_for_model(model),
955                vec!["off", "adaptive"],
956                "{model}"
957            );
958        }
959        assert_eq!(
960            thinking_options_for_model("kimi/kimi-k9000"),
961            vec!["adaptive"]
962        );
963    }
964
965    #[test]
966    fn kimi_reasoning_types_and_default_level() {
967        for model in [
968            "kimi/kimi-k3",
969            "kimi/kimi-k3.1-pro",
970            "kimi/kimi-k3.1-pro-highspeed",
971        ] {
972            assert_eq!(reasoning_type_for_model(model), "effort", "{model}");
973            assert_eq!(
974                default_level_for_model(model),
975                Some(ReasoningLevel::Max),
976                "{model}"
977            );
978        }
979        assert_eq!(reasoning_type_for_model("kimi/kimi-k2.7-code"), "intrinsic");
980        assert_eq!(
981            reasoning_type_for_model("kimi/kimi-k2.6"),
982            "toggle (on/off)"
983        );
984        assert_eq!(reasoning_type_for_model("kimi/kimi-k9000"), "unknown");
985        assert_eq!(
986            default_level_for_model("kimi/kimi-k2.6"),
987            Some(ReasoningLevel::Adaptive)
988        );
989    }
990}