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" | "claude-opus-4-7" | "claude-fable-5" | "claude-fable-5-1" => {
572            Some(ReasoningSupport::AnthropicAdaptive { adaptive: true })
573        }
574        "claude-sonnet-4-6" | "claude-opus-4-6" | "claude-haiku-4-5-20251001" => {
575            Some(ReasoningSupport::AnthropicAdaptive { adaptive: false })
576        }
577        _ => None,
578    }
579}
580
581pub fn anthropic_models_url(after_id: Option<&str>) -> String {
582    let mut url = format!("{ANTHROPIC_MODELS_URL}?limit={ANTHROPIC_MODELS_PAGE_LIMIT}");
583    if let Some(after_id) = after_id.filter(|id| !id.trim().is_empty()) {
584        url.push_str("&after_id=");
585        url.push_str(after_id);
586    }
587    url
588}
589
590pub fn merge_catalog_pages(pages: Vec<Vec<CatalogModel>>) -> Vec<CatalogModel> {
591    let mut seen = std::collections::BTreeSet::new();
592    let mut merged = Vec::new();
593    for page in pages {
594        for model in page {
595            if seen.insert(model.id.clone()) {
596                merged.push(model);
597            }
598        }
599    }
600    merged
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606
607    #[test]
608    fn standard_transport_accepts_bare_or_unknown_models_but_special_modes_require_authority() {
609        for model in ["claude-haiku-4-5", "anthropic/unknown-model"] {
610            for level in [
611                ReasoningLevel::Off,
612                ReasoningLevel::Adaptive,
613                ReasoningLevel::Low,
614                ReasoningLevel::Medium,
615                ReasoningLevel::High,
616                ReasoningLevel::XHigh,
617            ] {
618                let plan =
619                    plan_standard_anthropic_transport(model, level, ExecutionRole::Foreground)
620                        .expect("ordinary transport plan");
621                assert_eq!(plan.mode, AnthropicExecutionMode::Standard);
622                assert_eq!(plan.qualified_model, model);
623            }
624            assert!(plan_standard_anthropic_transport(
625                model,
626                ReasoningLevel::Max,
627                ExecutionRole::Foreground
628            )
629            .is_none());
630            assert!(plan_standard_anthropic_transport(
631                model,
632                ReasoningLevel::UltraCode,
633                ExecutionRole::Foreground
634            )
635            .is_none());
636        }
637    }
638
639    #[test]
640    fn exact_fable_mode_capabilities_are_evidence_locked() {
641        let caps = anthropic_mode_capabilities("anthropic/claude-fable-5")
642            .expect("exact qualified Fable 5 row");
643        assert!(caps.max_supported);
644        assert!(caps.xhigh_supported);
645        assert!(caps.workflow_supported);
646        assert!(caps.ultracode_supported());
647
648        for denied in [
649            "anthropic/claude-fable-5-preview",
650            "anthropic/claude-opus-4-7",
651            // Opus 5.5 is a known native model (adaptive effort, vision) but
652            // has no evidence-backed Max/UltraCode row yet.
653            "anthropic/claude-opus-5-5",
654            "openai/claude-fable-5",
655            "claude-fable-5",
656            "anthropic/fable-5",
657        ] {
658            assert_eq!(anthropic_mode_capabilities(denied), None, "{denied}");
659        }
660    }
661
662    #[test]
663    fn generic_live_effort_cannot_invent_special_modes_and_can_revoke() {
664        let static_caps = anthropic_mode_capabilities("anthropic/claude-fable-5").unwrap();
665        assert_eq!(static_caps.narrow_with_live_effort(Some(true)), static_caps);
666        let revoked = static_caps.narrow_with_live_effort(Some(false));
667        assert!(!revoked.max_supported);
668        assert!(!revoked.ultracode_supported());
669
670        assert_eq!(
671            AnthropicModeCapabilities::none().narrow_with_live_effort(Some(true)),
672            AnthropicModeCapabilities::none()
673        );
674    }
675
676    #[test]
677    fn typed_planner_maps_exact_modes_and_fails_closed() {
678        let prerequisites = AnthropicPlanPrerequisites::installed();
679        let plan = plan_anthropic_execution(
680            "anthropic/claude-fable-5",
681            ReasoningLevel::UltraCode,
682            ExecutionRole::Foreground,
683            prerequisites,
684            None,
685        )
686        .unwrap();
687        assert_eq!(plan.mode, AnthropicExecutionMode::UltraCode);
688        assert_eq!(plan.wire_effort, Some(AnthropicWireEffort::XHigh));
689        assert_eq!(plan.workflow, AnthropicWorkflowPlan::Standing);
690
691        for (level, wire, mode) in [
692            (
693                ReasoningLevel::Max,
694                AnthropicWireEffort::Max,
695                AnthropicExecutionMode::Max,
696            ),
697            (
698                ReasoningLevel::XHigh,
699                AnthropicWireEffort::XHigh,
700                AnthropicExecutionMode::Standard,
701            ),
702        ] {
703            let plan = plan_anthropic_execution(
704                "anthropic/claude-fable-5",
705                level,
706                ExecutionRole::Foreground,
707                prerequisites,
708                None,
709            )
710            .unwrap();
711            assert_eq!(plan.mode, mode);
712            assert_eq!(plan.wire_effort, Some(wire));
713            assert_eq!(plan.workflow, AnthropicWorkflowPlan::None);
714        }
715
716        for (model, level, role, prerequisites, code) in [
717            (
718                "openai-codex/claude-fable-5",
719                ReasoningLevel::UltraCode,
720                ExecutionRole::Foreground,
721                prerequisites,
722                AnthropicPlanErrorCode::InvalidProviderIdentity,
723            ),
724            (
725                "anthropic/claude-fable-5-preview",
726                ReasoningLevel::Max,
727                ExecutionRole::Foreground,
728                prerequisites,
729                AnthropicPlanErrorCode::CapabilityMetadataMissing,
730            ),
731            (
732                "anthropic/claude-fable-5",
733                ReasoningLevel::Ultra,
734                ExecutionRole::Foreground,
735                prerequisites,
736                AnthropicPlanErrorCode::UnsupportedReasoningLevel,
737            ),
738            (
739                "anthropic/claude-fable-5",
740                ReasoningLevel::UltraCode,
741                ExecutionRole::Worker,
742                prerequisites,
743                AnthropicPlanErrorCode::UltraCodeRequiresForeground,
744            ),
745            (
746                "anthropic/claude-fable-5",
747                ReasoningLevel::UltraCode,
748                ExecutionRole::Internal,
749                prerequisites,
750                AnthropicPlanErrorCode::UltraCodeRequiresForeground,
751            ),
752            (
753                "anthropic/claude-fable-5",
754                ReasoningLevel::UltraCode,
755                ExecutionRole::Foreground,
756                AnthropicPlanPrerequisites {
757                    orchestration_policy: false,
758                    ..AnthropicPlanPrerequisites::installed()
759                },
760                AnthropicPlanErrorCode::UltraCodeRequiresOrchestration,
761            ),
762            (
763                "anthropic/claude-fable-5",
764                ReasoningLevel::UltraCode,
765                ExecutionRole::Foreground,
766                AnthropicPlanPrerequisites {
767                    lifecycle_start: false,
768                    ..AnthropicPlanPrerequisites::installed()
769                },
770                AnthropicPlanErrorCode::UltraCodeRequiresLifecycleTools,
771            ),
772        ] {
773            assert_eq!(
774                plan_anthropic_execution(model, level, role, prerequisites, None)
775                    .unwrap_err()
776                    .code(),
777                code
778            );
779        }
780    }
781
782    #[test]
783    fn live_false_revokes_but_live_true_does_not_invent() {
784        let installed = AnthropicPlanPrerequisites::installed();
785        assert_eq!(
786            plan_anthropic_execution(
787                "anthropic/unknown",
788                ReasoningLevel::Max,
789                ExecutionRole::Foreground,
790                installed,
791                Some(true)
792            )
793            .unwrap_err()
794            .code(),
795            AnthropicPlanErrorCode::CapabilityMetadataMissing
796        );
797        assert_eq!(
798            plan_anthropic_execution(
799                "anthropic/claude-fable-5",
800                ReasoningLevel::Max,
801                ExecutionRole::Foreground,
802                installed,
803                Some(false)
804            )
805            .unwrap_err()
806            .code(),
807            AnthropicPlanErrorCode::UnsupportedReasoningLevel
808        );
809    }
810
811    // ── Exact static fallback descriptors (spec: anthropic-xai-reasoning-modes) ──
812
813    #[test]
814    fn static_capability_covers_exact_known_models_only() {
815        for id in ["claude-opus-5-5", "claude-opus-4-7", "claude-fable-5"] {
816            assert_eq!(
817                anthropic_static_capability(id),
818                Some(ReasoningSupport::AnthropicAdaptive { adaptive: true }),
819                "{id}"
820            );
821        }
822        for id in [
823            "claude-sonnet-4-6",
824            "claude-opus-4-6",
825            "claude-haiku-4-5-20251001",
826        ] {
827            assert_eq!(
828                anthropic_static_capability(id),
829                Some(ReasoningSupport::AnthropicAdaptive { adaptive: false }),
830                "{id}"
831            );
832        }
833        // No substring inference: near-miss ids fail closed.
834        for id in [
835            "claude-opus-4-7-preview",
836            "claude-haiku-4-5",
837            "opus-4-7",
838            "claude-opus-5.5",
839            "claude-opus-5-5-preview",
840            "opus-5-5",
841            "",
842        ] {
843            assert_eq!(anthropic_static_capability(id), None, "{id}");
844        }
845    }
846
847    /// Drift guard: the exact static capability table and the legacy
848    /// substring wire-shape classifier (`model_supports_adaptive_thinking`,
849    /// explicitly deferred — see spec) must agree on the adaptive/fixed wire
850    /// shape for EVERY source-controlled descriptor. If either side changes,
851    /// this test forces reconciling the other.
852    #[test]
853    fn static_table_and_wire_shape_classifier_agree_for_known_models() {
854        use agent_core::models::{model_supports_adaptive_thinking, KNOWN_MODELS};
855        for (id, _label) in KNOWN_MODELS {
856            let expected = ReasoningSupport::AnthropicAdaptive {
857                adaptive: model_supports_adaptive_thinking(id),
858            };
859            assert_eq!(
860                anthropic_static_capability(id),
861                Some(expected),
862                "static table and wire-shape classifier disagree for {id}"
863            );
864        }
865        // Near-miss ids: the substring classifier may infer a wire shape, but
866        // the exact static table must stay fail-closed (None) — do NOT broaden
867        // it to substring inference.
868        for (id, classifier_infers_adaptive) in [
869            ("claude-opus-4-7-preview", true),
870            ("claude-fable-5-latest", true),
871            ("claude-sonnet-4-6-legacy", false),
872        ] {
873            assert_eq!(
874                anthropic_static_capability(id),
875                None,
876                "near-miss {id} must fail closed in the static table"
877            );
878            // Documented divergence: classifier substring-infers on near-miss.
879            assert_eq!(
880                agent_core::models::model_supports_adaptive_thinking(id),
881                classifier_infers_adaptive,
882                "classifier expectation drifted for {id}"
883            );
884        }
885    }
886
887    #[test]
888    fn live_parse_maps_explicit_unsupported_thinking_to_none() {
889        let body = r#"{
890            "data": [
891                {"id": "claude-test-thinker",
892                 "capabilities": {"thinking": {"supported": true},
893                                   "effort": {"supported": true}}},
894                {"id": "claude-test-fixed",
895                 "capabilities": {"thinking": {"supported": true},
896                                   "effort": {"supported": false}}},
897                {"id": "claude-test-nothink",
898                 "capabilities": {"thinking": {"supported": false}}},
899                {"id": "claude-test-nocaps"}
900            ],
901            "has_more": false
902        }"#;
903        let models = parse_anthropic_catalog_models(body).expect("parse");
904        let by_id = |id: &str| models.iter().find(|m| m.id == id).unwrap();
905        assert_eq!(
906            by_id("claude-test-thinker").reasoning,
907            ReasoningSupport::AnthropicAdaptive { adaptive: true }
908        );
909        assert_eq!(
910            by_id("claude-test-fixed").reasoning,
911            ReasoningSupport::AnthropicAdaptive { adaptive: false }
912        );
913        // Explicit evidence of no thinking support fails closed as None.
914        assert_eq!(
915            by_id("claude-test-nothink").reasoning,
916            ReasoningSupport::None
917        );
918        // Absent capabilities stay Unknown (conservative, backward compatible).
919        assert_eq!(
920            by_id("claude-test-nocaps").reasoning,
921            ReasoningSupport::Unknown
922        );
923    }
924
925    #[test]
926    fn live_parse_populates_capability_cache_with_qualified_ids() {
927        let body = r#"{
928            "data": [
929                {"id": "claude-test-cache-entry",
930                 "capabilities": {"thinking": {"supported": true},
931                                   "effort": {"supported": true}}}
932            ],
933            "has_more": false
934        }"#;
935        parse_anthropic_catalog_models(body).expect("parse");
936        let cached = super::super::capability_cache::get("anthropic/claude-test-cache-entry")
937            .expect("live anthropic parse must populate the capability cache");
938        assert_eq!(
939            cached.reasoning,
940            ReasoningSupport::AnthropicAdaptive { adaptive: true }
941        );
942    }
943}