1use std::collections::{BTreeMap, HashSet};
10
11use serde::Deserialize;
12
13use super::model::{
14 fill_opt, Capabilities, CapabilitiesFile, ComputerUseStyle, ProviderDefaults,
15 ScreenshotScaling, WireDialect,
16};
17use crate::llm::providers::anthropic::claude_generation;
18use crate::llm::providers::openai_compat::gpt_generation;
19
20use harn_glob::match_name as glob_match;
24
25#[derive(Debug, Clone, Deserialize)]
27pub struct ProviderRule {
28 pub model_match: String,
31 #[serde(default)]
36 pub version_min: Option<Vec<u32>>,
37 #[serde(default)]
46 pub extends: bool,
47 #[serde(default)]
48 pub native_tools: Option<bool>,
49 #[serde(default)]
52 pub message_wire_format: Option<String>,
53 #[serde(default)]
56 pub native_tool_wire_format: Option<String>,
57 #[serde(default)]
58 pub defer_loading: Option<bool>,
59 #[serde(default)]
60 pub tool_search: Option<Vec<String>>,
61 #[serde(default)]
64 pub responses_api: Option<bool>,
65 #[serde(default)]
67 pub hosted_tools: Option<Vec<String>>,
68 #[serde(default)]
71 pub remote_mcp: Option<bool>,
72 #[serde(default)]
75 pub conversation_state: Option<bool>,
76 #[serde(default)]
78 pub compaction: Option<bool>,
79 #[serde(default)]
81 pub background_mode: Option<bool>,
82 #[serde(default)]
85 pub batch_api: Option<bool>,
86 #[serde(default)]
89 pub batch_wire_format: Option<String>,
90 #[serde(default)]
93 pub batch_input_mode: Option<String>,
94 #[serde(default)]
97 pub batch_discount_percent: Option<u32>,
98 #[serde(default)]
101 pub batch_turnaround_hours: Option<u32>,
102 #[serde(default)]
104 pub tool_approval_policy: Option<String>,
105 #[serde(default)]
106 pub max_tools: Option<u32>,
107 #[serde(default)]
108 pub prompt_caching: Option<bool>,
109 #[serde(default)]
113 pub cache_breakpoint_style: Option<String>,
114 #[serde(default)]
117 pub vision: Option<bool>,
118 #[serde(default, alias = "audio_supported")]
121 pub audio: Option<bool>,
122 #[serde(default, alias = "pdf_supported")]
125 pub pdf: Option<bool>,
126 #[serde(default, alias = "video_supported")]
129 pub video: Option<bool>,
130 #[serde(default)]
132 pub files_api_supported: Option<bool>,
133 #[serde(default)]
136 pub file_upload_wire_format: Option<String>,
137 #[serde(default)]
140 pub structured_output: Option<String>,
141 #[serde(default)]
144 pub json_schema: Option<String>,
145 #[serde(default)]
148 pub prefers_xml_scaffolding: Option<bool>,
149 #[serde(default)]
154 pub reserved_tool_call_token: Option<bool>,
155 #[serde(default)]
158 pub prefers_markdown_scaffolding: Option<bool>,
159 #[serde(default)]
163 pub structured_output_mode: Option<String>,
164 #[serde(default)]
166 pub supports_assistant_prefill: Option<bool>,
167 #[serde(default)]
170 pub prefers_role_developer: Option<bool>,
171 #[serde(default)]
174 pub prefers_xml_tools: Option<bool>,
175 #[serde(default)]
179 pub thinking_block_style: Option<String>,
180 #[serde(default)]
183 pub thinking_modes: Option<Vec<String>>,
184 #[serde(default)]
187 pub interleaved_thinking_supported: Option<bool>,
188 #[serde(default)]
190 pub anthropic_beta_features: Option<Vec<String>>,
191 #[serde(default)]
194 pub thinking: Option<bool>,
195 #[serde(default)]
197 pub vision_supported: Option<bool>,
198 #[serde(default)]
200 pub image_url_input_supported: Option<bool>,
201 #[serde(default)]
208 pub preserve_thinking: Option<bool>,
209 #[serde(default)]
213 pub server_parser: Option<String>,
214 #[serde(default)]
218 pub honors_chat_template_kwargs: Option<bool>,
219 #[serde(default)]
222 pub chat_template_options_field: Option<String>,
223 #[serde(default)]
226 pub requires_completion_tokens: Option<bool>,
227 #[serde(default)]
234 pub chat_completions_unsupported: Option<bool>,
235 #[serde(default)]
239 pub requires_streaming: Option<bool>,
240 #[serde(default)]
244 pub reasoning_effort_supported: Option<bool>,
245 #[serde(default)]
248 pub reasoning_effort_levels: Option<Vec<String>>,
249 #[serde(default)]
253 pub reasoning_none_supported: Option<bool>,
254 #[serde(default)]
262 pub max_thinking_budget: Option<i64>,
263 #[serde(default)]
266 pub reasoning_disable_supported: Option<bool>,
267 #[serde(default)]
280 pub reasoning_required_for_tools: Option<bool>,
281 #[serde(default)]
285 pub reasoning_text_promotable: Option<bool>,
286 #[serde(default)]
289 pub reasoning_wire_format: Option<String>,
290 #[serde(default)]
291 pub seed_supported: Option<bool>,
292 #[serde(default)]
293 pub top_k_supported: Option<bool>,
294 #[serde(default)]
295 pub temperature_supported: Option<bool>,
296 #[serde(default)]
297 pub top_p_supported: Option<bool>,
298 #[serde(default)]
299 pub frequency_penalty_supported: Option<bool>,
300 #[serde(default)]
301 pub presence_penalty_supported: Option<bool>,
302 #[serde(default)]
306 pub allowed_tool_choice_modes: Option<Vec<String>>,
307 #[serde(default)]
310 pub requires_tool_result_adjacency: Option<bool>,
311 #[serde(default)]
317 pub supports_parallel_tool_calls: Option<bool>,
318 #[serde(default)]
322 pub tools_exclude_response_format: Option<bool>,
323 #[serde(default)]
327 pub recommended_endpoint: Option<String>,
328 #[serde(default)]
331 pub text_tool_wire_format_supported: Option<bool>,
332 #[serde(default)]
337 pub preferred_tool_format: Option<String>,
338 #[serde(default)]
343 pub tool_mode_parity: Option<String>,
344 #[serde(default)]
346 pub tool_mode_parity_notes: Option<String>,
347 #[serde(default)]
354 pub thinking_disable_directive: Option<String>,
355 #[serde(default)]
368 pub auto_reasoning_overrides: Option<BTreeMap<String, String>>,
369 #[serde(default)]
380 pub provider_route_denylist: Option<Vec<String>>,
381 #[serde(default)]
399 pub openrouter_provider_order: Option<Vec<String>>,
400 #[serde(default)]
414 pub serving_precision: Option<String>,
415 #[serde(default)]
424 pub computer_use_style: Option<ComputerUseStyle>,
425 #[serde(default)]
429 pub screenshot_scaling: Option<ScreenshotScaling>,
430 #[serde(default)]
435 pub safety_ack_flow: Option<bool>,
436}
437
438impl ProviderRule {
439 fn fill_missing_from(&mut self, other: &ProviderRule) {
449 let ProviderRule {
450 model_match: _,
453 version_min: _,
454 extends: _,
455 native_tools,
456 message_wire_format,
457 native_tool_wire_format,
458 defer_loading,
459 tool_search,
460 responses_api,
461 hosted_tools,
462 remote_mcp,
463 conversation_state,
464 compaction,
465 background_mode,
466 batch_api,
467 batch_wire_format,
468 batch_input_mode,
469 batch_discount_percent,
470 batch_turnaround_hours,
471 tool_approval_policy,
472 max_tools,
473 prompt_caching,
474 cache_breakpoint_style,
475 vision,
476 audio,
477 pdf,
478 video,
479 files_api_supported,
480 file_upload_wire_format,
481 structured_output,
482 json_schema,
483 prefers_xml_scaffolding,
484 reserved_tool_call_token,
485 prefers_markdown_scaffolding,
486 structured_output_mode,
487 supports_assistant_prefill,
488 prefers_role_developer,
489 prefers_xml_tools,
490 thinking_block_style,
491 thinking_modes,
492 interleaved_thinking_supported,
493 anthropic_beta_features,
494 thinking,
495 vision_supported,
496 image_url_input_supported,
497 preserve_thinking,
498 server_parser,
499 honors_chat_template_kwargs,
500 chat_template_options_field,
501 requires_completion_tokens,
502 chat_completions_unsupported,
503 requires_streaming,
504 reasoning_effort_supported,
505 reasoning_effort_levels,
506 reasoning_none_supported,
507 max_thinking_budget,
508 reasoning_disable_supported,
509 reasoning_required_for_tools,
510 reasoning_text_promotable,
511 reasoning_wire_format,
512 seed_supported,
513 top_k_supported,
514 temperature_supported,
515 top_p_supported,
516 frequency_penalty_supported,
517 presence_penalty_supported,
518 allowed_tool_choice_modes,
519 requires_tool_result_adjacency,
520 supports_parallel_tool_calls,
521 tools_exclude_response_format,
522 recommended_endpoint,
523 text_tool_wire_format_supported,
524 preferred_tool_format,
525 tool_mode_parity,
526 tool_mode_parity_notes,
527 thinking_disable_directive,
528 auto_reasoning_overrides,
529 provider_route_denylist,
530 openrouter_provider_order,
531 serving_precision,
532 computer_use_style,
533 screenshot_scaling,
534 safety_ack_flow,
535 } = other;
536 fill_opt(&mut self.native_tools, native_tools);
537 fill_opt(&mut self.message_wire_format, message_wire_format);
538 fill_opt(&mut self.native_tool_wire_format, native_tool_wire_format);
539 fill_opt(&mut self.defer_loading, defer_loading);
540 fill_opt(&mut self.tool_search, tool_search);
541 fill_opt(&mut self.responses_api, responses_api);
542 fill_opt(&mut self.hosted_tools, hosted_tools);
543 fill_opt(&mut self.remote_mcp, remote_mcp);
544 fill_opt(&mut self.conversation_state, conversation_state);
545 fill_opt(&mut self.compaction, compaction);
546 fill_opt(&mut self.background_mode, background_mode);
547 fill_opt(&mut self.batch_api, batch_api);
548 fill_opt(&mut self.batch_wire_format, batch_wire_format);
549 fill_opt(&mut self.batch_input_mode, batch_input_mode);
550 fill_opt(&mut self.batch_discount_percent, batch_discount_percent);
551 fill_opt(&mut self.batch_turnaround_hours, batch_turnaround_hours);
552 fill_opt(&mut self.tool_approval_policy, tool_approval_policy);
553 fill_opt(&mut self.max_tools, max_tools);
554 fill_opt(&mut self.prompt_caching, prompt_caching);
555 fill_opt(&mut self.cache_breakpoint_style, cache_breakpoint_style);
556 fill_opt(&mut self.audio, audio);
557 fill_opt(&mut self.pdf, pdf);
558 fill_opt(&mut self.video, video);
559 fill_opt(&mut self.files_api_supported, files_api_supported);
560 fill_opt(&mut self.file_upload_wire_format, file_upload_wire_format);
561 fill_opt(&mut self.prefers_xml_scaffolding, prefers_xml_scaffolding);
562 fill_opt(&mut self.reserved_tool_call_token, reserved_tool_call_token);
563 fill_opt(
564 &mut self.prefers_markdown_scaffolding,
565 prefers_markdown_scaffolding,
566 );
567 fill_opt(&mut self.structured_output_mode, structured_output_mode);
568 fill_opt(
569 &mut self.supports_assistant_prefill,
570 supports_assistant_prefill,
571 );
572 fill_opt(&mut self.prefers_role_developer, prefers_role_developer);
573 fill_opt(&mut self.prefers_xml_tools, prefers_xml_tools);
574 fill_opt(&mut self.thinking_block_style, thinking_block_style);
575 fill_opt(
576 &mut self.interleaved_thinking_supported,
577 interleaved_thinking_supported,
578 );
579 fill_opt(&mut self.anthropic_beta_features, anthropic_beta_features);
580 fill_opt(
581 &mut self.image_url_input_supported,
582 image_url_input_supported,
583 );
584 fill_opt(&mut self.preserve_thinking, preserve_thinking);
585 fill_opt(&mut self.server_parser, server_parser);
586 fill_opt(
587 &mut self.honors_chat_template_kwargs,
588 honors_chat_template_kwargs,
589 );
590 fill_opt(
591 &mut self.chat_template_options_field,
592 chat_template_options_field,
593 );
594 fill_opt(
595 &mut self.requires_completion_tokens,
596 requires_completion_tokens,
597 );
598 fill_opt(
599 &mut self.chat_completions_unsupported,
600 chat_completions_unsupported,
601 );
602 fill_opt(&mut self.requires_streaming, requires_streaming);
603 fill_opt(
604 &mut self.reasoning_effort_supported,
605 reasoning_effort_supported,
606 );
607 fill_opt(&mut self.reasoning_effort_levels, reasoning_effort_levels);
608 fill_opt(&mut self.reasoning_none_supported, reasoning_none_supported);
609 fill_opt(&mut self.max_thinking_budget, max_thinking_budget);
610 fill_opt(
611 &mut self.reasoning_disable_supported,
612 reasoning_disable_supported,
613 );
614 fill_opt(
615 &mut self.reasoning_required_for_tools,
616 reasoning_required_for_tools,
617 );
618 fill_opt(
619 &mut self.reasoning_text_promotable,
620 reasoning_text_promotable,
621 );
622 fill_opt(&mut self.reasoning_wire_format, reasoning_wire_format);
623 fill_opt(&mut self.seed_supported, seed_supported);
624 fill_opt(&mut self.top_k_supported, top_k_supported);
625 fill_opt(&mut self.temperature_supported, temperature_supported);
626 fill_opt(&mut self.top_p_supported, top_p_supported);
627 fill_opt(
628 &mut self.frequency_penalty_supported,
629 frequency_penalty_supported,
630 );
631 fill_opt(
632 &mut self.presence_penalty_supported,
633 presence_penalty_supported,
634 );
635 fill_opt(
636 &mut self.allowed_tool_choice_modes,
637 allowed_tool_choice_modes,
638 );
639 fill_opt(
640 &mut self.requires_tool_result_adjacency,
641 requires_tool_result_adjacency,
642 );
643 fill_opt(
644 &mut self.supports_parallel_tool_calls,
645 supports_parallel_tool_calls,
646 );
647 fill_opt(
648 &mut self.tools_exclude_response_format,
649 tools_exclude_response_format,
650 );
651 fill_opt(&mut self.recommended_endpoint, recommended_endpoint);
652 fill_opt(
653 &mut self.text_tool_wire_format_supported,
654 text_tool_wire_format_supported,
655 );
656 fill_opt(&mut self.preferred_tool_format, preferred_tool_format);
657 fill_opt(&mut self.tool_mode_parity, tool_mode_parity);
658 fill_opt(&mut self.tool_mode_parity_notes, tool_mode_parity_notes);
659 fill_opt(
660 &mut self.thinking_disable_directive,
661 thinking_disable_directive,
662 );
663 fill_opt(&mut self.auto_reasoning_overrides, auto_reasoning_overrides);
664 fill_opt(&mut self.provider_route_denylist, provider_route_denylist);
665 fill_opt(
666 &mut self.openrouter_provider_order,
667 openrouter_provider_order,
668 );
669 fill_opt(&mut self.serving_precision, serving_precision);
670 fill_opt(&mut self.computer_use_style, computer_use_style);
671 fill_opt(&mut self.screenshot_scaling, screenshot_scaling);
672 fill_opt(&mut self.safety_ack_flow, safety_ack_flow);
673 if self.structured_output.is_none() && self.json_schema.is_none() {
679 self.structured_output.clone_from(structured_output);
680 self.json_schema.clone_from(json_schema);
681 }
682 if self.thinking_modes.is_none() && self.thinking.is_none() {
683 self.thinking_modes.clone_from(thinking_modes);
684 self.thinking.clone_from(thinking);
685 }
686 if self.vision.is_none() && self.vision_supported.is_none() {
687 self.vision.clone_from(vision);
688 self.vision_supported.clone_from(vision_supported);
689 }
690 }
691}
692
693pub(super) struct MatchedCapabilityRule {
694 pub(super) provider: String,
696 pub(super) rule: ProviderRule,
699 pub(super) matched_patterns: Vec<String>,
702}
703
704#[derive(Default)]
710struct RuleResolution {
711 provider: Option<String>,
713 merged: Option<ProviderRule>,
714 matched_patterns: Vec<String>,
716}
717
718impl RuleResolution {
719 fn absorb(&mut self, layer_provider: &str, rule: &ProviderRule) -> bool {
723 if self.provider.is_none() {
724 self.provider = Some(layer_provider.to_string());
725 }
726 self.matched_patterns.push(rule.model_match.clone());
727 match &mut self.merged {
728 None => self.merged = Some(rule.clone()),
729 Some(merged) => merged.fill_missing_from(rule),
730 }
731 !rule.extends
732 }
733
734 fn into_matched(self) -> Option<MatchedCapabilityRule> {
735 Some(MatchedCapabilityRule {
736 provider: self.provider?,
737 rule: self.merged.expect("merged is set whenever provider is set"),
738 matched_patterns: self.matched_patterns,
739 })
740 }
741}
742
743fn absorb_layer_matches(
748 user: Option<&CapabilitiesFile>,
749 builtin: &CapabilitiesFile,
750 layer_provider: &str,
751 model: &str,
752 resolution: &mut RuleResolution,
753) -> bool {
754 for file in user.into_iter().chain(std::iter::once(builtin)) {
755 if let Some(rules) = file.provider.get(layer_provider) {
756 for rule in rules {
757 if rule_matches(rule, model) && resolution.absorb(layer_provider, rule) {
758 return true;
759 }
760 }
761 }
762 }
763 false
764}
765
766fn resolve_rule_chain(
774 user: Option<&CapabilitiesFile>,
775 builtin: &CapabilitiesFile,
776 provider: &str,
777 model: &str,
778) -> (RuleResolution, ProviderDefaults) {
779 let mut resolution = RuleResolution::default();
780 let mut effective_defaults = ProviderDefaults::default();
781 let mut current = provider.to_string();
782 let mut visited = HashSet::new();
783 while visited.insert(current.clone()) {
784 let layer_defaults = merged_provider_defaults(user, builtin, ¤t);
785 if effective_defaults.has_any_field() {
786 effective_defaults.fill_missing_from(&layer_defaults);
787 } else {
788 effective_defaults.overlay(&layer_defaults);
789 }
790 if absorb_layer_matches(user, builtin, ¤t, model, &mut resolution) {
791 break;
792 }
793 let next = user
794 .and_then(|file| file.provider_family.get(¤t))
795 .or_else(|| builtin.provider_family.get(¤t))
796 .cloned();
797 match next {
798 Some(parent) => current = parent,
799 None => break,
800 }
801 }
802 (resolution, effective_defaults)
803}
804
805pub(super) fn first_matching_rule(
806 user: Option<&CapabilitiesFile>,
807 builtin: &CapabilitiesFile,
808 provider: &str,
809 model: &str,
810) -> Option<MatchedCapabilityRule> {
811 resolve_rule_chain(user, builtin, provider, model)
812 .0
813 .into_matched()
814}
815
816pub(super) fn rule_thinking_modes(rule: &ProviderRule) -> Vec<String> {
817 rule.thinking_modes.clone().unwrap_or_else(|| {
818 if rule.thinking.unwrap_or(false) {
819 vec!["enabled".to_string()]
820 } else {
821 Vec::new()
822 }
823 })
824}
825
826pub(super) fn rule_vision(rule: &ProviderRule) -> bool {
827 rule.vision.or(rule.vision_supported).unwrap_or(false)
828}
829
830pub(super) fn lookup_with(
831 provider: &str,
832 model: &str,
833 builtin: &CapabilitiesFile,
834 user: Option<&CapabilitiesFile>,
835) -> Capabilities {
836 if provider == "mock" {
847 for family in ["anthropic", "openai", "gemini"] {
848 let defaults = merged_provider_defaults(user, builtin, family);
849 let mut resolution = RuleResolution::default();
850 absorb_layer_matches(user, builtin, family, model, &mut resolution);
851 if let Some(rule) = resolution.merged.as_ref() {
852 let mut caps = rule_to_caps(rule, &defaults);
853 if family == "anthropic" {
854 caps.native_tool_wire_format = "openai".to_string();
855 }
856 return caps;
857 }
858 }
859 return Capabilities::default();
860 }
861
862 let (resolution, effective_defaults) = resolve_rule_chain(user, builtin, provider, model);
865 if let Some(rule) = resolution.merged.as_ref() {
866 return rule_to_caps(rule, &effective_defaults);
867 }
868 if effective_defaults.has_any_field() {
869 return defaults_to_caps(&effective_defaults);
870 }
871 Capabilities::default()
872}
873
874fn merged_provider_defaults(
875 user: Option<&CapabilitiesFile>,
876 builtin: &CapabilitiesFile,
877 provider: &str,
878) -> ProviderDefaults {
879 let mut defaults = builtin
880 .provider_defaults
881 .get(provider)
882 .cloned()
883 .unwrap_or_default();
884 if let Some(user_defaults) = user.and_then(|file| file.provider_defaults.get(provider)) {
885 defaults.overlay(user_defaults);
886 }
887 defaults
888}
889
890fn defaults_to_caps(defaults: &ProviderDefaults) -> Capabilities {
891 let empty = ProviderRule {
892 model_match: "*".to_string(),
893 version_min: None,
894 extends: false,
895 native_tools: None,
896 message_wire_format: None,
897 native_tool_wire_format: None,
898 defer_loading: None,
899 tool_search: None,
900 responses_api: None,
901 hosted_tools: None,
902 remote_mcp: None,
903 conversation_state: None,
904 compaction: None,
905 background_mode: None,
906 tool_approval_policy: None,
907 batch_api: None,
908 batch_wire_format: None,
909 batch_input_mode: None,
910 batch_discount_percent: None,
911 batch_turnaround_hours: None,
912 max_tools: None,
913 prompt_caching: None,
914 cache_breakpoint_style: None,
915 vision: None,
916 audio: None,
917 pdf: None,
918 video: None,
919 files_api_supported: None,
920 file_upload_wire_format: None,
921 structured_output: None,
922 prefers_xml_scaffolding: None,
923 reserved_tool_call_token: None,
924 prefers_markdown_scaffolding: None,
925 structured_output_mode: None,
926 supports_assistant_prefill: None,
927 prefers_role_developer: None,
928 prefers_xml_tools: None,
929 thinking_block_style: None,
930 json_schema: None,
931 thinking_modes: None,
932 interleaved_thinking_supported: None,
933 anthropic_beta_features: None,
934 thinking: None,
935 vision_supported: None,
936 image_url_input_supported: None,
937 preserve_thinking: None,
938 server_parser: None,
939 honors_chat_template_kwargs: None,
940 chat_template_options_field: None,
941 requires_completion_tokens: None,
942 chat_completions_unsupported: None,
943 requires_streaming: None,
944 reasoning_effort_supported: None,
945 reasoning_effort_levels: None,
946 reasoning_none_supported: None,
947 max_thinking_budget: None,
948 reasoning_disable_supported: None,
949 reasoning_required_for_tools: None,
950 reasoning_text_promotable: None,
951 reasoning_wire_format: None,
952 seed_supported: None,
953 top_k_supported: None,
954 temperature_supported: None,
955 top_p_supported: None,
956 frequency_penalty_supported: None,
957 presence_penalty_supported: None,
958 allowed_tool_choice_modes: None,
959 requires_tool_result_adjacency: None,
960 supports_parallel_tool_calls: None,
961 tools_exclude_response_format: None,
962 recommended_endpoint: None,
963 text_tool_wire_format_supported: None,
964 preferred_tool_format: None,
965 tool_mode_parity: None,
966 tool_mode_parity_notes: None,
967 thinking_disable_directive: None,
968 auto_reasoning_overrides: None,
969 provider_route_denylist: None,
970 openrouter_provider_order: None,
971 serving_precision: None,
972 computer_use_style: None,
973 screenshot_scaling: None,
974 safety_ack_flow: None,
975 };
976 let mut caps = rule_to_caps(&empty, defaults);
977 caps.preferred_tool_format = None;
978 caps.tool_mode_parity = None;
979 caps
980}
981
982fn rule_to_caps(rule: &ProviderRule, defaults: &ProviderDefaults) -> Capabilities {
983 let thinking_modes = rule_thinking_modes(rule);
984 let thinking_block_style = rule_thinking_block_style(rule);
985 let emits_inline_reasoning = thinking_block_style == "inline";
990 Capabilities {
991 native_tools: rule.native_tools.unwrap_or(false),
992 message_wire_format: WireDialect::from_message_wire_format(
993 &rule
994 .message_wire_format
995 .clone()
996 .or_else(|| defaults.message_wire_format.clone())
997 .unwrap_or_else(|| "openai".to_string()),
998 ),
999 native_tool_wire_format: rule
1000 .native_tool_wire_format
1001 .clone()
1002 .or_else(|| defaults.native_tool_wire_format.clone())
1003 .unwrap_or_else(|| "openai".to_string()),
1004 defer_loading: rule.defer_loading.unwrap_or(false),
1005 tool_search: rule.tool_search.clone().unwrap_or_default(),
1006 responses_api: rule.responses_api.unwrap_or(false),
1007 hosted_tools: rule.hosted_tools.clone().unwrap_or_default(),
1008 remote_mcp: rule.remote_mcp.unwrap_or(false),
1009 conversation_state: rule.conversation_state.unwrap_or(false),
1010 compaction: rule.compaction.unwrap_or(false),
1011 background_mode: rule.background_mode.unwrap_or(false),
1012 batch_api: rule.batch_api.or(defaults.batch_api).unwrap_or(false),
1013 batch_wire_format: rule
1014 .batch_wire_format
1015 .clone()
1016 .or_else(|| defaults.batch_wire_format.clone()),
1017 batch_input_mode: rule
1018 .batch_input_mode
1019 .clone()
1020 .or_else(|| defaults.batch_input_mode.clone()),
1021 batch_discount_percent: rule
1022 .batch_discount_percent
1023 .or(defaults.batch_discount_percent),
1024 batch_turnaround_hours: rule
1025 .batch_turnaround_hours
1026 .or(defaults.batch_turnaround_hours),
1027 tool_approval_policy: rule.tool_approval_policy.clone(),
1028 max_tools: rule.max_tools,
1029 prompt_caching: rule.prompt_caching.unwrap_or(false),
1030 cache_breakpoint_style: rule
1031 .cache_breakpoint_style
1032 .clone()
1033 .unwrap_or_else(|| "none".to_string()),
1034 vision: rule_vision(rule),
1035 audio: rule.audio.unwrap_or(false),
1036 pdf: rule.pdf.unwrap_or(false),
1037 video: rule.video.unwrap_or(false),
1038 files_api_supported: rule
1039 .files_api_supported
1040 .or(defaults.files_api_supported)
1041 .unwrap_or(false),
1042 file_upload_wire_format: rule
1043 .file_upload_wire_format
1044 .clone()
1045 .or_else(|| defaults.file_upload_wire_format.clone()),
1046 structured_output: rule_structured_output(rule),
1047 json_schema: rule_structured_output(rule),
1048 prefers_xml_scaffolding: rule.prefers_xml_scaffolding.unwrap_or(false),
1049 reserved_tool_call_token: rule.reserved_tool_call_token.unwrap_or(false),
1050 prefers_markdown_scaffolding: rule.prefers_markdown_scaffolding.unwrap_or(false),
1051 structured_output_mode: rule_structured_output_mode(rule),
1052 supports_assistant_prefill: rule.supports_assistant_prefill.unwrap_or(false),
1053 prefers_role_developer: rule.prefers_role_developer.unwrap_or(false),
1054 prefers_xml_tools: rule.prefers_xml_tools.unwrap_or(false),
1055 thinking_block_style,
1056 emits_inline_reasoning,
1057 thinking_modes,
1058 interleaved_thinking_supported: rule.interleaved_thinking_supported.unwrap_or(false),
1059 anthropic_beta_features: rule.anthropic_beta_features.clone().unwrap_or_default(),
1060 vision_supported: rule.vision_supported.unwrap_or(false),
1061 image_url_input_supported: rule
1062 .image_url_input_supported
1063 .or(defaults.image_url_input_supported)
1064 .unwrap_or(true),
1065 preserve_thinking: rule.preserve_thinking.unwrap_or(false),
1066 server_parser: rule
1067 .server_parser
1068 .clone()
1069 .unwrap_or_else(|| "none".to_string()),
1070 honors_chat_template_kwargs: rule.honors_chat_template_kwargs.unwrap_or(false),
1071 chat_template_options_field: rule.chat_template_options_field.clone(),
1072 requires_completion_tokens: rule.requires_completion_tokens.unwrap_or(false),
1073 chat_completions_unsupported: rule.chat_completions_unsupported.unwrap_or(false),
1074 requires_streaming: rule.requires_streaming.unwrap_or(false),
1075 reasoning_effort_supported: rule.reasoning_effort_supported.unwrap_or(false),
1076 reasoning_effort_levels: rule.reasoning_effort_levels.clone().unwrap_or_default(),
1077 reasoning_none_supported: rule.reasoning_none_supported.unwrap_or(false),
1078 max_thinking_budget: rule.max_thinking_budget,
1079 reasoning_disable_supported: rule.reasoning_disable_supported.unwrap_or(true),
1080 reasoning_required_for_tools: rule.reasoning_required_for_tools.unwrap_or(false),
1081 reasoning_text_promotable: rule.reasoning_text_promotable.unwrap_or(true),
1082 reasoning_wire_format: rule
1083 .reasoning_wire_format
1084 .clone()
1085 .or_else(|| defaults.reasoning_wire_format.clone()),
1086 seed_supported: rule
1087 .seed_supported
1088 .or(defaults.seed_supported)
1089 .unwrap_or(true),
1090 top_k_supported: rule
1091 .top_k_supported
1092 .or(defaults.top_k_supported)
1093 .unwrap_or(true),
1094 temperature_supported: rule
1095 .temperature_supported
1096 .or(defaults.temperature_supported)
1097 .unwrap_or(true),
1098 top_p_supported: rule
1099 .top_p_supported
1100 .or(defaults.top_p_supported)
1101 .unwrap_or(true),
1102 frequency_penalty_supported: rule
1103 .frequency_penalty_supported
1104 .or(defaults.frequency_penalty_supported)
1105 .unwrap_or(true),
1106 presence_penalty_supported: rule
1107 .presence_penalty_supported
1108 .or(defaults.presence_penalty_supported)
1109 .unwrap_or(true),
1110 allowed_tool_choice_modes: rule.allowed_tool_choice_modes.clone().unwrap_or_default(),
1111 requires_tool_result_adjacency: rule.requires_tool_result_adjacency.unwrap_or(false),
1112 supports_parallel_tool_calls: rule.supports_parallel_tool_calls.unwrap_or(true),
1113 tools_exclude_response_format: rule.tools_exclude_response_format.unwrap_or(false),
1114 recommended_endpoint: rule.recommended_endpoint.clone(),
1115 text_tool_wire_format_supported: rule.text_tool_wire_format_supported.unwrap_or(true),
1116 preferred_tool_format: Some(rule_preferred_tool_format(rule)),
1117 tool_mode_parity: Some(rule_tool_mode_parity(rule)),
1118 tool_mode_parity_notes: rule.tool_mode_parity_notes.clone(),
1119 thinking_disable_directive: rule.thinking_disable_directive.clone(),
1120 auto_reasoning_overrides: rule.auto_reasoning_overrides.clone().unwrap_or_default(),
1121 provider_route_denylist: rule.provider_route_denylist.clone().unwrap_or_default(),
1122 openrouter_provider_order: rule.openrouter_provider_order.clone().unwrap_or_default(),
1123 serving_precision: rule
1124 .serving_precision
1125 .clone()
1126 .unwrap_or_else(|| "unverified".to_string()),
1127 computer_use_style: rule.computer_use_style,
1128 screenshot_scaling: rule.screenshot_scaling,
1129 safety_ack_flow: rule.safety_ack_flow.unwrap_or(false),
1130 }
1131}
1132
1133pub(super) fn rule_preferred_tool_format(rule: &ProviderRule) -> String {
1134 rule.preferred_tool_format.clone().unwrap_or_else(|| {
1140 if rule.native_tools.unwrap_or(false) {
1141 "native".to_string()
1142 } else {
1143 "json".to_string()
1144 }
1145 })
1146}
1147
1148pub(super) fn rule_tool_mode_parity(rule: &ProviderRule) -> String {
1149 rule.tool_mode_parity.clone().unwrap_or_else(|| {
1150 match (
1151 rule.native_tools.unwrap_or(false),
1152 rule.text_tool_wire_format_supported.unwrap_or(true),
1153 ) {
1154 (true, true) => "unknown".to_string(),
1155 (true, false) => "native_only".to_string(),
1156 (false, true) => "text_only".to_string(),
1157 (false, false) => "unsupported".to_string(),
1158 }
1159 })
1160}
1161
1162pub(super) fn rule_structured_output(rule: &ProviderRule) -> Option<String> {
1163 rule.structured_output
1164 .clone()
1165 .or_else(|| rule.json_schema.clone())
1166 .filter(|value| value != "none")
1167}
1168
1169pub(super) fn rule_structured_output_mode(rule: &ProviderRule) -> String {
1170 if let Some(mode) = &rule.structured_output_mode {
1171 return mode.clone();
1172 }
1173 match rule_structured_output(rule).as_deref() {
1174 Some("native") | Some("format_kw") => "native_json".to_string(),
1175 Some("tool_use") => "xml_tagged".to_string(),
1176 _ => "none".to_string(),
1177 }
1178}
1179
1180pub(super) fn rule_thinking_block_style(rule: &ProviderRule) -> String {
1181 rule.thinking_block_style.clone().unwrap_or_else(|| {
1182 if rule.reasoning_effort_supported.unwrap_or(false)
1183 || rule.requires_completion_tokens.unwrap_or(false)
1184 {
1185 "reasoning_summary".to_string()
1186 } else {
1187 "none".to_string()
1188 }
1189 })
1190}
1191
1192pub(crate) fn rule_matches(rule: &ProviderRule, model: &str) -> bool {
1193 let lower = model.to_lowercase();
1194 if !glob_match(&rule.model_match.to_lowercase(), &lower) {
1195 return false;
1196 }
1197 if let Some(version_min) = &rule.version_min {
1198 if version_min.len() != 2 {
1199 return false;
1200 }
1201 let want = (version_min[0], version_min[1]);
1202 let have = match extract_version(model) {
1203 Some(v) => v,
1204 None => return false,
1208 };
1209 if have < want {
1210 return false;
1211 }
1212 }
1213 true
1214}
1215
1216fn extract_version(model: &str) -> Option<(u32, u32)> {
1221 claude_generation(model).or_else(|| gpt_generation(model))
1222}
1223
1224#[cfg(test)]
1225mod tests {
1226 use super::super::lookup::parse_capabilities_toml;
1227 use super::*;
1228
1229 #[test]
1230 fn glob_match_substring() {
1231 assert!(glob_match("*gpt*", "openai/gpt-5.4"));
1232 assert!(glob_match("*claude*", "anthropic/claude-opus-4-7"));
1233 assert!(!glob_match("*xyz*", "openai/gpt-5.4"));
1234 }
1235
1236 fn extends_caps(src: &str) -> Capabilities {
1240 let file = parse_capabilities_toml(src).expect("test capabilities toml parses");
1241 lookup_with("testprov", "test-model", &file, None)
1242 }
1243
1244 #[test]
1245 fn extends_rule_fills_unset_fields_from_later_matching_rule() {
1246 let caps = extends_caps(
1249 r#"
1250[[provider.testprov]]
1251model_match = "test-*"
1252extends = true
1253native_tools = true
1254
1255[[provider.testprov]]
1256model_match = "test-*"
1257vision = true
1258message_wire_format = "anthropic"
1259"#,
1260 );
1261 assert!(caps.native_tools, "field from the extends rule applies");
1262 assert!(
1263 caps.vision,
1264 "unset field filled from the later matching rule"
1265 );
1266 assert_eq!(caps.message_wire_format, WireDialect::Anthropic);
1267 }
1268
1269 #[test]
1270 fn non_extends_rule_terminates_resolution_unchanged() {
1271 let caps = extends_caps(
1275 r#"
1276[[provider.testprov]]
1277model_match = "test-*"
1278native_tools = true
1279
1280[[provider.testprov]]
1281model_match = "test-*"
1282vision = true
1283"#,
1284 );
1285 assert!(caps.native_tools);
1286 assert!(
1287 !caps.vision,
1288 "a non-extends first match must not absorb later rules"
1289 );
1290 }
1291
1292 #[test]
1293 fn extends_rule_does_not_override_explicitly_set_field() {
1294 let caps = extends_caps(
1298 r#"
1299[[provider.testprov]]
1300model_match = "test-*"
1301extends = true
1302native_tools = true
1303
1304[[provider.testprov]]
1305model_match = "test-*"
1306native_tools = false
1307vision = true
1308"#,
1309 );
1310 assert!(
1311 caps.native_tools,
1312 "the extends rule's explicit value is not overridden by a lower rule"
1313 );
1314 assert!(caps.vision, "still fills the field the chain left unset");
1315 }
1316
1317 #[test]
1318 fn extends_chain_falls_through_to_provider_defaults() {
1319 let caps = extends_caps(
1322 r#"
1323[provider_defaults.testprov]
1324seed_supported = true
1325
1326[[provider.testprov]]
1327model_match = "test-*"
1328extends = true
1329native_tools = true
1330"#,
1331 );
1332 assert!(caps.native_tools, "field from the extends rule applies");
1333 assert!(
1334 caps.seed_supported,
1335 "unset field filled from provider defaults"
1336 );
1337 }
1338}