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