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