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#[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 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
154const 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 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
235pub 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 Some(_) => ReasoningSupport::None,
537 None => ReasoningSupport::Unknown,
539 },
540 _ => ReasoningSupport::Unknown,
541 };
542 m.source = CatalogSource::Live;
543 Some(m)
544 })
545 .collect();
546
547 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
562pub fn anthropic_static_capability(model_id: &str) -> Option<ReasoningSupport> {
568 match model_id {
569 "claude-opus-4-7" | "claude-fable-5" => {
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 #[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 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 #[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 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 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 assert_eq!(
907 by_id("claude-test-nothink").reasoning,
908 ReasoningSupport::None
909 );
910 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}