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