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)]
236 pub files_api_supported: Option<bool>,
237 #[serde(default)]
240 pub file_upload_wire_format: Option<String>,
241 #[serde(default)]
244 pub structured_output: Option<String>,
245 #[serde(default)]
248 pub json_schema: Option<String>,
249 #[serde(default)]
252 pub prefers_xml_scaffolding: Option<bool>,
253 #[serde(default)]
256 pub prefers_markdown_scaffolding: Option<bool>,
257 #[serde(default)]
261 pub structured_output_mode: Option<String>,
262 #[serde(default)]
264 pub supports_assistant_prefill: Option<bool>,
265 #[serde(default)]
268 pub prefers_role_developer: Option<bool>,
269 #[serde(default)]
272 pub prefers_xml_tools: Option<bool>,
273 #[serde(default)]
277 pub thinking_block_style: Option<String>,
278 #[serde(default)]
281 pub thinking_modes: Option<Vec<String>>,
282 #[serde(default)]
285 pub interleaved_thinking_supported: Option<bool>,
286 #[serde(default)]
288 pub anthropic_beta_features: Option<Vec<String>>,
289 #[serde(default)]
292 pub thinking: Option<bool>,
293 #[serde(default)]
295 pub vision_supported: Option<bool>,
296 #[serde(default)]
298 pub image_url_input_supported: Option<bool>,
299 #[serde(default)]
306 pub preserve_thinking: Option<bool>,
307 #[serde(default)]
311 pub server_parser: Option<String>,
312 #[serde(default)]
315 pub honors_chat_template_kwargs: Option<bool>,
316 #[serde(default)]
319 pub requires_completion_tokens: Option<bool>,
320 #[serde(default)]
322 pub reasoning_effort_supported: Option<bool>,
323 #[serde(default)]
327 pub reasoning_none_supported: Option<bool>,
328 #[serde(default)]
331 pub reasoning_wire_format: Option<String>,
332 #[serde(default)]
333 pub seed_supported: Option<bool>,
334 #[serde(default)]
335 pub top_k_supported: Option<bool>,
336 #[serde(default)]
337 pub frequency_penalty_supported: Option<bool>,
338 #[serde(default)]
339 pub presence_penalty_supported: Option<bool>,
340 #[serde(default)]
344 pub recommended_endpoint: Option<String>,
345 #[serde(default)]
348 pub text_tool_wire_format_supported: Option<bool>,
349 #[serde(default)]
354 pub preferred_tool_format: Option<String>,
355 #[serde(default)]
360 pub tool_mode_parity: Option<String>,
361 #[serde(default)]
363 pub tool_mode_parity_notes: Option<String>,
364 #[serde(default)]
371 pub thinking_disable_directive: Option<String>,
372 #[serde(default)]
385 pub auto_reasoning_overrides: Option<BTreeMap<String, String>>,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq)]
392pub struct Capabilities {
393 pub native_tools: bool,
394 pub message_wire_format: String,
395 pub native_tool_wire_format: String,
396 pub defer_loading: bool,
397 pub tool_search: Vec<String>,
398 pub responses_api: bool,
399 pub hosted_tools: Vec<String>,
400 pub remote_mcp: bool,
401 pub conversation_state: bool,
402 pub compaction: bool,
403 pub background_mode: bool,
404 pub tool_approval_policy: Option<String>,
405 pub max_tools: Option<u32>,
406 pub prompt_caching: bool,
407 pub vision: bool,
408 pub audio: bool,
409 pub pdf: bool,
410 pub files_api_supported: bool,
411 pub file_upload_wire_format: Option<String>,
412 pub structured_output: Option<String>,
413 pub json_schema: Option<String>,
415 pub prefers_xml_scaffolding: bool,
416 pub prefers_markdown_scaffolding: bool,
417 pub structured_output_mode: String,
418 pub supports_assistant_prefill: bool,
419 pub prefers_role_developer: bool,
420 pub prefers_xml_tools: bool,
421 pub thinking_block_style: String,
422 pub thinking_modes: Vec<String>,
423 pub interleaved_thinking_supported: bool,
424 pub anthropic_beta_features: Vec<String>,
425 pub vision_supported: bool,
426 pub image_url_input_supported: bool,
427 pub preserve_thinking: bool,
428 pub server_parser: String,
429 pub honors_chat_template_kwargs: bool,
430 pub requires_completion_tokens: bool,
431 pub reasoning_effort_supported: bool,
432 pub reasoning_none_supported: bool,
433 pub reasoning_wire_format: Option<String>,
434 pub seed_supported: bool,
435 pub top_k_supported: bool,
436 pub frequency_penalty_supported: bool,
437 pub presence_penalty_supported: bool,
438 pub recommended_endpoint: Option<String>,
439 pub text_tool_wire_format_supported: bool,
440 pub preferred_tool_format: Option<String>,
441 pub tool_mode_parity: Option<String>,
442 pub tool_mode_parity_notes: Option<String>,
443 pub thinking_disable_directive: Option<String>,
444 pub auto_reasoning_overrides: BTreeMap<String, String>,
447}
448
449impl Default for Capabilities {
450 fn default() -> Self {
451 Self {
452 native_tools: false,
453 message_wire_format: "openai".to_string(),
454 native_tool_wire_format: "openai".to_string(),
455 defer_loading: false,
456 tool_search: Vec::new(),
457 responses_api: false,
458 hosted_tools: Vec::new(),
459 remote_mcp: false,
460 conversation_state: false,
461 compaction: false,
462 background_mode: false,
463 tool_approval_policy: None,
464 max_tools: None,
465 prompt_caching: false,
466 vision: false,
467 audio: false,
468 pdf: false,
469 files_api_supported: false,
470 file_upload_wire_format: None,
471 structured_output: None,
472 json_schema: None,
473 prefers_xml_scaffolding: false,
474 prefers_markdown_scaffolding: false,
475 structured_output_mode: "none".to_string(),
476 supports_assistant_prefill: false,
477 prefers_role_developer: false,
478 prefers_xml_tools: false,
479 thinking_block_style: "none".to_string(),
480 thinking_modes: Vec::new(),
481 interleaved_thinking_supported: false,
482 anthropic_beta_features: Vec::new(),
483 vision_supported: false,
484 image_url_input_supported: true,
485 preserve_thinking: false,
486 server_parser: "none".to_string(),
487 honors_chat_template_kwargs: false,
488 requires_completion_tokens: false,
489 reasoning_effort_supported: false,
490 reasoning_none_supported: false,
491 reasoning_wire_format: None,
492 seed_supported: true,
493 top_k_supported: true,
494 frequency_penalty_supported: true,
495 presence_penalty_supported: true,
496 recommended_endpoint: None,
497 text_tool_wire_format_supported: true,
498 preferred_tool_format: None,
499 tool_mode_parity: None,
500 tool_mode_parity_notes: None,
501 thinking_disable_directive: None,
502 auto_reasoning_overrides: BTreeMap::new(),
503 }
504 }
505}
506
507#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
513pub struct ProviderCapabilityMatrixRow {
514 pub provider: String,
515 pub model: String,
516 pub version_min: Option<Vec<u32>>,
517 pub thinking: Vec<String>,
518 pub vision: bool,
519 pub audio: bool,
520 pub pdf: bool,
521 pub streaming: bool,
522 pub files_api_supported: bool,
523 pub json_schema: Option<String>,
524 pub prefers_xml_scaffolding: bool,
525 pub prefers_markdown_scaffolding: bool,
526 pub structured_output_mode: String,
527 pub supports_assistant_prefill: bool,
528 pub prefers_role_developer: bool,
529 pub prefers_xml_tools: bool,
530 pub thinking_block_style: String,
531 pub native_tools: bool,
532 pub text_tools: bool,
533 pub preferred_tool_format: String,
534 pub tool_mode_parity: String,
535 pub tools: bool,
536 pub cache: bool,
537 pub source: String,
538}
539
540#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
541pub struct ToolCapabilityAuditReport {
542 pub audited_models: usize,
543 pub gaps: Vec<ToolCapabilityAuditGap>,
544}
545
546impl ToolCapabilityAuditReport {
547 pub fn ok(&self) -> bool {
548 self.gaps.is_empty()
549 }
550
551 pub fn render_human(&self) -> String {
552 if self.gaps.is_empty() {
553 return format!(
554 "provider capability audit OK: {} priced chat models have explicit native_tools and preferred_tool_format rules",
555 self.audited_models
556 );
557 }
558
559 let mut out = format!(
560 "provider capability audit found {} catalog gaps among {} priced chat models:",
561 self.gaps.len(),
562 self.audited_models
563 );
564 for gap in &self.gaps {
565 let matched = match (&gap.rule_provider, &gap.rule_model_match) {
566 (Some(provider), Some(model_match)) => {
567 format!("provider.{provider} model_match=\"{model_match}\"")
568 }
569 _ => "no matching rule".to_string(),
570 };
571 out.push_str(&format!(
572 "\n- {}:{} ({matched}) missing {}; suggest native_tools = {}, preferred_tool_format = \"{}\"",
573 gap.provider,
574 gap.model,
575 gap.missing_fields.join(", "),
576 gap.suggested_native_tools,
577 gap.suggested_preferred_tool_format,
578 ));
579 }
580 out
581 }
582}
583
584#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
585pub struct ToolCapabilityAuditGap {
586 pub provider: String,
587 pub model: String,
588 pub rule_provider: Option<String>,
589 pub rule_model_match: Option<String>,
590 pub missing_fields: Vec<String>,
591 pub suggested_native_tools: bool,
592 pub suggested_preferred_tool_format: String,
593}
594
595thread_local! {
596 static USER_OVERRIDES: RefCell<Option<CapabilitiesFile>> = const { RefCell::new(None) };
601}
602
603static BUILTIN: OnceLock<CapabilitiesFile> = OnceLock::new();
607
608fn builtin() -> &'static CapabilitiesFile {
609 BUILTIN.get_or_init(|| {
610 toml::from_str::<CapabilitiesFile>(BUILTIN_TOML)
611 .expect("capabilities.toml must parse at build time")
612 })
613}
614
615pub fn set_user_overrides(file: Option<CapabilitiesFile>) {
619 USER_OVERRIDES.with(|cell| *cell.borrow_mut() = file);
620}
621
622pub fn clear_user_overrides() {
624 set_user_overrides(None);
625}
626
627pub fn set_user_overrides_toml(src: &str) -> Result<(), String> {
632 let parsed: CapabilitiesFile = toml::from_str(src).map_err(|e| e.to_string())?;
633 set_user_overrides(Some(parsed));
634 Ok(())
635}
636
637pub fn set_user_overrides_from_manifest_toml(src: &str) -> Result<(), String> {
649 #[derive(Deserialize)]
650 struct Manifest {
651 #[serde(default)]
652 capabilities: Option<CapabilitiesFile>,
653 }
654 let parsed: Manifest = toml::from_str(src).map_err(|e| e.to_string())?;
655 set_user_overrides(parsed.capabilities);
656 Ok(())
657}
658
659pub fn lookup(provider: &str, model: &str) -> Capabilities {
665 let user = USER_OVERRIDES.with(|cell| cell.borrow().clone());
666 let mut caps = lookup_with(provider, model, builtin(), user.as_ref());
667 if provider != "openai" && provider != "mock" {
668 caps.responses_api = false;
669 caps.hosted_tools.clear();
670 caps.remote_mcp = false;
671 caps.conversation_state = false;
672 caps.compaction = false;
673 caps.background_mode = false;
674 caps.tool_approval_policy = None;
675 }
676 caps
677}
678
679pub fn matrix_rows() -> Vec<ProviderCapabilityMatrixRow> {
683 let user = USER_OVERRIDES.with(|cell| cell.borrow().clone());
684 let mut rows = Vec::new();
685 if let Some(user) = user.as_ref() {
686 push_matrix_rows(&mut rows, user, "project");
687 }
688 push_matrix_rows(&mut rows, builtin(), "builtin");
689 rows
690}
691
692pub fn audit_catalogued_chat_model_tool_capabilities() -> ToolCapabilityAuditReport {
696 let user = USER_OVERRIDES.with(|cell| cell.borrow().clone());
697 audit_tool_capability_coverage(
698 crate::llm_config::model_catalog_entries(),
699 builtin(),
700 user.as_ref(),
701 )
702}
703
704pub fn audit_builtin_catalogued_chat_model_tool_capabilities() -> ToolCapabilityAuditReport {
707 let catalog = crate::llm_config::parse_config_toml(BUILTIN_PROVIDERS_TOML)
708 .expect("providers.toml must parse at build time");
709 audit_tool_capability_coverage(catalog.models, builtin(), None)
710}
711
712fn audit_tool_capability_coverage<I>(
713 models: I,
714 builtin: &CapabilitiesFile,
715 user: Option<&CapabilitiesFile>,
716) -> ToolCapabilityAuditReport
717where
718 I: IntoIterator<Item = (String, crate::llm_config::ModelDef)>,
719{
720 let mut gaps = Vec::new();
721 let mut audited_models = 0;
722
723 for (model_id, model) in models {
724 if model.pricing.is_none() {
725 continue;
726 }
727 audited_models += 1;
728 let matched = first_matching_rule(user, builtin, &model.provider, &model_id);
729 let mut missing_fields = Vec::new();
730 match matched.as_ref().map(|matched| matched.rule) {
731 Some(rule) => {
732 if rule.native_tools.is_none() {
733 missing_fields.push("native_tools".to_string());
734 }
735 if rule.preferred_tool_format.is_none() {
736 missing_fields.push("preferred_tool_format".to_string());
737 }
738 }
739 None => {
740 missing_fields.push("native_tools".to_string());
741 missing_fields.push("preferred_tool_format".to_string());
742 }
743 }
744 if missing_fields.is_empty() {
745 continue;
746 }
747
748 let (suggested_native_tools, suggested_preferred_tool_format) =
749 suggested_tool_capability_defaults(
750 &model.provider,
751 &model_id,
752 &model,
753 matched.as_ref(),
754 );
755 gaps.push(ToolCapabilityAuditGap {
756 provider: model.provider,
757 model: model_id,
758 rule_provider: matched.as_ref().map(|matched| matched.provider.clone()),
759 rule_model_match: matched.map(|matched| matched.rule.model_match.clone()),
760 missing_fields,
761 suggested_native_tools,
762 suggested_preferred_tool_format,
763 });
764 }
765
766 gaps.sort_by(|left, right| {
767 left.provider
768 .cmp(&right.provider)
769 .then_with(|| left.model.cmp(&right.model))
770 });
771 ToolCapabilityAuditReport {
772 audited_models,
773 gaps,
774 }
775}
776
777struct MatchedCapabilityRule<'a> {
778 provider: String,
779 rule: &'a ProviderRule,
780}
781
782fn first_matching_rule<'a>(
783 user: Option<&'a CapabilitiesFile>,
784 builtin: &'a CapabilitiesFile,
785 provider: &str,
786 model: &str,
787) -> Option<MatchedCapabilityRule<'a>> {
788 let mut current = provider.to_string();
789 let mut visited = HashSet::new();
790 while visited.insert(current.clone()) {
791 if let Some(rule) = user
792 .and_then(|file| first_matching_rule_in_file(file, ¤t, model))
793 .or_else(|| first_matching_rule_in_file(builtin, ¤t, model))
794 {
795 return Some(MatchedCapabilityRule {
796 provider: current,
797 rule,
798 });
799 }
800 let next = user
801 .and_then(|file| file.provider_family.get(¤t))
802 .or_else(|| builtin.provider_family.get(¤t))
803 .cloned();
804 current = next?;
805 }
806 None
807}
808
809fn first_matching_rule_in_file<'a>(
810 file: &'a CapabilitiesFile,
811 provider: &str,
812 model: &str,
813) -> Option<&'a ProviderRule> {
814 file.provider
815 .get(provider)?
816 .iter()
817 .find(|rule| rule_matches(rule, model))
818}
819
820fn suggested_tool_capability_defaults(
821 provider: &str,
822 model_id: &str,
823 model: &crate::llm_config::ModelDef,
824 matched: Option<&MatchedCapabilityRule<'_>>,
825) -> (bool, String) {
826 if let Some(rule) = matched.map(|matched| matched.rule) {
827 let native_tools =
828 rule.native_tools
829 .unwrap_or_else(|| match rule.preferred_tool_format.as_deref() {
830 Some("native") => true,
831 Some("text") => false,
832 _ => suggested_native_tools(provider, model_id, model),
833 });
834 let preferred_tool_format = rule
835 .preferred_tool_format
836 .clone()
837 .unwrap_or_else(|| tool_format_for_native(native_tools));
838 return (native_tools, preferred_tool_format);
839 }
840
841 let native_tools = suggested_native_tools(provider, model_id, model);
842 (native_tools, tool_format_for_native(native_tools))
843}
844
845fn suggested_native_tools(
846 provider: &str,
847 model_id: &str,
848 model: &crate::llm_config::ModelDef,
849) -> bool {
850 if provider == "anthropic" || model_id.contains("claude") {
851 return true;
852 }
853 if matches!(
854 provider,
855 "openai" | "gemini" | "cerebras" | "bedrock" | "azure_openai" | "vertex"
856 ) {
857 return true;
858 }
859 model
860 .capabilities
861 .iter()
862 .any(|capability| capability == "tools")
863}
864
865fn tool_format_for_native(native_tools: bool) -> String {
866 if native_tools {
867 "native".to_string()
868 } else {
869 "text".to_string()
870 }
871}
872
873fn push_matrix_rows(
874 rows: &mut Vec<ProviderCapabilityMatrixRow>,
875 file: &CapabilitiesFile,
876 source: &str,
877) {
878 for (provider, rules) in &file.provider {
879 for rule in rules {
880 rows.push(rule_to_matrix_row(provider, rule, source));
881 }
882 }
883}
884
885fn rule_to_matrix_row(
886 provider: &str,
887 rule: &ProviderRule,
888 source: &str,
889) -> ProviderCapabilityMatrixRow {
890 ProviderCapabilityMatrixRow {
891 provider: provider.to_string(),
892 model: rule.model_match.clone(),
893 version_min: rule.version_min.clone(),
894 thinking: rule_thinking_modes(rule),
895 vision: rule_vision(rule),
896 audio: rule.audio.unwrap_or(false),
897 pdf: rule.pdf.unwrap_or(false),
898 streaming: true,
899 files_api_supported: rule.files_api_supported.unwrap_or(false),
900 json_schema: rule_structured_output(rule),
901 prefers_xml_scaffolding: rule.prefers_xml_scaffolding.unwrap_or(false),
902 prefers_markdown_scaffolding: rule.prefers_markdown_scaffolding.unwrap_or(false),
903 structured_output_mode: rule_structured_output_mode(rule),
904 supports_assistant_prefill: rule.supports_assistant_prefill.unwrap_or(false),
905 prefers_role_developer: rule
906 .prefers_role_developer
907 .unwrap_or_else(|| rule.requires_completion_tokens.unwrap_or(false)),
908 prefers_xml_tools: rule.prefers_xml_tools.unwrap_or(false),
909 thinking_block_style: rule_thinking_block_style(rule),
910 native_tools: rule.native_tools.unwrap_or(false),
911 text_tools: rule.text_tool_wire_format_supported.unwrap_or(true),
912 preferred_tool_format: rule_preferred_tool_format(rule),
913 tool_mode_parity: rule_tool_mode_parity(rule),
914 tools: rule.native_tools.unwrap_or(false)
915 || rule.text_tool_wire_format_supported.unwrap_or(true),
916 cache: rule.prompt_caching.unwrap_or(false),
917 source: source.to_string(),
918 }
919}
920
921fn rule_thinking_modes(rule: &ProviderRule) -> Vec<String> {
922 rule.thinking_modes.clone().unwrap_or_else(|| {
923 if rule.thinking.unwrap_or(false) {
924 vec!["enabled".to_string()]
925 } else {
926 Vec::new()
927 }
928 })
929}
930
931fn rule_vision(rule: &ProviderRule) -> bool {
932 rule.vision.or(rule.vision_supported).unwrap_or(false)
933}
934
935fn lookup_with(
936 provider: &str,
937 model: &str,
938 builtin: &CapabilitiesFile,
939 user: Option<&CapabilitiesFile>,
940) -> Capabilities {
941 if provider == "mock" {
952 let anthropic_defaults = merged_provider_defaults(user, builtin, "anthropic");
953 if let Some(mut caps) =
954 try_match_layer(user, builtin, "anthropic", model, &anthropic_defaults)
955 {
956 caps.native_tool_wire_format = "openai".to_string();
957 return caps;
958 }
959 let openai_defaults = merged_provider_defaults(user, builtin, "openai");
960 if let Some(caps) = try_match_layer(user, builtin, "openai", model, &openai_defaults) {
961 return caps;
962 }
963 let gemini_defaults = merged_provider_defaults(user, builtin, "gemini");
964 if let Some(caps) = try_match_layer(user, builtin, "gemini", model, &gemini_defaults) {
965 return caps;
966 }
967 return Capabilities::default();
968 }
969
970 let mut current = provider.to_string();
973 let mut effective_defaults = ProviderDefaults::default();
974 let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
975 while visited.insert(current.clone()) {
976 let layer_defaults = merged_provider_defaults(user, builtin, ¤t);
977 if effective_defaults.has_any_field() {
978 effective_defaults.fill_missing_from(&layer_defaults);
979 } else {
980 effective_defaults.overlay(&layer_defaults);
981 }
982 if let Some(caps) = try_match_layer(user, builtin, ¤t, model, &effective_defaults) {
983 return caps;
984 }
985 let next = user
986 .and_then(|f| f.provider_family.get(¤t))
987 .or_else(|| builtin.provider_family.get(¤t))
988 .cloned();
989 match next {
990 Some(parent) => current = parent,
991 None => break,
992 }
993 }
994 if effective_defaults.has_any_field() {
995 return defaults_to_caps(&effective_defaults);
996 }
997 Capabilities::default()
998}
999
1000fn try_match_layer(
1004 user: Option<&CapabilitiesFile>,
1005 builtin: &CapabilitiesFile,
1006 layer_provider: &str,
1007 model: &str,
1008 defaults: &ProviderDefaults,
1009) -> Option<Capabilities> {
1010 if let Some(user) = user {
1011 if let Some(rules) = user.provider.get(layer_provider) {
1012 for rule in rules {
1013 if rule_matches(rule, model) {
1014 return Some(rule_to_caps(rule, defaults));
1015 }
1016 }
1017 }
1018 }
1019 if let Some(rules) = builtin.provider.get(layer_provider) {
1020 for rule in rules {
1021 if rule_matches(rule, model) {
1022 return Some(rule_to_caps(rule, defaults));
1023 }
1024 }
1025 }
1026 None
1027}
1028
1029fn merged_provider_defaults(
1030 user: Option<&CapabilitiesFile>,
1031 builtin: &CapabilitiesFile,
1032 provider: &str,
1033) -> ProviderDefaults {
1034 let mut defaults = builtin
1035 .provider_defaults
1036 .get(provider)
1037 .cloned()
1038 .unwrap_or_default();
1039 if let Some(user_defaults) = user.and_then(|file| file.provider_defaults.get(provider)) {
1040 defaults.overlay(user_defaults);
1041 }
1042 defaults
1043}
1044
1045fn defaults_to_caps(defaults: &ProviderDefaults) -> Capabilities {
1046 let empty = ProviderRule {
1047 model_match: "*".to_string(),
1048 version_min: None,
1049 native_tools: None,
1050 message_wire_format: None,
1051 native_tool_wire_format: None,
1052 defer_loading: None,
1053 tool_search: None,
1054 responses_api: None,
1055 hosted_tools: None,
1056 remote_mcp: None,
1057 conversation_state: None,
1058 compaction: None,
1059 background_mode: None,
1060 tool_approval_policy: None,
1061 max_tools: None,
1062 prompt_caching: None,
1063 vision: None,
1064 audio: None,
1065 pdf: None,
1066 files_api_supported: None,
1067 file_upload_wire_format: None,
1068 structured_output: None,
1069 prefers_xml_scaffolding: None,
1070 prefers_markdown_scaffolding: None,
1071 structured_output_mode: None,
1072 supports_assistant_prefill: None,
1073 prefers_role_developer: None,
1074 prefers_xml_tools: None,
1075 thinking_block_style: None,
1076 json_schema: None,
1077 thinking_modes: None,
1078 interleaved_thinking_supported: None,
1079 anthropic_beta_features: None,
1080 thinking: None,
1081 vision_supported: None,
1082 image_url_input_supported: None,
1083 preserve_thinking: None,
1084 server_parser: None,
1085 honors_chat_template_kwargs: None,
1086 requires_completion_tokens: None,
1087 reasoning_effort_supported: None,
1088 reasoning_none_supported: None,
1089 reasoning_wire_format: None,
1090 seed_supported: None,
1091 top_k_supported: None,
1092 frequency_penalty_supported: None,
1093 presence_penalty_supported: None,
1094 recommended_endpoint: None,
1095 text_tool_wire_format_supported: None,
1096 preferred_tool_format: None,
1097 tool_mode_parity: None,
1098 tool_mode_parity_notes: None,
1099 thinking_disable_directive: None,
1100 auto_reasoning_overrides: None,
1101 };
1102 let mut caps = rule_to_caps(&empty, defaults);
1103 caps.preferred_tool_format = None;
1104 caps.tool_mode_parity = None;
1105 caps
1106}
1107
1108fn rule_to_caps(rule: &ProviderRule, defaults: &ProviderDefaults) -> Capabilities {
1109 let thinking_modes = rule_thinking_modes(rule);
1110 Capabilities {
1111 native_tools: rule.native_tools.unwrap_or(false),
1112 message_wire_format: rule
1113 .message_wire_format
1114 .clone()
1115 .or_else(|| defaults.message_wire_format.clone())
1116 .unwrap_or_else(|| "openai".to_string()),
1117 native_tool_wire_format: rule
1118 .native_tool_wire_format
1119 .clone()
1120 .or_else(|| defaults.native_tool_wire_format.clone())
1121 .unwrap_or_else(|| "openai".to_string()),
1122 defer_loading: rule.defer_loading.unwrap_or(false),
1123 tool_search: rule.tool_search.clone().unwrap_or_default(),
1124 responses_api: rule.responses_api.unwrap_or(false),
1125 hosted_tools: rule.hosted_tools.clone().unwrap_or_default(),
1126 remote_mcp: rule.remote_mcp.unwrap_or(false),
1127 conversation_state: rule.conversation_state.unwrap_or(false),
1128 compaction: rule.compaction.unwrap_or(false),
1129 background_mode: rule.background_mode.unwrap_or(false),
1130 tool_approval_policy: rule.tool_approval_policy.clone(),
1131 max_tools: rule.max_tools,
1132 prompt_caching: rule.prompt_caching.unwrap_or(false),
1133 vision: rule_vision(rule),
1134 audio: rule.audio.unwrap_or(false),
1135 pdf: rule.pdf.unwrap_or(false),
1136 files_api_supported: rule
1137 .files_api_supported
1138 .or(defaults.files_api_supported)
1139 .unwrap_or(false),
1140 file_upload_wire_format: rule
1141 .file_upload_wire_format
1142 .clone()
1143 .or_else(|| defaults.file_upload_wire_format.clone()),
1144 structured_output: rule_structured_output(rule),
1145 json_schema: rule_structured_output(rule),
1146 prefers_xml_scaffolding: rule.prefers_xml_scaffolding.unwrap_or(false),
1147 prefers_markdown_scaffolding: rule.prefers_markdown_scaffolding.unwrap_or(false),
1148 structured_output_mode: rule_structured_output_mode(rule),
1149 supports_assistant_prefill: rule.supports_assistant_prefill.unwrap_or(false),
1150 prefers_role_developer: rule.prefers_role_developer.unwrap_or(false),
1151 prefers_xml_tools: rule.prefers_xml_tools.unwrap_or(false),
1152 thinking_block_style: rule_thinking_block_style(rule),
1153 thinking_modes,
1154 interleaved_thinking_supported: rule.interleaved_thinking_supported.unwrap_or(false),
1155 anthropic_beta_features: rule.anthropic_beta_features.clone().unwrap_or_default(),
1156 vision_supported: rule.vision_supported.unwrap_or(false),
1157 image_url_input_supported: rule
1158 .image_url_input_supported
1159 .or(defaults.image_url_input_supported)
1160 .unwrap_or(true),
1161 preserve_thinking: rule.preserve_thinking.unwrap_or(false),
1162 server_parser: rule
1163 .server_parser
1164 .clone()
1165 .unwrap_or_else(|| "none".to_string()),
1166 honors_chat_template_kwargs: rule.honors_chat_template_kwargs.unwrap_or(false),
1167 requires_completion_tokens: rule.requires_completion_tokens.unwrap_or(false),
1168 reasoning_effort_supported: rule.reasoning_effort_supported.unwrap_or(false),
1169 reasoning_none_supported: rule.reasoning_none_supported.unwrap_or(false),
1170 reasoning_wire_format: rule
1171 .reasoning_wire_format
1172 .clone()
1173 .or_else(|| defaults.reasoning_wire_format.clone()),
1174 seed_supported: rule
1175 .seed_supported
1176 .or(defaults.seed_supported)
1177 .unwrap_or(true),
1178 top_k_supported: rule
1179 .top_k_supported
1180 .or(defaults.top_k_supported)
1181 .unwrap_or(true),
1182 frequency_penalty_supported: rule
1183 .frequency_penalty_supported
1184 .or(defaults.frequency_penalty_supported)
1185 .unwrap_or(true),
1186 presence_penalty_supported: rule
1187 .presence_penalty_supported
1188 .or(defaults.presence_penalty_supported)
1189 .unwrap_or(true),
1190 recommended_endpoint: rule.recommended_endpoint.clone(),
1191 text_tool_wire_format_supported: rule.text_tool_wire_format_supported.unwrap_or(true),
1192 preferred_tool_format: Some(rule_preferred_tool_format(rule)),
1193 tool_mode_parity: Some(rule_tool_mode_parity(rule)),
1194 tool_mode_parity_notes: rule.tool_mode_parity_notes.clone(),
1195 thinking_disable_directive: rule.thinking_disable_directive.clone(),
1196 auto_reasoning_overrides: rule.auto_reasoning_overrides.clone().unwrap_or_default(),
1197 }
1198}
1199
1200fn rule_preferred_tool_format(rule: &ProviderRule) -> String {
1201 rule.preferred_tool_format.clone().unwrap_or_else(|| {
1202 if rule.native_tools.unwrap_or(false) {
1203 "native".to_string()
1204 } else {
1205 "text".to_string()
1206 }
1207 })
1208}
1209
1210fn rule_tool_mode_parity(rule: &ProviderRule) -> String {
1211 rule.tool_mode_parity.clone().unwrap_or_else(|| {
1212 match (
1213 rule.native_tools.unwrap_or(false),
1214 rule.text_tool_wire_format_supported.unwrap_or(true),
1215 ) {
1216 (true, true) => "unknown".to_string(),
1217 (true, false) => "native_only".to_string(),
1218 (false, true) => "text_only".to_string(),
1219 (false, false) => "unsupported".to_string(),
1220 }
1221 })
1222}
1223
1224fn rule_structured_output(rule: &ProviderRule) -> Option<String> {
1225 rule.structured_output
1226 .clone()
1227 .or_else(|| rule.json_schema.clone())
1228 .filter(|value| value != "none")
1229}
1230
1231fn rule_structured_output_mode(rule: &ProviderRule) -> String {
1232 if let Some(mode) = &rule.structured_output_mode {
1233 return mode.clone();
1234 }
1235 match rule_structured_output(rule).as_deref() {
1236 Some("native") | Some("format_kw") => "native_json".to_string(),
1237 Some("tool_use") => "xml_tagged".to_string(),
1238 _ => "none".to_string(),
1239 }
1240}
1241
1242fn rule_thinking_block_style(rule: &ProviderRule) -> String {
1243 rule.thinking_block_style.clone().unwrap_or_else(|| {
1244 if rule.reasoning_effort_supported.unwrap_or(false)
1245 || rule.requires_completion_tokens.unwrap_or(false)
1246 {
1247 "reasoning_summary".to_string()
1248 } else {
1249 "none".to_string()
1250 }
1251 })
1252}
1253
1254fn rule_matches(rule: &ProviderRule, model: &str) -> bool {
1255 let lower = model.to_lowercase();
1256 if !glob_match(&rule.model_match.to_lowercase(), &lower) {
1257 return false;
1258 }
1259 if let Some(version_min) = &rule.version_min {
1260 if version_min.len() != 2 {
1261 return false;
1262 }
1263 let want = (version_min[0], version_min[1]);
1264 let have = match extract_version(model) {
1265 Some(v) => v,
1266 None => return false,
1270 };
1271 if have < want {
1272 return false;
1273 }
1274 }
1275 true
1276}
1277
1278fn extract_version(model: &str) -> Option<(u32, u32)> {
1283 claude_generation(model).or_else(|| gpt_generation(model))
1284}
1285
1286fn glob_match(pattern: &str, input: &str) -> bool {
1290 if let Some(prefix) = pattern.strip_suffix('*') {
1291 if let Some(rest) = prefix.strip_prefix('*') {
1292 return input.contains(rest);
1294 }
1295 return input.starts_with(prefix);
1296 }
1297 if let Some(suffix) = pattern.strip_prefix('*') {
1298 return input.ends_with(suffix);
1299 }
1300 if pattern.contains('*') {
1301 let parts: Vec<&str> = pattern.split('*').collect();
1302 if parts.len() == 2 {
1303 return input.starts_with(parts[0]) && input.ends_with(parts[1]);
1304 }
1305 return input == pattern;
1306 }
1307 input == pattern
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312 use super::*;
1313
1314 fn reset() {
1315 clear_user_overrides();
1316 }
1317
1318 #[test]
1319 fn every_catalogued_chat_model_has_explicit_tool_capabilities() {
1320 reset();
1321 let report = audit_builtin_catalogued_chat_model_tool_capabilities();
1322 assert!(report.ok(), "{}", report.render_human());
1323 }
1324
1325 #[test]
1326 fn tool_capability_audit_reports_suggested_defaults() {
1327 reset();
1328 let capabilities: CapabilitiesFile = toml::from_str(
1329 r#"
1330[[provider.acme]]
1331model_match = "acme-good-*"
1332preferred_tool_format = "native"
1333"#,
1334 )
1335 .unwrap();
1336 let report = audit_tool_capability_coverage(
1337 vec![(
1338 "acme-good-1".to_string(),
1339 crate::llm_config::ModelDef {
1340 name: "Acme Good".to_string(),
1341 provider: "acme".to_string(),
1342 context_window: 128_000,
1343 runtime_context_window: None,
1344 stream_timeout: None,
1345 capabilities: Vec::new(),
1346 pricing: Some(crate::llm_config::ModelPricing {
1347 input_per_mtok: 1.0,
1348 output_per_mtok: 2.0,
1349 cache_read_per_mtok: None,
1350 cache_write_per_mtok: None,
1351 }),
1352 deprecated: false,
1353 deprecation_note: None,
1354 superseded_by: None,
1355 fast_mode: None,
1356 quality_tags: Vec::new(),
1357 availability: crate::llm_config::ModelAvailability::Serverless,
1358 tier: None,
1359 open_weight: None,
1360 strengths: Vec::new(),
1361 benchmarks: std::collections::BTreeMap::new(),
1362 },
1363 )],
1364 &capabilities,
1365 None,
1366 );
1367
1368 assert!(!report.ok());
1369 assert_eq!(report.audited_models, 1);
1370 assert_eq!(report.gaps.len(), 1);
1371 assert_eq!(report.gaps[0].missing_fields, ["native_tools"]);
1372 assert!(report.gaps[0].suggested_native_tools);
1373 assert_eq!(report.gaps[0].suggested_preferred_tool_format, "native");
1374 assert!(report.render_human().contains(
1375 "acme:acme-good-1 (provider.acme model_match=\"acme-good-*\") missing native_tools; suggest native_tools = true, preferred_tool_format = \"native\""
1376 ));
1377 }
1378
1379 #[test]
1380 fn anthropic_opus_47_gets_full_capabilities() {
1381 reset();
1382 let caps = lookup("anthropic", "claude-opus-4-7");
1383 assert!(caps.native_tools);
1384 assert!(caps.defer_loading);
1385 assert_eq!(caps.tool_search, vec!["bm25", "regex"]);
1386 assert!(caps.prompt_caching);
1387 assert_eq!(caps.thinking_modes, vec!["adaptive"]);
1388 assert!(caps.vision_supported);
1389 assert!(caps.audio);
1390 assert!(caps.pdf);
1391 assert!(caps.files_api_supported);
1392 assert_eq!(caps.max_tools, Some(10000));
1393 assert!(caps.prefers_xml_scaffolding);
1394 assert!(!caps.prefers_markdown_scaffolding);
1395 assert_eq!(caps.structured_output_mode, "xml_tagged");
1396 assert!(!caps.supports_assistant_prefill);
1397 assert!(!caps.prefers_role_developer);
1398 assert!(caps.prefers_xml_tools);
1399 assert_eq!(caps.thinking_block_style, "thinking_blocks");
1400 }
1401
1402 #[test]
1403 fn anthropic_opus_46_uses_budgeted_thinking() {
1404 reset();
1405 let caps = lookup("anthropic", "claude-opus-4-6");
1406 assert_eq!(caps.thinking_modes, vec!["enabled"]);
1407 assert!(caps.interleaved_thinking_supported);
1408 assert!(!caps.supports_assistant_prefill);
1409 }
1410
1411 #[test]
1412 fn anthropic_opus_45_does_not_support_interleaved_thinking() {
1413 reset();
1414 let caps = lookup("anthropic", "claude-opus-4-5");
1415 assert_eq!(caps.thinking_modes, vec!["enabled"]);
1416 assert!(!caps.interleaved_thinking_supported);
1417 assert!(caps.supports_assistant_prefill);
1418 }
1419
1420 #[test]
1421 fn override_can_supply_anthropic_beta_features() {
1422 reset();
1423 let toml_src = r#"
1424[[provider.anthropic]]
1425model_match = "claude-custom-*"
1426native_tools = true
1427anthropic_beta_features = ["fine-grained-tool-streaming-2025-05-14"]
1428"#;
1429 set_user_overrides_toml(toml_src).unwrap();
1430 let caps = lookup("anthropic", "claude-custom-1");
1431 assert_eq!(
1432 caps.anthropic_beta_features,
1433 vec!["fine-grained-tool-streaming-2025-05-14"]
1434 );
1435 reset();
1436 }
1437
1438 #[test]
1439 fn anthropic_haiku_44_has_no_tool_search() {
1440 reset();
1441 let caps = lookup("anthropic", "claude-haiku-4-4");
1442 assert!(caps.native_tools);
1444 assert!(caps.prompt_caching);
1445 assert!(!caps.defer_loading);
1446 assert!(caps.tool_search.is_empty());
1447 }
1448
1449 #[test]
1450 fn anthropic_haiku_45_supports_tool_search() {
1451 reset();
1452 let caps = lookup("anthropic", "claude-haiku-4-5");
1453 assert!(caps.defer_loading);
1454 assert_eq!(caps.tool_search, vec!["bm25", "regex"]);
1455 }
1456
1457 #[test]
1458 fn old_claude_gets_catchall() {
1459 reset();
1460 let caps = lookup("anthropic", "claude-opus-3-5");
1461 assert!(caps.native_tools);
1462 assert!(caps.prompt_caching);
1463 assert!(!caps.defer_loading);
1464 assert!(caps.tool_search.is_empty());
1465 }
1466
1467 #[test]
1468 fn openai_gpt_54_supports_tool_search() {
1469 reset();
1470 let caps = lookup("openai", "gpt-5.4");
1471 assert!(caps.defer_loading);
1472 assert_eq!(caps.tool_search, vec!["hosted", "client"]);
1473 assert_eq!(caps.json_schema.as_deref(), Some("native"));
1474 assert_eq!(caps.thinking_modes, vec!["effort"]);
1475 assert!(caps.reasoning_effort_supported);
1476 assert!(caps.reasoning_none_supported);
1477 assert!(!caps.prefers_xml_scaffolding);
1478 assert!(caps.prefers_markdown_scaffolding);
1479 assert_eq!(caps.structured_output_mode, "native_json");
1480 assert!(!caps.supports_assistant_prefill);
1481 assert!(!caps.prefers_role_developer);
1482 assert!(!caps.prefers_xml_tools);
1483 assert_eq!(caps.thinking_block_style, "reasoning_summary");
1484 }
1485
1486 #[test]
1487 fn openai_gpt_53_has_reasoning_none_without_tool_search() {
1488 reset();
1489 let caps = lookup("openai", "gpt-5.3");
1490 assert!(caps.native_tools);
1491 assert!(!caps.defer_loading);
1492 assert!(caps.vision_supported);
1493 assert!(caps.tool_search.is_empty());
1494 assert_eq!(caps.thinking_modes, vec!["effort"]);
1495 assert!(caps.reasoning_effort_supported);
1496 assert!(caps.reasoning_none_supported);
1497 }
1498
1499 #[test]
1500 fn openai_original_gpt_5_has_reasoning_floor_without_none() {
1501 reset();
1502 let caps = lookup("openai", "gpt-5");
1503 assert!(caps.native_tools);
1504 assert!(!caps.defer_loading);
1505 assert_eq!(caps.thinking_modes, vec!["effort"]);
1506 assert!(caps.reasoning_effort_supported);
1507 assert!(!caps.reasoning_none_supported);
1508 }
1509
1510 #[test]
1511 fn openai_gpt_4o_matrix_fields_include_multimodal_support() {
1512 reset();
1513 let caps = lookup("openai", "gpt-4o");
1514 assert!(caps.native_tools);
1515 assert!(caps.vision);
1516 assert!(caps.audio);
1517 assert!(!caps.pdf);
1518 assert_eq!(caps.json_schema.as_deref(), Some("native"));
1519 }
1520
1521 #[test]
1522 fn openai_reasoning_models_support_effort() {
1523 reset();
1524 let caps = lookup("openai", "o3");
1525 assert_eq!(caps.thinking_modes, vec!["effort"]);
1526 assert!(caps.requires_completion_tokens);
1527 assert!(caps.reasoning_effort_supported);
1528 assert!(caps.prefers_role_developer);
1529 assert_eq!(caps.thinking_block_style, "reasoning_summary");
1530 let prefixed = lookup("openrouter", "openai/o4-mini");
1531 assert!(prefixed.requires_completion_tokens);
1532 assert!(prefixed.reasoning_effort_supported);
1533 }
1534
1535 #[test]
1536 fn vision_capability_gates_known_multimodal_models() {
1537 reset();
1538 assert!(lookup("openai", "gpt-4o").vision_supported);
1539 assert!(lookup("openai", "gpt-5.4-preview").vision_supported);
1540 assert!(lookup("anthropic", "claude-sonnet-4-6").vision_supported);
1541 assert!(lookup("anthropic", "claude-sonnet-4-6").pdf);
1542 assert!(lookup("anthropic", "claude-sonnet-4-6").files_api_supported);
1543 assert!(lookup("openrouter", "google/gemini-2.5-flash").vision_supported);
1544 assert!(lookup("gemini", "gemini-2.5-flash").vision_supported);
1545 assert!(lookup("gemini", "gemini-2.5-flash").audio);
1546 assert!(lookup("gemini", "gemini-2.5-flash").pdf);
1547 assert_eq!(
1548 lookup("gemini", "gemini-2.5-flash").structured_output_mode,
1549 "native_json"
1550 );
1551 assert!(lookup("ollama", "llava:latest").vision_supported);
1552 assert!(lookup("ollama", "gemma4:26b").vision_supported);
1553 assert!(lookup("ollama", "gemma4-128k:latest").vision_supported);
1554 assert!(!lookup("openai", "gpt-3.5-turbo").vision_supported);
1555 assert!(!lookup("ollama", "qwen3.5:35b-a3b-coding-nvfp4").vision_supported);
1556 }
1557
1558 #[test]
1559 fn openrouter_inherits_openai() {
1560 reset();
1561 let caps = lookup("openrouter", "gpt-5.4");
1562 assert!(caps.defer_loading);
1563 assert_eq!(caps.tool_search, vec!["hosted", "client"]);
1564 assert_eq!(caps.reasoning_wire_format.as_deref(), Some("openrouter"));
1565 assert!(!caps.top_k_supported);
1566 }
1567
1568 #[test]
1569 fn openrouter_structured_routes_cover_current_open_models() {
1570 reset();
1571 for model in [
1572 "deepseek/deepseek-v4-flash",
1573 "mistralai/devstral-small",
1574 "meta-llama/llama-4-scout",
1575 ] {
1576 let caps = lookup("openrouter", model);
1577 assert!(caps.native_tools, "{model} should expose native tools");
1578 assert_eq!(caps.structured_output.as_deref(), Some("native"));
1579 assert_eq!(caps.structured_output_mode, "native_json");
1580 }
1581 assert!(lookup("openrouter", "deepseek/deepseek-v4-flash").top_k_supported);
1582 assert!(lookup("openrouter", "meta-llama/llama-4-scout").top_k_supported);
1583 assert!(!lookup("openrouter", "mistralai/devstral-small").top_k_supported);
1584 assert!(lookup("openrouter", "google/gemma-4-26b-a4b-it").top_k_supported);
1585 }
1586
1587 #[test]
1588 fn openrouter_anthropic_claude_models_support_native_tools() {
1589 reset();
1595 for model in [
1596 "anthropic/claude-haiku-4-5",
1597 "anthropic/claude-haiku-4-5-20251001",
1598 "anthropic/claude-sonnet-4-6",
1599 "anthropic/claude-sonnet-4-7",
1600 "anthropic/claude-opus-4-7",
1601 ] {
1602 let caps = lookup("openrouter", model);
1603 assert!(
1604 caps.native_tools,
1605 "{model} via openrouter should report native_tools=true",
1606 );
1607 assert!(
1608 caps.prompt_caching,
1609 "{model} via openrouter should report prompt_caching=true",
1610 );
1611 assert_eq!(
1612 caps.structured_output.as_deref(),
1613 Some("tool_use"),
1614 "{model} via openrouter should structured_output=tool_use (matches direct anthropic)",
1615 );
1616 }
1617 }
1618
1619 #[test]
1620 fn openrouter_deepseek_v32_defaults_to_text_tools() {
1621 reset();
1622 let caps = lookup("openrouter", "deepseek/deepseek-v3.2");
1623 assert!(caps.native_tools);
1624 assert!(caps.text_tool_wire_format_supported);
1625 assert_eq!(caps.preferred_tool_format.as_deref(), Some("text"));
1626 assert_eq!(caps.tool_mode_parity.as_deref(), Some("native_unreliable"));
1627 assert_eq!(caps.structured_output.as_deref(), Some("native"));
1628 }
1629
1630 #[test]
1631 fn openrouter_qwen_coder_defaults_to_text_tools() {
1632 reset();
1633 let caps = lookup("openrouter", "qwen/qwen3-coder-flash");
1634 assert!(caps.native_tools);
1635 assert!(caps.text_tool_wire_format_supported);
1636 assert_eq!(caps.preferred_tool_format.as_deref(), Some("text"));
1637 assert_eq!(caps.tool_mode_parity.as_deref(), Some("native_unreliable"));
1638 }
1639
1640 #[test]
1641 fn bedrock_claude_uses_anthropic_wire_capabilities() {
1642 reset();
1643 let caps = lookup("bedrock", "anthropic.claude-3-5-sonnet-20240620-v1:0");
1644 assert!(caps.native_tools);
1645 assert_eq!(caps.message_wire_format, "anthropic");
1646 assert_eq!(caps.native_tool_wire_format, "anthropic");
1647 }
1648
1649 #[test]
1650 fn groq_inherits_openai_family_only() {
1651 reset();
1652 let caps = lookup("groq", "gpt-5.5-preview");
1653 assert!(caps.defer_loading);
1654 }
1655
1656 #[test]
1657 fn cerebras_inherits_openai_family() {
1658 reset();
1659 let caps = lookup("cerebras", "gpt-oss-120b");
1660 assert_eq!(caps.message_wire_format, "openai");
1661 assert_eq!(caps.native_tool_wire_format, "openai");
1662 assert!(caps.native_tools);
1663 }
1664
1665 #[test]
1666 fn mock_with_claude_model_routes_to_anthropic() {
1667 reset();
1668 let caps = lookup("mock", "claude-sonnet-4-7");
1669 assert!(caps.defer_loading);
1670 assert_eq!(caps.tool_search, vec!["bm25", "regex"]);
1671 }
1672
1673 #[test]
1674 fn mock_with_gpt_model_routes_to_openai() {
1675 reset();
1676 let caps = lookup("mock", "gpt-5.4-preview");
1677 assert!(caps.defer_loading);
1678 assert_eq!(caps.tool_search, vec!["hosted", "client"]);
1679 }
1680
1681 #[test]
1682 fn mock_with_gemini_model_routes_to_gemini() {
1683 reset();
1684 let caps = lookup("mock", "gemini-2.5-flash");
1685 assert_eq!(caps.message_wire_format, "gemini");
1686 assert_eq!(caps.native_tool_wire_format, "openai");
1687 assert!(caps.prefers_xml_scaffolding);
1688 }
1689
1690 #[test]
1691 fn qwen36_ollama_preserves_thinking() {
1692 reset();
1693 let caps = lookup("ollama", "qwen3.6:35b-a3b-coding-nvfp4");
1694 assert!(!caps.native_tools);
1695 assert_eq!(caps.json_schema.as_deref(), Some("format_kw"));
1696 assert!(!caps.thinking_modes.is_empty());
1697 assert!(
1698 caps.preserve_thinking,
1699 "Qwen3.6 should enable preserve_thinking by default for long-horizon loops"
1700 );
1701 assert_eq!(caps.server_parser, "none");
1702 assert!(!caps.honors_chat_template_kwargs);
1703 assert_eq!(caps.recommended_endpoint.as_deref(), Some("/api/chat"));
1704 assert!(caps.text_tool_wire_format_supported);
1705 assert!(caps.prefers_markdown_scaffolding);
1706 assert_eq!(caps.structured_output_mode, "delimited");
1707 assert!(!caps.prefers_xml_tools);
1708 assert_eq!(caps.thinking_block_style, "inline");
1709 }
1710
1711 #[test]
1712 fn qwen35_ollama_does_not_preserve_thinking() {
1713 reset();
1714 let caps = lookup("ollama", "qwen3.5:35b-a3b-coding-nvfp4");
1715 assert!(caps.native_tools);
1716 assert!(!caps.thinking_modes.is_empty());
1717 assert!(
1718 !caps.preserve_thinking,
1719 "Qwen3.5 lacks the preserve_thinking kwarg — rely on the chat template's rolling checkpoint instead"
1720 );
1721 assert_eq!(caps.server_parser, "ollama_qwen3coder");
1722 assert!(!caps.text_tool_wire_format_supported);
1723 }
1724
1725 #[test]
1726 fn qwen36_routed_providers_all_preserve_thinking() {
1727 reset();
1728 for (provider, model) in [
1729 ("openrouter", "qwen/qwen3.6-plus"),
1730 ("together", "Qwen/Qwen3.6-Plus"),
1731 ("huggingface", "Qwen/Qwen3.6-35B-A3B"),
1732 ("fireworks", "accounts/fireworks/models/qwen3p6-plus"),
1733 ("dashscope", "qwen3.6-plus"),
1734 ("local", "Qwen3.6-35B-A3B"),
1735 ("mlx", "unsloth/Qwen3.6-27B-UD-MLX-4bit"),
1736 ("mlx", "Qwen/Qwen3.6-27B"),
1737 ] {
1738 let caps = lookup(provider, model);
1739 assert!(
1740 !caps.thinking_modes.is_empty(),
1741 "{provider}/{model}: thinking"
1742 );
1743 assert!(
1744 caps.preserve_thinking,
1745 "{provider}/{model}: preserve_thinking must be on for Qwen3.6"
1746 );
1747 assert!(caps.native_tools, "{provider}/{model}: native_tools");
1748 assert_ne!(
1749 caps.server_parser, "ollama_qwen3coder",
1750 "{provider}/{model}: only Ollama routes through the qwen3coder response parser"
1751 );
1752 }
1753
1754 let caps = lookup("llamacpp", "unsloth/Qwen3.6-35B-A3B-GGUF");
1755 assert!(!caps.thinking_modes.is_empty());
1756 assert!(caps.preserve_thinking);
1757 assert!(!caps.native_tools);
1758 assert!(caps.text_tool_wire_format_supported);
1759 assert_eq!(caps.server_parser, "none");
1760 }
1761
1762 #[test]
1763 fn qwen_coder_models_do_not_claim_thinking_modes() {
1764 reset();
1765 for (provider, model) in [
1766 ("together", "Qwen/Qwen3-Coder-Next-FP8"),
1767 ("together", "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8"),
1768 ("openrouter", "qwen/qwen3-coder-next"),
1769 ("huggingface", "Qwen/Qwen3-Coder-Next"),
1770 ] {
1771 let caps = lookup(provider, model);
1772 assert!(caps.native_tools, "{provider}/{model}: native_tools");
1773 assert!(
1774 caps.thinking_modes.is_empty(),
1775 "{provider}/{model}: coder models are non-thinking routes"
1776 );
1777 assert!(
1778 !caps.preserve_thinking,
1779 "{provider}/{model}: preserve_thinking must stay off"
1780 );
1781 assert!(
1782 caps.thinking_disable_directive.is_none(),
1783 "{provider}/{model}: no /no_think shim should be needed"
1784 );
1785 }
1786 }
1787
1788 #[test]
1789 fn llamacpp_qwen_keeps_text_tool_wire_format() {
1790 reset();
1791 let caps = lookup("llamacpp", "unsloth/Qwen3.5-Coder-GGUF");
1792 assert_eq!(caps.server_parser, "none");
1793 assert!(caps.honors_chat_template_kwargs);
1794 assert!(!caps.native_tools);
1795 assert!(caps.text_tool_wire_format_supported);
1796 assert_eq!(
1797 caps.recommended_endpoint.as_deref(),
1798 Some("/v1/chat/completions")
1799 );
1800 }
1801
1802 #[test]
1803 fn devstral_local_routes_default_to_text_tools() {
1804 reset();
1805 for provider in ["ollama", "llamacpp"] {
1806 let caps = lookup(provider, "devstral-small-2:24b");
1807 assert!(!caps.native_tools, "{provider}: native tools stay opt-in");
1808 assert!(
1809 caps.text_tool_wire_format_supported,
1810 "{provider}: text tools should remain available"
1811 );
1812 }
1813 }
1814
1815 #[test]
1816 fn openrouter_mistral_routes_use_native_tools() {
1817 reset();
1818 let caps = lookup("openrouter", "mistralai/mistral-small-2603");
1819 assert!(caps.native_tools);
1820 assert!(caps.text_tool_wire_format_supported);
1821 assert_eq!(caps.structured_output.as_deref(), Some("native"));
1822 assert_eq!(caps.structured_output_mode, "native_json");
1823 }
1824
1825 #[test]
1826 fn dashscope_and_llamacpp_resolve_capabilities() {
1827 reset();
1828 let caps = lookup("dashscope", "gpt-5.4-preview");
1831 assert!(caps.defer_loading);
1832 let caps = lookup("llamacpp", "gpt-5.4-preview");
1833 assert!(caps.defer_loading);
1834 }
1835
1836 #[test]
1837 fn unknown_provider_has_no_capabilities() {
1838 reset();
1839 let caps = lookup("my-custom-proxy", "foo-bar-1");
1840 assert!(!caps.native_tools);
1841 assert!(!caps.defer_loading);
1842 assert!(caps.tool_search.is_empty());
1843 }
1844
1845 #[test]
1846 fn enterprise_routes_expose_format_preferences() {
1847 reset();
1848 let bedrock_claude = lookup("bedrock", "anthropic.claude-opus-4-7-v1:0");
1849 assert!(bedrock_claude.prefers_xml_scaffolding);
1850 assert_eq!(bedrock_claude.structured_output_mode, "xml_tagged");
1851 assert!(!bedrock_claude.supports_assistant_prefill);
1852 assert!(bedrock_claude.prefers_xml_tools);
1853
1854 let azure_o = lookup("azure_openai", "o3-prod");
1855 assert!(azure_o.prefers_markdown_scaffolding);
1856 assert_eq!(azure_o.structured_output_mode, "native_json");
1857 assert!(azure_o.prefers_role_developer);
1858 assert_eq!(azure_o.thinking_block_style, "reasoning_summary");
1859 }
1860
1861 #[test]
1862 fn user_override_adds_new_provider() {
1863 reset();
1864 let toml_src = concat!(
1865 "[[provider.my-proxy]]\n",
1866 "model_match = \"*\"\n",
1867 "native_tools = true\n",
1868 "tool_search = [\"hosted\"]\n",
1869 "prefers_xml_scaffolding = true\n",
1870 "structured_output_mode = \"xml_tagged\"\n",
1871 "supports_assistant_prefill = true\n",
1872 "prefers_xml_tools = true\n",
1873 "thinking_block_style = \"thinking_blocks\"\n",
1874 );
1875 set_user_overrides_toml(toml_src).unwrap();
1876 let caps = lookup("my-proxy", "anything");
1877 assert!(caps.native_tools);
1878 assert_eq!(caps.tool_search, vec!["hosted"]);
1879 assert!(caps.prefers_xml_scaffolding);
1880 assert_eq!(caps.structured_output_mode, "xml_tagged");
1881 assert!(caps.supports_assistant_prefill);
1882 assert!(caps.prefers_xml_tools);
1883 assert_eq!(caps.thinking_block_style, "thinking_blocks");
1884 clear_user_overrides();
1885 }
1886
1887 #[test]
1888 fn user_override_takes_precedence_over_builtin() {
1889 reset();
1890 let toml_src = r#"
1891[[provider.anthropic]]
1892model_match = "claude-opus-*"
1893native_tools = true
1894defer_loading = false
1895tool_search = []
1896"#;
1897 set_user_overrides_toml(toml_src).unwrap();
1898 let caps = lookup("anthropic", "claude-opus-4-7");
1899 assert!(caps.native_tools);
1900 assert!(!caps.defer_loading);
1901 assert!(caps.tool_search.is_empty());
1902 clear_user_overrides();
1903 }
1904
1905 #[test]
1906 fn user_override_from_manifest_toml() {
1907 reset();
1908 let manifest = concat!(
1909 "[package]\n",
1910 "name = \"demo\"\n\n",
1911 "[[capabilities.provider.my-proxy]]\n",
1912 "model_match = \"*\"\n",
1913 "native_tools = true\n",
1914 "tool_search = [\"hosted\"]\n",
1915 "prefers_markdown_scaffolding = true\n",
1916 "structured_output_mode = \"native_json\"\n",
1917 "prefers_role_developer = true\n",
1918 "thinking_block_style = \"reasoning_summary\"\n",
1919 );
1920 set_user_overrides_from_manifest_toml(manifest).unwrap();
1921 let caps = lookup("my-proxy", "foo");
1922 assert!(caps.native_tools);
1923 assert_eq!(caps.tool_search, vec!["hosted"]);
1924 assert!(caps.prefers_markdown_scaffolding);
1925 assert_eq!(caps.structured_output_mode, "native_json");
1926 assert!(caps.prefers_role_developer);
1927 assert_eq!(caps.thinking_block_style, "reasoning_summary");
1928 clear_user_overrides();
1929 }
1930
1931 #[test]
1932 fn version_min_requires_parseable_model() {
1933 reset();
1934 let toml_src = r#"
1935[[provider.custom]]
1936model_match = "*"
1937version_min = [5, 4]
1938native_tools = true
1939"#;
1940 set_user_overrides_toml(toml_src).unwrap();
1941 let caps = lookup("custom", "mystery-model");
1943 assert!(!caps.native_tools);
1944 clear_user_overrides();
1945 }
1946
1947 #[test]
1948 fn glob_match_substring() {
1949 assert!(glob_match("*gpt*", "openai/gpt-5.4"));
1950 assert!(glob_match("*claude*", "anthropic/claude-opus-4-7"));
1951 assert!(!glob_match("*xyz*", "openai/gpt-5.4"));
1952 }
1953
1954 #[test]
1955 fn openrouter_namespaced_anthropic_model() {
1956 reset();
1957 let caps = lookup("anthropic", "anthropic/claude-opus-4-7");
1958 assert!(caps.defer_loading);
1959 }
1960
1961 #[test]
1962 fn matrix_rows_include_provider_patterns_and_sources() {
1963 reset();
1964 let rows = matrix_rows();
1965 assert!(rows.iter().any(|row| {
1966 row.provider == "openai"
1967 && row.model == "gpt-4o*"
1968 && row.vision
1969 && row.audio
1970 && row.json_schema.as_deref() == Some("native")
1971 && row.source == "builtin"
1972 }));
1973 }
1974}