Skip to main content

agent_engine/runtime/openai/catalog/
anthropic.rs

1use super::*;
2use agent_core::reasoning::ReasoningLevel;
3use serde::Deserialize;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum AnthropicExecutionMode {
7    Standard,
8    Max,
9    UltraCode,
10}
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum AnthropicWireEffort {
14    Low,
15    Medium,
16    High,
17    XHigh,
18    Max,
19}
20
21impl AnthropicWireEffort {
22    pub const fn as_str(self) -> &'static str {
23        match self {
24            Self::Low => "low",
25            Self::Medium => "medium",
26            Self::High => "high",
27            Self::XHigh => "xhigh",
28            Self::Max => "max",
29        }
30    }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum AnthropicWorkflowPlan {
35    None,
36    Standing,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct AnthropicPlanPrerequisites {
41    pub orchestration_policy: bool,
42    pub foreground_worker_authorized: bool,
43    pub concurrent_limit: usize,
44    pub total_limit: usize,
45    pub lifecycle_start: bool,
46    pub lifecycle_status: bool,
47    pub lifecycle_steer: bool,
48    pub lifecycle_collect: bool,
49    pub lifecycle_resume: bool,
50}
51
52impl AnthropicPlanPrerequisites {
53    pub const fn installed() -> Self {
54        Self {
55            orchestration_policy: true,
56            foreground_worker_authorized: true,
57            concurrent_limit: 1,
58            total_limit: 1,
59            lifecycle_start: true,
60            lifecycle_status: true,
61            lifecycle_steer: true,
62            lifecycle_collect: true,
63            lifecycle_resume: true,
64        }
65    }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum AnthropicPlanErrorCode {
70    InvalidProviderIdentity,
71    CapabilityMetadataMissing,
72    UnsupportedReasoningLevel,
73    UltraCodeRequiresForeground,
74    UltraCodeRequiresOrchestration,
75    UltraCodeRequiresLifecycleTools,
76    UltraCodeRequiresWorkerAuthorization,
77    UltraCodeRequiresLimits,
78}
79
80impl AnthropicPlanErrorCode {
81    pub const fn as_str(self) -> &'static str {
82        match self {
83            Self::InvalidProviderIdentity => "invalid_provider_identity",
84            Self::CapabilityMetadataMissing => "capability_metadata_missing",
85            Self::UnsupportedReasoningLevel => "unsupported_reasoning_level",
86            Self::UltraCodeRequiresForeground => "ultracode_requires_foreground",
87            Self::UltraCodeRequiresOrchestration => "ultracode_requires_orchestration",
88            Self::UltraCodeRequiresLifecycleTools => "ultracode_requires_lifecycle_tools",
89            Self::UltraCodeRequiresWorkerAuthorization => "ultracode_requires_worker_authorization",
90            Self::UltraCodeRequiresLimits => "ultracode_requires_limits",
91        }
92    }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct AnthropicPlanError(AnthropicPlanErrorCode);
97impl AnthropicPlanError {
98    pub const fn code(&self) -> AnthropicPlanErrorCode {
99        self.0
100    }
101}
102impl std::fmt::Display for AnthropicPlanError {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.write_str(self.0.as_str())
105    }
106}
107impl std::error::Error for AnthropicPlanError {}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct AnthropicExecutionPlan {
111    pub qualified_model: String,
112    pub requested_level: ReasoningLevel,
113    pub role: ExecutionRole,
114    pub mode: AnthropicExecutionMode,
115    pub wire_effort: Option<AnthropicWireEffort>,
116    pub workflow: AnthropicWorkflowPlan,
117}
118
119pub(super) const ANTHROPIC_MODELS_URL: &str = "https://api.anthropic.com/v1/models";
120pub(super) const ANTHROPIC_MODELS_PAGE_LIMIT: usize = 100;
121
122/// Exact, evidence-backed Anthropic logical-mode capabilities.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub struct AnthropicModeCapabilities {
125    pub max_supported: bool,
126    pub xhigh_supported: bool,
127    pub workflow_supported: bool,
128}
129
130impl AnthropicModeCapabilities {
131    pub const fn none() -> Self {
132        Self {
133            max_supported: false,
134            xhigh_supported: false,
135            workflow_supported: false,
136        }
137    }
138
139    pub const fn ultracode_supported(self) -> bool {
140        self.xhigh_supported && self.workflow_supported
141    }
142
143    /// Generic live effort metadata can only preserve or revoke special modes.
144    pub const fn narrow_with_live_effort(mut self, supported: Option<bool>) -> Self {
145        if matches!(supported, Some(false)) {
146            self.max_supported = false;
147            self.xhigh_supported = false;
148            self.workflow_supported = false;
149        }
150        self
151    }
152}
153
154/// Source-controlled schema for exact Anthropic logical-mode authority.
155const ANTHROPIC_MODE_MANIFEST_VERSION: u16 = 1;
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub struct AnthropicManifestRow {
159    pub qualified_id: &'static str,
160    pub max_supported: bool,
161    pub xhigh_supported: bool,
162    pub workflow_supported: bool,
163    pub evidence: &'static str,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub struct AnthropicCapabilityManifest {
168    pub schema_version: u16,
169    pub rows: &'static [AnthropicManifestRow],
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub enum AnthropicManifestErrorCode {
174    UnsupportedVersion,
175    MalformedQualifiedId,
176    DuplicateId,
177    ContradictoryCapabilities,
178    EvidenceMissing,
179}
180
181#[cfg(test)]
182impl AnthropicManifestErrorCode {
183    /// Stable diagnostic identifier. Typed const manifests cannot contain
184    /// unknown enum values, so diagnostics intentionally never echo raw input.
185    pub const fn as_str(self) -> &'static str {
186        match self {
187            Self::UnsupportedVersion => "unsupported_version",
188            Self::MalformedQualifiedId => "malformed_qualified_id",
189            Self::DuplicateId => "duplicate_id",
190            Self::ContradictoryCapabilities => "contradictory_capabilities",
191            Self::EvidenceMissing => "evidence_missing",
192        }
193    }
194}
195
196impl AnthropicCapabilityManifest {
197    pub fn validate(self) -> Result<(), AnthropicManifestErrorCode> {
198        if self.schema_version != ANTHROPIC_MODE_MANIFEST_VERSION {
199            return Err(AnthropicManifestErrorCode::UnsupportedVersion);
200        }
201        let mut ids = std::collections::BTreeSet::new();
202        for row in self.rows {
203            let Some(id) = row.qualified_id.strip_prefix("anthropic/") else {
204                return Err(AnthropicManifestErrorCode::MalformedQualifiedId);
205            };
206            if id.is_empty() || id.contains('/') {
207                return Err(AnthropicManifestErrorCode::MalformedQualifiedId);
208            }
209            if !ids.insert(row.qualified_id) {
210                return Err(AnthropicManifestErrorCode::DuplicateId);
211            }
212            if row.workflow_supported && !row.xhigh_supported {
213                return Err(AnthropicManifestErrorCode::ContradictoryCapabilities);
214            }
215            if (row.max_supported || row.workflow_supported) && row.evidence.trim().is_empty() {
216                return Err(AnthropicManifestErrorCode::EvidenceMissing);
217            }
218        }
219        Ok(())
220    }
221}
222
223const ANTHROPIC_MODE_ROWS: &[AnthropicManifestRow] = &[AnthropicManifestRow {
224    qualified_id: "anthropic/claude-fable-5",
225    max_supported: true,
226    xhigh_supported: true,
227    workflow_supported: true,
228    evidence: "Claude Code 2.1.207; sha256:85e7e988a392d859f90802ca21fb26e89d3c9ab527f5ed0b08df3955e34d5c83",
229}];
230const ANTHROPIC_MODE_MANIFEST: AnthropicCapabilityManifest = AnthropicCapabilityManifest {
231    schema_version: ANTHROPIC_MODE_MANIFEST_VERSION,
232    rows: ANTHROPIC_MODE_ROWS,
233};
234
235/// Look up exact qualified identities only. The complete manifest is validated
236/// before each decision; malformed source authority therefore fails closed.
237pub fn anthropic_mode_capabilities(qualified_model: &str) -> Option<AnthropicModeCapabilities> {
238    ANTHROPIC_MODE_MANIFEST.validate().ok()?;
239    let row = ANTHROPIC_MODE_MANIFEST
240        .rows
241        .iter()
242        .find(|row| row.qualified_id == qualified_model)?;
243    Some(AnthropicModeCapabilities {
244        max_supported: row.max_supported,
245        xhigh_supported: row.xhigh_supported,
246        workflow_supported: row.workflow_supported,
247    })
248}
249
250pub fn plan_standard_anthropic_transport(
251    model: &str,
252    requested_level: ReasoningLevel,
253    role: ExecutionRole,
254) -> Option<AnthropicExecutionPlan> {
255    if matches!(
256        requested_level,
257        ReasoningLevel::Max | ReasoningLevel::UltraCode
258    ) {
259        return None;
260    }
261    let wire_effort = match requested_level {
262        ReasoningLevel::Low => Some(AnthropicWireEffort::Low),
263        ReasoningLevel::Medium => Some(AnthropicWireEffort::Medium),
264        ReasoningLevel::High => Some(AnthropicWireEffort::High),
265        ReasoningLevel::XHigh => Some(AnthropicWireEffort::XHigh),
266        _ => None,
267    };
268    Some(AnthropicExecutionPlan {
269        qualified_model: model.to_owned(),
270        requested_level,
271        role,
272        mode: AnthropicExecutionMode::Standard,
273        wire_effort,
274        workflow: AnthropicWorkflowPlan::None,
275    })
276}
277
278pub fn plan_anthropic_execution(
279    qualified_model: &str,
280    requested_level: ReasoningLevel,
281    role: ExecutionRole,
282    prerequisites: AnthropicPlanPrerequisites,
283    live_effort_supported: Option<bool>,
284) -> Result<AnthropicExecutionPlan, AnthropicPlanError> {
285    let Some(id) = qualified_model.strip_prefix("anthropic/") else {
286        return Err(AnthropicPlanError(
287            AnthropicPlanErrorCode::InvalidProviderIdentity,
288        ));
289    };
290    if id.is_empty() || id.contains('/') {
291        return Err(AnthropicPlanError(
292            AnthropicPlanErrorCode::InvalidProviderIdentity,
293        ));
294    }
295    if requested_level == ReasoningLevel::Ultra {
296        return Err(AnthropicPlanError(
297            AnthropicPlanErrorCode::UnsupportedReasoningLevel,
298        ));
299    }
300    let capabilities = anthropic_mode_capabilities(qualified_model)
301        .map(|caps| caps.narrow_with_live_effort(live_effort_supported));
302    if matches!(
303        requested_level,
304        ReasoningLevel::Max | ReasoningLevel::UltraCode
305    ) && capabilities.is_none()
306    {
307        return Err(AnthropicPlanError(
308            AnthropicPlanErrorCode::CapabilityMetadataMissing,
309        ));
310    }
311    if match requested_level {
312        ReasoningLevel::Max => !capabilities.is_some_and(|caps| caps.max_supported),
313        ReasoningLevel::UltraCode => !capabilities.is_some_and(|caps| caps.ultracode_supported()),
314        _ => false,
315    } {
316        return Err(AnthropicPlanError(
317            AnthropicPlanErrorCode::UnsupportedReasoningLevel,
318        ));
319    }
320    if requested_level == ReasoningLevel::UltraCode {
321        if role != ExecutionRole::Foreground {
322            return Err(AnthropicPlanError(
323                AnthropicPlanErrorCode::UltraCodeRequiresForeground,
324            ));
325        }
326        if !prerequisites.orchestration_policy {
327            return Err(AnthropicPlanError(
328                AnthropicPlanErrorCode::UltraCodeRequiresOrchestration,
329            ));
330        }
331        if !prerequisites.foreground_worker_authorized {
332            return Err(AnthropicPlanError(
333                AnthropicPlanErrorCode::UltraCodeRequiresWorkerAuthorization,
334            ));
335        }
336        if prerequisites.concurrent_limit == 0 || prerequisites.total_limit == 0 {
337            return Err(AnthropicPlanError(
338                AnthropicPlanErrorCode::UltraCodeRequiresLimits,
339            ));
340        }
341        if ![
342            prerequisites.lifecycle_start,
343            prerequisites.lifecycle_status,
344            prerequisites.lifecycle_steer,
345            prerequisites.lifecycle_collect,
346            prerequisites.lifecycle_resume,
347        ]
348        .into_iter()
349        .all(|present| present)
350        {
351            return Err(AnthropicPlanError(
352                AnthropicPlanErrorCode::UltraCodeRequiresLifecycleTools,
353            ));
354        }
355    }
356    let wire_effort = match requested_level {
357        ReasoningLevel::Low => Some(AnthropicWireEffort::Low),
358        ReasoningLevel::Medium => Some(AnthropicWireEffort::Medium),
359        ReasoningLevel::High => Some(AnthropicWireEffort::High),
360        ReasoningLevel::XHigh | ReasoningLevel::UltraCode => Some(AnthropicWireEffort::XHigh),
361        ReasoningLevel::Max => Some(AnthropicWireEffort::Max),
362        _ => None,
363    };
364    Ok(AnthropicExecutionPlan {
365        qualified_model: qualified_model.to_owned(),
366        requested_level,
367        role,
368        mode: match requested_level {
369            ReasoningLevel::Max => AnthropicExecutionMode::Max,
370            ReasoningLevel::UltraCode => AnthropicExecutionMode::UltraCode,
371            _ => AnthropicExecutionMode::Standard,
372        },
373        wire_effort,
374        workflow: if requested_level == ReasoningLevel::UltraCode {
375            AnthropicWorkflowPlan::Standing
376        } else {
377            AnthropicWorkflowPlan::None
378        },
379    })
380}
381
382#[cfg(test)]
383mod manifest_validation_tests {
384    use super::*;
385    const GOOD: AnthropicManifestRow = AnthropicManifestRow {
386        qualified_id: "anthropic/claude-fable-5",
387        max_supported: true,
388        xhigh_supported: true,
389        workflow_supported: true,
390        evidence: "exact evidence",
391    };
392    fn manifest(
393        version: u16,
394        rows: &'static [AnthropicManifestRow],
395    ) -> AnthropicCapabilityManifest {
396        AnthropicCapabilityManifest {
397            schema_version: version,
398            rows,
399        }
400    }
401    #[test]
402    fn dedicated_manifest_failures_and_exact_fable_success() {
403        assert_eq!(
404            manifest(2, &[GOOD]).validate(),
405            Err(AnthropicManifestErrorCode::UnsupportedVersion)
406        );
407        assert_eq!(
408            manifest(
409                1,
410                &[AnthropicManifestRow {
411                    qualified_id: "anthropic/fable/5",
412                    ..GOOD
413                }]
414            )
415            .validate(),
416            Err(AnthropicManifestErrorCode::MalformedQualifiedId)
417        );
418        assert_eq!(
419            manifest(1, &[GOOD, GOOD]).validate(),
420            Err(AnthropicManifestErrorCode::DuplicateId)
421        );
422        assert_eq!(
423            manifest(
424                1,
425                &[AnthropicManifestRow {
426                    xhigh_supported: false,
427                    ..GOOD
428                }]
429            )
430            .validate(),
431            Err(AnthropicManifestErrorCode::ContradictoryCapabilities)
432        );
433        assert_eq!(
434            manifest(
435                1,
436                &[AnthropicManifestRow {
437                    evidence: " ",
438                    ..GOOD
439                }]
440            )
441            .validate(),
442            Err(AnthropicManifestErrorCode::EvidenceMissing)
443        );
444        assert_eq!(manifest(1, &[GOOD]).validate(), Ok(()));
445        assert!(anthropic_mode_capabilities("anthropic/claude-fable-5").is_some());
446        assert!(anthropic_mode_capabilities("anthropic/claude-fable-5-near").is_none());
447    }
448    #[test]
449    fn stable_errors_are_input_free() {
450        assert_eq!(
451            [
452                AnthropicManifestErrorCode::UnsupportedVersion.as_str(),
453                AnthropicManifestErrorCode::MalformedQualifiedId.as_str(),
454                AnthropicManifestErrorCode::DuplicateId.as_str(),
455                AnthropicManifestErrorCode::ContradictoryCapabilities.as_str(),
456                AnthropicManifestErrorCode::EvidenceMissing.as_str()
457            ],
458            [
459                "unsupported_version",
460                "malformed_qualified_id",
461                "duplicate_id",
462                "contradictory_capabilities",
463                "evidence_missing"
464            ]
465        );
466    }
467}
468
469#[derive(Debug, Clone, PartialEq, Eq)]
470pub struct AnthropicCatalogPage {
471    pub models: Vec<CatalogModel>,
472    pub has_more: bool,
473    pub last_id: Option<String>,
474}
475
476#[derive(Debug, Deserialize)]
477struct AnthropicModelsPage {
478    data: Vec<AnthropicModelItem>,
479    #[serde(default)]
480    has_more: bool,
481    #[serde(default)]
482    last_id: Option<String>,
483}
484
485#[derive(Debug, Deserialize)]
486struct AnthropicModelItem {
487    id: String,
488    #[serde(default)]
489    display_name: Option<String>,
490    #[serde(default)]
491    max_input_tokens: Option<u64>,
492    #[serde(default)]
493    max_tokens: Option<u64>,
494    #[serde(default)]
495    capabilities: Option<AnthropicCapabilities>,
496}
497
498#[derive(Debug, Deserialize)]
499struct AnthropicCapabilities {
500    #[serde(default)]
501    thinking: Option<CapabilitySupported>,
502    #[serde(default)]
503    effort: Option<AnthropicEffortCapability>,
504}
505
506#[derive(Debug, Deserialize)]
507struct CapabilitySupported {
508    #[serde(default)]
509    supported: bool,
510}
511
512#[derive(Debug, Deserialize)]
513struct AnthropicEffortCapability {
514    #[serde(default)]
515    supported: bool,
516}
517
518pub fn parse_anthropic_catalog_page(body: &str) -> Result<AnthropicCatalogPage, serde_json::Error> {
519    let page: AnthropicModelsPage = serde_json::from_str(body)?;
520    let models: Vec<CatalogModel> = page
521        .data
522        .into_iter()
523        .filter_map(|item| {
524            let mut m = CatalogModel::new("anthropic", "Anthropic", item.id)?;
525            m.provider_kind = CatalogProviderKind::Anthropic;
526            m.label = item.display_name.filter(|name| !name.trim().is_empty());
527            m.context_tokens = item.max_input_tokens;
528            m.max_output_tokens = item.max_tokens;
529            m.reasoning = match item.capabilities {
530                Some(caps) => match caps.thinking.as_ref() {
531                    Some(thinking) if thinking.supported => ReasoningSupport::AnthropicAdaptive {
532                        adaptive: caps.effort.as_ref().is_some_and(|c| c.supported),
533                    },
534                    // Explicit evidence that thinking is unsupported → None
535                    // (named reasoning fails closed for this model).
536                    Some(_) => ReasoningSupport::None,
537                    // Capabilities present but thinking omitted → Unknown.
538                    None => ReasoningSupport::Unknown,
539                },
540                _ => ReasoningSupport::Unknown,
541            };
542            m.source = CatalogSource::Live;
543            Some(m)
544        })
545        .collect();
546
547    // Populate the process-local capability cache (keyed by "anthropic/<id>")
548    // so validation and dynamic option derivation see live data.
549    super::capability_cache::populate(&models);
550
551    Ok(AnthropicCatalogPage {
552        models,
553        has_more: page.has_more,
554        last_id: page.last_id.filter(|id| !id.trim().is_empty()),
555    })
556}
557
558pub fn parse_anthropic_catalog_models(body: &str) -> Result<Vec<CatalogModel>, serde_json::Error> {
559    parse_anthropic_catalog_page(body).map(|page| page.models)
560}
561
562/// Conservative exact static fallback descriptors for the source-controlled
563/// known-model list (`agent_core::models::KNOWN_MODELS`). Exact ids only —
564/// never substring-based. Evidence: adaptive-thinking notes in
565/// `crates/agent-core/src/core/models.rs` (Opus 4.7+ / Opus 5.5 / Fable 5
566/// adaptive+effort; Sonnet 4.6, Opus 4.6, Haiku 4.5 fixed-budget thinking).
567/// Max/UltraCode are NOT implied here — they stay locked to the dedicated
568/// mode manifest (`anthropic_mode_capabilities`).
569pub fn anthropic_static_capability(model_id: &str) -> Option<ReasoningSupport> {
570    match model_id {
571        "claude-opus-5-5"
572        | "claude-opus-4-7"
573        | "claude-fable-5"
574        | "claude-fable-5-1"
575        | "claude-fable-5-2" => {
576            Some(ReasoningSupport::AnthropicAdaptive { adaptive: true })
577        }
578        "claude-sonnet-4-6" | "claude-opus-4-6" | "claude-haiku-4-5-20251001" => {
579            Some(ReasoningSupport::AnthropicAdaptive { adaptive: false })
580        }
581        _ => None,
582    }
583}
584
585pub fn anthropic_models_url(after_id: Option<&str>) -> String {
586    let mut url = format!("{ANTHROPIC_MODELS_URL}?limit={ANTHROPIC_MODELS_PAGE_LIMIT}");
587    if let Some(after_id) = after_id.filter(|id| !id.trim().is_empty()) {
588        url.push_str("&after_id=");
589        url.push_str(after_id);
590    }
591    url
592}
593
594pub fn merge_catalog_pages(pages: Vec<Vec<CatalogModel>>) -> Vec<CatalogModel> {
595    let mut seen = std::collections::BTreeSet::new();
596    let mut merged = Vec::new();
597    for page in pages {
598        for model in page {
599            if seen.insert(model.id.clone()) {
600                merged.push(model);
601            }
602        }
603    }
604    merged
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    #[test]
612    fn standard_transport_accepts_bare_or_unknown_models_but_special_modes_require_authority() {
613        for model in ["claude-haiku-4-5", "anthropic/unknown-model"] {
614            for level in [
615                ReasoningLevel::Off,
616                ReasoningLevel::Adaptive,
617                ReasoningLevel::Low,
618                ReasoningLevel::Medium,
619                ReasoningLevel::High,
620                ReasoningLevel::XHigh,
621            ] {
622                let plan =
623                    plan_standard_anthropic_transport(model, level, ExecutionRole::Foreground)
624                        .expect("ordinary transport plan");
625                assert_eq!(plan.mode, AnthropicExecutionMode::Standard);
626                assert_eq!(plan.qualified_model, model);
627            }
628            assert!(plan_standard_anthropic_transport(
629                model,
630                ReasoningLevel::Max,
631                ExecutionRole::Foreground
632            )
633            .is_none());
634            assert!(plan_standard_anthropic_transport(
635                model,
636                ReasoningLevel::UltraCode,
637                ExecutionRole::Foreground
638            )
639            .is_none());
640        }
641    }
642
643    #[test]
644    fn exact_fable_mode_capabilities_are_evidence_locked() {
645        let caps = anthropic_mode_capabilities("anthropic/claude-fable-5")
646            .expect("exact qualified Fable 5 row");
647        assert!(caps.max_supported);
648        assert!(caps.xhigh_supported);
649        assert!(caps.workflow_supported);
650        assert!(caps.ultracode_supported());
651
652        for denied in [
653            "anthropic/claude-fable-5-preview",
654            "anthropic/claude-opus-4-7",
655            // Opus 5.5 is a known native model (adaptive effort, vision) but
656            // has no evidence-backed Max/UltraCode row yet.
657            "anthropic/claude-opus-5-5",
658            "openai/claude-fable-5",
659            "claude-fable-5",
660            "anthropic/fable-5",
661        ] {
662            assert_eq!(anthropic_mode_capabilities(denied), None, "{denied}");
663        }
664    }
665
666    #[test]
667    fn generic_live_effort_cannot_invent_special_modes_and_can_revoke() {
668        let static_caps = anthropic_mode_capabilities("anthropic/claude-fable-5").unwrap();
669        assert_eq!(static_caps.narrow_with_live_effort(Some(true)), static_caps);
670        let revoked = static_caps.narrow_with_live_effort(Some(false));
671        assert!(!revoked.max_supported);
672        assert!(!revoked.ultracode_supported());
673
674        assert_eq!(
675            AnthropicModeCapabilities::none().narrow_with_live_effort(Some(true)),
676            AnthropicModeCapabilities::none()
677        );
678    }
679
680    #[test]
681    fn typed_planner_maps_exact_modes_and_fails_closed() {
682        let prerequisites = AnthropicPlanPrerequisites::installed();
683        let plan = plan_anthropic_execution(
684            "anthropic/claude-fable-5",
685            ReasoningLevel::UltraCode,
686            ExecutionRole::Foreground,
687            prerequisites,
688            None,
689        )
690        .unwrap();
691        assert_eq!(plan.mode, AnthropicExecutionMode::UltraCode);
692        assert_eq!(plan.wire_effort, Some(AnthropicWireEffort::XHigh));
693        assert_eq!(plan.workflow, AnthropicWorkflowPlan::Standing);
694
695        for (level, wire, mode) in [
696            (
697                ReasoningLevel::Max,
698                AnthropicWireEffort::Max,
699                AnthropicExecutionMode::Max,
700            ),
701            (
702                ReasoningLevel::XHigh,
703                AnthropicWireEffort::XHigh,
704                AnthropicExecutionMode::Standard,
705            ),
706        ] {
707            let plan = plan_anthropic_execution(
708                "anthropic/claude-fable-5",
709                level,
710                ExecutionRole::Foreground,
711                prerequisites,
712                None,
713            )
714            .unwrap();
715            assert_eq!(plan.mode, mode);
716            assert_eq!(plan.wire_effort, Some(wire));
717            assert_eq!(plan.workflow, AnthropicWorkflowPlan::None);
718        }
719
720        for (model, level, role, prerequisites, code) in [
721            (
722                "openai-codex/claude-fable-5",
723                ReasoningLevel::UltraCode,
724                ExecutionRole::Foreground,
725                prerequisites,
726                AnthropicPlanErrorCode::InvalidProviderIdentity,
727            ),
728            (
729                "anthropic/claude-fable-5-preview",
730                ReasoningLevel::Max,
731                ExecutionRole::Foreground,
732                prerequisites,
733                AnthropicPlanErrorCode::CapabilityMetadataMissing,
734            ),
735            (
736                "anthropic/claude-fable-5",
737                ReasoningLevel::Ultra,
738                ExecutionRole::Foreground,
739                prerequisites,
740                AnthropicPlanErrorCode::UnsupportedReasoningLevel,
741            ),
742            (
743                "anthropic/claude-fable-5",
744                ReasoningLevel::UltraCode,
745                ExecutionRole::Worker,
746                prerequisites,
747                AnthropicPlanErrorCode::UltraCodeRequiresForeground,
748            ),
749            (
750                "anthropic/claude-fable-5",
751                ReasoningLevel::UltraCode,
752                ExecutionRole::Internal,
753                prerequisites,
754                AnthropicPlanErrorCode::UltraCodeRequiresForeground,
755            ),
756            (
757                "anthropic/claude-fable-5",
758                ReasoningLevel::UltraCode,
759                ExecutionRole::Foreground,
760                AnthropicPlanPrerequisites {
761                    orchestration_policy: false,
762                    ..AnthropicPlanPrerequisites::installed()
763                },
764                AnthropicPlanErrorCode::UltraCodeRequiresOrchestration,
765            ),
766            (
767                "anthropic/claude-fable-5",
768                ReasoningLevel::UltraCode,
769                ExecutionRole::Foreground,
770                AnthropicPlanPrerequisites {
771                    lifecycle_start: false,
772                    ..AnthropicPlanPrerequisites::installed()
773                },
774                AnthropicPlanErrorCode::UltraCodeRequiresLifecycleTools,
775            ),
776        ] {
777            assert_eq!(
778                plan_anthropic_execution(model, level, role, prerequisites, None)
779                    .unwrap_err()
780                    .code(),
781                code
782            );
783        }
784    }
785
786    #[test]
787    fn live_false_revokes_but_live_true_does_not_invent() {
788        let installed = AnthropicPlanPrerequisites::installed();
789        assert_eq!(
790            plan_anthropic_execution(
791                "anthropic/unknown",
792                ReasoningLevel::Max,
793                ExecutionRole::Foreground,
794                installed,
795                Some(true)
796            )
797            .unwrap_err()
798            .code(),
799            AnthropicPlanErrorCode::CapabilityMetadataMissing
800        );
801        assert_eq!(
802            plan_anthropic_execution(
803                "anthropic/claude-fable-5",
804                ReasoningLevel::Max,
805                ExecutionRole::Foreground,
806                installed,
807                Some(false)
808            )
809            .unwrap_err()
810            .code(),
811            AnthropicPlanErrorCode::UnsupportedReasoningLevel
812        );
813    }
814
815    // ── Exact static fallback descriptors (spec: anthropic-xai-reasoning-modes) ──
816
817    #[test]
818    fn static_capability_covers_exact_known_models_only() {
819        for id in ["claude-opus-5-5", "claude-opus-4-7", "claude-fable-5"] {
820            assert_eq!(
821                anthropic_static_capability(id),
822                Some(ReasoningSupport::AnthropicAdaptive { adaptive: true }),
823                "{id}"
824            );
825        }
826        for id in [
827            "claude-sonnet-4-6",
828            "claude-opus-4-6",
829            "claude-haiku-4-5-20251001",
830        ] {
831            assert_eq!(
832                anthropic_static_capability(id),
833                Some(ReasoningSupport::AnthropicAdaptive { adaptive: false }),
834                "{id}"
835            );
836        }
837        // No substring inference: near-miss ids fail closed.
838        for id in [
839            "claude-opus-4-7-preview",
840            "claude-haiku-4-5",
841            "opus-4-7",
842            "claude-opus-5.5",
843            "claude-opus-5-5-preview",
844            "opus-5-5",
845            "",
846        ] {
847            assert_eq!(anthropic_static_capability(id), None, "{id}");
848        }
849    }
850
851    /// Drift guard: the exact static capability table and the legacy
852    /// substring wire-shape classifier (`model_supports_adaptive_thinking`,
853    /// explicitly deferred — see spec) must agree on the adaptive/fixed wire
854    /// shape for EVERY source-controlled descriptor. If either side changes,
855    /// this test forces reconciling the other.
856    #[test]
857    fn static_table_and_wire_shape_classifier_agree_for_known_models() {
858        use agent_core::models::{model_supports_adaptive_thinking, KNOWN_MODELS};
859        for (id, _label) in KNOWN_MODELS {
860            let expected = ReasoningSupport::AnthropicAdaptive {
861                adaptive: model_supports_adaptive_thinking(id),
862            };
863            assert_eq!(
864                anthropic_static_capability(id),
865                Some(expected),
866                "static table and wire-shape classifier disagree for {id}"
867            );
868        }
869        // Near-miss ids: the substring classifier may infer a wire shape, but
870        // the exact static table must stay fail-closed (None) — do NOT broaden
871        // it to substring inference.
872        for (id, classifier_infers_adaptive) in [
873            ("claude-opus-4-7-preview", true),
874            ("claude-fable-5-latest", true),
875            ("claude-sonnet-4-6-legacy", false),
876        ] {
877            assert_eq!(
878                anthropic_static_capability(id),
879                None,
880                "near-miss {id} must fail closed in the static table"
881            );
882            // Documented divergence: classifier substring-infers on near-miss.
883            assert_eq!(
884                agent_core::models::model_supports_adaptive_thinking(id),
885                classifier_infers_adaptive,
886                "classifier expectation drifted for {id}"
887            );
888        }
889    }
890
891    #[test]
892    fn live_parse_maps_explicit_unsupported_thinking_to_none() {
893        let body = r#"{
894            "data": [
895                {"id": "claude-test-thinker",
896                 "capabilities": {"thinking": {"supported": true},
897                                   "effort": {"supported": true}}},
898                {"id": "claude-test-fixed",
899                 "capabilities": {"thinking": {"supported": true},
900                                   "effort": {"supported": false}}},
901                {"id": "claude-test-nothink",
902                 "capabilities": {"thinking": {"supported": false}}},
903                {"id": "claude-test-nocaps"}
904            ],
905            "has_more": false
906        }"#;
907        let models = parse_anthropic_catalog_models(body).expect("parse");
908        let by_id = |id: &str| models.iter().find(|m| m.id == id).unwrap();
909        assert_eq!(
910            by_id("claude-test-thinker").reasoning,
911            ReasoningSupport::AnthropicAdaptive { adaptive: true }
912        );
913        assert_eq!(
914            by_id("claude-test-fixed").reasoning,
915            ReasoningSupport::AnthropicAdaptive { adaptive: false }
916        );
917        // Explicit evidence of no thinking support fails closed as None.
918        assert_eq!(
919            by_id("claude-test-nothink").reasoning,
920            ReasoningSupport::None
921        );
922        // Absent capabilities stay Unknown (conservative, backward compatible).
923        assert_eq!(
924            by_id("claude-test-nocaps").reasoning,
925            ReasoningSupport::Unknown
926        );
927    }
928
929    #[test]
930    fn live_parse_populates_capability_cache_with_qualified_ids() {
931        let body = r#"{
932            "data": [
933                {"id": "claude-test-cache-entry",
934                 "capabilities": {"thinking": {"supported": true},
935                                   "effort": {"supported": true}}}
936            ],
937            "has_more": false
938        }"#;
939        parse_anthropic_catalog_models(body).expect("parse");
940        let cached = super::super::capability_cache::get("anthropic/claude-test-cache-entry")
941            .expect("live anthropic parse must populate the capability cache");
942        assert_eq!(
943            cached.reasoning,
944            ReasoningSupport::AnthropicAdaptive { adaptive: true }
945        );
946    }
947}