1use std::cell::RefCell;
22use std::collections::BTreeMap;
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");
32
33#[derive(Debug, Clone, Deserialize, Default)]
36pub struct CapabilitiesFile {
37 #[serde(default)]
39 pub provider: BTreeMap<String, Vec<ProviderRule>>,
40 #[serde(default)]
45 pub provider_defaults: BTreeMap<String, ProviderDefaults>,
46 #[serde(default)]
49 pub provider_family: BTreeMap<String, String>,
50}
51
52#[derive(Debug, Clone, Deserialize, Default)]
54pub struct ProviderDefaults {
55 #[serde(default)]
58 pub message_wire_format: Option<String>,
59 #[serde(default)]
62 pub native_tool_wire_format: Option<String>,
63 #[serde(default)]
65 pub image_url_input_supported: Option<bool>,
66 #[serde(default)]
69 pub file_upload_wire_format: Option<String>,
70 #[serde(default)]
73 pub reasoning_wire_format: Option<String>,
74 #[serde(default)]
75 pub files_api_supported: Option<bool>,
76 #[serde(default)]
77 pub seed_supported: Option<bool>,
78 #[serde(default)]
79 pub top_k_supported: Option<bool>,
80 #[serde(default)]
81 pub frequency_penalty_supported: Option<bool>,
82 #[serde(default)]
83 pub presence_penalty_supported: Option<bool>,
84}
85
86impl ProviderDefaults {
87 fn overlay(&mut self, other: &ProviderDefaults) {
88 if other.message_wire_format.is_some() {
89 self.message_wire_format = other.message_wire_format.clone();
90 }
91 if other.native_tool_wire_format.is_some() {
92 self.native_tool_wire_format = other.native_tool_wire_format.clone();
93 }
94 if other.image_url_input_supported.is_some() {
95 self.image_url_input_supported = other.image_url_input_supported;
96 }
97 if other.file_upload_wire_format.is_some() {
98 self.file_upload_wire_format = other.file_upload_wire_format.clone();
99 }
100 if other.reasoning_wire_format.is_some() {
101 self.reasoning_wire_format = other.reasoning_wire_format.clone();
102 }
103 if other.files_api_supported.is_some() {
104 self.files_api_supported = other.files_api_supported;
105 }
106 if other.seed_supported.is_some() {
107 self.seed_supported = other.seed_supported;
108 }
109 if other.top_k_supported.is_some() {
110 self.top_k_supported = other.top_k_supported;
111 }
112 if other.frequency_penalty_supported.is_some() {
113 self.frequency_penalty_supported = other.frequency_penalty_supported;
114 }
115 if other.presence_penalty_supported.is_some() {
116 self.presence_penalty_supported = other.presence_penalty_supported;
117 }
118 }
119
120 fn fill_missing_from(&mut self, other: &ProviderDefaults) {
121 if self.message_wire_format.is_none() {
122 self.message_wire_format = other.message_wire_format.clone();
123 }
124 if self.native_tool_wire_format.is_none() {
125 self.native_tool_wire_format = other.native_tool_wire_format.clone();
126 }
127 if self.image_url_input_supported.is_none() {
128 self.image_url_input_supported = other.image_url_input_supported;
129 }
130 if self.file_upload_wire_format.is_none() {
131 self.file_upload_wire_format = other.file_upload_wire_format.clone();
132 }
133 if self.reasoning_wire_format.is_none() {
134 self.reasoning_wire_format = other.reasoning_wire_format.clone();
135 }
136 if self.files_api_supported.is_none() {
137 self.files_api_supported = other.files_api_supported;
138 }
139 if self.seed_supported.is_none() {
140 self.seed_supported = other.seed_supported;
141 }
142 if self.top_k_supported.is_none() {
143 self.top_k_supported = other.top_k_supported;
144 }
145 if self.frequency_penalty_supported.is_none() {
146 self.frequency_penalty_supported = other.frequency_penalty_supported;
147 }
148 if self.presence_penalty_supported.is_none() {
149 self.presence_penalty_supported = other.presence_penalty_supported;
150 }
151 }
152
153 fn has_any_field(&self) -> bool {
154 self.message_wire_format.is_some()
155 || self.native_tool_wire_format.is_some()
156 || self.image_url_input_supported.is_some()
157 || self.file_upload_wire_format.is_some()
158 || self.reasoning_wire_format.is_some()
159 || self.files_api_supported.is_some()
160 || self.seed_supported.is_some()
161 || self.top_k_supported.is_some()
162 || self.frequency_penalty_supported.is_some()
163 || self.presence_penalty_supported.is_some()
164 }
165}
166
167#[derive(Debug, Clone, Deserialize)]
169pub struct ProviderRule {
170 pub model_match: String,
173 #[serde(default)]
178 pub version_min: Option<Vec<u32>>,
179 #[serde(default)]
180 pub native_tools: Option<bool>,
181 #[serde(default)]
184 pub message_wire_format: Option<String>,
185 #[serde(default)]
188 pub native_tool_wire_format: Option<String>,
189 #[serde(default)]
190 pub defer_loading: Option<bool>,
191 #[serde(default)]
192 pub tool_search: Option<Vec<String>>,
193 #[serde(default)]
194 pub max_tools: Option<u32>,
195 #[serde(default)]
196 pub prompt_caching: Option<bool>,
197 #[serde(default)]
200 pub vision: Option<bool>,
201 #[serde(default, alias = "audio_supported")]
204 pub audio: Option<bool>,
205 #[serde(default, alias = "pdf_supported")]
208 pub pdf: Option<bool>,
209 #[serde(default)]
211 pub files_api_supported: Option<bool>,
212 #[serde(default)]
215 pub file_upload_wire_format: Option<String>,
216 #[serde(default)]
219 pub structured_output: Option<String>,
220 #[serde(default)]
223 pub json_schema: Option<String>,
224 #[serde(default)]
227 pub prefers_xml_scaffolding: Option<bool>,
228 #[serde(default)]
231 pub prefers_markdown_scaffolding: Option<bool>,
232 #[serde(default)]
236 pub structured_output_mode: Option<String>,
237 #[serde(default)]
239 pub supports_assistant_prefill: Option<bool>,
240 #[serde(default)]
243 pub prefers_role_developer: Option<bool>,
244 #[serde(default)]
247 pub prefers_xml_tools: Option<bool>,
248 #[serde(default)]
252 pub thinking_block_style: Option<String>,
253 #[serde(default)]
256 pub thinking_modes: Option<Vec<String>>,
257 #[serde(default)]
260 pub interleaved_thinking_supported: Option<bool>,
261 #[serde(default)]
263 pub anthropic_beta_features: Option<Vec<String>>,
264 #[serde(default)]
267 pub thinking: Option<bool>,
268 #[serde(default)]
270 pub vision_supported: Option<bool>,
271 #[serde(default)]
273 pub image_url_input_supported: Option<bool>,
274 #[serde(default)]
281 pub preserve_thinking: Option<bool>,
282 #[serde(default)]
286 pub server_parser: Option<String>,
287 #[serde(default)]
290 pub honors_chat_template_kwargs: Option<bool>,
291 #[serde(default)]
294 pub requires_completion_tokens: Option<bool>,
295 #[serde(default)]
297 pub reasoning_effort_supported: Option<bool>,
298 #[serde(default)]
302 pub reasoning_none_supported: Option<bool>,
303 #[serde(default)]
306 pub reasoning_wire_format: Option<String>,
307 #[serde(default)]
308 pub seed_supported: Option<bool>,
309 #[serde(default)]
310 pub top_k_supported: Option<bool>,
311 #[serde(default)]
312 pub frequency_penalty_supported: Option<bool>,
313 #[serde(default)]
314 pub presence_penalty_supported: Option<bool>,
315 #[serde(default)]
319 pub recommended_endpoint: Option<String>,
320 #[serde(default)]
323 pub text_tool_wire_format_supported: Option<bool>,
324 #[serde(default)]
331 pub thinking_disable_directive: Option<String>,
332}
333
334#[derive(Debug, Clone, PartialEq, Eq)]
338pub struct Capabilities {
339 pub native_tools: bool,
340 pub message_wire_format: String,
341 pub native_tool_wire_format: String,
342 pub defer_loading: bool,
343 pub tool_search: Vec<String>,
344 pub max_tools: Option<u32>,
345 pub prompt_caching: bool,
346 pub vision: bool,
347 pub audio: bool,
348 pub pdf: bool,
349 pub files_api_supported: bool,
350 pub file_upload_wire_format: Option<String>,
351 pub structured_output: Option<String>,
352 pub json_schema: Option<String>,
354 pub prefers_xml_scaffolding: bool,
355 pub prefers_markdown_scaffolding: bool,
356 pub structured_output_mode: String,
357 pub supports_assistant_prefill: bool,
358 pub prefers_role_developer: bool,
359 pub prefers_xml_tools: bool,
360 pub thinking_block_style: String,
361 pub thinking_modes: Vec<String>,
362 pub interleaved_thinking_supported: bool,
363 pub anthropic_beta_features: Vec<String>,
364 pub vision_supported: bool,
365 pub image_url_input_supported: bool,
366 pub preserve_thinking: bool,
367 pub server_parser: String,
368 pub honors_chat_template_kwargs: bool,
369 pub requires_completion_tokens: bool,
370 pub reasoning_effort_supported: bool,
371 pub reasoning_none_supported: bool,
372 pub reasoning_wire_format: Option<String>,
373 pub seed_supported: bool,
374 pub top_k_supported: bool,
375 pub frequency_penalty_supported: bool,
376 pub presence_penalty_supported: bool,
377 pub recommended_endpoint: Option<String>,
378 pub text_tool_wire_format_supported: bool,
379 pub thinking_disable_directive: Option<String>,
380}
381
382impl Default for Capabilities {
383 fn default() -> Self {
384 Self {
385 native_tools: false,
386 message_wire_format: "openai".to_string(),
387 native_tool_wire_format: "openai".to_string(),
388 defer_loading: false,
389 tool_search: Vec::new(),
390 max_tools: None,
391 prompt_caching: false,
392 vision: false,
393 audio: false,
394 pdf: false,
395 files_api_supported: false,
396 file_upload_wire_format: None,
397 structured_output: None,
398 json_schema: None,
399 prefers_xml_scaffolding: false,
400 prefers_markdown_scaffolding: false,
401 structured_output_mode: "none".to_string(),
402 supports_assistant_prefill: false,
403 prefers_role_developer: false,
404 prefers_xml_tools: false,
405 thinking_block_style: "none".to_string(),
406 thinking_modes: Vec::new(),
407 interleaved_thinking_supported: false,
408 anthropic_beta_features: Vec::new(),
409 vision_supported: false,
410 image_url_input_supported: true,
411 preserve_thinking: false,
412 server_parser: "none".to_string(),
413 honors_chat_template_kwargs: false,
414 requires_completion_tokens: false,
415 reasoning_effort_supported: false,
416 reasoning_none_supported: false,
417 reasoning_wire_format: None,
418 seed_supported: true,
419 top_k_supported: true,
420 frequency_penalty_supported: true,
421 presence_penalty_supported: true,
422 recommended_endpoint: None,
423 text_tool_wire_format_supported: true,
424 thinking_disable_directive: None,
425 }
426 }
427}
428
429#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
434pub struct ProviderCapabilityMatrixRow {
435 pub provider: String,
436 pub model: String,
437 pub version_min: Option<Vec<u32>>,
438 pub thinking: Vec<String>,
439 pub vision: bool,
440 pub audio: bool,
441 pub pdf: bool,
442 pub streaming: bool,
443 pub files_api_supported: bool,
444 pub json_schema: Option<String>,
445 pub prefers_xml_scaffolding: bool,
446 pub prefers_markdown_scaffolding: bool,
447 pub structured_output_mode: String,
448 pub supports_assistant_prefill: bool,
449 pub prefers_role_developer: bool,
450 pub prefers_xml_tools: bool,
451 pub thinking_block_style: String,
452 pub tools: bool,
453 pub cache: bool,
454 pub source: String,
455}
456
457thread_local! {
458 static USER_OVERRIDES: RefCell<Option<CapabilitiesFile>> = const { RefCell::new(None) };
463}
464
465static BUILTIN: OnceLock<CapabilitiesFile> = OnceLock::new();
469
470fn builtin() -> &'static CapabilitiesFile {
471 BUILTIN.get_or_init(|| {
472 toml::from_str::<CapabilitiesFile>(BUILTIN_TOML)
473 .expect("capabilities.toml must parse at build time")
474 })
475}
476
477pub fn set_user_overrides(file: Option<CapabilitiesFile>) {
481 USER_OVERRIDES.with(|cell| *cell.borrow_mut() = file);
482}
483
484pub fn clear_user_overrides() {
486 set_user_overrides(None);
487}
488
489pub fn set_user_overrides_toml(src: &str) -> Result<(), String> {
494 let parsed: CapabilitiesFile = toml::from_str(src).map_err(|e| e.to_string())?;
495 set_user_overrides(Some(parsed));
496 Ok(())
497}
498
499pub fn set_user_overrides_from_manifest_toml(src: &str) -> Result<(), String> {
511 #[derive(Deserialize)]
512 struct Manifest {
513 #[serde(default)]
514 capabilities: Option<CapabilitiesFile>,
515 }
516 let parsed: Manifest = toml::from_str(src).map_err(|e| e.to_string())?;
517 set_user_overrides(parsed.capabilities);
518 Ok(())
519}
520
521pub fn lookup(provider: &str, model: &str) -> Capabilities {
527 let user = USER_OVERRIDES.with(|cell| cell.borrow().clone());
528 lookup_with(provider, model, builtin(), user.as_ref())
529}
530
531pub fn matrix_rows() -> Vec<ProviderCapabilityMatrixRow> {
535 let user = USER_OVERRIDES.with(|cell| cell.borrow().clone());
536 let mut rows = Vec::new();
537 if let Some(user) = user.as_ref() {
538 push_matrix_rows(&mut rows, user, "project");
539 }
540 push_matrix_rows(&mut rows, builtin(), "builtin");
541 rows
542}
543
544fn push_matrix_rows(
545 rows: &mut Vec<ProviderCapabilityMatrixRow>,
546 file: &CapabilitiesFile,
547 source: &str,
548) {
549 for (provider, rules) in &file.provider {
550 for rule in rules {
551 rows.push(rule_to_matrix_row(provider, rule, source));
552 }
553 }
554}
555
556fn rule_to_matrix_row(
557 provider: &str,
558 rule: &ProviderRule,
559 source: &str,
560) -> ProviderCapabilityMatrixRow {
561 ProviderCapabilityMatrixRow {
562 provider: provider.to_string(),
563 model: rule.model_match.clone(),
564 version_min: rule.version_min.clone(),
565 thinking: rule_thinking_modes(rule),
566 vision: rule_vision(rule),
567 audio: rule.audio.unwrap_or(false),
568 pdf: rule.pdf.unwrap_or(false),
569 streaming: true,
570 files_api_supported: rule.files_api_supported.unwrap_or(false),
571 json_schema: rule_structured_output(rule),
572 prefers_xml_scaffolding: rule.prefers_xml_scaffolding.unwrap_or(false),
573 prefers_markdown_scaffolding: rule.prefers_markdown_scaffolding.unwrap_or(false),
574 structured_output_mode: rule_structured_output_mode(rule),
575 supports_assistant_prefill: rule.supports_assistant_prefill.unwrap_or(false),
576 prefers_role_developer: rule
577 .prefers_role_developer
578 .unwrap_or_else(|| rule.requires_completion_tokens.unwrap_or(false)),
579 prefers_xml_tools: rule.prefers_xml_tools.unwrap_or(false),
580 thinking_block_style: rule_thinking_block_style(rule),
581 tools: rule.native_tools.unwrap_or(false),
582 cache: rule.prompt_caching.unwrap_or(false),
583 source: source.to_string(),
584 }
585}
586
587fn rule_thinking_modes(rule: &ProviderRule) -> Vec<String> {
588 rule.thinking_modes.clone().unwrap_or_else(|| {
589 if rule.thinking.unwrap_or(false) {
590 vec!["enabled".to_string()]
591 } else {
592 Vec::new()
593 }
594 })
595}
596
597fn rule_vision(rule: &ProviderRule) -> bool {
598 rule.vision.or(rule.vision_supported).unwrap_or(false)
599}
600
601fn lookup_with(
602 provider: &str,
603 model: &str,
604 builtin: &CapabilitiesFile,
605 user: Option<&CapabilitiesFile>,
606) -> Capabilities {
607 if provider == "mock" {
618 let anthropic_defaults = merged_provider_defaults(user, builtin, "anthropic");
619 if let Some(mut caps) =
620 try_match_layer(user, builtin, "anthropic", model, &anthropic_defaults)
621 {
622 caps.native_tool_wire_format = "openai".to_string();
623 return caps;
624 }
625 let openai_defaults = merged_provider_defaults(user, builtin, "openai");
626 if let Some(caps) = try_match_layer(user, builtin, "openai", model, &openai_defaults) {
627 return caps;
628 }
629 return Capabilities::default();
630 }
631
632 let mut current = provider.to_string();
635 let mut effective_defaults = ProviderDefaults::default();
636 let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
637 while visited.insert(current.clone()) {
638 let layer_defaults = merged_provider_defaults(user, builtin, ¤t);
639 if effective_defaults.has_any_field() {
640 effective_defaults.fill_missing_from(&layer_defaults);
641 } else {
642 effective_defaults.overlay(&layer_defaults);
643 }
644 if let Some(caps) = try_match_layer(user, builtin, ¤t, model, &effective_defaults) {
645 return caps;
646 }
647 let next = user
648 .and_then(|f| f.provider_family.get(¤t))
649 .or_else(|| builtin.provider_family.get(¤t))
650 .cloned();
651 match next {
652 Some(parent) => current = parent,
653 None => break,
654 }
655 }
656 if effective_defaults.has_any_field() {
657 return defaults_to_caps(&effective_defaults);
658 }
659 Capabilities::default()
660}
661
662fn try_match_layer(
666 user: Option<&CapabilitiesFile>,
667 builtin: &CapabilitiesFile,
668 layer_provider: &str,
669 model: &str,
670 defaults: &ProviderDefaults,
671) -> Option<Capabilities> {
672 if let Some(user) = user {
673 if let Some(rules) = user.provider.get(layer_provider) {
674 for rule in rules {
675 if rule_matches(rule, model) {
676 return Some(rule_to_caps(rule, defaults));
677 }
678 }
679 }
680 }
681 if let Some(rules) = builtin.provider.get(layer_provider) {
682 for rule in rules {
683 if rule_matches(rule, model) {
684 return Some(rule_to_caps(rule, defaults));
685 }
686 }
687 }
688 None
689}
690
691fn merged_provider_defaults(
692 user: Option<&CapabilitiesFile>,
693 builtin: &CapabilitiesFile,
694 provider: &str,
695) -> ProviderDefaults {
696 let mut defaults = builtin
697 .provider_defaults
698 .get(provider)
699 .cloned()
700 .unwrap_or_default();
701 if let Some(user_defaults) = user.and_then(|file| file.provider_defaults.get(provider)) {
702 defaults.overlay(user_defaults);
703 }
704 defaults
705}
706
707fn defaults_to_caps(defaults: &ProviderDefaults) -> Capabilities {
708 let empty = ProviderRule {
709 model_match: "*".to_string(),
710 version_min: None,
711 native_tools: None,
712 message_wire_format: None,
713 native_tool_wire_format: None,
714 defer_loading: None,
715 tool_search: None,
716 max_tools: None,
717 prompt_caching: None,
718 vision: None,
719 audio: None,
720 pdf: None,
721 files_api_supported: None,
722 file_upload_wire_format: None,
723 structured_output: None,
724 prefers_xml_scaffolding: None,
725 prefers_markdown_scaffolding: None,
726 structured_output_mode: None,
727 supports_assistant_prefill: None,
728 prefers_role_developer: None,
729 prefers_xml_tools: None,
730 thinking_block_style: None,
731 json_schema: None,
732 thinking_modes: None,
733 interleaved_thinking_supported: None,
734 anthropic_beta_features: None,
735 thinking: None,
736 vision_supported: None,
737 image_url_input_supported: None,
738 preserve_thinking: None,
739 server_parser: None,
740 honors_chat_template_kwargs: None,
741 requires_completion_tokens: None,
742 reasoning_effort_supported: None,
743 reasoning_none_supported: None,
744 reasoning_wire_format: None,
745 seed_supported: None,
746 top_k_supported: None,
747 frequency_penalty_supported: None,
748 presence_penalty_supported: None,
749 recommended_endpoint: None,
750 text_tool_wire_format_supported: None,
751 thinking_disable_directive: None,
752 };
753 rule_to_caps(&empty, defaults)
754}
755
756fn rule_to_caps(rule: &ProviderRule, defaults: &ProviderDefaults) -> Capabilities {
757 let thinking_modes = rule_thinking_modes(rule);
758 Capabilities {
759 native_tools: rule.native_tools.unwrap_or(false),
760 message_wire_format: rule
761 .message_wire_format
762 .clone()
763 .or_else(|| defaults.message_wire_format.clone())
764 .unwrap_or_else(|| "openai".to_string()),
765 native_tool_wire_format: rule
766 .native_tool_wire_format
767 .clone()
768 .or_else(|| defaults.native_tool_wire_format.clone())
769 .unwrap_or_else(|| "openai".to_string()),
770 defer_loading: rule.defer_loading.unwrap_or(false),
771 tool_search: rule.tool_search.clone().unwrap_or_default(),
772 max_tools: rule.max_tools,
773 prompt_caching: rule.prompt_caching.unwrap_or(false),
774 vision: rule_vision(rule),
775 audio: rule.audio.unwrap_or(false),
776 pdf: rule.pdf.unwrap_or(false),
777 files_api_supported: rule
778 .files_api_supported
779 .or(defaults.files_api_supported)
780 .unwrap_or(false),
781 file_upload_wire_format: rule
782 .file_upload_wire_format
783 .clone()
784 .or_else(|| defaults.file_upload_wire_format.clone()),
785 structured_output: rule_structured_output(rule),
786 json_schema: rule_structured_output(rule),
787 prefers_xml_scaffolding: rule.prefers_xml_scaffolding.unwrap_or(false),
788 prefers_markdown_scaffolding: rule.prefers_markdown_scaffolding.unwrap_or(false),
789 structured_output_mode: rule_structured_output_mode(rule),
790 supports_assistant_prefill: rule.supports_assistant_prefill.unwrap_or(false),
791 prefers_role_developer: rule.prefers_role_developer.unwrap_or(false),
792 prefers_xml_tools: rule.prefers_xml_tools.unwrap_or(false),
793 thinking_block_style: rule_thinking_block_style(rule),
794 thinking_modes,
795 interleaved_thinking_supported: rule.interleaved_thinking_supported.unwrap_or(false),
796 anthropic_beta_features: rule.anthropic_beta_features.clone().unwrap_or_default(),
797 vision_supported: rule.vision_supported.unwrap_or(false),
798 image_url_input_supported: rule
799 .image_url_input_supported
800 .or(defaults.image_url_input_supported)
801 .unwrap_or(true),
802 preserve_thinking: rule.preserve_thinking.unwrap_or(false),
803 server_parser: rule
804 .server_parser
805 .clone()
806 .unwrap_or_else(|| "none".to_string()),
807 honors_chat_template_kwargs: rule.honors_chat_template_kwargs.unwrap_or(false),
808 requires_completion_tokens: rule.requires_completion_tokens.unwrap_or(false),
809 reasoning_effort_supported: rule.reasoning_effort_supported.unwrap_or(false),
810 reasoning_none_supported: rule.reasoning_none_supported.unwrap_or(false),
811 reasoning_wire_format: rule
812 .reasoning_wire_format
813 .clone()
814 .or_else(|| defaults.reasoning_wire_format.clone()),
815 seed_supported: rule
816 .seed_supported
817 .or(defaults.seed_supported)
818 .unwrap_or(true),
819 top_k_supported: rule
820 .top_k_supported
821 .or(defaults.top_k_supported)
822 .unwrap_or(true),
823 frequency_penalty_supported: rule
824 .frequency_penalty_supported
825 .or(defaults.frequency_penalty_supported)
826 .unwrap_or(true),
827 presence_penalty_supported: rule
828 .presence_penalty_supported
829 .or(defaults.presence_penalty_supported)
830 .unwrap_or(true),
831 recommended_endpoint: rule.recommended_endpoint.clone(),
832 text_tool_wire_format_supported: rule.text_tool_wire_format_supported.unwrap_or(true),
833 thinking_disable_directive: rule.thinking_disable_directive.clone(),
834 }
835}
836
837fn rule_structured_output(rule: &ProviderRule) -> Option<String> {
838 rule.structured_output
839 .clone()
840 .or_else(|| rule.json_schema.clone())
841 .filter(|value| value != "none")
842}
843
844fn rule_structured_output_mode(rule: &ProviderRule) -> String {
845 if let Some(mode) = &rule.structured_output_mode {
846 return mode.clone();
847 }
848 match rule_structured_output(rule).as_deref() {
849 Some("native") | Some("format_kw") => "native_json".to_string(),
850 Some("tool_use") => "xml_tagged".to_string(),
851 _ => "none".to_string(),
852 }
853}
854
855fn rule_thinking_block_style(rule: &ProviderRule) -> String {
856 rule.thinking_block_style.clone().unwrap_or_else(|| {
857 if rule.reasoning_effort_supported.unwrap_or(false)
858 || rule.requires_completion_tokens.unwrap_or(false)
859 {
860 "reasoning_summary".to_string()
861 } else {
862 "none".to_string()
863 }
864 })
865}
866
867fn rule_matches(rule: &ProviderRule, model: &str) -> bool {
868 let lower = model.to_lowercase();
869 if !glob_match(&rule.model_match.to_lowercase(), &lower) {
870 return false;
871 }
872 if let Some(version_min) = &rule.version_min {
873 if version_min.len() != 2 {
874 return false;
875 }
876 let want = (version_min[0], version_min[1]);
877 let have = match extract_version(model) {
878 Some(v) => v,
879 None => return false,
883 };
884 if have < want {
885 return false;
886 }
887 }
888 true
889}
890
891fn extract_version(model: &str) -> Option<(u32, u32)> {
896 claude_generation(model).or_else(|| gpt_generation(model))
897}
898
899fn glob_match(pattern: &str, input: &str) -> bool {
903 if let Some(prefix) = pattern.strip_suffix('*') {
904 if let Some(rest) = prefix.strip_prefix('*') {
905 return input.contains(rest);
907 }
908 return input.starts_with(prefix);
909 }
910 if let Some(suffix) = pattern.strip_prefix('*') {
911 return input.ends_with(suffix);
912 }
913 if pattern.contains('*') {
914 let parts: Vec<&str> = pattern.split('*').collect();
915 if parts.len() == 2 {
916 return input.starts_with(parts[0]) && input.ends_with(parts[1]);
917 }
918 return input == pattern;
919 }
920 input == pattern
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926
927 fn reset() {
928 clear_user_overrides();
929 }
930
931 #[test]
932 fn anthropic_opus_47_gets_full_capabilities() {
933 reset();
934 let caps = lookup("anthropic", "claude-opus-4-7");
935 assert!(caps.native_tools);
936 assert!(caps.defer_loading);
937 assert_eq!(caps.tool_search, vec!["bm25", "regex"]);
938 assert!(caps.prompt_caching);
939 assert_eq!(caps.thinking_modes, vec!["adaptive"]);
940 assert!(caps.vision_supported);
941 assert!(caps.audio);
942 assert!(caps.pdf);
943 assert!(caps.files_api_supported);
944 assert_eq!(caps.max_tools, Some(10000));
945 assert!(caps.prefers_xml_scaffolding);
946 assert!(!caps.prefers_markdown_scaffolding);
947 assert_eq!(caps.structured_output_mode, "xml_tagged");
948 assert!(!caps.supports_assistant_prefill);
949 assert!(!caps.prefers_role_developer);
950 assert!(caps.prefers_xml_tools);
951 assert_eq!(caps.thinking_block_style, "thinking_blocks");
952 }
953
954 #[test]
955 fn anthropic_opus_46_uses_budgeted_thinking() {
956 reset();
957 let caps = lookup("anthropic", "claude-opus-4-6");
958 assert_eq!(caps.thinking_modes, vec!["enabled"]);
959 assert!(caps.interleaved_thinking_supported);
960 assert!(!caps.supports_assistant_prefill);
961 }
962
963 #[test]
964 fn anthropic_opus_45_does_not_support_interleaved_thinking() {
965 reset();
966 let caps = lookup("anthropic", "claude-opus-4-5");
967 assert_eq!(caps.thinking_modes, vec!["enabled"]);
968 assert!(!caps.interleaved_thinking_supported);
969 assert!(caps.supports_assistant_prefill);
970 }
971
972 #[test]
973 fn override_can_supply_anthropic_beta_features() {
974 reset();
975 let toml_src = r#"
976[[provider.anthropic]]
977model_match = "claude-custom-*"
978native_tools = true
979anthropic_beta_features = ["fine-grained-tool-streaming-2025-05-14"]
980"#;
981 set_user_overrides_toml(toml_src).unwrap();
982 let caps = lookup("anthropic", "claude-custom-1");
983 assert_eq!(
984 caps.anthropic_beta_features,
985 vec!["fine-grained-tool-streaming-2025-05-14"]
986 );
987 reset();
988 }
989
990 #[test]
991 fn anthropic_haiku_44_has_no_tool_search() {
992 reset();
993 let caps = lookup("anthropic", "claude-haiku-4-4");
994 assert!(caps.native_tools);
996 assert!(caps.prompt_caching);
997 assert!(!caps.defer_loading);
998 assert!(caps.tool_search.is_empty());
999 }
1000
1001 #[test]
1002 fn anthropic_haiku_45_supports_tool_search() {
1003 reset();
1004 let caps = lookup("anthropic", "claude-haiku-4-5");
1005 assert!(caps.defer_loading);
1006 assert_eq!(caps.tool_search, vec!["bm25", "regex"]);
1007 }
1008
1009 #[test]
1010 fn old_claude_gets_catchall() {
1011 reset();
1012 let caps = lookup("anthropic", "claude-opus-3-5");
1013 assert!(caps.native_tools);
1014 assert!(caps.prompt_caching);
1015 assert!(!caps.defer_loading);
1016 assert!(caps.tool_search.is_empty());
1017 }
1018
1019 #[test]
1020 fn openai_gpt_54_supports_tool_search() {
1021 reset();
1022 let caps = lookup("openai", "gpt-5.4");
1023 assert!(caps.defer_loading);
1024 assert_eq!(caps.tool_search, vec!["hosted", "client"]);
1025 assert_eq!(caps.json_schema.as_deref(), Some("native"));
1026 assert_eq!(caps.thinking_modes, vec!["effort"]);
1027 assert!(caps.reasoning_effort_supported);
1028 assert!(caps.reasoning_none_supported);
1029 assert!(!caps.prefers_xml_scaffolding);
1030 assert!(caps.prefers_markdown_scaffolding);
1031 assert_eq!(caps.structured_output_mode, "native_json");
1032 assert!(!caps.supports_assistant_prefill);
1033 assert!(!caps.prefers_role_developer);
1034 assert!(!caps.prefers_xml_tools);
1035 assert_eq!(caps.thinking_block_style, "reasoning_summary");
1036 }
1037
1038 #[test]
1039 fn openai_gpt_53_has_reasoning_none_without_tool_search() {
1040 reset();
1041 let caps = lookup("openai", "gpt-5.3");
1042 assert!(caps.native_tools);
1043 assert!(!caps.defer_loading);
1044 assert!(caps.vision_supported);
1045 assert!(caps.tool_search.is_empty());
1046 assert_eq!(caps.thinking_modes, vec!["effort"]);
1047 assert!(caps.reasoning_effort_supported);
1048 assert!(caps.reasoning_none_supported);
1049 }
1050
1051 #[test]
1052 fn openai_original_gpt_5_has_reasoning_floor_without_none() {
1053 reset();
1054 let caps = lookup("openai", "gpt-5");
1055 assert!(caps.native_tools);
1056 assert!(!caps.defer_loading);
1057 assert_eq!(caps.thinking_modes, vec!["effort"]);
1058 assert!(caps.reasoning_effort_supported);
1059 assert!(!caps.reasoning_none_supported);
1060 }
1061
1062 #[test]
1063 fn openai_gpt_4o_matrix_fields_include_multimodal_support() {
1064 reset();
1065 let caps = lookup("openai", "gpt-4o");
1066 assert!(caps.native_tools);
1067 assert!(caps.vision);
1068 assert!(caps.audio);
1069 assert!(!caps.pdf);
1070 assert_eq!(caps.json_schema.as_deref(), Some("native"));
1071 }
1072
1073 #[test]
1074 fn openai_reasoning_models_support_effort() {
1075 reset();
1076 let caps = lookup("openai", "o3");
1077 assert_eq!(caps.thinking_modes, vec!["effort"]);
1078 assert!(caps.requires_completion_tokens);
1079 assert!(caps.reasoning_effort_supported);
1080 assert!(caps.prefers_role_developer);
1081 assert_eq!(caps.thinking_block_style, "reasoning_summary");
1082 let prefixed = lookup("openrouter", "openai/o4-mini");
1083 assert!(prefixed.requires_completion_tokens);
1084 assert!(prefixed.reasoning_effort_supported);
1085 }
1086
1087 #[test]
1088 fn vision_capability_gates_known_multimodal_models() {
1089 reset();
1090 assert!(lookup("openai", "gpt-4o").vision_supported);
1091 assert!(lookup("openai", "gpt-5.4-preview").vision_supported);
1092 assert!(lookup("anthropic", "claude-sonnet-4-6").vision_supported);
1093 assert!(lookup("anthropic", "claude-sonnet-4-6").pdf);
1094 assert!(lookup("anthropic", "claude-sonnet-4-6").files_api_supported);
1095 assert!(lookup("openrouter", "google/gemini-2.5-flash").vision_supported);
1096 assert!(lookup("gemini", "gemini-2.5-flash").vision_supported);
1097 assert!(lookup("gemini", "gemini-2.5-flash").audio);
1098 assert!(lookup("gemini", "gemini-2.5-flash").pdf);
1099 assert_eq!(
1100 lookup("gemini", "gemini-2.5-flash").structured_output_mode,
1101 "native_json"
1102 );
1103 assert!(lookup("ollama", "llava:latest").vision_supported);
1104 assert!(lookup("ollama", "gemma4:26b").vision_supported);
1105 assert!(lookup("ollama", "gemma4-128k:latest").vision_supported);
1106 assert!(!lookup("openai", "gpt-3.5-turbo").vision_supported);
1107 assert!(!lookup("ollama", "qwen3.5:35b-a3b-coding-nvfp4").vision_supported);
1108 }
1109
1110 #[test]
1111 fn openrouter_inherits_openai() {
1112 reset();
1113 let caps = lookup("openrouter", "gpt-5.4");
1114 assert!(caps.defer_loading);
1115 assert_eq!(caps.tool_search, vec!["hosted", "client"]);
1116 assert_eq!(caps.reasoning_wire_format.as_deref(), Some("openrouter"));
1117 assert!(!caps.top_k_supported);
1118 }
1119
1120 #[test]
1121 fn bedrock_claude_uses_anthropic_wire_capabilities() {
1122 reset();
1123 let caps = lookup("bedrock", "anthropic.claude-3-5-sonnet-20240620-v1:0");
1124 assert!(caps.native_tools);
1125 assert_eq!(caps.message_wire_format, "anthropic");
1126 assert_eq!(caps.native_tool_wire_format, "anthropic");
1127 }
1128
1129 #[test]
1130 fn groq_inherits_openai_family_only() {
1131 reset();
1132 let caps = lookup("groq", "gpt-5.5-preview");
1133 assert!(caps.defer_loading);
1134 }
1135
1136 #[test]
1137 fn mock_with_claude_model_routes_to_anthropic() {
1138 reset();
1139 let caps = lookup("mock", "claude-sonnet-4-7");
1140 assert!(caps.defer_loading);
1141 assert_eq!(caps.tool_search, vec!["bm25", "regex"]);
1142 }
1143
1144 #[test]
1145 fn mock_with_gpt_model_routes_to_openai() {
1146 reset();
1147 let caps = lookup("mock", "gpt-5.4-preview");
1148 assert!(caps.defer_loading);
1149 assert_eq!(caps.tool_search, vec!["hosted", "client"]);
1150 }
1151
1152 #[test]
1153 fn qwen36_ollama_preserves_thinking() {
1154 reset();
1155 let caps = lookup("ollama", "qwen3.6:35b-a3b-coding-nvfp4");
1156 assert!(!caps.native_tools);
1157 assert_eq!(caps.json_schema.as_deref(), Some("format_kw"));
1158 assert!(!caps.thinking_modes.is_empty());
1159 assert!(
1160 caps.preserve_thinking,
1161 "Qwen3.6 should enable preserve_thinking by default for long-horizon loops"
1162 );
1163 assert_eq!(caps.server_parser, "none");
1164 assert!(!caps.honors_chat_template_kwargs);
1165 assert_eq!(caps.recommended_endpoint.as_deref(), Some("/api/chat"));
1166 assert!(caps.text_tool_wire_format_supported);
1167 assert!(caps.prefers_markdown_scaffolding);
1168 assert_eq!(caps.structured_output_mode, "delimited");
1169 assert!(!caps.prefers_xml_tools);
1170 assert_eq!(caps.thinking_block_style, "inline");
1171 }
1172
1173 #[test]
1174 fn qwen35_ollama_does_not_preserve_thinking() {
1175 reset();
1176 let caps = lookup("ollama", "qwen3.5:35b-a3b-coding-nvfp4");
1177 assert!(caps.native_tools);
1178 assert!(!caps.thinking_modes.is_empty());
1179 assert!(
1180 !caps.preserve_thinking,
1181 "Qwen3.5 lacks the preserve_thinking kwarg — rely on the chat template's rolling checkpoint instead"
1182 );
1183 assert_eq!(caps.server_parser, "ollama_qwen3coder");
1184 assert!(!caps.text_tool_wire_format_supported);
1185 }
1186
1187 #[test]
1188 fn qwen36_routed_providers_all_preserve_thinking() {
1189 reset();
1190 for (provider, model) in [
1191 ("openrouter", "qwen/qwen3.6-plus"),
1192 ("together", "Qwen/Qwen3.6-Plus"),
1193 ("huggingface", "Qwen/Qwen3.6-35B-A3B"),
1194 ("fireworks", "accounts/fireworks/models/qwen3p6-plus"),
1195 ("dashscope", "qwen3.6-plus"),
1196 ("local", "Qwen3.6-35B-A3B"),
1197 ("mlx", "unsloth/Qwen3.6-27B-UD-MLX-4bit"),
1198 ("mlx", "Qwen/Qwen3.6-27B"),
1199 ] {
1200 let caps = lookup(provider, model);
1201 assert!(
1202 !caps.thinking_modes.is_empty(),
1203 "{provider}/{model}: thinking"
1204 );
1205 assert!(
1206 caps.preserve_thinking,
1207 "{provider}/{model}: preserve_thinking must be on for Qwen3.6"
1208 );
1209 assert!(caps.native_tools, "{provider}/{model}: native_tools");
1210 assert_ne!(
1211 caps.server_parser, "ollama_qwen3coder",
1212 "{provider}/{model}: only Ollama routes through the qwen3coder response parser"
1213 );
1214 }
1215
1216 let caps = lookup("llamacpp", "unsloth/Qwen3.6-35B-A3B-GGUF");
1217 assert!(!caps.thinking_modes.is_empty());
1218 assert!(caps.preserve_thinking);
1219 assert!(!caps.native_tools);
1220 assert!(caps.text_tool_wire_format_supported);
1221 assert_eq!(caps.server_parser, "none");
1222 }
1223
1224 #[test]
1225 fn qwen_coder_models_do_not_claim_thinking_modes() {
1226 reset();
1227 for (provider, model) in [
1228 ("together", "Qwen/Qwen3-Coder-Next-FP8"),
1229 ("together", "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8"),
1230 ("openrouter", "qwen/qwen3-coder-next"),
1231 ("huggingface", "Qwen/Qwen3-Coder-Next"),
1232 ] {
1233 let caps = lookup(provider, model);
1234 assert!(caps.native_tools, "{provider}/{model}: native_tools");
1235 assert!(
1236 caps.thinking_modes.is_empty(),
1237 "{provider}/{model}: coder models are non-thinking routes"
1238 );
1239 assert!(
1240 !caps.preserve_thinking,
1241 "{provider}/{model}: preserve_thinking must stay off"
1242 );
1243 assert!(
1244 caps.thinking_disable_directive.is_none(),
1245 "{provider}/{model}: no /no_think shim should be needed"
1246 );
1247 }
1248 }
1249
1250 #[test]
1251 fn llamacpp_qwen_keeps_text_tool_wire_format() {
1252 reset();
1253 let caps = lookup("llamacpp", "unsloth/Qwen3.5-Coder-GGUF");
1254 assert_eq!(caps.server_parser, "none");
1255 assert!(caps.honors_chat_template_kwargs);
1256 assert!(!caps.native_tools);
1257 assert!(caps.text_tool_wire_format_supported);
1258 assert_eq!(
1259 caps.recommended_endpoint.as_deref(),
1260 Some("/v1/chat/completions")
1261 );
1262 }
1263
1264 #[test]
1265 fn devstral_local_routes_default_to_text_tools() {
1266 reset();
1267 for provider in ["ollama", "llamacpp"] {
1268 let caps = lookup(provider, "devstral-small-2:24b");
1269 assert!(!caps.native_tools, "{provider}: native tools stay opt-in");
1270 assert!(
1271 caps.text_tool_wire_format_supported,
1272 "{provider}: text tools should remain available"
1273 );
1274 }
1275 }
1276
1277 #[test]
1278 fn dashscope_and_llamacpp_resolve_capabilities() {
1279 reset();
1280 let caps = lookup("dashscope", "gpt-5.4-preview");
1283 assert!(caps.defer_loading);
1284 let caps = lookup("llamacpp", "gpt-5.4-preview");
1285 assert!(caps.defer_loading);
1286 }
1287
1288 #[test]
1289 fn unknown_provider_has_no_capabilities() {
1290 reset();
1291 let caps = lookup("my-custom-proxy", "foo-bar-1");
1292 assert!(!caps.native_tools);
1293 assert!(!caps.defer_loading);
1294 assert!(caps.tool_search.is_empty());
1295 }
1296
1297 #[test]
1298 fn enterprise_routes_expose_format_preferences() {
1299 reset();
1300 let bedrock_claude = lookup("bedrock", "anthropic.claude-opus-4-7-v1:0");
1301 assert!(bedrock_claude.prefers_xml_scaffolding);
1302 assert_eq!(bedrock_claude.structured_output_mode, "xml_tagged");
1303 assert!(!bedrock_claude.supports_assistant_prefill);
1304 assert!(bedrock_claude.prefers_xml_tools);
1305
1306 let azure_o = lookup("azure_openai", "o3-prod");
1307 assert!(azure_o.prefers_markdown_scaffolding);
1308 assert_eq!(azure_o.structured_output_mode, "native_json");
1309 assert!(azure_o.prefers_role_developer);
1310 assert_eq!(azure_o.thinking_block_style, "reasoning_summary");
1311 }
1312
1313 #[test]
1314 fn user_override_adds_new_provider() {
1315 reset();
1316 let toml_src = concat!(
1317 "[[provider.my-proxy]]\n",
1318 "model_match = \"*\"\n",
1319 "native_tools = true\n",
1320 "tool_search = [\"hosted\"]\n",
1321 "prefers_xml_scaffolding = true\n",
1322 "structured_output_mode = \"xml_tagged\"\n",
1323 "supports_assistant_prefill = true\n",
1324 "prefers_xml_tools = true\n",
1325 "thinking_block_style = \"thinking_blocks\"\n",
1326 );
1327 set_user_overrides_toml(toml_src).unwrap();
1328 let caps = lookup("my-proxy", "anything");
1329 assert!(caps.native_tools);
1330 assert_eq!(caps.tool_search, vec!["hosted"]);
1331 assert!(caps.prefers_xml_scaffolding);
1332 assert_eq!(caps.structured_output_mode, "xml_tagged");
1333 assert!(caps.supports_assistant_prefill);
1334 assert!(caps.prefers_xml_tools);
1335 assert_eq!(caps.thinking_block_style, "thinking_blocks");
1336 clear_user_overrides();
1337 }
1338
1339 #[test]
1340 fn user_override_takes_precedence_over_builtin() {
1341 reset();
1342 let toml_src = r#"
1343[[provider.anthropic]]
1344model_match = "claude-opus-*"
1345native_tools = true
1346defer_loading = false
1347tool_search = []
1348"#;
1349 set_user_overrides_toml(toml_src).unwrap();
1350 let caps = lookup("anthropic", "claude-opus-4-7");
1351 assert!(caps.native_tools);
1352 assert!(!caps.defer_loading);
1353 assert!(caps.tool_search.is_empty());
1354 clear_user_overrides();
1355 }
1356
1357 #[test]
1358 fn user_override_from_manifest_toml() {
1359 reset();
1360 let manifest = concat!(
1361 "[package]\n",
1362 "name = \"demo\"\n\n",
1363 "[[capabilities.provider.my-proxy]]\n",
1364 "model_match = \"*\"\n",
1365 "native_tools = true\n",
1366 "tool_search = [\"hosted\"]\n",
1367 "prefers_markdown_scaffolding = true\n",
1368 "structured_output_mode = \"native_json\"\n",
1369 "prefers_role_developer = true\n",
1370 "thinking_block_style = \"reasoning_summary\"\n",
1371 );
1372 set_user_overrides_from_manifest_toml(manifest).unwrap();
1373 let caps = lookup("my-proxy", "foo");
1374 assert!(caps.native_tools);
1375 assert_eq!(caps.tool_search, vec!["hosted"]);
1376 assert!(caps.prefers_markdown_scaffolding);
1377 assert_eq!(caps.structured_output_mode, "native_json");
1378 assert!(caps.prefers_role_developer);
1379 assert_eq!(caps.thinking_block_style, "reasoning_summary");
1380 clear_user_overrides();
1381 }
1382
1383 #[test]
1384 fn version_min_requires_parseable_model() {
1385 reset();
1386 let toml_src = r#"
1387[[provider.custom]]
1388model_match = "*"
1389version_min = [5, 4]
1390native_tools = true
1391"#;
1392 set_user_overrides_toml(toml_src).unwrap();
1393 let caps = lookup("custom", "mystery-model");
1395 assert!(!caps.native_tools);
1396 clear_user_overrides();
1397 }
1398
1399 #[test]
1400 fn glob_match_substring() {
1401 assert!(glob_match("*gpt*", "openai/gpt-5.4"));
1402 assert!(glob_match("*claude*", "anthropic/claude-opus-4-7"));
1403 assert!(!glob_match("*xyz*", "openai/gpt-5.4"));
1404 }
1405
1406 #[test]
1407 fn openrouter_namespaced_anthropic_model() {
1408 reset();
1409 let caps = lookup("anthropic", "anthropic/claude-opus-4-7");
1410 assert!(caps.defer_loading);
1411 }
1412
1413 #[test]
1414 fn matrix_rows_include_provider_patterns_and_sources() {
1415 reset();
1416 let rows = matrix_rows();
1417 assert!(rows.iter().any(|row| {
1418 row.provider == "openai"
1419 && row.model == "gpt-4o*"
1420 && row.vision
1421 && row.audio
1422 && row.json_schema.as_deref() == Some("native")
1423 && row.source == "builtin"
1424 }));
1425 }
1426}