1use std::cell::RefCell;
22use std::collections::{BTreeMap, HashSet};
23use std::sync::OnceLock;
24
25use serde::{Deserialize, Serialize};
26
27use super::providers::anthropic::claude_generation;
28use super::providers::openai_compat::gpt_generation;
29
30const BUILTIN_TOML: &str = include_str!("capabilities.toml");
32const BUILTIN_PROVIDERS_TOML: &str = include_str!("providers.toml");
33
34#[derive(Debug, Clone, Deserialize, Default)]
37pub struct CapabilitiesFile {
38 #[serde(default)]
40 pub provider: BTreeMap<String, Vec<ProviderRule>>,
41 #[serde(default)]
46 pub provider_defaults: BTreeMap<String, ProviderDefaults>,
47 #[serde(default)]
50 pub provider_family: BTreeMap<String, String>,
51}
52
53#[derive(Debug, Clone, Deserialize, Default)]
55pub struct ProviderDefaults {
56 #[serde(default)]
59 pub message_wire_format: Option<String>,
60 #[serde(default)]
63 pub native_tool_wire_format: Option<String>,
64 #[serde(default)]
66 pub image_url_input_supported: Option<bool>,
67 #[serde(default)]
70 pub file_upload_wire_format: Option<String>,
71 #[serde(default)]
74 pub reasoning_wire_format: Option<String>,
75 #[serde(default)]
76 pub files_api_supported: Option<bool>,
77 #[serde(default)]
78 pub seed_supported: Option<bool>,
79 #[serde(default)]
80 pub top_k_supported: Option<bool>,
81 #[serde(default)]
82 pub frequency_penalty_supported: Option<bool>,
83 #[serde(default)]
84 pub presence_penalty_supported: Option<bool>,
85}
86
87impl ProviderDefaults {
88 fn overlay(&mut self, other: &ProviderDefaults) {
89 if other.message_wire_format.is_some() {
90 self.message_wire_format = other.message_wire_format.clone();
91 }
92 if other.native_tool_wire_format.is_some() {
93 self.native_tool_wire_format = other.native_tool_wire_format.clone();
94 }
95 if other.image_url_input_supported.is_some() {
96 self.image_url_input_supported = other.image_url_input_supported;
97 }
98 if other.file_upload_wire_format.is_some() {
99 self.file_upload_wire_format = other.file_upload_wire_format.clone();
100 }
101 if other.reasoning_wire_format.is_some() {
102 self.reasoning_wire_format = other.reasoning_wire_format.clone();
103 }
104 if other.files_api_supported.is_some() {
105 self.files_api_supported = other.files_api_supported;
106 }
107 if other.seed_supported.is_some() {
108 self.seed_supported = other.seed_supported;
109 }
110 if other.top_k_supported.is_some() {
111 self.top_k_supported = other.top_k_supported;
112 }
113 if other.frequency_penalty_supported.is_some() {
114 self.frequency_penalty_supported = other.frequency_penalty_supported;
115 }
116 if other.presence_penalty_supported.is_some() {
117 self.presence_penalty_supported = other.presence_penalty_supported;
118 }
119 }
120
121 fn fill_missing_from(&mut self, other: &ProviderDefaults) {
122 if self.message_wire_format.is_none() {
123 self.message_wire_format = other.message_wire_format.clone();
124 }
125 if self.native_tool_wire_format.is_none() {
126 self.native_tool_wire_format = other.native_tool_wire_format.clone();
127 }
128 if self.image_url_input_supported.is_none() {
129 self.image_url_input_supported = other.image_url_input_supported;
130 }
131 if self.file_upload_wire_format.is_none() {
132 self.file_upload_wire_format = other.file_upload_wire_format.clone();
133 }
134 if self.reasoning_wire_format.is_none() {
135 self.reasoning_wire_format = other.reasoning_wire_format.clone();
136 }
137 if self.files_api_supported.is_none() {
138 self.files_api_supported = other.files_api_supported;
139 }
140 if self.seed_supported.is_none() {
141 self.seed_supported = other.seed_supported;
142 }
143 if self.top_k_supported.is_none() {
144 self.top_k_supported = other.top_k_supported;
145 }
146 if self.frequency_penalty_supported.is_none() {
147 self.frequency_penalty_supported = other.frequency_penalty_supported;
148 }
149 if self.presence_penalty_supported.is_none() {
150 self.presence_penalty_supported = other.presence_penalty_supported;
151 }
152 }
153
154 fn has_any_field(&self) -> bool {
155 self.message_wire_format.is_some()
156 || self.native_tool_wire_format.is_some()
157 || self.image_url_input_supported.is_some()
158 || self.file_upload_wire_format.is_some()
159 || self.reasoning_wire_format.is_some()
160 || self.files_api_supported.is_some()
161 || self.seed_supported.is_some()
162 || self.top_k_supported.is_some()
163 || self.frequency_penalty_supported.is_some()
164 || self.presence_penalty_supported.is_some()
165 }
166}
167
168#[derive(Debug, Clone, Deserialize)]
170pub struct ProviderRule {
171 pub model_match: String,
174 #[serde(default)]
179 pub version_min: Option<Vec<u32>>,
180 #[serde(default)]
181 pub native_tools: Option<bool>,
182 #[serde(default)]
185 pub message_wire_format: Option<String>,
186 #[serde(default)]
189 pub native_tool_wire_format: Option<String>,
190 #[serde(default)]
191 pub defer_loading: Option<bool>,
192 #[serde(default)]
193 pub tool_search: Option<Vec<String>>,
194 #[serde(default)]
197 pub responses_api: Option<bool>,
198 #[serde(default)]
200 pub hosted_tools: Option<Vec<String>>,
201 #[serde(default)]
204 pub remote_mcp: Option<bool>,
205 #[serde(default)]
208 pub conversation_state: Option<bool>,
209 #[serde(default)]
211 pub compaction: Option<bool>,
212 #[serde(default)]
214 pub background_mode: Option<bool>,
215 #[serde(default)]
217 pub tool_approval_policy: Option<String>,
218 #[serde(default)]
219 pub max_tools: Option<u32>,
220 #[serde(default)]
221 pub prompt_caching: Option<bool>,
222 #[serde(default)]
225 pub vision: Option<bool>,
226 #[serde(default, alias = "audio_supported")]
229 pub audio: Option<bool>,
230 #[serde(default, alias = "pdf_supported")]
233 pub pdf: Option<bool>,
234 #[serde(default, alias = "video_supported")]
237 pub video: Option<bool>,
238 #[serde(default)]
240 pub files_api_supported: Option<bool>,
241 #[serde(default)]
244 pub file_upload_wire_format: Option<String>,
245 #[serde(default)]
248 pub structured_output: Option<String>,
249 #[serde(default)]
252 pub json_schema: Option<String>,
253 #[serde(default)]
256 pub prefers_xml_scaffolding: Option<bool>,
257 #[serde(default)]
262 pub reserved_tool_call_token: Option<bool>,
263 #[serde(default)]
266 pub prefers_markdown_scaffolding: Option<bool>,
267 #[serde(default)]
271 pub structured_output_mode: Option<String>,
272 #[serde(default)]
274 pub supports_assistant_prefill: Option<bool>,
275 #[serde(default)]
278 pub prefers_role_developer: Option<bool>,
279 #[serde(default)]
282 pub prefers_xml_tools: Option<bool>,
283 #[serde(default)]
287 pub thinking_block_style: Option<String>,
288 #[serde(default)]
291 pub thinking_modes: Option<Vec<String>>,
292 #[serde(default)]
295 pub interleaved_thinking_supported: Option<bool>,
296 #[serde(default)]
298 pub anthropic_beta_features: Option<Vec<String>>,
299 #[serde(default)]
302 pub thinking: Option<bool>,
303 #[serde(default)]
305 pub vision_supported: Option<bool>,
306 #[serde(default)]
308 pub image_url_input_supported: Option<bool>,
309 #[serde(default)]
316 pub preserve_thinking: Option<bool>,
317 #[serde(default)]
321 pub server_parser: Option<String>,
322 #[serde(default)]
325 pub honors_chat_template_kwargs: Option<bool>,
326 #[serde(default)]
329 pub requires_completion_tokens: Option<bool>,
330 #[serde(default)]
332 pub reasoning_effort_supported: Option<bool>,
333 #[serde(default)]
336 pub reasoning_effort_levels: Option<Vec<String>>,
337 #[serde(default)]
341 pub reasoning_none_supported: Option<bool>,
342 #[serde(default)]
345 pub reasoning_wire_format: Option<String>,
346 #[serde(default)]
347 pub seed_supported: Option<bool>,
348 #[serde(default)]
349 pub top_k_supported: Option<bool>,
350 #[serde(default)]
351 pub frequency_penalty_supported: Option<bool>,
352 #[serde(default)]
353 pub presence_penalty_supported: Option<bool>,
354 #[serde(default)]
358 pub recommended_endpoint: Option<String>,
359 #[serde(default)]
362 pub text_tool_wire_format_supported: Option<bool>,
363 #[serde(default)]
368 pub preferred_tool_format: Option<String>,
369 #[serde(default)]
374 pub tool_mode_parity: Option<String>,
375 #[serde(default)]
377 pub tool_mode_parity_notes: Option<String>,
378 #[serde(default)]
385 pub thinking_disable_directive: Option<String>,
386 #[serde(default)]
399 pub auto_reasoning_overrides: Option<BTreeMap<String, String>>,
400}
401
402#[derive(Debug, Clone, PartialEq, Eq)]
406pub struct Capabilities {
407 pub native_tools: bool,
408 pub message_wire_format: String,
409 pub native_tool_wire_format: String,
410 pub defer_loading: bool,
411 pub tool_search: Vec<String>,
412 pub responses_api: bool,
413 pub hosted_tools: Vec<String>,
414 pub remote_mcp: bool,
415 pub conversation_state: bool,
416 pub compaction: bool,
417 pub background_mode: bool,
418 pub tool_approval_policy: Option<String>,
419 pub max_tools: Option<u32>,
420 pub prompt_caching: bool,
421 pub vision: bool,
422 pub audio: bool,
423 pub pdf: bool,
424 pub video: bool,
425 pub files_api_supported: bool,
426 pub file_upload_wire_format: Option<String>,
427 pub structured_output: Option<String>,
428 pub json_schema: Option<String>,
430 pub prefers_xml_scaffolding: bool,
431 pub reserved_tool_call_token: bool,
433 pub prefers_markdown_scaffolding: bool,
434 pub structured_output_mode: String,
435 pub supports_assistant_prefill: bool,
436 pub prefers_role_developer: bool,
437 pub prefers_xml_tools: bool,
438 pub thinking_block_style: String,
439 pub thinking_modes: Vec<String>,
440 pub interleaved_thinking_supported: bool,
441 pub anthropic_beta_features: Vec<String>,
442 pub vision_supported: bool,
443 pub image_url_input_supported: bool,
444 pub preserve_thinking: bool,
445 pub server_parser: String,
446 pub honors_chat_template_kwargs: bool,
447 pub requires_completion_tokens: bool,
448 pub reasoning_effort_supported: bool,
449 pub reasoning_effort_levels: Vec<String>,
450 pub reasoning_none_supported: bool,
451 pub reasoning_wire_format: Option<String>,
452 pub seed_supported: bool,
453 pub top_k_supported: bool,
454 pub frequency_penalty_supported: bool,
455 pub presence_penalty_supported: bool,
456 pub recommended_endpoint: Option<String>,
457 pub text_tool_wire_format_supported: bool,
458 pub preferred_tool_format: Option<String>,
459 pub tool_mode_parity: Option<String>,
460 pub tool_mode_parity_notes: Option<String>,
461 pub thinking_disable_directive: Option<String>,
462 pub auto_reasoning_overrides: BTreeMap<String, String>,
465}
466
467impl Default for Capabilities {
468 fn default() -> Self {
469 Self {
470 native_tools: false,
471 message_wire_format: "openai".to_string(),
472 native_tool_wire_format: "openai".to_string(),
473 defer_loading: false,
474 tool_search: Vec::new(),
475 responses_api: false,
476 hosted_tools: Vec::new(),
477 remote_mcp: false,
478 conversation_state: false,
479 compaction: false,
480 background_mode: false,
481 tool_approval_policy: None,
482 max_tools: None,
483 prompt_caching: false,
484 vision: false,
485 audio: false,
486 pdf: false,
487 video: false,
488 files_api_supported: false,
489 file_upload_wire_format: None,
490 structured_output: None,
491 json_schema: None,
492 prefers_xml_scaffolding: false,
493 reserved_tool_call_token: false,
494 prefers_markdown_scaffolding: false,
495 structured_output_mode: "none".to_string(),
496 supports_assistant_prefill: false,
497 prefers_role_developer: false,
498 prefers_xml_tools: false,
499 thinking_block_style: "none".to_string(),
500 thinking_modes: Vec::new(),
501 interleaved_thinking_supported: false,
502 anthropic_beta_features: Vec::new(),
503 vision_supported: false,
504 image_url_input_supported: true,
505 preserve_thinking: false,
506 server_parser: "none".to_string(),
507 honors_chat_template_kwargs: false,
508 requires_completion_tokens: false,
509 reasoning_effort_supported: false,
510 reasoning_effort_levels: Vec::new(),
511 reasoning_none_supported: false,
512 reasoning_wire_format: None,
513 seed_supported: true,
514 top_k_supported: true,
515 frequency_penalty_supported: true,
516 presence_penalty_supported: true,
517 recommended_endpoint: None,
518 text_tool_wire_format_supported: true,
519 preferred_tool_format: None,
520 tool_mode_parity: None,
521 tool_mode_parity_notes: None,
522 thinking_disable_directive: None,
523 auto_reasoning_overrides: BTreeMap::new(),
524 }
525 }
526}
527
528#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
534pub struct ProviderCapabilityMatrixRow {
535 pub provider: String,
536 pub model: String,
537 pub version_min: Option<Vec<u32>>,
538 pub thinking: Vec<String>,
539 pub vision: bool,
540 pub audio: bool,
541 pub pdf: bool,
542 pub video: bool,
543 pub streaming: bool,
544 pub files_api_supported: bool,
545 pub json_schema: Option<String>,
546 pub prefers_xml_scaffolding: bool,
547 pub reserved_tool_call_token: bool,
548 pub prefers_markdown_scaffolding: bool,
549 pub structured_output_mode: String,
550 pub supports_assistant_prefill: bool,
551 pub prefers_role_developer: bool,
552 pub prefers_xml_tools: bool,
553 pub thinking_block_style: String,
554 pub native_tools: bool,
555 pub text_tools: bool,
556 pub preferred_tool_format: String,
557 pub tool_mode_parity: String,
558 pub tools: bool,
559 pub cache: bool,
560 pub source: String,
561}
562
563#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
564pub struct ToolCapabilityAuditReport {
565 pub audited_models: usize,
566 pub gaps: Vec<ToolCapabilityAuditGap>,
567}
568
569impl ToolCapabilityAuditReport {
570 pub fn ok(&self) -> bool {
571 self.gaps.is_empty()
572 }
573
574 pub fn render_human(&self) -> String {
575 if self.gaps.is_empty() {
576 return format!(
577 "provider capability audit OK: {} priced chat models have explicit native_tools and preferred_tool_format rules",
578 self.audited_models
579 );
580 }
581
582 let mut out = format!(
583 "provider capability audit found {} catalog gaps among {} priced chat models:",
584 self.gaps.len(),
585 self.audited_models
586 );
587 for gap in &self.gaps {
588 let matched = match (&gap.rule_provider, &gap.rule_model_match) {
589 (Some(provider), Some(model_match)) => {
590 format!("provider.{provider} model_match=\"{model_match}\"")
591 }
592 _ => "no matching rule".to_string(),
593 };
594 out.push_str(&format!(
595 "\n- {}:{} ({matched}) missing {}; suggest native_tools = {}, preferred_tool_format = \"{}\"",
596 gap.provider,
597 gap.model,
598 gap.missing_fields.join(", "),
599 gap.suggested_native_tools,
600 gap.suggested_preferred_tool_format,
601 ));
602 }
603 out
604 }
605}
606
607#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
608pub struct ToolCapabilityAuditGap {
609 pub provider: String,
610 pub model: String,
611 pub rule_provider: Option<String>,
612 pub rule_model_match: Option<String>,
613 pub missing_fields: Vec<String>,
614 pub suggested_native_tools: bool,
615 pub suggested_preferred_tool_format: String,
616}
617
618thread_local! {
619 static USER_OVERRIDES: RefCell<Option<CapabilitiesFile>> = const { RefCell::new(None) };
624}
625
626static BUILTIN: OnceLock<CapabilitiesFile> = OnceLock::new();
630
631fn builtin() -> &'static CapabilitiesFile {
632 BUILTIN.get_or_init(|| {
633 toml::from_str::<CapabilitiesFile>(BUILTIN_TOML)
634 .expect("capabilities.toml must parse at build time")
635 })
636}
637
638pub fn set_user_overrides(file: Option<CapabilitiesFile>) {
642 USER_OVERRIDES.with(|cell| *cell.borrow_mut() = file);
643}
644
645pub fn clear_user_overrides() {
647 set_user_overrides(None);
648}
649
650pub fn set_user_overrides_toml(src: &str) -> Result<(), String> {
655 let parsed: CapabilitiesFile = toml::from_str(src).map_err(|e| e.to_string())?;
656 set_user_overrides(Some(parsed));
657 Ok(())
658}
659
660pub fn set_user_overrides_from_manifest_toml(src: &str) -> Result<(), String> {
672 #[derive(Deserialize)]
673 struct Manifest {
674 #[serde(default)]
675 capabilities: Option<CapabilitiesFile>,
676 }
677 let parsed: Manifest = toml::from_str(src).map_err(|e| e.to_string())?;
678 set_user_overrides(parsed.capabilities);
679 Ok(())
680}
681
682pub fn lookup(provider: &str, model: &str) -> Capabilities {
688 let user = USER_OVERRIDES.with(|cell| cell.borrow().clone());
689 lookup_with_user_overrides(provider, model, user.as_ref())
690}
691
692pub fn lookup_with_user_overrides(
693 provider: &str,
694 model: &str,
695 user_overrides: Option<&CapabilitiesFile>,
696) -> Capabilities {
697 let mut caps = lookup_with(provider, model, builtin(), user_overrides);
698 if provider != "openai" && provider != "mock" {
699 caps.responses_api = false;
700 caps.hosted_tools.clear();
701 caps.remote_mcp = false;
702 caps.conversation_state = false;
703 caps.compaction = false;
704 caps.background_mode = false;
705 caps.tool_approval_policy = None;
706 }
707 caps
708}
709
710pub fn matrix_rows() -> Vec<ProviderCapabilityMatrixRow> {
714 let user = USER_OVERRIDES.with(|cell| cell.borrow().clone());
715 let mut rows = Vec::new();
716 if let Some(user) = user.as_ref() {
717 push_matrix_rows(&mut rows, user, "project");
718 }
719 push_matrix_rows(&mut rows, builtin(), "builtin");
720 rows
721}
722
723pub fn audit_catalogued_chat_model_tool_capabilities() -> ToolCapabilityAuditReport {
727 let user = USER_OVERRIDES.with(|cell| cell.borrow().clone());
728 audit_tool_capability_coverage(
729 crate::llm_config::model_catalog_entries(),
730 builtin(),
731 user.as_ref(),
732 )
733}
734
735pub fn audit_builtin_catalogued_chat_model_tool_capabilities() -> ToolCapabilityAuditReport {
738 let catalog = crate::llm_config::parse_config_toml(BUILTIN_PROVIDERS_TOML)
739 .expect("providers.toml must parse at build time");
740 audit_tool_capability_coverage(catalog.models, builtin(), None)
741}
742
743fn audit_tool_capability_coverage<I>(
744 models: I,
745 builtin: &CapabilitiesFile,
746 user: Option<&CapabilitiesFile>,
747) -> ToolCapabilityAuditReport
748where
749 I: IntoIterator<Item = (String, crate::llm_config::ModelDef)>,
750{
751 let mut gaps = Vec::new();
752 let mut audited_models = 0;
753
754 for (model_id, model) in models {
755 if model.pricing.is_none() {
756 continue;
757 }
758 audited_models += 1;
759 let matched = first_matching_rule(user, builtin, &model.provider, &model_id);
760 let mut missing_fields = Vec::new();
761 match matched.as_ref().map(|matched| matched.rule) {
762 Some(rule) => {
763 if rule.native_tools.is_none() {
764 missing_fields.push("native_tools".to_string());
765 }
766 if rule.preferred_tool_format.is_none() {
767 missing_fields.push("preferred_tool_format".to_string());
768 }
769 }
770 None => {
771 missing_fields.push("native_tools".to_string());
772 missing_fields.push("preferred_tool_format".to_string());
773 }
774 }
775 if missing_fields.is_empty() {
776 continue;
777 }
778
779 let (suggested_native_tools, suggested_preferred_tool_format) =
780 suggested_tool_capability_defaults(
781 &model.provider,
782 &model_id,
783 &model,
784 matched.as_ref(),
785 );
786 gaps.push(ToolCapabilityAuditGap {
787 provider: model.provider,
788 model: model_id,
789 rule_provider: matched.as_ref().map(|matched| matched.provider.clone()),
790 rule_model_match: matched.map(|matched| matched.rule.model_match.clone()),
791 missing_fields,
792 suggested_native_tools,
793 suggested_preferred_tool_format,
794 });
795 }
796
797 gaps.sort_by(|left, right| {
798 left.provider
799 .cmp(&right.provider)
800 .then_with(|| left.model.cmp(&right.model))
801 });
802 ToolCapabilityAuditReport {
803 audited_models,
804 gaps,
805 }
806}
807
808struct MatchedCapabilityRule<'a> {
809 provider: String,
810 rule: &'a ProviderRule,
811}
812
813fn first_matching_rule<'a>(
814 user: Option<&'a CapabilitiesFile>,
815 builtin: &'a CapabilitiesFile,
816 provider: &str,
817 model: &str,
818) -> Option<MatchedCapabilityRule<'a>> {
819 let mut current = provider.to_string();
820 let mut visited = HashSet::new();
821 while visited.insert(current.clone()) {
822 if let Some(rule) = user
823 .and_then(|file| first_matching_rule_in_file(file, ¤t, model))
824 .or_else(|| first_matching_rule_in_file(builtin, ¤t, model))
825 {
826 return Some(MatchedCapabilityRule {
827 provider: current,
828 rule,
829 });
830 }
831 let next = user
832 .and_then(|file| file.provider_family.get(¤t))
833 .or_else(|| builtin.provider_family.get(¤t))
834 .cloned();
835 current = next?;
836 }
837 None
838}
839
840fn first_matching_rule_in_file<'a>(
841 file: &'a CapabilitiesFile,
842 provider: &str,
843 model: &str,
844) -> Option<&'a ProviderRule> {
845 file.provider
846 .get(provider)?
847 .iter()
848 .find(|rule| rule_matches(rule, model))
849}
850
851fn suggested_tool_capability_defaults(
852 provider: &str,
853 model_id: &str,
854 model: &crate::llm_config::ModelDef,
855 matched: Option<&MatchedCapabilityRule<'_>>,
856) -> (bool, String) {
857 if let Some(rule) = matched.map(|matched| matched.rule) {
858 let native_tools =
859 rule.native_tools
860 .unwrap_or_else(|| match rule.preferred_tool_format.as_deref() {
861 Some("native") => true,
862 Some("text") => false,
863 _ => suggested_native_tools(provider, model_id, model),
864 });
865 let preferred_tool_format = rule
866 .preferred_tool_format
867 .clone()
868 .unwrap_or_else(|| tool_format_for_native(native_tools));
869 return (native_tools, preferred_tool_format);
870 }
871
872 let native_tools = suggested_native_tools(provider, model_id, model);
873 (native_tools, tool_format_for_native(native_tools))
874}
875
876fn suggested_native_tools(
877 provider: &str,
878 model_id: &str,
879 model: &crate::llm_config::ModelDef,
880) -> bool {
881 if provider == "anthropic" || model_id.contains("claude") {
882 return true;
883 }
884 if matches!(
885 provider,
886 "openai" | "gemini" | "cerebras" | "bedrock" | "azure_openai" | "vertex"
887 ) {
888 return true;
889 }
890 model
891 .capabilities
892 .iter()
893 .any(|capability| capability == "tools")
894}
895
896fn tool_format_for_native(native_tools: bool) -> String {
897 if native_tools {
898 "native".to_string()
899 } else {
900 "text".to_string()
901 }
902}
903
904fn push_matrix_rows(
905 rows: &mut Vec<ProviderCapabilityMatrixRow>,
906 file: &CapabilitiesFile,
907 source: &str,
908) {
909 for (provider, rules) in &file.provider {
910 for rule in rules {
911 rows.push(rule_to_matrix_row(provider, rule, source));
912 }
913 }
914}
915
916fn rule_to_matrix_row(
917 provider: &str,
918 rule: &ProviderRule,
919 source: &str,
920) -> ProviderCapabilityMatrixRow {
921 ProviderCapabilityMatrixRow {
922 provider: provider.to_string(),
923 model: rule.model_match.clone(),
924 version_min: rule.version_min.clone(),
925 thinking: rule_thinking_modes(rule),
926 vision: rule_vision(rule),
927 audio: rule.audio.unwrap_or(false),
928 pdf: rule.pdf.unwrap_or(false),
929 video: rule.video.unwrap_or(false),
930 streaming: true,
931 files_api_supported: rule.files_api_supported.unwrap_or(false),
932 json_schema: rule_structured_output(rule),
933 prefers_xml_scaffolding: rule.prefers_xml_scaffolding.unwrap_or(false),
934 reserved_tool_call_token: rule.reserved_tool_call_token.unwrap_or(false),
935 prefers_markdown_scaffolding: rule.prefers_markdown_scaffolding.unwrap_or(false),
936 structured_output_mode: rule_structured_output_mode(rule),
937 supports_assistant_prefill: rule.supports_assistant_prefill.unwrap_or(false),
938 prefers_role_developer: rule
939 .prefers_role_developer
940 .unwrap_or_else(|| rule.requires_completion_tokens.unwrap_or(false)),
941 prefers_xml_tools: rule.prefers_xml_tools.unwrap_or(false),
942 thinking_block_style: rule_thinking_block_style(rule),
943 native_tools: rule.native_tools.unwrap_or(false),
944 text_tools: rule.text_tool_wire_format_supported.unwrap_or(true),
945 preferred_tool_format: rule_preferred_tool_format(rule),
946 tool_mode_parity: rule_tool_mode_parity(rule),
947 tools: rule.native_tools.unwrap_or(false)
948 || rule.text_tool_wire_format_supported.unwrap_or(true),
949 cache: rule.prompt_caching.unwrap_or(false),
950 source: source.to_string(),
951 }
952}
953
954fn rule_thinking_modes(rule: &ProviderRule) -> Vec<String> {
955 rule.thinking_modes.clone().unwrap_or_else(|| {
956 if rule.thinking.unwrap_or(false) {
957 vec!["enabled".to_string()]
958 } else {
959 Vec::new()
960 }
961 })
962}
963
964fn rule_vision(rule: &ProviderRule) -> bool {
965 rule.vision.or(rule.vision_supported).unwrap_or(false)
966}
967
968fn lookup_with(
969 provider: &str,
970 model: &str,
971 builtin: &CapabilitiesFile,
972 user: Option<&CapabilitiesFile>,
973) -> Capabilities {
974 if provider == "mock" {
985 let anthropic_defaults = merged_provider_defaults(user, builtin, "anthropic");
986 if let Some(mut caps) =
987 try_match_layer(user, builtin, "anthropic", model, &anthropic_defaults)
988 {
989 caps.native_tool_wire_format = "openai".to_string();
990 return caps;
991 }
992 let openai_defaults = merged_provider_defaults(user, builtin, "openai");
993 if let Some(caps) = try_match_layer(user, builtin, "openai", model, &openai_defaults) {
994 return caps;
995 }
996 let gemini_defaults = merged_provider_defaults(user, builtin, "gemini");
997 if let Some(caps) = try_match_layer(user, builtin, "gemini", model, &gemini_defaults) {
998 return caps;
999 }
1000 return Capabilities::default();
1001 }
1002
1003 let mut current = provider.to_string();
1006 let mut effective_defaults = ProviderDefaults::default();
1007 let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
1008 while visited.insert(current.clone()) {
1009 let layer_defaults = merged_provider_defaults(user, builtin, ¤t);
1010 if effective_defaults.has_any_field() {
1011 effective_defaults.fill_missing_from(&layer_defaults);
1012 } else {
1013 effective_defaults.overlay(&layer_defaults);
1014 }
1015 if let Some(caps) = try_match_layer(user, builtin, ¤t, model, &effective_defaults) {
1016 return caps;
1017 }
1018 let next = user
1019 .and_then(|f| f.provider_family.get(¤t))
1020 .or_else(|| builtin.provider_family.get(¤t))
1021 .cloned();
1022 match next {
1023 Some(parent) => current = parent,
1024 None => break,
1025 }
1026 }
1027 if effective_defaults.has_any_field() {
1028 return defaults_to_caps(&effective_defaults);
1029 }
1030 Capabilities::default()
1031}
1032
1033fn try_match_layer(
1037 user: Option<&CapabilitiesFile>,
1038 builtin: &CapabilitiesFile,
1039 layer_provider: &str,
1040 model: &str,
1041 defaults: &ProviderDefaults,
1042) -> Option<Capabilities> {
1043 if let Some(user) = user {
1044 if let Some(rules) = user.provider.get(layer_provider) {
1045 for rule in rules {
1046 if rule_matches(rule, model) {
1047 return Some(rule_to_caps(rule, defaults));
1048 }
1049 }
1050 }
1051 }
1052 if let Some(rules) = builtin.provider.get(layer_provider) {
1053 for rule in rules {
1054 if rule_matches(rule, model) {
1055 return Some(rule_to_caps(rule, defaults));
1056 }
1057 }
1058 }
1059 None
1060}
1061
1062fn merged_provider_defaults(
1063 user: Option<&CapabilitiesFile>,
1064 builtin: &CapabilitiesFile,
1065 provider: &str,
1066) -> ProviderDefaults {
1067 let mut defaults = builtin
1068 .provider_defaults
1069 .get(provider)
1070 .cloned()
1071 .unwrap_or_default();
1072 if let Some(user_defaults) = user.and_then(|file| file.provider_defaults.get(provider)) {
1073 defaults.overlay(user_defaults);
1074 }
1075 defaults
1076}
1077
1078fn defaults_to_caps(defaults: &ProviderDefaults) -> Capabilities {
1079 let empty = ProviderRule {
1080 model_match: "*".to_string(),
1081 version_min: None,
1082 native_tools: None,
1083 message_wire_format: None,
1084 native_tool_wire_format: None,
1085 defer_loading: None,
1086 tool_search: None,
1087 responses_api: None,
1088 hosted_tools: None,
1089 remote_mcp: None,
1090 conversation_state: None,
1091 compaction: None,
1092 background_mode: None,
1093 tool_approval_policy: None,
1094 max_tools: None,
1095 prompt_caching: None,
1096 vision: None,
1097 audio: None,
1098 pdf: None,
1099 video: None,
1100 files_api_supported: None,
1101 file_upload_wire_format: None,
1102 structured_output: None,
1103 prefers_xml_scaffolding: None,
1104 reserved_tool_call_token: None,
1105 prefers_markdown_scaffolding: None,
1106 structured_output_mode: None,
1107 supports_assistant_prefill: None,
1108 prefers_role_developer: None,
1109 prefers_xml_tools: None,
1110 thinking_block_style: None,
1111 json_schema: None,
1112 thinking_modes: None,
1113 interleaved_thinking_supported: None,
1114 anthropic_beta_features: None,
1115 thinking: None,
1116 vision_supported: None,
1117 image_url_input_supported: None,
1118 preserve_thinking: None,
1119 server_parser: None,
1120 honors_chat_template_kwargs: None,
1121 requires_completion_tokens: None,
1122 reasoning_effort_supported: None,
1123 reasoning_effort_levels: None,
1124 reasoning_none_supported: None,
1125 reasoning_wire_format: None,
1126 seed_supported: None,
1127 top_k_supported: None,
1128 frequency_penalty_supported: None,
1129 presence_penalty_supported: None,
1130 recommended_endpoint: None,
1131 text_tool_wire_format_supported: None,
1132 preferred_tool_format: None,
1133 tool_mode_parity: None,
1134 tool_mode_parity_notes: None,
1135 thinking_disable_directive: None,
1136 auto_reasoning_overrides: None,
1137 };
1138 let mut caps = rule_to_caps(&empty, defaults);
1139 caps.preferred_tool_format = None;
1140 caps.tool_mode_parity = None;
1141 caps
1142}
1143
1144fn rule_to_caps(rule: &ProviderRule, defaults: &ProviderDefaults) -> Capabilities {
1145 let thinking_modes = rule_thinking_modes(rule);
1146 Capabilities {
1147 native_tools: rule.native_tools.unwrap_or(false),
1148 message_wire_format: rule
1149 .message_wire_format
1150 .clone()
1151 .or_else(|| defaults.message_wire_format.clone())
1152 .unwrap_or_else(|| "openai".to_string()),
1153 native_tool_wire_format: rule
1154 .native_tool_wire_format
1155 .clone()
1156 .or_else(|| defaults.native_tool_wire_format.clone())
1157 .unwrap_or_else(|| "openai".to_string()),
1158 defer_loading: rule.defer_loading.unwrap_or(false),
1159 tool_search: rule.tool_search.clone().unwrap_or_default(),
1160 responses_api: rule.responses_api.unwrap_or(false),
1161 hosted_tools: rule.hosted_tools.clone().unwrap_or_default(),
1162 remote_mcp: rule.remote_mcp.unwrap_or(false),
1163 conversation_state: rule.conversation_state.unwrap_or(false),
1164 compaction: rule.compaction.unwrap_or(false),
1165 background_mode: rule.background_mode.unwrap_or(false),
1166 tool_approval_policy: rule.tool_approval_policy.clone(),
1167 max_tools: rule.max_tools,
1168 prompt_caching: rule.prompt_caching.unwrap_or(false),
1169 vision: rule_vision(rule),
1170 audio: rule.audio.unwrap_or(false),
1171 pdf: rule.pdf.unwrap_or(false),
1172 video: rule.video.unwrap_or(false),
1173 files_api_supported: rule
1174 .files_api_supported
1175 .or(defaults.files_api_supported)
1176 .unwrap_or(false),
1177 file_upload_wire_format: rule
1178 .file_upload_wire_format
1179 .clone()
1180 .or_else(|| defaults.file_upload_wire_format.clone()),
1181 structured_output: rule_structured_output(rule),
1182 json_schema: rule_structured_output(rule),
1183 prefers_xml_scaffolding: rule.prefers_xml_scaffolding.unwrap_or(false),
1184 reserved_tool_call_token: rule.reserved_tool_call_token.unwrap_or(false),
1185 prefers_markdown_scaffolding: rule.prefers_markdown_scaffolding.unwrap_or(false),
1186 structured_output_mode: rule_structured_output_mode(rule),
1187 supports_assistant_prefill: rule.supports_assistant_prefill.unwrap_or(false),
1188 prefers_role_developer: rule.prefers_role_developer.unwrap_or(false),
1189 prefers_xml_tools: rule.prefers_xml_tools.unwrap_or(false),
1190 thinking_block_style: rule_thinking_block_style(rule),
1191 thinking_modes,
1192 interleaved_thinking_supported: rule.interleaved_thinking_supported.unwrap_or(false),
1193 anthropic_beta_features: rule.anthropic_beta_features.clone().unwrap_or_default(),
1194 vision_supported: rule.vision_supported.unwrap_or(false),
1195 image_url_input_supported: rule
1196 .image_url_input_supported
1197 .or(defaults.image_url_input_supported)
1198 .unwrap_or(true),
1199 preserve_thinking: rule.preserve_thinking.unwrap_or(false),
1200 server_parser: rule
1201 .server_parser
1202 .clone()
1203 .unwrap_or_else(|| "none".to_string()),
1204 honors_chat_template_kwargs: rule.honors_chat_template_kwargs.unwrap_or(false),
1205 requires_completion_tokens: rule.requires_completion_tokens.unwrap_or(false),
1206 reasoning_effort_supported: rule.reasoning_effort_supported.unwrap_or(false),
1207 reasoning_effort_levels: rule.reasoning_effort_levels.clone().unwrap_or_default(),
1208 reasoning_none_supported: rule.reasoning_none_supported.unwrap_or(false),
1209 reasoning_wire_format: rule
1210 .reasoning_wire_format
1211 .clone()
1212 .or_else(|| defaults.reasoning_wire_format.clone()),
1213 seed_supported: rule
1214 .seed_supported
1215 .or(defaults.seed_supported)
1216 .unwrap_or(true),
1217 top_k_supported: rule
1218 .top_k_supported
1219 .or(defaults.top_k_supported)
1220 .unwrap_or(true),
1221 frequency_penalty_supported: rule
1222 .frequency_penalty_supported
1223 .or(defaults.frequency_penalty_supported)
1224 .unwrap_or(true),
1225 presence_penalty_supported: rule
1226 .presence_penalty_supported
1227 .or(defaults.presence_penalty_supported)
1228 .unwrap_or(true),
1229 recommended_endpoint: rule.recommended_endpoint.clone(),
1230 text_tool_wire_format_supported: rule.text_tool_wire_format_supported.unwrap_or(true),
1231 preferred_tool_format: Some(rule_preferred_tool_format(rule)),
1232 tool_mode_parity: Some(rule_tool_mode_parity(rule)),
1233 tool_mode_parity_notes: rule.tool_mode_parity_notes.clone(),
1234 thinking_disable_directive: rule.thinking_disable_directive.clone(),
1235 auto_reasoning_overrides: rule.auto_reasoning_overrides.clone().unwrap_or_default(),
1236 }
1237}
1238
1239fn rule_preferred_tool_format(rule: &ProviderRule) -> String {
1240 rule.preferred_tool_format.clone().unwrap_or_else(|| {
1241 if rule.native_tools.unwrap_or(false) {
1242 "native".to_string()
1243 } else {
1244 "text".to_string()
1245 }
1246 })
1247}
1248
1249fn rule_tool_mode_parity(rule: &ProviderRule) -> String {
1250 rule.tool_mode_parity.clone().unwrap_or_else(|| {
1251 match (
1252 rule.native_tools.unwrap_or(false),
1253 rule.text_tool_wire_format_supported.unwrap_or(true),
1254 ) {
1255 (true, true) => "unknown".to_string(),
1256 (true, false) => "native_only".to_string(),
1257 (false, true) => "text_only".to_string(),
1258 (false, false) => "unsupported".to_string(),
1259 }
1260 })
1261}
1262
1263fn rule_structured_output(rule: &ProviderRule) -> Option<String> {
1264 rule.structured_output
1265 .clone()
1266 .or_else(|| rule.json_schema.clone())
1267 .filter(|value| value != "none")
1268}
1269
1270fn rule_structured_output_mode(rule: &ProviderRule) -> String {
1271 if let Some(mode) = &rule.structured_output_mode {
1272 return mode.clone();
1273 }
1274 match rule_structured_output(rule).as_deref() {
1275 Some("native") | Some("format_kw") => "native_json".to_string(),
1276 Some("tool_use") => "xml_tagged".to_string(),
1277 _ => "none".to_string(),
1278 }
1279}
1280
1281fn rule_thinking_block_style(rule: &ProviderRule) -> String {
1282 rule.thinking_block_style.clone().unwrap_or_else(|| {
1283 if rule.reasoning_effort_supported.unwrap_or(false)
1284 || rule.requires_completion_tokens.unwrap_or(false)
1285 {
1286 "reasoning_summary".to_string()
1287 } else {
1288 "none".to_string()
1289 }
1290 })
1291}
1292
1293fn rule_matches(rule: &ProviderRule, model: &str) -> bool {
1294 let lower = model.to_lowercase();
1295 if !glob_match(&rule.model_match.to_lowercase(), &lower) {
1296 return false;
1297 }
1298 if let Some(version_min) = &rule.version_min {
1299 if version_min.len() != 2 {
1300 return false;
1301 }
1302 let want = (version_min[0], version_min[1]);
1303 let have = match extract_version(model) {
1304 Some(v) => v,
1305 None => return false,
1309 };
1310 if have < want {
1311 return false;
1312 }
1313 }
1314 true
1315}
1316
1317fn extract_version(model: &str) -> Option<(u32, u32)> {
1322 claude_generation(model).or_else(|| gpt_generation(model))
1323}
1324
1325fn glob_match(pattern: &str, input: &str) -> bool {
1329 if let Some(prefix) = pattern.strip_suffix('*') {
1330 if let Some(rest) = prefix.strip_prefix('*') {
1331 return input.contains(rest);
1333 }
1334 return input.starts_with(prefix);
1335 }
1336 if let Some(suffix) = pattern.strip_prefix('*') {
1337 return input.ends_with(suffix);
1338 }
1339 if pattern.contains('*') {
1340 let parts: Vec<&str> = pattern.split('*').collect();
1341 if parts.len() == 2 {
1342 return input.starts_with(parts[0]) && input.ends_with(parts[1]);
1343 }
1344 return input == pattern;
1345 }
1346 input == pattern
1347}
1348
1349#[cfg(test)]
1350mod tests {
1351 use super::*;
1352
1353 fn reset() {
1354 clear_user_overrides();
1355 }
1356
1357 fn assert_cerebras_effort_reasoning(model: &str, thinking_block_style: &str) {
1358 let caps = lookup("cerebras", model);
1359 assert_eq!(caps.thinking_modes, vec!["effort"]);
1360 assert!(caps.reasoning_effort_supported);
1361 assert_eq!(caps.preferred_tool_format.as_deref(), Some("native"));
1362 assert_eq!(caps.structured_output.as_deref(), Some("native"));
1363 assert_eq!(caps.structured_output_mode, "native_json");
1364 assert_eq!(caps.thinking_block_style, thinking_block_style);
1365 }
1366
1367 #[test]
1368 fn every_catalogued_chat_model_has_explicit_tool_capabilities() {
1369 reset();
1370 let report = audit_builtin_catalogued_chat_model_tool_capabilities();
1371 assert!(report.ok(), "{}", report.render_human());
1372 }
1373
1374 #[test]
1375 fn every_catalogued_alias_has_explicit_tool_capabilities() {
1376 reset();
1382 let catalog = crate::llm_config::parse_config_toml(BUILTIN_PROVIDERS_TOML)
1383 .expect("providers.toml must parse at build time");
1384 let builtin = builtin();
1385 let mut gaps = Vec::new();
1386 for (alias, def) in &catalog.aliases {
1387 let matched = first_matching_rule(None, builtin, &def.provider, &def.id);
1388 let explicit = matched
1389 .as_ref()
1390 .map(|matched| {
1391 matched.rule.native_tools.is_some()
1392 && matched.rule.preferred_tool_format.is_some()
1393 })
1394 .unwrap_or(false);
1395 if !explicit {
1396 gaps.push(format!(
1397 "{alias} -> {}:{} (rule={})",
1398 def.provider,
1399 def.id,
1400 matched
1401 .as_ref()
1402 .map(|matched| matched.rule.model_match.as_str())
1403 .unwrap_or("<none>")
1404 ));
1405 }
1406 }
1407 assert!(
1408 gaps.is_empty(),
1409 "aliases missing explicit native_tools/preferred_tool_format:\n- {}",
1410 gaps.join("\n- ")
1411 );
1412 }
1413
1414 #[test]
1415 fn tool_capability_audit_reports_suggested_defaults() {
1416 reset();
1417 let capabilities: CapabilitiesFile = toml::from_str(
1418 r#"
1419[[provider.acme]]
1420model_match = "acme-good-*"
1421preferred_tool_format = "native"
1422"#,
1423 )
1424 .unwrap();
1425 let report = audit_tool_capability_coverage(
1426 vec![(
1427 "acme-good-1".to_string(),
1428 crate::llm_config::ModelDef {
1429 name: "Acme Good".to_string(),
1430 provider: "acme".to_string(),
1431 context_window: 128_000,
1432 runtime_context_window: None,
1433 stream_timeout: None,
1434 capabilities: Vec::new(),
1435 pricing: Some(crate::llm_config::ModelPricing {
1436 input_per_mtok: 1.0,
1437 output_per_mtok: 2.0,
1438 cache_read_per_mtok: None,
1439 cache_write_per_mtok: None,
1440 }),
1441 deprecated: false,
1442 deprecation_note: None,
1443 superseded_by: None,
1444 fast_mode: None,
1445 quality_tags: Vec::new(),
1446 availability: crate::llm_config::ModelAvailability::Serverless,
1447 tier: None,
1448 open_weight: None,
1449 strengths: Vec::new(),
1450 benchmarks: std::collections::BTreeMap::new(),
1451 family: None,
1452 lineage: None,
1453 complementary_with: Vec::new(),
1454 avoid_as_reviewer_for: Vec::new(),
1455 },
1456 )],
1457 &capabilities,
1458 None,
1459 );
1460
1461 assert!(!report.ok());
1462 assert_eq!(report.audited_models, 1);
1463 assert_eq!(report.gaps.len(), 1);
1464 assert_eq!(report.gaps[0].missing_fields, ["native_tools"]);
1465 assert!(report.gaps[0].suggested_native_tools);
1466 assert_eq!(report.gaps[0].suggested_preferred_tool_format, "native");
1467 assert!(report.render_human().contains(
1468 "acme:acme-good-1 (provider.acme model_match=\"acme-good-*\") missing native_tools; suggest native_tools = true, preferred_tool_format = \"native\""
1469 ));
1470 }
1471
1472 #[test]
1473 fn anthropic_opus_47_gets_full_capabilities() {
1474 reset();
1475 let caps = lookup("anthropic", "claude-opus-4-7");
1476 assert!(caps.native_tools);
1477 assert!(caps.defer_loading);
1478 assert_eq!(caps.tool_search, vec!["bm25", "regex"]);
1479 assert!(caps.prompt_caching);
1480 assert_eq!(caps.thinking_modes, vec!["adaptive"]);
1481 assert!(caps.vision_supported);
1482 assert!(caps.audio);
1483 assert!(caps.pdf);
1484 assert!(caps.files_api_supported);
1485 assert_eq!(caps.max_tools, Some(10000));
1486 assert!(caps.prefers_xml_scaffolding);
1487 assert!(!caps.prefers_markdown_scaffolding);
1488 assert_eq!(caps.structured_output_mode, "xml_tagged");
1489 assert!(!caps.supports_assistant_prefill);
1490 assert!(!caps.prefers_role_developer);
1491 assert!(caps.prefers_xml_tools);
1492 assert_eq!(caps.thinking_block_style, "thinking_blocks");
1493 }
1494
1495 #[test]
1496 fn anthropic_opus_46_uses_budgeted_thinking() {
1497 reset();
1498 let caps = lookup("anthropic", "claude-opus-4-6");
1499 assert_eq!(caps.thinking_modes, vec!["enabled"]);
1500 assert!(caps.interleaved_thinking_supported);
1501 assert!(!caps.supports_assistant_prefill);
1502 }
1503
1504 #[test]
1505 fn anthropic_opus_45_does_not_support_interleaved_thinking() {
1506 reset();
1507 let caps = lookup("anthropic", "claude-opus-4-5");
1508 assert_eq!(caps.thinking_modes, vec!["enabled"]);
1509 assert!(!caps.interleaved_thinking_supported);
1510 assert!(caps.supports_assistant_prefill);
1511 }
1512
1513 #[test]
1514 fn override_can_supply_anthropic_beta_features() {
1515 reset();
1516 let toml_src = r#"
1517[[provider.anthropic]]
1518model_match = "claude-custom-*"
1519native_tools = true
1520anthropic_beta_features = ["fine-grained-tool-streaming-2025-05-14"]
1521"#;
1522 set_user_overrides_toml(toml_src).unwrap();
1523 let caps = lookup("anthropic", "claude-custom-1");
1524 assert_eq!(
1525 caps.anthropic_beta_features,
1526 vec!["fine-grained-tool-streaming-2025-05-14"]
1527 );
1528 reset();
1529 }
1530
1531 #[test]
1532 fn anthropic_haiku_44_has_no_tool_search() {
1533 reset();
1534 let caps = lookup("anthropic", "claude-haiku-4-4");
1535 assert!(caps.native_tools);
1537 assert!(caps.prompt_caching);
1538 assert!(!caps.defer_loading);
1539 assert!(caps.tool_search.is_empty());
1540 }
1541
1542 #[test]
1543 fn anthropic_haiku_45_supports_tool_search() {
1544 reset();
1545 let caps = lookup("anthropic", "claude-haiku-4-5");
1546 assert!(caps.defer_loading);
1547 assert_eq!(caps.tool_search, vec!["bm25", "regex"]);
1548 }
1549
1550 #[test]
1551 fn old_claude_gets_catchall() {
1552 reset();
1553 let caps = lookup("anthropic", "claude-opus-3-5");
1554 assert!(caps.native_tools);
1555 assert!(caps.prompt_caching);
1556 assert!(!caps.defer_loading);
1557 assert!(caps.tool_search.is_empty());
1558 }
1559
1560 #[test]
1561 fn openai_gpt_54_supports_tool_search() {
1562 reset();
1563 let caps = lookup("openai", "gpt-5.4");
1564 assert!(caps.defer_loading);
1565 assert_eq!(caps.tool_search, vec!["hosted", "client"]);
1566 assert_eq!(caps.json_schema.as_deref(), Some("native"));
1567 assert_eq!(caps.thinking_modes, vec!["effort"]);
1568 assert!(caps.reasoning_effort_supported);
1569 assert!(caps.reasoning_none_supported);
1570 assert!(!caps.prefers_xml_scaffolding);
1571 assert!(caps.prefers_markdown_scaffolding);
1572 assert_eq!(caps.structured_output_mode, "native_json");
1573 assert!(!caps.supports_assistant_prefill);
1574 assert!(!caps.prefers_role_developer);
1575 assert!(!caps.prefers_xml_tools);
1576 assert_eq!(caps.thinking_block_style, "reasoning_summary");
1577 }
1578
1579 #[test]
1580 fn openai_gpt_53_has_reasoning_none_without_tool_search() {
1581 reset();
1582 let caps = lookup("openai", "gpt-5.3");
1583 assert!(caps.native_tools);
1584 assert!(!caps.defer_loading);
1585 assert!(caps.vision_supported);
1586 assert!(caps.tool_search.is_empty());
1587 assert_eq!(caps.thinking_modes, vec!["effort"]);
1588 assert!(caps.reasoning_effort_supported);
1589 assert!(caps.reasoning_none_supported);
1590 }
1591
1592 #[test]
1593 fn openai_original_gpt_5_has_reasoning_floor_without_none() {
1594 reset();
1595 let caps = lookup("openai", "gpt-5");
1596 assert!(caps.native_tools);
1597 assert!(!caps.defer_loading);
1598 assert_eq!(caps.thinking_modes, vec!["effort"]);
1599 assert!(caps.reasoning_effort_supported);
1600 assert!(!caps.reasoning_none_supported);
1601 }
1602
1603 #[test]
1604 fn openai_gpt_4o_matrix_fields_include_multimodal_support() {
1605 reset();
1606 let caps = lookup("openai", "gpt-4o");
1607 assert!(caps.native_tools);
1608 assert!(caps.vision);
1609 assert!(caps.audio);
1610 assert!(!caps.pdf);
1611 assert_eq!(caps.json_schema.as_deref(), Some("native"));
1612 }
1613
1614 #[test]
1615 fn openai_reasoning_models_support_effort() {
1616 reset();
1617 let caps = lookup("openai", "o3");
1618 assert_eq!(caps.thinking_modes, vec!["effort"]);
1619 assert!(caps.requires_completion_tokens);
1620 assert!(caps.reasoning_effort_supported);
1621 assert!(caps.prefers_role_developer);
1622 assert_eq!(caps.thinking_block_style, "reasoning_summary");
1623 let prefixed = lookup("openrouter", "openai/o4-mini");
1624 assert!(prefixed.requires_completion_tokens);
1625 assert!(prefixed.reasoning_effort_supported);
1626 }
1627
1628 #[test]
1629 fn vision_capability_gates_known_multimodal_models() {
1630 reset();
1631 let minimax_m3 = lookup("minimax", "MiniMax-M3");
1632 assert!(minimax_m3.vision_supported);
1633 assert!(minimax_m3.video);
1634 assert_eq!(minimax_m3.thinking_modes, vec!["adaptive"]);
1635 assert_eq!(minimax_m3.reasoning_wire_format.as_deref(), Some("minimax"));
1636 assert!(minimax_m3.requires_completion_tokens);
1637 let openrouter_m3 = lookup("openrouter", "minimax/minimax-m3");
1638 assert!(openrouter_m3.vision_supported);
1639 assert!(openrouter_m3.video);
1640 assert!(lookup("openai", "gpt-4o").vision_supported);
1641 assert!(lookup("openai", "gpt-5.4-preview").vision_supported);
1642 assert!(lookup("anthropic", "claude-sonnet-4-6").vision_supported);
1643 assert!(lookup("anthropic", "claude-sonnet-4-6").pdf);
1644 assert!(lookup("anthropic", "claude-sonnet-4-6").files_api_supported);
1645 assert!(lookup("openrouter", "google/gemini-2.5-flash").vision_supported);
1646 assert!(lookup("gemini", "gemini-2.5-flash").vision_supported);
1647 assert!(lookup("gemini", "gemini-2.5-flash").audio);
1648 assert!(lookup("gemini", "gemini-2.5-flash").pdf);
1649 assert_eq!(
1650 lookup("gemini", "gemini-2.5-flash").structured_output_mode,
1651 "native_json"
1652 );
1653 assert!(lookup("ollama", "llava:latest").vision_supported);
1654 assert!(lookup("ollama", "gemma4:26b").vision_supported);
1655 assert!(lookup("ollama", "gemma4-128k:latest").vision_supported);
1656 assert!(!lookup("openai", "gpt-3.5-turbo").vision_supported);
1657 assert!(!lookup("ollama", "qwen3.5:35b-a3b-coding-nvfp4").vision_supported);
1658 }
1659
1660 #[test]
1661 fn local_gemma4_exposes_native_tools_and_structured_output() {
1662 reset();
1667 let caps = lookup("local", "gemma-4-26b-a4b-it");
1668 assert!(caps.native_tools);
1669 assert_eq!(caps.preferred_tool_format.as_deref(), Some("native"));
1670 assert_eq!(caps.structured_output.as_deref(), Some("native"));
1671 }
1672
1673 #[test]
1674 fn ollama_vision_models_have_no_reasoning_scaffold() {
1675 reset();
1680 for model in ["bakllava:latest", "llama3.2-vision:11b", "gemma3:27b"] {
1681 assert_eq!(
1682 lookup("ollama", model).thinking_block_style,
1683 "none",
1684 "{model} should resolve to thinking_block_style=\"none\""
1685 );
1686 }
1687 assert_eq!(
1689 lookup("ollama", "llava:latest").thinking_block_style,
1690 "none"
1691 );
1692 }
1693
1694 #[test]
1695 fn ollama_gemma4_supports_structured_output_and_text_tools() {
1696 reset();
1700 for model in ["gemma4:12b-mlx", "gemma4:26b"] {
1701 let caps = lookup("ollama", model);
1702 assert_eq!(
1703 caps.structured_output.as_deref(),
1704 Some("format_kw"),
1705 "{model} should resolve structured_output=\"format_kw\""
1706 );
1707 assert!(!caps.native_tools, "{model} should use text tools");
1708 assert_eq!(
1709 caps.preferred_tool_format.as_deref(),
1710 Some("text"),
1711 "{model} should prefer text tool format"
1712 );
1713 assert_eq!(
1714 caps.thinking_block_style, "none",
1715 "{model} ships thinking-off"
1716 );
1717 }
1718 }
1719
1720 #[test]
1721 fn openrouter_inherits_openai() {
1722 reset();
1723 let caps = lookup("openrouter", "gpt-5.4");
1724 assert!(caps.defer_loading);
1725 assert_eq!(caps.tool_search, vec!["hosted", "client"]);
1726 assert_eq!(caps.reasoning_wire_format.as_deref(), Some("openrouter"));
1727 assert!(!caps.top_k_supported);
1728 }
1729
1730 #[test]
1731 fn openrouter_structured_routes_cover_current_open_models() {
1732 reset();
1733 for model in [
1734 "deepseek/deepseek-v4-flash",
1735 "mistralai/devstral-small",
1736 "meta-llama/llama-4-scout",
1737 ] {
1738 let caps = lookup("openrouter", model);
1739 assert!(caps.native_tools, "{model} should expose native tools");
1740 assert_eq!(caps.structured_output.as_deref(), Some("native"));
1741 assert_eq!(caps.structured_output_mode, "native_json");
1742 }
1743 assert!(lookup("openrouter", "deepseek/deepseek-v4-flash").top_k_supported);
1744 assert!(lookup("openrouter", "meta-llama/llama-4-scout").top_k_supported);
1745 assert!(!lookup("openrouter", "mistralai/devstral-small").top_k_supported);
1746 assert!(lookup("openrouter", "google/gemma-4-26b-a4b-it").top_k_supported);
1747 }
1748
1749 #[test]
1750 fn openrouter_anthropic_claude_models_support_native_tools() {
1751 reset();
1757 for model in [
1758 "anthropic/claude-haiku-4-5",
1759 "anthropic/claude-haiku-4-5-20251001",
1760 "anthropic/claude-sonnet-4-6",
1761 "anthropic/claude-sonnet-4-7",
1762 "anthropic/claude-opus-4-7",
1763 ] {
1764 let caps = lookup("openrouter", model);
1765 assert!(
1766 caps.native_tools,
1767 "{model} via openrouter should report native_tools=true",
1768 );
1769 assert!(
1770 caps.prompt_caching,
1771 "{model} via openrouter should report prompt_caching=true",
1772 );
1773 assert_eq!(
1774 caps.structured_output.as_deref(),
1775 Some("tool_use"),
1776 "{model} via openrouter should structured_output=tool_use (matches direct anthropic)",
1777 );
1778 }
1779 }
1780
1781 #[test]
1782 fn openrouter_deepseek_v32_defaults_to_text_tools() {
1783 reset();
1784 let caps = lookup("openrouter", "deepseek/deepseek-v3.2");
1785 assert!(caps.native_tools);
1786 assert!(caps.text_tool_wire_format_supported);
1787 assert_eq!(caps.preferred_tool_format.as_deref(), Some("text"));
1788 assert_eq!(caps.tool_mode_parity.as_deref(), Some("native_unreliable"));
1789 assert_eq!(caps.structured_output.as_deref(), Some("native"));
1790 }
1791
1792 #[test]
1793 fn openrouter_qwen_coder_defaults_to_text_tools() {
1794 reset();
1795 let caps = lookup("openrouter", "qwen/qwen3-coder-flash");
1796 assert!(caps.native_tools);
1797 assert!(caps.text_tool_wire_format_supported);
1798 assert_eq!(caps.preferred_tool_format.as_deref(), Some("text"));
1799 assert_eq!(caps.tool_mode_parity.as_deref(), Some("native_unreliable"));
1800 }
1801
1802 #[test]
1803 fn bedrock_claude_uses_anthropic_wire_capabilities() {
1804 reset();
1805 let caps = lookup("bedrock", "anthropic.claude-3-5-sonnet-20240620-v1:0");
1806 assert!(caps.native_tools);
1807 assert_eq!(caps.message_wire_format, "anthropic");
1808 assert_eq!(caps.native_tool_wire_format, "anthropic");
1809 }
1810
1811 #[test]
1812 fn groq_inherits_openai_family_only() {
1813 reset();
1814 let caps = lookup("groq", "gpt-5.5-preview");
1815 assert!(caps.defer_loading);
1816 }
1817
1818 #[test]
1819 fn cerebras_inherits_openai_family() {
1820 reset();
1821 let caps = lookup("cerebras", "gpt-oss-120b");
1822 assert_eq!(caps.message_wire_format, "openai");
1823 assert_eq!(caps.native_tool_wire_format, "openai");
1824 assert!(caps.native_tools);
1825 }
1826
1827 #[test]
1828 fn cerebras_gpt_oss_declares_supported_reasoning_efforts() {
1829 reset();
1833 let caps = lookup("cerebras", "gpt-oss-120b");
1834 assert_cerebras_effort_reasoning("gpt-oss-120b", "reasoning_summary");
1835 assert!(!caps.reasoning_none_supported);
1836 assert_eq!(caps.reasoning_effort_levels, vec!["low", "medium", "high"]);
1837 }
1838
1839 #[test]
1840 fn cerebras_glm_47_supports_reasoning_none() {
1841 reset();
1845 let caps = lookup("cerebras", "zai-glm-4.7");
1846 assert_cerebras_effort_reasoning("zai-glm-4.7", "inline");
1847 assert!(caps.reasoning_none_supported);
1848 }
1849
1850 #[test]
1851 fn mock_with_claude_model_routes_to_anthropic() {
1852 reset();
1853 let caps = lookup("mock", "claude-sonnet-4-7");
1854 assert!(caps.defer_loading);
1855 assert_eq!(caps.tool_search, vec!["bm25", "regex"]);
1856 }
1857
1858 #[test]
1859 fn mock_with_gpt_model_routes_to_openai() {
1860 reset();
1861 let caps = lookup("mock", "gpt-5.4-preview");
1862 assert!(caps.defer_loading);
1863 assert_eq!(caps.tool_search, vec!["hosted", "client"]);
1864 }
1865
1866 #[test]
1867 fn mock_with_gemini_model_routes_to_gemini() {
1868 reset();
1869 let caps = lookup("mock", "gemini-2.5-flash");
1870 assert_eq!(caps.message_wire_format, "gemini");
1871 assert_eq!(caps.native_tool_wire_format, "openai");
1872 assert!(caps.prefers_xml_scaffolding);
1873 }
1874
1875 #[test]
1876 fn qwen36_ollama_preserves_thinking() {
1877 reset();
1878 let caps = lookup("ollama", "qwen3.6:35b-a3b-coding-nvfp4");
1879 assert!(!caps.native_tools);
1880 assert_eq!(caps.json_schema.as_deref(), Some("format_kw"));
1881 assert!(!caps.thinking_modes.is_empty());
1882 assert!(
1883 caps.preserve_thinking,
1884 "Qwen3.6 should enable preserve_thinking by default for long-horizon loops"
1885 );
1886 assert_eq!(caps.server_parser, "none");
1887 assert!(!caps.honors_chat_template_kwargs);
1888 assert_eq!(caps.recommended_endpoint.as_deref(), Some("/api/chat"));
1889 assert!(caps.text_tool_wire_format_supported);
1890 assert!(caps.prefers_markdown_scaffolding);
1891 assert_eq!(caps.structured_output_mode, "delimited");
1892 assert!(!caps.prefers_xml_tools);
1893 assert_eq!(caps.thinking_block_style, "inline");
1894 }
1895
1896 #[test]
1897 fn qwen35_ollama_does_not_preserve_thinking() {
1898 reset();
1899 let caps = lookup("ollama", "qwen3.5:35b-a3b-coding-nvfp4");
1900 assert!(caps.native_tools);
1901 assert!(!caps.thinking_modes.is_empty());
1902 assert!(
1903 !caps.preserve_thinking,
1904 "Qwen3.5 lacks the preserve_thinking kwarg — rely on the chat template's rolling checkpoint instead"
1905 );
1906 assert_eq!(caps.server_parser, "ollama_qwen3coder");
1907 assert!(!caps.text_tool_wire_format_supported);
1908 }
1909
1910 #[test]
1911 fn qwen36_routed_providers_all_preserve_thinking() {
1912 reset();
1913 for (provider, model) in [
1914 ("openrouter", "qwen/qwen3.6-plus"),
1915 ("together", "Qwen/Qwen3.6-Plus"),
1916 ("huggingface", "Qwen/Qwen3.6-35B-A3B"),
1917 ("fireworks", "accounts/fireworks/models/qwen3p6-plus"),
1918 ("dashscope", "qwen3.6-plus"),
1919 ("local", "Qwen3.6-35B-A3B"),
1920 ("mlx", "unsloth/Qwen3.6-27B-UD-MLX-4bit"),
1921 ("mlx", "Qwen/Qwen3.6-27B"),
1922 ] {
1923 let caps = lookup(provider, model);
1924 assert!(
1925 !caps.thinking_modes.is_empty(),
1926 "{provider}/{model}: thinking"
1927 );
1928 assert!(
1929 caps.preserve_thinking,
1930 "{provider}/{model}: preserve_thinking must be on for Qwen3.6"
1931 );
1932 assert!(caps.native_tools, "{provider}/{model}: native_tools");
1933 assert_ne!(
1934 caps.server_parser, "ollama_qwen3coder",
1935 "{provider}/{model}: only Ollama routes through the qwen3coder response parser"
1936 );
1937 }
1938
1939 let caps = lookup("llamacpp", "unsloth/Qwen3.6-35B-A3B-GGUF");
1940 assert!(!caps.thinking_modes.is_empty());
1941 assert!(caps.preserve_thinking);
1942 assert!(!caps.native_tools);
1943 assert!(caps.text_tool_wire_format_supported);
1944 assert_eq!(caps.server_parser, "none");
1945 }
1946
1947 #[test]
1948 fn qwen_coder_models_do_not_claim_thinking_modes() {
1949 reset();
1950 for (provider, model) in [
1951 ("together", "Qwen/Qwen3-Coder-Next-FP8"),
1952 ("together", "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8"),
1953 ("openrouter", "qwen/qwen3-coder-next"),
1954 ("huggingface", "Qwen/Qwen3-Coder-Next"),
1955 ] {
1956 let caps = lookup(provider, model);
1957 assert!(caps.native_tools, "{provider}/{model}: native_tools");
1958 assert!(
1959 caps.thinking_modes.is_empty(),
1960 "{provider}/{model}: coder models are non-thinking routes"
1961 );
1962 assert!(
1963 !caps.preserve_thinking,
1964 "{provider}/{model}: preserve_thinking must stay off"
1965 );
1966 assert!(
1967 caps.thinking_disable_directive.is_none(),
1968 "{provider}/{model}: no /no_think shim should be needed"
1969 );
1970 }
1971 }
1972
1973 #[test]
1974 fn llamacpp_qwen_keeps_text_tool_wire_format() {
1975 reset();
1976 let caps = lookup("llamacpp", "unsloth/Qwen3.5-Coder-GGUF");
1977 assert_eq!(caps.server_parser, "none");
1978 assert!(caps.honors_chat_template_kwargs);
1979 assert!(!caps.native_tools);
1980 assert!(caps.text_tool_wire_format_supported);
1981 assert_eq!(
1982 caps.recommended_endpoint.as_deref(),
1983 Some("/v1/chat/completions")
1984 );
1985 }
1986
1987 #[test]
1988 fn devstral_local_routes_default_to_text_tools() {
1989 reset();
1990 for provider in ["ollama", "llamacpp"] {
1991 let caps = lookup(provider, "devstral-small-2:24b");
1992 assert!(!caps.native_tools, "{provider}: native tools stay opt-in");
1993 assert!(
1994 caps.text_tool_wire_format_supported,
1995 "{provider}: text tools should remain available"
1996 );
1997 }
1998 }
1999
2000 #[test]
2001 fn openrouter_mistral_routes_use_native_tools() {
2002 reset();
2003 let caps = lookup("openrouter", "mistralai/mistral-small-2603");
2004 assert!(caps.native_tools);
2005 assert!(caps.text_tool_wire_format_supported);
2006 assert_eq!(caps.structured_output.as_deref(), Some("native"));
2007 assert_eq!(caps.structured_output_mode, "native_json");
2008 }
2009
2010 #[test]
2011 fn dashscope_and_llamacpp_resolve_capabilities() {
2012 reset();
2013 let caps = lookup("dashscope", "gpt-5.4-preview");
2016 assert!(caps.defer_loading);
2017 let caps = lookup("llamacpp", "gpt-5.4-preview");
2018 assert!(caps.defer_loading);
2019 }
2020
2021 #[test]
2022 fn unknown_provider_has_no_capabilities() {
2023 reset();
2024 let caps = lookup("my-custom-proxy", "foo-bar-1");
2025 assert!(!caps.native_tools);
2026 assert!(!caps.defer_loading);
2027 assert!(caps.tool_search.is_empty());
2028 }
2029
2030 #[test]
2031 fn enterprise_routes_expose_format_preferences() {
2032 reset();
2033 let bedrock_claude = lookup("bedrock", "anthropic.claude-opus-4-7-v1:0");
2034 assert!(bedrock_claude.prefers_xml_scaffolding);
2035 assert_eq!(bedrock_claude.structured_output_mode, "xml_tagged");
2036 assert!(!bedrock_claude.supports_assistant_prefill);
2037 assert!(bedrock_claude.prefers_xml_tools);
2038
2039 let azure_o = lookup("azure_openai", "o3-prod");
2040 assert!(azure_o.prefers_markdown_scaffolding);
2041 assert_eq!(azure_o.structured_output_mode, "native_json");
2042 assert!(azure_o.prefers_role_developer);
2043 assert_eq!(azure_o.thinking_block_style, "reasoning_summary");
2044 }
2045
2046 #[test]
2047 fn user_override_adds_new_provider() {
2048 reset();
2049 let toml_src = concat!(
2050 "[[provider.my-proxy]]\n",
2051 "model_match = \"*\"\n",
2052 "native_tools = true\n",
2053 "tool_search = [\"hosted\"]\n",
2054 "prefers_xml_scaffolding = true\n",
2055 "structured_output_mode = \"xml_tagged\"\n",
2056 "supports_assistant_prefill = true\n",
2057 "prefers_xml_tools = true\n",
2058 "thinking_block_style = \"thinking_blocks\"\n",
2059 );
2060 set_user_overrides_toml(toml_src).unwrap();
2061 let caps = lookup("my-proxy", "anything");
2062 assert!(caps.native_tools);
2063 assert_eq!(caps.tool_search, vec!["hosted"]);
2064 assert!(caps.prefers_xml_scaffolding);
2065 assert_eq!(caps.structured_output_mode, "xml_tagged");
2066 assert!(caps.supports_assistant_prefill);
2067 assert!(caps.prefers_xml_tools);
2068 assert_eq!(caps.thinking_block_style, "thinking_blocks");
2069 clear_user_overrides();
2070 }
2071
2072 #[test]
2073 fn user_override_takes_precedence_over_builtin() {
2074 reset();
2075 let toml_src = r#"
2076[[provider.anthropic]]
2077model_match = "claude-opus-*"
2078native_tools = true
2079defer_loading = false
2080tool_search = []
2081"#;
2082 set_user_overrides_toml(toml_src).unwrap();
2083 let caps = lookup("anthropic", "claude-opus-4-7");
2084 assert!(caps.native_tools);
2085 assert!(!caps.defer_loading);
2086 assert!(caps.tool_search.is_empty());
2087 clear_user_overrides();
2088 }
2089
2090 #[test]
2091 fn user_override_from_manifest_toml() {
2092 reset();
2093 let manifest = concat!(
2094 "[package]\n",
2095 "name = \"demo\"\n\n",
2096 "[[capabilities.provider.my-proxy]]\n",
2097 "model_match = \"*\"\n",
2098 "native_tools = true\n",
2099 "tool_search = [\"hosted\"]\n",
2100 "prefers_markdown_scaffolding = true\n",
2101 "structured_output_mode = \"native_json\"\n",
2102 "prefers_role_developer = true\n",
2103 "thinking_block_style = \"reasoning_summary\"\n",
2104 );
2105 set_user_overrides_from_manifest_toml(manifest).unwrap();
2106 let caps = lookup("my-proxy", "foo");
2107 assert!(caps.native_tools);
2108 assert_eq!(caps.tool_search, vec!["hosted"]);
2109 assert!(caps.prefers_markdown_scaffolding);
2110 assert_eq!(caps.structured_output_mode, "native_json");
2111 assert!(caps.prefers_role_developer);
2112 assert_eq!(caps.thinking_block_style, "reasoning_summary");
2113 clear_user_overrides();
2114 }
2115
2116 #[test]
2117 fn version_min_requires_parseable_model() {
2118 reset();
2119 let toml_src = r#"
2120[[provider.custom]]
2121model_match = "*"
2122version_min = [5, 4]
2123native_tools = true
2124"#;
2125 set_user_overrides_toml(toml_src).unwrap();
2126 let caps = lookup("custom", "mystery-model");
2128 assert!(!caps.native_tools);
2129 clear_user_overrides();
2130 }
2131
2132 #[test]
2133 fn glob_match_substring() {
2134 assert!(glob_match("*gpt*", "openai/gpt-5.4"));
2135 assert!(glob_match("*claude*", "anthropic/claude-opus-4-7"));
2136 assert!(!glob_match("*xyz*", "openai/gpt-5.4"));
2137 }
2138
2139 #[test]
2140 fn openrouter_namespaced_anthropic_model() {
2141 reset();
2142 let caps = lookup("anthropic", "anthropic/claude-opus-4-7");
2143 assert!(caps.defer_loading);
2144 }
2145
2146 #[test]
2147 fn matrix_rows_include_provider_patterns_and_sources() {
2148 reset();
2149 let rows = matrix_rows();
2150 assert!(rows.iter().any(|row| {
2151 row.provider == "openai"
2152 && row.model == "gpt-4o*"
2153 && row.vision
2154 && row.audio
2155 && row.json_schema.as_deref() == Some("native")
2156 && row.source == "builtin"
2157 }));
2158 }
2159}